mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-13 12:15:18 +03:00
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9375c2fccd | |||
| c83e47df0c | |||
| 715820e357 | |||
| 137a739af2 | |||
| 23220d1827 | |||
| 3c7ea272af | |||
| 34a92b6efc | |||
| 3a907cb76c | |||
| 90646e7193 | |||
| 3b2875ccd1 | |||
| a989d9fefa | |||
| fd3b6216c9 | |||
| 84c21c0013 | |||
| aca3339b16 | |||
| 6d6f9f4441 | |||
| fe98bdb42c | |||
| 7c8b20d8f3 | |||
| 6232493eed | |||
| 09997bd6a1 | |||
| 54c318908c | |||
| dc6f2e8506 | |||
| eff41a40f5 | |||
| b00163a71c | |||
| 9f60043375 | |||
| 004ecd7c64 | |||
| 581bb7e094 | |||
| 5fd10d897e | |||
| d7a83bab50 | |||
| 4aa70733d6 | |||
| 7063900dd4 | |||
| ff5298c0ae | |||
| 3c54368f03 | |||
| 905bbfd5ca | |||
| d84bc2c695 | |||
| 82ab9827eb | |||
| ff5dc4f20c | |||
| a99707666b | |||
| 91db55adc3 | |||
| ae8d4a27aa | |||
| cfc4673082 | |||
| 64a20f030a | |||
| c4536963f8 | |||
| 0b318156a4 | |||
| 30b3f36905 | |||
| 9b76ab90a7 | |||
| f3dfd3d9d8 | |||
| 95c6e6dce7 | |||
| 2fd7ad9334 | |||
| 97e8fd2223 | |||
| 119a39c4fe | |||
| f9d62ee84b | |||
| 939e9459ef | |||
| de76ce898e | |||
| 5bbe87500a | |||
| 61ea24bfdd | |||
| b5837bdca5 | |||
| b21a9cc35b | |||
| fe6fe54880 | |||
| 56748797eb | |||
| 9d504a34b0 | |||
| b59d7b9a73 | |||
| d3b13ebe26 | |||
| c2bfe4f2f3 | |||
| 178dc8822e | |||
| 2a966f178f | |||
| 4cb771a925 | |||
| 102dce2b75 | |||
| 27630b5657 | |||
| 8335af0f79 | |||
| e3ce405a41 | |||
| c5e001fda5 | |||
| eba97c8344 | |||
| 0413d133b5 |
@@ -27,28 +27,43 @@ A command-line app for downloading Apple Music songs, music videos and post vide
|
||||
- **Firefox**: [Export Cookies](https://addons.mozilla.org/addon/export-cookies-txt)
|
||||
- **Chromium**: [Get cookies.txt LOCALLY](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
|
||||
|
||||
### Optional
|
||||
### 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:
|
||||
|
||||
- **[FFmpeg](https://ffmpeg.org/download.html)** - Required for `ffmpeg` music video remux mode
|
||||
- **[mp4decrypt](https://www.bento4.com/downloads/)** - Required for `mp4box` music video remux mode
|
||||
- **[MP4Box](https://gpac.io/downloads/gpac-nightly-builds/)** - Required for `mp4box` music video remux mode
|
||||
- **[N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest)** - Required for `nm3u8dlre` download mode, which is faster than the default downloader
|
||||
- **[Wrapper](#️-wrapper)** - For downloading songs in ALAC and other experimental codecs without API limitations
|
||||
| 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
|
||||
|
||||
@@ -116,54 +131,57 @@ The file is created automatically on first run. Command-line arguments override
|
||||
| `--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 | `.` |
|
||||
| `--wvd-path` | .wvd file path | - |
|
||||
| `--overwrite` | Overwrite existing files | `false` |
|
||||
| `--save-cover`, `-s` | Save cover as separate file | `false` |
|
||||
| `--save-playlist` | Save M3U8 playlist file | `false` |
|
||||
| **Download Options** | | |
|
||||
| `--artist-auto-select` | Automatically select artist content to download (artist URLs) | - |
|
||||
| `--nm3u8dlre-path` | N_m3u8DL-RE executable path | `N_m3u8DL-RE` |
|
||||
| `--mp4decrypt-path` | mp4decrypt executable path | `mp4decrypt` |
|
||||
| `--ffmpeg-path` | FFmpeg executable path | `ffmpeg` |
|
||||
| `--mp4box-path` | MP4Box executable path | `MP4Box` |
|
||||
| `--use-wrapper` | Use wrapper | `false` |
|
||||
| `--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` |
|
||||
| `--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` |
|
||||
| `--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 | - |
|
||||
| `--cover-size` | Cover size in pixels | `1200` |
|
||||
| `--truncate` | Max filename length | - |
|
||||
| **Song Options** | | |
|
||||
| `--song-codec-priority` | Comma-separated codec priority | `aac-legacy` |
|
||||
| `--synced-lyrics-format` | Synced lyrics format | `lrc` |
|
||||
| `--no-synced-lyrics` | Don't download synced lyrics | `false` |
|
||||
| `--synced-lyrics-only` | Download only synced lyrics | `false` |
|
||||
| `--use-album-date` | Use album release date for songs | `false` |
|
||||
| `--fetch-extra-tags` | Fetch extra tags from preview (normalization and smooth playback) | `false` |
|
||||
| **Music Video Options** | | |
|
||||
| `--music-video-codec-priority` | Comma-separated codec priority | `h264,h265` |
|
||||
| `--music-video-remux-mode` | Remux mode | `ffmpeg` |
|
||||
| `--music-video-remux-format` | Music video remux format | `m4v` |
|
||||
| `--music-video-resolution` | Max music video resolution | `1080p` |
|
||||
| **Post Video Options** | | |
|
||||
| `--uploaded-video-quality` | Post video quality | `best` |
|
||||
| **File Output Options** | | |
|
||||
| `--overwrite` | Overwrite existing files | `false` |
|
||||
| `--save-cover`, `-s` | Save cover as separate file | `false` |
|
||||
| `--save-playlist` | Save M3U8 playlist file | `false` |
|
||||
|
||||
|
||||
### Template Variables
|
||||
@@ -194,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`
|
||||
@@ -282,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,
|
||||
@@ -291,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.9.3"
|
||||
__version__ = "3.3"
|
||||
|
||||
@@ -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
|
||||
+166
-158
@@ -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,141 +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,
|
||||
cover_format=config.cover_format,
|
||||
album_folder_template=config.album_folder_template,
|
||||
compilation_folder_template=config.compilation_folder_template,
|
||||
no_album_folder_template=config.no_album_folder_template,
|
||||
playlist_folder_template=config.playlist_folder_template,
|
||||
single_disc_file_template=config.single_disc_file_template,
|
||||
multi_disc_file_template=config.multi_disc_file_template,
|
||||
no_album_file_template=config.no_album_file_template,
|
||||
playlist_file_template=config.playlist_file_template,
|
||||
date_tag_template=config.date_tag_template,
|
||||
exclude_tags=config.exclude_tags,
|
||||
cover_size=config.cover_size,
|
||||
truncate=config.truncate,
|
||||
)
|
||||
|
||||
song_downloader = AppleMusicSongDownloader(
|
||||
base_downloader=base_downloader,
|
||||
interface=song_interface,
|
||||
codec_priority=config.song_codec_piority,
|
||||
synced_lyrics_format=config.synced_lyrics_format,
|
||||
no_synced_lyrics=config.no_synced_lyrics,
|
||||
synced_lyrics_only=config.synced_lyrics_only,
|
||||
use_album_date=config.use_album_date,
|
||||
fetch_extra_tags=config.fetch_extra_tags,
|
||||
base=base_downloader,
|
||||
)
|
||||
music_video_downloader = AppleMusicMusicVideoDownloader(
|
||||
base_downloader=base_downloader,
|
||||
interface=music_video_interface,
|
||||
codec_priority=config.music_video_codec_priority,
|
||||
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,
|
||||
artist_auto_select=config.artist_auto_select,
|
||||
song=song_downloader,
|
||||
music_video=music_video_downloader,
|
||||
uploaded_video=uploaded_video_downloader,
|
||||
overwrite=config.overwrite,
|
||||
save_cover=config.save_cover,
|
||||
save_playlist=config.save_playlist,
|
||||
no_synced_lyrics=config.no_synced_lyrics,
|
||||
synced_lyrics_only=config.synced_lyrics_only,
|
||||
)
|
||||
|
||||
if not config.synced_lyrics_only:
|
||||
if (
|
||||
config.download_mode == DownloadMode.NM3U8DLRE
|
||||
and not base_downloader.full_nm3u8dlre_path
|
||||
):
|
||||
logger.critical(X_NOT_IN_PATH.format("N_m3u8DL-RE", config.nm3u8dlre_path))
|
||||
return
|
||||
|
||||
missing_music_video_paths = []
|
||||
|
||||
if not base_downloader.full_ffmpeg_path and (
|
||||
config.music_video_remux_mode == RemuxMode.FFMPEG
|
||||
or config.download_mode == DownloadMode.NM3U8DLRE
|
||||
):
|
||||
missing_music_video_paths.append(
|
||||
X_NOT_IN_PATH.format("ffmpeg", config.ffmpeg_path)
|
||||
)
|
||||
|
||||
if (
|
||||
not base_downloader.full_mp4box_path
|
||||
and config.music_video_remux_mode == RemuxMode.MP4BOX
|
||||
):
|
||||
missing_music_video_paths.append(
|
||||
X_NOT_IN_PATH.format("MP4Box", config.mp4box_path)
|
||||
)
|
||||
|
||||
if not base_downloader.full_mp4decrypt_path and (
|
||||
config.song_codec_piority
|
||||
not in (SongCodec.AAC_LEGACY, SongCodec.AAC_HE_LEGACY)
|
||||
or config.music_video_remux_mode == RemuxMode.MP4BOX
|
||||
):
|
||||
missing_music_video_paths.append(
|
||||
X_NOT_IN_PATH.format("mp4decrypt", config.mp4decrypt_path)
|
||||
)
|
||||
|
||||
if missing_music_video_paths:
|
||||
logger.warning(
|
||||
"Music videos will not be downloaded due to missing dependencies:\n"
|
||||
+ "\n".join(missing_music_video_paths)
|
||||
)
|
||||
|
||||
if (
|
||||
any(not codec.is_legacy() for codec in config.song_codec_piority)
|
||||
and not config.use_wrapper
|
||||
):
|
||||
logger.warning(
|
||||
"You have chosen an experimental song codec "
|
||||
"without enabling wrapper. "
|
||||
"They're not guaranteed to work due to API limitations."
|
||||
)
|
||||
|
||||
if config.read_urls_as_txt:
|
||||
urls_from_file = []
|
||||
for url in config.urls:
|
||||
@@ -239,68 +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)")
|
||||
|
||||
+195
-163
@@ -11,14 +11,17 @@ from ..downloader import (
|
||||
AppleMusicBaseDownloader,
|
||||
AppleMusicDownloader,
|
||||
AppleMusicMusicVideoDownloader,
|
||||
AppleMusicSongDownloader,
|
||||
AppleMusicUploadedVideoDownloader,
|
||||
ArtistAutoSelect,
|
||||
DownloadMode,
|
||||
RemuxFormatMusicVideo,
|
||||
RemuxMode,
|
||||
)
|
||||
from ..interface import (
|
||||
AppleMusicBaseInterface,
|
||||
AppleMusicInterface,
|
||||
AppleMusicMusicVideoInterface,
|
||||
AppleMusicSongInterface,
|
||||
AppleMusicUploadedVideoInterface,
|
||||
ArtistMediaType,
|
||||
CoverFormat,
|
||||
MusicVideoCodec,
|
||||
MusicVideoResolution,
|
||||
@@ -30,13 +33,18 @@ 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__)
|
||||
|
||||
|
||||
@@ -105,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,
|
||||
@@ -135,17 +175,111 @@ class CliConfig:
|
||||
"--language",
|
||||
"-l",
|
||||
help="Metadata language",
|
||||
default=api_sig.parameters["language"].default,
|
||||
default=api_create_sig.parameters["language"].default,
|
||||
),
|
||||
]
|
||||
# Downloader specific options
|
||||
artist_auto_select: Annotated[
|
||||
ArtistAutoSelect | None,
|
||||
# Base Interface specific options
|
||||
cover_format: Annotated[
|
||||
CoverFormat,
|
||||
option(
|
||||
"--artist-auto-select",
|
||||
help="Automatically select artist content to download (only for artist URLs)",
|
||||
default=downloader_sig.parameters["artist_auto_select"].default,
|
||||
type=ArtistAutoSelect,
|
||||
"--cover-format",
|
||||
help="Cover format",
|
||||
default=base_interface_create_sig.parameters["cover_format"].default,
|
||||
type=CoverFormat,
|
||||
),
|
||||
]
|
||||
cover_size: Annotated[
|
||||
int,
|
||||
option(
|
||||
"--cover-size",
|
||||
help="Cover size in pixels",
|
||||
default=base_interface_create_sig.parameters["cover_size"].default,
|
||||
),
|
||||
]
|
||||
wvd_path: Annotated[
|
||||
str | None,
|
||||
option(
|
||||
"--wvd-path",
|
||||
help=".wvd file path",
|
||||
default=base_interface_create_sig.parameters["wvd_path"].default,
|
||||
type=click.Path(
|
||||
file_okay=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
|
||||
@@ -178,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(
|
||||
@@ -249,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(
|
||||
@@ -274,15 +361,6 @@ class CliConfig:
|
||||
type=DownloadMode,
|
||||
),
|
||||
]
|
||||
cover_format: Annotated[
|
||||
CoverFormat,
|
||||
option(
|
||||
"--cover-format",
|
||||
help="Cover format",
|
||||
default=base_downloader_sig.parameters["cover_format"].default,
|
||||
type=CoverFormat,
|
||||
),
|
||||
]
|
||||
album_folder_template: Annotated[
|
||||
str,
|
||||
option(
|
||||
@@ -309,6 +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(
|
||||
@@ -358,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(
|
||||
@@ -374,67 +452,7 @@ class CliConfig:
|
||||
default=base_downloader_sig.parameters["truncate"].default,
|
||||
),
|
||||
]
|
||||
# DownloaderSong specific options
|
||||
song_codec_piority: Annotated[
|
||||
list[SongCodec],
|
||||
option(
|
||||
"--song-codec-priority",
|
||||
help="Comma-separated codec priority",
|
||||
default=song_downloader_sig.parameters["codec_priority"].default,
|
||||
type=Csv(SongCodec),
|
||||
),
|
||||
]
|
||||
synced_lyrics_format: Annotated[
|
||||
SyncedLyricsFormat,
|
||||
option(
|
||||
"--synced-lyrics-format",
|
||||
help="Synced lyrics format",
|
||||
default=song_downloader_sig.parameters["synced_lyrics_format"].default,
|
||||
type=SyncedLyricsFormat,
|
||||
),
|
||||
]
|
||||
no_synced_lyrics: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--no-synced-lyrics",
|
||||
help="Don't download synced lyrics",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
synced_lyrics_only: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--synced-lyrics-only",
|
||||
help="Download only synced lyrics",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
use_album_date: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--use-album-date",
|
||||
help="Use album release date for songs",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
fetch_extra_tags: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--fetch-extra-tags",
|
||||
help="Fetch extra tags from preview (normalization and smooth playback)",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
# DownloaderMusicVideo specific options
|
||||
music_video_codec_priority: Annotated[
|
||||
list[MusicVideoCodec],
|
||||
option(
|
||||
"--music-video-codec-priority",
|
||||
help="Comma-separated codec priority",
|
||||
default=music_video_downloader_sig.parameters["codec_priority"].default,
|
||||
type=Csv(MusicVideoCodec),
|
||||
),
|
||||
]
|
||||
music_video_remux_mode: Annotated[
|
||||
RemuxMode,
|
||||
option(
|
||||
@@ -453,31 +471,45 @@ class CliConfig:
|
||||
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[
|
||||
# Downloader specific options
|
||||
overwrite: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--no-config-file",
|
||||
"-n",
|
||||
help="Don't use a config file",
|
||||
"--overwrite",
|
||||
help="Overwrite existing files",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
save_cover: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--save-cover",
|
||||
"-s",
|
||||
help="Save cover as separate file",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
save_playlist: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--save-playlist",
|
||||
help="Save M3U8 playlist file",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
no_synced_lyrics: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--no-synced-lyrics",
|
||||
help="Don't download synced lyrics",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
synced_lyrics_only: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--synced-lyrics-only",
|
||||
help="Download only synced lyrics",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -168,10 +168,17 @@ def extract_song(input_path: str) -> SongInfo:
|
||||
trex_defaults = (
|
||||
_extract_trex_defaults(song_info.moov_data, audio_track_id)
|
||||
if song_info.moov_data
|
||||
else {"default_sample_duration": 1024, "default_sample_size": 0}
|
||||
else None
|
||||
)
|
||||
default_sample_duration = trex_defaults["default_sample_duration"]
|
||||
default_sample_size = trex_defaults["default_sample_size"]
|
||||
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}"
|
||||
@@ -210,6 +217,18 @@ def extract_song(input_path: str) -> SongInfo:
|
||||
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
|
||||
|
||||
@@ -596,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:
|
||||
@@ -611,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")
|
||||
@@ -712,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)
|
||||
@@ -755,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)
|
||||
@@ -845,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.
|
||||
@@ -1188,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(
|
||||
@@ -1244,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)
|
||||
|
||||
@@ -1272,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)
|
||||
|
||||
@@ -1331,8 +1379,13 @@ def _extract_trex_defaults(moov_data: bytes, target_track_id: int = 0) -> dict:
|
||||
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": 1024,
|
||||
"default_sample_duration": fallback_duration,
|
||||
"default_sample_size": 0,
|
||||
"default_sample_description_index": 1,
|
||||
"default_sample_flags": 0,
|
||||
@@ -1361,12 +1414,17 @@ def _extract_trex_defaults(moov_data: bytes, target_track_id: int = 0) -> dict:
|
||||
defaults["default_sample_description_index"] = struct.unpack(
|
||||
">I", trex_data[16:20]
|
||||
)[0]
|
||||
defaults["default_sample_duration"] = struct.unpack(
|
||||
">I", trex_data[20:24]
|
||||
)[0]
|
||||
defaults["default_sample_size"] = struct.unpack(">I", trex_data[24:28])[
|
||||
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]
|
||||
@@ -1380,7 +1438,6 @@ def _extract_trex_defaults(moov_data: bytes, target_track_id: int = 0) -> dict:
|
||||
|
||||
return defaults
|
||||
|
||||
|
||||
def _extract_encryption_info(moov_data: bytes) -> Optional[EncryptionInfo]:
|
||||
"""Extract encryption scheme info from the audio track's sinf box.
|
||||
|
||||
|
||||
@@ -1,122 +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
|
||||
from .hardcoded_wvd import HARDCODED_WVD
|
||||
|
||||
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,
|
||||
cover_format: CoverFormat = CoverFormat.JPG,
|
||||
album_folder_template: str = "{album_artist}/{album}",
|
||||
compilation_folder_template: str = "Compilations/{album}",
|
||||
no_album_folder_template: str = "{artist}/Unknown Album",
|
||||
playlist_folder_template: str = "Playlists/{playlist_artist}",
|
||||
single_disc_file_template: str = "{track:02d} {title}",
|
||||
multi_disc_file_template: str = "{disc}-{track:02d} {title}",
|
||||
no_album_file_template: str = "{title}",
|
||||
playlist_file_template: str = "Playlists/{playlist_artist}/{playlist_title}",
|
||||
playlist_file_template: str = "{playlist_title}",
|
||||
date_tag_template: str = "%Y-%m-%dT%H:%M:%SZ",
|
||||
exclude_tags: list[str] = None,
|
||||
cover_size: int = 1200,
|
||||
truncate: int = None,
|
||||
silent: bool = False,
|
||||
):
|
||||
self.interface = interface
|
||||
self.output_path = output_path
|
||||
self.temp_path = temp_path
|
||||
self.wvd_path = wvd_path
|
||||
self.overwrite = overwrite
|
||||
self.save_cover = save_cover
|
||||
self.save_playlist = save_playlist
|
||||
self.nm3u8dlre_path = nm3u8dlre_path
|
||||
self.mp4decrypt_path = mp4decrypt_path
|
||||
self.ffmpeg_path = ffmpeg_path
|
||||
self.mp4box_path = mp4box_path
|
||||
self.use_wrapper = use_wrapper
|
||||
self.wrapper_decrypt_ip = wrapper_decrypt_ip
|
||||
self.download_mode = download_mode
|
||||
self.cover_format = cover_format
|
||||
self.album_folder_template = album_folder_template
|
||||
self.compilation_folder_template = compilation_folder_template
|
||||
self.no_album_folder_template = no_album_folder_template
|
||||
self.single_disc_file_template = single_disc_file_template
|
||||
self.multi_disc_file_template = multi_disc_file_template
|
||||
self.playlist_folder_template = playlist_folder_template
|
||||
self.no_album_file_template = no_album_file_template
|
||||
self.playlist_file_template = playlist_file_template
|
||||
self.date_tag_template = date_tag_template
|
||||
self.exclude_tags = exclude_tags
|
||||
self.cover_size = cover_size
|
||||
self.truncate = truncate
|
||||
self.silent = silent
|
||||
self.initialize()
|
||||
|
||||
def initialize(self):
|
||||
self._initialize_binary_paths()
|
||||
self._initialize_cdm()
|
||||
|
||||
def _initialize_binary_paths(self):
|
||||
log = logger.bind(action="initialize_binary_paths")
|
||||
|
||||
self.full_nm3u8dlre_path = shutil.which(self.nm3u8dlre_path)
|
||||
self.full_mp4decrypt_path = shutil.which(self.mp4decrypt_path)
|
||||
self.full_ffmpeg_path = shutil.which(self.ffmpeg_path)
|
||||
self.full_mp4box_path = shutil.which(self.mp4box_path)
|
||||
|
||||
def _initialize_cdm(self):
|
||||
if self.wvd_path:
|
||||
self.cdm = Cdm.from_device(Device.load(self.wvd_path))
|
||||
else:
|
||||
self.cdm = Cdm.from_device(Device.loads(HARDCODED_WVD))
|
||||
self.cdm.MAX_NUM_OF_SESSIONS = float("inf")
|
||||
|
||||
def get_random_uuid(self) -> str:
|
||||
return uuid.uuid4().hex[:8]
|
||||
|
||||
def is_media_streamable(
|
||||
self,
|
||||
media_metadata: dict,
|
||||
) -> bool:
|
||||
return bool(media_metadata["attributes"].get("playParams"))
|
||||
|
||||
def get_playlist_tags(
|
||||
self,
|
||||
playlist_metadata: dict,
|
||||
media_metadata: dict,
|
||||
) -> PlaylistTags:
|
||||
playlist_track = (
|
||||
playlist_metadata["relationships"]["tracks"]["data"].index(media_metadata)
|
||||
+ 1
|
||||
)
|
||||
|
||||
return PlaylistTags(
|
||||
playlist_artist=playlist_metadata["attributes"].get(
|
||||
"curatorName", "Unknown"
|
||||
),
|
||||
playlist_id=playlist_metadata["attributes"]["playParams"]["id"],
|
||||
playlist_title=playlist_metadata["attributes"]["name"],
|
||||
playlist_track=playlist_track,
|
||||
log = log.debug(
|
||||
"success",
|
||||
full_nm3u8dlre_path=self.full_nm3u8dlre_path,
|
||||
full_mp4decrypt_path=self.full_mp4decrypt_path,
|
||||
full_ffmpeg_path=self.full_ffmpeg_path,
|
||||
full_mp4box_path=self.full_mp4box_path,
|
||||
)
|
||||
|
||||
def get_temp_path(
|
||||
@@ -126,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,
|
||||
@@ -160,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("/")
|
||||
@@ -197,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=(
|
||||
@@ -205,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"),
|
||||
@@ -217,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,
|
||||
@@ -254,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)
|
||||
@@ -278,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(
|
||||
@@ -297,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()
|
||||
@@ -323,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()
|
||||
|
||||
@@ -347,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,46 +1,3 @@
|
||||
import re
|
||||
|
||||
TEMP_PATH_TEMPLATE = "gamdl_temp_{}"
|
||||
ILLEGAL_CHARS_RE = r'[\\/:*?"<>|;]'
|
||||
ILLEGAL_CHAR_REPLACEMENT = "_"
|
||||
|
||||
SONG_MEDIA_TYPE = {"song", "songs", "library-songs"}
|
||||
ALBUM_MEDIA_TYPE = {"album", "albums", "library-albums"}
|
||||
MUSIC_VIDEO_MEDIA_TYPE = {"music-video", "music-videos", "library-music-videos"}
|
||||
ARTIST_MEDIA_TYPE = {"artist", "artists", "library-artists"}
|
||||
UPLOADED_VIDEO_MEDIA_TYPE = {"post", "uploaded-videos"}
|
||||
PLAYLIST_MEDIA_TYPE = {"playlist", "playlists", "library-playlists"}
|
||||
|
||||
ARTIST_AUTO_SELECT_KEY_MAP = {
|
||||
"main-albums": ("views", "full-albums"),
|
||||
"compilation-albums": ("views", "compilation-albums"),
|
||||
"live-albums": ("views", "live-albums"),
|
||||
"singles-eps": ("views", "singles"),
|
||||
"all-albums": ("relationships", "albums"),
|
||||
"top-songs": ("views", "top-songs"),
|
||||
"music-videos": ("relationships", "music-videos"),
|
||||
}
|
||||
ARTIST_AUTO_SELECT_STR_MAP = {
|
||||
"main-albums": "Main Albums",
|
||||
"compilation-albums": "Compilation Albums",
|
||||
"live-albums": "Live Albums",
|
||||
"singles-eps": "Singles & EPs",
|
||||
"all-albums": "All Albums",
|
||||
"top-songs": "Top Songs",
|
||||
"music-videos": "Music Videos",
|
||||
}
|
||||
|
||||
VALID_URL_PATTERN = re.compile(
|
||||
r"https://(?:classical\.)?music\.apple\.com"
|
||||
r"(?:"
|
||||
r"/(?P<storefront>[a-z]{2})"
|
||||
r"/(?P<type>artist|album|playlist|song|music-video|post)"
|
||||
r"(?:/(?P<slug>[^\s/]+))?"
|
||||
r"/(?P<id>[0-9]+|pl\.[0-9a-z]{32}|pl\.u-[a-zA-Z0-9]+)"
|
||||
r"(?:\?i=(?P<sub_id>[0-9]+))?"
|
||||
r"|"
|
||||
r"(?:/(?P<library_storefront>[a-z]{2}))?"
|
||||
r"/library/(?P<library_type>playlist|albums)"
|
||||
r"/(?P<library_id>p\.[a-zA-Z0-9]+|l\.[a-zA-Z0-9]+)"
|
||||
r")"
|
||||
)
|
||||
|
||||
+213
-562
@@ -1,617 +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 ..api.exceptions import ApiError
|
||||
from ..interface import AppleMusicInterface
|
||||
from ..utils import safe_gather
|
||||
from .constants import (
|
||||
ALBUM_MEDIA_TYPE,
|
||||
ARTIST_MEDIA_TYPE,
|
||||
MUSIC_VIDEO_MEDIA_TYPE,
|
||||
PLAYLIST_MEDIA_TYPE,
|
||||
SONG_MEDIA_TYPE,
|
||||
UPLOADED_VIDEO_MEDIA_TYPE,
|
||||
VALID_URL_PATTERN,
|
||||
)
|
||||
from .downloader_base import AppleMusicBaseDownloader
|
||||
from .downloader_music_video import AppleMusicMusicVideoDownloader
|
||||
from .downloader_song import AppleMusicSongDownloader
|
||||
from .downloader_uploaded_video import AppleMusicUploadedVideoDownloader
|
||||
from .enums import ArtistAutoSelect, DownloadMode, RemuxMode
|
||||
from ..interface.types import AppleMusicMedia
|
||||
from .constants import TEMP_PATH_TEMPLATE
|
||||
from .enums import DownloadMode, 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,
|
||||
artist_auto_select: ArtistAutoSelect | None = None,
|
||||
skip_music_videos: bool = False,
|
||||
song: AppleMusicSongDownloader,
|
||||
music_video: AppleMusicMusicVideoDownloader,
|
||||
uploaded_video: AppleMusicUploadedVideoDownloader,
|
||||
overwrite: bool = False,
|
||||
save_cover: bool = False,
|
||||
save_playlist: bool = False,
|
||||
no_synced_lyrics: bool = False,
|
||||
synced_lyrics_only: bool = False,
|
||||
skip_cleanup: bool = False,
|
||||
skip_processing: bool = False,
|
||||
flat_filter: typing.Callable = None,
|
||||
):
|
||||
self.interface = interface
|
||||
self.base_downloader = base_downloader
|
||||
self.song_downloader = song_downloader
|
||||
self.music_video_downloader = music_video_downloader
|
||||
self.uploaded_video_downloader = uploaded_video_downloader
|
||||
self.artist_auto_select = artist_auto_select
|
||||
self.skip_music_videos = skip_music_videos
|
||||
self.song = song
|
||||
self.music_video = music_video
|
||||
self.uploaded_video = uploaded_video
|
||||
self.overwrite = overwrite
|
||||
self.save_cover = save_cover
|
||||
self.save_playlist = save_playlist
|
||||
self.no_synced_lyrics = no_synced_lyrics
|
||||
self.synced_lyrics_only = synced_lyrics_only
|
||||
self.skip_cleanup = skip_cleanup
|
||||
self.skip_processing = skip_processing
|
||||
self.flat_filter = flat_filter
|
||||
|
||||
async def get_single_download_item(
|
||||
self.base = song.base
|
||||
|
||||
async def get_download_item_from_url(
|
||||
self,
|
||||
media_metadata: dict,
|
||||
playlist_metadata: dict = None,
|
||||
url: str,
|
||||
) -> AsyncGenerator[DownloadItem, None]:
|
||||
async for media in self.base.interface.get_media_from_url(url):
|
||||
yield await self.parse_download_item(media)
|
||||
|
||||
async def parse_download_item(
|
||||
self,
|
||||
media: AppleMusicMedia,
|
||||
) -> DownloadItem:
|
||||
if self.flat_filter:
|
||||
flat_filter_result = self.flat_filter(media_metadata)
|
||||
if asyncio.iscoroutine(flat_filter_result):
|
||||
flat_filter_result = await flat_filter_result
|
||||
if media.error:
|
||||
return DownloadItem(media)
|
||||
|
||||
if flat_filter_result:
|
||||
return DownloadItem(
|
||||
media_metadata=media_metadata,
|
||||
playlist_metadata=playlist_metadata,
|
||||
flat_filter_result=flat_filter_result,
|
||||
)
|
||||
if media.partial:
|
||||
return DownloadItem(media)
|
||||
|
||||
return await self.get_single_download_item_no_filter(
|
||||
media_metadata,
|
||||
playlist_metadata,
|
||||
)
|
||||
elif media.media_metadata["type"] in {"songs", "library-songs"}:
|
||||
return await self.song.get_download_item(media)
|
||||
|
||||
async def get_single_download_item_no_filter(
|
||||
self,
|
||||
media_metadata: dict,
|
||||
playlist_metadata: dict = None,
|
||||
) -> DownloadItem:
|
||||
try:
|
||||
if not self.base_downloader.is_media_streamable(
|
||||
media_metadata,
|
||||
):
|
||||
raise NotStreamable(media_metadata["id"])
|
||||
|
||||
if media_metadata["type"] in SONG_MEDIA_TYPE:
|
||||
if not self.song_downloader:
|
||||
raise UnsupportedMediaType(media_metadata["type"])
|
||||
|
||||
download_item = await self.song_downloader.get_download_item(
|
||||
media_metadata,
|
||||
playlist_metadata,
|
||||
)
|
||||
|
||||
if media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE:
|
||||
if not self.music_video_downloader:
|
||||
raise UnsupportedMediaType(media_metadata["type"])
|
||||
|
||||
download_item = await self.music_video_downloader.get_download_item(
|
||||
media_metadata,
|
||||
playlist_metadata,
|
||||
)
|
||||
|
||||
if media_metadata["type"] in UPLOADED_VIDEO_MEDIA_TYPE:
|
||||
if not self.uploaded_video_downloader:
|
||||
raise UnsupportedMediaType(media_metadata["type"])
|
||||
|
||||
download_item = await self.uploaded_video_downloader.get_download_item(
|
||||
media_metadata,
|
||||
)
|
||||
except Exception as e:
|
||||
download_item = DownloadItem(
|
||||
media_metadata=media_metadata,
|
||||
playlist_metadata=playlist_metadata,
|
||||
error=e,
|
||||
)
|
||||
|
||||
return download_item
|
||||
|
||||
async def get_collection_download_items(
|
||||
self,
|
||||
collection_metadata: dict,
|
||||
) -> list[DownloadItem]:
|
||||
tracks_metadata = collection_metadata["relationships"]["tracks"]["data"]
|
||||
async for extended_data in self.interface.apple_music_api.extend_api_data(
|
||||
collection_metadata["relationships"]["tracks"],
|
||||
):
|
||||
tracks_metadata.extend(extended_data["data"])
|
||||
|
||||
tasks = [
|
||||
self.get_single_download_item(
|
||||
media_metadata,
|
||||
(
|
||||
collection_metadata
|
||||
if collection_metadata["type"] in PLAYLIST_MEDIA_TYPE
|
||||
else None
|
||||
),
|
||||
)
|
||||
for media_metadata in tracks_metadata
|
||||
]
|
||||
|
||||
download_items = await safe_gather(*tasks)
|
||||
return download_items
|
||||
|
||||
async def get_artist_download_items(
|
||||
self,
|
||||
artist_metadata: dict,
|
||||
) -> list[DownloadItem]:
|
||||
if not self.artist_auto_select:
|
||||
available_choices = []
|
||||
for artist_auto_select_option in list(ArtistAutoSelect):
|
||||
relation_key, type_key = artist_auto_select_option.path_key
|
||||
available_choices.append(
|
||||
Choice(
|
||||
name=str(artist_auto_select_option),
|
||||
value=(artist_auto_select_option,),
|
||||
),
|
||||
)
|
||||
|
||||
(artist_auto_select,) = await inquirer.select(
|
||||
message=f'Select which type to download for artist "{artist_metadata["attributes"]["name"]}":',
|
||||
choices=available_choices,
|
||||
validate=lambda result: artist_metadata.get(result[0].path_key[0], {})
|
||||
.get(result[0].path_key[1], {})
|
||||
.get("data"),
|
||||
).execute_async()
|
||||
else:
|
||||
artist_auto_select = self.artist_auto_select
|
||||
|
||||
relation_key, type_key = artist_auto_select.path_key
|
||||
async for extended_data in self.interface.apple_music_api.extend_api_data(
|
||||
artist_metadata[relation_key][type_key],
|
||||
):
|
||||
artist_metadata[relation_key][type_key]["data"].extend(extended_data["data"])
|
||||
|
||||
selected_items = artist_metadata[relation_key][type_key]["data"]
|
||||
select_all = self.artist_auto_select is not None
|
||||
|
||||
if artist_auto_select in {
|
||||
ArtistAutoSelect.MAIN_ALBUMS,
|
||||
ArtistAutoSelect.COMPILATION_ALBUMS,
|
||||
ArtistAutoSelect.LIVE_ALBUMS,
|
||||
ArtistAutoSelect.SINGLES_EPS,
|
||||
ArtistAutoSelect.ALL_ALBUMS,
|
||||
elif media.media_metadata["type"] in {
|
||||
"music-videos",
|
||||
"library-music-videos",
|
||||
}:
|
||||
return await self.get_artist_albums_download_items(
|
||||
selected_items,
|
||||
select_all,
|
||||
)
|
||||
elif artist_auto_select == ArtistAutoSelect.TOP_SONGS:
|
||||
return await self.get_artist_songs_download_items(
|
||||
selected_items,
|
||||
select_all,
|
||||
)
|
||||
elif artist_auto_select == ArtistAutoSelect.MUSIC_VIDEOS:
|
||||
return await self.get_artist_music_videos_download_items(
|
||||
selected_items,
|
||||
select_all,
|
||||
)
|
||||
return await self.music_video.get_download_item(media)
|
||||
|
||||
async def get_artist_albums_download_items(
|
||||
self,
|
||||
albums_metadata: list[dict],
|
||||
select_all: bool = False,
|
||||
) -> list[DownloadItem]:
|
||||
if not select_all:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
f'{album["attributes"]["trackCount"]:03d}',
|
||||
f'{album["attributes"]["releaseDate"]:<10}',
|
||||
f'{album["attributes"].get("contentRating", "None").title():<8}',
|
||||
f'{album["attributes"]["name"]}',
|
||||
]
|
||||
),
|
||||
value=album,
|
||||
)
|
||||
for album in albums_metadata
|
||||
if album.get("attributes")
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which albums to download: (Track Count | Release Date | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute_async()
|
||||
else:
|
||||
selected = albums_metadata
|
||||
elif media.media_metadata["type"] in {"uploaded-videos"}:
|
||||
return await self.uploaded_video.get_download_item(media)
|
||||
|
||||
download_items = []
|
||||
|
||||
album_tasks = [
|
||||
self.interface.apple_music_api.get_album(album_metadata["id"])
|
||||
for album_metadata in selected
|
||||
]
|
||||
album_responses = await safe_gather(*album_tasks)
|
||||
|
||||
track_tasks = [
|
||||
self.get_collection_download_items(album_response["data"][0])
|
||||
for album_response in album_responses
|
||||
]
|
||||
track_results = await safe_gather(*track_tasks)
|
||||
|
||||
for track_result in track_results:
|
||||
download_items.extend(track_result)
|
||||
|
||||
return download_items
|
||||
|
||||
async def get_artist_music_videos_download_items(
|
||||
self,
|
||||
music_videos_metadata: list[dict],
|
||||
select_all: bool = False,
|
||||
) -> list[DownloadItem]:
|
||||
if not select_all:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
self.millis_to_min_sec(
|
||||
music_video["attributes"]["durationInMillis"]
|
||||
),
|
||||
f'{music_video["attributes"].get("contentRating", "None").title():<8}',
|
||||
music_video["attributes"]["name"],
|
||||
],
|
||||
),
|
||||
value=music_video,
|
||||
)
|
||||
for music_video in music_videos_metadata
|
||||
if music_video.get("attributes")
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which music videos to download: (Duration | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute_async()
|
||||
else:
|
||||
selected = music_videos_metadata
|
||||
|
||||
music_video_tasks = [
|
||||
self.get_single_download_item(
|
||||
music_video_metadata,
|
||||
)
|
||||
for music_video_metadata in selected
|
||||
]
|
||||
download_items = await safe_gather(*music_video_tasks)
|
||||
|
||||
return download_items
|
||||
|
||||
async def get_artist_songs_download_items(
|
||||
self,
|
||||
songs_metadata: list[dict],
|
||||
select_all: bool = False,
|
||||
) -> list[DownloadItem]:
|
||||
if not select_all:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
self.millis_to_min_sec(
|
||||
song["attributes"]["durationInMillis"]
|
||||
),
|
||||
f'{song["attributes"].get("contentRating", "None").title():<8}',
|
||||
song["attributes"]["name"],
|
||||
],
|
||||
),
|
||||
value=song,
|
||||
)
|
||||
for song in songs_metadata
|
||||
if song.get("attributes")
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which songs to download: (Duration | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute_async()
|
||||
else:
|
||||
selected = songs_metadata
|
||||
|
||||
song_tasks = [
|
||||
self.get_single_download_item(
|
||||
song_metadata,
|
||||
)
|
||||
for song_metadata in selected
|
||||
]
|
||||
download_items = await safe_gather(*song_tasks)
|
||||
|
||||
return download_items
|
||||
|
||||
def millis_to_min_sec(self, millis) -> str:
|
||||
minutes, seconds = divmod(millis // 1000, 60)
|
||||
return f"{minutes:02}:{seconds:02}"
|
||||
|
||||
def get_url_info(self, url: str) -> UrlInfo | None:
|
||||
match = VALID_URL_PATTERN.match(url)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
return UrlInfo(
|
||||
**match.groupdict(),
|
||||
)
|
||||
|
||||
async def get_download_queue(
|
||||
self,
|
||||
url_info: UrlInfo,
|
||||
) -> list[DownloadItem] | None:
|
||||
return await self._get_download_queue(
|
||||
"song" if url_info.sub_id else url_info.type or url_info.library_type,
|
||||
url_info.sub_id or url_info.id or url_info.library_id,
|
||||
url_info.library_id is not None,
|
||||
)
|
||||
|
||||
async def _get_download_queue(
|
||||
self,
|
||||
url_type: str,
|
||||
id: str,
|
||||
is_library: bool,
|
||||
) -> list[DownloadItem] | None:
|
||||
download_items = []
|
||||
|
||||
if url_type in ARTIST_MEDIA_TYPE:
|
||||
try:
|
||||
artist_response = await self.interface.apple_music_api.get_artist(
|
||||
id,
|
||||
)
|
||||
except ApiError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise e
|
||||
|
||||
if artist_response is None:
|
||||
return None
|
||||
|
||||
download_items = await self.get_artist_download_items(
|
||||
artist_response["data"][0],
|
||||
)
|
||||
|
||||
if url_type in SONG_MEDIA_TYPE:
|
||||
try:
|
||||
song_respose = await self.interface.apple_music_api.get_song(id)
|
||||
except ApiError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise e
|
||||
|
||||
if song_respose is None:
|
||||
return None
|
||||
|
||||
download_items.append(
|
||||
await self.get_single_download_item(song_respose["data"][0])
|
||||
)
|
||||
|
||||
if url_type in ALBUM_MEDIA_TYPE:
|
||||
try:
|
||||
if is_library:
|
||||
album_response = (
|
||||
await self.interface.apple_music_api.get_library_album(id)
|
||||
)
|
||||
else:
|
||||
album_response = await self.interface.apple_music_api.get_album(id)
|
||||
except ApiError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise e
|
||||
|
||||
if album_response is None:
|
||||
return None
|
||||
|
||||
download_items = await self.get_collection_download_items(
|
||||
album_response["data"][0],
|
||||
)
|
||||
|
||||
if url_type in PLAYLIST_MEDIA_TYPE:
|
||||
try:
|
||||
if is_library:
|
||||
playlist_response = (
|
||||
await self.interface.apple_music_api.get_library_playlist(id)
|
||||
)
|
||||
else:
|
||||
playlist_response = (
|
||||
await self.interface.apple_music_api.get_playlist(id)
|
||||
)
|
||||
except ApiError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise e
|
||||
|
||||
if playlist_response is None:
|
||||
return None
|
||||
|
||||
download_items = await self.get_collection_download_items(
|
||||
playlist_response["data"][0],
|
||||
)
|
||||
|
||||
if url_type in MUSIC_VIDEO_MEDIA_TYPE:
|
||||
try:
|
||||
music_video_response = (
|
||||
await self.interface.apple_music_api.get_music_video(id)
|
||||
)
|
||||
except ApiError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise e
|
||||
|
||||
if music_video_response is None:
|
||||
return None
|
||||
|
||||
download_items.append(
|
||||
await self.get_single_download_item(music_video_response["data"][0])
|
||||
)
|
||||
|
||||
if url_type in UPLOADED_VIDEO_MEDIA_TYPE:
|
||||
try:
|
||||
uploaded_video = (
|
||||
await self.interface.apple_music_api.get_uploaded_video(id)
|
||||
)
|
||||
except ApiError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise e
|
||||
|
||||
if uploaded_video is None:
|
||||
return None
|
||||
|
||||
download_items.append(
|
||||
await self.get_single_download_item(uploaded_video["data"][0])
|
||||
)
|
||||
|
||||
return download_items
|
||||
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> DownloadItem:
|
||||
async def download(self, item: DownloadItem) -> None:
|
||||
try:
|
||||
if download_item.flat_filter_result:
|
||||
download_item = await self.get_single_download_item_no_filter(
|
||||
download_item.media_metadata,
|
||||
download_item.playlist_metadata,
|
||||
)
|
||||
if item.media.error:
|
||||
raise item.media.error
|
||||
|
||||
if download_item.error:
|
||||
raise download_item.error
|
||||
if item.media.partial:
|
||||
return
|
||||
|
||||
await self._initial_processing(download_item)
|
||||
await self._download(download_item)
|
||||
await self._final_processing(download_item)
|
||||
|
||||
return download_item
|
||||
await self._initial_processing(item)
|
||||
await self._download(item)
|
||||
await self._final_processing(item)
|
||||
finally:
|
||||
if isinstance(download_item, DownloadItem) and not self.skip_processing:
|
||||
self.base_downloader.cleanup_temp(download_item.random_uuid)
|
||||
self._cleanup_temp(item.uuid_)
|
||||
|
||||
async def _download(
|
||||
def _update_playlist_file(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
playlist_file_path: str,
|
||||
final_path: str,
|
||||
playlist_track: int,
|
||||
) -> None:
|
||||
if (
|
||||
self.song_downloader.synced_lyrics_only
|
||||
and download_item.media_metadata["type"] not in SONG_MEDIA_TYPE
|
||||
):
|
||||
raise SyncedLyricsOnly()
|
||||
log = logger.bind(
|
||||
action="update_playlist_file",
|
||||
playlist_file_path=playlist_file_path,
|
||||
final_path=final_path,
|
||||
playlist_track=playlist_track,
|
||||
)
|
||||
|
||||
if self.song_downloader.synced_lyrics_only:
|
||||
return
|
||||
playlist_file_path_obj = Path(playlist_file_path)
|
||||
final_path_obj = Path(final_path)
|
||||
output_dir_obj = Path(self.base.output_path)
|
||||
|
||||
if (
|
||||
Path(download_item.final_path).exists()
|
||||
and not self.base_downloader.overwrite
|
||||
):
|
||||
raise MediaFileExists(download_item.final_path)
|
||||
playlist_file_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
||||
playlist_file_path_parent_parts_len = len(playlist_file_path_obj.parent.parts)
|
||||
output_path_parts_len = len(output_dir_obj.parts)
|
||||
|
||||
if (
|
||||
self.base_downloader.download_mode == DownloadMode.NM3U8DLRE
|
||||
and not self.base_downloader.full_nm3u8dlre_path
|
||||
):
|
||||
raise ExecutableNotFound("N_m3u8DL-RE")
|
||||
|
||||
if download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE:
|
||||
if (
|
||||
self.music_video_downloader.remux_mode == RemuxMode.FFMPEG
|
||||
and not self.base_downloader.full_ffmpeg_path
|
||||
):
|
||||
raise ExecutableNotFound("ffmpeg")
|
||||
|
||||
if (
|
||||
self.music_video_downloader.remux_mode == RemuxMode.MP4BOX
|
||||
and not self.base_downloader.full_mp4box_path
|
||||
):
|
||||
raise ExecutableNotFound("MP4Box")
|
||||
|
||||
if (
|
||||
download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE
|
||||
or self.music_video_downloader.remux_mode == RemuxMode.MP4BOX
|
||||
) and not self.base_downloader.full_mp4decrypt_path:
|
||||
raise ExecutableNotFound("mp4decrypt")
|
||||
|
||||
if (
|
||||
not download_item.stream_info
|
||||
or not download_item.stream_info.audio_track
|
||||
or not download_item.stream_info.audio_track.stream_url
|
||||
or (
|
||||
(
|
||||
not download_item.decryption_key
|
||||
or not download_item.decryption_key.audio_track
|
||||
or not download_item.decryption_key.audio_track.key
|
||||
)
|
||||
and not self.base_downloader.use_wrapper
|
||||
final_path_relative = Path(
|
||||
("../" * (playlist_file_path_parent_parts_len - output_path_parts_len)),
|
||||
*final_path_obj.parts[output_path_parts_len:],
|
||||
)
|
||||
playlist_file_lines = (
|
||||
playlist_file_path_obj.open("r", encoding="utf8").readlines()
|
||||
if playlist_file_path_obj.exists()
|
||||
else []
|
||||
)
|
||||
if len(playlist_file_lines) < playlist_track:
|
||||
playlist_file_lines.extend(
|
||||
"\n" for _ in range(playlist_track - len(playlist_file_lines))
|
||||
)
|
||||
):
|
||||
raise FormatNotAvailable(download_item.media_metadata["id"])
|
||||
|
||||
if download_item.media_metadata["type"] in SONG_MEDIA_TYPE:
|
||||
await self.song_downloader.download(download_item)
|
||||
playlist_file_lines[playlist_track - 1] = final_path_relative.as_posix() + "\n"
|
||||
with playlist_file_path_obj.open("w", encoding="utf8") as playlist_file:
|
||||
playlist_file.writelines(playlist_file_lines)
|
||||
|
||||
if download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE:
|
||||
await self.music_video_downloader.download(download_item)
|
||||
log.debug("success")
|
||||
|
||||
if download_item.media_metadata["type"] in UPLOADED_VIDEO_MEDIA_TYPE:
|
||||
await self.uploaded_video_downloader.download(download_item)
|
||||
def _write_cover(self, cover_path: str, cover_bytes: bytes) -> None:
|
||||
log = logger.bind(action="write_cover_file", cover_path=cover_path)
|
||||
|
||||
async def _initial_processing(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
Path(cover_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(cover_path, "wb") as f:
|
||||
f.write(cover_bytes)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
def _write_synced_lyrics(self, synced_lyrics_path: str, lyrics: str) -> None:
|
||||
log = logger.bind(
|
||||
action="write_synced_lyrics",
|
||||
synced_lyrics_path=synced_lyrics_path,
|
||||
)
|
||||
|
||||
Path(synced_lyrics_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(synced_lyrics_path, "w", encoding="utf-8") as f:
|
||||
f.write(lyrics)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
async def _initial_processing(self, item: DownloadItem) -> None:
|
||||
if self.skip_processing:
|
||||
return
|
||||
|
||||
if download_item.cover_path and self.base_downloader.save_cover:
|
||||
cover_bytes = await self.interface.get_cover_bytes(download_item.cover_url)
|
||||
if cover_bytes and (
|
||||
self.base_downloader.overwrite
|
||||
or not Path(download_item.cover_path).exists()
|
||||
):
|
||||
self.base_downloader.write_cover_image(
|
||||
if item.playlist_file_path and item.final_path and self.save_playlist:
|
||||
self._update_playlist_file(
|
||||
item.playlist_file_path,
|
||||
item.final_path,
|
||||
item.media.playlist_tags.track,
|
||||
)
|
||||
|
||||
if item.cover_path and self.save_cover and item.media.cover.url:
|
||||
cover_bytes = await self.base.interface.base.get_cover_bytes(
|
||||
item.media.cover.url,
|
||||
)
|
||||
if cover_bytes and (self.overwrite or not Path(item.cover_path).exists()):
|
||||
self._write_cover(
|
||||
item.cover_path,
|
||||
cover_bytes,
|
||||
download_item.cover_path,
|
||||
)
|
||||
|
||||
if (
|
||||
download_item.lyrics
|
||||
and download_item.lyrics.synced
|
||||
and not self.song_downloader.no_synced_lyrics
|
||||
and (
|
||||
self.base_downloader.overwrite
|
||||
or not Path(download_item.synced_lyrics_path).exists()
|
||||
)
|
||||
item.synced_lyrics_path
|
||||
and not self.no_synced_lyrics
|
||||
and item.media.lyrics
|
||||
and item.media.lyrics.synced
|
||||
and (self.overwrite or not Path(item.synced_lyrics_path).exists())
|
||||
):
|
||||
self.song_downloader.write_synced_lyrics(
|
||||
download_item.lyrics.synced,
|
||||
download_item.synced_lyrics_path,
|
||||
self._write_synced_lyrics(
|
||||
item.synced_lyrics_path,
|
||||
item.media.lyrics.synced,
|
||||
)
|
||||
|
||||
if download_item.playlist_tags and self.base_downloader.save_playlist:
|
||||
self.base_downloader.update_playlist_file(
|
||||
download_item.playlist_file_path,
|
||||
download_item.final_path,
|
||||
download_item.playlist_tags.playlist_track,
|
||||
)
|
||||
async def _download(self, item: DownloadItem) -> None:
|
||||
if item.media.error:
|
||||
raise item.media.error
|
||||
|
||||
if self.synced_lyrics_only:
|
||||
raise GamdlDownloaderSyncedLyricsOnlyError()
|
||||
|
||||
if Path(item.final_path).exists() and not self.overwrite:
|
||||
raise GamdlDownloaderMediaFileExistsError(item.final_path)
|
||||
|
||||
if item.media.media_metadata["type"] in {
|
||||
"music-videos",
|
||||
"library-music-videos",
|
||||
"songs",
|
||||
"library-songs",
|
||||
}:
|
||||
if (
|
||||
self.base.download_mode == DownloadMode.NM3U8DLRE
|
||||
and not self.base.full_nm3u8dlre_path
|
||||
):
|
||||
raise GamdlDownloaderDependencyNotFoundError("N_m3u8DL-RE")
|
||||
|
||||
if item.media.media_metadata["type"] in {"songs", "library-songs"}:
|
||||
await self.song.download(item)
|
||||
|
||||
elif item.media.media_metadata["type"] in {
|
||||
"music-videos",
|
||||
"library-music-videos",
|
||||
}:
|
||||
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,285 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ..interface.enums import CoverFormat, MusicVideoCodec, MusicVideoResolution
|
||||
from ..interface.interface_music_video import AppleMusicMusicVideoInterface
|
||||
from ..interface.types import DecryptionKeyAv
|
||||
from ..utils import async_subprocess
|
||||
from .downloader_base import AppleMusicBaseDownloader
|
||||
from .enums import RemuxFormatMusicVideo, RemuxMode
|
||||
from .types import DownloadItem
|
||||
|
||||
|
||||
class AppleMusicMusicVideoDownloader(AppleMusicBaseDownloader):
|
||||
def __init__(
|
||||
self,
|
||||
base_downloader: AppleMusicBaseDownloader,
|
||||
interface: AppleMusicMusicVideoInterface,
|
||||
codec_priority: list[MusicVideoCodec] = [
|
||||
MusicVideoCodec.H264,
|
||||
MusicVideoCodec.H265,
|
||||
],
|
||||
remux_mode: RemuxMode = RemuxMode.FFMPEG,
|
||||
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
|
||||
resolution: MusicVideoResolution = MusicVideoResolution.R1080P,
|
||||
):
|
||||
self.__dict__.update(base_downloader.__dict__)
|
||||
self.interface = interface
|
||||
self.codec_priority = codec_priority
|
||||
self.remux_mode = remux_mode
|
||||
self.remux_format = remux_format
|
||||
self.resolution = resolution
|
||||
|
||||
async def remux_mp4box(
|
||||
self,
|
||||
input_path_video: str,
|
||||
input_path_audio: str,
|
||||
output_path: str,
|
||||
):
|
||||
await async_subprocess(
|
||||
self.full_mp4box_path,
|
||||
"-quiet",
|
||||
"-add",
|
||||
input_path_audio,
|
||||
"-add",
|
||||
input_path_video,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-keep-utc",
|
||||
"-new",
|
||||
output_path,
|
||||
silent=self.silent,
|
||||
)
|
||||
|
||||
async def remux_ffmpeg(
|
||||
self,
|
||||
input_path_video: str,
|
||||
input_path_audio: str,
|
||||
output_path: str,
|
||||
decryption_key: str = None,
|
||||
):
|
||||
if decryption_key:
|
||||
key = [
|
||||
"-decryption_key",
|
||||
decryption_key,
|
||||
]
|
||||
else:
|
||||
key = []
|
||||
|
||||
await async_subprocess(
|
||||
self.full_ffmpeg_path,
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
*key,
|
||||
"-i",
|
||||
input_path_video,
|
||||
"-i",
|
||||
input_path_audio,
|
||||
"-c",
|
||||
"copy",
|
||||
"-c:s",
|
||||
"mov_text",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
output_path,
|
||||
silent=self.silent,
|
||||
)
|
||||
|
||||
async def decrypt_mp4decrypt(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
decryption_key: str,
|
||||
):
|
||||
await async_subprocess(
|
||||
self.full_mp4decrypt_path,
|
||||
"--key",
|
||||
f"1:{decryption_key}",
|
||||
input_path,
|
||||
output_path,
|
||||
silent=self.silent,
|
||||
)
|
||||
|
||||
async def stage(
|
||||
self,
|
||||
encrypted_path_video: str,
|
||||
encrypted_path_audio: str,
|
||||
decrypted_path_video: str,
|
||||
decrypted_path_audio: str,
|
||||
staged_path: str,
|
||||
decryption_key: DecryptionKeyAv,
|
||||
):
|
||||
await self.decrypt_mp4decrypt(
|
||||
encrypted_path_video,
|
||||
decrypted_path_video,
|
||||
decryption_key.video_track.key,
|
||||
)
|
||||
await self.decrypt_mp4decrypt(
|
||||
encrypted_path_audio,
|
||||
decrypted_path_audio,
|
||||
decryption_key.audio_track.key,
|
||||
)
|
||||
|
||||
if self.remux_mode == RemuxMode.MP4BOX:
|
||||
await self.remux_mp4box(
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
staged_path,
|
||||
)
|
||||
else:
|
||||
await self.remux_ffmpeg(
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
staged_path,
|
||||
)
|
||||
|
||||
def get_cover_path(
|
||||
self,
|
||||
final_path: str,
|
||||
file_extension: str,
|
||||
) -> str:
|
||||
return str(Path(final_path).with_suffix(file_extension))
|
||||
|
||||
async def get_download_item(
|
||||
self,
|
||||
music_video_metadata: dict,
|
||||
playlist_metadata: dict = None,
|
||||
) -> DownloadItem:
|
||||
download_item = DownloadItem()
|
||||
|
||||
download_item.media_metadata = music_video_metadata
|
||||
download_item.playlist_metadata = playlist_metadata
|
||||
|
||||
music_video_id = self.interface.get_media_id_of_library_media(
|
||||
music_video_metadata,
|
||||
)
|
||||
|
||||
itunes_page_metadata = await self.interface.get_itunes_page_metadata(
|
||||
music_video_metadata,
|
||||
)
|
||||
download_item.media_tags = await self.interface.get_tags(
|
||||
music_video_metadata,
|
||||
itunes_page_metadata,
|
||||
)
|
||||
|
||||
if playlist_metadata:
|
||||
download_item.playlist_tags = self.get_playlist_tags(
|
||||
playlist_metadata,
|
||||
music_video_metadata,
|
||||
)
|
||||
download_item.playlist_file_path = self.get_playlist_file_path(
|
||||
download_item.playlist_tags,
|
||||
)
|
||||
|
||||
download_item.stream_info = await self.interface.get_stream_info(
|
||||
music_video_metadata,
|
||||
itunes_page_metadata,
|
||||
self.codec_priority,
|
||||
self.resolution,
|
||||
)
|
||||
|
||||
download_item.decryption_key = await self.interface.get_decryption_key(
|
||||
download_item.stream_info,
|
||||
self.cdm,
|
||||
)
|
||||
|
||||
download_item.random_uuid = self.get_random_uuid()
|
||||
download_item.staged_path = self.get_temp_path(
|
||||
music_video_id,
|
||||
download_item.random_uuid,
|
||||
"staged",
|
||||
(
|
||||
"."
|
||||
+ (
|
||||
"mp4"
|
||||
if self.remux_format == RemuxFormatMusicVideo.MP4
|
||||
else download_item.stream_info.file_format.value
|
||||
)
|
||||
),
|
||||
)
|
||||
download_item.final_path = self.get_final_path(
|
||||
download_item.media_tags,
|
||||
Path(download_item.staged_path).suffix,
|
||||
playlist_metadata,
|
||||
)
|
||||
|
||||
download_item.cover_url_template = self.interface.get_cover_url_template(
|
||||
music_video_metadata,
|
||||
self.cover_format,
|
||||
)
|
||||
download_item.cover_url = self.interface.get_cover_url(
|
||||
download_item.cover_url_template,
|
||||
self.cover_size,
|
||||
self.cover_format,
|
||||
)
|
||||
|
||||
cover_file_extension = await self.interface.get_cover_file_extension(
|
||||
download_item.cover_url,
|
||||
self.cover_format,
|
||||
)
|
||||
if cover_file_extension:
|
||||
download_item.cover_path = self.get_cover_path(
|
||||
download_item.final_path,
|
||||
cover_file_extension,
|
||||
)
|
||||
|
||||
return download_item
|
||||
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
encrypted_path_video = self.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"encrypted_video",
|
||||
".mp4",
|
||||
)
|
||||
encrypted_path_audio = self.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"encrypted_audio",
|
||||
".m4a",
|
||||
)
|
||||
|
||||
await self.download_stream(
|
||||
download_item.stream_info.video_track.stream_url,
|
||||
encrypted_path_video,
|
||||
)
|
||||
await self.download_stream(
|
||||
download_item.stream_info.audio_track.stream_url,
|
||||
encrypted_path_audio,
|
||||
)
|
||||
|
||||
decrypted_path_video = self.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"decrypted_video",
|
||||
".mp4",
|
||||
)
|
||||
decrypted_path_audio = self.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"decrypted_audio",
|
||||
".m4a",
|
||||
)
|
||||
|
||||
await self.stage(
|
||||
encrypted_path_video,
|
||||
encrypted_path_audio,
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
download_item.staged_path,
|
||||
download_item.decryption_key,
|
||||
)
|
||||
|
||||
cover_bytes = (
|
||||
await self.interface.get_cover_bytes(download_item.cover_url)
|
||||
if self.cover_format != CoverFormat.RAW
|
||||
else None
|
||||
)
|
||||
await self.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media_tags,
|
||||
cover_bytes,
|
||||
)
|
||||
@@ -1,248 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ..interface.enums import CoverFormat, SongCodec, SyncedLyricsFormat
|
||||
from ..interface.interface_song import AppleMusicSongInterface
|
||||
from ..interface.types import DecryptionKeyAv
|
||||
from .amdecrypt import decrypt_file, decrypt_file_hex
|
||||
from .downloader_base import AppleMusicBaseDownloader
|
||||
from .types import DownloadItem
|
||||
|
||||
|
||||
class AppleMusicSongDownloader(AppleMusicBaseDownloader):
|
||||
def __init__(
|
||||
self,
|
||||
base_downloader: AppleMusicBaseDownloader,
|
||||
interface: AppleMusicSongInterface,
|
||||
codec_priority: SongCodec = [SongCodec.AAC_LEGACY],
|
||||
synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC,
|
||||
no_synced_lyrics: bool = False,
|
||||
synced_lyrics_only: bool = False,
|
||||
use_album_date: bool = False,
|
||||
fetch_extra_tags: bool = False,
|
||||
):
|
||||
self.__dict__.update(base_downloader.__dict__)
|
||||
self.interface = interface
|
||||
self.codec_priority = codec_priority
|
||||
self.synced_lyrics_format = synced_lyrics_format
|
||||
self.no_synced_lyrics = no_synced_lyrics
|
||||
self.synced_lyrics_only = synced_lyrics_only
|
||||
self.use_album_date = use_album_date
|
||||
self.fetch_extra_tags = fetch_extra_tags
|
||||
|
||||
async def get_download_item(
|
||||
self,
|
||||
song_metadata: dict,
|
||||
playlist_metadata: dict = None,
|
||||
) -> DownloadItem:
|
||||
download_item = DownloadItem()
|
||||
|
||||
download_item.media_metadata = song_metadata
|
||||
download_item.playlist_metadata = playlist_metadata
|
||||
|
||||
song_id = self.interface.get_media_id_of_library_media(song_metadata)
|
||||
|
||||
download_item.lyrics = await self.interface.get_lyrics(
|
||||
song_metadata,
|
||||
self.synced_lyrics_format,
|
||||
)
|
||||
|
||||
webplayback = await self.interface.apple_music_api.get_webplayback(song_id)
|
||||
download_item.media_tags = await self.interface.get_tags(
|
||||
webplayback,
|
||||
download_item.lyrics.unsynced if download_item.lyrics else None,
|
||||
self.use_album_date,
|
||||
)
|
||||
if self.fetch_extra_tags:
|
||||
download_item.extra_tags = await self.interface.get_extra_tags(
|
||||
song_metadata,
|
||||
)
|
||||
|
||||
if playlist_metadata:
|
||||
download_item.playlist_tags = self.get_playlist_tags(
|
||||
playlist_metadata,
|
||||
song_metadata,
|
||||
)
|
||||
download_item.playlist_file_path = self.get_playlist_file_path(
|
||||
download_item.playlist_tags,
|
||||
)
|
||||
|
||||
download_item.final_path = self.get_final_path(
|
||||
download_item.media_tags,
|
||||
".m4a",
|
||||
download_item.playlist_tags,
|
||||
)
|
||||
download_item.synced_lyrics_path = self.get_lyrics_synced_path(
|
||||
download_item.final_path,
|
||||
)
|
||||
|
||||
if self.synced_lyrics_only:
|
||||
return download_item
|
||||
|
||||
for codec in self.codec_priority:
|
||||
download_item.stream_info = await self.interface.get_stream_info(
|
||||
codec,
|
||||
song_metadata,
|
||||
webplayback,
|
||||
)
|
||||
if download_item.stream_info:
|
||||
break
|
||||
|
||||
if download_item.stream_info.audio_track.legacy:
|
||||
download_item.decryption_key = (
|
||||
await self.interface.get_decryption_key_legacy(
|
||||
download_item.stream_info,
|
||||
self.cdm,
|
||||
)
|
||||
)
|
||||
elif (
|
||||
not self.use_wrapper
|
||||
and download_item.stream_info
|
||||
and download_item.stream_info.audio_track.widevine_pssh
|
||||
):
|
||||
download_item.decryption_key = await self.interface.get_decryption_key(
|
||||
download_item.stream_info,
|
||||
self.cdm,
|
||||
)
|
||||
|
||||
download_item.cover_url_template = self.interface.get_cover_url_template(
|
||||
song_metadata,
|
||||
self.cover_format,
|
||||
)
|
||||
download_item.cover_url = self.interface.get_cover_url(
|
||||
download_item.cover_url_template,
|
||||
self.cover_size,
|
||||
self.cover_format,
|
||||
)
|
||||
|
||||
download_item.random_uuid = self.get_random_uuid()
|
||||
if download_item.stream_info and download_item.stream_info.file_format:
|
||||
download_item.staged_path = self.get_temp_path(
|
||||
song_id,
|
||||
download_item.random_uuid,
|
||||
"staged",
|
||||
"." + download_item.stream_info.file_format.value,
|
||||
)
|
||||
else:
|
||||
download_item.staged_path = None
|
||||
|
||||
cover_file_extension = await self.interface.get_cover_file_extension(
|
||||
download_item.cover_url,
|
||||
self.cover_format,
|
||||
)
|
||||
if cover_file_extension:
|
||||
download_item.cover_path = self.get_cover_path(
|
||||
download_item.final_path,
|
||||
cover_file_extension,
|
||||
)
|
||||
|
||||
return download_item
|
||||
|
||||
async def decrypt_amdecrypt(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
media_id: str,
|
||||
fairplay_key: str,
|
||||
) -> None:
|
||||
await decrypt_file(
|
||||
self.wrapper_decrypt_ip,
|
||||
media_id,
|
||||
fairplay_key,
|
||||
input_path,
|
||||
output_path,
|
||||
)
|
||||
|
||||
async def decrypt_amdecrypt_hex(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
decryption_key: str,
|
||||
legacy: bool = False,
|
||||
) -> None:
|
||||
await decrypt_file_hex(
|
||||
input_path,
|
||||
output_path,
|
||||
decryption_key,
|
||||
legacy=legacy,
|
||||
)
|
||||
|
||||
async def stage(
|
||||
self,
|
||||
encrypted_path: str,
|
||||
staged_path: str,
|
||||
decryption_key: DecryptionKeyAv,
|
||||
legacy: bool,
|
||||
media_id: str,
|
||||
fairplay_key: str,
|
||||
):
|
||||
if self.use_wrapper and not legacy:
|
||||
await self.decrypt_amdecrypt(
|
||||
encrypted_path,
|
||||
staged_path,
|
||||
media_id,
|
||||
fairplay_key,
|
||||
)
|
||||
else:
|
||||
await self.decrypt_amdecrypt_hex(
|
||||
encrypted_path,
|
||||
staged_path,
|
||||
decryption_key.audio_track.key,
|
||||
legacy,
|
||||
)
|
||||
|
||||
def get_lyrics_synced_path(self, final_path: str) -> str:
|
||||
return str(Path(final_path).with_suffix("." + self.synced_lyrics_format.value))
|
||||
|
||||
def get_cover_path(
|
||||
self,
|
||||
final_path: str,
|
||||
file_extension: str,
|
||||
) -> str:
|
||||
return str(Path(final_path).parent / ("Cover" + file_extension))
|
||||
|
||||
def write_synced_lyrics(
|
||||
self,
|
||||
synced_lyrics: str,
|
||||
lyrics_synced_path: str,
|
||||
):
|
||||
Path(lyrics_synced_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(lyrics_synced_path).write_text(synced_lyrics, encoding="utf8")
|
||||
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
if self.synced_lyrics_only:
|
||||
return
|
||||
|
||||
encrypted_path = self.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"encrypted",
|
||||
".m4a",
|
||||
)
|
||||
await self.download_stream(
|
||||
download_item.stream_info.audio_track.stream_url,
|
||||
encrypted_path,
|
||||
)
|
||||
|
||||
await self.stage(
|
||||
encrypted_path,
|
||||
download_item.staged_path,
|
||||
download_item.decryption_key,
|
||||
download_item.stream_info.audio_track.legacy,
|
||||
download_item.media_metadata["id"],
|
||||
download_item.stream_info.audio_track.fairplay_key,
|
||||
)
|
||||
|
||||
cover_bytes = (
|
||||
await self.interface.get_cover_bytes(download_item.cover_url)
|
||||
if self.cover_format != CoverFormat.RAW
|
||||
else None
|
||||
)
|
||||
await self.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media_tags,
|
||||
cover_bytes,
|
||||
download_item.extra_tags,
|
||||
)
|
||||
@@ -1,107 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ..interface.enums import CoverFormat, UploadedVideoQuality
|
||||
from ..interface.interface_uploaded_video import AppleMusicUploadedVideoInterface
|
||||
from .downloader_base import AppleMusicBaseDownloader
|
||||
from .types import DownloadItem
|
||||
|
||||
|
||||
class AppleMusicUploadedVideoDownloader(AppleMusicBaseDownloader):
|
||||
def __init__(
|
||||
self,
|
||||
base_downloader: AppleMusicBaseDownloader,
|
||||
interface: AppleMusicUploadedVideoInterface,
|
||||
quality: UploadedVideoQuality = UploadedVideoQuality.BEST,
|
||||
):
|
||||
self.__dict__.update(base_downloader.__dict__)
|
||||
self.interface = interface
|
||||
self.quality = quality
|
||||
|
||||
def get_cover_path(self, final_path: str, file_extension: str) -> str:
|
||||
return str(Path(final_path).with_suffix(file_extension))
|
||||
|
||||
async def get_download_item(
|
||||
self,
|
||||
uploaded_video_metadata: dict,
|
||||
) -> DownloadItem:
|
||||
try:
|
||||
return await self._get_download_item(
|
||||
uploaded_video_metadata,
|
||||
)
|
||||
except Exception as e:
|
||||
return DownloadItem(
|
||||
media_metadata=uploaded_video_metadata,
|
||||
error=e,
|
||||
)
|
||||
|
||||
async def _get_download_item(
|
||||
self,
|
||||
uploaded_video_metadata: dict,
|
||||
) -> DownloadItem:
|
||||
download_item = DownloadItem()
|
||||
|
||||
download_item.media_metadata = uploaded_video_metadata
|
||||
|
||||
download_item.media_tags = self.interface.get_tags(
|
||||
uploaded_video_metadata,
|
||||
)
|
||||
|
||||
download_item.stream_info = await self.interface.get_stream_info(
|
||||
uploaded_video_metadata,
|
||||
self.quality,
|
||||
)
|
||||
|
||||
download_item.random_uuid = self.get_random_uuid()
|
||||
download_item.staged_path = self.get_temp_path(
|
||||
uploaded_video_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"staged",
|
||||
"." + download_item.stream_info.file_format.value,
|
||||
)
|
||||
download_item.final_path = self.get_final_path(
|
||||
download_item.media_tags,
|
||||
Path(download_item.staged_path).suffix,
|
||||
None,
|
||||
)
|
||||
|
||||
download_item.cover_url_template = self.interface.get_cover_url_template(
|
||||
uploaded_video_metadata,
|
||||
self.cover_format,
|
||||
)
|
||||
download_item.cover_url = self.interface.get_cover_url(
|
||||
download_item.cover_url_template,
|
||||
self.cover_size,
|
||||
self.cover_format,
|
||||
)
|
||||
|
||||
cover_file_extension = await self.interface.get_cover_file_extension(
|
||||
download_item.cover_url,
|
||||
self.cover_format,
|
||||
)
|
||||
if cover_file_extension:
|
||||
download_item.cover_path = self.get_cover_path(
|
||||
download_item.final_path,
|
||||
cover_file_extension,
|
||||
)
|
||||
|
||||
return download_item
|
||||
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
await self.download_ytdlp(
|
||||
download_item.stream_info.video_track.stream_url,
|
||||
download_item.staged_path,
|
||||
)
|
||||
|
||||
cover_bytes = (
|
||||
await self.interface.get_cover_bytes(download_item.cover_url)
|
||||
if self.cover_format != CoverFormat.RAW
|
||||
else None
|
||||
)
|
||||
await self.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media_tags,
|
||||
cover_bytes,
|
||||
)
|
||||
@@ -1,10 +1,5 @@
|
||||
from enum import Enum
|
||||
|
||||
from .constants import (
|
||||
ARTIST_AUTO_SELECT_KEY_MAP,
|
||||
ARTIST_AUTO_SELECT_STR_MAP,
|
||||
)
|
||||
|
||||
|
||||
class DownloadMode(Enum):
|
||||
YTDLP = "ytdlp"
|
||||
@@ -19,20 +14,3 @@ class RemuxMode(Enum):
|
||||
class RemuxFormatMusicVideo(Enum):
|
||||
M4V = "m4v"
|
||||
MP4 = "mp4"
|
||||
|
||||
|
||||
class ArtistAutoSelect(Enum):
|
||||
MAIN_ALBUMS = "main-albums"
|
||||
COMPILATION_ALBUMS = "compilation-albums"
|
||||
LIVE_ALBUMS = "live-albums"
|
||||
SINGLES_EPS = "singles-eps"
|
||||
ALL_ALBUMS = "all-albums"
|
||||
TOP_SONGS = "top-songs"
|
||||
MUSIC_VIDEOS = "music-videos"
|
||||
|
||||
@property
|
||||
def path_key(self) -> tuple[str, str]:
|
||||
return ARTIST_AUTO_SELECT_KEY_MAP[self.value]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return ARTIST_AUTO_SELECT_STR_MAP[self.value]
|
||||
|
||||
@@ -1,31 +1,20 @@
|
||||
from ..utils import GamdlError
|
||||
|
||||
|
||||
class MediaFileExists(GamdlError):
|
||||
def __init__(self, media_path: str):
|
||||
super().__init__(f"Media file already exists at path: {media_path}")
|
||||
class GamdlDownloaderError(GamdlError):
|
||||
pass
|
||||
|
||||
|
||||
class NotStreamable(GamdlError):
|
||||
def __init__(self, media_id: str):
|
||||
super().__init__(f"Media ID is not streamable: {media_id}")
|
||||
class GamdlDownloaderSyncedLyricsOnlyError(GamdlDownloaderError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Download mode is set to synced lyrics only")
|
||||
|
||||
|
||||
class FormatNotAvailable(GamdlError):
|
||||
def __init__(self, media_id: str):
|
||||
super().__init__(f"Requested format is not available for media ID: {media_id}")
|
||||
class GamdlDownloaderMediaFileExistsError(GamdlDownloaderError):
|
||||
def __init__(self, file_path: str) -> None:
|
||||
super().__init__(f"Media file already exists: {file_path}")
|
||||
|
||||
|
||||
class ExecutableNotFound(GamdlError):
|
||||
def __init__(self, executable: str):
|
||||
super().__init__(f"Executable not found: {executable}")
|
||||
|
||||
|
||||
class SyncedLyricsOnly(GamdlError):
|
||||
def __init__(self):
|
||||
super().__init__("Only downloading synced lyrics is supported")
|
||||
|
||||
|
||||
class UnsupportedMediaType(GamdlError):
|
||||
def __init__(self, media_type: str):
|
||||
super().__init__(f"Unsupported media type: {media_type}")
|
||||
class GamdlDownloaderDependencyNotFoundError(GamdlDownloaderError):
|
||||
def __init__(self, dependency_name: str) -> None:
|
||||
super().__init__(f"Required dependency not found: {dependency_name}")
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Dumped from Android Studio Virtual Device running Android 9
|
||||
|
||||
HARDCODED_WVD = """V1ZEAgIDAASoMIIEpAIBAAKCAQEAwnCFAPXy4U1J7p1NohAS+xl040f5FBaE/59bPp301bGz0UGFT9VoEtY3vaeakKh/d319xTNvCSWsEDRaMmp/wSnMiEZUkkl04872jx2uHuR4k6KYuuJoqhsIo1TwUBueFZynHBUJzXQeW8Eb1tYAROGwp8W7r+b0RIjHC89RFnfVXpYlF5I6McktyzJNSOwlQbMqlVihfSUkv3WRd3HFmA0Oxay51CEIkoTlNTHVlzVyhov5eHCDSp7QENRgaaQ03jC/CcgFOoQymhsBtRCM0CQmfuAHjA9e77R6m/GJPy75G9fqoZM1RMzVDHKbKZPd3sFd0c0+77gLzW8cWEaaHwIDAQABAoIBAQCB2pN46MikHvHZIcTPDt0eRQoDH/YArGl2Lf7J+sOgU2U7wv49KtCug9IGHwDiyyUVsAFmycrF2RroV45FTUq0vi2SdSXV7Kjb20Ren/vBNeQw9M37QWmU8Sj7q6YyWb9hv5T69DHvvDTqIjVtbM4RMojAAxYti5hmjNIh2PrWfVYWhXxCQ/WqAjWLtZBM6Oww1byfr5I/wFogAKkgHi8wYXZ4LnIC8V7jLAhujlToOvMMC9qwcBiPKDP2FO+CPSXaqVhH+LPSEgLggnU3EirihgxovbLNAuDEeEbRTyR70B0lW19tLHixso4ZQa7KxlVUwOmrHSZf7nVuWqPpxd+BAoGBAPQLyJ1IeRavmaU8XXxfMdYDoc8+xB7v2WaxkGXb6ToX1IWPkbMz4yyVGdB5PciIP3rLZ6s1+ruuRRV0IZ98i1OuN5TSR56ShCGg3zkd5C4L/xSMAz+NDfYSDBdO8BVvBsw21KqSRUi1ctL7QiIvfedrtGb5XrE4zhH0gjXlU5qZAoGBAMv2segn0Jx6az4rqRa2Y7zRx4iZ77JUqYDBI8WMnFeR54uiioTQ+rOs3zK2fGIWlrn4ohco/STHQSUTB8oCOFLMx1BkOqiR+UyebO28DJY7+V9ZmxB2Guyi7W8VScJcIdpSOPyJFOWZQKXdQFW3YICD2/toUx/pDAJh1sEVQsV3AoGBANyyp1rthmvoo5cVbymhYQ08vaERDwU3PLCtFXu4E0Ow90VNn6Ki4ueXcv/gFOp7pISk2/yuVTBTGjCblCiJ1en4HFWekJwrvgg3Vodtq8Okn6pyMCHRqvWEPqD5hw6rGEensk0K+FMXnF6GULlfn4mgEkYpb+PvDhSYvQSGfkPJAoGAF/bAKFqlM/1eJEvU7go35bNwEiij9Pvlfm8y2L8Qj2lhHxLV240CJ6IkBz1Rl+S3iNohkT8LnwqaKNT3kVB5daEBufxMuAmOlOX4PmZdxDj/r6hDg8ecmjj6VJbXt7JDd/c5ItKoVeGPqu035dpJyE+1xPAY9CLZel4scTsiQTkCgYBt3buRcZMwnc4qqpOOQcXK+DWD6QvpkcJ55ygHYw97iP/lF4euwdHd+I5b+11pJBAao7G0fHX3eSjqOmzReSKboSe5L8ZLB2cAI8AsKTBfKHWmCa8kDtgQuI86fUfirCGdhdA9AVP2QXN2eNCuPnFWi0WHm4fYuUB5be2c18ucxAb9CAESmgsK3QMIAhIQ071yBlsbLoO2CSB9Ds0cmRif6uevBiKOAjCCAQoCggEBAMJwhQD18uFNSe6dTaIQEvsZdONH+RQWhP+fWz6d9NWxs9FBhU/VaBLWN72nmpCof3d9fcUzbwklrBA0WjJqf8EpzIhGVJJJdOPO9o8drh7keJOimLriaKobCKNU8FAbnhWcpxwVCc10HlvBG9bWAEThsKfFu6/m9ESIxwvPURZ31V6WJReSOjHJLcsyTUjsJUGzKpVYoX0lJL91kXdxxZgNDsWsudQhCJKE5TUx1Zc1coaL+Xhwg0qe0BDUYGmkNN4wvwnIBTqEMpobAbUQjNAkJn7gB4wPXu+0epvxiT8u+RvX6qGTNUTM1QxymymT3d7BXdHNPu+4C81vHFhGmh8CAwEAASjwIkgBUqoBCAEQABqBAQQlRbfiBNDb6eU6aKrsH5WJaYszTioXjPLrWN9dqyW0vwfT11kgF0BbCGkAXew2tLJJqIuD95cjJvyGUSN6VyhL6dp44fWEGDSBIPR0mvRq7bMP+m7Y/RLKf83+OyVJu/BpxivQGC5YDL9f1/A8eLhTDNKXs4Ia5DrmTWdPTPBL8SIgyfUtg3ofI+/I9Tf7it7xXpT0AbQBJfNkcNXGpO3JcBMSgAIL5xsXK5of1mMwAl6ygN1Gsj4aZ052otnwN7kXk12SMsXheWTZ/PYh2KRzmt9RPS1T8hyFx/Kp5VkBV2vTAqqWrGw/dh4URqiHATZJUlhO7PN5m2Kq1LVFdXjWSzP5XBF2S83UMe+YruNHpE5GQrSyZcBqHO0QrdPcU35GBT7S7+IJr2AAXvnjqnb8yrtpPWN2ZW/IWUJN2z4vZ7/HV4aj3OZhkxC1DIMNyvsusUKoQQuf8gwKiEe8cFwbwFSicywlFk9la2IPe8oFShcxAzHLCCn/TIYUAvEL3/4LgaZvqWm80qCPYbgIP5HT8hPYkKWJ4WYknEWK+3InbnkzteFfGrQFCq4CCAESEGnj6Ji7LD+4o7MoHYT4jBQYjtW+kQUijgIwggEKAoIBAQDY9um1ifBRIOmkPtDZTqH+CZUBbb0eK0Cn3NHFf8MFUDzPEz+emK/OTub/hNxCJCao//pP5L8tRNUPFDrrvCBMo7Rn+iUb+mA/2yXiJ6ivqcN9Cu9i5qOU1ygon9SWZRsujFFB8nxVreY5Lzeq0283zn1Cg1stcX4tOHT7utPzFG/ReDFQt0O/GLlzVwB0d1sn3SKMO4XLjhZdncrtF9jljpg7xjMIlnWJUqxDo7TQkTytJmUl0kcM7bndBLerAdJFGaXc6oSY4eNy/IGDluLCQR3KZEQsy/mLeV1ggQ44MFr7XOM+rd+4/314q/deQbjHqjWFuVr8iIaKbq+R63ShAgMBAAEo8CISgAMii2Mw6z+Qs1bvvxGStie9tpcgoO2uAt5Zvv0CDXvrFlwnSbo+qR71Ru2IlZWVSbN5XYSIDwcwBzHjY8rNr3fgsXtSJty425djNQtF5+J2jrAhf3Q2m7EI5aohZGpD2E0cr+dVj9o8x0uJR2NWR8FVoVQSXZpad3M/4QzBLNto/tz+UKyZwa7Sc/eTQc2+ZcDS3ZEO3lGRsH864Kf/cEGvJRBBqcpJXKfG+ItqEW1AAPptjuggzmZEzRq5xTGf6or+bXrKjCpBS9G1SOyvCNF1k5z6lG8KsXhgQxL6ADHMoulxvUIihyPY5MpimdXfUdEQ5HA2EqNiNVNIO4qP007jW51yAeThOry4J22xs8RdkIClOGAauLIl0lLA4flMzW+VfQl5xYxP0E5tuhn0h+844DslU8ZF7U1dU2QprIApffXD9wgAACk26Rggy8e96z8i86/+YYyZQkc9hIdCAERrgEYCEbByzONrdRDs1MrS/ch1moV5pJv63BIKvQHGvLkaFwoMY29tcGFueV9uYW1lEgd1bmtub3duGioKCm1vZGVsX25hbWUSHEFuZHJvaWQgU0RLIGJ1aWx0IGZvciB4ODZfNjQaGwoRYXJjaGl0ZWN0dXJlX25hbWUSBng4Nl82NBodCgtkZXZpY2VfbmFtZRIOZ2VuZXJpY194ODZfNjQaIAoMcHJvZHVjdF9uYW1lEhBzZGtfcGhvbmVfeDg2XzY0GmMKCmJ1aWxkX2luZm8SVUFuZHJvaWQvc2RrX3Bob25lX3g4Nl82NC9nZW5lcmljX3g4Nl82NDo5L1BTUjEuMTgwNzIwLjAxMi80OTIzMjE0OnVzZXJkZWJ1Zy90ZXN0LWtleXMaHgoUd2lkZXZpbmVfY2RtX3ZlcnNpb24SBjE0LjAuMBokCh9vZW1fY3J5cHRvX3NlY3VyaXR5X3BhdGNoX2xldmVsEgEwMg4QASAAKA0wAEAASABQAA=="""
|
||||
@@ -0,0 +1,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
|
||||
+444
-137
@@ -1,161 +1,468 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
from io import BytesIO
|
||||
from typing import Any, AsyncGenerator, Callable
|
||||
|
||||
from async_lru import alru_cache
|
||||
from PIL import Image
|
||||
from pywidevine import PSSH, Cdm
|
||||
import structlog
|
||||
|
||||
from ..api.apple_music_api import AppleMusicApi
|
||||
from ..api.itunes_api import ItunesApi
|
||||
from ..utils import get_response
|
||||
from .constants import IMAGE_FILE_EXTENSION_MAP
|
||||
from .enums import CoverFormat
|
||||
from .types import DecryptionKey
|
||||
from ..utils import safe_gather
|
||||
from .constants import VALID_URL_PATTERN
|
||||
from .enums import ArtistMediaType
|
||||
from .exceptions import (
|
||||
GamdlInterfaceMediaNotAllowedError,
|
||||
GamdlInterfaceUrlParseError,
|
||||
GamdlInterfaceArtistMediaTypeError,
|
||||
GamdlInterfaceFlatFilterExcludedError,
|
||||
)
|
||||
from .music_video import AppleMusicMusicVideoInterface
|
||||
from .song import AppleMusicSongInterface
|
||||
from .types import AppleMusicMedia, AppleMusicUrlInfo
|
||||
from .uploaded_video import AppleMusicUploadedVideoInterface
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicInterface:
|
||||
def __init__(
|
||||
self,
|
||||
apple_music_api: AppleMusicApi,
|
||||
itunes_api: ItunesApi,
|
||||
song: AppleMusicSongInterface,
|
||||
music_video: AppleMusicMusicVideoInterface,
|
||||
uploaded_video: AppleMusicUploadedVideoInterface,
|
||||
artist_select_media_type_function: (
|
||||
Callable[[list[ArtistMediaType], dict], ArtistMediaType | None] | None
|
||||
) = None,
|
||||
artist_select_items_function: (
|
||||
Callable[[ArtistMediaType, list[dict]], list[dict] | None] | None
|
||||
) = None,
|
||||
flat_filter_function: Callable[[dict], Any] | None = None,
|
||||
concurrency: int = 1,
|
||||
disallowed_media_types: list[str] | None = None,
|
||||
) -> None:
|
||||
self.apple_music_api = apple_music_api
|
||||
self.itunes_api = itunes_api
|
||||
self.song = song
|
||||
self.music_video = music_video
|
||||
self.uploaded_video = uploaded_video
|
||||
self.artist_select_media_type_function = artist_select_media_type_function
|
||||
self.artist_select_items_function = artist_select_items_function
|
||||
self.flat_filter_function = flat_filter_function
|
||||
self.concurrency = concurrency
|
||||
self.disallowed_media_types = disallowed_media_types
|
||||
|
||||
self.base = song.base
|
||||
|
||||
@staticmethod
|
||||
def get_media_id_of_library_media(library_media_metadata: dict) -> str:
|
||||
play_params = library_media_metadata["attributes"].get("playParams", {})
|
||||
return play_params.get("catalogId", library_media_metadata["id"])
|
||||
def get_url_info(url: str) -> AppleMusicUrlInfo | None:
|
||||
log = logger.bind(action="get_url_info", url=url)
|
||||
|
||||
@staticmethod
|
||||
def parse_date(date: str) -> datetime.datetime:
|
||||
return datetime.datetime.fromisoformat(date.split("Z")[0])
|
||||
match = VALID_URL_PATTERN.match(url)
|
||||
if not match:
|
||||
log.debug("invalid_url_pattern")
|
||||
|
||||
async def get_decryption_key(
|
||||
self,
|
||||
track_uri: str,
|
||||
track_id: str,
|
||||
cdm: Cdm,
|
||||
) -> DecryptionKey:
|
||||
try:
|
||||
cdm_session = cdm.open()
|
||||
|
||||
pssh_obj = PSSH(track_uri.split(",")[-1])
|
||||
|
||||
challenge = base64.b64encode(
|
||||
await asyncio.to_thread(
|
||||
cdm.get_license_challenge, cdm_session, pssh_obj
|
||||
)
|
||||
).decode()
|
||||
license = await self.apple_music_api.get_license_exchange(
|
||||
track_id,
|
||||
track_uri,
|
||||
challenge,
|
||||
)
|
||||
|
||||
await asyncio.to_thread(cdm.parse_license, cdm_session, license["license"])
|
||||
decryption_key_info = next(
|
||||
i for i in cdm.get_keys(cdm_session) if i.type == "CONTENT"
|
||||
)
|
||||
finally:
|
||||
cdm.close(cdm_session)
|
||||
|
||||
decryption_key = DecryptionKey(
|
||||
key=decryption_key_info.key.hex(),
|
||||
kid=decryption_key_info.kid.hex,
|
||||
)
|
||||
logger.debug(f"Decryption key: {decryption_key}")
|
||||
|
||||
return decryption_key
|
||||
|
||||
def get_cover_url_template(self, metadata: dict, cover_format: CoverFormat) -> str:
|
||||
if cover_format == CoverFormat.RAW:
|
||||
cover_url_template = self._get_raw_cover_url(
|
||||
metadata["attributes"]["artwork"]["url"]
|
||||
)
|
||||
else:
|
||||
cover_url_template = metadata["attributes"]["artwork"]["url"]
|
||||
|
||||
logger.debug(f"Cover URL template: {cover_url_template}")
|
||||
return cover_url_template
|
||||
|
||||
def _get_raw_cover_url(self, cover_url_template: str) -> str:
|
||||
return re.sub(
|
||||
r"image/thumb/",
|
||||
"",
|
||||
re.sub(
|
||||
r"is1-ssl",
|
||||
"a1",
|
||||
cover_url_template,
|
||||
),
|
||||
)
|
||||
|
||||
def get_cover_url(
|
||||
self,
|
||||
cover_url_template: str,
|
||||
cover_size: int,
|
||||
cover_format: CoverFormat,
|
||||
) -> str:
|
||||
cover_url = re.sub(
|
||||
r"/\{w\}x\{h\}([a-z]{2})\.jpg",
|
||||
(
|
||||
f"/{cover_size}x{cover_size}bb.{cover_format.value}"
|
||||
if cover_format != CoverFormat.RAW
|
||||
else ""
|
||||
),
|
||||
cover_url_template,
|
||||
)
|
||||
|
||||
logger.debug(f"Cover URL: {cover_url}")
|
||||
return cover_url
|
||||
|
||||
@alru_cache()
|
||||
async def get_cover_file_extension(
|
||||
self,
|
||||
cover_url: str,
|
||||
cover_format: CoverFormat,
|
||||
) -> str | None:
|
||||
if cover_format != CoverFormat.RAW:
|
||||
return f".{cover_format.value}"
|
||||
|
||||
cover_bytes = await self.get_cover_bytes(cover_url)
|
||||
if cover_bytes is None:
|
||||
return None
|
||||
|
||||
image_obj = Image.open(BytesIO(await self.get_cover_bytes(cover_url)))
|
||||
image_format = image_obj.format.lower()
|
||||
return IMAGE_FILE_EXTENSION_MAP.get(
|
||||
image_format,
|
||||
f".{image_format.lower()}",
|
||||
url_match = AppleMusicUrlInfo(
|
||||
**match.groupdict(),
|
||||
)
|
||||
|
||||
@alru_cache()
|
||||
async def get_cover_bytes(self, cover_url: str) -> bytes | None:
|
||||
response = await get_response(cover_url, {200, 404})
|
||||
if response.status_code == 200:
|
||||
return response.content
|
||||
return None
|
||||
log.debug("success", url_info=url_match)
|
||||
|
||||
@alru_cache()
|
||||
async def get_media_date(
|
||||
return url_match
|
||||
|
||||
async def _run_flat_filter(self, media: AppleMusicMedia) -> None:
|
||||
if not self.flat_filter_function or not media.partial:
|
||||
return
|
||||
|
||||
result = self.flat_filter_function(media.media_metadata)
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
|
||||
if result:
|
||||
raise GamdlInterfaceFlatFilterExcludedError(media.media_id, result)
|
||||
|
||||
def _run_media_type_filter(self, media: AppleMusicMedia) -> None:
|
||||
if not self.disallowed_media_types or not media.partial:
|
||||
return
|
||||
|
||||
if media.media_metadata["type"] in self.disallowed_media_types:
|
||||
raise GamdlInterfaceMediaNotAllowedError(
|
||||
media.media_metadata["type"],
|
||||
media.media_id,
|
||||
)
|
||||
|
||||
async def _collect_generator(
|
||||
self, generator_or_coroutine: AsyncGenerator[AppleMusicMedia, None]
|
||||
) -> list[AppleMusicMedia]:
|
||||
results = []
|
||||
async for result in generator_or_coroutine:
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
async def _get_song_media(
|
||||
self,
|
||||
media_id: str,
|
||||
) -> datetime.datetime | None:
|
||||
lookup_result = await self.itunes_api.get_lookup_result(media_id)
|
||||
if not lookup_result["results"]:
|
||||
return None
|
||||
index: int | None = None,
|
||||
total: int | None = None,
|
||||
media_metadata: dict | None = None,
|
||||
playlist_metadata: dict | None = None,
|
||||
) -> 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,
|
||||
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,
|
||||
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,40 +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,
|
||||
codec: SongCodec,
|
||||
song_metadata: dict | None = None,
|
||||
webplayback: dict | None = None,
|
||||
) -> StreamInfoAv | None:
|
||||
if codec.is_legacy():
|
||||
return await self._get_stream_info_legacy(webplayback, codec)
|
||||
else:
|
||||
return await self._get_stream_info(song_metadata, codec)
|
||||
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:
|
||||
@@ -266,6 +312,7 @@ class AppleMusicSongInterface(AppleMusicInterface):
|
||||
)
|
||||
|
||||
if playlist is None:
|
||||
log.debug("no_matching_playlist", codec=codec.value)
|
||||
return None
|
||||
|
||||
stream_info = StreamInfo(legacy=False)
|
||||
@@ -298,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,
|
||||
@@ -317,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
|
||||
|
||||
@@ -361,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,
|
||||
@@ -402,6 +450,8 @@ class AppleMusicSongInterface(AppleMusicInterface):
|
||||
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(legacy=True)
|
||||
@@ -409,7 +459,9 @@ class AppleMusicSongInterface(AppleMusicInterface):
|
||||
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(
|
||||
@@ -417,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
|
||||
@@ -142,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.9.3"
|
||||
version = "3.3"
|
||||
description = "A command-line app for downloading Apple Music songs, music videos and post videos."
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
@@ -11,11 +11,13 @@ dependencies = [
|
||||
"colorama>=0.4.6",
|
||||
"dataclass-click>=1.0.4",
|
||||
"httpx>=0.28.1",
|
||||
"httpx-retries>=0.4.6",
|
||||
"inquirerpy>=0.3.4",
|
||||
"m3u8>=6.0.0",
|
||||
"mutagen>=1.47.0",
|
||||
"pillow>=12.0.0",
|
||||
"pywidevine>=1.8.0",
|
||||
"structlog>=25.5.0",
|
||||
"yt-dlp>=2025.10.22",
|
||||
]
|
||||
|
||||
@@ -24,3 +26,9 @@ Repository = "https://github.com/glomatico/gamdl"
|
||||
|
||||
[project.scripts]
|
||||
gamdl = "gamdl.cli.cli:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=9.0.3",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
]
|
||||
|
||||
@@ -29,6 +29,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/49/d10027df9fce941cb8184e78a02857af36360d33e1721df81c5ed2179a1a/async_lru-2.0.5-py3-none-any.whl", hash = "sha256:ab95404d8d2605310d345932697371a5f40def0487c03d6d0ad9138de52c9943", size = 6069, upload-time = "2025-03-16T17:25:35.422Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backports-asyncio-runner"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backports-datetime-fromisoformat"
|
||||
version = "2.0.3"
|
||||
@@ -214,7 +223,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "gamdl"
|
||||
version = "2.9.3"
|
||||
version = "3.3"
|
||||
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