feat(apple-music): embed gamdl-aio folder directly inside allstarr project

This commit is contained in:
joshpatra committed 2026-07-02 14:27:54 -04:00
1 parent 3465cde7de
commit 154abe28d1
251 files changed
+19241 -1

No files matched your search

+1 -1
View File
@@ -182,7 +182,7 @@ services:
gamdl-aio:
build:
context: ../gamdl-aio
context: ./gamdl-aio
args:
TARGET_ARCH: arm64-v8a
RUNTIME_PLATFORM: linux/arm64
+99
View File
@@ -0,0 +1,99 @@
# Multi-stage Dockerfile for gamdl-aio
ARG RUNTIME_PLATFORM=linux/arm64
ARG BUILD_PLATFORM=linux/amd64
# -----------------------------------------------------------------------------
# Build Stage (always amd64 for Android NDK toolchain)
# -----------------------------------------------------------------------------
FROM --platform=linux/amd64 debian:13.2 AS build
ARG TARGET_ARCH=arm64-v8a
ARG CMAKE_BUILD_TYPE=Release
ARG NDK_VERSION=23
SHELL ["/bin/bash", "-c"]
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
cmake \
curl \
git \
jq \
ninja-build \
unzip \
&& if [[ "$TARGET_ARCH" == "arm64-v8a" ]]; then \
apt-get install -y --no-install-recommends \
gcc-aarch64-linux-gnu g++-aarch64-linux-gnu; \
fi \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fSL -o /tmp/ndk.zip \
"https://dl.google.com/android/repository/android-ndk-r${NDK_VERSION}b-linux.zip" && \
unzip -q /tmp/ndk.zip -d /opt && \
rm /tmp/ndk.zip
ENV ANDROID_NDK_HOME=/opt/android-ndk-r${NDK_VERSION}b
WORKDIR /app/wrapper-v2
COPY wrapper-v2/ /app/wrapper-v2
# Run system staging inside the build image to get linker64 & standard libs
RUN bash tools/stage-system.sh --arch "${TARGET_ARCH}"
# Compile daemon and wrapper launcher
RUN host_cc=(); \
if [[ "$TARGET_ARCH" == "arm64-v8a" ]]; then \
host_cc=( \
-DCMAKE_C_COMPILER=aarch64-linux-gnu-gcc \
-DCMAKE_CXX_COMPILER=aarch64-linux-gnu-g++ \
); \
fi && \
cmake -S . -B build -G Ninja \
-DTARGET_ARCH="${TARGET_ARCH}" \
"${host_cc[@]}" \
-DCMAKE_BUILD_TYPE="${CMAKE_BUILD_TYPE}" && \
cmake --build build -j
# -----------------------------------------------------------------------------
# Runtime Stage (FastAPI Server + Decryption Wrapper + Transcoder)
# -----------------------------------------------------------------------------
FROM --platform=${RUNTIME_PLATFORM} python:3.10-slim-bookworm
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# Install runtime packages: FFmpeg for transcoding, unzip/jq for extract-libs
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ffmpeg \
unzip \
jq \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Copy wrapper files
COPY --from=build /app/wrapper-v2/wrapper /app/wrapper-v2/wrapper
COPY --from=build /app/wrapper-v2/rootfs /app/wrapper-v2/rootfs
COPY --from=build /etc/ssl/certs/ca-certificates.crt /app/wrapper-v2/rootfs/etc/ssl/certs/ca-certificates.crt
COPY wrapper-v2/tools /app/wrapper-v2/tools
COPY wrapper-v2/vendor /app/wrapper-v2/vendor
COPY wrapper-v2/LIBS_VERSION.json /app/wrapper-v2/LIBS_VERSION.json
# Copy server assets
COPY requirements.txt /app/
COPY main.py /app/
COPY gamdl/ /app/gamdl/
# Install python dependencies
RUN pip install --no-cache-dir -r requirements.txt
EXPOSE 8000
# Set environment
ENV TARGET_ARCH=arm64-v8a
ENV HTTP_PORT=18080
CMD ["python", "main.py"]
+13
View File
@@ -0,0 +1,13 @@
services:
gamdl-aio:
build:
context: .
args:
TARGET_ARCH: arm64-v8a
RUNTIME_PLATFORM: linux/arm64
container_name: gamdl-aio
ports:
- "8000:8000"
volumes:
- ./data:/app/data
restart: unless-stopped
+8
View File
@@ -0,0 +1,8 @@
/*
__pycache__
!gamdl
!.gitignore
!.python-version
!pyproject.toml
!README.md
!uv.lock
+1
View File
@@ -0,0 +1 @@
3.10
+380
View File
@@ -0,0 +1,380 @@
# Gamdl (Glomatico's Apple Music Downloader)
[![PyPI version](https://img.shields.io/pypi/v/gamdl?color=blue)](https://pypi.org/project/gamdl/)
[![Python versions](https://img.shields.io/pypi/pyversions/gamdl)](https://pypi.org/project/gamdl/)
[![License](https://img.shields.io/github/license/glomatico/gamdl)](https://github.com/glomatico/gamdl/blob/main/LICENSE)
[![Downloads](https://img.shields.io/pypi/dm/gamdl)](https://pypi.org/project/gamdl/)
A command-line app for downloading Apple Music songs, music videos and post videos.
**Join our Discord Server:** <https://discord.gg/aBjMEZ9tnq>
## ✨ Features
- 🎵 **High-Quality Songs** - Download songs in AAC 256kbps and other codecs
- 🎬 **High-Quality Music Videos** - Download music videos in resolutions up to 4K
- 📝 **Synced Lyrics** - Download synced lyrics in LRC, SRT, or TTML formats
- 🏷️ **Rich Metadata** - Automatic tagging with comprehensive metadata
- 🎤 **Artist Support** - Download all albums or music videos from an artist
- ⚙️ **Highly Customizable** - Extensive configuration options for advanced users
## 📋 Prerequisites
### Required
- **Python 3.10 or higher**
- **Active Apple Music subscription**
- **Apple Music Cookies** - export your browser cookies in Netscape format while logged in at [Apple Music](https://music.apple.com):
- **Firefox**: [Export Cookies](https://addons.mozilla.org/addon/export-cookies-txt)
- **Chromium**: [Get cookies.txt LOCALLY](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
### Optional Dependencies
#### Wrapper
Run the [Wrapper v2](https://github.com/glomatico/wrapper-v2) server for wrapper-backed account, playback, and decryption requests. Enable it with `--use-wrapper` or `use_wrapper = true`, and configure the base URL with `--wrapper-url` or `wrapper_url`.
The wrapper is recommended when using the `alac` song codec. ALAC can be attempted without wrapper, but it probably won't work due to API limitations.
**Note:**
- When using the Wrapper, you'll be asked to insert your credentials to login if you haven't already.
- Song codecs other than `alac` do not require the wrapper.
- Cookies can be skipped when using the wrapper.
#### N_m3u8DL-RE
Use [N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest) as a faster download alternative to the default yt-dlp download mode. Enable it with `--download-mode nm3u8dlre` or `download_mode = nm3u8dlre`.
If the executable is not available in your system PATH, set its location with `--nm3u8dlre-path` or `nm3u8dlre_path`.
N_m3u8DL-RE also needs FFmpeg. If the FFmpeg executable is not available in your system PATH, set its location with `--ffmpeg-path` or `ffmpeg_path`.
## 📦 Installation
1. **Install Gamdl via pip:**
```bash
pip install gamdl
```
2. **Set up the cookies file:**
- Place the cookies file in the working directory as `cookies.txt`, or
- Specify the path using `--cookies-path` or in the config file
3. **Optional: Set up dependencies** (only if you need the functionality)
See the [Optional Dependencies](#optional-dependencies) section to determine which optional tools you need.
## 🚀 Usage
```bash
gamdl [OPTIONS] URLS...
```
### Supported URL Types
- Songs (Catalog/Library)
- Albums (Catalog/Library)
- Playlists (Catalog/Library)
- Music Videos (Catalog/Library)
- Artists
- Post Videos
- Apple Music Classical
### Examples
**Download a song:**
```bash
gamdl "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
```
**Download an album:**
```bash
gamdl "https://music.apple.com/us/album/whenever-you-need-somebody-2022-remaster/1624945511"
```
**Download from an artist:**
```bash
gamdl "https://music.apple.com/us/artist/rick-astley/669771"
```
**Interactive Prompt Controls:**
| Key | Action |
| -------------- | ----------------- |
| **Arrow keys** | Move selection |
| **Space** | Toggle selection |
| **Ctrl + A** | Select all |
| **Enter** | Confirm selection |
## ⚙️ Configuration
Configure Gamdl using command-line arguments or a config file.
**Config file location:**
- Linux: `~/.gamdl/config.ini`
- Windows: `%USERPROFILE%\.gamdl\config.ini`
The file is created automatically on first run. Command-line arguments override config values.
### Configuration Options
| Option | Description | Default |
| ------------------------------- | ----------------------------------------------------------------- | ----------------------------- |
| **General Options** | | |
| `--read-urls-as-txt`, `-r` | Read URLs from text files | `false` |
| `--config-path` | Config file path | `<home>/.gamdl/config.ini` |
| `--log-level` | Logging level | `INFO` |
| `--log-file` | Log file path | - |
| `--no-exceptions` | Don't print exceptions | `false` |
| `--artist-auto-select` | Automatically select artist content to download (artist URLs) | - |
| `--database-path` | Path to the SQLite database file for registering downloaded media | - |
| `--no-config-file`, `-n` | Don't use a config file | `false` |
| **Apple Music Options** | | |
| `--cookies-path`, `-c` | Cookies file path | `./cookies.txt` |
| `--wrapper-url` | Wrapper base URL | `http://127.0.0.1` |
| `--language`, `-l` | Metadata language | `en-US` |
| **Interface Options** | | |
| `--cover-format` | Cover format | `jpg` |
| `--cover-size` | Cover size in pixels | `1200` |
| `--wvd-path` | .wvd file path | - |
| `--use-wrapper` | Use wrapper for account, playback, and decryption requests | `false` |
| **Song Options** | | |
| `--synced-lyrics-format` | Synced lyrics format | `lrc` |
| `--song-codec-priority` | Comma-separated codec priority | `aac-web` |
| `--use-album-date` | Use album release date for songs | `false` |
| `--no-synced-lyrics` | Don't download synced lyrics | `false` |
| `--synced-lyrics-only` | Download only synced lyrics | `false` |
| **Music Video Options** | | |
| `--music-video-resolution` | Max music video resolution | `1080p` |
| `--music-video-codec-priority` | Comma-separated codec priority | `h264,h265` |
| `--music-video-remux-format` | Music video remux format | `m4v` |
| **Post Video Options** | | |
| `--uploaded-video-quality` | Post video quality | `best` |
| **Download & Path Options** | | |
| `--output-path`, `-o` | Output directory path | `./Apple Music` |
| `--temp-path` | Temporary directory path | `.` |
| `--nm3u8dlre-path` | N_m3u8DL-RE executable path | `N_m3u8DL-RE` |
| `--ffmpeg-path` | FFmpeg executable path | `ffmpeg` |
| `--download-mode` | Download mode | `ytdlp` |
| **Template Options** | | |
| `--album-folder-template` | Album folder template | `{album_artist}/{album}` |
| `--compilation-folder-template` | Compilation folder template | `Compilations/{album}` |
| `--no-album-folder-template` | No album folder template | `{artist}/Unknown Album` |
| `--playlist-folder-template` | Playlist folder template | `Playlists/{playlist_artist}` |
| `--single-disc-file-template` | Single disc file template | `{track:02d} {title}` |
| `--multi-disc-file-template` | Multi disc file template | `{disc}-{track:02d} {title}` |
| `--no-album-file-template` | No album file template | `{title}` |
| `--playlist-file-template` | Playlist file template | `{playlist_title}` |
| `--date-tag-template` | Date tag template | `%Y-%m-%dT%H:%M:%SZ` |
| `--exclude-tags` | Comma-separated tags to exclude | - |
| `--truncate` | Max filename length | - |
| **File Output Options** | | |
| `--overwrite` | Overwrite existing files | `false` |
| `--save-cover`, `-s` | Save cover as separate file | `false` |
| `--save-playlist` | Save M3U8 playlist file | `false` |
### Template Variables
**Tags for templates and exclude-tags:**
- `album`, `album_artist`, `album_id`
- `artist`, `artist_id`
- `composer`, `composer_id`
- `date` (supports strftime format: `{date:%Y}`)
- `disc`, `disc_total`
- `media_type`
- `playlist_artist`, `playlist_id`, `playlist_title`, `playlist_track`
- `title`, `title_id`
- `track`, `track_total`
**Tags for exclude-tags only:**
- `album_sort`, `artist_sort`, `composer_sort`, `title_sort`
- `comment`, `compilation`, `copyright`, `cover`, `gapless`, `genre`, `genre_id`, `lyrics`, `rating`, `storefront`, `xid`
- `all` (special: skip all tagging)
### Logging Level
- `DEBUG`, `INFO`, `WARNING`, `ERROR`
### Download Mode
- `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.
### Cover Format
- `jpg`
- `png`
- `raw` - Raw format as provided by the artist (requires `save_cover` to be enabled as it doesn't embed covers into files)
### Metadata Language
Use ISO 639-1 language codes (e.g., `en-US`, `es-ES`, `ja-JP`, `pt-BR`). Don't always work for music videos.
### Song Codecs
**Web:**
- `aac-web` - AAC 256kbps 44.1kHz
- `aac-he-web` - AAC-HE 64kbps 44.1kHz
**Non-web** (`alac` can be attempted without wrapper, but it probably won't work due to API limitations):
- `aac` - AAC 256kbps up to 48kHz
- `aac-he` - AAC-HE 64kbps up to 48kHz
- `aac-binaural` - AAC 256kbps binaural
- `aac-downmix` - AAC 256kbps downmix
- `aac-he-binaural` - AAC-HE 64kbps binaural
- `aac-he-downmix` - AAC-HE 64kbps downmix
- `atmos` - Dolby Atmos 768kbps
- `ac3` - AC3 640kbps
- `alac` - ALAC up to 24-bit/192kHz
- `ask` - Interactive codec selection
### Synced Lyrics Format
- `lrc`
- `srt` - SubRip subtitle format (more accurate timing)
- `ttml` - Native Apple Music format (not compatible with most media players)
### Music Video Codecs
- `h264`
- `h265`
- `ask` - Interactive codec selection
### Music Video Resolutions
- H.264: `240p`, `360p`, `480p`, `540p`, `720p`, `1080p`
- H.265 only: `1440p`, `2160p`
### Music Video Remux Formats
- `m4v`, `mp4`
### Post Video Quality
- `best` - Up to 1080p with AAC 256kbps
- `ask` - Interactive quality selection
### Artist Auto-Select Options
- `main-albums`
- `compilation-albums`
- `live-albums`
- `singles-eps`
- `all-albums`
- `top-songs`
- `music-videos`
## 🐍 Embedding
Use Gamdl as a library in your Python projects:
```python
import asyncio
from gamdl.api import AppleMusicApi
from gamdl.downloader import (
AppleMusicBaseDownloader,
AppleMusicDownloader,
AppleMusicMusicVideoDownloader,
AppleMusicSongDownloader,
AppleMusicUploadedVideoDownloader,
)
from gamdl.interface import (
AppleMusicBaseInterface,
AppleMusicInterface,
AppleMusicMusicVideoInterface,
AppleMusicSongInterface,
AppleMusicUploadedVideoInterface,
)
async def main():
# Create AppleMusicApi instance from cookies
apple_music_api = await AppleMusicApi.create_from_netscape_cookies(
cookies_path="cookies.txt",
)
# Check subscription
if not apple_music_api.active_subscription:
print("No active Apple Music subscription")
return
# Create base interface
base_interface = await AppleMusicBaseInterface.create(
apple_music_api=apple_music_api,
)
# 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,
)
# 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"
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__":
asyncio.run(main())
```
## 📄 License
MIT License - see [LICENSE](LICENSE) file for details
## 🤝 Contributing
Currently, I'm not interested in reviewing pull requests that change or add features. Only critical bug fixes will be considered. However, feel free to open issues for bugs or feature requests.
+1
View File
@@ -0,0 +1 @@
__version__ = "3.8"
+3
View File
@@ -0,0 +1,3 @@
from .cli.cli import main
main()
+4
View File
@@ -0,0 +1,4 @@
from .apple_music import AppleMusicApi
from .exceptions import *
from .itunes import ItunesApi
from .wrapper import WrapperApi
+789
View File
@@ -0,0 +1,789 @@
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_ASSETS_API_URI,
APPLE_MUSIC_COOKIE_DOMAIN,
APPLE_MUSIC_HOMEPAGE_URL,
APPLE_MUSIC_LIBRARY_ALBUM_API_URI,
APPLE_MUSIC_LIBRARY_PLAYLIST_API_URI,
APPLE_MUSIC_LIBRARY_PLAYLISTS_API_URI,
APPLE_MUSIC_LICENSE_API_URL,
APPLE_MUSIC_LIBRARY_MUSIC_VIDEO_API_URI,
APPLE_MUSIC_MUSIC_VIDEO_API_URI,
APPLE_MUSIC_LIBRARY_ALBUMS_API_URI,
APPLE_MUSIC_PLAYLIST_API_URI,
APPLE_MUSIC_SEARCH_API_URI,
APPLE_MUSIC_LIBRARY_MUSIC_VIDEOS_API_URI,
APPLE_MUSIC_LIBRARY_SONG_API_URI,
APPLE_MUSIC_LIBRARY_SONGS_API_URI,
APPLE_MUSIC_SONG_API_URI,
APPLE_MUSIC_UPLOADED_VIDEO_API_URL,
APPLE_MUSIC_WEBPLAYBACK_API_URL,
)
from .exceptions import GamdlApiResponseError
from .wrapper import WrapperApi
logger = structlog.get_logger(__name__)
class AppleMusicApi:
def __init__(
self,
client: httpx.AsyncClient,
token: str,
storefront: str,
language: str,
media_user_token: str | None = None,
account_info: dict | None = None,
) -> None:
self.token = token
self.storefront = storefront
self.language = language
self.media_user_token = media_user_token
self.account_info = account_info
self.client = client
@property
def active_subscription(self) -> bool:
if not self.account_info:
return False
return (
self.account_info.get("meta", {})
.get("subscription", {})
.get("active", False)
)
@property
def account_restrictions(self) -> dict | None:
if not self.account_info:
return None
data = self.account_info.get("data", [])
if not data:
return None
return data[0].get("attributes", {}).get("restrictions")
@staticmethod
async def get_token() -> str:
log = logger.bind(action="get_token")
response = None
async with httpx.AsyncClient() as client:
try:
response = await client.get(
APPLE_MUSIC_HOMEPAGE_URL,
follow_redirects=True,
)
response.raise_for_status()
home_page = response.text
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching Apple Music homepage",
status_code=response.status_code if response is not None else None,
)
index_js_uri_match = re.search(
r"/(assets/index[~-][^/\"]+\.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(r'"(eyJ[A-Za-z0-9\-_]+\.eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+)"', index_js_page)
if not token_match:
raise GamdlApiResponseError("Error finding token in index.js page")
token = token_match.group(1)
log.debug("success")
return token
@staticmethod
async def get_account_info(
token: str,
media_user_token: str,
meta: str = "subscription",
) -> dict:
log = logger.bind(action="get_account_info", meta=meta)
response = None
async with httpx.AsyncClient() as client:
try:
response = await client.get(
APPLE_MUSIC_AMP_API_URL + APPLE_MUSIC_ACCOUNT_INFO_API_URI,
params={
"meta": meta,
},
headers={
"authorization": f"Bearer {token}",
"origin": APPLE_MUSIC_HOMEPAGE_URL,
"cookie": f"media-user-token={media_user_token}",
},
)
response.raise_for_status()
account_info = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching account info",
status_code=response.status_code if response is not None else None,
)
log.debug("success", account_info=account_info)
return account_info
@classmethod
async def create(
cls,
storefront: str | None = "us",
language: str = "en-US",
token: str | None = None,
media_user_token: str | None = None,
) -> "AppleMusicApi":
token = token or await cls.get_token()
account_info = (
await cls.get_account_info(token, media_user_token)
if media_user_token
else None
)
storefront = (
account_info["meta"]["subscription"]["storefront"]
if account_info
else storefront
)
if not storefront:
raise ValueError(
"Storefront must be provided if it cannot be determined from account info"
)
client = httpx.AsyncClient(
headers={
"authorization": f"Bearer {token}",
"origin": APPLE_MUSIC_HOMEPAGE_URL,
},
transport=RetryTransport(
retry=Retry(
total=6,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
)
),
)
if media_user_token:
client.headers.update(
{
"cookie": f"media-user-token={media_user_token}",
}
)
api = cls(
client=client,
token=token,
storefront=storefront,
language=language,
media_user_token=media_user_token,
account_info=account_info,
)
return api
@classmethod
async def create_from_netscape_cookies(
cls,
cookies_path: str = "./cookies.txt",
*args,
**kwargs,
) -> "AppleMusicApi":
cookies = MozillaCookieJar(cookies_path)
cookies.load(ignore_discard=True, ignore_expires=True)
parse_cookie = lambda name: next(
(
cookie.value
for cookie in cookies
if cookie.name == name and cookie.domain == APPLE_MUSIC_COOKIE_DOMAIN
),
None,
)
media_user_token = parse_cookie("media-user-token")
if not media_user_token:
raise ValueError(
'"media-user-token" cookie not found in cookies. '
"Make sure you have exported the cookies from the Apple Music webpage "
"and are logged in with an active subscription."
)
return await cls.create(
media_user_token=media_user_token,
*args,
**kwargs,
)
@classmethod
async def create_from_wrapper(
cls,
wrapper_api: WrapperApi,
*args,
**kwargs,
) -> "AppleMusicApi":
auth = wrapper_api.me.get("auth", {})
media_user_token = auth.get("music_user_token")
token = auth.get("dev_token")
if not media_user_token or not token:
raise GamdlApiResponseError(
"Wrapper account info is missing auth tokens",
status_code=None,
)
return await cls.create(
media_user_token=media_user_token,
token=token,
*args,
**kwargs,
)
async def _amp_request(
self,
uri: str,
params: dict | None = None,
) -> dict:
response = None
try:
response = await self.client.get(
APPLE_MUSIC_AMP_API_URL + uri,
params=params,
)
response.raise_for_status()
response_json = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching from AMP API",
content=response.text if response is not None else None,
status_code=response.status_code if response is not None else None,
)
if "errors" in response_json:
raise GamdlApiResponseError(
"Error fetching from AMP API",
content=response_json["errors"],
)
return response_json
async def get_song(
self,
song_id: str,
extend: str = "extendedAssetUrls",
include: str = "lyrics,albums",
) -> dict:
log = logger.bind(action="get_song", song_id=song_id)
song = await self._amp_request(
APPLE_MUSIC_SONG_API_URI.format(
storefront=self.storefront,
song_id=song_id,
),
{
"extend": extend,
"include": include,
},
)
log.debug("success", song=song)
return song
async def get_music_video(
self,
music_video_id: str,
include: str = "albums",
) -> dict:
log = logger.bind(action="get_music_video", music_video_id=music_video_id)
music_video = await self._amp_request(
APPLE_MUSIC_MUSIC_VIDEO_API_URI.format(
storefront=self.storefront,
music_video_id=music_video_id,
),
{
"include": include,
},
)
log.debug("success", music_video=music_video)
return music_video
async def get_uploaded_video(
self,
uploaded_video_id: str,
) -> dict:
log = logger.bind(
action="get_uploaded_video", uploaded_video_id=uploaded_video_id
)
uploaded_video = await self._amp_request(
APPLE_MUSIC_UPLOADED_VIDEO_API_URL.format(
storefront=self.storefront,
uploaded_video_id=uploaded_video_id,
)
)
log.debug("success", uploaded_video=uploaded_video)
return uploaded_video
async def get_album(
self,
album_id: str,
extend: str = "extendedAssetUrls",
) -> dict:
log = logger.bind(action="get_album", album_id=album_id)
album = await self._amp_request(
APPLE_MUSIC_ALBUM_API_URI.format(
storefront=self.storefront,
album_id=album_id,
),
{
"extend": extend,
},
)
log.debug("success", album=album)
return album
async def get_playlist(
self,
playlist_id: str,
limit_tracks: int = 300,
extend: str = "extendedAssetUrls",
) -> dict:
log = logger.bind(action="get_playlist", playlist_id=playlist_id)
playlist = await self._amp_request(
APPLE_MUSIC_PLAYLIST_API_URI.format(
storefront=self.storefront,
playlist_id=playlist_id,
),
{
"limit[tracks]": limit_tracks,
"extend": extend,
},
)
log.debug("success", playlist=playlist)
return playlist
async def get_artist(
self,
artist_id: str,
include: str = "albums,music-videos",
views: str = "full-albums,compilation-albums,live-albums,singles,top-songs",
limit: int = 100,
) -> dict:
log = logger.bind(action="get_artist", artist_id=artist_id)
artist = await self._amp_request(
APPLE_MUSIC_ARTIST_API_URI.format(
storefront=self.storefront,
artist_id=artist_id,
),
{
"include": include,
"views": views,
**{
f"limit[{_include}]": limit
for _include in [*include.split(","), *views.split(",")]
},
},
)
log.debug("success", artist=artist)
return artist
async def get_library_song(
self,
song_id: str,
include: str = "catalog",
extend: str = "extendedAssetUrls",
) -> dict:
log = logger.bind(action="get_library_song", song_id=song_id)
song = await self._amp_request(
APPLE_MUSIC_LIBRARY_SONG_API_URI.format(
song_id=song_id,
),
{
"include": include,
"extend": extend,
},
)
log.debug("success", song=song)
return song
async def get_library_music_video(
self,
music_video_id: str,
include: str = "catalog",
) -> dict:
log = logger.bind(
action="get_library_music_video", music_video_id=music_video_id
)
music_video = await self._amp_request(
APPLE_MUSIC_LIBRARY_MUSIC_VIDEO_API_URI.format(
music_video_id=music_video_id,
),
{
"include": include,
},
)
log.debug("success", music_video=music_video)
return music_video
async def get_library_album(
self,
album_id: str,
include: str = "catalog",
extend: str = "extendedAssetUrls",
) -> dict:
log = logger.bind(action="get_library_album", album_id=album_id)
album = await self._amp_request(
APPLE_MUSIC_LIBRARY_ALBUM_API_URI.format(
album_id=album_id,
),
{
"include": include,
"extend": extend,
},
)
log.debug("success", album=album)
return album
async def get_library_playlist(
self,
playlist_id: str,
include: str = "catalog,tracks",
limit: int = 100,
extend: str = "extendedAssetUrls",
) -> dict:
log = logger.bind(action="get_library_playlist", playlist_id=playlist_id)
playlist = await self._amp_request(
APPLE_MUSIC_LIBRARY_PLAYLIST_API_URI.format(
playlist_id=playlist_id,
),
{
"include": include,
**{f"limit[{_include}]": limit for _include in include.split(",")},
"extend": extend,
},
)
log.debug("success", playlist=playlist)
return playlist
async def get_library_songs(
self,
limit: int = 100,
offset: int = 0,
include: str = "catalog",
extend: str = "extendedAssetUrls",
) -> dict:
log = logger.bind(action="get_library_songs")
library_songs = await self._amp_request(
APPLE_MUSIC_LIBRARY_SONGS_API_URI,
{
"limit": limit,
"offset": offset,
"include": include,
"extend": extend,
},
)
log.debug("success", library_songs=library_songs)
return library_songs
async def get_library_music_videos(
self,
limit: int = 100,
offset: int = 0,
include: str = "catalog",
) -> dict:
log = logger.bind(action="get_library_music_videos")
library_music_videos = await self._amp_request(
APPLE_MUSIC_LIBRARY_MUSIC_VIDEOS_API_URI,
{
"limit": limit,
"offset": offset,
"include": include,
},
)
log.debug("success", library_music_videos=library_music_videos)
return library_music_videos
async def get_library_albums(
self,
limit: int = 100,
offset: int = 0,
include: str = "catalog",
) -> dict:
log = logger.bind(action="get_library_albums")
library_albums = await self._amp_request(
APPLE_MUSIC_LIBRARY_ALBUMS_API_URI,
{
"limit": limit,
"offset": offset,
"include": include,
},
)
log.debug("success", library_albums=library_albums)
return library_albums
async def get_library_playlists(
self,
limit: int = 100,
offset: int = 0,
include: str = "catalog",
) -> dict:
log = logger.bind(action="get_library_playlists")
library_playlists = await self._amp_request(
APPLE_MUSIC_LIBRARY_PLAYLISTS_API_URI,
{
"limit": limit,
"offset": offset,
"include": include,
},
)
log.debug("success", library_playlists=library_playlists)
return library_playlists
async def get_search_results(
self,
term: str,
types: str = "songs,music-videos,albums,playlists,artists",
limit: int = 50,
offset: int = 0,
) -> dict:
log = logger.bind(action="get_search_results", term=term, types=types)
search_results = await self._amp_request(
APPLE_MUSIC_SEARCH_API_URI.format(
storefront=self.storefront,
),
{
"term": term,
"types": types,
"limit": limit,
"offset": offset,
},
)
log.debug("success", search_results=search_results)
return search_results
async def get_assets(
self,
media_id: str,
kind: str = "song",
include_license_urls: bool = True,
hls_encryption: str = "CBC",
hls_profile: str = "enhancedHls",
) -> dict:
log = logger.bind(
action="get_assets",
media_id=media_id,
kind=kind,
include_license_urls=include_license_urls,
hls_encryption=hls_encryption,
hls_profile=hls_profile,
)
params = {
"id": media_id,
"kind": kind,
"includeLicenseUrls": include_license_urls,
"hlsEncryption": hls_encryption,
}
if hls_profile:
params["hlsProfile"] = hls_profile
assets = await self._amp_request(
APPLE_MUSIC_ASSETS_API_URI,
params,
)
log.debug("success", assets=assets)
return assets
async def get_extended_api_data(
self,
next_uri: str | None,
href_uri: str,
) -> dict:
log = logger.bind(
action="extend_api_data", next_uri=next_uri, href_uri=href_uri
)
if not next_uri:
log.debug("no_next_uri")
return
href_params = parse_qs(urlparse(href_uri).query)
next_params = parse_qs(urlparse(next_uri).query)
if href_params.get("limit"):
limit = int(href_params["limit"][0])
else:
limit = None
extended_data = await self._amp_request(
urlparse(next_uri).path,
{
**({"limit": limit} if limit else {}),
**{k: v for k, v in next_params.items() if k not in ["limit"]},
},
)
log.debug("success", extended_data=extended_data)
return extended_data
async def get_webplayback(
self,
track_id: str,
is_library: bool = False,
) -> dict:
log = logger.bind(action="get_webplayback", track_id=track_id)
response = None
if is_library:
request_body = {
"universalLibraryId": track_id,
}
else:
request_body = {
"salableAdamId": track_id,
}
request_body["language"] = self.language
try:
response = await self.client.post(
APPLE_MUSIC_WEBPLAYBACK_API_URL,
json=request_body,
)
response.raise_for_status()
webplayback = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching webplayback data",
content=response.text if response is not None else None,
status_code=response.status_code if response is not None else None,
)
if "dialog" in webplayback:
raise GamdlApiResponseError(
"Error fetching webplayback data",
content=webplayback["dialog"],
)
log.debug("success", webplayback=webplayback)
return webplayback
async def get_license_exchange(
self,
track_id: str,
track_uri: str,
challenge: str,
key_system: str = "com.widevine.alpha",
is_library: bool = False,
) -> dict:
log = logger.bind(action="get_license_exchange", track_id=track_id)
response = None
try:
response = await self.client.post(
APPLE_MUSIC_LICENSE_API_URL,
json={
"challenge": challenge,
"key-system": key_system,
"uri": track_uri,
"adamId": track_id,
"isLibrary": is_library,
"user-initiated": True,
},
)
response.raise_for_status()
license_exchange = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching license exchange data",
content=response.text if response is not None else None,
status_code=response.status_code if response is not None else None,
)
if license_exchange.get("status") != 0:
raise GamdlApiResponseError(
"Error fetching license exchange data",
content=response.text,
status_code=response.status_code,
)
log.debug("success", license_exchange=license_exchange)
return license_exchange
+43
View File
@@ -0,0 +1,43 @@
APPLE_MUSIC_HOMEPAGE_URL = "https://music.apple.com"
APPLE_MUSIC_COOKIE_DOMAIN = ".music.apple.com"
APPLE_MUSIC_AMP_API_URL = "https://amp-api.music.apple.com"
APPLE_MUSIC_ACCOUNT_INFO_API_URI = "/v1/me/account"
APPLE_MUSIC_SONG_API_URI = "/v1/catalog/{storefront}/songs/{song_id}"
APPLE_MUSIC_MUSIC_VIDEO_API_URI = (
"/v1/catalog/{storefront}/music-videos/{music_video_id}"
)
APPLE_MUSIC_UPLOADED_VIDEO_API_URL = (
"/v1/catalog/{storefront}/uploaded-videos/{uploaded_video_id}"
)
APPLE_MUSIC_ALBUM_API_URI = "/v1/catalog/{storefront}/albums/{album_id}"
APPLE_MUSIC_PLAYLIST_API_URI = "/v1/catalog/{storefront}/playlists/{playlist_id}"
APPLE_MUSIC_ARTIST_API_URI = "/v1/catalog/{storefront}/artists/{artist_id}"
APPLE_MUSIC_LIBRARY_SONG_API_URI = "/v1/me/library/songs/{song_id}"
APPLE_MUSIC_LIBRARY_MUSIC_VIDEO_API_URI = (
"/v1/me/library/music-videos/{music_video_id}"
)
APPLE_MUSIC_LIBRARY_ALBUM_API_URI = "/v1/me/library/albums/{album_id}"
APPLE_MUSIC_LIBRARY_PLAYLIST_API_URI = "/v1/me/library/playlists/{playlist_id}"
APPLE_MUSIC_SEARCH_API_URI = "/v1/catalog/{storefront}/search"
APPLE_MUSIC_ASSETS_API_URI = "/v1/play/assets"
APPLE_MUSIC_LIBRARY_SONGS_API_URI = "/v1/me/library/songs"
APPLE_MUSIC_LIBRARY_MUSIC_VIDEOS_API_URI = "/v1/me/library/music-videos"
APPLE_MUSIC_LIBRARY_ALBUMS_API_URI = "/v1/me/library/albums"
APPLE_MUSIC_LIBRARY_PLAYLISTS_API_URI = "/v1/me/library/playlists"
APPLE_MUSIC_WEBPLAYBACK_API_URL = (
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback"
)
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/{media_type}/{media_id}"
+37
View File
@@ -0,0 +1,37 @@
import json
from typing import Any
from ..utils import GamdlError
class GamdlApiError(GamdlError):
pass
class GamdlApiResponseError(GamdlApiError):
def __init__(
self,
message: str,
content: Any | 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 is not None:
if isinstance(content, str):
content_text = content
else:
try:
content_text = json.dumps(content)
except TypeError:
content_text = str(content)
if content_text:
message += f": {content_text}"
super().__init__(message)
+151
View File
@@ -0,0 +1,151 @@
import re
import httpx
import structlog
from .constants import (
APPLE_MUSIC_MUSIC_KIT_URL,
ITUNES_LOOKUP_API_URL,
ITUNES_PAGE_API_URL,
)
from .exceptions import GamdlApiResponseError
logger = structlog.get_logger(__name__)
class ItunesApi:
def __init__(
self,
client: httpx.AsyncClient,
storefront: str,
language: str,
storefront_id: int,
) -> None:
self.client = client
self.storefront = storefront
self.language = language
self.storefront_id = storefront_id
@staticmethod
async def get_storefront_id(storefront: str) -> int:
log = logger.bind(action="get_storefront_id", storefront=storefront)
response = None
async with httpx.AsyncClient() as client:
try:
response = await client.get(APPLE_MUSIC_MUSIC_KIT_URL)
response.raise_for_status()
music_kit_content = response.text
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching MusicKit content",
status_code=response.status_code if response is not None else None,
)
normalized_storefront = storefront.upper()
country_code_pattern = f'{normalized_storefront}:"([A-Z]{{3}})"'
country_code_match = re.search(country_code_pattern, music_kit_content)
if not country_code_match:
raise GamdlApiResponseError(
f"Country code {storefront} not found in MusicKit content"
)
three_letter_code = country_code_match.group(1)
storefront_pattern = f'{three_letter_code}:"(\\d+)"'
storefront_match = re.search(storefront_pattern, music_kit_content)
if not storefront_match:
raise GamdlApiResponseError(
f"Storefront ID not found for country code {storefront}"
)
storefront_id = int(storefront_match.group(1))
log.debug("success", storefront_id=storefront_id)
return storefront_id
@classmethod
async def create(
cls,
storefront: str = "us",
storefront_id: int | None = 143441,
language: str = "en-US",
) -> "ItunesApi":
storefront_id = storefront_id or await cls.get_storefront_id(storefront)
client = httpx.AsyncClient(
timeout=60.0,
follow_redirects=True,
)
return cls(
client=client,
storefront=storefront,
language=language,
storefront_id=storefront_id,
)
async def get_lookup_result(
self,
media_id: str,
entity: str = "album",
) -> dict:
log = logger.bind(action="get_lookup_result", media_id=media_id, entity=entity)
response = None
try:
response = await self.client.get(
ITUNES_LOOKUP_API_URL,
params={
"id": media_id,
"entity": entity,
"country": self.storefront,
"lang": self.language,
},
)
response.raise_for_status()
lookup_result = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching iTunes lookup result",
content=response.text if response is not None else None,
status_code=response.status_code if response is not None else None,
)
log.debug("success", lookup_result=lookup_result)
return lookup_result
async def get_itunes_page(
self,
media_type: str,
media_id: str,
) -> dict:
log = logger.bind(
action="get_itunes_page",
media_type=media_type,
media_id=media_id,
)
response = None
try:
response = await self.client.get(
ITUNES_PAGE_API_URL.format(media_type=media_type, media_id=media_id),
headers={
"X-Apple-Store-Front": f"{self.storefront_id},32 t:music31",
},
)
response.raise_for_status()
itunes_page = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching iTunes page",
content=response.text if response is not None else None,
status_code=response.status_code if response is not None else None,
)
log.debug("success", itunes_page=itunes_page)
return itunes_page
+272
View File
@@ -0,0 +1,272 @@
from __future__ import annotations
import inspect
import struct
from collections.abc import Awaitable, Callable
from typing import TypeVar
import httpx
import structlog
from .exceptions import GamdlApiResponseError
logger = structlog.get_logger(__name__)
T = TypeVar("T")
CredentialsFunc = (
Callable[[], tuple[str, str]] | Callable[[], Awaitable[tuple[str, str]]]
)
TwoFactorCodeFunc = Callable[[], str] | Callable[[], Awaitable[str]]
async def _invoke(func: Callable[[], T | Awaitable[T]]) -> T:
result = func()
if inspect.isawaitable(result):
return await result
return result
class WrapperApi:
def __init__(
self,
base_url: str,
client: httpx.AsyncClient,
me: dict,
):
self.base_url = base_url
self.client = client
self.me = me
@staticmethod
def build_decrypt_sample_frame(
adam_id: str,
skd_uri: str,
ciphertexts: list[bytes],
) -> bytes:
"""Build wrapper-v2 /decrypt binary request frame."""
adam_id_bytes = adam_id.encode("utf-8")
skd_uri_bytes = skd_uri.encode("utf-8")
if not adam_id_bytes:
raise ValueError("wrapper-v2: adam_id must not be empty")
if not skd_uri_bytes:
raise ValueError("wrapper-v2: skd_uri must not be empty")
if not ciphertexts:
raise ValueError("wrapper-v2: ciphertext batch must not be empty")
frame = bytearray()
frame += struct.pack(
">III",
len(adam_id_bytes),
len(skd_uri_bytes),
len(ciphertexts),
)
for ciphertext in ciphertexts:
frame += struct.pack(">I", len(ciphertext))
frame += adam_id_bytes
frame += skd_uri_bytes
for ciphertext in ciphertexts:
frame += ciphertext
return bytes(frame)
@staticmethod
def parse_decrypt_sample_frame(data: bytes, expected_count: int) -> list[bytes]:
"""Parse wrapper-v2 /decrypt binary response frame."""
if len(data) < 4:
raise IOError("wrapper-v2: POST /decrypt returned a truncated response")
(sample_count,) = struct.unpack_from(">I", data, 0)
if sample_count != expected_count:
raise IOError(
f"wrapper-v2: expected {expected_count} samples in response, "
f"got {sample_count}"
)
table_end = 4 + sample_count * 4
if len(data) < table_end:
raise IOError("wrapper-v2: POST /decrypt returned a truncated length table")
lengths = [
struct.unpack_from(">I", data, 4 + i * 4)[0] for i in range(sample_count)
]
offset = table_end
out: list[bytes] = []
for i, length in enumerate(lengths):
end = offset + length
if end > len(data):
raise IOError(
f"wrapper-v2: POST /decrypt returned truncated sample {i}"
)
out.append(data[offset:end])
offset = end
if offset != len(data):
raise IOError("wrapper-v2: POST /decrypt returned trailing bytes")
return out
@classmethod
async def create(
cls,
base_url: str = "http://127.0.0.1",
get_credentials_func: CredentialsFunc | None = None,
get_2fa_code: TwoFactorCodeFunc | None = None,
) -> WrapperApi:
client = httpx.AsyncClient(
timeout=httpx.Timeout(600.0, connect=30.0),
)
base_url = base_url.rstrip("/")
me = await cls.get_me(client, base_url)
if get_credentials_func is not None and me["auth"]["state"] == "logged_out":
username, password = await _invoke(get_credentials_func)
await cls.login(
client,
base_url,
username,
password,
get_2fa_code,
)
me = await cls.get_me(client, base_url)
if me.get("auth", {}).get("state") == "logged_out":
raise GamdlApiResponseError(
"Wrapper is not authenticated. "
"Provide get_credentials_func or log in via the wrapper.",
)
return cls(base_url, client, me)
@staticmethod
async def login(
client: httpx.AsyncClient,
base_url: str,
username: str,
password: str,
get_2fa_code: TwoFactorCodeFunc | None = None,
) -> None:
base_url = base_url.rstrip("/")
response = await client.post(
f"{base_url}/login",
json={"username": username, "password": password},
)
if response.status_code == 200:
return
if response.status_code == 202:
if get_2fa_code is None:
raise GamdlApiResponseError(
"Wrapper login requires 2FA; provide get_2fa_code",
status_code=202,
)
code = await _invoke(get_2fa_code)
tfa_response = await client.post(
f"{base_url}/login/2fa",
json={"code": code},
)
if tfa_response.is_error:
raise GamdlApiResponseError(
"Wrapper 2FA login failed",
content=tfa_response.text,
status_code=tfa_response.status_code,
)
return
raise GamdlApiResponseError(
"Wrapper login failed",
content=response.text,
status_code=response.status_code,
)
@staticmethod
async def get_me(client: httpx.AsyncClient, base_url: str) -> dict:
log = logger.bind(action="wrapper_get_me")
response = None
try:
response = await client.get(f"{base_url}/me")
response.raise_for_status()
account_info = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching wrapper account info",
content=getattr(response, "text", None),
status_code=getattr(response, "status_code", None),
)
log.debug("success", account_info=account_info)
return account_info
async def get_playback(self, media_id: str) -> dict:
log = logger.bind(action="wrapper_get_playback", media_id=media_id)
response = None
try:
response = await self.client.get(
f"{self.base_url}/playback",
params={"adam_id": media_id},
)
response.raise_for_status()
playback = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching wrapper playback",
content=getattr(response, "text", None),
status_code=getattr(response, "status_code", None),
)
log.debug("success", playback=playback)
return playback
async def decrypt(
self,
adam_id: str,
skd_uri: str,
ciphertexts: list[bytes],
) -> list[bytes]:
"""Decrypt one POST /decrypt batch; plaintexts match ciphertext order."""
log = logger.bind(
action="wrapper_decrypt",
adam_id=adam_id,
sample_count=len(ciphertexts),
)
frame = self.build_decrypt_sample_frame(adam_id, skd_uri, ciphertexts)
response = await self.client.post(
f"{self.base_url}/decrypt",
content=frame,
headers={
"content-type": "application/octet-stream",
"accept": "application/octet-stream",
},
)
if response.status_code == 401:
raise IOError(
"wrapper-v2: POST /decrypt returned 401 — log in with POST /login "
"or restore a session on the daemon first"
)
if response.status_code == 503:
raise IOError(
"wrapper-v2: decrypt unavailable (503) — check daemon logs /health "
"for playback_ready and Apple lib init"
)
if response.status_code != 200:
detail = ""
try:
j = response.json()
detail = (j.get("detail") or j.get("error") or str(j)) or ""
except Exception:
detail = (response.text or "")[:500]
raise IOError(
f"wrapper-v2: POST /decrypt failed HTTP {response.status_code}: {detail}"
)
plaintexts = self.parse_decrypt_sample_frame(
response.content,
len(ciphertexts),
)
log.debug("success")
return plaintexts
Whitespace-only changes.
+312
View File
@@ -0,0 +1,312 @@
import asyncio
from functools import wraps
from pathlib import Path
import click
import colorama
import structlog
from dataclass_click import dataclass_click
from httpx import ConnectError
from .. import __version__
from ..api import AppleMusicApi
from ..api.wrapper import WrapperApi
from ..downloader import (
AppleMusicBaseDownloader,
AppleMusicDownloader,
AppleMusicMusicVideoDownloader,
AppleMusicSongDownloader,
AppleMusicUploadedVideoDownloader,
GamdlDownloaderDependencyNotFoundError,
GamdlDownloaderMediaFileExistsError,
GamdlDownloaderSyncedLyricsOnlyError,
)
from ..interface import (
AppleMusicBaseInterface,
AppleMusicInterface,
AppleMusicMusicVideoInterface,
AppleMusicSongInterface,
AppleMusicUploadedVideoInterface,
GamdlInterfaceArtistMediaTypeError,
GamdlInterfaceDecryptionNotAvailableError,
GamdlInterfaceFlatFilterExcludedError,
GamdlInterfaceFormatNotAvailableError,
GamdlInterfaceMediaNotStreamableError,
GamdlInterfaceUrlParseError,
)
from ..interface.enums import SongCodec
from .cli_config import CliConfig
from .config_file import ConfigFile
from .database import Database
from .interactive_prompts import InteractivePrompts
from .utils import CustomOutputWriter, custom_structlog_formatter, prompt_path
logger = structlog.get_logger(__name__)
def make_sync(func):
@wraps(func)
def wrapper(*args, **kwargs):
return asyncio.run(func(*args, **kwargs))
return wrapper
@click.command()
@click.help_option("-h", "--help")
@click.version_option(__version__, "-v", "--version")
@dataclass_click(CliConfig)
@ConfigFile.loader
@make_sync
async def main(config: CliConfig):
colorama.just_fix_windows_console()
log_output = CustomOutputWriter()
if config.log_file:
log_output.add_file(config.log_file)
processors = [structlog.processors.add_log_level]
if not config.no_exceptions:
processors.append(structlog.processors.ExceptionPrettyPrinter())
processors.append(custom_structlog_formatter)
structlog.configure(
processors=processors,
logger_factory=structlog.PrintLoggerFactory(file=log_output),
wrapper_class=structlog.make_filtering_bound_logger(config.log_level),
)
logger.info(f"Starting Gamdl {__version__}")
interactive_prompts = InteractivePrompts(
artist_auto_select=config.artist_auto_select,
)
if config.use_wrapper:
try:
wrapper_api = await WrapperApi.create(
base_url=config.wrapper_url,
get_credentials_func=InteractivePrompts.get_wrapper_credentials,
get_2fa_code=InteractivePrompts.get_wrapper_2fa_code,
)
apple_music_api = await AppleMusicApi.create_from_wrapper(
wrapper_api=wrapper_api,
language=config.language,
)
except Exception as e:
logger.exception(f"Error: {e}")
return
else:
cookies_path = prompt_path(config.cookies_path)
apple_music_api = await AppleMusicApi.create_from_netscape_cookies(
cookies_path=cookies_path,
language=config.language,
)
wrapper_api = None
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"
)
alac_codecs = {
SongCodec.ALAC,
SongCodec.ALAC_16_44,
SongCodec.ALAC_24_44,
SongCodec.ALAC_24_48,
SongCodec.ALAC_24_96,
SongCodec.ALAC_24_192,
}
if any(codec in config.song_codec_piority for codec in alac_codecs) and not config.use_wrapper:
logger.warning(
"You have chosen ALAC without enabling wrapper. "
"ALAC may be attempted without wrapper, but it probably won't work due "
"to API limitations."
)
if config.database_path:
database = Database(config.database_path, config.overwrite)
flat_filter = database.flat_filter
else:
database = None
flat_filter = None
base_interface = await AppleMusicBaseInterface.create(
apple_music_api=apple_music_api,
cover_format=config.cover_format,
cover_size=config.cover_size,
wvd_path=config.wvd_path,
wrapper_api=wrapper_api,
)
song_interface = AppleMusicSongInterface(
base=base_interface,
synced_lyrics_format=config.synced_lyrics_format,
codec_priority=config.song_codec_piority,
use_album_date=config.use_album_date,
skip_stream_info=config.synced_lyrics_only,
ask_codec_function=interactive_prompts.ask_song_codec,
)
music_video_interface = AppleMusicMusicVideoInterface(
base=base_interface,
resolution=config.music_video_resolution,
codec_priority=config.music_video_codec_priority,
ask_video_codec_function=interactive_prompts.ask_music_video_video_codec_function,
ask_audio_codec_function=interactive_prompts.ask_music_video_audio_codec_function,
)
uploaded_video_interface = AppleMusicUploadedVideoInterface(
base=base_interface,
quality=config.uploaded_video_quality,
ask_quality_function=interactive_prompts.ask_uploaded_video_quality_function,
)
interface = AppleMusicInterface(
song=song_interface,
music_video=music_video_interface,
uploaded_video=uploaded_video_interface,
artist_select_media_type_function=interactive_prompts.ask_artist_media_type,
artist_select_items_function=interactive_prompts.ask_artist_select_items,
flat_filter_function=flat_filter,
)
base_downloader = AppleMusicBaseDownloader(
interface=interface,
output_path=config.output_path,
temp_path=config.temp_path,
nm3u8dlre_path=config.nm3u8dlre_path,
ffmpeg_path=config.ffmpeg_path,
download_mode=config.download_mode,
album_folder_template=config.album_folder_template,
compilation_folder_template=config.compilation_folder_template,
no_album_folder_template=config.no_album_folder_template,
playlist_folder_template=config.playlist_folder_template,
single_disc_file_template=config.single_disc_file_template,
multi_disc_file_template=config.multi_disc_file_template,
no_album_file_template=config.no_album_file_template,
playlist_file_template=config.playlist_file_template,
date_tag_template=config.date_tag_template,
exclude_tags=config.exclude_tags,
truncate=config.truncate,
)
song_downloader = AppleMusicSongDownloader(
base=base_downloader,
)
music_video_downloader = AppleMusicMusicVideoDownloader(
base=base_downloader,
remux_format=config.music_video_remux_format,
)
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(
base=base_downloader,
)
downloader = AppleMusicDownloader(
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 config.read_urls_as_txt:
urls_from_file = []
for url in config.urls:
if Path(url).is_file() and Path(url).exists():
urls_from_file.extend(
[
line.strip()
for line in Path(url).read_text(encoding="utf-8").splitlines()
if line.strip()
]
)
urls = urls_from_file
else:
urls = config.urls
error_count = 0
for url_index, url in enumerate(urls, 1):
url_log = logger.bind(action=f"URL {url_index:>3}/{len(urls):<3}")
url_log.info(f'Processing "{url}"')
try:
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 "-"
track_log = logger.bind(
action=f"Track {media_index:>3}/{media_total:<3}"
)
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
continue
logger.info(f"Finished with {error_count} error(s)")
+475
View File
@@ -0,0 +1,475 @@
import inspect
from dataclasses import dataclass
from pathlib import Path
from typing import Annotated
import click
from dataclass_click import argument, option
from ..api import AppleMusicApi, WrapperApi
from ..downloader import (
AppleMusicBaseDownloader,
AppleMusicDownloader,
AppleMusicMusicVideoDownloader,
DownloadMode,
RemuxFormatMusicVideo,
RemuxMode,
)
from ..interface import (
AppleMusicBaseInterface,
AppleMusicInterface,
AppleMusicMusicVideoInterface,
AppleMusicSongInterface,
AppleMusicUploadedVideoInterface,
ArtistMediaType,
CoverFormat,
MusicVideoCodec,
MusicVideoResolution,
SongCodec,
SyncedLyricsFormat,
UploadedVideoQuality,
)
from .utils import Csv
api_from_cookies_sig = inspect.signature(AppleMusicApi.create_from_netscape_cookies)
wrapper_api_create_sig = inspect.signature(WrapperApi.create)
api_create_sig = inspect.signature(AppleMusicApi.create)
base_interface_create_sig = inspect.signature(AppleMusicBaseInterface.create)
song_interface_sig = inspect.signature(AppleMusicSongInterface.__init__)
music_video_interface_sig = inspect.signature(AppleMusicMusicVideoInterface.__init__)
uploaded_video_interface_sig = inspect.signature(
AppleMusicUploadedVideoInterface.__init__
)
interface_create_sig = inspect.signature(AppleMusicInterface)
base_downloader_sig = inspect.signature(AppleMusicBaseDownloader.__init__)
music_video_downloader_sig = inspect.signature(AppleMusicMusicVideoDownloader.__init__)
downloader_sig = inspect.signature(AppleMusicDownloader.__init__)
@dataclass
class CliConfig:
# CLI specific options
urls: Annotated[
list[str],
argument(
nargs=-1,
type=str,
required=True,
),
]
read_urls_as_txt: Annotated[
bool,
option(
"--read-urls-as-txt",
"-r",
help="Read URLs from text files",
is_flag=True,
),
]
config_path: Annotated[
str,
option(
"--config-path",
help="Config file path",
default=str(Path.home() / ".gamdl" / "config.ini"),
type=click.Path(
file_okay=True,
dir_okay=False,
writable=True,
resolve_path=True,
),
),
]
log_level: Annotated[
str,
option(
"--log-level",
help="Logging level",
default="INFO",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]),
),
]
log_file: Annotated[
str,
option(
"--log-file",
help="Log file path",
default=None,
type=click.Path(
file_okay=True,
dir_okay=False,
writable=True,
resolve_path=True,
),
),
]
no_exceptions: Annotated[
bool,
option(
"--no-exceptions",
help="Don't print exceptions",
is_flag=True,
),
]
artist_auto_select: Annotated[
ArtistMediaType | None,
option(
"--artist-auto-select",
help="Automatically select artist content to download (only for artist URLs)",
default=None,
type=ArtistMediaType,
),
]
database_path: Annotated[
str,
option(
"--database-path",
help="Path to the SQLite database file for registering downloaded media",
default=None,
type=click.Path(
file_okay=True,
dir_okay=False,
writable=True,
resolve_path=True,
),
),
]
no_config_file: Annotated[
bool,
option(
"--no-config-file",
"-n",
help="Don't use a config file",
is_flag=True,
),
]
# Wrapper specific options
wrapper_url: Annotated[
str,
option(
"--wrapper-url",
help="Wrapper base URL",
default=wrapper_api_create_sig.parameters["base_url"].default,
),
]
# API specific options
cookies_path: Annotated[
str,
option(
"--cookies-path",
"-c",
help="Cookies file path",
default=api_from_cookies_sig.parameters["cookies_path"].default,
type=click.Path(
file_okay=True,
dir_okay=False,
readable=True,
resolve_path=True,
),
),
]
language: Annotated[
str,
option(
"--language",
"-l",
help="Metadata language",
default=api_create_sig.parameters["language"].default,
),
]
# Base Interface specific options
cover_format: Annotated[
CoverFormat,
option(
"--cover-format",
help="Cover format",
default=base_interface_create_sig.parameters["cover_format"].default,
type=CoverFormat,
),
]
cover_size: Annotated[
int,
option(
"--cover-size",
help="Cover size in pixels",
default=base_interface_create_sig.parameters["cover_size"].default,
),
]
wvd_path: Annotated[
str | None,
option(
"--wvd-path",
help=".wvd file path",
default=base_interface_create_sig.parameters["wvd_path"].default,
type=click.Path(
file_okay=True,
dir_okay=False,
writable=False,
resolve_path=True,
),
),
]
use_wrapper: Annotated[
bool,
option(
"--use-wrapper",
help="Use wrapper for account, playback, and decryption requests",
is_flag=True,
),
]
# Song Interface Options
synced_lyrics_format: Annotated[
SyncedLyricsFormat,
option(
"--synced-lyrics-format",
help="Synced lyrics format",
default=song_interface_sig.parameters["synced_lyrics_format"].default,
type=SyncedLyricsFormat,
),
]
song_codec_piority: Annotated[
list[SongCodec],
option(
"--song-codec-priority",
help="Comma-separated codec priority",
default=song_interface_sig.parameters["codec_priority"].default,
type=Csv(SongCodec),
),
]
use_album_date: Annotated[
bool,
option(
"--use-album-date",
help="Use album release date for songs",
is_flag=True,
),
]
# Music Video Interface Options
music_video_resolution: Annotated[
MusicVideoResolution,
option(
"--music-video-resolution",
help="Max music video resolution",
default=music_video_interface_sig.parameters["resolution"].default,
type=MusicVideoResolution,
),
]
music_video_codec_priority: Annotated[
list[MusicVideoCodec],
option(
"--music-video-codec-priority",
help="Comma-separated codec priority",
default=music_video_interface_sig.parameters["codec_priority"].default,
type=Csv(MusicVideoCodec),
),
]
# Uploaded Video Interface Options
uploaded_video_quality: Annotated[
UploadedVideoQuality,
option(
"--uploaded-video-quality",
help="Post video quality",
default=uploaded_video_interface_sig.parameters["quality"].default,
type=UploadedVideoQuality,
),
]
# Base Downloader specific options
output_path: Annotated[
str,
option(
"--output-path",
"-o",
help="Output directory path",
default=base_downloader_sig.parameters["output_path"].default,
type=click.Path(
file_okay=False,
dir_okay=True,
writable=True,
resolve_path=True,
),
),
]
temp_path: Annotated[
str,
option(
"--temp-path",
help="Temporary directory path",
default=base_downloader_sig.parameters["temp_path"].default,
type=click.Path(
file_okay=False,
dir_okay=True,
writable=True,
resolve_path=True,
),
),
]
nm3u8dlre_path: Annotated[
str,
option(
"--nm3u8dlre-path",
help="N_m3u8DL-RE executable path",
default=base_downloader_sig.parameters["nm3u8dlre_path"].default,
),
]
ffmpeg_path: Annotated[
str,
option(
"--ffmpeg-path",
help="FFmpeg executable path",
default=base_downloader_sig.parameters["ffmpeg_path"].default,
),
]
download_mode: Annotated[
DownloadMode,
option(
"--download-mode",
help="Download mode",
default=base_downloader_sig.parameters["download_mode"].default,
type=DownloadMode,
),
]
album_folder_template: Annotated[
str,
option(
"--album-folder-template",
help="Album folder template",
default=base_downloader_sig.parameters["album_folder_template"].default,
),
]
compilation_folder_template: Annotated[
str,
option(
"--compilation-folder-template",
help="Compilation folder template",
default=base_downloader_sig.parameters[
"compilation_folder_template"
].default,
),
]
no_album_folder_template: Annotated[
str,
option(
"--no-album-folder-template",
help="No album folder template",
default=base_downloader_sig.parameters["no_album_folder_template"].default,
),
]
playlist_folder_template: Annotated[
str,
option(
"--playlist-folder-template",
help="Playlist folder template",
default=base_downloader_sig.parameters["playlist_folder_template"].default,
),
]
single_disc_file_template: Annotated[
str,
option(
"--single-disc-file-template",
help="Single disc file template",
default=base_downloader_sig.parameters["single_disc_file_template"].default,
),
]
multi_disc_file_template: Annotated[
str,
option(
"--multi-disc-file-template",
help="Multi disc file template",
default=base_downloader_sig.parameters["multi_disc_file_template"].default,
),
]
no_album_file_template: Annotated[
str,
option(
"--no-album-file-template",
help="No album file template",
default=base_downloader_sig.parameters["no_album_file_template"].default,
),
]
playlist_file_template: Annotated[
str,
option(
"--playlist-file-template",
help="Playlist file template",
default=base_downloader_sig.parameters["playlist_file_template"].default,
),
]
date_tag_template: Annotated[
str,
option(
"--date-tag-template",
help="Date tag template",
default=base_downloader_sig.parameters["date_tag_template"].default,
),
]
exclude_tags: Annotated[
list[str],
option(
"--exclude-tags",
help="Comma-separated tags to exclude",
default=base_downloader_sig.parameters["exclude_tags"].default,
type=Csv(str),
),
]
truncate: Annotated[
int,
option(
"--truncate",
help="Max filename length",
default=base_downloader_sig.parameters["truncate"].default,
),
]
# DownloaderMusicVideo specific options
music_video_remux_format: Annotated[
RemuxFormatMusicVideo,
option(
"--music-video-remux-format",
help="Music video remux format",
default=music_video_downloader_sig.parameters["remux_format"].default,
type=RemuxFormatMusicVideo,
),
]
# Downloader specific options
overwrite: Annotated[
bool,
option(
"--overwrite",
help="Overwrite existing files",
is_flag=True,
),
]
save_cover: Annotated[
bool,
option(
"--save-cover",
"-s",
help="Save cover as separate file",
is_flag=True,
),
]
save_playlist: Annotated[
bool,
option(
"--save-playlist",
help="Save M3U8 playlist file",
is_flag=True,
),
]
no_synced_lyrics: Annotated[
bool,
option(
"--no-synced-lyrics",
help="Don't download synced lyrics",
is_flag=True,
),
]
synced_lyrics_only: Annotated[
bool,
option(
"--synced-lyrics-only",
help="Download only synced lyrics",
is_flag=True,
),
]
+167
View File
@@ -0,0 +1,167 @@
import configparser
import typing
from functools import wraps
from pathlib import Path
import click
import click.types as click_types
from .cli_config import CliConfig
from .constants import EXCLUDED_CONFIG_FILE_PARAMS
from .utils import Csv
class ConfigFile:
def __init__(
self,
config_path: str,
section_name: str = "gamdl",
) -> None:
self.config_path = config_path
self.section_name = section_name
self.click_context = click.get_current_context()
self._read_config_file()
def _read_config_file(self) -> None:
self.config = configparser.ConfigParser(interpolation=None)
if Path(self.config_path).exists():
self.config.read(self.config_path, encoding="utf-8")
else:
Path(self.config_path).parent.mkdir(parents=True, exist_ok=True)
if not self.config.has_section(self.section_name):
self.config.add_section(self.section_name)
def _write_config_file(self) -> None:
with open(self.config_path, "w", encoding="utf-8") as config_file:
self.config.write(config_file)
def _serialize_param_default(self, param: click.Parameter) -> str:
if param.default is None:
return "null"
if isinstance(param.type, Csv):
return ",".join(
item.value if hasattr(item, "value") else str(item)
for item in param.default
)
if isinstance(param.type, click_types.FuncParamType):
return param.default.value
if isinstance(param.type, click_types.BoolParamType):
return "true" if param.default else "false"
if isinstance(
param.type,
click_types.Choice
| click_types.Path
| click_types.StringParamType
| click_types.IntParamType,
):
return str(param.default)
raise NotImplementedError(
f"Serialization for parameter '{param.name}' of type "
f"'{type(param.type)}' is not implemented."
)
def _add_param_default_to_config(
self,
param: click.Parameter,
) -> bool:
if self.config.has_option(self.section_name, param.name):
return False
value = self._serialize_param_default(param)
self.config.set(self.section_name, param.name, value)
return True
def _parse_param_from_config(
self,
param: click.Parameter,
) -> typing.Any:
value = self.config[self.section_name].get(param.name)
if value is None:
return param.default
if value == "null":
return None
if not isinstance(param.type, click_types.ParamType):
raise NotImplementedError(
f"Parsing for parameter '{param.name}' of type "
f"'{type(param.type)}' is not implemented."
)
return param.type.convert(value, None, None)
def add_params_default_to_config(self) -> None:
has_changes = False
for param in self.click_context.command.params:
if param.name in EXCLUDED_CONFIG_FILE_PARAMS:
continue
has_changes = self._add_param_default_to_config(param) or has_changes
if has_changes:
self._write_config_file()
def cleanup_unknown_params(self) -> None:
param_names = {info.name for info in self.click_context.command.params}
has_changes = False
for key in list(self.config[self.section_name].keys()):
if key not in param_names:
self.config.remove_option(self.section_name, key)
has_changes = True
if has_changes:
self._write_config_file()
def update_params_from_config(self) -> None:
for param in self.click_context.command.params:
if (
self.click_context.get_parameter_source(param.name)
== click.core.ParameterSource.COMMANDLINE
):
continue
if self.config.has_option(self.section_name, param.name):
self.click_context.params[param.name] = self._parse_param_from_config(
param
)
def get_cli_config(self) -> CliConfig:
config_dict = {}
for param in self.click_context.command.params:
if param.name in {"help", "version"}:
continue
config_dict[param.name] = self.click_context.params.get(
param.name, param.default
)
return CliConfig(**config_dict)
def load(self) -> CliConfig:
self.cleanup_unknown_params()
self.add_params_default_to_config()
self.update_params_from_config()
return self.get_cli_config()
@staticmethod
def loader(func):
@wraps(func)
def wrapper(cli_config: CliConfig):
ctx = click.get_current_context()
config_path = ctx.params.get("config_path")
no_config_file = ctx.params.get("no_config_file")
if config_path and not no_config_file:
cli_config = ConfigFile(config_path).load()
return func(cli_config)
return wrapper
+9
View File
@@ -0,0 +1,9 @@
EXCLUDED_CONFIG_FILE_PARAMS = {
"urls",
"config_path",
"read_urls_as_txt",
"no_config_file",
"version",
"help",
}
X_NOT_IN_PATH = '{} was not found in PATH at "{}"'
+58
View File
@@ -0,0 +1,58 @@
import sqlite3
from pathlib import Path
class Database:
def __init__(
self,
path: Path,
overwrite: bool,
):
self.overwrite = overwrite
self.connection = sqlite3.connect(path)
self.cursor = self.connection.cursor()
self._create_tables()
def _create_tables(self) -> None:
self.cursor.execute(
"""
CREATE TABLE IF NOT EXISTS media (
id TEXT PRIMARY KEY,
path TEXT NOT NULL
)
"""
)
self.connection.commit()
def get(self, media_id: str) -> str | None:
self.cursor.execute("SELECT path FROM media WHERE id = ?", (media_id,))
row = self.cursor.fetchone()
return row[0] if row else None
def add(self, media_id: str, path: str) -> None:
self.cursor.execute(
"INSERT OR REPLACE INTO media (id, path) VALUES (?, ?)",
(media_id, str(Path(path).absolute())),
)
self.connection.commit()
def remove(self, media_id: str) -> None:
self.cursor.execute("DELETE FROM media WHERE id = ?", (media_id,))
self.connection.commit()
def close(self) -> None:
self.connection.close()
def flat_filter(self, media_metadata: dict) -> str | None:
media_id = media_metadata["id"]
result = self.get(media_id)
if not result:
return None
return (
"Registered in database"
if Path(result).exists() and not self.overwrite
else None
)
@@ -0,0 +1,248 @@
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
import m3u8
from ..interface import ArtistMediaType
class InteractivePrompts:
def __init__(
self,
artist_auto_select: ArtistMediaType | None = None,
):
self.artist_auto_select = artist_auto_select
@staticmethod
def millis_to_min_sec(millis) -> str:
minutes, seconds = divmod(millis // 1000, 60)
return f"{minutes:02}:{seconds:02}"
@staticmethod
async def get_wrapper_credentials() -> tuple[str, str]:
username = await inquirer.text(
message="Apple ID:",
).execute_async()
password = await inquirer.secret(
message="Password:",
).execute_async()
return username, password
@staticmethod
async def get_wrapper_2fa_code() -> str:
return await inquirer.text(
message="Two-factor authentication code:",
).execute_async()
@staticmethod
async def ask_song_codec(
playlists: list[dict],
) -> dict:
choices = [
Choice(
name=playlist["stream_info"]["audio"],
value=playlist,
)
for playlist in playlists
]
return await inquirer.select(
message="Select which codec to download:",
choices=choices,
).execute_async()
@staticmethod
async def ask_music_video_video_codec_function(
playlists: list[m3u8.Playlist],
) -> dict:
choices = [
Choice(
name=" | ".join(
[
playlist.stream_info.codecs[:4],
"x".join(str(v) for v in playlist.stream_info.resolution),
str(playlist.stream_info.bandwidth),
]
),
value=playlist,
)
for playlist in playlists
]
return await inquirer.select(
message="Select which video codec to download: (Codec | Resolution | Bitrate)",
choices=choices,
).execute_async()
@staticmethod
async def ask_music_video_audio_codec_function(
playlists: list[dict],
) -> dict:
choices = [
Choice(
name=playlist["group_id"],
value=playlist,
)
for playlist in playlists
]
selected = await inquirer.select(
message="Select which audio codec to download:",
choices=choices,
).execute_async()
return selected
@staticmethod
async def ask_uploaded_video_quality_function(
available_qualities: dict[str, str],
) -> str:
qualities = list(available_qualities.keys())
choices = [
Choice(
name=quality,
value=quality,
)
for quality in qualities
]
selected = await inquirer.select(
message="Select which quality to download:",
choices=choices,
).execute_async()
return available_qualities[selected]
async def ask_artist_media_type(
self,
media_types: list[ArtistMediaType],
artist_metadata: dict,
) -> ArtistMediaType:
if self.artist_auto_select:
return self.artist_auto_select
available_choices = []
for media_types in media_types:
available_choices.append(
Choice(
name=str(media_types),
value=(media_types,),
),
)
(media_type,) = await inquirer.select(
message=f'Select which type to download for artist "{artist_metadata["attributes"]["name"]}":',
choices=available_choices,
validate=lambda result: artist_metadata.get(result[0].path_key[0], {})
.get(result[0].path_key[1], {})
.get("data"),
).execute_async()
return media_type
async def ask_artist_select_items(
self,
media_type: ArtistMediaType,
items: list[dict],
) -> list[dict]:
if media_type in {
ArtistMediaType.MAIN_ALBUMS,
ArtistMediaType.COMPILATION_ALBUMS,
ArtistMediaType.LIVE_ALBUMS,
ArtistMediaType.SINGLES_EPS,
ArtistMediaType.ALL_ALBUMS,
}:
return await self._ask_artist_select_albums(items)
elif media_type == ArtistMediaType.TOP_SONGS:
return await self._ask_artist_select_songs(
items,
)
elif media_type == ArtistMediaType.MUSIC_VIDEOS:
return await self._ask_artist_select_music_videos(items)
async def _ask_artist_select_albums(
self,
albums: list[dict],
) -> list[dict]:
if self.artist_auto_select:
return albums
choices = [
Choice(
name=" | ".join(
[
f'{album["attributes"]["trackCount"]:03d}',
f'{album["attributes"]["releaseDate"]:<10}',
f'{album["attributes"].get("contentRating", "None").title():<8}',
f'{album["attributes"]["name"]}',
]
),
value=album,
)
for album in albums
if album.get("attributes")
]
selected = await inquirer.select(
message="Select which albums to download: (Track Count | Release Date | Rating | Title)",
choices=choices,
multiselect=True,
).execute_async()
return selected
async def _ask_artist_select_songs(
self,
songs: list[dict],
) -> list[dict]:
if self.artist_auto_select:
return songs
choices = [
Choice(
name=" | ".join(
[
self.millis_to_min_sec(song["attributes"]["durationInMillis"]),
f'{song["attributes"].get("contentRating", "None").title():<8}',
song["attributes"]["name"],
],
),
value=song,
)
for song in songs
if song.get("attributes")
]
selected = await inquirer.select(
message="Select which songs to download: (Duration | Rating | Title)",
choices=choices,
multiselect=True,
).execute_async()
return selected
async def _ask_artist_select_music_videos(
self,
music_videos: list[dict],
) -> list[dict]:
if self.artist_auto_select:
return music_videos
choices = [
Choice(
name=" | ".join(
[
self.millis_to_min_sec(
music_video["attributes"]["durationInMillis"]
),
f'{music_video["attributes"].get("contentRating", "None").title():<8}',
music_video["attributes"]["name"],
],
),
value=music_video,
)
for music_video in music_videos
if music_video.get("attributes")
]
selected = await inquirer.select(
message="Select which music videos to download: (Duration | Rating | Title)",
choices=choices,
multiselect=True,
).execute_async()
return selected
+123
View File
@@ -0,0 +1,123 @@
import atexit
import sys
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Any
import click
class Csv(click.ParamType):
name = "csv"
def __init__(
self,
subtype: Enum,
) -> None:
self.subtype = subtype
def convert(
self,
value: str,
param: click.Parameter,
ctx: click.Context,
) -> list[Enum]:
if not isinstance(value, str):
return value
items = [v.strip() for v in value.split(",") if v.strip()]
result = []
for item in items:
try:
result.append(self.subtype(item))
except ValueError as e:
self.fail(
f"'{item}' is not a valid value for {self.subtype.__name__}",
param,
ctx,
)
return result
class CustomOutputWriter:
def __init__(
self,
streams: list[Any] = [sys.stdout],
):
self.streams = streams
def add_file(self, path: str):
file_stream = open(path, "a", encoding="utf-8")
atexit.register(file_stream.close)
self.streams.append(file_stream)
def write(self, message: str):
for stream in self.streams:
stream.write(message)
def flush(self):
for stream in self.streams:
stream.flush()
def custom_structlog_formatter(
logger: Any,
name: str,
event_dict: dict[str, Any],
) -> str:
level = event_dict.pop("level", "INFO").upper()
timestamp = datetime.now().strftime("%H:%M:%S")
level_colors = {
"DEBUG": "cyan",
"INFO": "green",
"WARNING": "yellow",
"ERROR": "red",
"CRITICAL": "red",
}
color = level_colors.get(level, "white")
prefix = click.style(f"[{level:<8} {timestamp}]", fg=color)
action = event_dict.pop("action", None)
if action:
prefix += click.style(f" [{action}]", dim=True)
if level in {"INFO", "WARNING", "ERROR", "CRITICAL"}:
message = event_dict.pop("event", "")
return f"{prefix} {message}"
else:
return f"{prefix} {event_dict}"
def prompt_path(
input_path: str,
is_dir: bool = False,
) -> str:
path_validator = click.Path(
exists=True,
file_okay=not is_dir,
dir_okay=is_dir,
)
path_type = "directory" if is_dir else "file"
while True:
try:
result_path = path_validator.convert(input_path, None, None)
break
except click.BadParameter as e:
input_path = click.prompt(
(
f'{path_type.capitalize()} "{Path(input_path).absolute()}" does not exist. '
f"Create the {path_type} at the specified path, "
f"type a new path or drag and drop the {path_type} here. "
"Then, press enter to continue"
),
default=input_path,
show_default=False,
)
input_path = input_path.strip('"')
return result_path
@@ -0,0 +1,9 @@
from .amdecrypt import decrypt_file_hex, decrypt_wrapper, write_decrypted_media
from .base import AppleMusicBaseDownloader
from .downloader import AppleMusicDownloader
from .enums import *
from .exceptions import *
from .music_video import AppleMusicMusicVideoDownloader
from .song import AppleMusicSongDownloader
from .types import *
from .uploaded_video import AppleMusicUploadedVideoDownloader
@@ -0,0 +1,3546 @@
"""
This is a modified version of https://github.com/sn0wst0rm/st0rmMusicPlayer/blob/main/scripts/amdecrypt.py
All the modifications made here were AI generated
FairPlay sample decryption talks to wrapper-v2 over HTTP POST /decrypt
(binary frame), not the legacy raw TCP port used by the original wrapper (e.g. 10020).
"""
from __future__ import annotations
import asyncio
import io
import os
import struct
import tempfile
import time
from collections import Counter
from dataclasses import dataclass, field
from typing import BinaryIO, List, Optional
import structlog
from Crypto.Cipher import AES
from ..api.wrapper import WrapperApi
logger = structlog.get_logger(__name__)
# Default decryption key for songs without per-sample keys (legacy AAC)
DEFAULT_SONG_DECRYPTION_KEY = b"2\xb8\xad\xe1v\x9e&\xb1\xff\xb8\x98cRy?\xc6"
# Pre-fetch key used for first sample description
PREFETCH_KEY = "skd://itunes.apple.com/P000000000/s1/e1"
# Max ciphertext blobs per POST /decrypt (same adam_id + uri). Increase for fewer
# round-trips; set to 1 if a given wrapper build mis-handles CBC between chunks.
WRAPPER_DECRYPT_BATCH_SIZE = 128
# wrapper-v2: use one SKD segment per ``adam_id``+``uri`` (do not interleave prefetch
# and main keys in one request). Flush pending ciphertexts before switching keys.
def _cbcs_ciphertext_for_sample(sample: SampleInfo) -> Optional[tuple[bytes, bytes]]:
"""Payload for FairPlay CBC: aligned decrypt input and trailing pass-through bytes.
Matches ``decrypt_samples_hex``: all encrypted subsample runs in one MP4 sample
are **concatenated**, then ``len & ~0xF`` bytes are decrypted as **one** CBC
stream; the remainder is copied (see hex path). Clear subsample bytes must never
be fed to the decryptor.
Returns:
``None`` — no AES input for this sample (emit literal ``sample.data``).
``(aligned, tail)`` — ``aligned`` may be empty if only a short tail exists;
then ``tail`` is the full encrypted concatenation (pass-through).
"""
data = sample.data
if not sample.subsamples:
n = len(data)
t = n & ~0xF
tail = data[t:] if t < n else b""
if t > 0:
return (data[:t], tail)
return None
enc_cat = bytearray()
offset = 0
for clear_b, enc_b in sample.subsamples:
offset += clear_b
if enc_b:
enc_cat.extend(data[offset : offset + enc_b])
offset += enc_b
if not enc_cat:
return None
blob = bytes(enc_cat)
cbc_len = len(blob) & ~0xF
tail = blob[cbc_len:]
aligned = blob[:cbc_len]
return (aligned, tail)
def _reassemble_cbcs_sample(sample: SampleInfo, plain: bytes, tail: bytes) -> bytes:
"""Place decrypted CBCS bytes back into the original MP4 sample layout."""
full_dec = plain + tail
data = sample.data
if not sample.subsamples:
if len(full_dec) != len(data):
raise IOError(
f"decrypted sample length mismatch: expected {len(data)}, got {len(full_dec)}"
)
return full_dec
encrypted_total = sum(enc_b for _, enc_b in sample.subsamples)
if len(full_dec) != encrypted_total:
raise IOError(
"decrypted subsample length mismatch: "
f"expected {encrypted_total}, got {len(full_dec)}"
)
out = bytearray()
dec_off = 0
offset = 0
for clear_b, enc_b in sample.subsamples:
if clear_b:
out.extend(data[offset : offset + clear_b])
offset += clear_b
if enc_b:
out.extend(full_dec[dec_off : dec_off + enc_b])
dec_off += enc_b
offset += enc_b
if offset < len(data):
out.extend(data[offset:])
if len(out) != len(data):
raise IOError(
f"reassembled sample length mismatch: expected {len(data)}, got {len(out)}"
)
return bytes(out)
def _append_reassembled_sample(
decrypted_data: bytearray, sample: SampleInfo, plain: bytes, tail: bytes
) -> None:
"""Append one decrypted CBCS sample to the output stream."""
decrypted_data.extend(_reassemble_cbcs_sample(sample, plain, tail))
def _sample_size(sample: SampleInfo) -> int:
"""Return sample payload size even after payload bytes have been released."""
return sample.size or len(sample.data)
def _sample_data(sample: SampleInfo) -> bytes:
"""Return sample payload bytes, loading file-backed samples on demand."""
if sample.data:
return sample.data
if sample.data_path and sample.size:
with open(sample.data_path, "rb") as f:
f.seek(sample.data_offset)
data = f.read(sample.size)
if len(data) != sample.size:
raise IOError(
f"unexpected EOF while reading sample at {sample.data_offset} "
f"from {sample.data_path}"
)
return data
return sample.data
def _with_sample_data(sample: SampleInfo, data: bytes) -> SampleInfo:
"""Return a copy of a sample with materialized payload bytes."""
return SampleInfo(
data=data,
duration=sample.duration,
desc_index=sample.desc_index,
iv=sample.iv,
subsamples=sample.subsamples,
composition_time_offset=sample.composition_time_offset,
sample_flags=sample.sample_flags,
is_sync=sample.is_sync,
size=sample.size or len(data),
data_path=sample.data_path,
data_offset=sample.data_offset,
)
def _decrypt_cbcs_sample_with_key(
sample: SampleInfo, key: bytes, enc_info: EncryptionInfo
) -> bytes:
"""Decrypt one CBCS sample with a raw AES key."""
if enc_info.crypt_byte_block and enc_info.skip_byte_block:
return _decrypt_cbcs_sample_with_pattern(sample, key, enc_info)
parts = _cbcs_ciphertext_for_sample(sample)
if parts is None:
return sample.data
aligned, tail = parts
plain = b""
if aligned:
iv = sample.iv if sample.iv else enc_info.constant_iv
if len(iv) < 16:
iv = iv + b"\x00" * (16 - len(iv))
cipher = AES.new(key, AES.MODE_CBC, iv=iv)
plain = cipher.decrypt(aligned)
return _reassemble_cbcs_sample(sample, plain, tail)
def _decrypt_cbcs_protected_range_with_pattern(
protected_data: bytes, key: bytes, iv: bytes, crypt_blocks: int, skip_blocks: int
) -> bytes:
"""Decrypt one CBCS pattern-encrypted protected byte stream."""
if crypt_blocks <= 0:
return protected_data
if len(iv) < 16:
iv = iv + b"\x00" * (16 - len(iv))
out = bytearray()
offset = 0
crypt_bytes = crypt_blocks * 16
skip_bytes = skip_blocks * 16
next_iv = iv
while offset < len(protected_data):
remaining = len(protected_data) - offset
crypt_window = min(crypt_bytes, remaining)
aligned_crypt_len = crypt_window & ~0xF
if aligned_crypt_len:
ciphertext = protected_data[offset : offset + aligned_crypt_len]
cipher = AES.new(key, AES.MODE_CBC, iv=next_iv)
out.extend(cipher.decrypt(ciphertext))
next_iv = ciphertext[-16:]
offset += aligned_crypt_len
crypt_tail_len = crypt_window - aligned_crypt_len
if crypt_tail_len:
out.extend(protected_data[offset : offset + crypt_tail_len])
offset += crypt_tail_len
if offset >= len(protected_data):
break
clear_len = min(skip_bytes, len(protected_data) - offset)
if clear_len:
out.extend(protected_data[offset : offset + clear_len])
offset += clear_len
else:
# Avoid spinning forever if a malformed tenc reports no skip phase.
remaining = len(protected_data) - offset
aligned_crypt_len = remaining & ~0xF
if aligned_crypt_len:
ciphertext = protected_data[offset : offset + aligned_crypt_len]
cipher = AES.new(key, AES.MODE_CBC, iv=next_iv)
out.extend(cipher.decrypt(ciphertext))
next_iv = ciphertext[-16:]
offset += aligned_crypt_len
if offset < len(protected_data):
out.extend(protected_data[offset:])
break
return bytes(out)
def _decrypt_cbcs_sample_with_pattern(
sample: SampleInfo, key: bytes, enc_info: EncryptionInfo
) -> bytes:
"""Decrypt CBCS pattern-encrypted samples, preserving skipped video blocks.
CBCS pattern state and AES-CBC state reset for each protected subsample
range. Carrying either one into the next range corrupts later video regions.
"""
iv = sample.iv if sample.iv else enc_info.constant_iv
if not sample.subsamples:
return _decrypt_cbcs_protected_range_with_pattern(
sample.data,
key,
iv,
enc_info.crypt_byte_block,
enc_info.skip_byte_block,
)
out = bytearray()
offset = 0
for clear_bytes, encrypted_bytes in sample.subsamples:
if clear_bytes:
out.extend(sample.data[offset : offset + clear_bytes])
offset += clear_bytes
if encrypted_bytes:
protected_data = sample.data[offset : offset + encrypted_bytes]
out.extend(
_decrypt_cbcs_protected_range_with_pattern(
protected_data,
key,
iv,
enc_info.crypt_byte_block,
enc_info.skip_byte_block,
)
)
offset += encrypted_bytes
if offset < len(sample.data):
out.extend(sample.data[offset:])
return bytes(out)
@dataclass
class SampleInfo:
"""Information about a single media sample."""
data: bytes
duration: int
desc_index: int
iv: bytes = b"" # Per-sample IV from senc (empty if constant IV)
subsamples: List[tuple] = field(
default_factory=list
) # [(clear_bytes, encrypted_bytes), ...]
composition_time_offset: int = 0
sample_flags: int = 0
is_sync: bool = True
size: int = 0
data_path: Optional[str] = None
data_offset: int = 0
@dataclass
class EncryptionInfo:
"""Encryption scheme info extracted from sinf/schm + sinf/schi/tenc."""
scheme_type: str = "cbcs" # 'cenc' or 'cbcs'
crypt_byte_block: int = 0 # CBCS pattern encrypted 16-byte blocks
skip_byte_block: int = 0 # CBCS pattern clear 16-byte blocks
per_sample_iv_size: int = 0 # 0, 8, or 16
constant_iv: bytes = b"" # Constant IV (when per_sample_iv_size == 0)
kid: bytes = b"" # Default Key ID (16 bytes)
@dataclass
class SongInfo:
"""Extracted song information from MP4 file."""
samples: List[SampleInfo] = field(default_factory=list)
moov_data: bytes = b""
ftyp_data: bytes = b""
encryption_info: Optional[EncryptionInfo] = None
handler_type: bytes = b"soun"
track_id: int = 1
@dataclass
class DecryptedTrack:
"""A decrypted media track and the source metadata needed to write it."""
input_path: str
track_info: SongInfo
data: bytes = b""
data_path: Optional[str] = None
data_size: int = 0
@dataclass
class DecryptedMedia:
"""Decrypted audio, optional video, and optional timed text tracks."""
audio: DecryptedTrack
video: Optional[DecryptedTrack] = None
captions: List[DecryptedTrack] = field(default_factory=list)
def _format_handler_type(handler_type: bytes) -> str:
return handler_type.decode("ascii", errors="replace")
def read_box_header(f: BinaryIO) -> tuple[int, str, int]:
"""Read MP4 box header, return (size, type, header_size)."""
header = f.read(8)
if len(header) < 8:
return 0, "", 0
size = struct.unpack(">I", header[:4])[0]
box_type = header[4:8].decode("ascii", errors="replace")
header_size = 8
if size == 1: # Extended size
ext_size = f.read(8)
size = struct.unpack(">Q", ext_size)[0]
header_size = 16
elif size == 0: # Box extends to end of file
pos = f.tell()
f.seek(0, 2) # Seek to end
size = f.tell() - pos + header_size
f.seek(pos)
return size, box_type, header_size
def find_box(data: bytes, box_path: List[str]) -> Optional[bytes]:
"""Find a box in MP4 data by path (e.g., ['moov', 'trak', 'mdia'])."""
f = io.BytesIO(data)
for target_type in box_path:
found = False
while True:
pos = f.tell()
size, box_type, header_size = read_box_header(f)
if size == 0:
break
if box_type == target_type:
f.seek(pos + header_size) # Skip past header
found = True
break
else:
f.seek(pos + size) # Skip this box
if not found:
return None
# Return remaining data from current position
return f.read()
def extract_song(
input_path: str,
handler_type: bytes = b"soun",
file_backed_samples: bool = False,
) -> SongInfo:
"""
Extract media samples and metadata from encrypted MP4 file.
This parses the MP4 structure to extract:
- ftyp and moov boxes (for reassembly)
- Individual audio samples from mdat boxes
- Sample durations and description indices from moof boxes
"""
song_info = SongInfo(handler_type=handler_type)
# First pass: collect all top-level boxes
boxes = []
if file_backed_samples:
file_size = os.path.getsize(input_path)
with open(input_path, "rb") as f:
offset = 0
while offset + 8 <= file_size:
f.seek(offset)
header = f.read(8)
if len(header) < 8:
break
size = struct.unpack(">I", header[:4])[0]
box_type = header[4:8].decode("ascii", errors="replace")
header_size = 8
if size == 0:
size = file_size - offset
elif size == 1:
ext_size = f.read(8)
if len(ext_size) < 8:
break
size = struct.unpack(">Q", ext_size)[0]
header_size = 16
if size < header_size or offset + size > file_size:
break
data = b""
if box_type in ("ftyp", "moov", "moof"):
f.seek(offset)
data = f.read(size)
boxes.append(
{
"offset": offset,
"size": size,
"type": box_type,
"header_size": header_size,
"data": data,
}
)
offset += size
else:
with open(input_path, "rb") as f:
raw_data = f.read()
offset = 0
while offset < len(raw_data) - 8:
size = struct.unpack(">I", raw_data[offset : offset + 4])[0]
box_type = raw_data[offset + 4 : offset + 8].decode(
"ascii", errors="replace"
)
header_size = 8
if size == 0:
break
if size == 1:
# Extended size
if offset + 16 > len(raw_data):
break
size = struct.unpack(">Q", raw_data[offset + 8 : offset + 16])[0]
header_size = 16
boxes.append(
{
"offset": offset,
"size": size,
"type": box_type,
"header_size": header_size,
"data": raw_data[offset : offset + size],
}
)
offset += size
# Extract ftyp and moov
for box in boxes:
if box["type"] == "ftyp":
song_info.ftyp_data = box["data"]
elif box["type"] == "moov":
song_info.moov_data = box["data"]
# Determine which track carries the requested media handler.
track_id = (
_extract_track_id(song_info.moov_data, handler_type, 0)
if song_info.moov_data
else 0
)
song_info.track_id = track_id
if track_id == 0:
return song_info
# Get default sample info from trex (inside moov/mvex)
trex_defaults = (
_extract_trex_defaults(song_info.moov_data, track_id)
if song_info.moov_data
else None
)
if trex_defaults:
default_sample_duration = trex_defaults["default_sample_duration"]
default_sample_size = trex_defaults["default_sample_size"]
default_sample_flags = trex_defaults["default_sample_flags"]
else:
# Fallback defaults. ALAC typically uses 4096 samples per frame,
# while AAC uses 1024. Default to 4096 if the track contains 'alac'.
is_alac = (
handler_type == b"soun"
and song_info.moov_data
and b"alac" in song_info.moov_data
)
default_sample_duration = (
4096 if is_alac else (1024 if handler_type == b"soun" else 0)
)
default_sample_size = 0
default_sample_flags = 0
# Extract encryption scheme info from moov (sinf/schm + sinf/schi/tenc)
if song_info.moov_data:
song_info.encryption_info = _extract_encryption_info(
song_info.moov_data, handler_type
)
# Parse moof/mdat pairs
moof_box = None
for box in boxes:
if box["type"] == "moof":
moof_box = box
elif box["type"] == "mdat" and moof_box is not None:
# Parse this moof/mdat pair
moof_data = moof_box["data"]
if file_backed_samples:
mdat_data = b""
mdat_data_size = box["size"] - box["header_size"]
else:
mdat_data = box["data"][box["header_size"] :] # Skip mdat header
mdat_data_size = len(mdat_data)
# Parse moof for tfhd (sample description index, defaults) and trun (entries)
_iv_size = (
song_info.encryption_info.per_sample_iv_size
if song_info.encryption_info
else 0
)
samples_from_pair = _parse_moof_mdat(
moof_data,
mdat_data,
default_sample_duration,
default_sample_size,
default_sample_flags,
audio_track_id=track_id,
moof_offset=moof_box["offset"],
mdat_data_offset=box["offset"] + box["header_size"],
per_sample_iv_size=_iv_size,
mdat_data_size=mdat_data_size,
mdat_source_path=input_path if file_backed_samples else None,
)
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 = (
handler_type == b"soun"
and song_info.moov_data
and (b"alac" in song_info.moov_data or b"ALAC" in song_info.moov_data)
)
if is_alac:
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
return song_info
def _parse_moof_mdat(
moof_data: bytes,
mdat_data: bytes,
default_sample_duration: int,
default_sample_size: int,
default_sample_flags: int = 0,
audio_track_id: int = 1,
moof_offset: int = 0,
mdat_data_offset: int = 0,
per_sample_iv_size: int = 0,
mdat_data_size: Optional[int] = None,
mdat_source_path: Optional[str] = None,
) -> List[SampleInfo]:
"""Parse a moof box and extract samples from corresponding mdat.
Handles multi-track fragmented MP4s by only extracting samples from
the traf matching the audio track ID.
Args:
audio_track_id: Track ID of the audio track to extract.
moof_offset: Absolute file offset of the moof box.
mdat_data_offset: Absolute file offset of the mdat content (after header).
per_sample_iv_size: IV size per sample from tenc (0, 8, or 16).
"""
samples = []
available_mdat_bytes = len(mdat_data) if mdat_data_size is None else mdat_data_size
# Simple box parsing inside moof
offset = 8 # Skip moof header
while offset < len(moof_data) - 8:
size = struct.unpack(">I", moof_data[offset : offset + 4])[0]
box_type = moof_data[offset + 4 : offset + 8].decode("ascii", errors="replace")
if size == 0 or offset + size > len(moof_data):
break
if box_type == "traf":
# Parse inside traf with per-traf state
tfhd_info = {
"track_id": 0,
"desc_index": 0,
"default_duration": default_sample_duration,
"default_size": default_sample_size,
"default_sample_flags": default_sample_flags,
"flags": 0,
"base_data_offset": None,
}
# Each 'trun' has its own optional data_offset; concatenating entry lists
# and keeping only the first data_offset breaks multi-trun fragments.
trun_runs: List[tuple] = []
raw_senc_data: bytes | None = None
traf_offset = offset + 8
traf_end = offset + size
while traf_offset < traf_end - 8:
inner_size = struct.unpack(
">I", moof_data[traf_offset : traf_offset + 4]
)[0]
inner_type = moof_data[traf_offset + 4 : traf_offset + 8].decode(
"ascii", errors="replace"
)
if inner_size == 0:
break
if inner_type == "tfhd":
_parse_tfhd(
moof_data[traf_offset + 8 : traf_offset + inner_size], tfhd_info
)
elif inner_type == "trun":
entries, data_off = _parse_trun(
moof_data[traf_offset + 8 : traf_offset + inner_size], tfhd_info
)
trun_runs.append((entries, data_off))
elif inner_type == "senc":
raw_senc_data = moof_data[
traf_offset + 8 : traf_offset + inner_size
]
traf_offset += inner_size
# Only process this traf if it matches the audio track
if tfhd_info["track_id"] != audio_track_id:
offset += size
continue
base = tfhd_info.get("base_data_offset")
if base is None:
base = moof_offset # Default: first byte of containing moof
desc_index = tfhd_info["desc_index"]
if desc_index > 0:
desc_index -= 1 # Convert to 0-indexed
sample_sizes = [
entry.get("size", tfhd_info["default_size"])
for trun_entries, _ in trun_runs
for entry in trun_entries
]
senc_entries = (
_parse_senc_for_sample_sizes(
raw_senc_data,
sample_sizes,
per_sample_iv_size,
)
if raw_senc_data is not None
else []
)
mdat_pos: Optional[int] = None
sample_index_in_traf = 0
for trun_entries, trun_data_off in trun_runs:
if trun_data_off is not None:
mdat_pos = base + trun_data_off - mdat_data_offset
elif mdat_pos is None:
# Legacy behaviour: first trun without data_offset starts at mdat payload 0.
mdat_pos = 0
mdat_pos = max(0, mdat_pos)
mdat_read_offset = mdat_pos
for entry in trun_entries:
sample_size = entry.get("size", tfhd_info["default_size"])
sample_duration = entry.get(
"duration", tfhd_info["default_duration"]
)
sample_flags = entry.get(
"sample_flags", tfhd_info["default_sample_flags"]
)
if (
sample_size > 0
and mdat_read_offset + sample_size <= available_mdat_bytes
):
sample_iv = b""
sample_subsamples: List[tuple] = []
if sample_index_in_traf < len(senc_entries):
sample_iv = senc_entries[sample_index_in_traf]["iv"]
sample_subsamples = senc_entries[sample_index_in_traf][
"subsamples"
]
if mdat_source_path:
sample_data = b""
sample_data_offset = mdat_data_offset + mdat_read_offset
else:
sample_data = mdat_data[
mdat_read_offset : mdat_read_offset + sample_size
]
sample_data_offset = 0
sample = SampleInfo(
data=sample_data,
duration=sample_duration,
desc_index=desc_index,
iv=sample_iv,
subsamples=sample_subsamples,
composition_time_offset=entry.get(
"composition_time_offset", 0
),
sample_flags=sample_flags,
is_sync=not bool(sample_flags & 0x10000),
size=sample_size,
data_path=mdat_source_path,
data_offset=sample_data_offset,
)
samples.append(sample)
mdat_read_offset += sample_size
# One senc row per trun sample entry (even if size 0 or read fails)
sample_index_in_traf += 1
mdat_pos = mdat_read_offset
offset += size
return samples
def _parse_tfhd(data: bytes, tfhd_info: dict):
"""Parse track fragment header box (FullBox: version + flags + content)."""
if len(data) < 8: # version(1) + flags(3) + track_id(4)
return
# FullBox: version(1) + flags(3)
version = data[0]
flags = struct.unpack(">I", b"\x00" + data[1:4])[0]
tfhd_info["flags"] = flags
# After version+flags is track_id(4)
tfhd_info["track_id"] = struct.unpack(">I", data[4:8])[0]
offset = 4 + 4 # version+flags + track_id
if flags & 0x01 and offset + 8 <= len(data): # base_data_offset
tfhd_info["base_data_offset"] = struct.unpack(">Q", data[offset : offset + 8])[
0
]
offset += 8
if flags & 0x02 and offset + 4 <= len(data): # sample_description_index
tfhd_info["desc_index"] = struct.unpack(">I", data[offset : offset + 4])[0]
offset += 4
if flags & 0x08 and offset + 4 <= len(data): # default_sample_duration
tfhd_info["default_duration"] = struct.unpack(">I", data[offset : offset + 4])[
0
]
offset += 4
if flags & 0x10 and offset + 4 <= len(data): # default_sample_size
tfhd_info["default_size"] = struct.unpack(">I", data[offset : offset + 4])[0]
offset += 4
if flags & 0x20 and offset + 4 <= len(data): # default_sample_flags
tfhd_info["default_sample_flags"] = struct.unpack(
">I", data[offset : offset + 4]
)[0]
def _parse_trun(data: bytes, tfhd_info: dict) -> tuple[List[dict], Optional[int]]:
"""Parse track run box to get sample entries and data_offset.
Returns:
Tuple of (entries, data_offset). data_offset is the signed offset from
base_data_offset to the first sample's data, or None if not present.
"""
entries = []
data_offset_value = None
if len(data) < 8: # version(1) + flags(3) + sample_count(4)
return entries, data_offset_value
# FullBox: version(1) + flags(3)
version = data[0]
flags = struct.unpack(">I", b"\x00" + data[1:4])[0]
sample_count = struct.unpack(">I", data[4:8])[0]
# Start reading entries after header fields
offset = 8 # version+flags(4) + sample_count(4)
if flags & 0x01: # data_offset present
data_offset_value = struct.unpack(">i", data[offset : offset + 4])[0]
offset += 4
first_sample_flags = None
if flags & 0x04: # first_sample_flags present
first_sample_flags = struct.unpack(">I", data[offset : offset + 4])[0]
offset += 4
for sample_index in range(sample_count):
entry = {}
if flags & 0x100 and offset + 4 <= len(data): # sample_duration
entry["duration"] = struct.unpack(">I", data[offset : offset + 4])[0]
offset += 4
if flags & 0x200 and offset + 4 <= len(data): # sample_size
entry["size"] = struct.unpack(">I", data[offset : offset + 4])[0]
offset += 4
if flags & 0x400 and offset + 4 <= len(data): # sample_flags
entry["sample_flags"] = struct.unpack(">I", data[offset : offset + 4])[0]
offset += 4
elif sample_index == 0 and first_sample_flags is not None:
entry["sample_flags"] = first_sample_flags
if flags & 0x800 and offset + 4 <= len(data): # sample_composition_time_offset
if version == 1:
entry["composition_time_offset"] = struct.unpack(
">i", data[offset : offset + 4]
)[0]
else:
entry["composition_time_offset"] = struct.unpack(
">I", data[offset : offset + 4]
)[0]
offset += 4
entries.append(entry)
return entries, data_offset_value
def _parse_senc(data: bytes, per_sample_iv_size: int) -> List[dict]:
"""Parse Sample Encryption Box (senc) content (after box header).
Returns a list of dicts, one per sample:
{"iv": bytes, "subsamples": [(clear_bytes, encrypted_bytes), ...]}
The data starts after the 8-byte box header (size+type) but includes
the FullBox header (version 1 byte + flags 3 bytes).
per_sample_iv_size can be 0 (cbcs with constant IV from tenc) — in that case
there are 0 IV bytes per sample but subsample info may still be present.
"""
if len(data) < 8:
return []
version = data[0]
flags = struct.unpack(">I", b"\x00" + data[1:4])[0]
sample_count = struct.unpack(">I", data[4:8])[0]
entries: List[dict] = []
offset = 8
for _ in range(sample_count):
# Read per-sample IV (0 bytes when per_sample_iv_size == 0)
iv = b""
if per_sample_iv_size > 0:
if offset + per_sample_iv_size > len(data):
break
iv = data[offset : offset + per_sample_iv_size]
offset += per_sample_iv_size
subsamples = []
if flags & 0x02:
# Subsample encryption info present
if offset + 2 > len(data):
break
subsample_count = struct.unpack(">H", data[offset : offset + 2])[0]
offset += 2
for _ in range(subsample_count):
if offset + 6 > len(data):
break
clear_bytes = struct.unpack(">H", data[offset : offset + 2])[0]
encrypted_bytes = struct.unpack(">I", data[offset + 2 : offset + 6])[0]
subsamples.append((clear_bytes, encrypted_bytes))
offset += 6
entries.append({"iv": iv, "subsamples": subsamples})
return entries
def _parse_senc_strict(
data: bytes,
per_sample_iv_size: int,
sample_sizes: List[int],
) -> tuple[List[dict], bool, str]:
"""Parse senc and reject rows whose subsamples cannot fit their samples."""
if len(data) < 8:
return [], False, "truncated_header"
flags = struct.unpack(">I", b"\x00" + data[1:4])[0]
sample_count = struct.unpack(">I", data[4:8])[0]
if sample_count > len(sample_sizes):
return [], False, f"sample_count_gt_trun:{sample_count}>{len(sample_sizes)}"
entries: List[dict] = []
offset = 8
for sample_index in range(sample_count):
iv = b""
if per_sample_iv_size > 0:
if offset + per_sample_iv_size > len(data):
return [], False, f"truncated_iv:index={sample_index}"
iv = data[offset : offset + per_sample_iv_size]
offset += per_sample_iv_size
subsamples = []
if flags & 0x02:
if offset + 2 > len(data):
return [], False, f"truncated_subsample_count:index={sample_index}"
subsample_count = struct.unpack(">H", data[offset : offset + 2])[0]
offset += 2
sample_size = sample_sizes[sample_index]
total_bytes = 0
for _ in range(subsample_count):
if offset + 6 > len(data):
return [], False, f"truncated_subsample:index={sample_index}"
clear_bytes = struct.unpack(">H", data[offset : offset + 2])[0]
encrypted_bytes = struct.unpack(">I", data[offset + 2 : offset + 6])[0]
total_bytes += clear_bytes + encrypted_bytes
if total_bytes > sample_size:
return (
[],
False,
"subsample_total_gt_sample:"
f"index={sample_index},total={total_bytes},sample={sample_size},"
f"clear={clear_bytes},enc={encrypted_bytes},count={subsample_count}",
)
subsamples.append((clear_bytes, encrypted_bytes))
offset += 6
entries.append({"iv": iv, "subsamples": subsamples})
return entries, True, "ok"
def _parse_senc_for_sample_sizes(
data: bytes,
sample_sizes: List[int],
preferred_iv_size: int,
) -> List[dict]:
"""Parse senc using the preferred IV size, falling back when it is impossible."""
iv_size_candidates = []
for iv_size in (preferred_iv_size, 8, 16, 0):
if iv_size not in iv_size_candidates:
iv_size_candidates.append(iv_size)
for iv_size in iv_size_candidates:
entries, valid, reason = _parse_senc_strict(data, iv_size, sample_sizes)
if valid:
return entries
logger.warning(
"senc_parse_failed_validation",
preferred_iv_size=preferred_iv_size,
data_len=len(data),
sample_count=len(sample_sizes),
last_reason=reason,
)
return []
async def decrypt_samples(
wrapper_api: WrapperApi,
track_id: str,
fairplay_key: str,
samples: List[SampleInfo],
encryption_info: EncryptionInfo,
encryption_info_per_desc: Optional[dict] = None,
*,
use_single_content_key: bool = False,
progress_callback=None,
decrypted_data_path: Optional[str] = None,
) -> bytes:
"""
Send track-key samples to wrapper-v2 (HTTP POST /decrypt) for CBCS
decryption and decrypt default prefetch-key samples locally.
Ciphertext is sent in batches of up to :data:`WRAPPER_DECRYPT_BATCH_SIZE` MP4 samples
per request (same ``adam_id`` and ``uri``). Literal or tail-only samples are applied
between batches so output order matches the input.
Subsample layout matches ``decrypt_samples_hex`` (concat encrypted runs, decrypt
``len & ~0xF``, reassemble).
Requires an authenticated wrapper-v2 session (POST /login or restored session).
Args:
wrapper_api: Authenticated :class:`~gamdl.api.wrapper.WrapperApi` client
use_single_content_key: When ``False`` (default), description 0 uses the built-in
prefetch key locally and description 1+ uses ``fairplay_key`` via the wrapper.
When ``True``, every sample uses ``fairplay_key`` through the wrapper only.
progress_callback: Optional callback(current_sample, total_samples, bytes_processed, speed)
"""
keys = [fairplay_key] if use_single_content_key else [PREFETCH_KEY, fairplay_key]
decrypted_data = bytearray()
decrypted_output = open(decrypted_data_path, "wb") if decrypted_data_path else None
decrypted_bytes = 0
last_desc_index: int = 255
total_samples = len(samples)
bytes_processed = 0
start_time = time.time()
last_progress_time = start_time
segment_adam: Optional[str] = None
segment_uri: Optional[str] = None
# Pending (sample, aligned_cbc, tail) for one SKD segment, flushed in batches.
crypto_batch: List[tuple] = []
def emit(data: bytes) -> None:
nonlocal decrypted_bytes
if decrypted_output:
decrypted_output.write(data)
else:
decrypted_data.extend(data)
decrypted_bytes += len(data)
async def flush_crypto_batch() -> None:
if not crypto_batch:
return
if segment_adam is None or segment_uri is None:
raise IOError("wrapper-v2: internal error (segment without adam/uri)")
chunks = [t[1] for t in crypto_batch]
tails = [t[2] for t in crypto_batch]
sources = [t[0] for t in crypto_batch]
plains = await wrapper_api.decrypt(segment_adam, segment_uri, chunks)
if len(plains) != len(chunks):
raise IOError("wrapper-v2: plaintext batch count mismatch")
for s, plain, tail in zip(sources, plains, tails):
emit(_reassemble_cbcs_sample(s, plain, tail))
crypto_batch.clear()
try:
for i, original_sample in enumerate(samples):
sample = (
_with_sample_data(original_sample, _sample_data(original_sample))
if not original_sample.data and original_sample.data_path
else original_sample
)
if last_desc_index != sample.desc_index:
await flush_crypto_batch()
if use_single_content_key:
segment_adam = track_id
segment_uri = fairplay_key
else:
key_uri = keys[min(sample.desc_index, len(keys) - 1)]
segment_adam = "0" if key_uri == PREFETCH_KEY else track_id
segment_uri = key_uri
last_desc_index = sample.desc_index
if not use_single_content_key and segment_adam == "0":
await flush_crypto_batch()
enc_info = (
encryption_info_per_desc.get(sample.desc_index)
if encryption_info_per_desc
and sample.desc_index in encryption_info_per_desc
else encryption_info
)
emit(
_decrypt_cbcs_sample_with_key(
sample, DEFAULT_SONG_DECRYPTION_KEY, enc_info
)
)
bytes_processed += _sample_size(sample)
now = time.time()
if progress_callback and (
i % 50 == 0
or now - last_progress_time > 0.5
or i == total_samples - 1
):
elapsed = now - start_time
speed = bytes_processed / elapsed if elapsed > 0 else 0
progress_callback(i + 1, total_samples, bytes_processed, speed)
last_progress_time = now
continue
enc_info = (
encryption_info_per_desc.get(sample.desc_index)
if encryption_info_per_desc
and sample.desc_index in encryption_info_per_desc
else encryption_info
)
if enc_info.crypt_byte_block and enc_info.skip_byte_block:
raise IOError(
"wrapper-v2 pattern CBCS decrypt is not supported by gamdl's "
"batch decrypt path; use hex-key decrypt for this track"
)
parts = _cbcs_ciphertext_for_sample(sample)
if parts is None:
await flush_crypto_batch()
emit(sample.data)
else:
aligned, tail = parts
if len(aligned) == 0:
await flush_crypto_batch()
emit(_reassemble_cbcs_sample(sample, b"", tail))
else:
crypto_batch.append((sample, aligned, tail))
if len(crypto_batch) >= WRAPPER_DECRYPT_BATCH_SIZE:
await flush_crypto_batch()
bytes_processed += _sample_size(sample)
now = time.time()
if progress_callback and (
i % 50 == 0 or now - last_progress_time > 0.5 or i == total_samples - 1
):
elapsed = now - start_time
speed = bytes_processed / elapsed if elapsed > 0 else 0
progress_callback(i + 1, total_samples, bytes_processed, speed)
last_progress_time = now
await flush_crypto_batch()
finally:
if decrypted_output:
decrypted_output.close()
logger.debug(f"Decrypted {len(samples)} samples ({decrypted_bytes} bytes)")
return bytes(decrypted_data)
def write_decrypted_m4a(
output_path: str,
song_info: SongInfo,
decrypted_data: bytes,
original_path: str = None,
decrypted_data_path: str | None = None,
) -> None:
"""
Write decrypted MP4 file as non-fragmented MP4.
Creates a new MP4 from scratch with:
- ftyp box (M4A compatible)
- moov box with proper sample tables (stts, stsc, stsz, stco)
- Single mdat box with all decrypted samples
This matches the output format of Go's amdecrypt which is required
for ALAC playback.
"""
# Extract original boxes for faithful reproduction
# Note: _extract_stsd_content automatically cleans encryption metadata
stsd_content = None
orig_mvhd = None
orig_tkhd = None
orig_mdhd = None
orig_smhd = None
orig_dinf = None
# 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
preferred_desc_index = _preferred_sample_description_index(song_info.samples)
if song_info.moov_data:
orig_data = song_info.ftyp_data + song_info.moov_data
elif original_path:
with open(original_path, "rb") as f:
orig_data = f.read()
else:
orig_data = None
if orig_data:
stsd_content = _extract_stsd_content(orig_data, preferred_desc_index)
# 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")
if moov_idx >= 4:
moov_size = struct.unpack(">I", orig_data[moov_idx - 4 : moov_idx])[0]
moov_data = orig_data[moov_idx - 4 : moov_idx - 4 + moov_size]
orig_mvhd = _find_child_box(moov_data, b"mvhd")
audio_trak = _find_audio_trak(moov_data)
if audio_trak:
orig_tkhd = _find_child_box(audio_trak, b"tkhd")
mdia = _find_child_box(audio_trak, b"mdia")
if mdia:
orig_mdhd = _find_child_box(mdia, b"mdhd")
orig_hdlr = _find_child_box(mdia, b"hdlr")
minf = _find_child_box(mdia, b"minf")
if minf:
orig_smhd = _find_child_box(minf, b"smhd")
orig_dinf = _find_child_box(minf, b"dinf")
with open(output_path, "wb") as f:
# Write ftyp
_write_ftyp(f)
# Calculate total duration
total_duration = sum(s.duration for s in song_info.samples)
# Write moov with sample tables
_write_moov(
f,
song_info.samples,
total_duration,
timescale,
stsd_content,
decrypted_data,
orig_mvhd=orig_mvhd,
orig_tkhd=orig_tkhd,
orig_mdhd=orig_mdhd,
orig_hdlr=orig_hdlr,
orig_smhd=orig_smhd,
orig_dinf=orig_dinf,
)
# Write mdat
if decrypted_data_path:
_write_mdat_from_sources(
f,
[(decrypted_data_path, 0, os.path.getsize(decrypted_data_path))],
)
else:
_write_mdat(f, decrypted_data)
logger.debug(f"Wrote decrypted file to {output_path}")
def write_decrypted_mp4_track(
output_path: str,
track_info: SongInfo,
decrypted_data: bytes,
original_path: str = None,
decrypted_data_path: str | None = None,
) -> None:
"""Write one decrypted audio or video track as a flat MP4 file."""
stsd_content = None
orig_mvhd = None
orig_tkhd = None
orig_mdhd = None
orig_hdlr = None
orig_smhd = None
orig_vmhd = None
orig_nmhd = None
orig_dinf = None
timescale = 44100 if track_info.handler_type == b"soun" else 90000
preferred_desc_index = _preferred_sample_description_index(track_info.samples)
if track_info.moov_data:
orig_data = track_info.ftyp_data + track_info.moov_data
elif original_path:
with open(original_path, "rb") as f:
orig_data = f.read()
else:
orig_data = None
if orig_data:
stsd_content = _extract_stsd_content(
orig_data,
preferred_desc_index,
track_info.handler_type,
)
if track_info.handler_type == b"soun":
timescale = _extract_sample_rate_from_stsd(
stsd_content
) or _extract_track_timescale(orig_data, track_info.handler_type, timescale)
else:
timescale = _extract_track_timescale(
orig_data, track_info.handler_type, timescale
)
moov_idx = orig_data.find(b"moov")
if moov_idx >= 4:
moov_size = struct.unpack(">I", orig_data[moov_idx - 4 : moov_idx])[0]
moov_data = orig_data[moov_idx - 4 : moov_idx - 4 + moov_size]
orig_mvhd = _find_child_box(moov_data, b"mvhd")
trak = _find_track_by_handler(moov_data, track_info.handler_type)
if trak:
orig_tkhd = _find_child_box(trak, b"tkhd")
mdia = _find_child_box(trak, b"mdia")
if mdia:
orig_mdhd = _find_child_box(mdia, b"mdhd")
orig_hdlr = _find_child_box(mdia, b"hdlr")
minf = _find_child_box(mdia, b"minf")
if minf:
orig_smhd = _find_child_box(minf, b"smhd")
orig_vmhd = _find_child_box(minf, b"vmhd")
orig_nmhd = _find_child_box(minf, b"nmhd")
orig_dinf = _find_child_box(minf, b"dinf")
with open(output_path, "wb") as f:
if track_info.handler_type == b"soun":
_write_ftyp(f)
else:
_write_ftyp_mp4(f)
total_duration = sum(s.duration for s in track_info.samples)
_write_moov(
f,
track_info.samples,
total_duration,
timescale,
stsd_content,
decrypted_data,
orig_mvhd=orig_mvhd,
orig_tkhd=orig_tkhd,
orig_mdhd=orig_mdhd,
orig_hdlr=orig_hdlr,
orig_smhd=orig_smhd,
orig_vmhd=orig_vmhd,
orig_nmhd=orig_nmhd,
orig_dinf=orig_dinf,
handler_type=track_info.handler_type,
)
if decrypted_data_path:
_write_mdat_from_sources(
f,
[(decrypted_data_path, 0, os.path.getsize(decrypted_data_path))],
)
else:
_write_mdat(f, decrypted_data)
logger.debug(f"Wrote decrypted track file to {output_path}")
def _build_decrypted_track_moov(
track_info: SongInfo,
original_path: str | None = None,
) -> bytes:
"""Build a single-track moov box for a decrypted track without writing mdat."""
stsd_content = None
orig_mvhd = None
orig_tkhd = None
orig_mdhd = None
orig_hdlr = None
orig_smhd = None
orig_vmhd = None
orig_nmhd = None
orig_dinf = None
timescale = 44100 if track_info.handler_type == b"soun" else 90000
preferred_desc_index = _preferred_sample_description_index(track_info.samples)
if track_info.moov_data:
orig_data = track_info.ftyp_data + track_info.moov_data
elif original_path:
with open(original_path, "rb") as f:
orig_data = f.read()
else:
orig_data = None
if orig_data:
stsd_content = _extract_stsd_content(
orig_data,
preferred_desc_index,
track_info.handler_type,
)
if track_info.handler_type == b"soun":
timescale = _extract_sample_rate_from_stsd(
stsd_content
) or _extract_track_timescale(orig_data, track_info.handler_type, timescale)
else:
timescale = _extract_track_timescale(
orig_data, track_info.handler_type, timescale
)
moov_idx = orig_data.find(b"moov")
if moov_idx >= 4:
moov_size = struct.unpack(">I", orig_data[moov_idx - 4 : moov_idx])[0]
moov_data = orig_data[moov_idx - 4 : moov_idx - 4 + moov_size]
orig_mvhd = _find_child_box(moov_data, b"mvhd")
trak = _find_track_by_handler(moov_data, track_info.handler_type)
if trak:
orig_tkhd = _find_child_box(trak, b"tkhd")
mdia = _find_child_box(trak, b"mdia")
if mdia:
orig_mdhd = _find_child_box(mdia, b"mdhd")
orig_hdlr = _find_child_box(mdia, b"hdlr")
minf = _find_child_box(mdia, b"minf")
if minf:
orig_smhd = _find_child_box(minf, b"smhd")
orig_vmhd = _find_child_box(minf, b"vmhd")
orig_nmhd = _find_child_box(minf, b"nmhd")
orig_dinf = _find_child_box(minf, b"dinf")
buf = io.BytesIO()
total_duration = sum(s.duration for s in track_info.samples)
_write_moov(
buf,
track_info.samples,
total_duration,
timescale,
stsd_content,
b"",
orig_mvhd=orig_mvhd,
orig_tkhd=orig_tkhd,
orig_mdhd=orig_mdhd,
orig_hdlr=orig_hdlr,
orig_smhd=orig_smhd,
orig_vmhd=orig_vmhd,
orig_nmhd=orig_nmhd,
orig_dinf=orig_dinf,
handler_type=track_info.handler_type,
)
return buf.getvalue()
def _decrypted_track_payload_source(track: DecryptedTrack):
"""Return an mdat source tuple for a decrypted track."""
if track.data_path:
size = track.data_size or os.path.getsize(track.data_path)
return (track.data_path, 0, size)
return (None, 0, len(track.data), track.data)
def _sample_payload_bytes(samples: List[SampleInfo]) -> bytes:
"""Materialize only the payload bytes for the given samples."""
return b"".join(_sample_data(sample) for sample in samples)
def mux_decrypted_media_direct(
decrypted_media: DecryptedMedia,
output_path: str,
m4v_brand: bool = False,
) -> None:
"""Mux decrypted media directly to the final file without temp MP4 tracks."""
if decrypted_media.video is None:
raise ValueError("direct AV mux requires a video track")
video_moov = _build_decrypted_track_moov(decrypted_media.video.track_info)
audio_moov = _build_decrypted_track_moov(decrypted_media.audio.track_info)
extra_track_files = [
(
_build_decrypted_track_moov(caption.track_info),
_decrypted_track_payload_source(caption),
)
for caption in decrypted_media.captions
]
mvhd = _find_child_box(video_moov, b"mvhd")
video_trak = _find_track_by_handler(video_moov, b"vide")
audio_trak = _find_track_by_handler(audio_moov, b"soun")
if not mvhd or not video_trak or not audio_trak:
raise IOError("mux: missing required audio/video track metadata")
movie_timescale = _extract_mvhd_timescale(mvhd)
audio_trak = _patch_trak_track_id(audio_trak, 2)
audio_trak = _patch_trak_duration_to_movie_timescale(audio_trak, movie_timescale)
extra_traks = []
for index, (extra_moov, extra_source) in enumerate(extra_track_files, start=3):
extra_trak = _find_first_trak(extra_moov)
if extra_trak:
extra_trak = _patch_trak_track_id(extra_trak, index)
extra_trak = _patch_trak_duration_to_movie_timescale(
extra_trak, movie_timescale
)
extra_traks.append((extra_trak, extra_source))
ftyp = _build_ftyp_m4v_bytes() if m4v_brand else _build_ftyp_mp4_bytes()
moov = _build_muxed_moov(
mvhd, [video_trak, audio_trak] + [t for t, _ in extra_traks]
)
mdat_data_offset = len(ftyp) + len(moov) + 8
video_source = _decrypted_track_payload_source(decrypted_media.video)
audio_source = _decrypted_track_payload_source(decrypted_media.audio)
video_trak = _patch_first_chunk_offset(video_trak, mdat_data_offset)
next_mdat_offset = mdat_data_offset + video_source[2]
audio_trak = _patch_first_chunk_offset(audio_trak, next_mdat_offset)
next_mdat_offset += audio_source[2]
patched_extra_traks = []
for extra_trak, extra_source in extra_traks:
patched_extra_traks.append(
_patch_first_chunk_offset(extra_trak, next_mdat_offset)
)
next_mdat_offset += extra_source[2]
moov = _build_muxed_moov(mvhd, [video_trak, audio_trak] + patched_extra_traks)
with open(output_path, "wb") as f:
f.write(ftyp)
f.write(moov)
_write_mdat_from_sources(
f,
[video_source, audio_source] + [source for _, source in extra_traks],
)
logger.debug(f"Muxed decrypted AV file to {output_path}")
def mux_decrypted_mp4_tracks(
input_path_video: str,
input_path_audio: str,
output_path: str,
input_path_extra_tracks: Optional[List[str]] = None,
m4v_brand: bool = False,
) -> None:
"""Mux one flat video MP4 and one flat audio MP4 into a single MP4/M4V."""
with open(input_path_video, "rb") as f:
video_data = f.read()
with open(input_path_audio, "rb") as f:
audio_data = f.read()
input_path_extra_tracks = input_path_extra_tracks or []
extra_track_files = []
for input_path_extra_track in input_path_extra_tracks:
with open(input_path_extra_track, "rb") as f:
extra_track_data = f.read()
extra_track_files.append(
(
_extract_top_level_box(extra_track_data, b"moov"),
_extract_mdat_payload(extra_track_data),
)
)
video_moov = _extract_top_level_box(video_data, b"moov")
audio_moov = _extract_top_level_box(audio_data, b"moov")
video_mdat_payload = _extract_mdat_payload(video_data)
audio_mdat_payload = _extract_mdat_payload(audio_data)
if not video_moov or not audio_moov:
raise IOError("mux: missing moov box in decrypted track file")
mvhd = _find_child_box(video_moov, b"mvhd")
video_trak = _find_track_by_handler(video_moov, b"vide")
audio_trak = _find_track_by_handler(audio_moov, b"soun")
if not mvhd or not video_trak or not audio_trak:
raise IOError("mux: missing required audio/video track metadata")
movie_timescale = _extract_mvhd_timescale(mvhd)
audio_trak = _patch_trak_track_id(audio_trak, 2)
audio_trak = _patch_trak_duration_to_movie_timescale(audio_trak, movie_timescale)
extra_traks = []
for index, (extra_moov, extra_mdat_payload) in enumerate(
extra_track_files, start=3
):
if not extra_moov:
continue
extra_trak = _find_first_trak(extra_moov)
if extra_trak:
extra_trak = _patch_trak_track_id(extra_trak, index)
extra_trak = _patch_trak_duration_to_movie_timescale(
extra_trak, movie_timescale
)
extra_traks.append((extra_trak, extra_mdat_payload))
ftyp = _build_ftyp_m4v_bytes() if m4v_brand else _build_ftyp_mp4_bytes()
moov = _build_muxed_moov(
mvhd, [video_trak, audio_trak] + [t for t, _ in extra_traks]
)
mdat_data_offset = len(ftyp) + len(moov) + 8
video_trak = _patch_first_chunk_offset(video_trak, mdat_data_offset)
next_mdat_offset = mdat_data_offset + len(video_mdat_payload)
audio_trak = _patch_first_chunk_offset(audio_trak, next_mdat_offset)
next_mdat_offset += len(audio_mdat_payload)
patched_extra_traks = []
for extra_trak, extra_mdat_payload in extra_traks:
patched_extra_traks.append(
_patch_first_chunk_offset(extra_trak, next_mdat_offset)
)
next_mdat_offset += len(extra_mdat_payload)
moov = _build_muxed_moov(mvhd, [video_trak, audio_trak] + patched_extra_traks)
with open(output_path, "wb") as f:
f.write(ftyp)
f.write(moov)
_write_mdat(
f,
video_mdat_payload
+ audio_mdat_payload
+ b"".join(payload for _, payload in extra_traks),
)
logger.debug(f"Muxed decrypted AV file to {output_path}")
def _encryption_info_for_hex_decrypt(
track_info: SongInfo,
*,
use_cenc: bool,
) -> EncryptionInfo:
"""Resolve encryption scheme for hex decrypt (moov metadata or defaults)."""
base = track_info.encryption_info or EncryptionInfo(scheme_type="cbcs")
if not use_cenc:
return base
return EncryptionInfo(
scheme_type="cenc",
crypt_byte_block=0,
skip_byte_block=0,
per_sample_iv_size=base.per_sample_iv_size,
constant_iv=base.constant_iv,
kid=base.kid,
)
async def _decrypt_track_hex(
input_path: str,
decryption_key: str,
handler_type: bytes,
*,
use_cenc: bool = False,
use_single_content_key: bool = False,
file_backed: bool = False,
) -> DecryptedTrack:
"""Decrypt one audio/video/text track with a raw AES key.
``use_single_content_key``:
``False`` (default for catalog audio): description 0 uses the built-in
prefetch AES key, description 1+ uses ``decryption_key`` (Apple CBCS layout).
``True`` (web AAC, muxed MV audio): every sample description uses
``decryption_key``.
"""
track_info = await asyncio.to_thread(
extract_song, input_path, handler_type, file_backed
)
track_key = bytes.fromhex(decryption_key)
if use_single_content_key:
keys = {sample.desc_index: track_key for sample in track_info.samples}
elif handler_type == b"soun":
keys = {0: DEFAULT_SONG_DECRYPTION_KEY, 1: track_key}
else:
keys = {sample.desc_index: track_key for sample in track_info.samples}
enc_info = _encryption_info_for_hex_decrypt(track_info, use_cenc=use_cenc)
enc_info_per_desc = None
if track_info.moov_data:
enc_info_per_desc = await asyncio.to_thread(
_extract_encryption_info_per_stsd,
track_info.moov_data,
handler_type,
)
if file_backed:
temp_file = tempfile.NamedTemporaryFile(
prefix="gamdl_decrypted_", suffix=".bin", delete=False
)
temp_path = temp_file.name
temp_file.close()
try:
data_size = decrypt_samples_hex_to_file(
track_info.samples,
keys,
enc_info,
temp_path,
enc_info_per_desc,
release_sample_data=True,
)
except Exception:
try:
os.remove(temp_path)
except FileNotFoundError:
pass
raise
return DecryptedTrack(
input_path,
track_info,
data_path=temp_path,
data_size=data_size,
)
decrypted_data = decrypt_samples_hex(
track_info.samples,
keys,
enc_info,
enc_info_per_desc,
)
return DecryptedTrack(
input_path,
track_info,
decrypted_data,
data_size=len(decrypted_data),
)
async def decrypt_file_hex(
decryption_key_audio: str,
input_audio_path: str,
decryption_key_video: str | None = None,
input_video_path: str | None = None,
*,
use_cenc: bool = False,
use_single_content_key: bool = False,
) -> DecryptedMedia:
"""Decrypt audio and optional video with raw AES hex keys."""
audio = await _decrypt_track_hex(
input_audio_path,
decryption_key_audio,
b"soun",
use_cenc=use_cenc,
use_single_content_key=use_single_content_key or input_video_path is not None,
file_backed=input_video_path is not None,
)
if input_video_path is None:
return DecryptedMedia(audio=audio)
video_key = decryption_key_video or decryption_key_audio
video_task = asyncio.create_task(
_decrypt_track_hex(
input_video_path,
video_key,
b"vide",
use_cenc=use_cenc,
file_backed=True,
)
)
caption_tracks = [
track
for track in await asyncio.gather(
asyncio.to_thread(extract_song, input_video_path, b"clcp", True),
asyncio.to_thread(extract_song, input_video_path, b"text", True),
asyncio.to_thread(extract_song, input_video_path, b"sbtl", True),
asyncio.to_thread(extract_song, input_video_path, b"subt", True),
)
if track.samples
]
captions = []
for caption_track in caption_tracks:
caption_data = _sample_payload_bytes(caption_track.samples)
if caption_track.encryption_info:
caption_key = bytes.fromhex(video_key)
caption_enc_info_per_desc = await asyncio.to_thread(
_extract_encryption_info_per_stsd,
caption_track.moov_data,
caption_track.handler_type,
)
caption_data = decrypt_samples_hex(
caption_track.samples,
{sample.desc_index: caption_key for sample in caption_track.samples},
caption_track.encryption_info,
caption_enc_info_per_desc,
)
captions.append(DecryptedTrack(input_video_path, caption_track, caption_data))
return DecryptedMedia(
audio=audio,
video=await video_task,
captions=captions,
)
async def write_decrypted_media(
decrypted_media: DecryptedMedia,
output_path: str,
m4v_brand: bool = False,
) -> None:
"""Write decrypted audio as M4A, or mux decrypted audio/video/text tracks."""
if decrypted_media.video is None:
await asyncio.to_thread(
write_decrypted_m4a,
output_path,
decrypted_media.audio.track_info,
decrypted_media.audio.data,
decrypted_media.audio.input_path,
decrypted_media.audio.data_path,
)
return
try:
await asyncio.to_thread(
mux_decrypted_media_direct,
decrypted_media,
output_path,
m4v_brand,
)
finally:
for track in (
decrypted_media.audio,
decrypted_media.video,
*decrypted_media.captions,
):
if track and track.data_path:
try:
os.remove(track.data_path)
except FileNotFoundError:
pass
track.data_path = None
async def decrypt_av_files_hex(
input_path_video: str,
input_path_audio: str,
output_path: str,
decryption_key_video: str,
decryption_key_audio: str,
m4v_brand: bool = False,
) -> None:
"""Decrypt separate encrypted video/audio MP4s and mux them in Python."""
decrypted_media = await decrypt_file_hex(
decryption_key_audio,
input_path_audio,
decryption_key_video,
input_path_video,
)
await write_decrypted_media(decrypted_media, output_path, m4v_brand)
def _preferred_sample_description_index(samples: List[SampleInfo]) -> int:
"""Return the 0-based sample description index to keep in flattened output."""
counts = Counter(sample.desc_index for sample in samples if _sample_size(sample))
if not counts:
return 0
return counts.most_common(1)[0][0]
def _write_box(f, box_type: bytes, content: bytes):
"""Write a simple MP4 box."""
size = len(content) + 8
f.write(struct.pack(">I", size))
f.write(box_type)
f.write(content)
def _write_ftyp(f):
"""Write ftyp box for M4A."""
content = b"M4A " + struct.pack(">I", 0) # major brand + minor version
content += b"M4A mp42isom\x00\x00\x00\x00" # compatible brands
_write_box(f, b"ftyp", content)
def _write_ftyp_mp4(f):
"""Write ftyp box for MP4/M4V video."""
content = b"mp42" + struct.pack(">I", 0)
content += b"mp42isomiso6avc1hvc1"
_write_box(f, b"ftyp", content)
def _write_ftyp_m4v(f):
"""Write an iTunes-like ftyp box for M4V outputs."""
content = b"M4V " + struct.pack(">I", 0)
content += b"M4V mp42isom"
_write_box(f, b"ftyp", content)
def _build_ftyp_mp4_bytes() -> bytes:
buf = io.BytesIO()
_write_ftyp_mp4(buf)
return buf.getvalue()
def _build_ftyp_m4v_bytes() -> bytes:
buf = io.BytesIO()
_write_ftyp_m4v(buf)
return buf.getvalue()
def _write_fullbox(f, box_type: bytes, version: int, flags: int, content: bytes):
"""Write a FullBox (with version and flags)."""
size = len(content) + 12
f.write(struct.pack(">I", size))
f.write(box_type)
f.write(struct.pack("B", version))
f.write(struct.pack(">I", flags)[1:]) # 3 bytes for flags
f.write(content)
def _write_moov(
f,
samples: List[SampleInfo],
total_duration: int,
timescale: int,
stsd_content: bytes,
decrypted_data: bytes,
orig_mvhd: Optional[bytes] = None,
orig_tkhd: Optional[bytes] = None,
orig_mdhd: Optional[bytes] = None,
orig_hdlr: Optional[bytes] = None,
orig_smhd: Optional[bytes] = None,
orig_vmhd: Optional[bytes] = None,
orig_nmhd: Optional[bytes] = None,
orig_dinf: Optional[bytes] = None,
handler_type: bytes = b"soun",
):
"""Write moov box with sample tables.
When original box data is available, it is copied verbatim (with duration
fields patched) to faithfully reproduce the source file's metadata.
This matches the Go amdecrypt behavior of copying boxes from the original.
"""
moov_start = f.tell()
f.write(b"\x00" * 8) # moov header placeholder
# mvhd (movie header)
if orig_mvhd:
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)
mvhd_content += struct.pack(">I", total_duration)
mvhd_content += struct.pack(">I", 0x00010000) # rate (1.0)
mvhd_content += struct.pack(">H", 0x0100) # volume (1.0)
mvhd_content += b"\x00" * 10 # reserved
mvhd_content += struct.pack(
">9I", 0x00010000, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000
) # matrix
mvhd_content += b"\x00" * 24 # pre_defined
mvhd_content += struct.pack(">I", 2) # next_track_id
_write_fullbox(f, b"mvhd", 0, 0, mvhd_content)
# trak (track)
trak_start = f.tell()
f.write(b"\x00" * 8) # trak header placeholder
# tkhd (track header)
if orig_tkhd:
f.write(_patch_tkhd_duration(orig_tkhd, total_duration))
else:
tkhd_content = struct.pack(">II", 0, 0) # creation, modification
tkhd_content += struct.pack(">I", 1) # track_id
tkhd_content += struct.pack(">I", 0) # reserved
tkhd_content += struct.pack(">I", total_duration)
tkhd_content += b"\x00" * 8 # reserved
tkhd_content += struct.pack(">HH", 0, 0) # layer, alternate_group
tkhd_content += struct.pack(">H", 0x0100) # volume
tkhd_content += struct.pack(">H", 0) # reserved
tkhd_content += struct.pack(
">9I", 0x00010000, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000
) # matrix
tkhd_content += struct.pack(">II", 0, 0) # width, height
_write_fullbox(f, b"tkhd", 0, 7, tkhd_content)
# mdia (media)
mdia_start = f.tell()
f.write(b"\x00" * 8)
# mdhd (media header) - preserves original language code
if orig_mdhd:
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)
mdhd_content += struct.pack(">I", total_duration)
mdhd_content += struct.pack(">H", 0x55C4) # language (und)
mdhd_content += struct.pack(">H", 0) # quality
_write_fullbox(f, b"mdhd", 0, 0, mdhd_content)
# hdlr (handler) - preserves original handler name (e.g. "Core Media Audio")
if orig_hdlr:
f.write(orig_hdlr)
else:
hdlr_content = struct.pack(">I", 0) # pre_defined
hdlr_content += b"soun" # handler_type
hdlr_content += b"\x00" * 12 # reserved
handler_name = b"SoundHandler"
hdlr_content += struct.pack("B", len(handler_name)) + handler_name + b"\x00"
_write_fullbox(f, b"hdlr", 0, 0, hdlr_content)
# minf (media info)
minf_start = f.tell()
f.write(b"\x00" * 8)
# media header
if handler_type == b"vide" and orig_vmhd:
f.write(orig_vmhd)
elif handler_type == b"soun" and orig_smhd:
f.write(orig_smhd)
elif orig_nmhd:
f.write(orig_nmhd)
elif handler_type == b"vide":
vmhd_content = struct.pack(">HHHH", 0, 0, 0, 0)
_write_fullbox(f, b"vmhd", 0, 1, vmhd_content)
elif handler_type not in (b"soun", b"vide"):
_write_fullbox(f, b"nmhd", 0, 0, b"")
else:
smhd_content = struct.pack(">HH", 0, 0) # balance, reserved
_write_fullbox(f, b"smhd", 0, 0, smhd_content)
# dinf + dref
if orig_dinf:
f.write(orig_dinf)
else:
dinf_start = f.tell()
f.write(b"\x00" * 8)
dref_content = struct.pack(">I", 1) # entry_count
dref_content += (
struct.pack(">I", 12) + b"url " + struct.pack(">I", 1)
) # url entry (self-contained)
_write_fullbox(f, b"dref", 0, 0, dref_content)
_fixup_box_size(f, dinf_start, b"dinf")
# stbl (sample table)
stbl_start = f.tell()
f.write(b"\x00" * 8)
# stsd (sample description) - use content from original file
_write_stsd(f, stsd_content)
# stts (time-to-sample)
_write_stts(f, samples)
# ctts (composition time-to-sample) preserves B-frame presentation timing.
_write_ctts(f, samples)
# stss (sync samples) helps players seek/decode video keyframes correctly.
if handler_type == b"vide":
_write_stss(f, samples)
# stsc (sample-to-chunk) - all samples in one chunk
stsc_content = struct.pack(">I", 1) # entry_count
stsc_content += struct.pack(
">III", 1, len(samples), 1
) # first_chunk, samples_per_chunk, sample_description_index
_write_fullbox(f, b"stsc", 0, 0, stsc_content)
# stsz (sample size)
stsz_content = struct.pack(">I", 0) # sample_size (0 = variable)
stsz_content += struct.pack(">I", len(samples)) # sample_count
for sample in samples:
stsz_content += struct.pack(">I", _sample_size(sample))
_write_fullbox(f, b"stsz", 0, 0, stsz_content)
# stco (chunk offset) - will be fixed up later
stco_pos = f.tell()
stco_content = struct.pack(">I", 1) # entry_count
stco_content += struct.pack(">I", 0) # chunk_offset (placeholder)
_write_fullbox(f, b"stco", 0, 0, stco_content)
_fixup_box_size(f, stbl_start, b"stbl")
_fixup_box_size(f, minf_start, b"minf")
_fixup_box_size(f, mdia_start, b"mdia")
_fixup_box_size(f, trak_start, b"trak")
# udta > meta > hdlr(mdir) + ilst - metadata container
_write_udta(f)
_fixup_box_size(f, moov_start, b"moov")
# Fix up stco with correct mdat offset
mdat_offset = f.tell() + 8 # +8 for mdat header
f.seek(stco_pos + 16) # +12 for box header + version/flags, +4 for entry_count
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.
This preserves the original codec info (ALAC, EC-3, AAC, etc.).
"""
if stsd_content:
# Write the full stsd box with its content from the source file
size = len(stsd_content) + 8
f.write(struct.pack(">I", size))
f.write(b"stsd")
f.write(stsd_content)
else:
# Fallback: write a basic ALAC stsd if no source content available
_write_stsd_alac_fallback(f)
def _write_stsd_alac_fallback(f):
"""Write a default ALAC sample description box (fallback)."""
stsd_start = f.tell()
f.write(b"\x00" * 12) # box header + version/flags placeholder
f.write(struct.pack(">I", 1)) # entry_count
# alac sample entry
alac_start = f.tell()
f.write(b"\x00" * 8) # alac box header placeholder
f.write(b"\x00" * 6) # reserved
f.write(struct.pack(">H", 1)) # data_reference_index
f.write(b"\x00" * 8) # reserved
f.write(struct.pack(">H", 2)) # channel_count
f.write(struct.pack(">H", 16)) # sample_size (bits)
f.write(struct.pack(">H", 0)) # pre_defined
f.write(struct.pack(">H", 0)) # reserved
f.write(struct.pack(">I", 44100 << 16)) # sample_rate (16.16 fixed point)
# alac magic cookie box
# Default ALAC config for 44.1kHz stereo 24-bit
default_config = bytes(
[
0x00,
0x00,
0x10,
0x00, # frame_length
0x00, # compatible_version
0x18, # bit_depth (24)
0x28,
0x28,
0x0A, # pb, mb, kb
0x02, # num_channels
0x00,
0x00, # max_run
0x00,
0x00,
0xFF,
0xFF, # max_frame_bytes
0x00,
0x0D,
0x00,
0x80, # avg_bit_rate
0x00,
0x00,
0xAC,
0x44, # sample_rate
]
)
_write_box(f, b"alac", default_config)
_fixup_box_size(f, alac_start, b"alac")
# Fix stsd size
end_pos = f.tell()
size = end_pos - stsd_start
f.seek(stsd_start)
f.write(struct.pack(">I", size))
f.write(b"stsd")
f.write(struct.pack(">I", 0)) # version + flags
f.seek(end_pos)
def _write_stts(f, samples: List[SampleInfo]):
"""Write time-to-sample box (run-length encoded)."""
# Run-length encode durations
entries = []
for sample in samples:
if entries and entries[-1][1] == sample.duration:
entries[-1] = (entries[-1][0] + 1, sample.duration)
else:
entries.append((1, sample.duration))
content = struct.pack(">I", len(entries))
for count, delta in entries:
content += struct.pack(">II", count, delta)
_write_fullbox(f, b"stts", 0, 0, content)
def _write_ctts(f, samples: List[SampleInfo]):
"""Write composition time-to-sample box when samples have composition offsets."""
if not any(sample.composition_time_offset for sample in samples):
return
entries = []
for sample in samples:
offset = sample.composition_time_offset
if entries and entries[-1][1] == offset:
entries[-1] = (entries[-1][0] + 1, offset)
else:
entries.append((1, offset))
version = 1 if any(offset < 0 for _, offset in entries) else 0
content = struct.pack(">I", len(entries))
for count, offset in entries:
if version == 1:
content += struct.pack(">Ii", count, offset)
else:
content += struct.pack(">II", count, offset)
_write_fullbox(f, b"ctts", version, 0, content)
def _write_stss(f, samples: List[SampleInfo]):
"""Write sync sample box when video sample flags identify non-sync samples."""
if not samples or all(sample.is_sync for sample in samples):
return
sync_sample_numbers = [
index for index, sample in enumerate(samples, start=1) if sample.is_sync
]
if not sync_sample_numbers:
return
content = struct.pack(">I", len(sync_sample_numbers))
for sample_number in sync_sample_numbers:
content += struct.pack(">I", sample_number)
_write_fullbox(f, b"stss", 0, 0, content)
def _fixup_box_size(f, start_pos: int, box_type: bytes):
"""Fix up the size field of a box that was written with placeholder."""
end_pos = f.tell()
size = end_pos - start_pos
f.seek(start_pos)
f.write(struct.pack(">I", size))
f.write(box_type)
f.seek(end_pos)
def _write_mdat(f, data: bytes):
"""Write mdat box with decrypted data."""
size = len(data) + 8
f.write(struct.pack(">I", size))
f.write(b"mdat")
f.write(data)
def _copy_file_range(
output_file: BinaryIO,
input_path: str,
offset: int,
size: int,
chunk_size: int = 1024 * 1024,
) -> None:
"""Copy a byte range without loading it all into memory."""
remaining = size
with open(input_path, "rb") as input_file:
input_file.seek(offset)
while remaining > 0:
chunk = input_file.read(min(chunk_size, remaining))
if not chunk:
raise IOError(f"unexpected EOF while reading {input_path}")
output_file.write(chunk)
remaining -= len(chunk)
def _write_mdat_from_sources(f, sources: List[tuple]) -> None:
"""Write mdat by streaming file-backed sources and small in-memory sources."""
payload_size = sum(source[2] for source in sources)
if payload_size + 8 > 0xFFFFFFFF:
raise IOError("mux: mdat too large for 32-bit box size")
f.write(struct.pack(">I", payload_size + 8))
f.write(b"mdat")
for source in sources:
input_path, offset, size = source[:3]
if input_path is None:
data = source[3]
if len(data) != size:
raise IOError("mux: in-memory mdat source size mismatch")
f.write(data)
else:
_copy_file_range(f, input_path, offset, size)
def _extract_top_level_box(data: bytes, box_type: bytes) -> Optional[bytes]:
offset = 0
while offset + 8 <= len(data):
size = struct.unpack(">I", data[offset : offset + 4])[0]
current_type = data[offset + 4 : offset + 8]
header_size = 8
if size == 1:
if offset + 16 > len(data):
return None
size = struct.unpack(">Q", data[offset + 8 : offset + 16])[0]
header_size = 16
elif size == 0:
size = len(data) - offset
if size < header_size or offset + size > len(data):
return None
if current_type == box_type:
return data[offset : offset + size]
offset += size
return None
def _extract_mdat_payload(data: bytes) -> bytes:
mdat = _extract_top_level_box(data, b"mdat")
if not mdat:
raise IOError("mux: missing mdat box in decrypted track file")
size = struct.unpack(">I", mdat[:4])[0]
header_size = 16 if size == 1 else 8
return mdat[header_size:]
def _build_muxed_moov(mvhd: bytes, traks: List[bytes]) -> bytes:
payload = bytearray()
payload.extend(_patch_mvhd_next_track_id(mvhd, len(traks) + 1))
for trak in traks:
payload.extend(trak)
udta_buf = io.BytesIO()
_write_udta(udta_buf)
payload.extend(udta_buf.getvalue())
return struct.pack(">I", len(payload) + 8) + b"moov" + bytes(payload)
def _patch_mvhd_next_track_id(mvhd_data: bytes, next_track_id: int) -> bytes:
"""Return a copy of mvhd with next_track_id set past the muxed tracks."""
data = bytearray(mvhd_data)
if len(data) < 112:
return bytes(data)
version = data[8]
next_track_id_offset = 108 if version == 0 else 120
if next_track_id_offset + 4 <= len(data):
struct.pack_into(">I", data, next_track_id_offset, next_track_id)
return bytes(data)
def _extract_mvhd_timescale(mvhd_data: bytes) -> int:
"""Extract movie timescale from an mvhd box."""
if len(mvhd_data) < 32:
return 1000
version = mvhd_data[8]
if version == 0 and len(mvhd_data) >= 24:
return struct.unpack(">I", mvhd_data[20:24])[0]
if version == 1 and len(mvhd_data) >= 32:
return struct.unpack(">I", mvhd_data[28:32])[0]
return 1000
def _extract_mdhd_duration_timescale(trak_data: bytes) -> tuple[int, int]:
"""Extract media duration and timescale from a trak's mdhd box."""
mdhd_offset = _find_box_offset_recursive(trak_data, b"mdhd")
if mdhd_offset < 0 or mdhd_offset + 32 > len(trak_data):
return 0, 1
version = trak_data[mdhd_offset + 8]
if version == 0:
timescale = struct.unpack(">I", trak_data[mdhd_offset + 20 : mdhd_offset + 24])[
0
]
duration = struct.unpack(">I", trak_data[mdhd_offset + 24 : mdhd_offset + 28])[
0
]
else:
timescale = struct.unpack(">I", trak_data[mdhd_offset + 28 : mdhd_offset + 32])[
0
]
duration = struct.unpack(">Q", trak_data[mdhd_offset + 32 : mdhd_offset + 40])[
0
]
return duration, timescale or 1
def _patch_trak_duration_to_movie_timescale(
trak_data: bytes, movie_timescale: int
) -> bytes:
"""Patch tkhd duration to movie timescale while preserving mdhd duration."""
media_duration, media_timescale = _extract_mdhd_duration_timescale(trak_data)
if media_duration <= 0:
return trak_data
movie_duration = round(media_duration * movie_timescale / media_timescale)
return _patch_trak_tkhd_duration(trak_data, movie_duration)
def _patch_trak_tkhd_duration(trak_data: bytes, duration: int) -> bytes:
"""Patch the nested tkhd duration inside a full trak box."""
data = bytearray(trak_data)
tkhd_offset = _find_box_offset_recursive(data, b"tkhd")
if tkhd_offset < 0:
return bytes(data)
version = data[tkhd_offset + 8]
data[tkhd_offset + 9 : tkhd_offset + 12] = struct.pack(">I", 7)[1:]
if version == 0:
duration_offset = tkhd_offset + 28
if duration_offset + 4 <= len(data):
struct.pack_into(">I", data, duration_offset, duration)
else:
duration_offset = tkhd_offset + 36
if duration_offset + 8 <= len(data):
struct.pack_into(">Q", data, duration_offset, duration)
return bytes(data)
def _find_first_trak(moov_data: bytes) -> Optional[bytes]:
offset = 8
while offset + 8 <= len(moov_data):
size = struct.unpack(">I", moov_data[offset : offset + 4])[0]
box_type = moov_data[offset + 4 : offset + 8]
if size < 8 or offset + size > len(moov_data):
break
if box_type == b"trak":
return moov_data[offset : offset + size]
offset += size
return None
def _find_box_offset_recursive(
data: bytes,
target_type: bytes,
start: int = 0,
end: Optional[int] = None,
) -> int:
"""Find a box by walking MP4 box boundaries instead of byte substrings."""
end = len(data) if end is None else min(end, len(data))
offset = start
container_types = {
b"moov",
b"trak",
b"mdia",
b"minf",
b"stbl",
b"dinf",
b"edts",
b"udta",
b"meta",
}
while offset + 8 <= end:
size = struct.unpack(">I", data[offset : offset + 4])[0]
box_type = bytes(data[offset + 4 : offset + 8])
header_size = 8
if size == 1:
if offset + 16 > end:
break
size = struct.unpack(">Q", data[offset + 8 : offset + 16])[0]
header_size = 16
elif size == 0:
size = end - offset
if size < header_size or offset + size > end:
break
if box_type == target_type:
return offset
if box_type in container_types:
child_start = offset + header_size
if box_type == b"meta":
child_start += 4
found = _find_box_offset_recursive(
data, target_type, child_start, offset + size
)
if found >= 0:
return found
offset += size
return -1
def _patch_first_chunk_offset(trak_data: bytes, chunk_offset: int) -> bytes:
data = bytearray(trak_data)
stco_offset = _find_box_offset_recursive(data, b"stco")
co64_offset = _find_box_offset_recursive(data, b"co64")
if stco_offset >= 0:
entry_count_offset = stco_offset + 12
first_entry_offset = stco_offset + 16
if first_entry_offset + 4 <= len(data):
entry_count = struct.unpack(
">I", data[entry_count_offset:first_entry_offset]
)[0]
if entry_count > 0:
struct.pack_into(">I", data, first_entry_offset, chunk_offset)
return bytes(data)
if co64_offset >= 0:
entry_count_offset = co64_offset + 12
first_entry_offset = co64_offset + 16
if first_entry_offset + 8 <= len(data):
entry_count = struct.unpack(
">I", data[entry_count_offset:first_entry_offset]
)[0]
if entry_count > 0:
struct.pack_into(">Q", data, first_entry_offset, chunk_offset)
return bytes(data)
raise IOError("mux: unable to patch chunk offset")
def _patch_trak_track_id(trak_data: bytes, track_id: int) -> bytes:
data = bytearray(trak_data)
tkhd_offset = _find_box_offset_recursive(data, b"tkhd")
if tkhd_offset < 0:
return bytes(data)
version = data[tkhd_offset + 8]
track_id_offset = tkhd_offset + 20 if version == 0 else tkhd_offset + 28
if track_id_offset + 4 <= len(data):
struct.pack_into(">I", data, track_id_offset, track_id)
return bytes(data)
def _extract_stsd_content(
data: bytes,
preferred_desc_index: Optional[int] = None,
handler_type: bytes = b"soun",
) -> Optional[bytes]:
"""Extract cleaned stsd box content from moov box (supports any codec)."""
moov_idx = data.find(b"moov")
if moov_idx < 4:
return None
moov_size = struct.unpack(">I", data[moov_idx - 4 : moov_idx])[0]
if moov_size < 8 or moov_idx - 4 + moov_size > len(data):
return None
moov_data = data[moov_idx - 4 : moov_idx - 4 + moov_size]
trak_data = _find_track_by_handler(moov_data, handler_type)
if trak_data is None:
return None
mdia = _find_child_box(trak_data, b"mdia")
minf = _find_child_box(mdia, b"minf") if mdia else None
stbl = _find_child_box(minf, b"stbl") if minf else None
stsd = _find_child_box(stbl, b"stsd") if stbl else None
if stsd is None or len(stsd) < 16:
return None
# Return stsd content (after box header = size + type)
raw_content = stsd[8:]
# Clean the stsd content to remove encryption metadata
return _clean_stsd_content(raw_content, preferred_desc_index)
def _clean_stsd_content(
stsd_content: bytes, preferred_desc_index: Optional[int] = None
) -> bytes:
"""
Clean stsd content by removing encryption metadata.
This replaces the mp4decrypt cleanup step. For encrypted files, the stsd
contains encrypted sample entries (enca, encv) with sinf boxes that describe
the encryption scheme. We need to:
1. Convert encrypted entries (enca -> original format from frma)
2. Remove sinf boxes entirely
"""
if len(stsd_content) < 8:
return stsd_content
# stsd content: version(1) + flags(3) + entry_count(4) + entries...
version_flags = stsd_content[:4]
entry_count = struct.unpack(">I", stsd_content[4:8])[0]
# Parse and clean each sample entry.
cleaned_entries = []
offset = 8
for _ in range(entry_count):
if offset + 8 > len(stsd_content):
break
entry_size = struct.unpack(">I", stsd_content[offset : offset + 4])[0]
entry_type = stsd_content[offset + 4 : offset + 8]
if entry_size < 8 or offset + entry_size > len(stsd_content):
break
entry_data = stsd_content[offset : offset + entry_size]
# Check if this is an encrypted entry
if entry_type in (b"enca", b"encv", b"encs", b"encm"):
# Clean the encrypted entry
cleaned_entry = _clean_encrypted_sample_entry(entry_data)
cleaned_entries.append(cleaned_entry)
else:
# Keep as-is but still remove any sinf boxes that might be present
cleaned_entry = _remove_sinf_from_entry(entry_data)
cleaned_entries.append(cleaned_entry)
offset += entry_size
# The writer emits one chunk and one stsc entry with sample_description_index=1.
# Keep only the dominant source description so that the flattened MP4's sample
# table and stsd agree. iTunes is stricter about this than many players.
if preferred_desc_index is not None and cleaned_entries:
if 0 <= preferred_desc_index < len(cleaned_entries):
cleaned_entries = [cleaned_entries[preferred_desc_index]]
else:
cleaned_entries = [cleaned_entries[0]]
# Rebuild stsd content
result = version_flags + struct.pack(">I", len(cleaned_entries))
for entry in cleaned_entries:
result += entry
return result
def _sample_entry_header_size(entry_type: bytes) -> int:
"""Return fixed sample-entry bytes before child boxes."""
if entry_type in (b"encv", b"avc1", b"avc3", b"hvc1", b"hev1", b"dvh1", b"dvhe"):
return 86
if entry_type in (b"enca", b"mp4a", b"alac", b"ac-3", b"ec-3"):
return 36
if entry_type in (b"c608", b"c708", b"text", b"tx3g", b"wvtt", b"stpp"):
return 8
return 36
def _clean_encrypted_sample_entry(entry_data: bytes) -> bytes:
"""
Clean an encrypted sample entry (enca, encv, etc.).
Structure of encrypted audio entry (enca):
- Box header (8 bytes): size + 'enca'
- Reserved (6 bytes)
- Data reference index (2 bytes)
- Audio specific data (varies by codec)
- Child boxes including:
- sinf (protection scheme info) - REMOVE THIS
- Original codec box (alac, esds, etc.) - KEEP
We need to:
1. Find frma box inside sinf to get original format
2. Replace 'enca' with original format
3. Remove sinf box
"""
if len(entry_data) < 16:
return entry_data
entry_size = struct.unpack(">I", entry_data[:4])[0]
entry_type = entry_data[4:8]
sample_entry_header_size = _sample_entry_header_size(entry_type)
if len(entry_data) < sample_entry_header_size:
return entry_data
# Find the original format from sinf/frma
original_format = _find_original_format(entry_data)
if not original_format:
# If we can't find frma, try common mappings
if entry_type == b"enca":
original_format = b"mp4a" # Default to AAC
elif entry_type == b"encv":
original_format = b"avc1" # Default to H.264
else:
original_format = entry_type # Keep as-is
# Copy the fixed header part, replacing the type
new_entry = (
entry_data[:4] + original_format + entry_data[8:sample_entry_header_size]
)
# Process child boxes, removing sinf
child_offset = sample_entry_header_size
while child_offset + 8 <= len(entry_data):
child_size = struct.unpack(">I", entry_data[child_offset : child_offset + 4])[0]
child_type = entry_data[child_offset + 4 : child_offset + 8]
if child_size < 8 or child_offset + child_size > len(entry_data):
break
# Skip sinf box (encryption metadata)
if child_type != b"sinf":
new_entry += entry_data[child_offset : child_offset + child_size]
child_offset += child_size
# Update the entry size
new_size = len(new_entry)
new_entry = struct.pack(">I", new_size) + new_entry[4:]
return new_entry
def _find_original_format(entry_data: bytes) -> Optional[bytes]:
"""
Find the original format (frma) from sinf box in an encrypted entry.
sinf box contains:
- frma: original format (4 bytes codec type)
- schm: scheme type
- schi: scheme info (contains tenc for CENC)
"""
# Look for sinf box
sinf_idx = entry_data.find(b"sinf")
if sinf_idx < 4:
return None
sinf_size = struct.unpack(">I", entry_data[sinf_idx - 4 : sinf_idx])[0]
if sinf_size < 16 or sinf_idx + sinf_size > len(entry_data) + 4:
return None
sinf_data = entry_data[sinf_idx - 4 : sinf_idx - 4 + sinf_size]
# Look for frma box inside sinf
frma_idx = sinf_data.find(b"frma")
if frma_idx < 4:
return None
frma_size = struct.unpack(">I", sinf_data[frma_idx - 4 : frma_idx])[0]
if frma_size != 12: # frma is always 12 bytes: size(4) + type(4) + format(4)
return None
# Extract the original format
return sinf_data[frma_idx + 4 : frma_idx + 8]
def _remove_sinf_from_entry(entry_data: bytes) -> bytes:
"""
Remove sinf box from a sample entry (if present).
Used for non-encrypted entries that might still have protection info.
"""
if len(entry_data) < 16:
return entry_data
sample_entry_header_size = _sample_entry_header_size(entry_data[4:8])
if len(entry_data) < sample_entry_header_size:
return entry_data
# Check if sinf exists
if b"sinf" not in entry_data:
return entry_data
# Rebuild entry without sinf
new_entry = entry_data[:sample_entry_header_size]
child_offset = sample_entry_header_size
while child_offset + 8 <= len(entry_data):
child_size = struct.unpack(">I", entry_data[child_offset : child_offset + 4])[0]
child_type = entry_data[child_offset + 4 : child_offset + 8]
if child_size < 8 or child_offset + child_size > len(entry_data):
break
if child_type != b"sinf":
new_entry += entry_data[child_offset : child_offset + child_size]
child_offset += child_size
# Update size
new_size = len(new_entry)
new_entry = struct.pack(">I", new_size) + new_entry[4:]
return new_entry
def _extract_alac_config(data: bytes) -> Optional[bytes]:
"""Extract ALAC configuration from moov/stsd box (for backwards compatibility)."""
# Simple search for 'alac' box in data
idx = data.find(b"alac")
if idx < 4:
return None
# Check if it's inside stsd (look for full structure)
# The 'alac' cookie box follows the sample entry
alac_idx = idx
while alac_idx < len(data) - 100:
if data[alac_idx : alac_idx + 4] == b"alac":
size = struct.unpack(">I", data[alac_idx - 4 : alac_idx])[0]
if 20 < size < 100: # Reasonable ALAC config size
return data[alac_idx + 4 : alac_idx - 4 + size]
alac_idx += 1
if alac_idx > idx + 200:
break
return None
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 + 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 _extract_track_timescale(
data: bytes, handler_type: bytes = b"soun", default: int = 44100
) -> int:
"""Extract mdhd timescale from the selected track."""
moov_idx = data.find(b"moov")
if moov_idx < 4:
return default
moov_size = struct.unpack(">I", data[moov_idx - 4 : moov_idx])[0]
if moov_size < 8 or moov_idx - 4 + moov_size > len(data):
return default
moov_data = data[moov_idx - 4 : moov_idx - 4 + moov_size]
trak = _find_track_by_handler(moov_data, handler_type)
mdia = _find_child_box(trak, b"mdia") if trak else None
mdhd = _find_child_box(mdia, b"mdhd") if mdia else None
if not mdhd or len(mdhd) < 28:
return default
version = mdhd[8]
if version == 0 and len(mdhd) >= 24:
return struct.unpack(">I", mdhd[20:24])[0]
if version == 1 and len(mdhd) >= 32:
return struct.unpack(">I", mdhd[28:32])[0]
return default
def _find_child_box(
container_data: bytes, target_type: bytes, skip_header: int = 8
) -> Optional[bytes]:
"""Find a direct child box in container data.
Args:
container_data: Raw bytes of the container box (including its own header).
target_type: 4-byte box type to search for.
skip_header: Bytes to skip at start (8 for plain box, 12 for FullBox).
Returns:
Full box bytes (size + type + content) or None.
"""
offset = skip_header
while offset + 8 <= len(container_data):
size = struct.unpack(">I", container_data[offset : offset + 4])[0]
box_type = container_data[offset + 4 : offset + 8]
if size < 8 or offset + size > len(container_data):
break
if box_type == target_type:
return container_data[offset : offset + size]
offset += size
return None
def _find_track_by_handler(moov_data: bytes, handler_type: bytes) -> Optional[bytes]:
"""Find the trak box in moov data for a media handler.
Iterates trak children and returns the first one whose mdia/hdlr has
the requested handler type. Returns full trak box bytes or None.
"""
offset = 8 # Skip moov header
while offset + 8 <= len(moov_data):
size = struct.unpack(">I", moov_data[offset : offset + 4])[0]
box_type = moov_data[offset + 4 : offset + 8]
if size < 8 or offset + size > len(moov_data):
break
if box_type == b"trak":
trak_data = moov_data[offset : offset + size]
hdlr_idx = trak_data.find(b"hdlr")
if hdlr_idx > 0:
# hdlr FullBox: version+flags(4) + pre_defined(4) + handler_type(4)
handler_offset = hdlr_idx + 4 + 4 + 4
if handler_offset + 4 <= len(trak_data):
if trak_data[handler_offset : handler_offset + 4] == handler_type:
return trak_data
offset += size
return None
def _find_audio_trak(moov_data: bytes) -> Optional[bytes]:
"""Find the audio trak box in moov data."""
return _find_track_by_handler(moov_data, b"soun")
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)
def _patch_tkhd_duration(box_data: bytes, duration: int) -> bytes:
"""Return a copy of the tkhd box with duration patched and flags set to 7."""
data = bytearray(box_data)
version = data[8]
# Set flags = 7 (enabled | in_movie | in_preview)
data[9:12] = struct.pack(">I", 7)[1:] # 3-byte flags
if version == 0:
# v0: ver+flags(4) + creation(4) + modification(4) + track_id(4) + reserved(4) + duration(4)
struct.pack_into(">I", data, 28, duration)
else:
# v1: ver+flags(4) + creation(8) + modification(8) + track_id(4) + reserved(4) + duration(8)
struct.pack_into(">Q", data, 36, duration)
return bytes(data)
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:
# 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)
def _write_udta(f):
"""Write udta > meta > hdlr(mdir) + ilst metadata container.
This matches the Go amdecrypt output which creates an empty metadata
container so tools can find and write metadata atoms.
"""
udta_start = f.tell()
f.write(b"\x00" * 8) # udta placeholder
meta_start = f.tell()
f.write(b"\x00" * 8) # meta placeholder
# meta is a FullBox: version(1) + flags(3)
f.write(struct.pack(">I", 0)) # version + flags
# hdlr for metadata (handler_type = 'mdir', reserved = 'appl' + zeros)
hdlr_content = struct.pack(">I", 0) # pre_defined
hdlr_content += b"mdir" # handler_type
hdlr_content += struct.pack(">III", 0x6170706C, 0, 0) # reserved ('appl', 0, 0)
hdlr_content += b"\x00" # empty name (null terminator)
_write_fullbox(f, b"hdlr", 0, 0, hdlr_content)
# ilst (empty)
_write_box(f, b"ilst", b"")
_fixup_box_size(f, meta_start, b"meta")
_fixup_box_size(f, udta_start, b"udta")
def _extract_trex_defaults(moov_data: bytes, target_track_id: int = 0) -> dict:
"""Extract default sample values from moov/mvex/trex box.
The trex (Track Extends) box provides default values for sample duration,
size, description index, and flags used by track fragments (traf/trun)
when those fields are not explicitly present.
Args:
moov_data: Raw bytes of the moov box.
target_track_id: If > 0, only return defaults for this track.
If 0, return the first trex found.
Returns:
Dict with keys: default_sample_duration, default_sample_size,
default_sample_description_index, default_sample_flags.
"""
# Determine fallback duration based on codec
# ALAC frames are 4096 samples, AAC frames are 1024 samples
is_alac = b"alac" in moov_data or b"ALAC" in moov_data
fallback_duration = 4096 if is_alac else 1024
defaults = {
"default_sample_duration": fallback_duration,
"default_sample_size": 0,
"default_sample_description_index": 1,
"default_sample_flags": 0,
}
# Find mvex box inside moov
mvex = _find_child_box(moov_data, b"mvex")
if mvex is None:
return defaults
# Iterate trex children inside mvex
offset = 8 # Skip mvex box header
while offset + 8 <= len(mvex):
size = struct.unpack(">I", mvex[offset : offset + 4])[0]
box_type = mvex[offset + 4 : offset + 8]
if size < 8 or offset + size > len(mvex):
break
if box_type == b"trex" and size >= 32:
# trex FullBox: size(4) + type(4) + version(1) + flags(3)
# + track_id(4) + default_sample_description_index(4)
# + default_sample_duration(4) + default_sample_size(4)
# + default_sample_flags(4)
trex_data = mvex[offset : offset + size]
track_id = struct.unpack(">I", trex_data[12:16])[0]
if target_track_id == 0 or track_id == target_track_id:
defaults["default_sample_description_index"] = struct.unpack(
">I", trex_data[16:20]
)[0]
# Extract duration and protect against Apple's dummy values
parsed_duration = struct.unpack(">I", trex_data[20:24])[0]
# Override if the provider wrote 0, or if they incorrectly wrote 1024 for an ALAC track
if parsed_duration == 0 or (is_alac and parsed_duration == 1024):
defaults["default_sample_duration"] = fallback_duration
else:
defaults["default_sample_duration"] = parsed_duration
defaults["default_sample_size"] = struct.unpack(">I", trex_data[24:28])[
0
]
defaults["default_sample_flags"] = struct.unpack(
">I", trex_data[28:32]
)[0]
break
offset += size
return defaults
def _extract_encryption_info(
moov_data: bytes, handler_type: bytes = b"soun"
) -> Optional[EncryptionInfo]:
"""Extract encryption scheme info from the selected track's sinf box.
Walks moov → trak (audio) → mdia → minf → stbl → stsd → sample_entry → sinf,
then reads sinf/schm for scheme_type and sinf/schi/tenc for IV size, constant IV,
and default KID.
Returns EncryptionInfo or None if no sinf is found.
"""
trak_data = _find_track_by_handler(moov_data, handler_type)
if trak_data is None:
return None
# Navigate trak → mdia → minf → stbl → stsd
mdia = _find_child_box(trak_data, b"mdia")
if mdia is None:
return None
minf = _find_child_box(mdia, b"minf")
if minf is None:
return None
stbl = _find_child_box(minf, b"stbl")
if stbl is None:
return None
stsd = _find_child_box(stbl, b"stsd")
if stsd is None:
return None
# stsd is a FullBox: 4 (size) + 4 (type) + 4 (version+flags) + 4 (entry_count)
# Then the first sample entry immediately follows
if len(stsd) < 16:
return None
entry_offset = 16 # past header+version+flags+entry_count
if entry_offset + 8 > len(stsd):
return None
entry_size = struct.unpack(">I", stsd[entry_offset : entry_offset + 4])[0]
entry_data = stsd[entry_offset : entry_offset + entry_size]
# Find sinf inside this sample entry
sample_entry_header_size = _sample_entry_header_size(entry_data[4:8])
sinf = _find_child_box(entry_data, b"sinf", skip_header=sample_entry_header_size)
if sinf is None:
return None
info = EncryptionInfo()
# Parse schm (Scheme Type Box) inside sinf
schm = _find_child_box(sinf, b"schm")
if schm and len(schm) >= 20:
# schm: 4(size) + 4(type) + 4(ver+flags) + 4(scheme_type) + 4(scheme_version)
info.scheme_type = schm[12:16].decode("ascii", errors="replace")
# Parse tenc (Track Encryption Box) inside sinf/schi
schi = _find_child_box(sinf, b"schi")
if schi:
tenc = _find_child_box(schi, b"tenc")
if tenc and len(tenc) >= 32:
# tenc FullBox layout (offsets include 8-byte box header):
# [0:4] size
# [4:8] type "tenc"
# [8] version
# [9:12] flags (3 bytes)
# [12] reserved
# [13] reserved (v0) / crypt_byte_block|skip_byte_block (v1)
# [14] default_isProtected
# [15] default_Per_Sample_IV_Size
# [16:32] default_KID (16 bytes)
# if per_sample_iv_size==0:
# [32] default_constant_IV_size
# [33..] default_constant_IV
tenc_version = tenc[8]
if tenc_version > 0:
pattern = tenc[13]
info.crypt_byte_block = pattern >> 4
info.skip_byte_block = pattern & 0x0F
per_sample_iv_size = tenc[15]
kid = tenc[16:32]
constant_iv_offset = 32
else:
per_sample_iv_size = tenc[15]
kid = tenc[16:32]
constant_iv_offset = 32
info.per_sample_iv_size = per_sample_iv_size
info.kid = kid
# If per_sample_iv_size is 0, a constant IV follows the KID
if per_sample_iv_size == 0 and len(tenc) > constant_iv_offset:
constant_iv_size = tenc[constant_iv_offset]
if len(tenc) >= constant_iv_offset + 1 + constant_iv_size:
info.constant_iv = tenc[
constant_iv_offset
+ 1 : constant_iv_offset
+ 1
+ constant_iv_size
]
return info
def _extract_encryption_info_per_stsd(
moov_data: bytes, handler_type: bytes = b"soun"
) -> Optional[dict]:
"""Extract encryption scheme info for each stsd entry (sample description).
Returns a dict mapping desc_index (0-based) → EncryptionInfo, or None if no
encryption found. This handles cases where different sample descriptions have
different encryption parameters (e.g., different IVs or key schemes).
"""
trak_data = _find_track_by_handler(moov_data, handler_type)
if trak_data is None:
return None
# Navigate trak → mdia → minf → stbl → stsd
mdia = _find_child_box(trak_data, b"mdia")
if mdia is None:
return None
minf = _find_child_box(mdia, b"minf")
if minf is None:
return None
stbl = _find_child_box(minf, b"stbl")
if stbl is None:
return None
stsd = _find_child_box(stbl, b"stsd")
if stsd is None:
return None
if len(stsd) < 16:
return None
entry_count = struct.unpack(">I", stsd[12:16])[0]
if entry_count == 0:
return None
encryption_info_per_desc = {}
entry_offset = 16 # past header+version+flags+entry_count
for desc_idx in range(entry_count):
if entry_offset + 8 > len(stsd):
break
entry_size = struct.unpack(">I", stsd[entry_offset : entry_offset + 4])[0]
if entry_size < 8 or entry_offset + entry_size > len(stsd):
break
entry_data = stsd[entry_offset : entry_offset + entry_size]
sample_entry_header_size = _sample_entry_header_size(entry_data[4:8])
sinf = _find_child_box(
entry_data, b"sinf", skip_header=sample_entry_header_size
)
if sinf is not None:
# Extract encryption info for this stsd entry
info = EncryptionInfo()
# Parse schm
schm = _find_child_box(sinf, b"schm")
if schm and len(schm) >= 20:
info.scheme_type = schm[12:16].decode("ascii", errors="replace")
# Parse tenc
schi = _find_child_box(sinf, b"schi")
if schi:
tenc = _find_child_box(schi, b"tenc")
if tenc and len(tenc) >= 32:
tenc_version = tenc[8]
if tenc_version > 0:
pattern = tenc[13]
info.crypt_byte_block = pattern >> 4
info.skip_byte_block = pattern & 0x0F
per_sample_iv_size = tenc[15]
kid = tenc[16:32]
constant_iv_offset = 32
else:
per_sample_iv_size = tenc[15]
kid = tenc[16:32]
constant_iv_offset = 32
info.per_sample_iv_size = per_sample_iv_size
info.kid = kid
# If per_sample_iv_size is 0, extract constant IV
if per_sample_iv_size == 0 and len(tenc) > constant_iv_offset:
constant_iv_size = tenc[constant_iv_offset]
if len(tenc) >= constant_iv_offset + 1 + constant_iv_size:
info.constant_iv = tenc[
constant_iv_offset
+ 1 : constant_iv_offset
+ 1
+ constant_iv_size
]
encryption_info_per_desc[desc_idx] = info
entry_offset += entry_size
return encryption_info_per_desc if encryption_info_per_desc else None
def _extract_track_id(
moov_data: bytes, handler_type: bytes = b"soun", default_track_id: int = 1
) -> int:
"""Extract the track ID for the requested handler from the moov box.
Parses trak boxes in moov to find one with the requested handler, then
returns its track_id from tkhd.
"""
offset = 8 # Skip moov box header
while offset < len(moov_data) - 8:
size = struct.unpack(">I", moov_data[offset : offset + 4])[0]
box_type = moov_data[offset + 4 : offset + 8]
if size < 8 or offset + size > len(moov_data):
break
if box_type == b"trak":
trak_data = moov_data[offset : offset + size]
# Check handler type in hdlr box
hdlr_idx = trak_data.find(b"hdlr")
if hdlr_idx > 0:
# hdlr FullBox: after 'hdlr' type comes version+flags(4) + pre_defined(4) + handler_type(4)
handler_offset = hdlr_idx + 4 + 4 + 4
if handler_offset + 4 <= len(trak_data):
parsed_handler_type = trak_data[handler_offset : handler_offset + 4]
if parsed_handler_type == handler_type:
# Found requested track, extract track_id from tkhd
tkhd_idx = trak_data.find(b"tkhd")
if tkhd_idx > 0:
version = trak_data[tkhd_idx + 4]
if version == 0:
# v0: ver+flags(4) + creation(4) + modification(4) + track_id(4)
tid_offset = tkhd_idx + 4 + 4 + 4 + 4
else:
# v1: ver+flags(4) + creation(8) + modification(8) + track_id(4)
tid_offset = tkhd_idx + 4 + 4 + 8 + 8
if tid_offset + 4 <= len(trak_data):
return struct.unpack(
">I", trak_data[tid_offset : tid_offset + 4]
)[0]
offset += size
return default_track_id
def _extract_audio_track_id(moov_data: bytes) -> int:
"""Extract the track ID of the audio track from the moov box."""
return _extract_track_id(moov_data, b"soun", 1)
async def _decrypt_track_wrapper(
wrapper_api: WrapperApi,
track_id: str,
fairplay_key: str,
input_path: str,
handler_type: bytes = b"soun",
*,
use_single_content_key: bool = False,
file_backed: bool = False,
progress_callback=None,
) -> DecryptedTrack:
"""Decrypt one track through wrapper-v2 (CBCS via FairPlay SKD)."""
song_info = await asyncio.to_thread(
extract_song, input_path, handler_type, file_backed
)
enc_info = song_info.encryption_info or EncryptionInfo(scheme_type="cbcs")
enc_info_per_desc = None
if song_info.moov_data:
enc_info_per_desc = await asyncio.to_thread(
_extract_encryption_info_per_stsd,
song_info.moov_data,
handler_type,
)
temp_path = None
if file_backed:
temp_file = tempfile.NamedTemporaryFile(
prefix="gamdl_decrypted_", suffix=".bin", delete=False
)
temp_path = temp_file.name
temp_file.close()
try:
decrypted_data = await decrypt_samples(
wrapper_api,
track_id,
fairplay_key,
song_info.samples,
enc_info,
enc_info_per_desc,
use_single_content_key=use_single_content_key,
progress_callback=progress_callback,
decrypted_data_path=temp_path,
)
except Exception:
if temp_path:
try:
os.remove(temp_path)
except FileNotFoundError:
pass
raise
if temp_path:
return DecryptedTrack(
input_path,
song_info,
data_path=temp_path,
data_size=os.path.getsize(temp_path),
)
return DecryptedTrack(input_path, song_info, decrypted_data)
async def decrypt_wrapper(
wrapper_api: WrapperApi,
track_id: str,
input_audio_path: str,
input_video_path: str | None = None,
fairplay_key_video: str | None = None,
*,
fairplay_key_audio: str | None = None,
use_single_content_key: bool = False,
progress_callback=None,
) -> DecryptedMedia:
"""Decrypt audio and optional video through wrapper-v2 (CBCS)."""
if fairplay_key_audio is None:
if input_video_path is None and fairplay_key_video is not None:
fairplay_key_audio = fairplay_key_video
else:
raise ValueError("fairplay_key_audio is required for wrapper audio decrypt")
audio = await _decrypt_track_wrapper(
wrapper_api,
track_id,
fairplay_key_audio,
input_audio_path,
b"soun",
use_single_content_key=use_single_content_key,
progress_callback=progress_callback,
)
if input_video_path is None:
return DecryptedMedia(audio=audio)
if fairplay_key_video is None:
raise ValueError("fairplay_key_video is required for wrapper video decrypt")
video_task = asyncio.create_task(
_decrypt_track_wrapper(
wrapper_api,
track_id,
fairplay_key_video,
input_video_path,
b"vide",
use_single_content_key=use_single_content_key,
file_backed=True,
progress_callback=progress_callback,
)
)
caption_tracks = [
track
for track in await asyncio.gather(
asyncio.to_thread(extract_song, input_video_path, b"clcp", True),
asyncio.to_thread(extract_song, input_video_path, b"text", True),
asyncio.to_thread(extract_song, input_video_path, b"sbtl", True),
asyncio.to_thread(extract_song, input_video_path, b"subt", True),
)
if track.samples
]
captions = [
DecryptedTrack(
input_video_path,
caption_track,
_sample_payload_bytes(caption_track.samples),
)
for caption_track in caption_tracks
]
return DecryptedMedia(
audio=audio,
video=await video_task,
captions=captions,
)
decrypt_file = decrypt_wrapper
def decrypt_samples_hex(
samples: List[SampleInfo],
keys: dict,
encryption_info: EncryptionInfo,
encryption_info_per_desc: Optional[dict] = None,
) -> bytes:
"""Decrypt samples using hex AES keys (no wrapper needed).
Supports both CENC (AES-128-CTR) and CBCS (AES-128-CBC) schemes.
Args:
samples: List of SampleInfo with data, desc_index, iv, subsamples.
keys: Mapping of desc_index (int) → AES key (16 bytes, raw).
encryption_info: EncryptionInfo with scheme_type, constant_iv, etc.
encryption_info_per_desc: Optional dict mapping desc_index → EncryptionInfo
(used when different stsd entries have different params).
Returns:
Concatenated decrypted sample data.
"""
decrypted = bytearray()
for sample in samples:
key = keys.get(sample.desc_index)
if key is None:
# No key for this desc_index — keep data as-is (shouldn't happen)
decrypted.extend(_sample_data(sample))
continue
# Get encryption info for this sample's desc_index (if per-description info exists)
if encryption_info_per_desc and sample.desc_index in encryption_info_per_desc:
enc_info = encryption_info_per_desc[sample.desc_index]
else:
enc_info = encryption_info
if not sample.data and sample.data_path:
sample = _with_sample_data(sample, _sample_data(sample))
is_cenc = enc_info.scheme_type == "cenc"
if is_cenc:
# AES-128-CTR: per-sample IV from senc, zero-padded to 16 bytes
data = sample.data
iv = sample.iv
if len(iv) < 16:
iv = iv + b"\x00" * (16 - len(iv))
cipher = AES.new(key, AES.MODE_CTR, nonce=b"", initial_value=iv)
if sample.subsamples:
plaintext = bytearray()
offset = 0
for clear_bytes, encrypted_bytes in sample.subsamples:
plaintext.extend(data[offset : offset + clear_bytes])
offset += clear_bytes
plaintext.extend(
cipher.decrypt(data[offset : offset + encrypted_bytes])
)
offset += encrypted_bytes
plaintext.extend(data[offset:])
decrypted.extend(plaintext)
else:
decrypted.extend(cipher.decrypt(data))
else:
# CBCS (AES-128-CBC): constant IV or per-sample IV
if enc_info.crypt_byte_block and enc_info.skip_byte_block:
decrypted.extend(
_decrypt_cbcs_sample_with_pattern(sample, key, enc_info)
)
continue
iv = sample.iv if sample.iv else enc_info.constant_iv
if len(iv) < 16:
iv = iv + b"\x00" * (16 - len(iv))
parts = _cbcs_ciphertext_for_sample(sample)
if parts is None:
decrypted.extend(sample.data)
continue
aligned, tail = parts
plain = b""
if aligned:
cipher = AES.new(key, AES.MODE_CBC, iv=iv)
plain = cipher.decrypt(aligned)
decrypted.extend(_reassemble_cbcs_sample(sample, plain, tail))
return bytes(decrypted)
def _decrypt_sample_hex(
sample: SampleInfo,
key: Optional[bytes],
encryption_info: EncryptionInfo,
) -> bytes:
"""Decrypt one sample with a raw AES key."""
data = _sample_data(sample)
if data is not sample.data:
sample = _with_sample_data(sample, data)
if key is None:
return data
is_cenc = encryption_info.scheme_type == "cenc"
if is_cenc:
iv = sample.iv
if len(iv) < 16:
iv = iv + b"\x00" * (16 - len(iv))
cipher = AES.new(key, AES.MODE_CTR, nonce=b"", initial_value=iv)
if not sample.subsamples:
return cipher.decrypt(data)
plaintext = bytearray()
offset = 0
for clear_bytes, encrypted_bytes in sample.subsamples:
plaintext.extend(data[offset : offset + clear_bytes])
offset += clear_bytes
plaintext.extend(
cipher.decrypt(data[offset : offset + encrypted_bytes])
)
offset += encrypted_bytes
plaintext.extend(data[offset:])
return bytes(plaintext)
if encryption_info.crypt_byte_block and encryption_info.skip_byte_block:
return _decrypt_cbcs_sample_with_pattern(sample, key, encryption_info)
iv = sample.iv if sample.iv else encryption_info.constant_iv
if len(iv) < 16:
iv = iv + b"\x00" * (16 - len(iv))
parts = _cbcs_ciphertext_for_sample(sample)
if parts is None:
return sample.data
aligned, tail = parts
plain = b""
if aligned:
cipher = AES.new(key, AES.MODE_CBC, iv=iv)
plain = cipher.decrypt(aligned)
return _reassemble_cbcs_sample(sample, plain, tail)
def decrypt_samples_hex_to_file(
samples: List[SampleInfo],
keys: dict,
encryption_info: EncryptionInfo,
output_path: str,
encryption_info_per_desc: Optional[dict] = None,
release_sample_data: bool = False,
) -> int:
"""Decrypt samples to a raw payload file without building one large bytes object."""
bytes_written = 0
with open(output_path, "wb") as f:
for sample in samples:
enc_info = (
encryption_info_per_desc[sample.desc_index]
if encryption_info_per_desc
and sample.desc_index in encryption_info_per_desc
else encryption_info
)
decrypted_sample = _decrypt_sample_hex(
sample,
keys.get(sample.desc_index),
enc_info,
)
f.write(decrypted_sample)
sample.size = len(decrypted_sample)
bytes_written += len(decrypted_sample)
if release_sample_data:
sample.data = b""
sample.subsamples = []
sample.iv = b""
return bytes_written
+436
View File
@@ -0,0 +1,436 @@
import asyncio
import multiprocessing
import queue
import re
import shutil
import traceback
from pathlib import Path
import structlog
from mutagen.mp4 import MP4, MP4Cover
from yt_dlp import YoutubeDL
from yt_dlp.downloader.hls import HlsFD
from yt_dlp.downloader.http import HttpFD
from ..interface.enums import CoverFormat
from ..interface.interface import AppleMusicInterface
from ..interface.types import MediaTags, PlaylistTags
from ..utils import CustomStringFormatter, async_subprocess
from .constants import ILLEGAL_CHAR_REPLACEMENT, ILLEGAL_CHARS_RE, TEMP_PATH_TEMPLATE
from .enums import DownloadMode
logger = structlog.get_logger(__name__)
def _download_ytdlp_process(
stream_url: str,
download_path: str,
silent: bool,
result_queue,
) -> None:
try:
Path(download_path).parent.mkdir(parents=True, exist_ok=True)
with YoutubeDL(
{
"quiet": True,
"no_warnings": True,
"overwrites": True,
"noprogress": silent,
"allow_unplayable_formats": True,
"concurrent_fragment_downloads": 8,
}
) as ydl:
if stream_url.split("?")[0].endswith(".m3u8"):
hls_downloader = HlsFD(ydl, ydl.params)
success, _ = hls_downloader.download(
download_path,
{
"url": stream_url,
"ext": "mp4",
"protocol": "m3u8",
},
)
if not success:
raise RuntimeError("yt-dlp HLS download failed")
else:
http_downloader = HttpFD(ydl, ydl.params)
success, _ = http_downloader.download(
download_path,
{
"url": stream_url,
},
)
if not success:
raise RuntimeError("yt-dlp HTTP download failed")
except Exception as e:
result_queue.put(("error", repr(e), traceback.format_exc()))
class AppleMusicBaseDownloader:
def __init__(
self,
interface: AppleMusicInterface,
output_path: str = "./Apple Music",
temp_path: str = ".",
nm3u8dlre_path: str = "N_m3u8DL-RE",
ffmpeg_path: str = "ffmpeg",
download_mode: DownloadMode = DownloadMode.YTDLP,
album_folder_template: str = "{album_artist}/{album}",
compilation_folder_template: str = "Compilations/{album}",
no_album_folder_template: str = "{artist}/Unknown Album",
playlist_folder_template: str = "Playlists/{playlist_artist}",
single_disc_file_template: str = "{track:02d} {title}",
multi_disc_file_template: str = "{disc}-{track:02d} {title}",
no_album_file_template: str = "{title}",
playlist_file_template: str = "{playlist_title}",
date_tag_template: str = "%Y-%m-%dT%H:%M:%SZ",
exclude_tags: list[str] = None,
truncate: int = None,
silent: bool = False,
):
self.interface = interface
self.output_path = output_path
self.temp_path = temp_path
self.nm3u8dlre_path = nm3u8dlre_path
self.ffmpeg_path = ffmpeg_path
self.download_mode = download_mode
self.album_folder_template = album_folder_template
self.compilation_folder_template = compilation_folder_template
self.no_album_folder_template = no_album_folder_template
self.single_disc_file_template = single_disc_file_template
self.multi_disc_file_template = multi_disc_file_template
self.playlist_folder_template = playlist_folder_template
self.no_album_file_template = no_album_file_template
self.playlist_file_template = playlist_file_template
self.date_tag_template = date_tag_template
self.exclude_tags = exclude_tags
self.truncate = truncate
self.silent = silent
self._initialize_binary_paths()
def _initialize_binary_paths(self):
log = logger.bind(action="initialize_binary_paths")
self.full_nm3u8dlre_path = shutil.which(self.nm3u8dlre_path)
self.full_ffmpeg_path = shutil.which(self.ffmpeg_path)
log = log.debug(
"success",
full_nm3u8dlre_path=self.full_nm3u8dlre_path,
full_ffmpeg_path=self.full_ffmpeg_path,
)
def get_temp_path(
self,
media_id: str,
folder_tag: str,
file_tag: str,
file_extension: str,
) -> str:
log = logger.bind(action="get_temp_path")
temp_path = str(
Path(self.temp_path)
/ TEMP_PATH_TEMPLATE.format(folder_tag)
/ (f"{media_id}_{file_tag}" + file_extension)
)
log.debug("success", temp_path=temp_path)
return temp_path
def _sanitize_string(
self,
dirty_string: str,
file_ext: str = None,
) -> str:
sanitized_string = re.sub(
ILLEGAL_CHARS_RE,
ILLEGAL_CHAR_REPLACEMENT,
dirty_string,
)
if file_ext is None:
sanitized_string = sanitized_string[: self.truncate]
if sanitized_string.endswith("."):
sanitized_string = sanitized_string[:-1] + ILLEGAL_CHAR_REPLACEMENT
else:
if self.truncate is not None:
sanitized_string = sanitized_string[: self.truncate - len(file_ext)]
sanitized_string += file_ext
return sanitized_string.strip()
def get_final_path(
self,
tags: MediaTags,
file_extension: str,
playlist_tags: PlaylistTags | None,
) -> str:
log = logger.bind(action="get_final_path")
if tags.album:
template_folder_parts = (
self.compilation_folder_template.split("/")
if tags.compilation
else self.album_folder_template.split("/")
)
else:
template_folder_parts = self.no_album_folder_template.split("/")
if tags.album:
template_file_parts = (
self.multi_disc_file_template.split("/")
if isinstance(tags.disc_total, int) and tags.disc_total > 1
else self.single_disc_file_template.split("/")
)
else:
template_file_parts = self.no_album_file_template.split("/")
template_parts = template_folder_parts + template_file_parts
formatted_parts = []
for i, part in enumerate(template_parts):
is_folder = i < len(template_parts) - 1
formatted_part = CustomStringFormatter().format(
part,
album=(tags.album, "Unknown Album"),
album_artist=(tags.album_artist, "Unknown Artist"),
album_id=(tags.album_id, "Unknown Album ID"),
artist=(tags.artist, "Unknown Artist"),
artist_id=(tags.artist_id, "Unknown Artist ID"),
composer=(tags.composer, "Unknown Composer"),
composer_id=(tags.composer_id, "Unknown Composer ID"),
date=(tags.date, "Unknown Date"),
disc=(tags.disc, ""),
disc_total=(tags.disc_total, ""),
media_type=(tags.media_type, "Unknown Media Type"),
playlist_artist=(
(playlist_tags.artist if playlist_tags else None),
"Unknown Playlist Artist",
),
playlist_id=(
(playlist_tags.playlist_id if playlist_tags else None),
"Unknown Playlist ID",
),
playlist_title=(
(playlist_tags.title if playlist_tags else None),
"Unknown Playlist Title",
),
playlist_track=(
(playlist_tags.track if playlist_tags else None),
"",
),
title=(tags.title, "Unknown Title"),
title_id=(tags.title_id, "Unknown Title ID"),
track=(tags.track, ""),
track_total=(tags.track_total, ""),
)
sanitized_formatted_part = self._sanitize_string(
formatted_part,
file_extension if not is_folder else None,
)
formatted_parts.append(sanitized_formatted_part)
final_path = str(Path(self.output_path, *formatted_parts))
log.debug("success", final_path=final_path)
return final_path
async def download_stream(
self,
stream_url: str,
download_path: str,
):
log = logger.bind(
action="download_stream", stream_url=stream_url, download_path=download_path
)
stream_url_stripped = stream_url.split("?")[0]
if (
self.download_mode == DownloadMode.YTDLP
or not stream_url_stripped.endswith(".m3u8")
):
await self._download_ytdlp_async(
stream_url,
download_path,
)
elif self.download_mode == DownloadMode.NM3U8DLRE:
await self._download_nm3u8dlre(stream_url, download_path)
log.debug("success")
async def _download_ytdlp_async(
self,
stream_url: str,
download_path: str,
) -> None:
ctx = multiprocessing.get_context()
result_queue = ctx.Queue()
process = ctx.Process(
target=_download_ytdlp_process,
args=(stream_url, download_path, self.silent, result_queue),
)
process.start()
try:
while process.is_alive():
await asyncio.sleep(0.1)
process.join()
try:
status, error_repr, error_traceback = result_queue.get_nowait()
except queue.Empty:
status = None
if status == "error":
raise RuntimeError(
f"yt-dlp failed: {error_repr}\n{error_traceback}"
) from None
if process.exitcode != 0:
raise RuntimeError(f"yt-dlp exited with code {process.exitcode}")
finally:
if process.is_alive():
process.terminate()
await asyncio.to_thread(process.join, 5)
if process.is_alive():
process.kill()
await asyncio.to_thread(process.join)
process.close()
async def _download_nm3u8dlre(self, stream_url: str, download_path: str):
download_path_obj = Path(download_path)
download_path_obj.parent.mkdir(parents=True, exist_ok=True)
await async_subprocess(
self.full_nm3u8dlre_path,
stream_url,
"--binary-merge",
"--no-log",
"--log-level",
"off",
"--ffmpeg-binary-path",
self.full_ffmpeg_path,
"--save-name",
download_path_obj.stem,
"--save-dir",
download_path_obj.parent,
"--tmp-dir",
download_path_obj.parent,
silent=self.silent,
)
async def apply_tags(
self,
media_path: str,
tags: MediaTags,
cover_bytes: bytes | None,
):
log = logger.bind(action="apply_tags", media_path=media_path)
exclude_tags = self.exclude_tags or []
filtered_tags = MediaTags(
**{
k: v
for k, v in tags.__dict__.items()
if v is not None and k not in exclude_tags
}
)
mp4_tags = filtered_tags.as_mp4_tags(self.date_tag_template)
skip_tagging = "all" in exclude_tags
await asyncio.to_thread(
self._apply_mp4_tags,
media_path,
mp4_tags,
cover_bytes,
skip_tagging,
)
log.debug("success")
def _apply_mp4_tags(
self,
media_path: str,
tags: dict,
cover_bytes: bytes | None,
skip_tagging: bool,
):
mp4 = MP4(media_path)
if not skip_tagging:
if cover_bytes is not None:
mp4["covr"] = [
MP4Cover(
data=cover_bytes,
imageformat=(
MP4Cover.FORMAT_JPEG
if self.interface.base.cover_format == CoverFormat.JPG
else MP4Cover.FORMAT_PNG
),
)
]
mp4.update(tags)
mp4.save()
async def _apply_cover(
self,
mp4: MP4,
cover_bytes: bytes | None,
) -> None:
if cover_bytes is None:
return
mp4["covr"] = [
MP4Cover(
data=cover_bytes,
imageformat=(
MP4Cover.FORMAT_JPEG
if self.interface.base.cover_format == CoverFormat.JPG
else MP4Cover.FORMAT_PNG
),
)
]
def get_playlist_file_path(
self,
tags: PlaylistTags,
) -> str:
log = logger.bind(action="get_playlist_file_path")
template_folder_parts = self.playlist_folder_template.split("/")
template_file_parts = self.playlist_file_template.split("/")
template_parts = template_folder_parts + template_file_parts
formatted_parts = []
for i, part in enumerate(template_parts):
is_folder = i < len(template_parts) - 1
formatted_part = CustomStringFormatter().format(
part,
playlist_artist=(tags.artist, "Unknown Playlist Artist"),
playlist_id=(tags.playlist_id, "Unknown Playlist ID"),
playlist_title=(tags.title, "Unknown Playlist Title"),
playlist_track=(tags.track, ""),
)
file_ext = None if is_folder else ".m3u"
sanitized_formatted_part = self._sanitize_string(
formatted_part,
file_ext,
)
formatted_parts.append(sanitized_formatted_part)
final_path = str(Path(self.output_path, *formatted_parts))
log.debug("success", playlist_file_path=final_path)
return final_path
@@ -0,0 +1,3 @@
TEMP_PATH_TEMPLATE = "gamdl_temp_{}"
ILLEGAL_CHARS_RE = r'[\\/:*?"<>|;]'
ILLEGAL_CHAR_REPLACEMENT = "_"
@@ -0,0 +1,254 @@
import shutil
from pathlib import Path
from typing import AsyncGenerator
import structlog
from ..interface.types import AppleMusicMedia
from .constants import TEMP_PATH_TEMPLATE
from .enums import DownloadMode
from .exceptions import (
GamdlDownloaderDependencyNotFoundError,
GamdlDownloaderMediaFileExistsError,
GamdlDownloaderSyncedLyricsOnlyError,
)
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,
song: AppleMusicSongDownloader,
music_video: AppleMusicMusicVideoDownloader,
uploaded_video: AppleMusicUploadedVideoDownloader,
overwrite: bool = False,
save_cover: bool = False,
save_playlist: bool = False,
no_synced_lyrics: bool = False,
synced_lyrics_only: bool = False,
skip_cleanup: bool = False,
skip_processing: bool = False,
):
self.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.base = song.base
async def get_download_item_from_url(
self,
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 media.error:
return DownloadItem(media)
if media.partial:
return DownloadItem(media)
elif media.media_metadata["type"] in {"songs", "library-songs"}:
return await self.song.get_download_item(media)
elif media.media_metadata["type"] in {
"music-videos",
"library-music-videos",
}:
return await self.music_video.get_download_item(media)
elif media.media_metadata["type"] in {"uploaded-videos"}:
return await self.uploaded_video.get_download_item(media)
async def download(self, item: DownloadItem) -> None:
try:
if item.media.error:
raise item.media.error
if item.media.partial:
return
await self._initial_processing(item)
await self._download(item)
await self._final_processing(item)
finally:
if not self.skip_cleanup:
self._cleanup_temp(item.uuid_)
def _update_playlist_file(
self,
playlist_file_path: str,
final_path: str,
playlist_track: int,
) -> None:
log = logger.bind(
action="update_playlist_file",
playlist_file_path=playlist_file_path,
final_path=final_path,
playlist_track=playlist_track,
)
playlist_file_path_obj = Path(playlist_file_path)
final_path_obj = Path(final_path)
output_dir_obj = Path(self.base.output_path)
playlist_file_path_obj.parent.mkdir(parents=True, exist_ok=True)
playlist_file_path_parent_parts_len = len(playlist_file_path_obj.parent.parts)
output_path_parts_len = len(output_dir_obj.parts)
final_path_relative = Path(
("../" * (playlist_file_path_parent_parts_len - output_path_parts_len)),
*final_path_obj.parts[output_path_parts_len:],
)
playlist_file_lines = (
playlist_file_path_obj.open("r", encoding="utf8").readlines()
if playlist_file_path_obj.exists()
else []
)
if len(playlist_file_lines) < playlist_track:
playlist_file_lines.extend(
"\n" for _ in range(playlist_track - len(playlist_file_lines))
)
playlist_file_lines[playlist_track - 1] = final_path_relative.as_posix() + "\n"
with playlist_file_path_obj.open("w", encoding="utf8") as playlist_file:
playlist_file.writelines(playlist_file_lines)
log.debug("success")
def _write_cover(self, cover_path: str, cover_bytes: bytes) -> None:
log = logger.bind(action="write_cover_file", cover_path=cover_path)
Path(cover_path).parent.mkdir(parents=True, exist_ok=True)
with open(cover_path, "wb") as f:
f.write(cover_bytes)
log.debug("success")
def _write_synced_lyrics(self, synced_lyrics_path: str, lyrics: str) -> None:
log = logger.bind(
action="write_synced_lyrics",
synced_lyrics_path=synced_lyrics_path,
)
Path(synced_lyrics_path).parent.mkdir(parents=True, exist_ok=True)
with open(synced_lyrics_path, "w", encoding="utf-8") as f:
f.write(lyrics)
log.debug("success")
async def _initial_processing(self, item: DownloadItem) -> None:
if self.skip_processing:
return
if 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,
)
if (
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._write_synced_lyrics(
item.synced_lyrics_path,
item.media.lyrics.synced,
)
async def _download(self, item: DownloadItem) -> None:
if item.media.error:
raise item.media.error
if self.synced_lyrics_only:
raise GamdlDownloaderSyncedLyricsOnlyError()
if Path(item.final_path).exists() and not self.overwrite:
raise GamdlDownloaderMediaFileExistsError(item.final_path)
if item.media.media_metadata["type"] in {
"music-videos",
"library-music-videos",
"songs",
"library-songs",
}:
if (
self.base.download_mode == DownloadMode.NM3U8DLRE
and not self.base.full_nm3u8dlre_path
):
raise GamdlDownloaderDependencyNotFoundError("N_m3u8DL-RE")
if item.media.media_metadata["type"] in {"songs", "library-songs"}:
await self.song.download(item)
elif item.media.media_metadata["type"] in {
"music-videos",
"library-music-videos",
}:
await self.music_video.download(item)
elif item.media.media_metadata["type"] in {"uploaded-videos"}:
await self.uploaded_video.download(item)
def _move_to_final_path(self, staged_path: str, final_path: str) -> None:
log = logger.bind(
action="move_to_final_path",
staged_path=staged_path,
final_path=final_path,
)
Path(final_path).parent.mkdir(parents=True, exist_ok=True)
shutil.move(staged_path, final_path)
log.debug("success")
async def _final_processing(
self,
item: DownloadItem,
) -> None:
if self.skip_processing:
return
if Path(item.staged_path).exists():
self._move_to_final_path(
item.staged_path,
item.final_path,
)
def _cleanup_temp(self, folder_tag: str) -> None:
log = logger.bind(action="cleanup_temp", folder_tag=folder_tag)
temp_path = Path(self.base.temp_path) / TEMP_PATH_TEMPLATE.format(folder_tag)
if temp_path.exists() and temp_path.is_dir():
shutil.rmtree(temp_path, ignore_errors=True)
log.debug("success")
+16
View File
@@ -0,0 +1,16 @@
from enum import Enum
class DownloadMode(Enum):
YTDLP = "ytdlp"
NM3U8DLRE = "nm3u8dlre"
class RemuxMode(Enum):
FFMPEG = "ffmpeg"
MP4BOX = "mp4box"
class RemuxFormatMusicVideo(Enum):
M4V = "m4v"
MP4 = "mp4"
@@ -0,0 +1,20 @@
from ..utils import GamdlError
class GamdlDownloaderError(GamdlError):
pass
class GamdlDownloaderSyncedLyricsOnlyError(GamdlDownloaderError):
def __init__(self) -> None:
super().__init__("Download mode is set to synced lyrics only")
class GamdlDownloaderMediaFileExistsError(GamdlDownloaderError):
def __init__(self, file_path: str) -> None:
super().__init__(f"Media file already exists: {file_path}")
class GamdlDownloaderDependencyNotFoundError(GamdlDownloaderError):
def __init__(self, dependency_name: str) -> None:
super().__init__(f"Required dependency not found: {dependency_name}")
@@ -0,0 +1,123 @@
from pathlib import Path
from ..interface.enums import CoverFormat
from ..interface.types import AppleMusicMedia, DecryptionKeyAv
from .amdecrypt import decrypt_file_hex, write_decrypted_media
from .base import AppleMusicBaseDownloader
from .enums import RemuxFormatMusicVideo, RemuxMode
from .types import DownloadItem
class AppleMusicMusicVideoDownloader:
def __init__(
self,
base: AppleMusicBaseDownloader,
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
):
self.base = base
self.remux_format = remux_format
async def stage(
self,
encrypted_path_video: str,
encrypted_path_audio: str,
staged_path: str,
decryption_key: DecryptionKeyAv,
is_m4v: bool = False,
):
decrypted_media = await decrypt_file_hex(
decryption_key.audio_track.key,
encrypted_path_audio,
decryption_key.video_track.key,
encrypted_path_video,
)
await write_decrypted_media(
decrypted_media,
staged_path,
m4v_brand=is_m4v,
)
def get_cover_path(
self,
final_path: str,
file_extension: str,
) -> str:
return str(Path(final_path).with_suffix(file_extension))
async def get_download_item(
self,
media: AppleMusicMedia,
) -> DownloadItem:
download_item = DownloadItem(media)
download_item.staged_path = self.base.get_temp_path(
media.media_metadata["id"],
download_item.uuid_,
"staged",
"." + media.stream_info.file_format.value,
)
download_item.final_path = self.base.get_final_path(
media.tags,
"." + media.stream_info.file_format.value,
media.playlist_tags,
)
if media.playlist_tags:
download_item.playlist_file_path = self.base.get_playlist_file_path(
media.playlist_tags,
)
download_item.cover_path = self.get_cover_path(
download_item.final_path,
media.cover.file_extension,
)
return download_item
async def download(
self,
download_item: DownloadItem,
) -> None:
encrypted_path_video = self.base.get_temp_path(
download_item.media.media_metadata["id"],
download_item.uuid_,
"encrypted_video",
".mp4",
)
encrypted_path_audio = self.base.get_temp_path(
download_item.media.media_metadata["id"],
download_item.uuid_,
"encrypted_audio",
".m4a",
)
await self.base.download_stream(
download_item.media.stream_info.video_track.stream_url,
encrypted_path_video,
)
await self.base.download_stream(
download_item.media.stream_info.audio_track.stream_url,
encrypted_path_audio,
)
await self.stage(
encrypted_path_video,
encrypted_path_audio,
download_item.staged_path,
download_item.media.decryption_key,
download_item.staged_path.endswith(".m4v"),
)
cover_bytes = (
await self.base.interface.base.get_cover_bytes(
download_item.media.cover.url
)
if self.base.interface.base.cover_format != CoverFormat.RAW
else None
)
await self.base.apply_tags(
download_item.staged_path,
download_item.media.tags,
cover_bytes,
)
+200
View File
@@ -0,0 +1,200 @@
from pathlib import Path
import structlog
from ..interface.enums import CoverFormat
from ..interface.types import AppleMusicMedia, DecryptionKeyAv
from .amdecrypt import decrypt_file_hex, decrypt_wrapper, write_decrypted_media
from .base import AppleMusicBaseDownloader
from .types import DownloadItem
logger = structlog.get_logger(__name__)
class AppleMusicSongDownloader:
def __init__(
self,
base: AppleMusicBaseDownloader,
):
self.base = base
async def get_download_item(self, media: AppleMusicMedia) -> DownloadItem:
download_item = DownloadItem(media)
if media.stream_info:
download_item.staged_path = self.base.get_temp_path(
media.media_metadata["id"],
download_item.uuid_,
"staged",
"." + media.stream_info.file_format.value,
)
download_item.final_path = self.base.get_final_path(
media.tags,
".m4a",
media.playlist_tags,
)
if media.playlist_tags:
download_item.playlist_file_path = self.base.get_playlist_file_path(
media.playlist_tags,
)
download_item.synced_lyrics_path = self.get_synced_lyrics_path(
download_item.final_path
)
download_item.cover_path = self.get_cover_path(
download_item.final_path,
media.cover.file_extension,
)
return download_item
async def _decrypt_amdecrypt(
self,
input_path: str,
output_path: str,
media_id: str,
fairplay_key: str,
use_single_content_key: bool = False,
) -> None:
wrapper_api = self.base.interface.base.wrapper_api
if wrapper_api is None:
raise ValueError("wrapper_api is required for FairPlay decrypt")
decrypted_media = await decrypt_wrapper(
wrapper_api,
media_id,
input_path,
fairplay_key_audio=fairplay_key,
use_single_content_key=use_single_content_key,
)
await write_decrypted_media(decrypted_media, output_path)
async def _decrypt_amdecrypt_hex(
self,
input_path: str,
output_path: str,
decryption_key: str,
*,
use_cenc: bool = False,
use_single_content_key: bool = False,
) -> None:
decrypted_media = await decrypt_file_hex(
decryption_key,
input_path,
use_cenc=use_cenc,
use_single_content_key=use_single_content_key,
)
await write_decrypted_media(decrypted_media, output_path)
async def stage(
self,
encrypted_path: str,
staged_path: str,
media_id: str,
decryption_key: DecryptionKeyAv | None = None,
fairplay_key: str = None,
use_cenc: bool = False,
use_single_content_key: bool = False,
):
log = logger.bind(
action="stage_song",
media_id=media_id,
encrypted_path=encrypted_path,
staged_path=staged_path,
)
if decryption_key:
await self._decrypt_amdecrypt_hex(
encrypted_path,
staged_path,
decryption_key.audio_track.key,
use_cenc=use_cenc,
use_single_content_key=use_single_content_key,
)
else:
await self._decrypt_amdecrypt(
encrypted_path,
staged_path,
media_id,
fairplay_key,
use_single_content_key=use_single_content_key,
)
log.debug("success")
def get_synced_lyrics_path(self, final_path: str) -> str:
log = logger.bind(action="get_synced_lyrics_path", final_path=final_path)
synced_lyrics_path = str(
Path(final_path).with_suffix(
"." + self.base.interface.song.synced_lyrics_format.value
)
)
log.debug("success", synced_lyrics_path=synced_lyrics_path)
return synced_lyrics_path
def get_cover_path(
self,
final_path: str,
file_extension: str,
) -> str:
log = logger.bind(
action="get_song_cover_path",
final_path=final_path,
file_extension=file_extension,
)
cover_path = str(Path(final_path).parent / ("Cover" + file_extension))
log.debug("success", cover_path=cover_path)
return cover_path
async def download(
self,
download_item: DownloadItem,
) -> None:
if download_item.media.stream_info.audio_track.drm_free:
await self.base.download_stream(
download_item.media.stream_info.audio_track.stream_url,
download_item.staged_path,
)
else:
encrypted_path = self.base.get_temp_path(
download_item.media.media_metadata["id"],
download_item.uuid_,
"encrypted",
".m4a",
)
await self.base.download_stream(
download_item.media.stream_info.audio_track.stream_url,
encrypted_path,
)
await self.stage(
encrypted_path,
download_item.staged_path,
download_item.media.media_id,
download_item.media.decryption_key,
download_item.media.stream_info.audio_track.fairplay_key,
download_item.media.stream_info.audio_track.use_cenc,
download_item.media.stream_info.audio_track.use_single_content_key,
)
cover_bytes = (
await self.base.interface.base.get_cover_bytes(
download_item.media.cover.url
)
if self.base.interface.base.cover_format != CoverFormat.RAW
else None
)
await self.base.apply_tags(
download_item.staged_path,
download_item.media.tags,
cover_bytes,
)
+15
View File
@@ -0,0 +1,15 @@
import uuid
from dataclasses import dataclass, field
from ..interface.types import AppleMusicMedia
@dataclass
class DownloadItem:
media: AppleMusicMedia
uuid_: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
staged_path: str = None
final_path: str = None
playlist_file_path: str = None
synced_lyrics_path: str = None
cover_path: str = None
@@ -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_stream(
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,
)
@@ -0,0 +1,8 @@
from .base import AppleMusicBaseInterface
from .enums 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
+407
View File
@@ -0,0 +1,407 @@
import asyncio
import base64
import datetime
import re
from io import BytesIO
import httpx
import structlog
from async_lru import alru_cache
from PIL import Image
from pywidevine import PSSH, Cdm, Device
from pywidevine.license_protocol_pb2 import WidevinePsshData
from gamdl.interface.wvd import WVD
from ..api.apple_music import AppleMusicApi
from ..api.itunes import ItunesApi
from ..api.wrapper import WrapperApi
from .constants import IMAGE_FILE_EXTENSION_MAP
from .enums import CoverFormat
from .types import Cover, DecryptionKey, MediaRating, MediaTags, MediaType, PlaylistTags
logger = structlog.get_logger(__name__)
class AppleMusicBaseInterface:
def __init__(
self,
apple_music_api: AppleMusicApi,
itunes_api: ItunesApi,
wrapper_api: WrapperApi | None,
cover_format: CoverFormat,
cover_size: int,
cdm: Cdm,
) -> None:
self.apple_music_api = apple_music_api
self.itunes_api = itunes_api
self.cover_format = cover_format
self.cover_size = cover_size
self.cdm = cdm
self.wrapper_api = wrapper_api
@staticmethod
def create_cdm(wvd_path: str | None = None) -> Cdm:
if wvd_path:
cdm = Cdm.from_device(Device.load(wvd_path))
else:
cdm = Cdm.from_device(Device.loads(WVD))
cdm.MAX_NUM_OF_SESSIONS = float("inf")
return cdm
@staticmethod
def is_media_streamable(
media_metadata: dict,
) -> bool:
return bool(media_metadata["attributes"].get("playParams"))
@staticmethod
def parse_media_id_from_url(media_metadata: dict) -> str | None:
media_url = media_metadata["attributes"].get("url")
if media_url is None:
return None
url_media_id = media_url.split("/")[-1].split("?")[0]
return url_media_id
@staticmethod
def parse_date(date: str) -> datetime.datetime:
return datetime.datetime.fromisoformat(date.split("Z")[0])
@staticmethod
def reconstruct_pssh(pssh: str) -> bytes:
pssh = pssh.split(",")[-1]
decoded_pssh = base64.b64decode(pssh)
if len(decoded_pssh) > 30:
return pssh
widevine_pssh_data = WidevinePsshData(
algorithm=1,
key_ids=[decoded_pssh],
)
return widevine_pssh_data.SerializeToString()
@staticmethod
async def get_response(
url: str,
valid_responses: list[int] = [200],
) -> httpx.Response:
async with httpx.AsyncClient(timeout=60.0) as client:
try:
response = await client.get(url)
response.raise_for_status()
except httpx.HTTPStatusError as e:
if e.response.status_code in valid_responses:
return e.response
raise e
return response
@staticmethod
def format_cover(
template_cover_url: str,
cover_size: int,
cover_format: CoverFormat,
) -> str:
return re.sub(
r"/\{w\}x\{h\}([a-z]{2})\.jpg",
f"/{cover_size}x{cover_size}bb.{cover_format.value}",
template_cover_url,
)
@staticmethod
def get_catalog_metadata_from_library(library_metadata: dict) -> dict | None:
data = library_metadata.get("relationships", {}).get("catalog", {}).get("data")
if not data:
return None
return data[0]
@classmethod
async def create(
cls,
apple_music_api: AppleMusicApi,
cover_format: CoverFormat = CoverFormat.JPG,
cover_size: int = 1200,
wvd_path: str | None = None,
itunes_api: ItunesApi | None = None,
wrapper_api: WrapperApi | None = None,
):
itunes_api = itunes_api or await ItunesApi.create(
storefront=apple_music_api.storefront,
language=apple_music_api.language,
**(
{"storefront_id": None}
if apple_music_api.storefront.lower() != "us"
else {}
),
)
cdm = cls.create_cdm(wvd_path)
base = cls(
apple_music_api=apple_music_api,
itunes_api=itunes_api,
cover_format=cover_format,
cover_size=cover_size,
cdm=cdm,
wrapper_api=wrapper_api,
)
return base
@alru_cache()
async def get_album_cached(
self,
album_id: int,
) -> dict | None:
return (await self.apple_music_api.get_album(album_id))["data"][0]
async def get_decryption_key(
self,
pssh: str,
track_id: str,
) -> DecryptionKey:
log = logger.bind(action="get_decryption_key", track_id=track_id)
reconstructed_pssh = self.reconstruct_pssh(pssh)
cdm_session = self.cdm.open()
try:
pssh_obj = PSSH(reconstructed_pssh)
challenge = base64.b64encode(
await asyncio.to_thread(
self.cdm.get_license_challenge, cdm_session, pssh_obj
)
).decode()
license = await self.apple_music_api.get_license_exchange(
track_id,
pssh,
challenge,
)
await asyncio.to_thread(
self.cdm.parse_license, cdm_session, license["license"]
)
decryption_key_info = next(
i for i in self.cdm.get_keys(cdm_session) if i.type == "CONTENT"
)
finally:
self.cdm.close(cdm_session)
decryption_key = DecryptionKey(
key=decryption_key_info.key.hex(),
kid=decryption_key_info.kid.hex,
)
log.debug("success", decryption_key=decryption_key)
return decryption_key
@alru_cache()
async def get_cover_bytes(self, cover_url: str) -> bytes | None:
log = logger.bind(action="get_cover_bytes", cover_url=cover_url)
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(cover_url, follow_redirects=True)
if response.status_code == 404:
log.debug("cover_not_found")
return None
response.raise_for_status()
return response.content
def _get_cover_template_url(self, metadata: dict) -> str:
if self.cover_format == CoverFormat.RAW:
cover_template_url = self._get_raw_cover_url(
metadata["attributes"]["artwork"]["url"]
)
else:
cover_template_url = metadata["attributes"]["artwork"]["url"]
return cover_template_url
def _get_raw_cover_url(self, cover_url_template: str) -> str:
return re.sub(
r"/\{w\}x\{h\}bb\.jpg",
"",
re.sub(
r"image/thumb/",
"",
re.sub(
r"is1-ssl",
"a1",
cover_url_template,
),
),
)
@alru_cache()
async def _get_cover_file_extension(
self,
cover_url: str,
) -> str | None:
log = logger.bind(action="get_cover_file_extension", cover_url=cover_url)
if self.cover_format != CoverFormat.RAW:
return f".{self.cover_format.value}"
cover_bytes = await self.get_cover_bytes(cover_url)
if cover_bytes is None:
log.debug("cover_bytes_empty")
return None
image_obj = Image.open(BytesIO(cover_bytes))
image_format = image_obj.format.lower()
return IMAGE_FILE_EXTENSION_MAP.get(
image_format,
f".{image_format.lower()}",
)
async def get_cover(
self,
metadata: dict,
) -> str:
log = logger.bind(action="get_cover", media_id=metadata["id"])
template_url = self._get_cover_template_url(metadata)
if self.cover_format == CoverFormat.RAW:
cover_url = template_url
else:
cover_url = self.format_cover(
template_url,
self.cover_size,
self.cover_format,
)
cover_file_extension = await self._get_cover_file_extension(cover_url)
cover = Cover(
template_url=template_url,
url=cover_url,
file_extension=cover_file_extension,
)
log.debug("success", cover=cover)
return cover
@alru_cache()
async def get_media_date(
self,
media_id: str,
) -> datetime.datetime | None:
log = logger.bind(action="get_media_date", media_id=media_id)
lookup_result = await self.itunes_api.get_lookup_result(media_id)
if not lookup_result["results"]:
log.debug("no_media_id")
return None
release_date = lookup_result["results"][0].get("releaseDate")
if not release_date:
log.debug("no_release_date")
return None
parsed_date = self.parse_date(release_date)
log.debug("success", release_date=parsed_date)
return parsed_date
def get_playlist_tags(
self,
playlist_metadata: dict,
playlist_track: int,
) -> PlaylistTags:
log = logger.bind(
action="get_playlist_tags",
playlist_id=playlist_metadata["id"],
)
playlist_tags = PlaylistTags(
artist=playlist_metadata["attributes"].get("curatorName", "Unknown"),
playlist_id=playlist_metadata["attributes"]["playParams"]["id"],
title=playlist_metadata["attributes"]["name"],
track=playlist_track,
)
log.debug("success", playlist_tags=playlist_tags)
return playlist_tags
async def get_tags_from_asset_info(
self,
asset_data: dict,
lyrics: str | None = None,
use_album_date: bool = False,
) -> MediaTags:
log = logger.bind(
action="get_tags_from_asset_info", asset_id=asset_data["itemId"]
)
date = None
if use_album_date:
if asset_data.get("playlistId"):
date = await self.get_media_date(asset_data["playlistId"])
else:
log.debug("no_playlist_id_for_album_date")
if date is None and asset_data.get("releaseDate"):
date = self.parse_date(asset_data["releaseDate"])
tags = MediaTags(
album=asset_data.get("playlistName"),
album_artist=asset_data.get("playlistArtistName"),
album_id=(
int(asset_data["playlistId"]) if asset_data.get("playlistId") else None
),
album_sort=asset_data.get("sort-album"),
artist=asset_data["artistName"],
artist_id=(
int(asset_data["artistId"]) if asset_data.get("artistId") else None
),
artist_sort=asset_data["sort-artist"],
comment=asset_data.get("comments"),
compilation=asset_data.get("compilation"),
composer=asset_data.get("composerName"),
composer_id=(
int(asset_data.get("composerId"))
if asset_data.get("composerId")
else None
),
composer_sort=asset_data.get("sort-composer"),
copyright=asset_data.get("copyright"),
date=date,
disc=asset_data.get("discNumber"),
disc_total=asset_data.get("discCount"),
gapless=asset_data.get("gapless"),
genre=asset_data.get("genre"),
genre_id=(
int(asset_data["genreId"]) if asset_data.get("genreId") else None
),
lyrics=lyrics if lyrics else None,
media_type=(
MediaType.SONG
if asset_data["kind"] == "song"
else MediaType.MUSIC_VIDEO
),
rating=MediaRating(asset_data["explicit"]),
storefront=(int(asset_data["s"]) if asset_data.get("s") else None),
title=asset_data["itemName"],
title_id=int(asset_data["itemId"]),
title_sort=asset_data["sort-name"],
track=asset_data.get("trackNumber"),
track_total=asset_data.get("trackCount"),
xid=asset_data.get("xid"),
)
log.debug("success", tags=tags)
return tags
@@ -0,0 +1,108 @@
import re
MEDIA_TYPE_STR_MAP = {
1: "Song",
6: "Music Video",
}
MEDIA_RATING_STR_MAP = {
0: "None",
1: "Explicit",
2: "Clean",
}
DRM_DEFAULT_KEY_MAPPING = {
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed": (
"data:text/plain;base64,AAAAOHBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAABgSEAAAAAA"
"AAAAAczEvZTEgICBI88aJmwY="
),
"com.microsoft.playready": (
"data:text/plain;charset=UTF-16;base64,vgEAAAEAAQC0ATwAVwBSAE0ASABFAEEARABF"
"AFIAIAB4AG0AbABuAHMAPQAiAGgAdAB0AHAAOgAvAC8AcwBjAGgAZQBtAGEAcwAuAG0AaQBjAH"
"IAbwBzAG8AZgB0AC4AYwBvAG0ALwBEAFIATQAvADIAMAAwADcALwAwADMALwBQAGwAYQB5AFIA"
"ZQBhAGQAeQBIAGUAYQBkAGUAcgAiACAAdgBlAHIAcwBpAG8AbgA9ACIANAAuADMALgAwAC4AMA"
"AiAD4APABEAEEAVABBAD4APABQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsASQBEAFMAPgA8"
"AEsASQBEACAAQQBMAEcASQBEAD0AIgBBAEUAUwBDAEIAQwAiACAAVgBBAEwAVQBFAD0AIgBBAE"
"EAQQBBAEEAQQBBAEEAQQBBAEIAegBNAFMAOQBsAE0AUwBBAGcASQBBAD0APQAiAD4APAAvAEsA"
"SQBEAD4APAAvAEsASQBEAFMAPgA8AC8AUABSAE8AVABFAEMAVABJAE4ARgBPAD4APAAvAEQAQQ"
"BUAEEAPgA8AC8AVwBSAE0ASABFAEEARABFAFIAPgA="
),
"com.apple.streamingkeydelivery": "skd://itunes.apple.com/P000000000/s1/e1",
}
MP4_FORMAT_CODECS = ["ec-3", "hvc1", "audio-atmos", "audio-ec3"]
SONG_CODEC_REGEX_MAP = {
"aac": r"audio-stereo-\d+",
"aac-he": r"audio-HE-stereo-\d+",
"aac-binaural": r"audio-stereo-\d+-binaural",
"aac-downmix": r"audio-stereo-\d+-downmix",
"aac-he-binaural": r"audio-HE-stereo-\d+-binaural",
"aac-he-downmix": r"audio-HE-stereo-\d+-downmix",
"atmos": r"audio-atmos-.*",
"ac3": r"audio-ac3-.*",
"alac": r"audio-alac-.*",
"alac-16-44": r"audio-alac-stereo-1644100",
"alac-24-44": r"audio-alac-stereo-2444100",
"alac-24-48": r"audio-alac-stereo-2448000",
"alac-24-96": r"audio-alac-stereo-2496000",
"alac-24-192": r"audio-alac-stereo-24192000",
}
FOURCC_MAP = {
"h264": "avc1",
"h265": "hvc1",
}
UPLOADED_VIDEO_QUALITY_RANK = [
"1080pHdVideo",
"720pHdVideo",
"sdVideoWithPlusAudio",
"sdVideo",
"sd480pVideo",
"provisionalUploadVideo",
]
IMAGE_FILE_EXTENSION_MAP = {
"jpeg": ".jpg",
"tiff": ".tif",
}
VALID_URL_PATTERN = re.compile(
r"https://(?:classical\.)?music\.apple\.com"
r"(?:"
r"/(?P<storefront>[a-z]{2})"
r"/(?P<type>artist|album|playlist|song|music-video|post)"
r"(?:/(?P<slug>[^\s/]+))?"
r"/(?P<id>[0-9]+|pl\.[0-9a-z]{32}|pl\.u-[a-zA-Z0-9]+)"
r"(?:\?i=(?P<sub_id>[0-9]+))?"
r"|"
r"(?:/(?P<library_storefront>[a-z]{2}))?"
r"/library/(?P<library_type>playlist|albums|songs|music-videos)"
r"/(?P<library_id>[pli]\.[a-zA-Z0-9]+)"
r")"
)
ARTIST_AUTO_SELECT_KEY_MAP = {
"main-albums": ("views", "full-albums"),
"compilation-albums": ("views", "compilation-albums"),
"live-albums": ("views", "live-albums"),
"singles-eps": ("views", "singles"),
"all-albums": ("relationships", "albums"),
"top-songs": ("views", "top-songs"),
"music-videos": ("relationships", "music-videos"),
}
ARTIST_AUTO_SELECT_STR_MAP = {
"main-albums": "Main Albums",
"compilation-albums": "Compilation Albums",
"live-albums": "Live Albums",
"singles-eps": "Singles & EPs",
"all-albums": "All Albums",
"top-songs": "Top Songs",
"music-videos": "Music Videos",
}
MEDIA_CODEC_FLAVOR_MAP = {
"aac-web": "28:ctrp256",
"aac-he-web": "32:ctrp64",
"aac-fps-web": "30:cbcp256",
"aac-he-fps-web": "34:cbcp64",
}
+132
View File
@@ -0,0 +1,132 @@
from enum import Enum
from .constants import (
ARTIST_AUTO_SELECT_KEY_MAP,
ARTIST_AUTO_SELECT_STR_MAP,
FOURCC_MAP,
MEDIA_RATING_STR_MAP,
MEDIA_TYPE_STR_MAP,
MEDIA_CODEC_FLAVOR_MAP,
)
class SyncedLyricsFormat(Enum):
LRC = "lrc"
SRT = "srt"
TTML = "ttml"
class MediaType(Enum):
SONG = 1
MUSIC_VIDEO = 6
def __str__(self) -> str:
return MEDIA_TYPE_STR_MAP[self.value]
def __int__(self) -> int:
return self.value
class MediaRating(Enum):
NONE = 0
EXPLICIT = 1
CLEAN = 2
def __str__(self) -> str:
return MEDIA_RATING_STR_MAP[self.value]
def __int__(self) -> int:
return self.value
class MediaFileFormat(Enum):
MP4 = "mp4"
M4V = "m4v"
M4A = "m4a"
class SongCodec(Enum):
AAC_WEB = "aac-web"
AAC_HE_WEB = "aac-he-web"
# doesnt work with wrapper, gives ckc error
# AAC_FPS_WEB = "aac-fps-web"
# AAC_HE_FPS_WEB = "aac-he-fps-web"
AAC = "aac"
AAC_HE = "aac-he"
AAC_BINAURAL = "aac-binaural"
AAC_DOWNMIX = "aac-downmix"
AAC_HE_BINAURAL = "aac-he-binaural"
AAC_HE_DOWNMIX = "aac-he-downmix"
ATMOS = "atmos"
AC3 = "ac3"
ALAC = "alac"
ALAC_16_44 = "alac-16-44"
ALAC_24_44 = "alac-24-44"
ALAC_24_48 = "alac-24-48"
ALAC_24_96 = "alac-24-96"
ALAC_24_192 = "alac-24-192"
ASK = "ask"
@property
def is_web(self) -> bool:
return self.value.endswith("-web")
@property
def flavor(self) -> str | None:
return MEDIA_CODEC_FLAVOR_MAP.get(self.value)
@property
def is_cenc(self) -> bool:
return self.flavor is not None and "ctrp" in self.flavor
class MusicVideoCodec(Enum):
H264 = "h264"
H265 = "h265"
ASK = "ask"
@property
def fourcc(self) -> str | None:
return FOURCC_MAP.get(self.value)
class MusicVideoResolution(Enum):
R240P = "240p"
R360P = "360p"
R480P = "480p"
R540P = "540p"
R720P = "720p"
R1080P = "1080p"
R1440P = "1440p"
R2160P = "2160p"
def __int__(self) -> int:
return int(self.value[:-1])
class UploadedVideoQuality(Enum):
BEST = "best"
ASK = "ask"
class CoverFormat(Enum):
JPG = "jpg"
PNG = "png"
RAW = "raw"
class ArtistMediaType(Enum):
MAIN_ALBUMS = "main-albums"
COMPILATION_ALBUMS = "compilation-albums"
LIVE_ALBUMS = "live-albums"
SINGLES_EPS = "singles-eps"
ALL_ALBUMS = "all-albums"
TOP_SONGS = "top-songs"
MUSIC_VIDEOS = "music-videos"
@property
def path_key(self) -> tuple[str, str]:
return ARTIST_AUTO_SELECT_KEY_MAP[self.value]
def __str__(self) -> str:
return ARTIST_AUTO_SELECT_STR_MAP[self.value]
@@ -0,0 +1,53 @@
from ..utils import GamdlError
from typing import Any
class GamdlInterfaceError(GamdlError):
pass
class GamdlInterfaceMediaNotStreamableError(GamdlInterfaceError):
def __init__(self, media_id: str):
super().__init__(f"Media is not streamable: {media_id}")
class GamdlInterfaceFormatNotAvailableError(GamdlInterfaceError):
def __init__(self, media_id: str, codec: Any | None = None):
super().__init__(
f"Requested format is not available (media ID: {media_id}): {codec}"
)
class GamdlInterfaceDecryptionNotAvailableError(GamdlInterfaceError):
def __init__(self, media_id: str):
super().__init__(f"Decryption is not available for media ID: {media_id}")
class GamdlInterfaceMediaNotAllowedError(GamdlInterfaceError):
def __init__(self, media_type: str, media_id: str | None = None):
message = "Media type is disallowed"
if media_id:
message += f" (media ID: {media_id})"
super().__init__(f"{message}: {media_type}")
class GamdlInterfaceUrlParseError(GamdlInterfaceError):
def __init__(self, url: str):
super().__init__(f"URL is not valid or supported: {url}")
class GamdlInterfaceArtistMediaTypeError(GamdlInterfaceError):
def __init__(self, media_id: str, media_type: str):
super().__init__(
f"Artist has no media of type (media ID: {media_id}): {media_type}"
)
class GamdlInterfaceFlatFilterExcludedError(GamdlInterfaceError):
def __init__(self, media_id: str, result: Any):
super().__init__(
f"Media excluded by flat filter (media ID: {media_id}): {result}"
)
self.result = result
@@ -0,0 +1,480 @@
import asyncio
from typing import Any, AsyncGenerator, Callable
import structlog
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 = structlog.get_logger(__name__)
class AppleMusicInterface:
def __init__(
self,
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.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_url_info(url: str) -> AppleMusicUrlInfo | None:
log = logger.bind(action="get_url_info", url=url)
match = VALID_URL_PATTERN.match(url)
if not match:
log.debug("invalid_url_pattern")
return None
url_match = AppleMusicUrlInfo(
**match.groupdict(),
)
log.debug("success", url_info=url_match)
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,
index: int | None = None,
total: int | None = None,
media_metadata: dict | None = None,
playlist_metadata: dict | None = None,
is_library: bool = False,
) -> AsyncGenerator[AppleMusicMedia, None]:
media = AppleMusicMedia(
media_id=media_id,
is_library=is_library,
index=index,
total=total,
media_metadata=media_metadata,
playlist_metadata=playlist_metadata,
)
try:
async for media in self.song.get_media(media):
yield media
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,
is_library: bool = False,
) -> AsyncGenerator[AppleMusicMedia, None]:
media = AppleMusicMedia(
media_id=media_id,
is_library=is_library,
index=index,
total=total,
media_metadata=media_metadata,
playlist_metadata=playlist_metadata,
)
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.uploaded_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_album_media(
self,
media_id: str,
is_library: bool = False,
) -> AsyncGenerator[AppleMusicMedia, None]:
base_media = AppleMusicMedia(media_id)
try:
base_media.media_metadata = (
await (
self.base.apple_music_api.get_library_album(
media_id,
)
if is_library
else self.base.apple_music_api.get_album(
media_id,
)
)
)["data"][0]
self._run_media_type_filter(base_media)
await self._run_flat_filter(base_media)
except Exception as e:
base_media.partial = False
base_media.error = e
yield base_media
return
yield base_media
tracks = base_media.media_metadata["relationships"]["tracks"]["data"]
tasks = [
(
self._get_song_media(
media_id=track["id"],
index=index,
total=base_media.media_metadata["attributes"]["trackCount"],
media_metadata=track,
is_library=is_library,
)
if track["type"] in {"songs", "library-songs"}
else self._get_music_video_media(
media_id=track["id"],
index=index,
total=base_media.media_metadata["attributes"]["trackCount"],
media_metadata=track,
is_library=is_library,
)
)
for index, track in enumerate(tracks)
]
if self.concurrency == 1:
for task in tasks:
async for media in task:
yield media
else:
collected_tasks = [self._collect_generator(task) for task in tasks]
batches = await safe_gather(*collected_tasks, limit=self.concurrency)
for batch in batches:
for media in batch:
yield media
async def _get_playlist_media(
self,
media_id: str,
is_library: bool = False,
) -> AsyncGenerator[AppleMusicMedia, None]:
base_media = AppleMusicMedia(media_id)
try:
base_media.media_metadata = (
await (
self.base.apple_music_api.get_library_playlist(
media_id,
)
if is_library
else self.base.apple_music_api.get_playlist(
media_id,
)
)
)["data"][0]
self._run_media_type_filter(base_media)
await self._run_flat_filter(base_media)
tracks = base_media.media_metadata["relationships"]["tracks"]["data"]
next_uri = base_media.media_metadata["relationships"]["tracks"].get("next")
href_uri = base_media.media_metadata["relationships"]["tracks"].get("href")
while next_uri:
extended_data = await self.base.apple_music_api.get_extended_api_data(
next_uri,
href_uri,
)
tracks.extend(extended_data["data"])
next_uri = extended_data.get("next")
except Exception as e:
base_media.partial = False
base_media.error = e
yield base_media
return
yield base_media
tasks = [
(
self._get_song_media(
media_id=track["id"],
index=index,
media_metadata=track,
playlist_metadata=base_media.media_metadata,
is_library=is_library,
)
if track["type"] in {"songs", "library-songs"}
else self._get_music_video_media(
media_id=track["id"],
index=index,
media_metadata=track,
playlist_metadata=base_media.media_metadata,
is_library=is_library,
)
)
for index, track in enumerate(tracks)
]
if self.concurrency == 1:
for task in tasks:
async for media in task:
yield media
else:
collected_tasks = [self._collect_generator(task) for task in tasks]
batches = await safe_gather(*collected_tasks, limit=self.concurrency)
for batch in batches:
for media in batch:
yield media
async def _get_artist_media(
self,
media_id: str,
) -> AsyncGenerator[AppleMusicMedia, None]:
base_media = AppleMusicMedia(media_id)
try:
base_media.media_metadata = (
await self.base.apple_music_api.get_artist(
media_id,
)
)["data"][0]
self._run_media_type_filter(base_media)
await self._run_flat_filter(base_media)
if self.artist_select_media_type_function:
artist_media_type = self.artist_select_media_type_function(
list(ArtistMediaType),
base_media.media_metadata,
)
if asyncio.iscoroutine(artist_media_type):
artist_media_type = await artist_media_type
else:
artist_media_type = list(ArtistMediaType)[0]
relation_key, type_key = artist_media_type.path_key
items_relation = base_media.media_metadata.get(relation_key, {}).get(
type_key, {}
)
items = items_relation.get("data", [])
if not items:
raise GamdlInterfaceArtistMediaTypeError(
base_media.media_id,
str(artist_media_type),
)
next_uri = items_relation.get("next")
href_uri = items_relation.get("href")
while next_uri:
extended_data = await self.base.apple_music_api.get_extended_api_data(
next_uri,
href_uri,
)
items.extend(extended_data.get("data", []))
next_uri = extended_data.get("next")
except Exception as e:
yield AppleMusicMedia(
media_id=media_id,
media_metadata=None,
error=e,
)
return
yield base_media
if self.artist_select_items_function:
selected_items = self.artist_select_items_function(
artist_media_type,
items,
)
if asyncio.iscoroutine(selected_items):
selected_items = await selected_items
else:
selected_items = items[:1]
tasks = []
for index, item in enumerate(selected_items):
if item["type"] in {"songs", "library-songs"}:
tasks.append(
self._get_song_media(
media_id=item["id"],
index=index,
total=len(selected_items),
media_metadata=item,
)
)
elif item["type"] in {"albums", "library-albums"}:
tasks.append(
self._get_album_media(
media_id=item["id"],
)
)
else:
tasks.append(
self._get_music_video_media(
media_id=item["id"],
index=index,
total=len(selected_items),
media_metadata=item,
)
)
if self.concurrency == 1:
for task in tasks:
async for media in task:
yield media
else:
collected_tasks = [self._collect_generator(task) for task in tasks]
batches = await safe_gather(*collected_tasks, limit=self.concurrency)
for batch in batches:
for media in batch:
yield media
async def get_media_from_url(
self,
url: str,
) -> AsyncGenerator[AppleMusicMedia, None]:
url_info = self.get_url_info(url)
if not url_info:
raise GamdlInterfaceUrlParseError(url)
if self.disallowed_media_types and url_info.type in self.disallowed_media_types:
raise GamdlInterfaceMediaNotAllowedError(
url_info.type,
)
if (
url_info.type == "song"
or url_info.library_type == "songs"
or url_info.sub_id
):
async for media in self._get_song_media(
media_id=url_info.sub_id or url_info.id or url_info.library_id,
index=0,
total=1,
is_library=bool(url_info.library_type),
):
yield media
elif url_info.type == "music-video" or url_info.library_type == "music-videos":
async for media in self._get_music_video_media(
media_id=url_info.id or url_info.library_id,
index=0,
total=1,
is_library=bool(url_info.library_type),
):
yield media
elif url_info.type == "album" or url_info.library_type == "albums":
async for media in self._get_album_media(
media_id=url_info.id or url_info.library_id,
is_library=bool(url_info.library_type),
):
yield media
elif url_info.type == "playlist" or url_info.library_type == "playlist":
async for media in self._get_playlist_media(
media_id=url_info.id or url_info.library_id,
is_library=bool(url_info.library_type),
):
yield media
elif url_info.type == "post":
async for media in self._get_uploaded_video_media(
media_id=url_info.id,
):
yield media
elif url_info.type == "artist":
async for media in self._get_artist_media(
media_id=url_info.id,
):
yield media
@@ -0,0 +1,504 @@
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:
log = logger.bind(action="get_m3u8_master_url_from_webplayback")
m3u8_master_url = webplayback["hls-playlist-url"]
log.debug("success", m3u8_master_url=m3u8_master_url)
return m3u8_master_url
def _get_m3u8_master_url_from_itunes_page_metadata(
self,
itunes_page_metadata: dict,
) -> str | None:
log = logger.bind(action="get_m3u8_master_url_from_itunes_page_metadata")
stream_url = itunes_page_metadata["offers"][0]["assets"][0].get("hlsUrl")
if not stream_url:
return None
url_parts = urllib.parse.urlparse(stream_url)
query = urllib.parse.parse_qs(url_parts.query, keep_blank_values=True)
query.update({"aec": "HD", "dsid": "1"})
m3u8_master_url = url_parts._replace(
query=urllib.parse.urlencode(query, doseq=True)
).geturl()
m3u8_master_url = m3u8_master_url.replace(
"play-edge.itunes.apple.com",
"play.itunes.apple.com",
).replace(
"MZPlayLocal.woa",
"MZPlay.woa",
)
log.debug("success", m3u8_master_url=m3u8_master_url)
return m3u8_master_url
async def get_tags(
self,
metadata: dict,
itunes_page_metadata: dict,
) -> MediaTags:
log = logger.bind(
action="get_music_video_tags",
media_id=metadata["id"],
)
url_media_id = self.base.parse_media_id_from_url(metadata)
lookup_metadata = (await self.base.itunes_api.get_lookup_result(url_media_id))[
"results"
]
explicitness = lookup_metadata[0]["trackExplicitness"]
if explicitness == "notExplicit":
rating = MediaRating.NONE
elif explicitness == "explicit":
rating = MediaRating.EXPLICIT
else:
rating = MediaRating.CLEAN
tags = MediaTags(
artist=lookup_metadata[0]["artistName"],
artist_id=int(lookup_metadata[0]["artistId"]),
copyright=itunes_page_metadata.get("copyright"),
date=self.base.parse_date(lookup_metadata[0]["releaseDate"]),
genre=lookup_metadata[0]["primaryGenreName"],
genre_id=int(itunes_page_metadata["genres"][0]["genreId"]),
media_type=MediaType.MUSIC_VIDEO,
storefront=self.base.itunes_api.storefront_id,
title=lookup_metadata[0]["trackName"],
title_sort=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]["collectionName"]
tags.album_sort = 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_m3u8_master_url(
self,
metadata: dict,
itunes_page_metadata: dict,
) -> str | None:
url_media_id = self.base.parse_media_id_from_url(metadata)
if url_media_id == metadata["id"]:
return self._get_m3u8_master_url_from_itunes_page_metadata(
itunes_page_metadata,
)
webplayback_response = await self.base.apple_music_api.get_webplayback(
metadata["id"]
)
return self._get_m3u8_master_url_from_webplayback(
webplayback_response["songList"][0],
)
async def _get_stream_info(
self,
m3u8_master_url: str | None,
codec: MusicVideoCodec,
) -> StreamInfoAv | None:
log = logger.bind(
action="get_music_video_stream_info",
m3u8_master_url=m3u8_master_url,
codec=codec.value,
)
if not m3u8_master_url:
log.debug("no_m3u8_master_url")
return None
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,
codec,
)
stream_info_audio = await self._get_stream_info_audio(
playlist_master_m3u8_obj.data,
codec,
)
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],
codec: MusicVideoCodec,
) -> m3u8.Playlist | None:
playlist_results = [
playlist
for playlist in video_playlists
if playlist.stream_info.codecs.startswith(codec.fourcc)
]
if not playlist_results:
return None
def sort_key(
playlist: m3u8.Playlist,
) -> tuple[bool, int, int, int]:
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,
-playlist_resolution,
-bandwidth,
)
playlist_results.sort(key=sort_key)
return playlist_results[0]
def _get_best_stereo_audio_playlist(
self,
playlist_master_data: dict,
) -> dict | None:
audio_playlist = next(
(
media
for media in playlist_master_data["media"]
if media["group_id"] == "audio-stereo-256"
),
None,
)
return audio_playlist
async def _get_video_playlist_from_user(
self,
video_playlists: list[m3u8.Playlist],
) -> m3u8.Playlist | 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,
codec: MusicVideoCodec,
) -> StreamInfo | None:
stream_info = StreamInfo()
if codec != MusicVideoCodec.ASK:
playlist = self._get_video_playlist_from_resolution(
playlist_master_m3u8_obj.playlists,
codec,
)
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,
codec: MusicVideoCodec,
) -> StreamInfo | None:
stream_info = StreamInfo()
if codec != MusicVideoCodec.ASK:
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_stream_info(
self,
media_id: str,
m3u8_master_url: str | None,
) -> StreamInfoAv:
stream_info = None
for codec in self.codec_priority:
stream_info = await self._get_stream_info(m3u8_master_url, codec)
if stream_info:
break
if not stream_info:
raise GamdlInterfaceFormatNotAvailableError(
media_id=media_id,
codec=[codec.value for codec in self.codec_priority],
)
return stream_info
async def get_decryption_key(
self,
stream_info: StreamInfoAv,
) -> DecryptionKeyAv:
decryption_key_video, decryption_key_audio = await asyncio.gather(
self.base.get_decryption_key(
stream_info.video_track.widevine_pssh,
stream_info.media_id,
),
self.base.get_decryption_key(
stream_info.audio_track.widevine_pssh,
stream_info.media_id,
),
)
return DecryptionKeyAv(
video_track=decryption_key_video,
audio_track=decryption_key_audio,
)
async def get_media(
self,
media: AppleMusicMedia,
) -> AsyncGenerator[AppleMusicMedia, None]:
if not media.media_metadata:
media.media_metadata = (
await (
self.base.apple_music_api.get_library_music_video(media.media_id)
if media.is_library
else self.base.apple_music_api.get_music_video(media.media_id)
)
)["data"][0]
if media.media_metadata["attributes"].get("playParams", {}).get("isLibrary"):
catalog_metadata = self.base.get_catalog_metadata_from_library(
media.media_metadata
)
if catalog_metadata:
media.media_id = catalog_metadata["id"]
media.is_library = False
media.media_metadata = catalog_metadata
if media.is_library:
raise GamdlInterfaceMediaNotStreamableError(media.media_id)
yield media
if not self.base.is_media_streamable(media.media_metadata):
raise GamdlInterfaceMediaNotStreamableError(media.media_id)
if media.playlist_metadata:
media.playlist_tags = self.base.get_playlist_tags(
media.playlist_metadata,
media.index,
)
media.cover = await self.base.get_cover(media.media_metadata)
itunes_page_metadata = await self.get_itunes_page_metadata(media.media_metadata)
if self.base.wrapper_api:
playback = await self.base.wrapper_api.get_playback(media.media_id)
media.tags = await self.base.get_tags_from_asset_info(
playback["songList"][0]["assets"][0]["metadata"],
)
else:
playback = None
media.tags = await self.get_tags(
media.media_metadata,
itunes_page_metadata,
)
m3u8_master_url = await self.get_m3u8_master_url(
media.media_metadata,
itunes_page_metadata,
)
media.stream_info = await self.get_stream_info(
media.media_id,
m3u8_master_url,
)
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
+628
View File
@@ -0,0 +1,628 @@
import asyncio
import base64
import datetime
import json
import re
from typing import AsyncGenerator, Callable
from xml.dom import minidom
from xml.etree import ElementTree
import m3u8
import structlog
from .base import AppleMusicBaseInterface
from .constants import DRM_DEFAULT_KEY_MAPPING, MP4_FORMAT_CODECS, SONG_CODEC_REGEX_MAP
from .enums import SongCodec, SyncedLyricsFormat
from .exceptions import (
GamdlInterfaceDecryptionNotAvailableError,
GamdlInterfaceFormatNotAvailableError,
GamdlInterfaceMediaNotStreamableError,
)
from .types import (
AppleMusicMedia,
DecryptionKeyAv,
Lyrics,
MediaFileFormat,
StreamInfo,
StreamInfoAv,
)
logger = structlog.get_logger(__name__)
class AppleMusicSongInterface:
def __init__(
self,
base: AppleMusicBaseInterface,
synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC,
codec_priority: list[SongCodec] = [SongCodec.AAC_WEB],
use_album_date: bool = False,
skip_stream_info: bool = False,
ask_codec_function: Callable[[list[dict]], dict | None] | None = None,
):
self.base = base
self.synced_lyrics_format = synced_lyrics_format
self.codec_priority = codec_priority
self.use_album_date = use_album_date
self.skip_stream_info = skip_stream_info
self.ask_codec_function = ask_codec_function
async def get_lyrics(
self,
song_metadata: dict,
) -> Lyrics | None:
log = logger.bind(
action="get_lyrics",
song_id=song_metadata["id"],
)
if song_metadata["attributes"]["playParams"].get("isLibrary"):
log.debug("library_song_no_lyrics")
return None
if not song_metadata["attributes"]["hasLyrics"]:
log.debug("no_lyrics")
return None
if (
"relationships" not in song_metadata
or "lyrics" not in song_metadata["relationships"]
):
song_metadata = (
await self.base.apple_music_api.get_song(
song_metadata["id"],
)
)["data"][0]
if (
"lyrics" in song_metadata["relationships"]
and "data" in song_metadata["relationships"]["lyrics"]
and len(song_metadata["relationships"]["lyrics"]["data"]) > 0
and "attributes" in song_metadata["relationships"]["lyrics"]["data"][0]
and song_metadata["relationships"]["lyrics"]["data"][0]["attributes"].get(
"ttml"
)
is not None
):
lyrics = self._get_lyrics(
song_metadata["relationships"]["lyrics"]["data"][0]["attributes"][
"ttml"
],
)
log.debug("success", lyrics=lyrics)
return lyrics
else:
log.debug("no_lyrics_data")
def _get_lyrics(
self,
lyrics_ttml: str,
) -> Lyrics:
lyrics_ttml_et = ElementTree.fromstring(lyrics_ttml)
unsynced_lyrics = []
synced_lyrics = []
index = 1
for div in lyrics_ttml_et.iter("{http://www.w3.org/ns/ttml}div"):
stanza = []
unsynced_lyrics.append(stanza)
for p in div.iter("{http://www.w3.org/ns/ttml}p"):
if p.text is not None:
stanza.append(p.text)
if p.attrib.get("begin"):
if self.synced_lyrics_format == SyncedLyricsFormat.LRC:
synced_lyrics.append(self._get_lyrics_line_lrc(p))
if self.synced_lyrics_format == SyncedLyricsFormat.SRT:
synced_lyrics.append(self._get_lyrics_line_srt(index, p))
if self.synced_lyrics_format == SyncedLyricsFormat.TTML:
if not synced_lyrics:
synced_lyrics.append(
minidom.parseString(lyrics_ttml).toprettyxml()
)
continue
index += 1
return Lyrics(
synced="\n".join(synced_lyrics + ["\n"]) if synced_lyrics else None,
unsynced=(
"\n\n".join(["\n".join(lyric_group) for lyric_group in unsynced_lyrics])
if unsynced_lyrics
else None
),
)
def _parse_ttml_timestamp(
self,
timestamp_ttml: str,
) -> datetime.datetime:
mins_secs_ms = re.findall(r"\d+", timestamp_ttml)
ms, secs, mins = 0, 0, 0
if len(mins_secs_ms) == 2 and ":" in timestamp_ttml:
secs, mins = int(mins_secs_ms[-1]), int(mins_secs_ms[-2])
elif len(mins_secs_ms) == 1:
ms = int(mins_secs_ms[-1])
else:
secs = float(f"{mins_secs_ms[-2]}.{mins_secs_ms[-1]}")
if len(mins_secs_ms) > 2:
mins = int(mins_secs_ms[-3])
return datetime.datetime.fromtimestamp(
(mins * 60) + secs + (ms / 1000),
tz=datetime.timezone.utc,
)
def _get_lyrics_line_srt(self, index: int, element: ElementTree.Element) -> str:
timestamp_begin_ttml = element.attrib.get("begin")
timestamp_end_ttml = element.attrib.get("end")
text = element.text
timestamp_begin = self._parse_ttml_timestamp(timestamp_begin_ttml)
timestamp_end = self._parse_ttml_timestamp(timestamp_end_ttml)
return (
f"{index}\n"
f"{timestamp_begin.strftime('%H:%M:%S,%f')[:-3]} --> "
f"{timestamp_end.strftime('%H:%M:%S,%f')[:-3]}\n"
f"{text}\n"
)
def _get_lyrics_line_lrc(self, element: ElementTree.Element) -> str:
timestamp_ttml = element.attrib.get("begin")
text = element.text
timestamp = self._parse_ttml_timestamp(timestamp_ttml)
ms_new = timestamp.strftime("%f")[:-3]
if int(ms_new[-1]) >= 5:
ms = int(f"{int(ms_new[:2]) + 1}") * 10
timestamp += datetime.timedelta(milliseconds=ms) - datetime.timedelta(
microseconds=timestamp.microsecond
)
return f"[{timestamp.strftime('%M:%S.%f')[:-4]}]{text}"
def _switch_m3u8_master_url_to_default(self, m3u8_master_url: str) -> str:
return re.sub(
r"(P\d+)_[^/]+(\.m3u8)",
r"\1_default\2",
m3u8_master_url,
)
def _get_m3u8_from_playback(self, playback: dict) -> str | None:
log = logger.bind(action="get_m3u8_master_url_from_playback")
m3u8_master_url = playback["songList"][0].get("hls-playlist-url")
if m3u8_master_url:
m3u8_master_url = self._switch_m3u8_master_url_to_default(m3u8_master_url)
log.debug("success", m3u8_master_url=m3u8_master_url)
return m3u8_master_url
log.debug("no_m3u8_master_url")
async def _get_m3u8_master_url_from_assets(
self,
song_metadata: dict,
) -> str | None:
log = logger.bind(
action="get_m3u8_master_url_from_assets",
song_id=song_metadata["id"],
)
if song_metadata["attributes"]["playParams"].get("isLibrary"):
log.debug("library_song_no_m3u8_master_url")
return None
play_params = song_metadata["attributes"].get("playParams", {})
assets = await self.base.apple_music_api.get_assets(
play_params.get("id") or song_metadata["id"],
play_params.get("kind", "song"),
)
asset = next(
(
asset
for asset in assets.get("results", {}).get("assets", [])
if asset.get("url")
),
None,
)
enhanced = asset["url"] if asset else None
if enhanced:
enhanced = self._switch_m3u8_master_url_to_default(enhanced)
log.debug("success", m3u8_master_url=enhanced)
return enhanced
log.debug("no_m3u8_master_url")
return None
async def get_m3u8_master_url(
self,
playback: dict | None,
song_metadata: dict | None,
) -> str | None:
if playback:
return self._get_m3u8_from_playback(playback)
else:
return await self._get_m3u8_master_url_from_assets(song_metadata)
async def get_stream_info(
self,
media_id: str,
is_library: bool,
m3u8_master_url: str | None = None,
webplayback: dict | None = None,
) -> StreamInfoAv:
stream_info = None
if is_library:
stream_info = await self._get_library_stream_info(webplayback)
else:
for codec in self.codec_priority:
if codec.is_web:
stream_info = await self._get_web_stream_info(webplayback, codec)
else:
stream_info = await self._get_stream_info(m3u8_master_url, codec)
if stream_info:
break
if not stream_info:
raise GamdlInterfaceFormatNotAvailableError(
media_id=media_id,
codec=[codec.value for codec in self.codec_priority],
)
return stream_info
async def _get_stream_info(
self,
m3u8_master_url: str | None,
codec: SongCodec,
) -> StreamInfoAv | None:
log = logger.bind(action="get_song_stream_info")
if not m3u8_master_url:
log.debug("no_m3u8_master_url")
return None
m3u8_master_obj = m3u8.loads(
(await self.base.get_response(m3u8_master_url)).text
)
m3u8_master_data = m3u8_master_obj.data
if codec == SongCodec.ASK:
playlist = await self._get_playlist_from_user(m3u8_master_data)
else:
playlist = self._get_playlist_from_codec(
m3u8_master_data,
codec,
)
if playlist is None:
log.debug("no_matching_playlist", codec=codec.value)
return None
stream_info = StreamInfo(use_single_content_key=False)
stream_info.stream_url = (
f"{m3u8_master_url.rpartition('/')[0]}/{playlist['uri']}"
)
stream_info.codec = playlist["stream_info"]["codecs"]
is_mp4 = any(stream_info.codec.startswith(codec) for codec in MP4_FORMAT_CODECS)
session_key_metadata = self._get_audio_session_key_metadata(m3u8_master_data)
if session_key_metadata:
asset_metadata = self._get_asset_metadata(m3u8_master_data)
variant_id = playlist["stream_info"]["stable_variant_id"]
drm_ids = asset_metadata[variant_id]["AUDIO-SESSION-KEY-IDS"]
stream_info.widevine_pssh = self._get_drm_uri_from_session_key(
session_key_metadata,
drm_ids,
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
)
stream_info.playready_pssh = self._get_drm_uri_from_session_key(
session_key_metadata,
drm_ids,
"com.microsoft.playready",
)
stream_info.fairplay_key = self._get_drm_uri_from_session_key(
session_key_metadata,
drm_ids,
"com.apple.streamingkeydelivery",
)
else:
m3u8_obj = m3u8.loads(
(await self.base.get_response(stream_info.stream_url)).text
)
stream_info.widevine_pssh = self._get_drm_uri_from_m3u8_keys(
m3u8_obj,
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
)
stream_info.playready_pssh = self._get_drm_uri_from_m3u8_keys(
m3u8_obj,
"com.microsoft.playready",
)
stream_info.fairplay_key = self._get_drm_uri_from_m3u8_keys(
m3u8_obj,
"com.apple.streamingkeydelivery",
)
stream_info_av = StreamInfoAv(
audio_track=stream_info,
file_format=MediaFileFormat.MP4 if is_mp4 else MediaFileFormat.M4A,
)
log.debug("success", stream_info=stream_info_av)
return stream_info_av
def _get_m3u8_metadata(self, m3u8_data: dict, data_id: str) -> dict | None:
for session_data in m3u8_data.get("session_data", []):
if session_data["data_id"] == data_id:
return json.loads(
base64.b64decode(session_data["value"]).decode("utf-8")
)
return None
def _get_audio_session_key_metadata(self, m3u8_data: dict) -> dict | None:
return self._get_m3u8_metadata(
m3u8_data,
"com.apple.hls.AudioSessionKeyInfo",
)
def _get_asset_metadata(self, m3u8_data: dict) -> dict | None:
return self._get_m3u8_metadata(
m3u8_data,
"com.apple.hls.audioAssetMetadata",
)
def _get_playlist_from_codec(
self, m3u8_data: dict, codec: SongCodec
) -> dict | None:
matching_playlists = [
playlist
for playlist in m3u8_data["playlists"]
if re.fullmatch(
SONG_CODEC_REGEX_MAP[codec.value], playlist["stream_info"]["audio"]
)
]
if not matching_playlists:
return None
return max(
matching_playlists,
key=lambda x: x["stream_info"]["average_bandwidth"],
)
async def _get_playlist_from_user(self, m3u8_data: dict) -> dict | None:
if self.ask_codec_function:
playlist = self.ask_codec_function(
[playlist for playlist in m3u8_data["playlists"]]
)
if asyncio.iscoroutine(playlist):
playlist = await playlist
return playlist
return None
def _get_drm_uri_from_session_key(
self,
drm_infos: dict,
drm_ids: list,
drm_key: str,
) -> str | None:
for drm_id in drm_ids:
if drm_id != "1" and drm_key in drm_infos.get(drm_id, {}):
return drm_infos[drm_id][drm_key]["URI"]
return None
def _get_drm_uri_from_m3u8_keys(
self,
m3u8_obj: m3u8.M3U8,
drm_key: str,
) -> str | None:
default_uri = DRM_DEFAULT_KEY_MAPPING[drm_key]
for key in m3u8_obj.keys:
if key.keyformat == drm_key and key.uri != default_uri:
return key.uri
return None
async def _get_web_stream_info(
self,
webplayback: dict | None,
codec: SongCodec,
) -> StreamInfoAv:
log = logger.bind(action="get_web_song_stream_info")
if not webplayback:
log.debug("no_webplayback")
return None
flavor = codec.flavor
stream_info = StreamInfo(
use_cenc=codec.is_cenc,
)
asset = next(
(i for i in webplayback["songList"][0]["assets"] if i["flavor"] == flavor),
None,
)
if not asset:
log.debug("no_matching_asset", codec=codec.value, flavor=flavor)
return None
stream_info.stream_url = asset["URL"]
m3u8_obj = m3u8.loads(
(await self.base.get_response(stream_info.stream_url)).text
)
if stream_info.use_cenc:
stream_info.widevine_pssh = m3u8_obj.keys[0].uri
else:
stream_info.fairplay_key = m3u8_obj.keys[0].uri
stream_info_av = StreamInfoAv(
media_id=webplayback["songList"][0]["songId"],
audio_track=stream_info,
file_format=MediaFileFormat.M4A,
)
log.debug("success", stream_info=stream_info_av)
return stream_info_av
async def _get_library_stream_info(
self,
webplayback: dict | None,
) -> StreamInfoAv | None:
log = logger.bind(action="get_library_song_stream_info")
if not webplayback:
log.debug("no_webplayback")
return None
stream_info = StreamInfo(drm_free=True)
if len(webplayback["songList"][0]["assets"]) == 0:
log.debug("no_matching_asset")
return None
asset = webplayback["songList"][0]["assets"][0]
stream_info.stream_url = asset["URL"]
stream_info_av = StreamInfoAv(
media_id=webplayback["songList"][0]["songId"],
audio_track=stream_info,
file_format=MediaFileFormat.M4A,
)
log.debug("success", stream_info=stream_info_av)
return stream_info_av
async def get_media(
self,
media: AppleMusicMedia,
) -> AsyncGenerator[AppleMusicMedia, None]:
if not media.media_metadata:
media.media_metadata = (
await (
self.base.apple_music_api.get_library_song(media.media_id)
if media.is_library
else self.base.apple_music_api.get_song(media.media_id)
)
)["data"][0]
if media.media_metadata["attributes"].get("playParams", {}).get("isLibrary"):
catalog_metadata = self.base.get_catalog_metadata_from_library(
media.media_metadata
)
if catalog_metadata:
media.media_id = catalog_metadata["id"]
media.is_library = False
media.media_metadata = catalog_metadata
yield media
if not self.base.is_media_streamable(media.media_metadata):
raise GamdlInterfaceMediaNotStreamableError(
media_id=media.media_id,
)
if media.playlist_metadata:
media.playlist_tags = self.base.get_playlist_tags(
media.playlist_metadata,
media.index,
)
media.cover = await self.base.get_cover(media.media_metadata)
media.lyrics = await self.get_lyrics(media.media_metadata)
if self.base.wrapper_api:
playback = (
await self.base.wrapper_api.get_playback(media.media_id)
if not media.is_library
else None
)
webplayback = (
await self.base.apple_music_api.get_webplayback(
media.media_id,
media.is_library,
)
if media.is_library
or any(codec.is_web for codec in self.codec_priority)
else None
)
else:
playback = None
webplayback = await self.base.apple_music_api.get_webplayback(
media.media_id,
media.is_library,
)
if playback:
media.tags = await self.base.get_tags_from_asset_info(
playback["songList"][0]["assets"][0]["metadata"],
media.lyrics.unsynced if media.lyrics else None,
self.use_album_date,
)
else:
media.tags = await self.base.get_tags_from_asset_info(
webplayback["songList"][0]["assets"][0]["metadata"],
media.lyrics.unsynced if media.lyrics else None,
self.use_album_date,
)
if not self.skip_stream_info:
m3u8_master_url = await self.get_m3u8_master_url(
playback,
media.media_metadata,
)
media.stream_info = await self.get_stream_info(
media.media_id,
media.is_library,
m3u8_master_url,
webplayback,
)
if media.stream_info.audio_track.drm_free:
pass
elif (
not self.base.wrapper_api
and not media.stream_info.audio_track.widevine_pssh
) or (
self.base.wrapper_api
and not media.stream_info.audio_track.fairplay_key
and not media.stream_info.audio_track.use_cenc
):
raise GamdlInterfaceDecryptionNotAvailableError(media_id=media.media_id)
elif media.stream_info.audio_track.widevine_pssh:
media.decryption_key = DecryptionKeyAv(
audio_track=await self.base.get_decryption_key(
media.stream_info.audio_track.widevine_pssh,
media.media_id,
)
)
media.partial = False
yield media
+185
View File
@@ -0,0 +1,185 @@
import datetime
from dataclasses import dataclass
from typing import Any
from .enums import MediaFileFormat, MediaRating, MediaType
@dataclass
class Lyrics:
synced: str = None
unsynced: str = None
@dataclass
class MediaTags:
album: str = None
album_artist: str = None
album_id: int = None
album_sort: str = None
artist: str = None
artist_id: int = None
artist_sort: str = None
comment: str = None
compilation: bool = None
composer: str = None
composer_id: int = None
composer_sort: str = None
copyright: str = None
date: datetime.date | str = None
disc: int = None
disc_total: int = None
gapless: bool = None
genre: str = None
genre_id: int = None
lyrics: str = None
media_type: MediaType = None
rating: MediaRating = None
storefront: str = None
title: str = None
title_id: int = None
title_sort: str = None
track: int = None
track_total: int = None
xid: str = None
def as_mp4_tags(self, date_format: str = None) -> dict:
disc_mp4 = [
self.disc if self.disc is not None else 0,
self.disc_total if self.disc_total is not None else 0,
]
if disc_mp4[0] == 0 and disc_mp4[1] == 0:
disc_mp4 = None
track_mp4 = [
self.track if self.track is not None else 0,
self.track_total if self.track_total is not None else 0,
]
if track_mp4[0] == 0 and track_mp4[1] == 0:
track_mp4 = None
if isinstance(self.date, datetime.date):
if date_format is None:
date_mp4 = self.date.isoformat()
else:
date_mp4 = self.date.strftime(date_format)
elif isinstance(self.date, str):
date_mp4 = self.date
else:
date_mp4 = None
mp4_tags = {
"\xa9alb": self.album,
"aART": self.album_artist,
"plID": self.album_id,
"soal": self.album_sort,
"\xa9ART": self.artist,
"atID": self.artist_id,
"soar": self.artist_sort,
"\xa9cmt": self.comment,
"cpil": bool(self.compilation) if self.compilation is not None else None,
"\xa9wrt": self.composer,
"cmID": self.composer_id,
"soco": self.composer_sort,
"cprt": self.copyright,
"\xa9day": date_mp4,
"disk": disc_mp4,
"pgap": bool(self.gapless) if self.gapless is not None else None,
"\xa9gen": self.genre,
"\xa9lyr": self.lyrics,
"geID": self.genre_id,
"stik": int(self.media_type) if self.media_type is not None else None,
"rtng": int(self.rating) if self.rating is not None else None,
"sfID": self.storefront,
"\xa9nam": self.title,
"cnID": self.title_id,
"sonm": self.title_sort,
"trkn": track_mp4,
"xid ": self.xid,
}
return {
k: ([v] if not isinstance(v, bool) else v)
for k, v in mp4_tags.items()
if v is not None
}
@dataclass
class PlaylistTags:
artist: str = None
playlist_id: int = None
title: str = None
track: int = None
@dataclass
class StreamInfo:
stream_url: str = None
widevine_pssh: str = None
playready_pssh: str = None
fairplay_key: str = None
codec: str = None
width: int = None
height: int = None
drm_free: bool = False
use_cenc: bool = False
use_single_content_key: bool = True
@dataclass
class StreamInfoAv:
media_id: str = None
video_track: StreamInfo = None
audio_track: StreamInfo = None
file_format: MediaFileFormat = None
@dataclass
class DecryptionKey:
kid: str = None
key: str = None
@dataclass
class DecryptionKeyAv:
video_track: DecryptionKey = None
audio_track: DecryptionKey = None
@dataclass
class Cover:
template_url: str = None
file_extension: str = None
url: str = None
@dataclass
class AppleMusicMedia:
media_id: str
is_library: bool = False
index: int = 0
total: int = 0
partial: bool = True
media_metadata: dict | None = None
error: BaseException | None = None
playlist_metadata: dict | None = None
playlist_tags: PlaylistTags | None = None
extra_tags: dict | None = None
cover: Cover | None = None
lyrics: Lyrics | None = None
tags: MediaTags | None = None
stream_info: StreamInfoAv | None = None
decryption_key: DecryptionKeyAv | None = None
@dataclass
class AppleMusicUrlInfo:
storefront: str = None
type: str = None
slug: str = None
id: str = None
sub_id: str = None
library_storefront: str = None
library_type: str = None
library_id: str = None
@@ -0,0 +1,134 @@
import asyncio
from collections.abc import Callable
from typing import AsyncGenerator
import structlog
from .base import AppleMusicBaseInterface
from .constants import UPLOADED_VIDEO_QUALITY_RANK
from .enums import UploadedVideoQuality
from .exceptions import (
GamdlInterfaceFormatNotAvailableError,
GamdlInterfaceMediaNotStreamableError,
)
from .types import AppleMusicMedia, MediaFileFormat, MediaTags, StreamInfo, StreamInfoAv
logger = structlog.get_logger(__name__)
class AppleMusicUploadedVideoInterface:
def __init__(
self,
base: AppleMusicBaseInterface,
quality: UploadedVideoQuality = UploadedVideoQuality.BEST,
ask_quality_function: Callable[[dict], dict | None] | None = None,
):
self.base = base
self.quality = quality
self.ask_quality_function = ask_quality_function
def _get_best_stream_url(self, metadata: dict) -> str:
best_quality = next(
(
quality
for quality in UPLOADED_VIDEO_QUALITY_RANK
if metadata["attributes"]["assetTokens"].get(quality)
),
None,
)
return metadata["attributes"]["assetTokens"][best_quality]
async def _get_stream_url_from_user(self, metadata: dict) -> str | None:
if self.ask_quality_function:
selected_quality = self.ask_quality_function(
metadata["attributes"]["assetTokens"]
)
if asyncio.iscoroutine(selected_quality):
selected_quality = await selected_quality
return selected_quality
return None
async def _get_stream_url(
self,
metadata: dict,
) -> str | None:
if self.quality == UploadedVideoQuality.BEST:
stream_url = self._get_best_stream_url(metadata)
if self.quality == UploadedVideoQuality.ASK:
stream_url = await self._get_stream_url_from_user(metadata)
return stream_url
async def get_stream_info(
self,
metadata: dict,
) -> StreamInfo | None:
log = logger.bind(
action="get_uploaded_video_stream_info", media_id=metadata["id"]
)
stream_url = await self._get_stream_url(metadata)
if not stream_url:
log.debug("no_stream_url_available")
return None
stream_info = StreamInfoAv(
file_format=MediaFileFormat.M4V,
video_track=StreamInfo(
stream_url=stream_url,
drm_free=True,
),
)
log.debug("success", stream_info=stream_info)
return stream_info
def get_tags(self, metadata: dict) -> MediaTags:
log = logger.bind(action="get_uploaded_video_tags", media_id=metadata["id"])
attributes = metadata["attributes"]
upload_date = attributes.get("uploadDate")
tags = MediaTags(
artist=attributes.get("artistName"),
date=self.base.parse_date(upload_date) if upload_date else None,
title=attributes.get("name"),
title_id=int(metadata["id"]),
storefront=self.base.itunes_api.storefront_id,
)
log.debug("success", tags=tags)
return tags
async def get_media(
self,
media: AppleMusicMedia,
) -> AsyncGenerator[AppleMusicMedia, None]:
if not media.media_metadata:
media.media_metadata = (
await self.base.apple_music_api.get_uploaded_video(media.media_id)
)["data"][0]
media.media_id = media.media_metadata["id"]
yield media
if not self.base.is_media_streamable(media.media_metadata):
raise GamdlInterfaceMediaNotStreamableError(media.media_id)
media.cover = await self.base.get_cover(media.media_metadata)
media.stream_info = await self.get_stream_info(media.media_metadata)
if not media.stream_info:
raise GamdlInterfaceFormatNotAvailableError(media.media_id)
media.tags = self.get_tags(media.media_metadata)
media.partial = False
yield media
+3
View File
@@ -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=="""
+67
View File
@@ -0,0 +1,67 @@
import asyncio
import string
import typing
async def async_subprocess(*args: str, silent: bool = False) -> None:
if silent:
additional_args = {
"stdout": asyncio.subprocess.PIPE,
"stderr": asyncio.subprocess.PIPE,
}
else:
additional_args = {}
proc = await asyncio.create_subprocess_exec(
*args,
**additional_args,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
msg = (
f"Exited with code {proc.returncode}: {' '.join(str(arg) for arg in args)}"
)
if stdout:
msg += f"\nstdout:\n{stdout.decode()}"
if stderr:
msg += f"\nstderr:\n{stderr.decode()}"
raise Exception(msg)
async def safe_gather(
*tasks: typing.Awaitable[typing.Any],
limit: int = 10,
) -> list[typing.Any]:
semaphore = asyncio.Semaphore(limit)
async def bounded_task(task: typing.Awaitable[typing.Any]) -> typing.Any:
async with semaphore:
return await task
return await asyncio.gather(
*(bounded_task(task) for task in tasks),
return_exceptions=True,
)
class CustomStringFormatter(string.Formatter):
def format_field(self, value: typing.Any, format_spec: str) -> str:
if isinstance(value, tuple) and len(value) == 2:
actual_value, fallback_value = value
if actual_value is None:
return fallback_value
try:
return super().format_field(actual_value, format_spec)
except Exception:
return fallback_value
return super().format_field(value, format_spec)
class GamdlError(Exception):
pass
+34
View File
@@ -0,0 +1,34 @@
[project]
name = "gamdl"
version = "3.8"
description = "A command-line app for downloading Apple Music songs, music videos and post videos."
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
dependencies = [
"async-lru>=2.0.5",
"click>=8.3.0",
"colorama>=0.4.6",
"dataclass-click>=1.0.4",
"httpx>=0.28.1",
"httpx-retries>=0.4.6",
"inquirerpy>=0.3.4",
"m3u8>=6.0.0",
"mutagen>=1.47.0",
"pillow>=12.0.0",
"pywidevine>=1.8.0",
"structlog>=25.5.0",
"yt-dlp>=2025.10.22",
]
[project.urls]
Repository = "https://github.com/glomatico/gamdl"
[project.scripts]
gamdl = "gamdl.cli.cli:main"
[dependency-groups]
dev = [
"pytest>=9.0.3",
"pytest-asyncio>=1.3.0",
]
+828
View File
@@ -0,0 +1,828 @@
version = 1
revision = 3
requires-python = ">=3.10"
[[package]]
name = "anyio"
version = "4.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "idna" },
{ name = "sniffio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
]
[[package]]
name = "async-lru"
version = "2.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/4d/71ec4d3939dc755264f680f6c2b4906423a304c3d18e96853f0a595dfe97/async_lru-2.0.5.tar.gz", hash = "sha256:481d52ccdd27275f42c43a928b4a50c3bfb2d67af4e78b170e3e0bb39c66e5bb", size = 10380, upload-time = "2025-03-16T17:25:36.919Z" }
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"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/71/81/eff3184acb1d9dc3ce95a98b6f3c81a49b4be296e664db8e1c2eeabef3d9/backports_datetime_fromisoformat-2.0.3.tar.gz", hash = "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d", size = 23588, upload-time = "2024-12-28T20:18:15.017Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/4b/d6b051ca4b3d76f23c2c436a9669f3be616b8cf6461a7e8061c7c4269642/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f681f638f10588fa3c101ee9ae2b63d3734713202ddfcfb6ec6cea0778a29d4", size = 27561, upload-time = "2024-12-28T20:16:47.974Z" },
{ url = "https://files.pythonhosted.org/packages/6d/40/e39b0d471e55eb1b5c7c81edab605c02f71c786d59fb875f0a6f23318747/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:cd681460e9142f1249408e5aee6d178c6d89b49e06d44913c8fdfb6defda8d1c", size = 34448, upload-time = "2024-12-28T20:16:50.712Z" },
{ url = "https://files.pythonhosted.org/packages/f2/28/7a5c87c5561d14f1c9af979231fdf85d8f9fad7a95ff94e56d2205e2520a/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ee68bc8735ae5058695b76d3bb2aee1d137c052a11c8303f1e966aa23b72b65b", size = 27093, upload-time = "2024-12-28T20:16:52.994Z" },
{ url = "https://files.pythonhosted.org/packages/80/ba/f00296c5c4536967c7d1136107fdb91c48404fe769a4a6fd5ab045629af8/backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8273fe7932db65d952a43e238318966eab9e49e8dd546550a41df12175cc2be4", size = 52836, upload-time = "2024-12-28T20:16:55.283Z" },
{ url = "https://files.pythonhosted.org/packages/e3/92/bb1da57a069ddd601aee352a87262c7ae93467e66721d5762f59df5021a6/backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d57ea50aa5a524bb239688adc1d1d824c31b6094ebd39aa164d6cadb85de22", size = 52798, upload-time = "2024-12-28T20:16:56.64Z" },
{ url = "https://files.pythonhosted.org/packages/df/ef/b6cfd355982e817ccdb8d8d109f720cab6e06f900784b034b30efa8fa832/backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac6272f87693e78209dc72e84cf9ab58052027733cd0721c55356d3c881791cf", size = 52891, upload-time = "2024-12-28T20:16:58.887Z" },
{ url = "https://files.pythonhosted.org/packages/37/39/b13e3ae8a7c5d88b68a6e9248ffe7066534b0cfe504bf521963e61b6282d/backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:44c497a71f80cd2bcfc26faae8857cf8e79388e3d5fbf79d2354b8c360547d58", size = 52955, upload-time = "2024-12-28T20:17:00.028Z" },
{ url = "https://files.pythonhosted.org/packages/1e/e4/70cffa3ce1eb4f2ff0c0d6f5d56285aacead6bd3879b27a2ba57ab261172/backports_datetime_fromisoformat-2.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:6335a4c9e8af329cb1ded5ab41a666e1448116161905a94e054f205aa6d263bc", size = 29323, upload-time = "2024-12-28T20:17:01.125Z" },
{ url = "https://files.pythonhosted.org/packages/62/f5/5bc92030deadf34c365d908d4533709341fb05d0082db318774fdf1b2bcb/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2e4b66e017253cdbe5a1de49e0eecff3f66cd72bcb1229d7db6e6b1832c0443", size = 27626, upload-time = "2024-12-28T20:17:03.448Z" },
{ url = "https://files.pythonhosted.org/packages/28/45/5885737d51f81dfcd0911dd5c16b510b249d4c4cf6f4a991176e0358a42a/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:43e2d648e150777e13bbc2549cc960373e37bf65bd8a5d2e0cef40e16e5d8dd0", size = 34588, upload-time = "2024-12-28T20:17:04.459Z" },
{ url = "https://files.pythonhosted.org/packages/bc/6d/bd74de70953f5dd3e768c8fc774af942af0ce9f211e7c38dd478fa7ea910/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4ce6326fd86d5bae37813c7bf1543bae9e4c215ec6f5afe4c518be2635e2e005", size = 27162, upload-time = "2024-12-28T20:17:06.752Z" },
{ url = "https://files.pythonhosted.org/packages/47/ba/1d14b097f13cce45b2b35db9898957578b7fcc984e79af3b35189e0d332f/backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7c8fac333bf860208fd522a5394369ee3c790d0aa4311f515fcc4b6c5ef8d75", size = 54482, upload-time = "2024-12-28T20:17:08.15Z" },
{ url = "https://files.pythonhosted.org/packages/25/e9/a2a7927d053b6fa148b64b5e13ca741ca254c13edca99d8251e9a8a09cfe/backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4da5ab3aa0cc293dc0662a0c6d1da1a011dc1edcbc3122a288cfed13a0b45", size = 54362, upload-time = "2024-12-28T20:17:10.605Z" },
{ url = "https://files.pythonhosted.org/packages/c1/99/394fb5e80131a7d58c49b89e78a61733a9994885804a0bb582416dd10c6f/backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:58ea11e3bf912bd0a36b0519eae2c5b560b3cb972ea756e66b73fb9be460af01", size = 54162, upload-time = "2024-12-28T20:17:12.301Z" },
{ url = "https://files.pythonhosted.org/packages/88/25/1940369de573c752889646d70b3fe8645e77b9e17984e72a554b9b51ffc4/backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8a375c7dbee4734318714a799b6c697223e4bbb57232af37fbfff88fb48a14c6", size = 54118, upload-time = "2024-12-28T20:17:13.609Z" },
{ url = "https://files.pythonhosted.org/packages/b7/46/f275bf6c61683414acaf42b2df7286d68cfef03e98b45c168323d7707778/backports_datetime_fromisoformat-2.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:ac677b1664c4585c2e014739f6678137c8336815406052349c85898206ec7061", size = 29329, upload-time = "2024-12-28T20:17:16.124Z" },
{ url = "https://files.pythonhosted.org/packages/a2/0f/69bbdde2e1e57c09b5f01788804c50e68b29890aada999f2b1a40519def9/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147", size = 27630, upload-time = "2024-12-28T20:17:19.442Z" },
{ url = "https://files.pythonhosted.org/packages/d5/1d/1c84a50c673c87518b1adfeafcfd149991ed1f7aedc45d6e5eac2f7d19d7/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4", size = 34707, upload-time = "2024-12-28T20:17:21.79Z" },
{ url = "https://files.pythonhosted.org/packages/71/44/27eae384e7e045cda83f70b551d04b4a0b294f9822d32dea1cbf1592de59/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35", size = 27280, upload-time = "2024-12-28T20:17:24.503Z" },
{ url = "https://files.pythonhosted.org/packages/a7/7a/a4075187eb6bbb1ff6beb7229db5f66d1070e6968abeb61e056fa51afa5e/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1", size = 55094, upload-time = "2024-12-28T20:17:25.546Z" },
{ url = "https://files.pythonhosted.org/packages/71/03/3fced4230c10af14aacadc195fe58e2ced91d011217b450c2e16a09a98c8/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32", size = 55605, upload-time = "2024-12-28T20:17:29.208Z" },
{ url = "https://files.pythonhosted.org/packages/f6/0a/4b34a838c57bd16d3e5861ab963845e73a1041034651f7459e9935289cfd/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d", size = 55353, upload-time = "2024-12-28T20:17:32.433Z" },
{ url = "https://files.pythonhosted.org/packages/d9/68/07d13c6e98e1cad85606a876367ede2de46af859833a1da12c413c201d78/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7", size = 55298, upload-time = "2024-12-28T20:17:34.919Z" },
{ url = "https://files.pythonhosted.org/packages/60/33/45b4d5311f42360f9b900dea53ab2bb20a3d61d7f9b7c37ddfcb3962f86f/backports_datetime_fromisoformat-2.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d", size = 29375, upload-time = "2024-12-28T20:17:36.018Z" },
{ url = "https://files.pythonhosted.org/packages/be/03/7eaa9f9bf290395d57fd30d7f1f2f9dff60c06a31c237dc2beb477e8f899/backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90e202e72a3d5aae673fcc8c9a4267d56b2f532beeb9173361293625fe4d2039", size = 28980, upload-time = "2024-12-28T20:18:06.554Z" },
{ url = "https://files.pythonhosted.org/packages/47/80/a0ecf33446c7349e79f54cc532933780341d20cff0ee12b5bfdcaa47067e/backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2df98ef1b76f5a58bb493dda552259ba60c3a37557d848e039524203951c9f06", size = 28449, upload-time = "2024-12-28T20:18:07.77Z" },
]
[[package]]
name = "certifi"
version = "2025.10.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" },
{ url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" },
{ url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" },
{ url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" },
{ url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" },
{ url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" },
{ url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" },
{ url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" },
{ url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" },
{ url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" },
{ url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" },
{ url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" },
{ url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" },
{ url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" },
{ url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" },
{ url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" },
{ url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" },
{ url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" },
{ url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" },
{ url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" },
{ url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" },
{ url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" },
{ url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" },
{ url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" },
{ url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" },
{ url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" },
{ url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" },
{ url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" },
{ url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" },
{ url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" },
{ url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
{ url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
{ url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
{ url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
{ url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
{ url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
{ url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
{ url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
{ url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
{ url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
{ url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
{ url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
{ url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
{ url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
[[package]]
name = "click"
version = "8.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "construct"
version = "2.8.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/2c/66bab4fef920ef8caa3e180ea601475b2cbbe196255b18f1c58215940607/construct-2.8.8.tar.gz", hash = "sha256:1b84b8147f6fd15bcf64b737c3e8ac5100811ad80c830cb4b2545140511c4157", size = 717694, upload-time = "2016-10-20T22:29:12.563Z" }
[[package]]
name = "dataclass-click"
version = "1.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
]
sdist = { url = "https://files.pythonhosted.org/packages/89/82/5b6035efd90621771fa039960eab3e1ec7ff2a8625033272856843e8bd27/dataclass_click-1.0.4.tar.gz", hash = "sha256:10e7de638dd9e68ae9abd5086f61d8ddee42b1873a70f5fd9fd2167856afac11", size = 7580, upload-time = "2025-10-10T21:11:31.956Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/86/dc/38a94a2eb5f756724a6dc87a7aea38f7b747fe7b2e9daabc34a65e6cd9ac/dataclass_click-1.0.4-py3-none-any.whl", hash = "sha256:a225d30c04e4abbdba411cc3d5ec0a2ea829e1dca6500afe5f87cc243e5ead72", size = 8553, upload-time = "2025-10-10T21:11:30.514Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
]
[[package]]
name = "gamdl"
version = "3.8"
source = { virtual = "." }
dependencies = [
{ name = "async-lru" },
{ name = "click" },
{ name = "colorama" },
{ name = "dataclass-click" },
{ name = "httpx" },
{ name = "httpx-retries" },
{ name = "inquirerpy" },
{ name = "m3u8" },
{ name = "mutagen" },
{ name = "pillow" },
{ name = "pywidevine" },
{ name = "structlog" },
{ name = "yt-dlp" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[package.metadata]
requires-dist = [
{ name = "async-lru", specifier = ">=2.0.5" },
{ name = "click", specifier = ">=8.3.0" },
{ name = "colorama", specifier = ">=0.4.6" },
{ name = "dataclass-click", specifier = ">=1.0.4" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "httpx-retries", specifier = ">=0.4.6" },
{ name = "inquirerpy", specifier = ">=0.3.4" },
{ name = "m3u8", specifier = ">=6.0.0" },
{ name = "mutagen", specifier = ">=1.47.0" },
{ name = "pillow", specifier = ">=12.0.0" },
{ name = "pywidevine", specifier = ">=1.8.0" },
{ name = "structlog", specifier = ">=25.5.0" },
{ name = "yt-dlp", specifier = ">=2025.10.22" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
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"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
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"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pfzy" },
{ name = "prompt-toolkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/64/73/7570847b9da026e07053da3bbe2ac7ea6cde6bb2cbd3c7a5a950fa0ae40b/InquirerPy-0.3.4.tar.gz", hash = "sha256:89d2ada0111f337483cb41ae31073108b2ec1e618a49d7110b0d7ade89fc197e", size = 44431, upload-time = "2022-06-27T23:11:20.598Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/ff/3b59672c47c6284e8005b42e84ceba13864aa0f39f067c973d1af02f5d91/InquirerPy-0.3.4-py3-none-any.whl", hash = "sha256:c65fdfbac1fa00e3ee4fb10679f4d3ed7a012abf4833910e63c295827fe2a7d4", size = 67677, upload-time = "2022-06-27T23:11:17.723Z" },
]
[[package]]
name = "m3u8"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-datetime-fromisoformat", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/a5/73697aaa99bb32b610adc1f11d46a0c0c370351292e9b271755084a145e6/m3u8-6.0.0.tar.gz", hash = "sha256:7ade990a1667d7a653bcaf9413b16c3eb5cd618982ff46aaff57fe6d9fa9c0fd", size = 42720, upload-time = "2024-08-07T11:20:06.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/31/50f3c38b38ff28635ff9c4a4afefddccc5f1b57457b539bdbdf75ce18669/m3u8-6.0.0-py3-none-any.whl", hash = "sha256:566d0748739c552dad10f8c87150078de6a0ec25071fa48e6968e96fc6dcba5d", size = 24133, upload-time = "2024-08-07T11:20:03.96Z" },
]
[[package]]
name = "mutagen"
version = "1.47.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/81/e6/64bc71b74eef4b68e61eb921dcf72dabd9e4ec4af1e11891bbd312ccbb77/mutagen-1.47.0.tar.gz", hash = "sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99", size = 1274186, upload-time = "2023-09-03T16:33:33.411Z" }
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"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d9/5a/32b50c077c86bfccc7bed4881c5a2b823518f5450a30e639db5d3711952e/pfzy-0.3.4.tar.gz", hash = "sha256:717ea765dd10b63618e7298b2d98efd819e0b30cd5905c9707223dceeb94b3f1", size = 8396, upload-time = "2022-01-28T02:26:17.946Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/d7/8ff98376b1acc4503253b685ea09981697385ce344d4e3935c2af49e044d/pfzy-0.3.4-py3-none-any.whl", hash = "sha256:5f50d5b2b3207fa72e7ec0ef08372ef652685470974a107d0d4999fc5a903a96", size = 8537, upload-time = "2022-01-28T02:26:16.047Z" },
]
[[package]]
name = "pillow"
version = "12.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/08/26e68b6b5da219c2a2cb7b563af008b53bb8e6b6fcb3fa40715fcdb2523a/pillow-12.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b", size = 5289809, upload-time = "2025-10-15T18:21:27.791Z" },
{ url = "https://files.pythonhosted.org/packages/cb/e9/4e58fb097fb74c7b4758a680aacd558810a417d1edaa7000142976ef9d2f/pillow-12.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1", size = 4650606, upload-time = "2025-10-15T18:21:29.823Z" },
{ url = "https://files.pythonhosted.org/packages/4b/e0/1fa492aa9f77b3bc6d471c468e62bfea1823056bf7e5e4f1914d7ab2565e/pillow-12.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363", size = 6221023, upload-time = "2025-10-15T18:21:31.415Z" },
{ url = "https://files.pythonhosted.org/packages/c1/09/4de7cd03e33734ccd0c876f0251401f1314e819cbfd89a0fcb6e77927cc6/pillow-12.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca", size = 8024937, upload-time = "2025-10-15T18:21:33.453Z" },
{ url = "https://files.pythonhosted.org/packages/2e/69/0688e7c1390666592876d9d474f5e135abb4acb39dcb583c4dc5490f1aff/pillow-12.0.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e", size = 6334139, upload-time = "2025-10-15T18:21:35.395Z" },
{ url = "https://files.pythonhosted.org/packages/ed/1c/880921e98f525b9b44ce747ad1ea8f73fd7e992bafe3ca5e5644bf433dea/pillow-12.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782", size = 7026074, upload-time = "2025-10-15T18:21:37.219Z" },
{ url = "https://files.pythonhosted.org/packages/28/03/96f718331b19b355610ef4ebdbbde3557c726513030665071fd025745671/pillow-12.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10", size = 6448852, upload-time = "2025-10-15T18:21:39.168Z" },
{ url = "https://files.pythonhosted.org/packages/3a/a0/6a193b3f0cc9437b122978d2c5cbce59510ccf9a5b48825096ed7472da2f/pillow-12.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa", size = 7117058, upload-time = "2025-10-15T18:21:40.997Z" },
{ url = "https://files.pythonhosted.org/packages/a7/c4/043192375eaa4463254e8e61f0e2ec9a846b983929a8d0a7122e0a6d6fff/pillow-12.0.0-cp310-cp310-win32.whl", hash = "sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275", size = 6295431, upload-time = "2025-10-15T18:21:42.518Z" },
{ url = "https://files.pythonhosted.org/packages/92/c6/c2f2fc7e56301c21827e689bb8b0b465f1b52878b57471a070678c0c33cd/pillow-12.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d", size = 7000412, upload-time = "2025-10-15T18:21:44.404Z" },
{ url = "https://files.pythonhosted.org/packages/b2/d2/5f675067ba82da7a1c238a73b32e3fd78d67f9d9f80fbadd33a40b9c0481/pillow-12.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7", size = 2435903, upload-time = "2025-10-15T18:21:46.29Z" },
{ url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" },
{ url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" },
{ url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" },
{ url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" },
{ url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" },
{ url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" },
{ url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" },
{ url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" },
{ url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" },
{ url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" },
{ url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" },
{ url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" },
{ url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" },
{ url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" },
{ url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" },
{ url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" },
{ url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" },
{ url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" },
{ url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" },
{ url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" },
{ url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" },
{ url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" },
{ url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" },
{ url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" },
{ url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" },
{ url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" },
{ url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" },
{ url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" },
{ url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" },
{ url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" },
{ url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" },
{ url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" },
{ url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" },
{ url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" },
{ url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" },
{ url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" },
{ url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" },
{ url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" },
{ url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" },
{ url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" },
{ url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" },
{ url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" },
{ url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" },
{ url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" },
{ url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" },
{ url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" },
{ url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" },
{ url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" },
{ url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" },
{ url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" },
{ url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" },
{ url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" },
{ url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" },
{ url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" },
{ url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" },
{ url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" },
{ url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" },
{ url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" },
{ url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" },
{ url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" },
{ url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" },
{ url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" },
{ url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" },
{ url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" },
{ url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" },
{ url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" },
{ url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" },
{ url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" },
{ url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" },
{ url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" },
{ url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" },
{ url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" },
{ url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" },
{ 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"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
]
[[package]]
name = "protobuf"
version = "4.25.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/01/34c8d2b6354906d728703cb9d546a0e534de479e25f1b581e4094c4a85cc/protobuf-4.25.8.tar.gz", hash = "sha256:6135cf8affe1fc6f76cced2641e4ea8d3e59518d1f24ae41ba97bcad82d397cd", size = 380920, upload-time = "2025-05-28T14:22:25.153Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/ff/05f34305fe6b85bbfbecbc559d423a5985605cad5eda4f47eae9e9c9c5c5/protobuf-4.25.8-cp310-abi3-win32.whl", hash = "sha256:504435d831565f7cfac9f0714440028907f1975e4bed228e58e72ecfff58a1e0", size = 392745, upload-time = "2025-05-28T14:22:10.524Z" },
{ url = "https://files.pythonhosted.org/packages/08/35/8b8a8405c564caf4ba835b1fdf554da869954712b26d8f2a98c0e434469b/protobuf-4.25.8-cp310-abi3-win_amd64.whl", hash = "sha256:bd551eb1fe1d7e92c1af1d75bdfa572eff1ab0e5bf1736716814cdccdb2360f9", size = 413736, upload-time = "2025-05-28T14:22:13.156Z" },
{ url = "https://files.pythonhosted.org/packages/28/d7/ab27049a035b258dab43445eb6ec84a26277b16105b277cbe0a7698bdc6c/protobuf-4.25.8-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:ca809b42f4444f144f2115c4c1a747b9a404d590f18f37e9402422033e464e0f", size = 394537, upload-time = "2025-05-28T14:22:14.768Z" },
{ url = "https://files.pythonhosted.org/packages/bd/6d/a4a198b61808dd3d1ee187082ccc21499bc949d639feb948961b48be9a7e/protobuf-4.25.8-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:9ad7ef62d92baf5a8654fbb88dac7fa5594cfa70fd3440488a5ca3bfc6d795a7", size = 294005, upload-time = "2025-05-28T14:22:16.052Z" },
{ url = "https://files.pythonhosted.org/packages/d6/c6/c9deaa6e789b6fc41b88ccbdfe7a42d2b82663248b715f55aa77fbc00724/protobuf-4.25.8-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:83e6e54e93d2b696a92cad6e6efc924f3850f82b52e1563778dfab8b355101b0", size = 294924, upload-time = "2025-05-28T14:22:17.105Z" },
{ url = "https://files.pythonhosted.org/packages/0c/c1/6aece0ab5209981a70cd186f164c133fdba2f51e124ff92b73de7fd24d78/protobuf-4.25.8-py3-none-any.whl", hash = "sha256:15a0af558aa3b13efef102ae6e4f3efac06f1eea11afb3a57db2901447d9fb59", size = 156757, upload-time = "2025-05-28T14:22:24.135Z" },
]
[[package]]
name = "pycryptodome"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
{ url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" },
{ url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" },
{ url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" },
{ url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" },
{ 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"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "construct" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a5/46/dfb3f5363fc71adaf419147fdcb93341029ca638634a5cc6f7e7446416b2/pymp4-1.4.0.tar.gz", hash = "sha256:bc9e77732a8a143d34c38aa862a54180716246938e4bf3e07585d19252b77bb5", size = 13018, upload-time = "2023-05-07T15:01:34.02Z" }
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"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "protobuf" },
{ name = "pycryptodome" },
{ name = "pymp4" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "unidecode" },
]
sdist = { url = "https://files.pythonhosted.org/packages/99/12/6ff0e6ffa2711187ee629392396d7c18ae6ca8e2e576dcef2d636316d667/pywidevine-1.8.0.tar.gz", hash = "sha256:c14f3fe2864473416b9caa73d9a21251a02d72138e6d54d8c1a3f44b7a6b05c9", size = 76406, upload-time = "2023-12-22T11:13:12.556Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/9f/60f8a4c8e7767a8c34f5c42428662e03fa3e38ad18ba41fcc5370ee43263/pywidevine-1.8.0-py3-none-any.whl", hash = "sha256:1ecf029ce562789b18bbbd64604596d15645aadf413b255cf0fafc8d8b06659d", size = 70476, upload-time = "2023-12-22T11:13:10.84Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
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"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "unidecode"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149, upload-time = "2025-04-24T08:45:03.798Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837, upload-time = "2025-04-24T08:45:01.609Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
[[package]]
name = "wcwidth"
version = "0.2.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" },
]
[[package]]
name = "yt-dlp"
version = "2025.10.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/08/70/cf4bd6c837ab0a709040888caa70d166aa2dfbb5018d1d5c983bf0b50254/yt_dlp-2025.10.22.tar.gz", hash = "sha256:db2d48133222b1d9508c6de757859c24b5cefb9568cf68ccad85dac20b07f77b", size = 3046863, upload-time = "2025-10-22T19:53:19.301Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/2a/fd184bf97d570841aa86b4aeb84aee93e7957a34059dafd4982157c10bff/yt_dlp-2025.10.22-py3-none-any.whl", hash = "sha256:9c803a9598859f91d0d5bd3337f1506ecb40bbe97f6efbe93bc4461fed344fb2", size = 3248983, upload-time = "2025-10-22T19:53:16.483Z" },
]
+467
View File
@@ -0,0 +1,467 @@
import os
import sys
import asyncio
import subprocess
import shutil
import json
import logging
from pathlib import Path
from typing import Optional, List, Dict, Any
from fastapi import FastAPI, File, UploadFile, Query, HTTPException, BackgroundTasks
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
# Set up logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("gamdl-aio")
# Add gamdl to sys.path
PROJECT_ROOT = Path(__file__).parent.resolve()
sys.path.insert(0, str(PROJECT_ROOT / "gamdl"))
# Import gamdl modules
try:
from gamdl.api import AppleMusicApi
from gamdl.api.wrapper import WrapperApi
from gamdl.interface import AppleMusicBaseInterface, AppleMusicInterface, AppleMusicSongInterface
from gamdl.downloader import AppleMusicBaseDownloader, AppleMusicDownloader, AppleMusicSongDownloader
from gamdl.interface.enums import SongCodec, SyncedLyricsFormat, CoverFormat
except ImportError as e:
logger.error(f"Failed to import gamdl modules: {e}")
# We will let the app start so the user can use the WebUI to troubleshoot if needed.
app = FastAPI(title="Gamdl All-in-One", version="1.0.0")
# Global states
wrapper_proc: Optional[subprocess.Popen] = None
wrapper_api: Optional[WrapperApi] = None
apple_music_api: Optional[AppleMusicApi] = None
# Paths
WRAPPER_DIR = PROJECT_ROOT / "wrapper-v2"
ROOTFS_DIR = WRAPPER_DIR / "rootfs"
SYSTEM_LIBS_DIR = ROOTFS_DIR / "system" / "lib64"
DATA_DIR = PROJECT_ROOT / "data"
DATA_DIR.mkdir(exist_ok=True)
# Environment settings
HTTP_PORT = int(os.getenv("HTTP_PORT", "8080"))
TARGET_ARCH = os.getenv("TARGET_ARCH", "arm64-v8a")
class LoginRequest(BaseModel):
username: str
password: str
class Login2FARequest(BaseModel):
code: str
# --- Helper Functions ---
async def start_wrapper_daemon():
global wrapper_proc, wrapper_api, apple_music_api
# Check if native libraries are staged
sentinel = SYSTEM_LIBS_DIR / "libandroidappmusic.so"
if not sentinel.exists():
logger.warning(f"Native Apple libraries not staged at {sentinel}. Wrapper daemon cannot start yet.")
return False
logger.info("Starting wrapper-v2 daemon...")
# Configure env for wrapper launcher
env = os.environ.copy()
env["HTTP_PORT"] = str(HTTP_PORT)
env["TARGET_ARCH"] = TARGET_ARCH
env["WRAPPER_BASE_DIR"] = str(DATA_DIR / "mpl_db")
(DATA_DIR / "mpl_db").mkdir(exist_ok=True)
# The wrapper binary requires rootfs/ to be in its working directory
wrapper_bin = WRAPPER_DIR / "wrapper"
if not wrapper_bin.exists():
logger.error(f"Wrapper binary not found at {wrapper_bin}. Run build first.")
return False
try:
# Start wrapper daemon as supervisor
wrapper_proc = subprocess.Popen(
[str(wrapper_bin)],
cwd=str(WRAPPER_DIR),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True
)
# Monitor startup output
await asyncio.sleep(2)
logger.info("wrapper-v2 daemon started successfully.")
# Initialize Wrapper API and Apple Music API
await init_apple_music_api()
return True
except Exception as e:
logger.error(f"Failed to start wrapper daemon: {e}")
return False
async def init_apple_music_api():
global wrapper_api, apple_music_api
try:
wrapper_api = await WrapperApi.create(
base_url=f"http://127.0.0.1:{HTTP_PORT}",
get_credentials_func=None,
get_2fa_code=None,
)
apple_music_api = await AppleMusicApi.create_from_wrapper(
wrapper_api=wrapper_api,
language="en-US",
)
logger.info("AppleMusicApi initialized from wrapper.")
except Exception as e:
logger.warning(f"Could not initialize AppleMusicApi (likely not logged in yet): {e}")
# --- API Endpoints ---
@app.on_event("startup")
async def startup_event():
# Attempt to start the wrapper daemon on startup
await start_wrapper_daemon()
@app.on_event("shutdown")
def shutdown_event():
global wrapper_proc
if wrapper_proc:
logger.info("Stopping wrapper-v2 daemon...")
wrapper_proc.terminate()
wrapper_proc.wait()
logger.info("wrapper-v2 daemon stopped.")
@app.get("/api/health")
async def health_check():
staged = (SYSTEM_LIBS_DIR / "libandroidappmusic.so").exists()
daemon_running = wrapper_proc is not None and wrapper_proc.poll() is None
wrapper_healthy = False
if daemon_running:
try:
import httpx
async with httpx.AsyncClient() as client:
res = await client.get(f"http://127.0.0.1:{HTTP_PORT}/health", timeout=2.0)
if res.status_code == 200:
wrapper_healthy = True
except Exception:
pass
return {
"status": "ok",
"staged": staged,
"daemon_running": daemon_running,
"wrapper_healthy": wrapper_healthy,
"logged_in": apple_music_api is not None and apple_music_api.active_subscription
}
@app.get("/api/me")
async def get_me():
if not wrapper_api:
return {"logged_in": False, "error": "Wrapper API not initialized"}
try:
import httpx
async with httpx.AsyncClient() as client:
res = await client.get(f"http://127.0.0.1:{HTTP_PORT}/me")
return res.json()
except Exception as e:
return {"logged_in": False, "error": str(e)}
@app.post("/api/login")
async def login(req: LoginRequest):
if not wrapper_api:
raise HTTPException(status_code=503, detail="Wrapper API not ready")
try:
import httpx
async with httpx.AsyncClient() as client:
res = await client.post(
f"http://127.0.0.1:{HTTP_PORT}/login",
json={"username": req.username, "password": req.password}
)
# Re-initialize API if login successful immediately (no 2FA)
if res.status_code == 200:
await init_apple_music_api()
return JSONResponse(status_code=res.status_code, content=res.json())
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/login/2fa")
async def login_2fa(req: Login2FARequest):
if not wrapper_api:
raise HTTPException(status_code=503, detail="Wrapper API not ready")
try:
import httpx
async with httpx.AsyncClient() as client:
res = await client.post(
f"http://127.0.0.1:{HTTP_PORT}/login/2fa",
json={"code": req.code}
)
if res.status_code == 200:
await init_apple_music_api()
return JSONResponse(status_code=res.status_code, content=res.json())
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/setup")
async def upload_apk(file: UploadFile = File(...)):
# Save uploaded file
temp_apk = DATA_DIR / file.filename
try:
with open(temp_apk, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
logger.info(f"Received APK file: {temp_apk.name}. Extracting libraries...")
# Run stage system libraries first
stage_cmd = ["bash", "tools/stage-system.sh", "--arch", TARGET_ARCH]
stage_res = subprocess.run(stage_cmd, cwd=str(WRAPPER_DIR), capture_output=True, text=True)
if stage_res.returncode != 0:
logger.error(f"stage-system failed: {stage_res.stderr}")
raise HTTPException(status_code=500, detail=f"Failed to stage system libs: {stage_res.stderr}")
# Run extract libraries
extract_cmd = [
"bash", "tools/extract-libs.sh",
"--bundle", str(temp_apk),
"--arch", TARGET_ARCH,
"--ignore-hash"
]
extract_res = subprocess.run(extract_cmd, cwd=str(WRAPPER_DIR), capture_output=True, text=True)
if extract_res.returncode != 0:
logger.error(f"extract-libs failed: {extract_res.stderr}")
raise HTTPException(status_code=500, detail=f"Failed to extract Apple libs: {extract_res.stderr}")
# Clean up uploaded APK file to save disk space
temp_apk.unlink()
# Start/Restart the daemon
global wrapper_proc
if wrapper_proc:
wrapper_proc.terminate()
wrapper_proc.wait()
success = await start_wrapper_daemon()
return {"status": "success", "daemon_started": success}
except Exception as e:
if temp_apk.exists():
temp_apk.unlink()
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/search")
async def search(q: str, type: str = "song", limit: int = 20):
if not apple_music_api or not apple_music_api.active_subscription:
raise HTTPException(status_code=401, detail="Apple Music subscription not authenticated")
try:
# Map types
results = []
if type == "song":
res = await apple_music_api.get_search_results(q, limit=limit, types="songs")
songs = res.get("results", {}).get("songs", {}).get("data", [])
for s in songs:
attrs = s["attributes"]
results.append({
"id": s["id"],
"title": attrs["name"],
"artist": attrs["artistName"],
"album": attrs["albumName"],
"duration": int(attrs["durationInMillis"] / 1000),
"cover_url": attrs["artwork"]["url"].replace("{w}", "600").replace("{h}", "600"),
"track_number": attrs.get("trackNumber"),
"isrc": attrs.get("isrc")
})
elif type == "album":
res = await apple_music_api.get_search_results(q, limit=limit, types="albums")
albums = res.get("results", {}).get("albums", {}).get("data", [])
for al in albums:
attrs = al["attributes"]
results.append({
"id": al["id"],
"title": attrs["name"],
"artist": attrs["artistName"],
"cover_url": attrs["artwork"]["url"].replace("{w}", "600").replace("{h}", "600"),
"release_date": attrs.get("releaseDate")
})
elif type == "artist":
res = await apple_music_api.get_search_results(q, limit=limit, types="artists")
artists = res.get("results", {}).get("artists", {}).get("data", [])
for ar in artists:
attrs = ar["attributes"]
results.append({
"id": ar["id"],
"name": attrs["name"],
"url": attrs.get("url")
})
return results
except Exception as e:
logger.exception(f"Search failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/song/{track_id}")
async def get_song(track_id: str):
if not apple_music_api or not apple_music_api.active_subscription:
raise HTTPException(status_code=401, detail="Apple Music subscription not authenticated")
try:
res = await apple_music_api.get_song(track_id)
song_data = res.get("data", [])
if not song_data:
raise HTTPException(status_code=404, detail="Song not found")
s = song_data[0]
attrs = s["attributes"]
# Extract metadata
return {
"id": s["id"],
"title": attrs["name"],
"artist": attrs["artistName"],
"album": attrs["albumName"],
"duration": int(attrs["durationInMillis"] / 1000),
"track_number": attrs.get("trackNumber"),
"disc_number": attrs.get("discNumber"),
"isrc": attrs.get("isrc"),
"cover_url": attrs["artwork"]["url"].replace("{w}", "1200").replace("{h}", "1200"),
"release_date": attrs.get("releaseDate"),
"copyright": attrs.get("copyright"),
"composer": attrs.get("composerName"),
"genre": attrs.get("genreNames", [None])[0] if attrs.get("genreNames") else None
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/stream/{track_id}")
async def stream_audio(track_id: str, quality: str = "alac-16-44"):
if not apple_music_api or not apple_music_api.active_subscription:
raise HTTPException(status_code=401, detail="Apple Music subscription not authenticated")
# Setup temporary directory for download/transcode
temp_dir = DATA_DIR / f"temp_{track_id}"
temp_dir.mkdir(exist_ok=True)
try:
# Determine codec matching the requested quality
codec_priority = [SongCodec.ALAC]
if quality == "alac-16-44":
codec_priority = [SongCodec.ALAC_16_44]
elif quality == "alac-24-96":
codec_priority = [SongCodec.ALAC_24_96]
elif quality == "alac-24-192":
codec_priority = [SongCodec.ALAC_24_192]
elif quality == "aac-320":
codec_priority = [SongCodec.AAC]
elif quality == "aac-96":
codec_priority = [SongCodec.AAC_HE]
logger.info(f"Downloading stream for track {track_id} with codec {codec_priority[0].value}")
# Instantiate gamdl interfaces programmatically
base_interface = await AppleMusicBaseInterface.create(
apple_music_api=apple_music_api,
cover_format=CoverFormat.JPG,
cover_size=1200,
wrapper_api=wrapper_api,
)
song_interface = AppleMusicSongInterface(
base=base_interface,
synced_lyrics_format=SyncedLyricsFormat.LRC,
codec_priority=codec_priority,
)
interface = AppleMusicInterface(
song=song_interface,
)
base_downloader = AppleMusicBaseDownloader(
interface=interface,
output_path=str(temp_dir),
temp_path=str(temp_dir),
download_mode="ytdlp", # Use yt-dlp library mode inside container
no_album_folder_template=True, # Save as flat filename
no_album_file_template="{title_id}", # File named after track ID
)
song_downloader = AppleMusicSongDownloader(base=base_downloader)
downloader = AppleMusicDownloader(
song=song_downloader,
overwrite=True,
no_synced_lyrics=True,
)
# Get download items and execute
url = f"https://music.apple.com/us/song/{track_id}"
download_queue = []
async for item in downloader.get_download_item_from_url(url):
download_queue.append(item)
if not download_queue:
raise HTTPException(status_code=404, detail="Track not available or mismatch")
item = download_queue[0]
await downloader.download(item)
# Find the decrypted M4A file
m4a_path = Path(item.staged_path)
if not m4a_path.exists():
raise FileNotFoundError("M4A download file was not generated.")
# Transcode on-the-fly to FLAC
flac_path = temp_dir / f"{track_id}.flac"
logger.info(f"Transcoding {m4a_path.name} to FLAC...")
cmd = [
"ffmpeg", "-y",
"-i", str(m4a_path),
"-c:a", "flac",
"-map", "0:a",
"-map", "0:v?",
"-c:v", "copy",
str(flac_path)
]
res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if res.returncode != 0:
logger.error(f"FFmpeg failed: {res.stderr.decode('utf-8')}")
raise Exception("FFmpeg transcode failed")
# Return streaming file response
# Background task cleans up files AFTER response is sent
def cleanup():
try:
shutil.rmtree(temp_dir)
logger.info(f"Cleaned up temp directory: {temp_dir}")
except Exception as e:
logger.error(f"Failed to cleanup temp files: {e}")
return FileResponse(
path=str(flac_path),
media_type="audio/flac",
filename=f"{track_id}.flac",
background=BackgroundTasks().add_task(cleanup)
)
except Exception as e:
logger.exception(f"Streaming failed for track {track_id}")
if temp_dir.exists():
shutil.rmtree(temp_dir)
raise HTTPException(status_code=500, detail=f"Streaming failed: {str(e)}")
# Serve API status on root
@app.get("/")
async def root():
return {"name": "Gamdl All-in-One", "status": "online"}
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=False)
+19
View File
@@ -0,0 +1,19 @@
fastapi>=0.100.0
uvicorn>=0.22.0
python-multipart>=0.0.6
httpx>=0.24.1
pydantic>=2.0
structlog>=23.1.0
click>=8.1.3
requests>=2.31.0
colorama>=0.4.6
inquirerpy>=0.3.4
mutagen>=1.46.0
pywidevine>=1.5.0
yt-dlp>=2023.7.6
m3u8>=4.0.0
pyyaml>=6.0.1
pillow>=10.0.0
construct==2.8.8
anyio>=3.7.1
httpx-retries>=0.4.2
+17
View File
@@ -0,0 +1,17 @@
.git/
.github/
.tmp/
.cache/
build/
build-*/
cmake-build-*/
*.apk
*.apkm
*.md
!README.md
.vscode/
.idea/
.DS_Store
Thumbs.db
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
[*.{yml,yaml,json}]
indent_size = 2
[Dockerfile]
indent_size = 4
[Makefile]
indent_style = tab
+7
View File
@@ -0,0 +1,7 @@
# wrapper-v2 environment for macOS Apple Silicon
# Build stage is always linux/amd64 (Google NDK is x86_64-only)
# Runtime is linux/arm64 for native performance on Apple Silicon
HTTP_PORT=8080
TARGET_ARCH=arm64-v8a
BUILD_PLATFORM=linux/amd64
RUNTIME_PLATFORM=linux/arm64
+68
View File
@@ -0,0 +1,68 @@
# wrapper-v2 environment template.
# Copy to .env and fill in. Anything left commented uses the default.
# Public host port that maps onto the daemon's HTTP server.
# Defaults to 80 inside the container; set this if your host already has
# something bound to :80 (nginx, caddy, IIS, Docker Desktop, ...).
# HTTP_PORT=8080
# Target arch for compose/docker build (must match extract-libs + stage-system).
# x86_64 (default) or arm64-v8a. For arm64 also set BUILD_PLATFORM and RUNTIME_PLATFORM.
# TARGET_ARCH=x86_64
# BUILD_PLATFORM=linux/amd64
# RUNTIME_PLATFORM=linux/amd64
# arm64-v8a example (native arm64 Docker builder):
# TARGET_ARCH=arm64-v8a
# BUILD_PLATFORM=linux/arm64
# RUNTIME_PLATFORM=linux/arm64
# URL the CI workflow downloads the APKMirror .apkm bundle from (all splits in one file).
# Set as a GitHub Actions secret, not committed here. Local builds do not
# need this - run tools/fetch-apk.sh + tools/extract-libs.sh manually.
# APK_URL=https://example.invalid/apple-music-3.6.0-beta-1109.apkm
# ---------------------------------------------------------------------------
# Daemon runtime (read inside the chroot, prefix WRAPPER_*)
# ---------------------------------------------------------------------------
# Bind address and port the public supervisor listens on inside the chroot.
# WRAPPER_HOST=0.0.0.0
# WRAPPER_PORT=80
# Process role. Leave as supervisor for normal use; the supervisor sets
# WRAPPER_MODE=worker automatically for its private subprocess.
# WRAPPER_MODE=supervisor
# Private loopback port used by the supervisor to talk to the Apple runtime
# worker subprocess.
# WRAPPER_WORKER_PORT=18080
# Apple-lib working directory (FairPlay key cache, mpl_db, etc).
# Must exist inside rootfs/. The default matches upstream wrapper.
# WRAPPER_BASE_DIR=/data/data/com.apple.android.music/files
# Slash-separated 9-tuple identifying the fake "Apple Music Android client"
# the daemon presents to Apple's servers. Order:
# ClientId/VersionId/PlatformId/ProductVersion/DeviceModel/BuildVersion/
# LocaleId/LanguageId/AndroidID
# Default is the same Galaxy-S9 fingerprint upstream wrapper uses.
# WRAPPER_DEVICE_INFO=Music/4.9/Android/10/Samsung S9/7663313/en-US/en-US/dc28071e981c439e
# Set to 0 to skip Apple lib initialization at startup. With this flag set,
# the daemon binds the HTTP server but does NOT call into libstoreservicescore
# / libmediaplatform / libandroidappmusic. Useful for /health-only smoke
# tests on builds where you have not staged the Apple libraries yet.
# WRAPPER_APPLE_INIT=1
# Set to 1 to print stage-by-stage diagnostic lines from loader.cpp
# (`loader: dlopen ... / dlsym ...`) and runtime.cpp (`runtime: trace ...`).
# Useful when the daemon SIGSEGVs during init - the last printed stage shows
# which dlopen / dlsym / Apple call was running when the fault hit.
# WRAPPER_RUNTIME_TRACE=1
# Optional: sign in at startup (after session restore) using the same
# credentials as POST /login. Secrets live in the process environment — avoid
# committing a .env that contains real values; use app-specific passwords.
# WRAPPER_USERNAME=you@example.com
# WRAPPER_PASSWORD=your-app-specific-password
+19
View File
@@ -0,0 +1,19 @@
* text=auto eol=lf
*.sh text eol=lf
*.bash text eol=lf
Dockerfile text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.json text eol=lf
*.cmake text eol=lf
*.c text eol=lf
*.cpp text eol=lf
*.h text eol=lf
*.hpp text eol=lf
*.png binary
*.jpg binary
*.so binary
*.apk binary
*.apkm binary
+31
View File
@@ -0,0 +1,31 @@
# Build outputs
/build/
/build-*/
/cmake-build-*/
# Runtime mount for docker compose
/data/
# Local rootfs overlay (IANA DB is vendored under vendor/android-system/…/zoneinfo)
/rootfs/
# Final binaries at repo root (produced by build)
/wrapper
/main
# Tooling caches and APK staging
/.tmp/
/.temp/.login-response.json
/.cache/
*.apk
*.apkm
# Editor / IDE
.vscode/
.idea/
*.swp
*~
# OS
.DS_Store
Thumbs.db
+130
View File
@@ -0,0 +1,130 @@
cmake_minimum_required(VERSION 3.22)
# wrapper-v2 builds two binaries:
# wrapper - native-Linux chroot launcher (built with the host toolchain)
# main - the daemon, cross-compiled with the Android NDK so that it can
# be exec'd inside a chroot whose dynamic loader is Android's
# linker64 and whose .so dependencies are Apple Music's libs.
#
# These two targets cannot share a single CMake configuration because they
# target different sysroots, ABIs, and toolchains. The top-level project here
# only ever builds the launcher; it then invokes a sub-build for the daemon
# using the NDK toolchain file. This keeps each target's flags clean and
# avoids contaminating either build with the other's compiler.
#
# Usage:
# cmake -S . -B build -DTARGET_ARCH=x86_64 # or arm64-v8a
# cmake --build build -j
#
# Required input:
# ANDROID_NDK_HOME or NDK_PATH must point at a usable NDK r23b install.
project(wrapper-v2 LANGUAGES C CXX)
set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# -----------------------------------------------------------------------------
# Options
# -----------------------------------------------------------------------------
set(TARGET_ARCH "x86_64" CACHE STRING "Target ABI for the chrooted daemon (matches extract-libs/stage-system --arch)")
set_property(CACHE TARGET_ARCH PROPERTY STRINGS "x86_64" "arm64-v8a")
option(WRAPPER_BUILD_DAEMON "Drive the NDK sub-build for the chrooted daemon" ON)
# -----------------------------------------------------------------------------
# Locate the NDK (only required when WRAPPER_BUILD_DAEMON is ON)
# -----------------------------------------------------------------------------
if(WRAPPER_BUILD_DAEMON)
if(DEFINED ENV{ANDROID_NDK_HOME} AND NOT DEFINED NDK_PATH)
set(NDK_PATH "$ENV{ANDROID_NDK_HOME}")
endif()
if(NOT DEFINED NDK_PATH)
message(FATAL_ERROR
"NDK_PATH not set. Provide -DNDK_PATH=/path/to/android-ndk-r23b "
"or export ANDROID_NDK_HOME, or pass -DWRAPPER_BUILD_DAEMON=OFF "
"to skip the daemon sub-build.")
endif()
if(NOT EXISTS "${NDK_PATH}/build/cmake/android.toolchain.cmake")
message(FATAL_ERROR "NDK_PATH '${NDK_PATH}' does not look like an NDK install.")
endif()
endif()
# -----------------------------------------------------------------------------
# Host build: the chroot launcher
# -----------------------------------------------------------------------------
add_executable(wrapper src/launcher/wrapper.c)
target_compile_options(wrapper PRIVATE -Wall -Wextra -O2)
set_target_properties(wrapper PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}"
)
# Place the launcher at the repo root after build (matches upstream layout
# expected by entrypoint.sh and Docker COPY paths).
add_custom_command(TARGET wrapper POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
$<TARGET_FILE:wrapper>
${CMAKE_SOURCE_DIR}/wrapper
COMMENT "Staging wrapper binary at repo root"
)
# -----------------------------------------------------------------------------
# Cross build: the chrooted daemon
# -----------------------------------------------------------------------------
# We invoke a fully separate CMake configure for src/daemon using the NDK
# toolchain file. This produces rootfs/system/bin/main.
if(WRAPPER_BUILD_DAEMON)
set(_daemon_src "${CMAKE_SOURCE_DIR}/src/daemon")
set(_daemon_build "${CMAKE_BINARY_DIR}/daemon-${TARGET_ARCH}")
set(_daemon_output "${CMAKE_SOURCE_DIR}/rootfs/system/bin/main")
if(TARGET_ARCH STREQUAL "x86_64")
set(_android_abi "x86_64")
elseif(TARGET_ARCH STREQUAL "arm64-v8a")
set(_android_abi "arm64-v8a")
else()
message(FATAL_ERROR "TARGET_ARCH '${TARGET_ARCH}' is not supported (use x86_64 or arm64-v8a).")
endif()
file(MAKE_DIRECTORY "${_daemon_build}")
# ANDROID_PLATFORM is intentionally low: we ship vendored bionic/AOSP
# binaries from an Android 5.0-era system image (no __register_atfork,
# no DT_GNU_HASH support in the linker). Targeting API 21 makes NDK
# r23b emit code that runs against that older bionic. Apple's libs
# ship libc++_shared.so built the same way, so ABIs line up.
add_custom_target(daemon ALL
COMMAND ${CMAKE_COMMAND}
-S "${_daemon_src}"
-B "${_daemon_build}"
-G "${CMAKE_GENERATOR}"
-DCMAKE_TOOLCHAIN_FILE=${NDK_PATH}/build/cmake/android.toolchain.cmake
-DANDROID_ABI=${_android_abi}
-DANDROID_PLATFORM=android-21
-DANDROID_STL=c++_shared
-DCMAKE_BUILD_TYPE=$<IF:$<CONFIG:Debug>,Debug,Release>
-DLIBS_DIR=${CMAKE_SOURCE_DIR}/rootfs/system/lib64
COMMAND ${CMAKE_COMMAND} --build "${_daemon_build}" -j
COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_SOURCE_DIR}/rootfs/system/bin"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${_daemon_build}/main"
"${_daemon_output}"
BYPRODUCTS "${_daemon_output}"
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
VERBATIM
COMMENT "Building daemon (NDK, ABI=${_android_abi}) -> ${_daemon_output}"
)
endif()
# -----------------------------------------------------------------------------
# Diagnostics
# -----------------------------------------------------------------------------
message(STATUS "wrapper-v2:")
message(STATUS " TARGET_ARCH = ${TARGET_ARCH}")
message(STATUS " WRAPPER_BUILD_DAEMON= ${WRAPPER_BUILD_DAEMON}")
if(WRAPPER_BUILD_DAEMON)
message(STATUS " NDK_PATH = ${NDK_PATH}")
endif()
+103
View File
@@ -0,0 +1,103 @@
# wrapper-v2 image.
#
# This Dockerfile assumes that rootfs/system/lib64/ has already been populated
# by the host (or in CI) for the same TARGET_ARCH. The image does not download
# or extract Apple Music artifacts.
#
# The build stage is always linux/amd64: Google only publishes the Linux NDK as
# an x86_64-host zip (android-ndk-r*b-linux.zip). On TARGET_ARCH=arm64-v8a we
# cross-compile the host `wrapper` with gcc-aarch64-linux-gnu; the daemon is
# still built with the NDK for ANDROID_ABI=arm64-v8a.
#
# Final image platform (e.g. linux/arm64 vs linux/amd64) comes from
# RUNTIME_PLATFORM and must match TARGET_ARCH / the staged rootfs.
#
# Example:
# docker build --build-arg TARGET_ARCH=arm64-v8a \
# --build-arg RUNTIME_PLATFORM=linux/arm64 -t wrapper-v2:arm64 .
ARG RUNTIME_PLATFORM=linux/amd64
# Kept for docker-compose compatibility; the compile stage ignores this.
ARG BUILD_PLATFORM=linux/amd64
# -----------------------------------------------------------------------------
# Build stage (always amd64 — official NDK is x86_64-hosted)
# -----------------------------------------------------------------------------
FROM --platform=linux/amd64 debian:13.2 AS build
ARG TARGET_ARCH=x86_64
ARG CMAKE_BUILD_TYPE=Release
ARG NDK_VERSION=23
SHELL ["/bin/bash", "-c"]
ENV DEBIAN_FRONTEND=noninteractive
RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
--mount=type=cache,target=/var/cache/apt,sharing=locked \
apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
cmake \
curl \
git \
jq \
ninja-build \
unzip \
&& if [[ "$TARGET_ARCH" == "arm64-v8a" ]]; then \
apt-get install -y --no-install-recommends \
gcc-aarch64-linux-gnu g++-aarch64-linux-gnu; \
fi \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fSL -o /tmp/ndk.zip \
"https://dl.google.com/android/repository/android-ndk-r${NDK_VERSION}b-linux.zip" && \
unzip -q /tmp/ndk.zip -d /opt && \
rm /tmp/ndk.zip
ENV ANDROID_NDK_HOME=/opt/android-ndk-r${NDK_VERSION}b
WORKDIR /app
COPY . /app
RUN test -f rootfs/system/bin/linker64 || { \
echo "ERROR: rootfs/system/bin/linker64 is missing." >&2; \
echo "Run tools/stage-system.sh on the host before docker build." >&2; \
exit 1; \
} && \
test -d rootfs/system/lib64 && \
ls rootfs/system/lib64/*.so >/dev/null 2>&1 || { \
echo "ERROR: rootfs/system/lib64/ has no .so files." >&2; \
echo "Stage the required Apple Music native libraries before docker build." >&2; \
exit 1; \
}
RUN host_cc=(); \
if [[ "$TARGET_ARCH" == "arm64-v8a" ]]; then \
host_cc=( \
-DCMAKE_C_COMPILER=aarch64-linux-gnu-gcc \
-DCMAKE_CXX_COMPILER=aarch64-linux-gnu-g++ \
); \
fi && \
cmake -S . -B build -G Ninja \
-DTARGET_ARCH="${TARGET_ARCH}" \
"${host_cc[@]}" \
-DCMAKE_BUILD_TYPE="${CMAKE_BUILD_TYPE}" && \
cmake --build build -j
# -----------------------------------------------------------------------------
# Runtime stage
# -----------------------------------------------------------------------------
FROM --platform=${RUNTIME_PLATFORM} debian:13.2
WORKDIR /app
COPY --from=build /app/wrapper /app/wrapper
COPY --from=build /app/rootfs /app/rootfs
# Apple's libcurl inside the chroot needs CA certificates for SSL verification.
# The build stage has ca-certificates installed; copy the bundle into the rootfs
# where the launcher will point SSL_CERT_FILE / CURL_CA_BUNDLE at it.
COPY --from=build /etc/ssl/certs/ca-certificates.crt /app/rootfs/etc/ssl/certs/ca-certificates.crt
EXPOSE 80
ENTRYPOINT ["/app/wrapper"]
+225
View File
@@ -0,0 +1,225 @@
{
"$comment": "Pinned native library hashes. Apple .so files are not committed; when staged manually they should match .libs.<arch>. AOSP paths match .android_system.",
"apple_music": {
"package": "com.apple.android.music",
"version": "3.6.0-beta",
"build": "1109",
"ndk_version": "r23b"
},
"android_system": {
"$comment": "Android system libraries needed at runtime by Apple's .so files. Committed to the repo under vendor/android-system/<arch>/. Apache-2.0 (AOSP). See vendor/android-system/README.md for provenance.",
"x86_64": {
"bin/linker64": "85982b4aa9a0a5f24f8c1dca285ba08eb6a5dcdbb9820ac719a7e3c77aa4c737",
"lib64/libEGL.so": "3897b833e0bf0d56c5ec3895a28682d8273d7c4df1ced68ff06e62979827a94a",
"lib64/libETC1.so": "a48f6370d303952bca9c3fd2c0b4c97a31a0a5930c9bfb0d974cff513ab6638e",
"lib64/libGLES_trace.so": "9875cf00f54c17c26f7a1270f33e1ec7edddd0890cb700e37c9537692d99fd56",
"lib64/libGLESv1_CM.so": "12cce12f3177ddadaf30b904f9cf426d3e9c71aad55d4c25c04e6f3fef90b0c0",
"lib64/libGLESv2.so": "ee1d8a85e80f4805137888793f13906cc20dd2ee06dfba909fae0ecbf69ef49e",
"lib64/libLLVM.so": "3cb17292f33af0b6ad2de8c44e6a9fcd25861f933d03a554b9671803bdd1232e",
"lib64/libOpenSLES.so": "9bd7f62f3b5f047c6f60c7a97fb4be64b1caadc8ea7ce468c3a8e20be5cbb89f",
"lib64/libRS.so": "959312aa5bc0ea5a898999c75b924805d5ea3960999633d2f5be1528e0d31c2a",
"lib64/libRScpp.so": "83d0b08309a64ec9bf60652ef96b7f2579635039736e463247528352712de97c",
"lib64/libandroid.so": "8e9ee27bc19bd3a3b9e1bc24301bf385a22ff18c9f97fff19679ce885f18742e",
"lib64/libandroid_runtime.so": "6b7f3fe6db03e2f6e0118504f1c40a89fe28000ecd04d6d2a20961f102306d8d",
"lib64/libandroidfw.so": "546535df9106b347525b749b68cbb1372939795215f32005b160563205d31ba2",
"lib64/libaudioutils.so": "ad6657529259260ce78fb57a85f52f8f237e02f78c8d75d16588c8bfe807e39a",
"lib64/libbacktrace.so": "97046a07be82e646f2da90dae7ed0cf9b69b076c3100f678622db93d72e4517b",
"lib64/libbcc.so": "f6a519e0452b736733ae673cfa91dec3d526f27d03807531444177f6b0d6237c",
"lib64/libbcinfo.so": "0f69323449c8053695b63c818aa0bdcb32dab1f1aa54f3df760211076e524c04",
"lib64/libbinder.so": "b6c0890b23ab130961196e5c09d0e4afc5901a61f1f06f202aae6b63e55fac30",
"lib64/libc++.so": "c854f749424bc2019d36bef9da63a185569c6a70613b3db8830d62b99e4c4e87",
"lib64/libc.so": "421be2eaae5cd2c730048b4b8ea176d4287e21b126ead4075a52a9b9cc41e51e",
"lib64/libcamera_client.so": "4b010eed0129633663298cc569790cdc930b389950ff6b4b567f67927237683a",
"lib64/libcamera_metadata.so": "1681a88f4d5e3a0a997f03c052b5a1ea9738181b8149d5a6f16ba20031895ebf",
"lib64/libcommon_time_client.so": "54e47ca25a77dd067bc1b331a30281a4217dd7e812f46bb889be168bf157e549",
"lib64/libcrypto.so": "8cff426b8b966d465dda91984a87a1272cb23b8b405c1592d6e6e9a57879f7c5",
"lib64/libcutils.so": "e50b38ead1b65a9910ee8274a207af1abda6db7880cd7722d2a8eb3507fd4b27",
"lib64/libdrmframework.so": "0914786cbacc464409a3a25eae291568f98917d42b64f3f14a181769f7db6749",
"lib64/libeffects.so": "3ee4c19e86a8a98426db97f210cccd98041a0a77bc1308f3faba3ffc2d6d2291",
"lib64/libexpat.so": "7db72b51eee87b8162df047b5a4eaf086bd076bd8e37b88f5c84c567f75f721b",
"lib64/libft2.so": "8ac64e7b7fe86be6fbb717f1981c96cf5974f092e364397c186f98017ce84195",
"lib64/libgabi++.so": "332cdd74588f040422b21b37ad16264cabdb50c64f8c7a18b6184498a89b4c66",
"lib64/libgccdemangle.so": "6724c3212b762c8c5dbcb0f76d485204f49542923540d97b4a27ee8a6b18a851",
"lib64/libgui.so": "462d0013640d9074639ddb222c4ca6a4e798a9f9af0ed10dc734501fc8c90a28",
"lib64/libhardware.so": "fdfe37ace293f0a766f13a632ff4b21223abb0897ea807c875e45846b12a7b30",
"lib64/libhardware_legacy.so": "8842fa4376ce6388f7538a1a347f94cfa25f06ddc2c012265831a077d5a67336",
"lib64/libharfbuzz_ng.so": "5435e3617594fd1e738d2b920cfdc4a1d22ef85363186a8ec8e2f70c3cb27679",
"lib64/libhwui.so": "6b057a0892f6c333f15848ab124ba346d745bb8ba82314acc6a90ba3243270e2",
"lib64/libicui18n.so": "ae6c3d0989db44b5b6c75f858c9d7e3021248a714b5569507f4f7b94ab4b1440",
"lib64/libicuuc.so": "a59d30bfd8104bf23ffdae1f07b9f7e7316f65b5b3a3d5b81c45694d84b84a0c",
"lib64/libimg_utils.so": "94ea3388b058103be175086fe664f8db6a3630d1e825b70fd720ef549e59f9f6",
"lib64/libinput.so": "0c5a1f046291d390806927d9c9dcc44bddaa51e4ffb41671c83d44172855dbd4",
"lib64/libinputflinger.so": "eccfdf5e7d4f7d853dec1fcec6f20e9088c3eb362163682aadf4d0e39f3c14da",
"lib64/libjpeg.so": "e7a24eff0714521e37527dece9214cb1f19d46e2439411f2f8d49eb575d1c5fb",
"lib64/liblog.so": "f312d563748c1e15cdc3ec3469b16331f7fdaa6ad9b922c7ccbbf1ad38840d02",
"lib64/libm.so": "0aa4a18dcc9ae8091de4e6636e143ff62e04c800bffafe2f1315427a33a62e84",
"lib64/libmedia.so": "ec9ddd6841221d362214fcec50450e7c0a8ae1d3f4af3788b3da381e2acb817c",
"lib64/libmemtrack.so": "59ed8b60461b327ae1497178800788daa7253d1e736bae6005d22bdd31b31fc4",
"lib64/libminikin.so": "6b89b31f2d803fb944610e33b3a3b018fb2c04ea3f25bd7a35fa9a4355308579",
"lib64/libnativebridge.so": "2ef850ef9e41e7faa4677a528022b8350e2878b970412a1ad870c5a814856aa4",
"lib64/libnativehelper.so": "1f7a91bb32248c0cba1af91a315543d25cb8d7d48365aeaa53884452e07103bf",
"lib64/libnbaio.so": "020203924ddc35e0b1ca11b5a4d7e97b2ebe072ea094dfcb1704350ef94e7a69",
"lib64/libnetd_client.so": "c3c8242fcfc83abcafaafb5f25016884ff98ba131711bb5b04c558b831a26d40",
"lib64/libnetutils.so": "1f59f6798eb13528b71435d5ba0825847acff5edb3cebdfbb09337874a119c57",
"lib64/libopus.so": "76b2e2dd4270200f846feac778397eecfa94e1dbf0b0c46a18fcd43b4e80b1cf",
"lib64/libpdfium.so": "e2e03e9d4c617659705bac564664feb7fd881a725e6b767dfed4d101d9f2f1f1",
"lib64/libpng.so": "f1c5bfc2d5d4270581e5e5b6e9a911742658136efb5f977af5d5930557a8bf62",
"lib64/libpowermanager.so": "036fb1c24391a75b7017fe95c83dbd8f0623fa1e504903ccd2ca685ca8345eb0",
"lib64/libprocessgroup.so": "6158400be15bc9d3ef89b1d0b0e79bb280d62fd4cf63307493b55762e5a9df00",
"lib64/libselinux.so": "2f0569cb34e7b11302142b7cb8a34efefc8bb8472b6b1a6c7d39b7c23f20af09",
"lib64/libskia.so": "5db2ed76d49a752b0d3538f1aa865a5eb71a0fd0e02ac4aaa54bc4d80cb956c5",
"lib64/libsonivox.so": "f17ded03cdd3569cda816082e7b156861414b21dbe87f92545b8b3b22724fcf8",
"lib64/libsoundtrigger.so": "225358c3a5f3c9b84e239c11505bf3317960aacf931f5baf2186645c175d1756",
"lib64/libspeexresampler.so": "cccfaab64e58ce9e8d57539569c052c3acd49f58fced0c0ce3fc88a2b99b2106",
"lib64/libsqlite.so": "d0b7e563c0b874a11ef0d1c48bd86d0dbd3216510dd27ab8f6db317eb4199963",
"lib64/libssl.so": "8051495241833b0c86ceeca51bf0d360ff58c220d75d630ecf7dedde13026a63",
"lib64/libstagefright.so": "0c7de7fffa5cb1d0d6998b5fc11ccf90e49e17b6949e0b7b8dd4ae42bfe97332",
"lib64/libstagefright_avc_common.so": "f5354c23f43746c5a8d2050d1c2163a0755abd94c2bc059aa25fa257af3648b5",
"lib64/libstagefright_enc_common.so": "74c1442a588683dd313ac96a024bf4ac9a4b2ec7f05ee56e2a89db542d5aeb64",
"lib64/libstagefright_foundation.so": "39b828c6b05f5990f151d92ded661334ffe978887df936b5f24ca8bd0afa471f",
"lib64/libstagefright_http_support.so": "120e2907561a0b72ae4a502352845209516950697b0c912f251d9da1eadfe69e",
"lib64/libstagefright_omx.so": "0d9fed3e95ff112d7833994223e6197f7bebc17efec3befcb71e7e30ca5eb410",
"lib64/libstagefright_yuv.so": "91b6a7bdd92e20b1a61f3722cde6730dc4530f2d37d0bf26c6e9dfb0ede823be",
"lib64/libstdc++.so": "4d65dd2fe51ac841cb734ce198c6709d5dd2e76393964ac7a6ebc99306ff38d3",
"lib64/libstlport.so": "a8a1b79a77aff5918477c2b52f14ebc3715efdf73c05c3a79396e03384f27bec",
"lib64/libsync.so": "fdefe4820145826961a830f865c686b09eaad25a14640b92f04ef0a662d8b31b",
"lib64/libui.so": "a7674dd0ccad7b55f60221961bda68862a527f3921951b65e89694a2f454abfc",
"lib64/libunwind-ptrace.so": "481de65399c61d0671b43abc37086ec9550044f08800412cba9b52f5dfec8d6c",
"lib64/libunwind.so": "93579f0545253735391b1dc27a69e44ca8b8237967ef66646afa028cfb093816",
"lib64/libusbhost.so": "fc7f99a6b03889b31d70008e31a939941e2668281b91995131a7ad4f53cbf3fb",
"lib64/libutils.so": "0697bc44481570774af63367e7082c01297997bec0ca86f488948e25a986e2b7",
"lib64/libvorbisidec.so": "83eccc34fcb87cccd05422bda279d3af87e70e803d2dacb2f55fc2cfd2a5de5a",
"lib64/libwilhelm.so": "ad6cbba01c6fb9cdf6d33082441ba1a04f6e4c030df6026403f59a1c93e8e6e2",
"lib64/libz.so": "e953e12985e0d756445e49ace84bd7c0e25c48616d3a572b0037a4c920b38e22",
"usr/share/zoneinfo/tzdata": "586f372496315b1563e62a1a28d18adf65dea5a7b7453d7e0a91df9dfc2c8482"
},
"arm64-v8a": {
"bin/linker64": "869ca3c5781c1c32f35d9067ae627e075181babf4996f7c3a20ff32610efc094",
"lib64/libEGL.so": "0cd67d47a531e1be95f1ebe1d55043e374d3f8ce498c752dae656a0bea8b8ae4",
"lib64/libETC1.so": "9477838b36672691ad3071ac6dbac20dbca43d608663db9ac9b9ca830eecb5fd",
"lib64/libGLES_trace.so": "7beec44ae8bef4e61e2c47d2f04e22dc48821b6102e0bfa8b8dcb61621af50b9",
"lib64/libGLESv1_CM.so": "a64e796eb683007c157c74cf250d2604a35b7ec20784e7df352e7756b65f2ed9",
"lib64/libGLESv2.so": "73d89aa60cb3e916ed6855942efa5fe943141814dbad857854ad575b244d812d",
"lib64/libLLVM.so": "a655d05bd577b3380033fa84e66e5677478c5f24f9a213c89661f0ed1009fd8a",
"lib64/libOpenSLES.so": "e6f9e61eb33dc78b04f43150124573d6ecdd911c4381585a468066de80c2ad0c",
"lib64/libRS.so": "f5bbd1864c1d33f4a75010944c98f880916734513a662acba499edf396dc85ec",
"lib64/libRScpp.so": "4a293203213421e0f9674849c4b32165795ca9a496bc2c1548722a76ef5e212c",
"lib64/libandroid.so": "2e24d2fcd2800ee0e041bd0acb590c70b5c4c86aeb41a60db39481dbac8a878d",
"lib64/libandroid_runtime.so": "c2749580dd52b1bf5b67d33f68a828b4c41e74bab9e211900ab38f0f57913fa5",
"lib64/libandroidfw.so": "5ae6a507ddc0126cb8b3a0d75e3b3d2ef56ba69166f1cd89821b4c0d04fbf208",
"lib64/libaudioutils.so": "28d73d0859d6a9db34f0359ab83fb6bee352d1c5fabb46910a9d4c07a26ad887",
"lib64/libbacktrace.so": "137e0029678383e7efe715ee6e75dc4833f88c224e8ceef4b1297fdeb50edefe",
"lib64/libbcc.so": "babd72ed6d5f8c7e221fc0f30e13529357683ad8fbaed781d77dd7c9955e326e",
"lib64/libbcinfo.so": "ea0b40b279107af86a159536deb8819544f33dd478829d6cc582d01640424e8d",
"lib64/libbinder.so": "5847ee51c3bc96af1ed08b8a2b23a91853185e4e91121adccbbed2c38c2540d2",
"lib64/libc++.so": "e255ce426593c963283c3702b444c90b60c5c99b82ac95b78335a72bd6aaea9d",
"lib64/libc.so": "659d21c18e2e3ea83621555227d7b931bf9db7c1a413caf10ee1fd4408ec7dee",
"lib64/libcamera_client.so": "4c088e43c35fb210f21fd4f899c69db11d2ac1e4d409567c09ea65e789215593",
"lib64/libcamera_metadata.so": "e49d55a4c767a0fcdf596d66e694007352e6369e41e7bee0e30df724f448ba1b",
"lib64/libcommon_time_client.so": "654e69d7b891b6c6a2b5e370b0b5e47e09ed67a705626e2196b8c6ef3195a28c",
"lib64/libcrypto.so": "e1bc23079146cdc600cbfeb82a4a084f3a6908c76b48f41fd21211f63c8da8af",
"lib64/libcutils.so": "b5a40fa431f1841cda763ec4b10cb89af9df823fbd65836e0c79aee0cad7186e",
"lib64/libdrmframework.so": "326b6d3197f76745ecb9e51817b7a4d031c9c84e28a37337f645286a8fe1df29",
"lib64/libeffects.so": "e821b2dfb55abffc0d67948105ce042a70dbde905eb85d1597b5ba82f330a472",
"lib64/libexpat.so": "0cad10b0f6770d5bd68075e486306ab5389c773b623d5d163abba54ebd0a3ec2",
"lib64/libft2.so": "4420226d33f197f91e8e9eb5c1487f3060f334176759924d7660b5837d67eba0",
"lib64/libgabi++.so": "dc11e7d6c0111598366dfdbd76251ce424336a8eec88299a31bf0988fb503145",
"lib64/libgccdemangle.so": "7b96280ca7f72fea67abb83d7b45670899f5c2d165bff22082763843a6f8c907",
"lib64/libgui.so": "23e282ee6aa752bd93f12d52fccd70a30660e98f35bdf14aee3b45cf4a1f2346",
"lib64/libhardware.so": "e0adae13e59c1ef61e91841284e9a4d1edd0b8b377dafc9cb1c62e9ad2d84fdd",
"lib64/libhardware_legacy.so": "8c99a78242f3e70e58717f39da6a775f21dedab124012786bbb7ab8ec0171b1f",
"lib64/libharfbuzz_ng.so": "2ee7516e4b7000377137714db2734179a0e71f4afc74710df685cd4758416786",
"lib64/libhwui.so": "1c0cbcc2292b30b4b49c641596cf91feae53f87e3560fc3c23a52b58b702fda0",
"lib64/libicui18n.so": "b543a4a0ef8356b1095f91b3352556d0d91562b89b556d0bfce3e44cf3b2a0d9",
"lib64/libicuuc.so": "4f20375fdeef3bfcde402ea7f2f93a88014222842d41679d61d411b0a455f281",
"lib64/libimg_utils.so": "79769265c9bda7286dac7c34116a748e43bf8c0680f14712a24a33bcffd28073",
"lib64/libinput.so": "01518687ee722baa1c79d392037409907a8f9aa1babb54aec4b36ae29ace4fde",
"lib64/libinputflinger.so": "76f3b0fdab740ae407cc037a3dac3e259a8b127364fbc33af7a78906f1bf46d0",
"lib64/libjpeg.so": "3470636fd1c7c303c59157f0cdc302308815e9b332e9ee335df7861e494b930c",
"lib64/liblog.so": "2537da397e5417b925267cb62c313ce1f4b8ab62fd6989b0e8bdac7892051bc8",
"lib64/libm.so": "e9dbef4b2902ca8da0c36c0b52a7fc8b4ab045308cbd1d03174286a8d142eccf",
"lib64/libmedia.so": "2cdc4b27f625bb623f12fdd84ac9db276d2f6db7fb95026aabc79512370e22a7",
"lib64/libmemtrack.so": "0c1241961d924c6ccd5af43497c8f684d8dcdb52f82847b4c1eaa5bfe01d6798",
"lib64/libminikin.so": "a949d61f0f89c47c77a60e51b91d0355e907fa452d1056be108349c15517fa04",
"lib64/libnativebridge.so": "359a8f005840445971de26490410b0c41f08aabce4b7eac39f341fd6ca289996",
"lib64/libnativehelper.so": "a0f70bab4ddca7e9f1d350245f9912282b87eeea91df283066c348412ed84650",
"lib64/libnbaio.so": "9753833073cea1e225ac2cb2f547241d250900efa08434468052cb76ddbe9fa5",
"lib64/libnetd_client.so": "e68eadae0dd59e9cdc9cf8077ae458816788afacea68919ce6e51e830a98cd29",
"lib64/libnetutils.so": "5ef491e362dde12c779e9342e863263249b226c7b385733f5f9faf19f8cf374a",
"lib64/libopus.so": "d05d2ee653d00ffe53338d841b2b9fed2e979e40d5b3bbc2b438d0603ab3c096",
"lib64/libpcre.so": "46229f7fd0dff4814aaecc8df2030d36ce1ac517880433b25054fb2285fd26bf",
"lib64/libpdfium.so": "4cbb3799ebf90884fd8083f662c96d6f21483c9a3c8bb8ab49005012fbe99b64",
"lib64/libpng.so": "309934f1ec93c028db323e4567d9fb80fdb9fa3fd56fe0fd0109ffabd289d6c7",
"lib64/libpowermanager.so": "1c03f0a4f693a762c4e18421d2a1f8940daeec47cdcd2d86a10eb6bae6301cd7",
"lib64/libprocessgroup.so": "7e30271e2edb356862bc119a968eee0f211db0ff28af491d9c31702b55beae7e",
"lib64/libselinux.so": "b0037d63c28b455e605958c30e42d9378ae6824e79f29e3b182ae1004082f31d",
"lib64/libskia.so": "1458e674cebd11b50c93fce4abdd258cff8fd3033e9c7c546b6088e0f842e3d0",
"lib64/libsonivox.so": "c7a0ff605a1f50ada52891042639aa8cdcb355df7391a08347d33556ae1f1639",
"lib64/libsoundtrigger.so": "b5f04e600b54516e411816e6deaca681328de861c515dc6b4808be9f0a057a8a",
"lib64/libspeexresampler.so": "dceb2fd2c9e87e7f393846f92574161312a31c237132eacbc36bca930cc839ba",
"lib64/libsqlite.so": "1c770ecfd71bfa8a994de857449363152f071b7eb24a8d9f8f7686c5b0a1b40d",
"lib64/libssl.so": "1879b632f756c436fabb8ca4811ca9b8af49e4068f68ad7b261c9bbe8ded3332",
"lib64/libstagefright.so": "00b94b0cd40f2b89a52a6a8b53e4abf23767fcfb0f7fd4cfc4880354571e4782",
"lib64/libstagefright_avc_common.so": "ec3ec349b4c19930b3c4cec4e6723495c9cb92c4c28033e01e5019cfca4b15cb",
"lib64/libstagefright_enc_common.so": "7424950dc6959db9ea73c082245174245dada99d6d80d1d47e6feeed3c8874cd",
"lib64/libstagefright_foundation.so": "129d3d7bdc1341290e929cd6f2952a62f86c9785a1e2b2310d52073eb74a3813",
"lib64/libstagefright_http_support.so": "f0ed814d40b2fc46de167cd081ce195338bb669011c49d79ff6bd2e4fd7113bd",
"lib64/libstagefright_omx.so": "6e5f4704f05c7f2b6e6ce70ebb7a66e42466b2597b400de081416b53a0ae0662",
"lib64/libstagefright_yuv.so": "2d03e5c45eaf206cf8229963ee1a69375fd87957d6378fe10138fc4035867a47",
"lib64/libstdc++.so": "a1a7bbaa275dea74aa7aef20ee53f77c2b607bd035cb6bf28d2daaf6b60e7e1e",
"lib64/libstlport.so": "cd3b5629c574c3340d5d277f22d3903aba0db5ca0704b044c0587b2f16d47825",
"lib64/libsync.so": "5cc064479293de7a7e78cce715900b157ee98f0c3748377c0f5dac31f7fb3fe6",
"lib64/libui.so": "d9bb04eee8399d89f38208e7f0d733c358b630c0c571a4addb39c953601784d4",
"lib64/libunwind-ptrace.so": "d305606d1096a8c7325f7a6c87279306ed33f8cb33e696d183ce21badd56c71c",
"lib64/libunwind.so": "c5fab447a096b570c267e714b864ae29d3bcd4eb0bb0f16a22404b159fef0b3c",
"lib64/libusbhost.so": "5be0c5b127c2f9feccbeed65fd1a9fe91542bebae677a611d882243b875841ad",
"lib64/libutils.so": "42f589f49912f34b25c7d579bf8a66764a1267b9e078619aa7d26faab014e66b",
"lib64/libvorbisidec.so": "bfba62d8598022b778805482e5c1b4c6297e80bf7a58634ccfa3e3561927c3bb",
"lib64/libwilhelm.so": "8418709da5ec21fae5dbfd73da7dce813d5b2635b7438a7346251392f27e769c",
"lib64/libz.so": "26f0f06e40b212bb55f17eb4df812438b3e993e6485f6267bdd4225d2dfda8e1",
"usr/share/zoneinfo/tzdata": "586f372496315b1563e62a1a28d18adf65dea5a7b7453d7e0a91df9dfc2c8482"
}
},
"libs": {
"x86_64": {
"libBlocksRuntime.so": "9814b756ae173a546eb12bf1019746f14b17a6546806445ff67f75b0aba8856a",
"libCoreADI.so": "b5b971cea0bbe828bdcfaf219fd91fc1e37cf8daee84001ef917d0b1c2b985ed",
"libCoreFoundation.so": "c5727cecfa18833a6828caf2072aa6a4edf39b73752597e4de35854ea9a71ce0",
"libCoreFP.so": "81ba0dbd857a9e76402434ee13756051a77de91ec18d637ea4350687e0da39b3",
"libCoreLSKD.so": "bf813206d382bd8b27384bd546a5b9aa730df32c8543d709756b081b619a8b3a",
"libandroidappmusic.so": "2fcdf31a0b3f0f8841ef63cf46efffcf9672a2cea0eef5b066c537d43e293083",
"libc++_shared.so": "82ffc20ba230bbdd031b27853d6cfaaa27079aabe6dd478cafd9b6fa866a35c2",
"libcurl.so": "922d6c70ab8a86a761046c70902d3d6c56b136fb2a3b21dbc5a4e3fe5f6130ae",
"libdaapkit.so": "e70575f2f3279141d03c91dff7400151e321fb30051f170e25bdbeae862a7bbd",
"libdispatch.so": "7d360c1e81c6982b70b32bdc4e343fd586d727722fd6562df211aaf1955b0e2c",
"libicudata_sv_apple.so": "afe9021fd9fee67f1f2909f3bb603f0362e93be5ef761d4dee9812249b2aa0f0",
"libicui18n_sv_apple.so": "7ae2e3c0e78e54e94a2ce2d7f3efbb4375e2505769f4ebeb43f05ac7d344ff76",
"libicutest_sv_apple.so": "3bc7b14ca5a9eefcbfdbaa943a0ff81a9406832ecd6ac5d18fb818569a733750",
"libicuuc_sv_apple.so": "cac055936358d0ea35021240a7a36a2110994f3c8883a47bb28fd2c634e6f069",
"libmedialibrarycore.so": "3c42bf5f52d90931d65e463f44720a28c60f575416a6420ad71682e1be91e6b7",
"libmediaplatform.so": "cb4ab591e0ea6c74cbcbe23b616566425172efc77f83933b6aa7a3ffcc88de6d",
"libstoreservicescore.so": "0ba169fec7b5a0f70113ab01ba6c60b893c36f9aa080797b13d3830dd3784010",
"libxml2.so": "41422a7384114f4a98fa7a09e019c9f2aec07c429d6d2ae4b63022b111325109"
},
"arm64-v8a": {
"libBlocksRuntime.so": "f7ebfdcf5115c6bf2073c79c498bcf6652f2501a697774a053cab4f6cf3e638a",
"libCoreADI.so": "db8779a9e1b026a087cd9a35c7d69f8af51418a29249ef325e6391ec560f6f47",
"libCoreFoundation.so": "9ccf6cd1507a3682084ebd3f749932f588430c092ba8fa9fbfbd44fea4abc7d2",
"libCoreFP.so": "27200468fbe5e4404edf466e3dd9d5f2fa01fe5835dc60aa76619d55721a447e",
"libCoreLSKD.so": "36e49510c1c3b0c133b263cfc6996f79cda112320b51d5f9dbaa1175cab6d8d9",
"libandroidappmusic.so": "3e0af0b8aa95bfc79a27d7dba54fc3ee77f6878347e63f4ea549713b0fdeb26e",
"libc++_shared.so": "a94a44bc0abd5729ef55a43b3afcacbc064b7c40041c9df4c33950a60f36652b",
"libcurl.so": "42226f2c2e9151d59e9db9020fb115dcbfe4396762946437c62b12a3c3000fa7",
"libdaapkit.so": "e38a16d14979f3c6ab884fbb6510c526667d5b2e90e86fc96e9d7dbcc862fcae",
"libdispatch.so": "d82bcc112e828fe23111e9de5359a5f242aa3791c891fefb3c13a08600c2dc86",
"libicudata_sv_apple.so": "62f30712e07a85fcb2977a613cbb2fd95f6224029ca2438a749cc7390fb89e4e",
"libicui18n_sv_apple.so": "d249ef1f5e5994a6800966daa2ec9a00316cadb4ad8e9954afeb66ae8aece0a4",
"libicutest_sv_apple.so": "84c72c42616a4e7b403c83ec339a3035cbd8d4054ed7a117fa93af264a34a8d0",
"libicuuc_sv_apple.so": "391fac15907aa57008ad4f61b5053b6f55f5d17946adb3bc949f7d58dc2fb885",
"libmedialibrarycore.so": "8b04fc00c9936e8be774659896bff2d9e4434eb1b252df458c66e26dc8b88be3",
"libmediaplatform.so": "6f5e1121441b21ec42b854576858fb27617e74441d420c46d824666ec152dc34",
"libstoreservicescore.so": "a6b3440c5e2bc747cbc50cf0d337c7ff42dcd5ed5ddb8080889fa63c1263df16",
"libxml2.so": "a7349a425f3cd59080d661b0507cee1c0057c7ce5db791c73078dac26bbc6dd5"
}
}
}
+21
View File
@@ -0,0 +1,21 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or distribute
this software, either in source code form or as a compiled binary, for any
purpose, commercial or non-commercial, and by any means.
In jurisdictions that recognize copyright laws, the author or authors of
this software dedicate any and all copyright interest in the software to
the public domain. We make this dedication for the benefit of the public
at large and to the detriment of our heirs and successors. We intend this
dedication to be an overt act of relinquishment in perpetuity of all
present and future rights to this software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>
+297
View File
@@ -0,0 +1,297 @@
# wrapper-v2
A clean rewrite of the Apple Music FairPlay decryption wrapper, based on
[`WorldObservationLog/wrapper`](https://github.com/WorldObservationLog/wrapper).
## Development note
This project has been developed with heavy AI assistance. The code should be
treated as research-grade and reviewed carefully, especially around native ABI
calls, FairPlay state handling, and experimental endpoints. AI-generated changes
are not assumed to be correct just because they compile.
## What it is
A small daemon that exposes a local HTTP API for FairPlay key fetching and
sample decryption, and gives downstream tooling (e.g.
[`gamdl`](https://github.com/glomatico/gamdl)) a uniform interface that does
not depend on platform or language.
At runtime the binary starts in **supervisor** mode by default. The supervisor
owns the public HTTP port and starts a private `WRAPPER_MODE=worker` subprocess on
`127.0.0.1:${WRAPPER_WORKER_PORT:-18080}`. Only the worker loads Apple Music's
Android native libraries inside the Linux chroot. If FairPlay hangs or returns
a CKC/KD-style decrypt error, the supervisor can kill the worker, start a fresh
one, and retry the decrypt request without dropping the public HTTP server. If
the worker cannot be started three consecutive times, the supervisor exits so
the container supervisor can recreate the whole runtime.
The daemon ships _no_ Apple code. Apple Music native libraries must be supplied
by the person building the image and staged into `rootfs/system/lib64/`; the
expected `.so` SHA-256 digests are pinned in `LIBS_VERSION.json`.
## HTTP API
Most endpoints accept and return `application/json`. `POST /decrypt`
uses `application/octet-stream` for successful request and response bodies;
errors still return JSON.
| Method | Path | Description |
| -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET` | `/health` | Liveness probe. `{status, version, runtime}` — `runtime.playback_ready` is true when FairPlay decrypt is available. |
| `GET` | `/me` | `{version, runtime, auth}` — same runtime flags as `/health`. |
| `POST` | `/login` | Body: `{"username": "...", "password": "..."}` or `{"apple_id": "...", "password": "..."}` (synonyms). Drives Apple's `AuthenticateFlow`. Returns `200` + token snapshot, `202` if **2FA** is required (then `POST /login/2fa`), or `401` on failure. |
| `POST` | `/login/2fa` | Body: `{"code": "123456"}`. Continues a login waiting for HSA2. |
| `GET` | `/playback` | Query string `?adam_id=<numeric store id>`. Returns `200` with a JSON object `{"songList":[...]}` containing the **whole MZ playback dispatch** Apple's `subDownload` URL bag returns (every flavor, key URI, asset URL, metadata field). CFData fields are base64; CFDate fields are ISO 8601. Needs an **authenticated** session; otherwise `401` / `503`. Apple errors → `502`. |
| `POST` | `/decrypt` | Binary FairPlay sample decrypt batch. Request frame contains `adam_id`, SKD `uri`, and one or more encrypted samples. Response frame contains plaintext samples. Needs **authenticated** session and `playback_ready`; otherwise `401` / `503`. On FairPlay errors or worker timeouts, the supervisor restarts the worker and retries once before returning the final result. |
| `DELETE` | `/login` | Aborts an in-flight login or clears cached tokens from memory. Apple's on-disk `mpl_db` cache is unchanged. |
### `POST /decrypt` Binary Format
All integer fields are unsigned 32-bit big-endian.
Request body:
```text
adam_id_len
uri_len
sample_count
sample_len[0]
...
sample_len[sample_count - 1]
adam_id bytes
uri bytes
sample[0] bytes
...
sample[sample_count - 1] bytes
```
Response body:
```text
sample_count
sample_len[0]
...
sample_len[sample_count - 1]
sample[0] bytes
...
sample[sample_count - 1] bytes
```
The endpoint accepts and returns `application/octet-stream` on success.
Validation and Apple/native errors use the normal JSON error envelope.
Sign-in matches the legacy wrapper model: you send **email (Apple ID) and password**
to the daemon; it fills credentials through the native presentation interface.
With a persistent `WRAPPER_BASE_DIR` volume, Apple keeps `mpl_db/kvs.sqlitedb` on
disk. On each process start the daemon tries **session restore** (default
`WRAPPER_RESTORE_SESSION=1`): if that session is still valid, `GET /me` can show
**authenticated** and fresh tokens **without** another `POST /login`. Use
`POST /login` when the volume is new, restore fails, or you need to re-auth.
Optional `WRAPPER_APPLE_ID` only sets the `apple_id` label in `/me` after restore.
## Layout
```
.
├── CMakeLists.txt top-level build (host launcher + NDK sub-build)
├── Dockerfile multi-stage build
├── compose.yaml docker compose entrypoint
├── LIBS_VERSION.json per-.so SHA-256 digests
├── src/
│ ├── daemon/ C++ daemon (cross-compiled with the NDK)
│ │ ├── CMakeLists.txt
│ │ ├── main.cpp process entry: env parsing, lifecycle
│ │ ├── server.{hpp,cpp} HTTP route mounting (cpp-httplib)
│ │ └── apple/
│ │ ├── abi.hpp Apple-lib mangled symbol declarations
│ │ ├── auth.{hpp,cpp} Apple ID login + 2FA + token cache
│ │ ├── loader.{hpp,cpp} dlopen / dlsym
│ │ ├── runtime.{hpp,cpp} FootHillConfig + RequestContext + credential UI
│ │ └── tokens.{hpp,cpp} dev token + music user token harvest
│ └── launcher/
│ └── wrapper.c host-Linux chroot launcher
├── rootfs/ chroot tree assembled at build time
│ └── system/
│ ├── bin/ <- main, linker64 (staged)
│ └── lib64/ <- Apple's .so + Android system .so (staged)
├── tools/
│ ├── extract-libs.sh optional local helper to extract and verify Apple .so files
│ └── stage-system.sh copy committed Android binaries into rootfs/
└── vendor/
└── android-system/ linker64 + bionic + AOSP libs, SHA-pinned
├── x86_64/
│ ├── bin/linker64
│ └── lib64/*.so
└── arm64-v8a/
├── bin/linker64
└── lib64/*.so
```
## Building
### One-time setup
You need a working Docker installation. Apart from that, the entire build
runs inside the image. There is no host toolchain prerequisite for the
default workflow.
For the build to succeed, `rootfs/system/lib64/` must already contain the
required Apple Music native libraries for your `TARGET_ARCH`. The recommended
source version is Apple Music for Android **3.6.0-beta**. This repository does
not provide the download for those files.
### Local build
#### 1. Extract Apple Music native libraries
Provide a local Apple Music `.apk` or `.apkm` for the target architecture. The
default output is `rootfs/system/lib64/`, and every extracted `.so` must match
the hashes in `LIBS_VERSION.json`.
```bash
bash tools/extract-libs.sh --bundle path/to/local/apple-music.apk --arch x86_64
```
`.apkm` bundles are also accepted:
```bash
bash tools/extract-libs.sh --bundle path/to/local/apple-music.apkm --arch x86_64
```
#### 2. Stage Android system binaries
This copies the committed Android linker and system libraries into `rootfs/`,
verifying their SHA-256 hashes against `LIBS_VERSION.json`.
```bash
bash tools/stage-system.sh --arch x86_64
```
#### 3. Build and run
```bash
docker compose up --build
```
#### 4. Smoke test
```bash
curl http://127.0.0.1/health
curl http://127.0.0.1/me
```
#### 5. Sign in
Use your real Apple ID. If the first request returns `202`, continue with the
2FA request.
```bash
curl -X POST http://127.0.0.1/login \
-H 'content-type: application/json' \
-d '{"username":"you@example.com","password":"your-app-specific-password"}'
```
```bash
curl -X POST http://127.0.0.1/login/2fa \
-H 'content-type: application/json' \
-d '{"code":"123456"}'
```
Check the current session or clear the in-memory login state:
```bash
curl http://127.0.0.1/me
curl -X DELETE http://127.0.0.1/login
```
The daemon binds port 80 inside the container and the compose file maps it
to host port 80 by default. Override with `HTTP_PORT=8080 docker compose up`
on machines that already have something on `:80`.
### arm64-v8a image (Apple Silicon / AArch64 Linux)
Stage **arm64-v8a** Android system binaries and Apple Music native libraries,
then build a **linux/arm64** image so `wrapper`, the NDK daemon, and the staged
`linker64` / `.so` set share the same ABI.
The Docker **compile** stage is always **linux/amd64** (Google ships the Linux NDK as an
x86_64-host ZIP only). The image then cross-compiles `wrapper` for AArch64 when
`TARGET_ARCH=arm64-v8a`. Set **runtime** platform to arm64; `BUILD_PLATFORM` in Compose is
ignored but kept for compatibility.
Extract and stage the arm64 files:
```bash
bash tools/extract-libs.sh --bundle path/to/local/apple-music.apk --arch arm64-v8a
bash tools/stage-system.sh --arch arm64-v8a
```
Or use a local `.apkm` bundle:
```bash
bash tools/extract-libs.sh --bundle path/to/local/apple-music.apkm --arch arm64-v8a
bash tools/stage-system.sh --arch arm64-v8a
```
Build the arm64 image:
```bash
TARGET_ARCH=arm64-v8a RUNTIME_PLATFORM=linux/arm64 \
docker compose up --build
```
On an **x86_64** host, `docker compose` / `docker run` need **QEMU** (binfmt) to run a
`linux/arm64` container. On an **arm64** host, run the image **natively** (no emulation).
### Daemon configuration
The daemon reads `WRAPPER_*` environment variables (forwarded via
`compose.yaml`). See `.env.example` for the full list. The most useful are:
- `WRAPPER_HOST`, `WRAPPER_PORT` - public supervisor bind address inside the
chroot.
- `WRAPPER_MODE` - process role. Default `supervisor`; the supervisor sets
`worker` automatically for its private subprocess.
- `WRAPPER_WORKER_PORT` - private loopback port used by the supervisor to talk
to the Apple runtime worker. Default `18080`.
- `WRAPPER_BASE_DIR` - filesystem dir Apple's libs use for the FairPlay
key cache and `mpl_db`. The default matches upstream wrapper.
- `WRAPPER_RESTORE_SESSION` - set to `0` to skip startup token harvest from
an existing on-disk Apple session (default is restore on).
- `WRAPPER_APPLE_ID` - optional display label for `apple_id` in `GET /me` after
session restore only (not sent to Apple).
- `WRAPPER_DEVICE_INFO` - 9-tuple identifying the fake Apple Music
Android client. Same fingerprint upstream uses by default.
- `WRAPPER_APPLE_INIT=0` - skip Apple lib initialization at startup.
Lets you bring up the HTTP server alone for `/health` smoke tests
even on builds where you have not staged the Apple libraries yet.
- `WRAPPER_USERNAME` + `WRAPPER_PASSWORD` - if both are set and the runtime
initialized, the daemon runs password sign-in at startup when not already
authenticated (same semantics as `POST /login`; 2FA still needs
`POST /login/2fa`). Treat these as secrets.
### CI build
The `.github/workflows/build.yml` workflow runs on **push** to `main`,
on **pull_request** (same-repo only for the full job), and **workflow_dispatch**.
It uses the same host steps as above plus a Docker build and `/health` smoke
test, with one repository secret:
- `APK_URL` - private/local CI URL for a compatible Apple Music `.apk` or
`.apkm`. The artifact is downloaded inside CI only, extracted with
`tools/extract-libs.sh`, and is not committed.
**Matrix:** both `x86_64` and `arm64-v8a` jobs use `ubuntu-latest`. The arm64 image is
`linux/arm64` at runtime; QEMU is enabled before the smoke `docker run` so the job works
on amd64 GitHub runners. The compile stage stays **linux/amd64** for the official NDK ZIP.
Pull requests opened from forks skip the build job because they cannot read the
secret.
## License
[Unlicense](./LICENSE) - public domain dedication.
This project is not affiliated with Apple Inc. The Apple-authored libraries
it loads at runtime are not redistributed by this repository.
+28
View File
@@ -0,0 +1,28 @@
services:
wrapper:
build:
context: .
dockerfile: Dockerfile
args:
TARGET_ARCH: ${TARGET_ARCH:-x86_64}
BUILD_PLATFORM: ${BUILD_PLATFORM:-linux/amd64}
RUNTIME_PLATFORM: ${RUNTIME_PLATFORM:-linux/amd64}
image: wrapper-v2:latest
container_name: wrapper-v2
restart: unless-stopped
cap_add:
- SYS_ADMIN
- SYS_CHROOT
- SYS_PTRACE
security_opt:
- apparmor:unconfined
ports:
- "${HTTP_PORT:-80}:80"
environment:
WRAPPER_USERNAME: ${WRAPPER_USERNAME:-}
WRAPPER_PASSWORD: ${WRAPPER_PASSWORD:-}
WRAPPER_APPLE_INIT: ${WRAPPER_APPLE_INIT:-}
WRAPPER_RUNTIME_TRACE: ${WRAPPER_RUNTIME_TRACE:-}
volumes:
# Persist Apple state at the same path shape as the Android app sandbox.
- ./data:/app/rootfs/data/data/com.apple.android.music/files
@@ -0,0 +1,118 @@
cmake_minimum_required(VERSION 3.22)
project(daemon LANGUAGES CXX)
# This sub-build is invoked by the top-level CMakeLists.txt with the NDK
# toolchain pre-configured. It produces the chrooted daemon binary 'main'.
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
include(FetchContent)
# -----------------------------------------------------------------------------
# Third-party deps
# -----------------------------------------------------------------------------
# cpp-httplib: header-only HTTP/1.1 server. MIT license.
FetchContent_Declare(
httplib
GIT_REPOSITORY https://github.com/yhirose/cpp-httplib.git
GIT_TAG v0.18.5
GIT_SHALLOW TRUE
)
set(HTTPLIB_COMPILE OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(httplib)
# nlohmann::json: JSON parser/serializer. MIT license.
FetchContent_Declare(
nlohmann_json
GIT_REPOSITORY https://github.com/nlohmann/json.git
GIT_TAG v3.11.3
GIT_SHALLOW TRUE
)
set(JSON_BuildTests OFF CACHE INTERNAL "")
FetchContent_MakeAvailable(nlohmann_json)
# -----------------------------------------------------------------------------
# Daemon executable
# -----------------------------------------------------------------------------
add_executable(main
main.cpp
server.cpp
supervisor.cpp
apple/auth.cpp
apple/decrypt.cpp
apple/loader.cpp
apple/playback.cpp
apple/runtime.cpp
apple/tokens.cpp
)
target_include_directories(main PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
)
target_link_libraries(main PRIVATE
httplib::httplib
nlohmann_json::nlohmann_json
)
target_compile_options(main PRIVATE
-Wall -Wextra -Wpedantic -Werror=return-type
-fexceptions
)
# RTTI is intentionally on. nlohmann::json compiles fine with or
# without it but cpp-httplib's exception path uses typeid lookups,
# and we share libc++_shared.so with Apple's libs which were also
# built with RTTI. Disabling here would split the runtime.
# Our vendored `linker64` is from an older Android build that requires
# DT_HASH and does not understand GNU-hash-only executables. NDK r23b's
# default is `--hash-style=gnu`, which produces only DT_GNU_HASH and trips
# the linker with "empty/missing DT_HASH in <argv[0]>". Force `both` so the
# daemon ELF carries both hash sections - works with old and new linkers.
target_link_options(main PRIVATE
-Wl,--hash-style=both
)
# -----------------------------------------------------------------------------
# Apple native libraries
# -----------------------------------------------------------------------------
# Apple's libstoreservicescore / libmediaplatform / libandroidappmusic
# are NOT in the daemon's DT_NEEDED chain. The Loader (apple/loader.cpp)
# resolves them at runtime via dlopen/dlsym, which means the daemon can
# start in stub mode even when the Apple libs (and their AOSP closure)
# are absent.
#
# We DO link against libc++_shared.so here so that the daemon's own
# C++ runtime SONAME matches what Apple's libs expect. At runtime the
# dynamic linker resolves "libc++_shared.so" against /system/lib64/
# (set via -rpath below) so the same .so file backs both the daemon
# and Apple's libs once they get dlopen'd.
if(NOT DEFINED LIBS_DIR)
message(FATAL_ERROR
"LIBS_DIR is not set. The top-level CMakeLists.txt should pass "
"-DLIBS_DIR=<repo>/rootfs/system/lib64.")
endif()
if(NOT EXISTS "${LIBS_DIR}/libc++_shared.so")
message(FATAL_ERROR
"${LIBS_DIR}/libc++_shared.so is missing. Stage the required Apple "
"Music native libraries in rootfs/system/lib64/ before building.")
endif()
target_link_libraries(main PRIVATE
"${LIBS_DIR}/libc++_shared.so"
log
dl
)
target_link_options(main PRIVATE
-Wl,-rpath,/system/lib64
)
set_target_properties(main PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}"
)
@@ -0,0 +1,395 @@
// AArch64 AAPCS places the hidden C++ "struct return" pointer in x8 and the
// implicit `this` pointer in x0. Our function-pointer typedefs follow the x86_64
// SysV layout (sret in rdi, `this` in rsi, ...), so a plain call would pass the
// wrong registers. These thunks reorder into x8/x0/x1/... before blr.
//
// The callee address MUST live in a register other than x0–x8 while we set x8
// (sret) and argument registers. If "blr %[callee]" shared a register with the sret
// slot, Clang could place the target in x8, then `mov x8, %[sret]` would corrupt it
// and we would branch into the return buffer (SIGSEGV at a small address).
// Fix: load the target into x9 first, then set x8/x0–x7, then `blr x9`.
//
// On x86_64, call through the raw function pointer unchanged.
#pragma once
#include <cstdint>
#include "apple/abi.hpp"
namespace wrapper::apple::aarch64_sret {
#if defined(__aarch64__) || defined(__AARCH64EL__)
#define WRAPPER_A64_CLOB_CALL \
"memory", "cc", "x0", "x1", "x2", "x3", "x4", "x5", "x6", "x7", "x8", \
"x9", "x10", "x11", "x12", "x13", "x14", "x15", "x16", "x17", "lr"
inline void device_guid_instance(abi::shared_ptr* out, abi::fn_DeviceGUID_instance fn) {
void* callee = reinterpret_cast<void*>(fn);
asm volatile(
"mov x9, %[callee]\n\t"
"mov x8, %[out]\n\t"
"blr x9\n\t"
:
: [callee]"r"(callee), [out]"r"(out)
: WRAPPER_A64_CLOB_CALL);
}
inline void make_shared_request_context(abi::shared_ptr* out,
abi::std_string* arg,
abi::fn_make_shared_RequestContext fn) {
void* callee = reinterpret_cast<void*>(fn);
asm volatile(
"mov x9, %[callee]\n\t"
"mov x8, %[out]\n\t"
"mov x0, %[arg]\n\t"
"blr x9\n\t"
:
: [callee]"r"(callee), [out]"r"(out), [arg]"r"(arg)
: WRAPPER_A64_CLOB_CALL);
}
inline void make_shared_android_presentation_interface(
abi::shared_ptr* out,
abi::fn_make_shared_AndroidPresentationInterface fn) {
void* callee = reinterpret_cast<void*>(fn);
asm volatile(
"mov x9, %[callee]\n\t"
"mov x8, %[out]\n\t"
"blr x9\n\t"
:
: [callee]"r"(callee), [out]"r"(out)
: WRAPPER_A64_CLOB_CALL);
}
inline void make_shared_authenticate_flow(abi::shared_ptr* out,
abi::shared_ptr* req_ctx,
abi::fn_make_shared_AuthenticateFlow fn) {
void* callee = reinterpret_cast<void*>(fn);
asm volatile(
"mov x9, %[callee]\n\t"
"mov x8, %[out]\n\t"
"mov x0, %[req]\n\t"
"blr x9\n\t"
:
: [callee]"r"(callee), [out]"r"(out), [req]"r"(req_ctx)
: WRAPPER_A64_CLOB_CALL);
}
inline void device_guid_configure(void* cfg_ret,
void* this_,
abi::std_string* a1,
abi::std_string* a2,
const unsigned int* a3,
const std::uint8_t* a4,
abi::fn_DeviceGUID_configure fn) {
void* callee = reinterpret_cast<void*>(fn);
asm volatile(
"mov x9, %[callee]\n\t"
"mov x8, %[sret]\n\t"
"mov x0, %[ths]\n\t"
"mov x1, %[a1]\n\t"
"mov x2, %[a2]\n\t"
"mov x3, %[a3]\n\t"
"mov x4, %[a4]\n\t"
"blr x9\n\t"
:
: [callee]"r"(callee),
[sret]"r"(cfg_ret),
[ths]"r"(this_),
[a1]"r"(a1),
[a2]"r"(a2),
[a3]"r"(a3),
[a4]"r"(a4)
: WRAPPER_A64_CLOB_CALL);
}
inline void request_context_init(void* rci_ret,
void* this_,
abi::shared_ptr* cfg,
abi::fn_RequestContext_init fn) {
void* callee = reinterpret_cast<void*>(fn);
asm volatile(
"mov x9, %[callee]\n\t"
"mov x8, %[sret]\n\t"
"mov x0, %[ths]\n\t"
"mov x1, %[cfg]\n\t"
"blr x9\n\t"
:
: [callee]"r"(callee),
[sret]"r"(rci_ret),
[ths]"r"(this_),
[cfg]"r"(cfg)
: WRAPPER_A64_CLOB_CALL);
}
inline void device_guid_guid(void* hidden_pair, void* this_, abi::fn_DeviceGUID_guid fn) {
void* callee = reinterpret_cast<void*>(fn);
asm volatile(
"mov x9, %[callee]\n\t"
"mov x8, %[sret]\n\t"
"mov x0, %[ths]\n\t"
"blr x9\n\t"
:
: [callee]"r"(callee), [sret]"r"(hidden_pair), [ths]"r"(this_)
: WRAPPER_A64_CLOB_CALL);
}
inline void request_context_store_front_identifier(
abi::std_string* out,
void* this_,
abi::shared_ptr* url_bag,
abi::fn_RequestContext_storeFrontIdentifier fn) {
void* callee = reinterpret_cast<void*>(fn);
asm volatile(
"mov x9, %[callee]\n\t"
"mov x8, %[sret]\n\t"
"mov x0, %[ths]\n\t"
"mov x1, %[bag]\n\t"
"blr x9\n\t"
:
: [callee]"r"(callee),
[sret]"r"(out),
[ths]"r"(this_),
[bag]"r"(url_bag)
: WRAPPER_A64_CLOB_CALL);
}
inline void purchase_response_items(abi::std_vector* out,
void* this_,
abi::fn_PurchaseResponse_items fn) {
void* callee = reinterpret_cast<void*>(fn);
asm volatile(
"mov x9, %[callee]\n\t"
"mov x8, %[sret]\n\t"
"mov x0, %[ths]\n\t"
"blr x9\n\t"
:
: [callee]"r"(callee), [sret]"r"(out), [ths]"r"(this_)
: WRAPPER_A64_CLOB_CALL);
}
// Eight std::string parameters (seven S8_ in the mangled name): x1..x7 in regs,
// eighth on stack at [sp]. Matches zhaarey agent-arm64.js and
// WorldObservationLog/wrapper arm64 import.h.
inline void svfoot_get_persistent_key(abi::shared_ptr* ret,
void* fh,
abi::std_string* a1,
abi::std_string* a2,
abi::std_string* a3,
abi::std_string* a4,
abi::std_string* a5,
abi::std_string* a6,
abi::std_string* a7,
abi::std_string* a8_stack,
abi::fn_SVFootHillSessionCtrl_getPersistentKey fn) {
void* callee = reinterpret_cast<void*>(fn);
asm volatile(
"mov x9, %[callee]\n\t"
"mov x8, %[sret]\n\t"
"mov x0, %[ths]\n\t"
"mov x1, %[a1]\n\t"
"mov x2, %[a2]\n\t"
"mov x3, %[a3]\n\t"
"mov x4, %[a4]\n\t"
"mov x5, %[a5]\n\t"
"mov x6, %[a6]\n\t"
"mov x7, %[a7]\n\t"
"sub sp, sp, #0x10\n\t"
"mov x10, %[a8]\n\t"
"str x10, [sp]\n\t"
"blr x9\n\t"
"add sp, sp, #0x10\n\t"
:
: [callee]"r"(callee),
[sret]"r"(ret),
[ths]"r"(fh),
[a1]"r"(a1),
[a2]"r"(a2),
[a3]"r"(a3),
[a4]"r"(a4),
[a5]"r"(a5),
[a6]"r"(a6),
[a7]"r"(a7),
[a8]"r"(a8_stack)
: WRAPPER_A64_CLOB_CALL);
}
// Seven std::string parameters (six S8_): all in x1..x7; no stack argument.
inline void svfoot_get_persistent_key_7str(abi::shared_ptr* ret,
void* fh,
abi::std_string* adam,
abi::std_string* key_uri,
abi::std_string* key_format,
abi::std_string* key_format_ver,
abi::std_string* server_uri,
abi::std_string* protocol_type,
abi::std_string* fps_cert,
abi::fn_SVFootHillSessionCtrl_getPersistentKey7 fn) {
void* callee = reinterpret_cast<void*>(fn);
asm volatile(
"mov x9, %[callee]\n\t"
"mov x8, %[sret]\n\t"
"mov x0, %[ths]\n\t"
"mov x1, %[a1]\n\t"
"mov x2, %[a2]\n\t"
"mov x3, %[a3]\n\t"
"mov x4, %[a4]\n\t"
"mov x5, %[a5]\n\t"
"mov x6, %[a6]\n\t"
"mov x7, %[a7]\n\t"
"blr x9\n\t"
:
: [callee]"r"(callee),
[sret]"r"(ret),
[ths]"r"(fh),
[a1]"r"(adam),
[a2]"r"(key_uri),
[a3]"r"(key_format),
[a4]"r"(key_format_ver),
[a5]"r"(server_uri),
[a6]"r"(protocol_type),
[a7]"r"(fps_cert)
: WRAPPER_A64_CLOB_CALL);
}
inline void svfoot_decrypt_context(abi::shared_ptr* ret,
void* fh,
void* persist,
abi::fn_SVFootHillSessionCtrl_decryptContext fn) {
void* callee = reinterpret_cast<void*>(fn);
asm volatile(
"mov x9, %[callee]\n\t"
"mov x8, %[sret]\n\t"
"mov x0, %[ths]\n\t"
"mov x1, %[pk]\n\t"
"blr x9\n\t"
:
: [callee]"r"(callee),
[sret]"r"(ret),
[ths]"r"(fh),
[pk]"r"(persist)
: WRAPPER_A64_CLOB_CALL);
}
// Keep an x8 scratch slot for URLRequest::run() on AArch64. Some Apple C++
// entry points in this area return non-trivial values via the hidden result
// register, and the scratch slot is harmless for void-returning builds.
inline void urlrequest_run(void* this_, abi::fn_URLRequest_run fn) {
void* callee = reinterpret_cast<void*>(fn);
// 32-byte scratch buffer for the sret return value (we discard it).
alignas(8) std::uint8_t scratch[32] = {};
void* sret = reinterpret_cast<void*>(scratch);
asm volatile(
"mov x9, %[callee]\n\t"
"mov x8, %[sret]\n\t"
"mov x0, %[ths]\n\t"
"blr x9\n\t"
:
: [callee]"r"(callee), [sret]"r"(sret), [ths]"r"(this_)
: WRAPPER_A64_CLOB_CALL);
}
#undef WRAPPER_A64_CLOB_CALL
#else
inline void device_guid_instance(abi::shared_ptr* out, abi::fn_DeviceGUID_instance fn) {
fn(out);
}
inline void make_shared_request_context(abi::shared_ptr* out,
abi::std_string* arg,
abi::fn_make_shared_RequestContext fn) {
fn(out, arg);
}
inline void make_shared_android_presentation_interface(
abi::shared_ptr* out,
abi::fn_make_shared_AndroidPresentationInterface fn) {
fn(out);
}
inline void make_shared_authenticate_flow(abi::shared_ptr* out,
abi::shared_ptr* req_ctx,
abi::fn_make_shared_AuthenticateFlow fn) {
fn(out, req_ctx);
}
inline void device_guid_configure(void* cfg_ret,
void* this_,
abi::std_string* a1,
abi::std_string* a2,
const unsigned int* a3,
const std::uint8_t* a4,
abi::fn_DeviceGUID_configure fn) {
fn(cfg_ret, this_, a1, a2, a3, a4);
}
inline void request_context_init(void* rci_ret,
void* this_,
abi::shared_ptr* cfg,
abi::fn_RequestContext_init fn) {
fn(rci_ret, this_, cfg);
}
inline void device_guid_guid(void* hidden_pair, void* this_, abi::fn_DeviceGUID_guid fn) {
fn(hidden_pair, this_);
}
inline void request_context_store_front_identifier(
abi::std_string* out,
void* this_,
abi::shared_ptr* url_bag,
abi::fn_RequestContext_storeFrontIdentifier fn) {
fn(out, this_, url_bag);
}
inline void purchase_response_items(abi::std_vector* out,
void* this_,
abi::fn_PurchaseResponse_items fn) {
fn(out, this_);
}
inline void svfoot_get_persistent_key(abi::shared_ptr* ret,
void* fh,
abi::std_string* a1,
abi::std_string* a2,
abi::std_string* a3,
abi::std_string* a4,
abi::std_string* a5,
abi::std_string* a6,
abi::std_string* a7,
abi::std_string* a8,
abi::fn_SVFootHillSessionCtrl_getPersistentKey fn) {
fn(ret, fh, a1, a2, a3, a4, a5, a6, a7, a8);
}
inline void svfoot_get_persistent_key_7str(abi::shared_ptr* ret,
void* fh,
abi::std_string* adam,
abi::std_string* key_uri,
abi::std_string* key_format,
abi::std_string* key_format_ver,
abi::std_string* server_uri,
abi::std_string* protocol_type,
abi::std_string* fps_cert,
abi::fn_SVFootHillSessionCtrl_getPersistentKey7 fn) {
fn(ret, fh, adam, key_uri, key_format, key_format_ver, server_uri, protocol_type, fps_cert);
}
inline void svfoot_decrypt_context(abi::shared_ptr* ret,
void* fh,
void* persist,
abi::fn_SVFootHillSessionCtrl_decryptContext fn) {
fn(ret, fh, persist);
}
inline void urlrequest_run(void* this_, abi::fn_URLRequest_run fn) {
fn(this_);
}
#endif
} // namespace wrapper::apple::aarch64_sret
@@ -0,0 +1,708 @@
// Apple Music native-lib ABI bindings (x86_64 / arm64-v8a, APK 3.6.0-beta).
//
// Returning C++ objects by value from Apple code uses a hidden first parameter
// (struct-return / sret). On x86_64 that slot is the first C argument; on
// AArch64 it is passed in x8 while `this` stays in x0. Plain C calls into those
// entry points break on arm64 — use aarch64_sret_thunks.hpp helpers there.
//
// We call into Apple's libstoreservicescore / libmediaplatform /
// libandroidappmusic by way of the Itanium-mangled C++ symbols they
// export. Each symbol below is declared as a function pointer type;
// the Loader class (apple/loader.hpp) dlopens the libs at runtime
// and resolves the symbols via dlsym.
//
// We deliberately treat all Apple types as opaque (`void*`) and pass
// strings as pointers to a layout-compatible `std_string` union. This
// is the pattern used by the upstream `wrapper` project; it avoids
// compile-time inclusion of Apple headers we don't have, at the cost
// of giving up type safety.
//
// Stability: the symbol names below are pinned to APK build 1109. If
// the APK is changed, re-derive with `nm -D --defined libstoreservicescore.so`
// and update LIBS_VERSION.json.
//
// The declarations cover runtime init, auth/token harvest, playback dispatch,
// and FairPlay sample decrypt symbols.
#pragma once
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <string>
namespace wrapper::apple::abi {
// libc++ on the Android NDK uses an inline namespace `__ndk1`, which
// shows up in mangled names. The C++ standard library types we touch
// (`std::shared_ptr`, `std::basic_string`, `std::vector`) have a
// well-known POD-ish layout we can fake without including <memory>
// etc. Mismatching this layout WILL miscompile silently; tread
// carefully.
// std::__ndk1::shared_ptr<T> on x86_64: { T* obj; control_block* ctl; }.
// Total size 16 bytes. Apple methods returning shared_ptr take a
// hidden first arg pointing at one of these.
struct shared_ptr {
void* obj = nullptr;
void* ctrl_blk = nullptr;
};
// std::__ndk1::basic_string<char> on x86_64 uses the libc++ "long" /
// "short" union layout. Size: 24 bytes. Short mode uses the low bit
// of the first byte as the tag (0 = short). Long mode stores
// {capacity, size, data_ptr}.
union std_string {
struct {
std::uint8_t mark; // bit 0 set => long mode
char str[23]; // SSO buffer (22 chars + NUL)
} _short;
struct {
std::size_t cap; // capacity (with bit 0 = 1 => long)
std::size_t size; // length
const char* data; // pointer to heap-allocated storage
} _long;
};
static_assert(sizeof(std_string) == 24, "std_string ABI mismatch");
static_assert(sizeof(shared_ptr) == 16, "shared_ptr ABI mismatch");
// Pass a C string to an Apple method by reference. We deliberately do
// NOT take ownership of `s`: the Apple call reads the pointer
// directly. The caller must keep the underlying buffer live for the
// duration of the call.
inline std_string make_string_view(const char* s) {
std_string out{};
out._long.cap = 1; // any odd value => long mode
out._long.size = std::strlen(s);
out._long.data = s;
return out;
}
// Empty std::vector<T> on x86_64: three null pointers (begin/end/capacity).
struct std_vector {
void* begin = nullptr;
void* end = nullptr;
void* end_capacity = nullptr;
};
// ---------------------------------------------------------------------------
// Function pointer types for runtime-init symbols
// ---------------------------------------------------------------------------
//
// `using fn_X = void (*)(...);` rather than direct extern "C" decls.
// This keeps the daemon ELF free of unresolved Apple symbols at link
// time so it can be started without the Apple libs available - useful
// for stub-mode operation (WRAPPER_APPLE_INIT=0).
// bionic: void _resolv_set_nameservers_for_net(unsigned, const char**, int, const char*)
using fn_resolv_set_nameservers_for_net =
void (*)(unsigned netid, const char** servers, int count, const char* domains);
// FootHillConfig::config(std::string const&)
using fn_FootHillConfig_config = void (*)(std_string* device_id);
// storeservicescore::DeviceGUID::instance() -> shared_ptr<DeviceGUID>
using fn_DeviceGUID_instance = void (*)(shared_ptr* out);
// storeservicescore::DeviceGUID::configure(std::string const&,
// std::string const&,
// unsigned int const&,
// bool const&)
using fn_DeviceGUID_configure =
void (*)(void* hidden_return,
void* this_,
std_string* arg1,
std_string* arg2,
const unsigned int* arg3,
const std::uint8_t* arg4);
// std::shared_ptr<RequestContext>::make_shared<std::string&>(std::string&)
using fn_make_shared_RequestContext =
void (*)(shared_ptr* out, std_string* arg);
// storeservicescore::RequestContextConfig::RequestContextConfig()
using fn_RequestContextConfig_ctor = void (*)(void* this_);
// All RequestContextConfig string setters have the same signature.
using fn_RCC_set_string = void (*)(void* this_, std_string* s);
// RequestContextManager::configure(shared_ptr<RequestContext> const&)
using fn_RequestContextManager_configure = void (*)(shared_ptr* req_ctx);
// storeservicescore::RequestContext::init(shared_ptr<RequestContextConfig> const&)
using fn_RequestContext_init =
void (*)(void* hidden_return, void* this_, shared_ptr* config);
// ---------------------------------------------------------------------------
// AndroidPresentationInterface + callbacks
// ---------------------------------------------------------------------------
//
// AuthenticateFlow uses a "presentation interface" object to ask the
// host program for credentials and to surface UI dialogs. We satisfy
// it with the Android-flavored implementation Apple ships
// (`AndroidPresentationInterface`), and we register two handlers whose
// signatures match Apple's C++ `std::shared_ptr` callback types. The
// dialog handler logs each ProtocolDialog and must call
// handleProtocolDialogResponse or login stalls.
// std::shared_ptr<AndroidPresentationInterface>::make_shared<>()
using fn_make_shared_AndroidPresentationInterface = void (*)(shared_ptr* out);
// Apple's setCredentialsHandler / setDialogHandler store a function pointer
// whose C++ type passes std::shared_ptr by value. On x86_64 Itanium ABI,
// std::shared_ptr has a non-trivial copy ctor/dtor so it is passed *indirectly*
// (the caller puts a pointer to a temporary in %rdi/%rsi). Declaring the
// callback with explicit shared_ptr* parameters matches that exactly — which
// is also what the upstream C wrapper does (struct shared_ptr *).
using fn_credential_handler = void (*)(shared_ptr* cred_request,
shared_ptr* cred_response_handler);
using fn_dialog_handler = void (*)(long dialog_id,
shared_ptr* dialog,
shared_ptr* response_handler);
// AndroidPresentationInterface::setCredentialsHandler(fn_credential_handler)
using fn_API_setCredentialsHandler =
void (*)(void* this_, fn_credential_handler cb);
// AndroidPresentationInterface::setDialogHandler(fn_dialog_handler)
using fn_API_setDialogHandler =
void (*)(void* this_, fn_dialog_handler cb);
// AndroidPresentationInterface::handleCredentialsResponse(
// shared_ptr<CredentialsResponse> const&)
using fn_API_handleCredentialsResponse =
void (*)(void* this_, shared_ptr* cred_response);
// AndroidPresentationInterface::handleProtocolDialogResponse(
// long const&, shared_ptr<ProtocolDialogResponse> const&)
using fn_API_handleProtocolDialogResponse =
void (*)(void* this_, const long* dialog_id, const shared_ptr* dialog_response);
// ---------------------------------------------------------------------------
// ProtocolDialog / ProtocolDialogResponse (login UI)
// ---------------------------------------------------------------------------
using fn_ProtocolDialog_title = std_string* (*)(void* this_);
using fn_ProtocolDialog_message = std_string* (*)(void* this_);
using fn_ProtocolDialog_buttons = std_vector* (*)(void* this_);
using fn_ProtocolButton_title = std_string* (*)(void* this_);
using fn_ProtocolDialogResponse_ctor = void (*)(void* this_);
using fn_ProtocolDialogResponse_setSelectedButton =
void (*)(void* this_, const shared_ptr* button);
// storeservicescore::RequestContext::setPresentationInterface(
// shared_ptr<PresentationInterface> const&)
using fn_RequestContext_setPresentationInterface =
void (*)(void* this_, shared_ptr* presentation_interface);
// ---------------------------------------------------------------------------
// CredentialsRequest / CredentialsResponse
// ---------------------------------------------------------------------------
//
// CredentialsRequest is what gets passed *to* the credential handler
// (read-only metadata about why credentials are being asked for).
// CredentialsResponse is what we construct and return.
// CredentialsRequest::requiresHSA2VerificationCode() const
using fn_CR_requiresHSA2VerificationCode = std::uint8_t (*)(void* this_);
// CredentialsRequest::title() const / message() const - return a
// pointer to a std::string we may read but must not free.
using fn_CR_title = std_string* (*)(void* this_);
using fn_CR_message = std_string* (*)(void* this_);
// CredentialsResponse::CredentialsResponse() - default constructor.
using fn_CredentialsResponse_ctor = void (*)(void* this_);
// CredentialsResponse::setUserName / setPassword (std::string const&)
using fn_CredentialsResponse_set_string = void (*)(void* this_, std_string* s);
// CredentialsResponse::setResponseType(ResponseType) - upstream passes
// `2` after setting username+password to signal "submit credentials".
using fn_CredentialsResponse_setResponseType = void (*)(void* this_, int response_type);
// ---------------------------------------------------------------------------
// AuthenticateFlow / AuthenticateResponse
// ---------------------------------------------------------------------------
// std::shared_ptr<AuthenticateFlow>::make_shared<RequestContext&>(RequestContext&)
using fn_make_shared_AuthenticateFlow =
void (*)(shared_ptr* out, shared_ptr* req_ctx);
// AuthenticateFlow::run() - synchronous; fires credentialHandler internally.
using fn_AuthenticateFlow_run = void (*)(void* this_);
// AuthenticateFlow::response() const - returns a shared_ptr<AuthenticateResponse>
// owned by the flow.
using fn_AuthenticateFlow_response = shared_ptr* (*)(void* this_);
// AuthenticateResponse::responseType() const - 6 means success;
// other values indicate failure or cancellation.
using fn_AR_responseType = int (*)(void* this_);
// AuthenticateResponse::customerMessage() const - returns pointer to
// a std::string (might be empty).
using fn_AR_customerMessage = std_string* (*)(void* this_);
// AuthenticateResponse::error() const - returns a shared_ptr<StoreErrorCondition>;
// null if no error.
using fn_AR_error = shared_ptr* (*)(void* this_);
// ---------------------------------------------------------------------------
// StoreErrorCondition
// ---------------------------------------------------------------------------
using fn_SEC_errorCode = int (*)(void* this_);
using fn_SEC_what = const char* (*)(void* this_);
// ---------------------------------------------------------------------------
// DeviceGUID accessor + mediaplatform::Data
// ---------------------------------------------------------------------------
// DeviceGUID::guid() - returns a (Data, ...) tuple via a hidden 16-byte
// out param. The first 8 bytes are a pointer to mediaplatform::Data
// whose bytes() yields the GUID string.
using fn_DeviceGUID_guid = void (*)(void* hidden_return /* [2]void* */,
void* this_);
// mediaplatform::Data::bytes() const -> char* (NUL-terminated for the
// strings Apple's HTTP layer returns).
using fn_Data_bytes = const char* (*)(void* this_);
// ---------------------------------------------------------------------------
// URLRequest / URLResponse / HTTPMessage
// ---------------------------------------------------------------------------
//
// To call out to Apple's iTunes endpoints (apiToken, createMusicToken)
// we construct an HTTPMessage, wrap it in a URLRequest which is bound
// to our RequestContext (so it gets signed with DSID + X-Token), call
// run(), then unwrap URLResponse -> underlyingResponse -> raw bytes.
// HTTPMessage::HTTPMessage(std::string url, std::string method)
//
// The mangled constructor takes both strings by value, not by const&.
// Keep these as real std::string parameters so the compiler emits the
// correct C++ ABI for non-trivial by-value arguments (caller-created
// temporaries passed indirectly on AArch64). Passing abi::std_string* here
// is only safe for const& symbols and can corrupt/free string literals.
using fn_HTTPMessage_ctor =
void (*)(void* this_, std::string url, std::string method);
// C1 (complete object constructor) – used as a fallback on arm64 when
// HTTPMessage has virtual bases that C2 does not initialise.
using fn_HTTPMessage_ctor_c1 =
void (*)(void* this_, std::string url, std::string method);
// HTTPMessage::setHeader(string name, string value)
using fn_HTTPMessage_setHeader =
void (*)(void* this_, std_string* name, std_string* value);
// HTTPMessage::setBodyData(char* body, size_t len)
using fn_HTTPMessage_setBodyData =
void (*)(void* this_, const char* body, std::size_t len);
// URLRequest::URLRequest(shared_ptr<HTTPMessage> const&,
// shared_ptr<RequestContext> const&)
using fn_URLRequest_ctor =
void (*)(void* this_, shared_ptr* http_message, shared_ptr* req_ctx);
// URLRequest::setRequestParameter(std::string name, std::string value)
// - the two-string overload (a vector<string> overload also exists).
using fn_URLRequest_setRequestParameter =
void (*)(void* this_, std_string* name, std_string* value);
// URLRequest::run(). Call through aarch64_sret::urlrequest_run so AArch64 gets
// a harmless hidden-result scratch slot if the exported build expects one.
using fn_URLRequest_run = void (*)(void* this_);
using fn_URLRequest_error = shared_ptr* (*)(void* this_);
using fn_URLRequest_response = shared_ptr* (*)(void* this_);
// URLResponse::underlyingResponse() const - returns the inner
// HTTPMessage shared_ptr whose body bytes we read out at offset +48.
using fn_URLResponse_underlyingResponse = shared_ptr* (*)(void* this_);
// ---------------------------------------------------------------------------
// PurchaseRequest + URLResponse::protocolDictionary
// (GET /playback "raw playback dispatch plist" path).
// ---------------------------------------------------------------------------
//
// PurchaseRequest is the high-level storeservicescore object that wraps
// the iTunes store "buyProduct" / "subDownload" URL bag dispatch. We
// drive it the same way upstream wrapper main.c does:
// 1. ctor on a sufficiently-sized buffer (PurchaseRequest is heavy;
// we use a 4 KiB stack buffer, mirroring URLRequest).
// 2. setProcessDialogActions(true)
// 3. setURLBagKey("subDownload")
// 4. setBuyParameters("salableAdamId=...&pricingParameters=SUBS&...")
// 5. run() (blocking; uses our presentation interface for any UI).
//
// To capture the *raw* response plist instead of just the parsed
// `PurchaseResponse` accessors, we install a URLResponsePreprocessor
// hook on URLRequest (PurchaseRequest's base class). Apple invokes it
// synchronously inside run() with the parsed URLResponse shared_ptr;
// we call URLResponse::protocolDictionary() which returns a borrowed
// CFDictionaryRef of the entire MZ-protocol envelope (cancel-batch
// flags, songList, assets, key URIs, ...). We CFRetain it, then after
// run() returns we re-serialize via CFPropertyListCreateData to XML.
// PurchaseRequest::PurchaseRequest(shared_ptr<RequestContext> const&)
using fn_PurchaseRequest_ctor =
void (*)(void* this_, shared_ptr* req_ctx);
// PurchaseRequest::setProcessDialogActions(bool)
using fn_PurchaseRequest_setProcessDialogActions =
void (*)(void* this_, std::uint8_t flag);
// PurchaseRequest::setURLBagKey / setBuyParameters - both take std::string const&.
using fn_PurchaseRequest_set_string =
void (*)(void* this_, std_string* s);
// PurchaseRequest::run() (blocking)
using fn_PurchaseRequest_run = void (*)(void* this_);
// PurchaseRequest::response() const -> shared_ptr<PurchaseResponse>*
// (returns a pointer to an internal member; same shape as URLRequest::response)
using fn_PurchaseRequest_response = shared_ptr* (*)(void* this_);
// PurchaseResponse::error() -> shared_ptr<StoreErrorCondition>*
// (non-const in Apple's lib despite being a getter; see nm output)
using fn_PurchaseResponse_error = shared_ptr* (*)(void* this_);
// PurchaseResponse::items() const -> std::vector<shared_ptr<PurchaseItem>>
// Vector is 24 bytes (3 pointers), so SysV ABI returns it via a hidden
// first arg (the local std_vector the caller wants to fill).
using fn_PurchaseResponse_items =
void (*)(std_vector* out, void* this_);
// PurchaseItem::dictionary() const — in the pinned lib the body returns `this`
// (not CFDictionaryRef); the plist lives at object offset 0. Caller should use
// purchase_item_cfdictionary() in playback.cpp. Kept for dlsym + builds where
// dictionary() returns the dict pointer directly.
using fn_PurchaseItem_dictionary = void* (*)(void* this_);
// URLRequest::setURLResponsePreprocessor(
// std::function<void(std::shared_ptr<URLResponse> const&)> const&)
// We pass a pointer to a libc++ std::function we built; Apple copies it
// into the request and invokes it during run().
using fn_URLRequest_setURLResponsePreprocessor =
void (*)(void* this_, const void* fn);
// URLResponse::protocolDictionary() -> CFDictionaryRef (borrowed, NOT
// retained by us; CFRetain it inside the preprocessor before run()
// returns or it may be freed when Apple drops the URLResponse).
using fn_URLResponse_protocolDictionary = void* (*)(void* this_);
// RequestContext::storeFrontIdentifier(shared_ptr<URLBag> const&) const
// - writes a std::string to the hidden first arg; the URLBag arg is
// passed as a null shared_ptr (upstream pattern).
using fn_RequestContext_storeFrontIdentifier =
void (*)(std_string* out, void* this_, shared_ptr* url_bag);
// ---------------------------------------------------------------------------
// SVPlaybackLeaseManager + SVFootHillSessionCtrl (FairPlay decrypt)
// ---------------------------------------------------------------------------
// SVPlaybackLeaseManager::SVPlaybackLeaseManager(
// std::function<void(int const&)> const&,
// std::function<void(std::shared_ptr<storeservicescore::StoreErrorCondition>
// const&)> const&) [upstream main.cpp uses std::function<void(void*)> for 2nd]
using fn_SVPlaybackLeaseManager_ctor = void (*)(
void* this_,
void* end_lease_std_function,
void* pb_err_std_function);
using fn_SVPlaybackLeaseManager_refreshLeaseAutomatically = void (*)(void* this_,
std::uint8_t* flag);
using fn_SVPlaybackLeaseManager_requestLease = void (*)(void* this_,
std::uint8_t* flag);
using fn_SVFootHillSessionCtrl_instance = void* (*)();
// getPersistentKey: Apple bumped the signature across Music app releases.
// Itanium abbreviation S8_ is one trailing std::string const& per segment.
// - 7× S8_ after the first RK… = 8 string refs (adam, prefetch, uri, …).
// Used by zhaarey/apple-music-downloader agent-arm64.js and some APKs.
// - 6× S8_ = 7 string refs (no separate prefetch slot). Older libandroidappmusic.
// Loader resolves whichever symbol exists; decrypt dispatches to the matching thunk.
using fn_SVFootHillSessionCtrl_getPersistentKey =
void (*)(shared_ptr* ret,
void* fh,
std_string* adam_id,
std_string* prefetch_adam_id,
std_string* key_uri,
std_string* key_format,
std_string* key_format_ver,
std_string* server_uri,
std_string* protocol_type,
std_string* fps_cert);
using fn_SVFootHillSessionCtrl_getPersistentKey7 =
void (*)(shared_ptr* ret,
void* fh,
std_string* adam_id,
std_string* key_uri,
std_string* key_format,
std_string* key_format_ver,
std_string* server_uri,
std_string* protocol_type,
std_string* fps_cert);
// Third argument is the persistent-key handle (upstream passes persistK.obj).
using fn_SVFootHillSessionCtrl_decryptContext = void (*)(shared_ptr* ret,
void* fh,
void* persistent_key_obj);
// Returns pointer to kdContext; upstream dereferences once.
using fn_SVFootHillPContext_kdContext = void** (*)(void* pctx);
using fn_fp_sample_decrypt = long (*)(void* kd_context,
std::uint32_t op,
void* in_out,
void* out_alias,
std::size_t size);
using fn_SVFootHillSessionCtrl_resetAllContexts = void (*)(void* fh);
using fn_shared_ptr_SVFootHillPContext_dtor = void (*)(shared_ptr* this_);
// Mangled symbol names. These are the *strings* we feed to dlsym.
// Kept centrally so that the Loader implementation does not embed
// them inline (easier to audit and regenerate from `nm` output).
namespace mangled {
inline constexpr const char* resolv_set_nameservers_for_net =
"_resolv_set_nameservers_for_net";
inline constexpr const char* FootHillConfig_config =
"_ZN14FootHillConfig6configERKNSt6__ndk112basic_stringIcNS0_11char_traitsIcEENS0_9allocatorIcEEEE";
inline constexpr const char* DeviceGUID_instance =
"_ZN17storeservicescore10DeviceGUID8instanceEv";
inline constexpr const char* DeviceGUID_configure =
"_ZN17storeservicescore10DeviceGUID9configureERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEES9_RKjRKb";
inline constexpr const char* make_shared_RequestContext =
"_ZNSt6__ndk110shared_ptrIN17storeservicescore14RequestContextEE11make_sharedIJRNS_12basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEEEEES3_DpOT_";
inline constexpr const char* vtable_RequestContextConfig =
"_ZTVNSt6__ndk120__shared_ptr_emplaceIN17storeservicescore20RequestContextConfigENS_9allocatorIS2_EEEE";
inline constexpr const char* RequestContextConfig_ctor =
"_ZN17storeservicescore20RequestContextConfigC2Ev";
inline constexpr const char* RCC_setBaseDirectoryPath =
"_ZN17storeservicescore20RequestContextConfig20setBaseDirectoryPathERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
inline constexpr const char* RCC_setClientIdentifier =
"_ZN17storeservicescore20RequestContextConfig19setClientIdentifierERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
inline constexpr const char* RCC_setVersionIdentifier =
"_ZN17storeservicescore20RequestContextConfig20setVersionIdentifierERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
inline constexpr const char* RCC_setPlatformIdentifier =
"_ZN17storeservicescore20RequestContextConfig21setPlatformIdentifierERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
inline constexpr const char* RCC_setProductVersion =
"_ZN17storeservicescore20RequestContextConfig17setProductVersionERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
inline constexpr const char* RCC_setDeviceModel =
"_ZN17storeservicescore20RequestContextConfig14setDeviceModelERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
inline constexpr const char* RCC_setBuildVersion =
"_ZN17storeservicescore20RequestContextConfig15setBuildVersionERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
inline constexpr const char* RCC_setLocaleIdentifier =
"_ZN17storeservicescore20RequestContextConfig19setLocaleIdentifierERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
inline constexpr const char* RCC_setLanguageIdentifier =
"_ZN17storeservicescore20RequestContextConfig21setLanguageIdentifierERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
inline constexpr const char* RequestContextManager_configure =
"_ZN21RequestContextManager9configureERKNSt6__ndk110shared_ptrIN17storeservicescore14RequestContextEEE";
inline constexpr const char* RequestContext_init =
"_ZN17storeservicescore14RequestContext4initERKNSt6__ndk110shared_ptrINS_20RequestContextConfigEEE";
inline constexpr const char* RequestContext_setFairPlayDirectoryPath =
"_ZN17storeservicescore14RequestContext24setFairPlayDirectoryPathERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
// ---- AndroidPresentationInterface + auth flow ----
inline constexpr const char* make_shared_AndroidPresentationInterface =
"_ZNSt6__ndk110shared_ptrIN20androidstoreservices28AndroidPresentationInterfaceEE11make_sharedIJEEES3_DpOT_";
inline constexpr const char* API_setCredentialsHandler =
"_ZN20androidstoreservices28AndroidPresentationInterface21setCredentialsHandlerEPFvNSt6__ndk110shared_ptrIN17storeservicescore18CredentialsRequestEEENS2_INS_33AndroidCredentialsResponseHandlerEEEE";
inline constexpr const char* API_setDialogHandler =
"_ZN20androidstoreservices28AndroidPresentationInterface16setDialogHandlerEPFvlNSt6__ndk110shared_ptrIN17storeservicescore14ProtocolDialogEEENS2_INS_36AndroidProtocolDialogResponseHandlerEEEE";
inline constexpr const char* API_handleCredentialsResponse =
"_ZN20androidstoreservices28AndroidPresentationInterface25handleCredentialsResponseERKNSt6__ndk110shared_ptrIN17storeservicescore19CredentialsResponseEEE";
inline constexpr const char* API_handleProtocolDialogResponse =
"_ZN20androidstoreservices28AndroidPresentationInterface28handleProtocolDialogResponseERKlRKNSt6__ndk110shared_ptrIN17storeservicescore22ProtocolDialogResponseEEE";
inline constexpr const char* vtable_ProtocolDialogResponse =
"_ZTVNSt6__ndk120__shared_ptr_emplaceIN17storeservicescore22ProtocolDialogResponseENS_9allocatorIS2_EEEE";
inline constexpr const char* ProtocolDialogResponse_ctor =
"_ZN17storeservicescore22ProtocolDialogResponseC1Ev";
inline constexpr const char* ProtocolDialogResponse_setSelectedButton =
"_ZN17storeservicescore22ProtocolDialogResponse17setSelectedButtonERKNSt6__ndk110shared_ptrINS_14ProtocolButtonEEE";
inline constexpr const char* ProtocolDialog_title =
"_ZNK17storeservicescore14ProtocolDialog5titleEv";
inline constexpr const char* ProtocolDialog_message =
"_ZNK17storeservicescore14ProtocolDialog7messageEv";
inline constexpr const char* ProtocolDialog_buttons =
"_ZNK17storeservicescore14ProtocolDialog7buttonsEv";
inline constexpr const char* ProtocolButton_title =
"_ZNK17storeservicescore14ProtocolButton5titleEv";
inline constexpr const char* RequestContext_setPresentationInterface =
"_ZN17storeservicescore14RequestContext24setPresentationInterfaceERKNSt6__ndk110shared_ptrINS_21PresentationInterfaceEEE";
inline constexpr const char* CR_requiresHSA2VerificationCode =
"_ZNK17storeservicescore18CredentialsRequest28requiresHSA2VerificationCodeEv";
inline constexpr const char* CR_title =
"_ZNK17storeservicescore18CredentialsRequest5titleEv";
inline constexpr const char* CR_message =
"_ZNK17storeservicescore18CredentialsRequest7messageEv";
inline constexpr const char* vtable_CredentialsResponse =
"_ZTVNSt6__ndk120__shared_ptr_emplaceIN17storeservicescore19CredentialsResponseENS_9allocatorIS2_EEEE";
inline constexpr const char* CredentialsResponse_ctor =
"_ZN17storeservicescore19CredentialsResponseC1Ev";
inline constexpr const char* CredentialsResponse_setUserName =
"_ZN17storeservicescore19CredentialsResponse11setUserNameERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
inline constexpr const char* CredentialsResponse_setPassword =
"_ZN17storeservicescore19CredentialsResponse11setPasswordERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
inline constexpr const char* CredentialsResponse_setResponseType =
"_ZN17storeservicescore19CredentialsResponse15setResponseTypeENS0_12ResponseTypeE";
inline constexpr const char* make_shared_AuthenticateFlow =
"_ZNSt6__ndk110shared_ptrIN17storeservicescore16AuthenticateFlowEE11make_sharedIJRNS0_INS1_14RequestContextEEEEEES3_DpOT_";
inline constexpr const char* AuthenticateFlow_run =
"_ZN17storeservicescore16AuthenticateFlow3runEv";
inline constexpr const char* AuthenticateFlow_response =
"_ZNK17storeservicescore16AuthenticateFlow8responseEv";
inline constexpr const char* AR_responseType =
"_ZNK17storeservicescore20AuthenticateResponse12responseTypeEv";
inline constexpr const char* AR_customerMessage =
"_ZNK17storeservicescore20AuthenticateResponse15customerMessageEv";
inline constexpr const char* AR_error =
"_ZNK17storeservicescore20AuthenticateResponse5errorEv";
inline constexpr const char* SEC_errorCode =
"_ZNK17storeservicescore19StoreErrorCondition9errorCodeEv";
inline constexpr const char* SEC_what =
"_ZNK17storeservicescore19StoreErrorCondition4whatEv";
// ---- token harvest ----
inline constexpr const char* DeviceGUID_guid =
"_ZN17storeservicescore10DeviceGUID4guidEv";
inline constexpr const char* Data_bytes =
"_ZNK13mediaplatform4Data5bytesEv";
inline constexpr const char* vtable_HTTPMessage =
"_ZTVNSt6__ndk120__shared_ptr_emplaceIN13mediaplatform11HTTPMessageENS_9allocatorIS2_EEEE";
inline constexpr const char* HTTPMessage_ctor =
"_ZN13mediaplatform11HTTPMessageC2ENSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEES7_";
inline constexpr const char* HTTPMessage_ctor_c1 =
"_ZN13mediaplatform11HTTPMessageC1ENSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEES7_";
inline constexpr const char* HTTPMessage_setHeader =
"_ZN13mediaplatform11HTTPMessage9setHeaderERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEES9_";
inline constexpr const char* HTTPMessage_setBodyData =
"_ZN13mediaplatform11HTTPMessage11setBodyDataEPcm";
inline constexpr const char* URLRequest_ctor =
"_ZN17storeservicescore10URLRequestC2ERKNSt6__ndk110shared_ptrIN13mediaplatform11HTTPMessageEEERKNS2_INS_14RequestContextEEE";
inline constexpr const char* URLRequest_setRequestParameter =
"_ZN17storeservicescore10URLRequest19setRequestParameterERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEES9_";
inline constexpr const char* URLRequest_run =
"_ZN17storeservicescore10URLRequest3runEv";
inline constexpr const char* URLRequest_error =
"_ZNK17storeservicescore10URLRequest5errorEv";
inline constexpr const char* URLRequest_response =
"_ZNK17storeservicescore10URLRequest8responseEv";
inline constexpr const char* URLResponse_underlyingResponse =
"_ZNK17storeservicescore11URLResponse18underlyingResponseEv";
// ---- PurchaseRequest + URLResponse::protocolDictionary ----
inline constexpr const char* PurchaseRequest_ctor =
"_ZN17storeservicescore15PurchaseRequestC2ERKNSt6__ndk110shared_ptrINS_14RequestContextEEE";
inline constexpr const char* PurchaseRequest_setProcessDialogActions =
"_ZN17storeservicescore15PurchaseRequest23setProcessDialogActionsEb";
inline constexpr const char* PurchaseRequest_setURLBagKey =
"_ZN17storeservicescore15PurchaseRequest12setURLBagKeyERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
inline constexpr const char* PurchaseRequest_setBuyParameters =
"_ZN17storeservicescore15PurchaseRequest16setBuyParametersERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
inline constexpr const char* PurchaseRequest_run =
"_ZN17storeservicescore15PurchaseRequest3runEv";
inline constexpr const char* PurchaseRequest_response =
"_ZNK17storeservicescore15PurchaseRequest8responseEv";
inline constexpr const char* PurchaseResponse_error =
"_ZN17storeservicescore16PurchaseResponse5errorEv";
inline constexpr const char* PurchaseResponse_items =
"_ZNK17storeservicescore16PurchaseResponse5itemsEv";
inline constexpr const char* PurchaseItem_dictionary =
"_ZNK17storeservicescore12PurchaseItem10dictionaryEv";
inline constexpr const char* URLRequest_setURLResponsePreprocessor =
"_ZN17storeservicescore10URLRequest26setURLResponsePreprocessorERKNSt6__ndk18functionIFvRKNS1_10shared_ptrINS_11URLResponseEEEEEE";
inline constexpr const char* URLResponse_protocolDictionary =
"_ZN17storeservicescore11URLResponse18protocolDictionaryEv";
inline constexpr const char* RequestContext_storeFrontIdentifier =
"_ZNK17storeservicescore14RequestContext20storeFrontIdentifierERKNSt6__ndk110shared_ptrINS_6URLBagEEE";
// ---- FairPlay decrypt / lease ----
inline constexpr const char* SVPlaybackLeaseManager_ctor =
"_ZN22SVPlaybackLeaseManagerC2ERKNSt6__ndk18functionIFvRKiEEERKNS1_IFvRKNS0_10shared_ptrIN17storeservicescore19StoreErrorConditionEEEEEE";
inline constexpr const char* SVPlaybackLeaseManager_refreshLeaseAutomatically =
"_ZN22SVPlaybackLeaseManager25refreshLeaseAutomaticallyERKb";
inline constexpr const char* SVPlaybackLeaseManager_requestLease =
"_ZN22SVPlaybackLeaseManager12requestLeaseERKb";
inline constexpr const char* SVFootHillSessionCtrl_instance =
"_ZN21SVFootHillSessionCtrl8instanceEv";
// dlsym is exact: count S8_ segments against `nm -D libandroidappmusic.so`.
// In Itanium mangling, the first std::string arg is spelled out fully (the
// `RKNSt6__ndk112basic_string...` chunk) and creates a substitution slot. Each
// subsequent std::string arg is then `S8_`. So:
// N string args == 1 explicit `RKNSt...` + (N-1) `S8_`.
//
// 8 string params (= seven `S8_`). Used by zhaarey/agent-arm64.js on builds
// where Apple split adam_id into a separate prefetch slot (adam_id duplicated).
inline constexpr const char* SVFootHillSessionCtrl_getPersistentKey_8str =
"_ZN21SVFootHillSessionCtrl16getPersistentKeyERKNSt6__ndk112basic_stringIcNS0_11char_traitsIcEENS0_9allocatorIcEEEES8_S8_S8_S8_S8_S8_S8_";
// 7 string params (= six `S8_`). What `nm -D` shows on both arm64-v8a and
// x86_64 splits of Apple Music 3.6.0-beta-1109. No prefetch slot — one
// adam_id, six other strings (uri, key format, key format version, server
// uri, protocol type, fps cert).
inline constexpr const char* SVFootHillSessionCtrl_getPersistentKey_7str =
"_ZN21SVFootHillSessionCtrl16getPersistentKeyERKNSt6__ndk112basic_stringIcNS0_11char_traitsIcEENS0_9allocatorIcEEEES8_S8_S8_S8_S8_S8_";
// Legacy alias points at the variant that actually exports on the pinned
// APK (3.6.0-beta-1109): the 7-string / six-`S8_` symbol. Kept so unqualified
// references to mangled::SVFootHillSessionCtrl_getPersistentKey resolve to
// the working symbol; loader.cpp tries _8str first, then _7str regardless.
inline constexpr const char* SVFootHillSessionCtrl_getPersistentKey =
SVFootHillSessionCtrl_getPersistentKey_7str;
inline constexpr const char* SVFootHillSessionCtrl_decryptContext =
"_ZN21SVFootHillSessionCtrl14decryptContextERKNSt6__ndk112basic_stringIcNS0_11char_traitsIcEENS0_9allocatorIcEEEERKN11SVDecryptor15SVDecryptorTypeERKb";
inline constexpr const char* SVFootHillPContext_kdContext =
"_ZNK18SVFootHillPContext9kdContextEv";
inline constexpr const char* fp_sample_decrypt = "NfcRKVnxuKZy04KWbdFu71Ou";
inline constexpr const char* SVFootHillSessionCtrl_resetAllContexts =
"_ZN21SVFootHillSessionCtrl16resetAllContextsEv";
inline constexpr const char* shared_ptr_SVFootHillPContext_dtor =
"_ZNSt6__ndk110shared_ptrI18SVFootHillPContextED2Ev";
} // namespace mangled
} // namespace wrapper::apple::abi
@@ -0,0 +1,383 @@
#include "apple/auth.hpp"
#include <cstdlib>
#include <cstdio>
#include <cstring>
#include <utility>
#include <sys/stat.h>
#include <unistd.h>
#include "apple/aarch64_sret_thunks.hpp"
#include "apple/loader.hpp"
#include "apple/runtime.hpp"
#include "apple/tokens.hpp"
namespace wrapper::apple {
namespace {
void unlink_quiet(const char* path) {
if (path == nullptr || *path == '\0') return;
(void)::unlink(path);
}
// Apple writes mpl_db/kvs.sqlitedb the first time AuthenticateFlow succeeds.
// Without that file, RequestContext::storeFrontIdentifier on arm64 dereferences
// uninitialized internal state at offset 0x10 and SIGSEGVs (x86_64 returns an
// empty string instead). Gate the cached-session probe on this file so a cold
// `./data` mount does not crash the daemon at startup.
bool warm_session_present(const std::string& base_dir) {
if (base_dir.empty()) return false;
const std::string kvs = base_dir + "/mpl_db/kvs.sqlitedb";
struct stat st{};
if (::stat(kvs.c_str(), &st) != 0) return false;
return S_ISREG(st.st_mode) && st.st_size > 0;
}
} // namespace
const char* to_string(LoginState s) {
switch (s) {
case LoginState::LoggedOut: return "logged_out";
case LoginState::InProgress: return "in_progress";
case LoginState::Awaiting2FA: return "awaiting_2fa";
case LoginState::Authenticated: return "authenticated";
case LoginState::Failed: return "failed";
}
return "unknown";
}
Account& Account::instance() {
static Account a;
return a;
}
Account::~Account() {
if (worker_.joinable()) {
worker_.detach();
}
}
bool Account::start_login(const Loader& loader,
const Runtime& runtime,
std::string apple_id,
std::string password) {
std::unique_lock<std::mutex> g(mu_);
LoginState s = state_.load(std::memory_order_acquire);
if (s == LoginState::InProgress || s == LoginState::Awaiting2FA) {
return false;
}
// Drain a previous worker thread, if any. join() outside the lock
// would risk re-entering Account from the worker; we take the
// hit of joining under the lock since the worker has already
// transitioned out of the busy states. This also allows POST /login
// to refresh an existing Authenticated snapshot after a stale restore.
if (worker_.joinable()) {
worker_.join();
}
loader_ = &loader;
runtime_ = &runtime;
apple_id_ = std::move(apple_id);
password_ = std::move(password);
twofa_code_.clear();
twofa_submitted_ = false;
aborted_ = false;
tokens_ = Tokens{};
last_error_.clear();
last_error_code_ = 0;
state_.store(LoginState::InProgress, std::memory_order_release);
cv_state_.notify_all();
worker_ = std::thread(&Account::worker_main, this);
return true;
}
bool Account::submit_2fa(std::string code) {
std::lock_guard<std::mutex> g(mu_);
if (state_.load(std::memory_order_acquire) != LoginState::Awaiting2FA) {
return false;
}
twofa_code_ = std::move(code);
twofa_submitted_ = true;
state_.store(LoginState::InProgress, std::memory_order_release);
cv_state_.notify_all();
cv_2fa_.notify_all();
return true;
}
bool Account::try_restore_cached_session(const Loader& loader, const Runtime& runtime) {
std::lock_guard<std::mutex> restore_guard(restore_mu_);
if (!loader.ok() || !runtime.initialized()) {
return false;
}
std::string bd = runtime.base_dir();
bool warm = warm_session_present(bd);
if (!warm) {
std::fprintf(stderr,
"auth: no warm Apple session at %s/mpl_db/kvs.sqlitedb; "
"skipping cached-session restore\n",
bd.c_str());
return false;
}
{
std::lock_guard<std::mutex> g(mu_);
if (state_.load(std::memory_order_acquire) != LoginState::LoggedOut) {
return false;
}
if (worker_.joinable()) {
worker_.join();
}
loader_ = &loader;
runtime_ = &runtime;
password_.clear();
twofa_code_.clear();
twofa_submitted_ = false;
aborted_ = false;
tokens_ = Tokens{};
last_error_.clear();
last_error_code_ = 0;
state_.store(LoginState::InProgress, std::memory_order_release);
cv_state_.notify_all();
}
const Symbols& s = loader.sym();
auto req_ctx = runtime.request_ctx_copy();
if (req_ctx.obj == nullptr) {
std::lock_guard<std::mutex> g(mu_);
loader_ = nullptr;
runtime_ = nullptr;
state_.store(LoginState::LoggedOut, std::memory_order_release);
cv_state_.notify_all();
return false;
}
auto device_guid = runtime.device_guid_copy();
Tokens t;
if (!tokens::harvest_all(s, req_ctx, device_guid, &t)) {
std::fprintf(stderr,
"auth: cached-session restore found Apple session files "
"but token harvest failed\n");
std::lock_guard<std::mutex> g(mu_);
loader_ = nullptr;
runtime_ = nullptr;
state_.store(LoginState::LoggedOut, std::memory_order_release);
cv_state_.notify_all();
return false;
}
if (const char* label = std::getenv("WRAPPER_APPLE_ID")) {
if (label[0] != '\0') {
t.apple_id = label;
}
}
finish_authenticated(std::move(t));
return true;
}
void Account::logout() {
{
std::lock_guard<std::mutex> g(mu_);
aborted_ = true;
cv_2fa_.notify_all();
cv_state_.notify_all();
}
if (worker_.joinable()) {
worker_.join();
}
std::lock_guard<std::mutex> g(mu_);
state_.store(LoginState::LoggedOut, std::memory_order_release);
apple_id_.clear();
password_.clear();
twofa_code_.clear();
twofa_submitted_ = false;
aborted_ = false;
tokens_ = Tokens{};
last_error_.clear();
last_error_code_ = 0;
cv_state_.notify_all();
}
LoginState Account::wait_for_settled_state(std::chrono::milliseconds timeout) {
std::unique_lock<std::mutex> g(mu_);
cv_state_.wait_for(g, timeout, [this] {
auto s = state_.load(std::memory_order_acquire);
return s != LoginState::InProgress;
});
return state_.load(std::memory_order_acquire);
}
LoginState Account::state() const {
return state_.load(std::memory_order_acquire);
}
AccountSnapshot Account::public_snapshot() const {
std::lock_guard<std::mutex> g(mu_);
AccountSnapshot snap;
snap.state = state_.load(std::memory_order_acquire);
snap.apple_id = apple_id_;
if (snap.state == LoginState::Authenticated) {
snap.storefront = tokens_.storefront;
snap.dsid = tokens_.dsid;
snap.music_user_token = tokens_.music_user_token;
snap.dev_token = tokens_.dev_token;
snap.logged_in_at = tokens_.logged_in_at;
}
if (snap.state == LoginState::Failed) {
snap.last_error = last_error_;
snap.last_error_code = last_error_code_;
}
return snap;
}
bool Account::fetch_credentials_blocking(std::string* username,
std::string* password,
bool needs_2fa) {
std::unique_lock<std::mutex> g(mu_);
if (!needs_2fa) {
*username = apple_id_;
*password = password_;
return !apple_id_.empty();
}
// Park here as Awaiting2FA. submit_2fa() or logout() will wake us.
state_.store(LoginState::Awaiting2FA, std::memory_order_release);
cv_state_.notify_all();
constexpr auto k2faTimeout = std::chrono::minutes(5);
cv_2fa_.wait_for(g, k2faTimeout, [this] {
return twofa_submitted_ || aborted_;
});
state_.store(LoginState::InProgress, std::memory_order_release);
cv_state_.notify_all();
*username = apple_id_;
if (aborted_ || !twofa_submitted_) {
// No code arrived in time. Returning empty creds lets the flow
// resolve cleanly with an auth error rather than hang.
*password = password_;
return false;
}
// Upstream's quirk: AuthenticateFlow on Android expects the 2FA
// code to be appended to the password field rather than delivered
// as a separate input.
*password = password_ + twofa_code_;
return true;
}
void Account::finish_authenticated(Tokens t) {
std::lock_guard<std::mutex> g(mu_);
tokens_ = std::move(t);
tokens_.logged_in_at = std::chrono::system_clock::now();
apple_id_ = tokens_.apple_id;
// Wipe the password from memory now that the flow is done.
password_.assign(password_.size(), '\0');
password_.clear();
twofa_code_.assign(twofa_code_.size(), '\0');
twofa_code_.clear();
twofa_submitted_ = false;
state_.store(LoginState::Authenticated, std::memory_order_release);
cv_state_.notify_all();
}
void Account::finish_failed(std::string message, int code) {
std::lock_guard<std::mutex> g(mu_);
last_error_ = std::move(message);
last_error_code_ = code;
password_.assign(password_.size(), '\0');
password_.clear();
twofa_code_.assign(twofa_code_.size(), '\0');
twofa_code_.clear();
twofa_submitted_ = false;
state_.store(LoginState::Failed, std::memory_order_release);
cv_state_.notify_all();
}
void Account::worker_main() {
// Worker thread: drive AuthenticateFlow::run() to completion, then
// harvest tokens or record the failure. All Apple calls below are
// unchecked: a crash inside Apple's libs takes the daemon down
// rather than masking the failure.
if (loader_ == nullptr || !loader_->ok() || runtime_ == nullptr || !runtime_->initialized()) {
finish_failed("runtime not initialized", -1);
return;
}
const Symbols& s = loader_->sym();
auto req_ctx = runtime_->request_ctx_copy();
if (req_ctx.obj == nullptr) {
finish_failed("request context unavailable", -2);
return;
}
/* Upstream login() removes these before each AuthenticateFlow so stale
session files do not confuse the flow. */
{
std::string bd = runtime_->base_dir();
if (!bd.empty()) {
unlink_quiet((bd + "/STOREFRONT_ID").c_str());
unlink_quiet((bd + "/MUSIC_TOKEN").c_str());
}
}
abi::shared_ptr flow;
aarch64_sret::make_shared_authenticate_flow(&flow, &req_ctx,
s.make_shared_AuthenticateFlow);
if (flow.obj == nullptr) {
finish_failed("make_shared<AuthenticateFlow> returned null", -3);
return;
}
s.AuthenticateFlow_run(flow.obj);
abi::shared_ptr* resp = s.AuthenticateFlow_response(flow.obj);
if (resp == nullptr || resp->obj == nullptr) {
finish_failed("AuthenticateFlow::response() returned null", -4);
return;
}
int resp_type = s.AR_responseType(resp->obj);
if (resp_type != 6) {
std::string msg;
int code = 0;
abi::shared_ptr* err = s.AR_error(resp->obj);
if (err != nullptr && err->obj != nullptr) {
code = s.SEC_errorCode(err->obj);
const char* what = s.SEC_what(err->obj);
if (what != nullptr) msg = what;
}
if (msg.empty()) {
abi::std_string* customer = s.AR_customerMessage(resp->obj);
if (customer != nullptr) {
bool is_long = (customer->_long.cap & 1) != 0;
const char* p = is_long ? customer->_long.data
: customer->_short.str;
if (p && *p) msg = p;
}
}
if (msg.empty()) {
msg = "Apple returned response type " + std::to_string(resp_type);
}
finish_failed(std::move(msg), code);
return;
}
Tokens t;
t.apple_id = apple_id_;
if (!tokens::harvest_all(s, runtime_->request_ctx_copy(),
runtime_->device_guid_copy(), &t)) {
finish_failed("token harvest failed after flow.run() succeeded", -5);
return;
}
finish_authenticated(std::move(t));
}
} // namespace wrapper::apple
@@ -0,0 +1,168 @@
// Account / login state for wrapper-v2.
//
// Apple-ID password sign-in flow driven through Apple's AuthenticateFlow. The
// flow is callback-based (Apple calls our credentialHandler inline
// from AuthenticateFlow::run()), so we run it on a dedicated worker
// thread and coordinate with the HTTP layer through a state machine
// plus a condvar that parks the credentialHandler while we wait for
// a 2FA code from POST /login/2fa.
//
// Threading model:
// - One concurrent login flow at a time (the underlying RequestContext
// is process-global; Apple's libs are not re-entrant on the auth
// path).
// - Account::instance() is a singleton so the static credentialHandler
// trampoline (which has no user-data param) can reach state.
// - All state mutation is mutex-protected; readers see consistent
// snapshots.
//
// Persistence (mirrors upstream zhaarey/wrapper without -L):
// - We never persist email/password. Apple writes mpl_db/kvs.sqlitedb
// under WRAPPER_BASE_DIR when a real login has run at least once.
// - On startup, try_restore_cached_session() may harvest storefront /
// dev / music tokens from that persisted session (same as upstream
// main() after skipping login()).
// - Optional WRAPPER_APPLE_ID is only for labeling /me after restore;
// it is not sent to Apple.
#pragma once
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
#include "apple/abi.hpp"
namespace wrapper::apple {
class Loader;
class Runtime;
// Coarse-grained login state surfaced via /me.
enum class LoginState : std::uint8_t {
LoggedOut, // No credentials presented yet.
InProgress, // Worker thread is running AuthenticateFlow::run().
Awaiting2FA, // credentialHandler is parked waiting for a 6-digit code.
Authenticated, // flow.run() returned responseType=6 and tokens are cached.
Failed, // flow.run() reported an error; see last_error / last_code.
};
const char* to_string(LoginState s);
// Cached account-side data harvested after a successful flow.run().
// Returned (with secrets) only via Account::tokens(); a sanitized
// subset is exposed via Account::public_snapshot().
struct Tokens {
std::string apple_id;
std::string storefront; // e.g. "143441" (US) or storefront/region string
std::string dev_token; // sf-api-token-service apiToken
std::string music_user_token; // createMusicToken result
std::string dsid; // decoded from dev_token JWT (empty if not extractable)
std::chrono::system_clock::time_point logged_in_at{};
};
// Public-safe subset of Tokens for /me payloads.
struct AccountSnapshot {
LoginState state = LoginState::LoggedOut;
std::string apple_id;
std::string storefront;
std::string dsid;
std::string music_user_token;
std::string dev_token;
std::chrono::system_clock::time_point logged_in_at{};
// Set when state == Failed.
std::string last_error;
int last_error_code = 0;
};
class Account {
public:
static Account& instance();
// Detach (rather than join) any still-running worker at process
// exit. The worker is likely sitting inside Apple's libs and we
// can't safely cancel that synchronously - the OS will reap it.
~Account();
// Returns false if a login is already in progress (InProgress or
// Awaiting2FA). A fresh login request is allowed to replace an existing
// authenticated snapshot, which lets clients recover from stale restored
// Apple sessions without a separate DELETE /login. On true, the worker
// thread has been started.
bool start_login(const Loader& loader,
const Runtime& runtime,
std::string apple_id,
std::string password);
// Hand a 2FA code to the parked credentialHandler. Returns false if
// the state is not Awaiting2FA.
bool submit_2fa(std::string code);
// Reset to LoggedOut. If a worker thread is running, signals it to
// exit at the next condvar wake. Tokens are wiped.
void logout();
// If state is LoggedOut and Apple already has a warm session on disk
// (same pattern as upstream: init_ctx() without -L), harvest tokens
// and become Authenticated. Returns false if no usable session.
bool try_restore_cached_session(const Loader& loader, const Runtime& runtime);
// Block (up to `timeout`) until state transitions out of InProgress.
// Returns the resulting state. Used by POST /login and POST /login/2fa
// to know when to send the HTTP response.
LoginState wait_for_settled_state(std::chrono::milliseconds timeout);
// Public-safe snapshot for /me.
AccountSnapshot public_snapshot() const;
LoginState state() const;
// ---- Internal: only the credentialHandler trampoline should call these ----
// Pull the current login attempt's credentials. Returns false if
// there is no active login (e.g. the daemon never received /login).
// If 2FA is required, this method blocks (up to a generous timeout)
// until submit_2fa() is called or logout() aborts, then returns the
// password+code concatenation upstream's flow expects.
bool fetch_credentials_blocking(std::string* username,
std::string* password,
bool needs_2fa);
private:
Account() = default;
void worker_main();
// Set by the worker after flow.run() finishes (success or failure).
void finish_authenticated(Tokens t);
void finish_failed(std::string message, int code);
mutable std::mutex mu_;
std::condition_variable cv_state_; // notified on any state change
std::condition_variable cv_2fa_; // notified when 2FA code arrives or logout
std::mutex restore_mu_; // serializes cached-session harvests
std::atomic<LoginState> state_{LoginState::LoggedOut};
// Inputs for the active login attempt.
const Loader* loader_ = nullptr;
const Runtime* runtime_ = nullptr;
std::string apple_id_;
std::string password_;
std::string twofa_code_;
bool twofa_submitted_ = false;
bool aborted_ = false;
// Outputs from a completed login.
Tokens tokens_;
std::string last_error_;
int last_error_code_ = 0;
std::thread worker_;
};
} // namespace wrapper::apple
@@ -0,0 +1,198 @@
#include "apple/decrypt.hpp"
#include <cstdio>
#include <exception>
#include <mutex>
#include <string>
#include <unordered_map>
#include <utility>
#include "apple/fairplay_cert.inc"
#include "apple/aarch64_sret_thunks.hpp"
#include "apple/loader.hpp"
#include "apple/runtime.hpp"
namespace wrapper::apple {
namespace {
constexpr char kPrefetchAdam[] = "0";
// zhaarey/wrapper main.c getKdContext:
// - Never destroys SVFootHillPContext (stack POD shared_ptr, no refcount decrement).
// - preshareCtx: first successful adam=="0" path caches kd; later adam=="0" skips
// getPersistentKey entirely (no URI check in upstream).
// - This wrapper also caches regular track kd contexts by adam_id + key URI so
// batch decrypt requests do not repeatedly process the same CKC.
//
// Calling shared_ptr_SVFootHillPContext_dtor breaks kd / Apple's FootHill state.
std::mutex g_kd_cache_mu;
std::unordered_map<std::string, void*> g_kd_cache;
std::string kd_cache_key(const std::string& adam_id, const std::string& key_uri) {
std::string key;
key.reserve(adam_id.size() + 1 + key_uri.size());
key.append(adam_id);
key.push_back('\n');
key.append(key_uri);
return key;
}
void* find_cached_kd(const std::string& adam_id, const std::string& key_uri) {
std::lock_guard<std::mutex> lock(g_kd_cache_mu);
auto it = g_kd_cache.find(kd_cache_key(adam_id, key_uri));
if (it == g_kd_cache.end()) return nullptr;
return it->second;
}
void store_cached_kd(const std::string& adam_id, const std::string& key_uri, void* kd) {
std::lock_guard<std::mutex> lock(g_kd_cache_mu);
g_kd_cache[kd_cache_key(adam_id, key_uri)] = kd;
}
void erase_cached_kd(const std::string& adam_id, const std::string& key_uri) {
std::lock_guard<std::mutex> lock(g_kd_cache_mu);
g_kd_cache.erase(kd_cache_key(adam_id, key_uri));
}
} // namespace
DecryptResult decrypt_samples(const Loader& loader,
Runtime& runtime,
std::string adam_id,
std::string key_uri,
std::vector<std::vector<std::uint8_t>> ciphertexts) {
DecryptResult out;
if (!loader.ok() || !loader.fairplay_decrypt_available()) {
out.error = "FairPlay decrypt chain not loaded";
return out;
}
if (!runtime.playback_ready()) {
out.error = "playback stack not ready";
return out;
}
if (adam_id.empty() || key_uri.empty()) {
out.error = "adam_id and uri are required";
return out;
}
if (ciphertexts.empty()) {
out.error = "at least one sample required";
return out;
}
const Symbols& s = loader.sym();
void* fh = runtime.foothill_session();
auto decrypt_once = [&](bool allow_cache,
std::vector<std::vector<std::uint8_t>> chunks,
std::string* error) -> DecryptResult {
DecryptResult attempt;
void* kd = nullptr;
if (allow_cache) {
kd = find_cached_kd(adam_id, key_uri);
}
if (kd == nullptr && adam_id == kPrefetchAdam) {
*error = "prefetch decrypt context is not cached; decrypt prefetch samples locally";
return attempt;
}
if (kd == nullptr) {
std::fprintf(stderr, "decrypt: getPersistentKey adam=%s samples=%zu\n",
adam_id.c_str(), chunks.size());
auto default_id = abi::make_string_view(adam_id.c_str());
auto uri = abi::make_string_view(key_uri.c_str());
auto key_format = abi::make_string_view("com.apple.streamingkeydelivery");
auto key_ver = abi::make_string_view("1");
auto server_uri =
abi::make_string_view("https://play.itunes.apple.com/WebObjects/MZPlay.woa/music/fps");
auto protocol = abi::make_string_view("simplified");
auto fps_cert = abi::make_string_view(kFairPlayCert);
abi::shared_ptr persist{};
loader.foot_hill_get_persistent_key(
&persist, fh,
&default_id, &uri, &key_format, &key_ver,
&server_uri, &protocol, &fps_cert);
if (persist.obj == nullptr) {
*error = "getPersistentKey failed (key or lease?)";
return attempt;
}
std::fprintf(stderr, "decrypt: decryptContext adam=%s\n", adam_id.c_str());
abi::shared_ptr sv_ctx{};
aarch64_sret::svfoot_decrypt_context(&sv_ctx, fh, persist.obj,
s.SVFootHillSessionCtrl_decryptContext);
if (sv_ctx.obj == nullptr) {
*error = "decryptContext failed";
return attempt;
}
// Upstream main.c does TWO dereferences:
// void* p = *kdContext_method(ctx); // *(void**) -> void*
// ... NfcRKVn(*(void**)p, ...) // re-cast and deref again
// i.e. fp_sample_decrypt receives **kdContext_method(ctx). Doing only
// one deref passes the kd-handle struct pointer instead of the actual
// engine state pointer; fp_sample_decrypt doesn't error but the
// produced plaintext is garbage (audio plays back unplayable).
void** kd_pp = s.SVFootHillPContext_kdContext(sv_ctx.obj);
if (kd_pp == nullptr || *kd_pp == nullptr) {
*error = "kdContext is null";
return attempt;
}
kd = *reinterpret_cast<void**>(*kd_pp);
if (kd == nullptr) {
*error = "kdContext inner pointer is null";
return attempt;
}
store_cached_kd(adam_id, key_uri, kd);
// Intentionally no shared_ptr dtors — see block comment above.
(void)persist;
(void)sv_ctx;
}
attempt.plaintexts.reserve(chunks.size());
std::fprintf(stderr, "decrypt: fp_sample_decrypt samples=%zu\n", chunks.size());
for (auto& chunk : chunks) {
if (chunk.empty()) {
*error = "empty sample";
attempt.plaintexts.clear();
return attempt;
}
const long status = s.fp_sample_decrypt(kd, 5u, chunk.data(), chunk.data(), chunk.size());
if (status < 0) {
*error = "FairPlay sample decrypt failed status=" + std::to_string(status);
std::fprintf(stderr, "decrypt: fp_sample_decrypt failed status=%ld\n", status);
attempt.plaintexts.clear();
return attempt;
}
attempt.plaintexts.push_back(std::move(chunk));
}
attempt.ok = true;
return attempt;
};
std::string first_error;
try {
out = decrypt_once(true, ciphertexts, &first_error);
} catch (const std::exception& e) {
first_error = e.what();
} catch (...) {
first_error = "native FairPlay decrypt threw an unknown exception";
}
if (out.ok) return out;
erase_cached_kd(adam_id, key_uri);
out.error = "FairPlay decrypt failed";
if (!first_error.empty()) out.error += " (first: " + first_error + ")";
return out;
}
} // namespace wrapper::apple
@@ -0,0 +1,29 @@
// FairPlay FPS sample decryption (POST /decrypt). Mirrors upstream
// wrapper main.c:getKdContext + NfcRKVnxuKZy04KWbdFu71Ou.
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace wrapper::apple {
class Loader;
class Runtime;
struct DecryptResult {
bool ok = false;
std::string error;
std::vector<std::vector<std::uint8_t>> plaintexts;
};
// Decrypt one or more ciphertext samples for the same (adam_id, uri) key.
// Caller must hold runtime.playback_mutex() for the Apple calls (server does).
DecryptResult decrypt_samples(const Loader& loader,
Runtime& runtime,
std::string adam_id,
std::string key_uri,
std::vector<std::vector<std::uint8_t>> ciphertexts);
} // namespace wrapper::apple
@@ -0,0 +1,4 @@
// Embedded Apple FPS leaf certificate blob (from upstream import.h).
#pragma once
inline constexpr const char kFairPlayCert[] =
"MIIEzjCCA7agAwIBAgIIAXAVjHFZDjgwDQYJKoZIhvcNAQEFBQAwfzELMAkGA1UEBhMCVVMxEzARBgNVBAoMCkFwcGxlIEluYy4xJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MTMwMQYDVQQDDCpBcHBsZSBLZXkgU2VydmljZXMgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTIwNzI1MTgwMjU4WhcNMTQwNzI2MTgwMjU4WjAwMQswCQYDVQQGEwJVUzESMBAGA1UECgwJQXBwbGUgSW5jMQ0wCwYDVQQDDARGUFMxMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCqZ9IbMt0J0dTKQN4cUlfeQRY9bcnbnP95HFv9A16Yayh4xQzRLAQqVSmisZtBK2/nawZcDmcs+XapBojRb+jDM4Dzk6/Ygdqo8LoA+BE1zipVyalGLj8Y86hTC9QHX8i05oWNCDIlmabjjWvFBoEOk+ezOAPg8c0SET38x5u+TwIDAQABo4ICHzCCAhswHQYDVR0OBBYEFPP6sfTWpOQ5Sguf5W3Y0oibbEc3MAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUY+RHVMuFcVlGLIOszEQxZGcDLL4wgeIGA1UdIASB2jCB1zCB1AYJKoZIhvdjZAUBMIHGMIHDBggrBgEFBQcCAjCBtgyBs1JlbGlhbmNlIG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBjb25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZpY2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMDUGA1UdHwQuMCwwKqAooCaGJGh0dHA6Ly9jcmwuYXBwbGUuY29tL2tleXNlcnZpY2VzLmNybDAOBgNVHQ8BAf8EBAMCBSAwFAYLKoZIhvdjZAYNAQUBAf8EAgUAMBsGCyqGSIb3Y2QGDQEGAQH/BAkBAAAAAQAAAAEwKQYLKoZIhvdjZAYNAQMBAf8EFwF+bjsY57ASVFmeehD2bdu6HLGBxeC2MEEGCyqGSIb3Y2QGDQEEAQH/BC8BHrKviHJf/Se/ibc7T0/55Bt1GePzaYBVfgF3ZiNuV93z8P3qsawAqAXzzh9o5DANBgkqhkiG9w0BAQUFAAOCAQEAVGyCtuLYcYb/aPijBCtaemxuV0IokXJn3EgmwYHZynaR6HZmeGRUp9p3f8EXu6XPSekKCCQi+a86hXX9RfnGEjRdvtP+jts5MDSKuUIoaqce8cLX2dpUOZXdf3lR0IQM0kXHb5boNGBsmbTLVifqeMsexfZryGw2hE/4WDOJdGQm1gMJZU4jP1b/HSLNIUhHWAaMeWtcJTPRBucR4urAtvvtOWD88mriZNHG+veYw55b+qA36PSqDPMbku9xTY7fsMa6mxIRmwULQgi8nOk1wNhw3ZO0qUKtaCO3gSqWdloecxpxUQSZCSW7tWPkpXXwDZqegUkij9xMFS1pr37RIjCCBVAwggQ4oAMCAQICEEVKuaGraq1Cp4z6TFOeVfUwDQYJKoZIhvcNAQELBQAwUDEsMCoGA1UEAwwjQXBwbGUgRlAgU2VydmljZSBFbmFibGUgUlNBIENBIC0gRzExEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMB4XDTIwMDQwNzIwMjY0NFoXDTIyMDQwNzIwMjY0NFowWjEhMB8GA1UEAwwYZnBzMjA0OC5pdHVuZXMuYXBwbGUuY29tMRMwEQYDVQQLDApBcHBsZSBJbmMuMRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJNoUHuTRLafofQgIRgGa2TFIf+bsFDMjs+y3Ep1xCzFLE4QbnwG6OG0duKUl5IoGUsouzZk9iGsXz5k3ESLOWKz2BFrDTvGrzAcuLpH66jJHGsk/l+ZzsDOJaoQ22pu0JvzYzW8/yEKvpE6JF/2dsC6V9RDTri3VWFxrl5uh8czzncoEQoRcQsSatHzs4tw/QdHFtBIigqxqr4R7XiCaHbsQmqbP9h7oxRs/6W/DDA2BgkuFY1ocX/8dTjmH6szKPfGt3KaYCwy3fuRC+FibTyohtvmlXsYhm7AUzorwWIwN/MbiFQ0OHHtDomIy71wDcTNMnY0jZYtGmIlJETAgYcCAwEAAaOCAhowggIWMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUrI/yBkpV623/IeMrXzs8fC7VkZkwRQYIKwYBBQUHAQEEOTA3MDUGCCsGAQUFBzABhilodHRwOi8vb2NzcC5hcHBsZS5jb20vb2NzcDAzLWZwc3J2cnNhZzEwMzCBwwYDVR0gBIG7MIG4MIG1BgkqhkiG92NkBQEwgacwgaQGCCsGAQUFBwICMIGXDIGUUmVsaWFuY2Ugb24gdGhpcyBjZXJ0aWZpY2F0ZSBieSBhbnkgcGFydHkgYXNzdW1lcyBhY2NlcHRhbmNlIG9mIGFueSBhcHBsaWNhYmxlIHRlcm1zIGFuZCBjb25kaXRpb25zIG9mIHVzZSBhbmQvb3IgY2VydGlmaWNhdGlvbiBwcmFjdGljZSBzdGF0ZW1lbnRzLjAdBgNVHQ4EFgQU2RpCSSHFXeoZQQWxbwJuRZ9RrIEwDgYDVR0PAQH/BAQDAgUgMBQGCyqGSIb3Y2QGDQEFAQH/BAIFADAjBgsqhkiG92NkBg0BBgEB/wQRAQAAAAMAAAABAAAAAgAAAAMwOQYLKoZIhvdjZAYNAQMBAf8EJwG+pUeWbeZBUI0PikyFwSggL5dHaeugSDoQKwcP28csLuh5wplpATAzBgsqhkiG92NkBg0BBAEB/wQhAfl9TGjP/UY9TyQzYsn8sX9ZvHChok9QrrUhtAyWR1yCMA0GCSqGSIb3DQEBCwUAA4IBAQBNMzZ6llQ0laLXsrmyVieuoW9+pHeAaDJ7cBiQLjM3ZdIO3Gq5dkbWYYYwJwymdxZ74WGZMuVv3ueJKcxG1jAhCRhr0lb6QaPaQQSNW+xnoesb3CLA0RzrcgBp/9WFZNdttJOSyC93lQmiE0r5RqPpe/IWUzwoZxri8qnsghVFxCBEcMB+U4PJR8WeAkPrji8po2JLYurvgNRhGkDKcAFPuGEpXdF86hPts+07zazsP0fBjBSVgP3jqb8G31w5W+O+wBW0B9uCf3s0vXU4LuJTAywws2ImZ7O/AaY/uXWOyIUMUKPgL1/QJieB7pBoENIJ2CeJS2M3iv00ssmCmTEJ";
@@ -0,0 +1,450 @@
#include "apple/loader.hpp"
#include "apple/aarch64_sret_thunks.hpp"
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <dlfcn.h>
namespace wrapper::apple {
namespace {
// Mirrors WRAPPER_RUNTIME_TRACE in runtime.cpp: when set to any non-empty
// non-"0" value, the loader prints a single stderr line before each dlopen /
// dlsym group so a SIGSEGV inside DT_INIT_ARRAY (or a missing symbol) can be
// localized to the exact library group. Stays off by default since the
// per-call tracing is verbose.
bool loader_trace_enabled() {
static const bool v = []() {
const char* x = std::getenv("WRAPPER_RUNTIME_TRACE");
if (x == nullptr || *x == '\0') return false;
if (std::strcmp(x, "0") == 0) return false;
return true;
}();
return v;
}
void trace(const char* stage) {
if (!loader_trace_enabled()) return;
std::fprintf(stderr, "loader: trace %s\n", stage);
std::fflush(stderr);
}
// Helper: dlopen with friendlier error reporting.
void* open_lib(const std::string& path, std::string* err_out) {
if (loader_trace_enabled()) {
std::fprintf(stderr, "loader: dlopen %s ...\n", path.c_str());
std::fflush(stderr);
}
void* h = dlopen(path.c_str(), RTLD_NOW | RTLD_GLOBAL);
if (h == nullptr) {
const char* msg = dlerror();
if (err_out != nullptr) {
*err_out = "dlopen(" + path + "): " + (msg ? msg : "unknown error");
}
std::fprintf(stderr, "loader: %s\n", err_out ? err_out->c_str() : "");
} else if (loader_trace_enabled()) {
std::fprintf(stderr, "loader: dlopen %s -> %p\n", path.c_str(), h);
std::fflush(stderr);
}
return h;
}
void* open_lib_optional(const std::string& path) {
if (loader_trace_enabled()) {
std::fprintf(stderr, "loader: dlopen(optional) %s ...\n", path.c_str());
std::fflush(stderr);
}
void* h = dlopen(path.c_str(), RTLD_NOW | RTLD_GLOBAL);
if (h == nullptr) {
const char* msg = dlerror();
std::fprintf(stderr, "loader: optional dlopen %s: %s\n", path.c_str(),
msg ? msg : "?");
} else if (loader_trace_enabled()) {
std::fprintf(stderr, "loader: dlopen(optional) %s -> %p\n", path.c_str(), h);
std::fflush(stderr);
}
return h;
}
// Helper: dlsym with friendlier error reporting. Handle may be a real
// dlopen result OR RTLD_DEFAULT (which on bionic/x86_64 is the literal
// value 0 - so a null-check on the handle here would be wrong).
template <typename T>
bool resolve(void* h, const char* name, T* out, std::string* err_out) {
dlerror();
void* sym = dlsym(h, name);
const char* msg = dlerror();
if (msg != nullptr || sym == nullptr) {
if (err_out) {
*err_out = std::string("dlsym(") + name + "): " + (msg ? msg : "not found");
}
std::fprintf(stderr, "loader: %s\n", err_out ? err_out->c_str() : "");
return false;
}
*out = reinterpret_cast<T>(sym);
return true;
}
bool resolve_vtable(const char* name, void*** out, std::string* err_out) {
dlerror();
void* sym = dlsym(RTLD_DEFAULT, name);
const char* msg = dlerror();
if (sym == nullptr || msg != nullptr) {
if (err_out) {
*err_out = std::string("dlsym(") + name + "): " + (msg ? msg : "not found");
}
std::fprintf(stderr, "loader: %s\n", err_out ? err_out->c_str() : "");
return false;
}
*out = reinterpret_cast<void**>(sym);
return true;
}
void clear_fairplay_symbols(Symbols* s) {
s->SVPlaybackLeaseManager_ctor = nullptr;
s->SVPlaybackLeaseManager_refreshLeaseAutomatically = nullptr;
s->SVPlaybackLeaseManager_requestLease = nullptr;
s->SVFootHillSessionCtrl_instance = nullptr;
s->SVFootHillSessionCtrl_decryptContext = nullptr;
s->SVFootHillPContext_kdContext = nullptr;
s->fp_sample_decrypt = nullptr;
s->SVFootHillSessionCtrl_resetAllContexts = nullptr;
s->shared_ptr_SVFootHillPContext_dtor = nullptr;
}
} // namespace
bool Loader::open(const std::string& libs_dir) {
if (ok_) return true;
auto load = [&](const std::string& path, void** dest) {
*dest = open_lib(path, &last_error_);
return *dest != nullptr;
};
// libc++_shared.so is already a DT_NEEDED of the daemon; do not dlopen.
//
// Pre-load FairPlay helpers first so SVFootHill symbols resolve (some
// builds only export the chain once CoreFP/CoreLSKD are in the process).
trace("dlopen libCoreFP.so (optional)");
h_libcorefp_ = open_lib_optional(libs_dir + "/libCoreFP.so");
trace("dlopen libCoreLSKD.so (optional)");
h_libcorelskd_ = open_lib_optional(libs_dir + "/libCoreLSKD.so");
// Match upstream CMake link order: androidappmusic, storeservicescore,
// mediaplatform (after cxx). Dependencies still resolve transitively.
trace("dlopen libandroidappmusic.so");
if (!load(libs_dir + "/libandroidappmusic.so", &h_libandroidappmusic_)) {
return false;
}
trace("dlopen libstoreservicescore.so");
if (!load(libs_dir + "/libstoreservicescore.so", &h_libstoreservicescore_)) {
return false;
}
trace("dlopen libmediaplatform.so");
if (!load(libs_dir + "/libmediaplatform.so", &h_libmediaplatform_)) {
return false;
}
trace("all core libs loaded; starting symbol resolution");
using namespace abi;
if (!resolve(RTLD_DEFAULT,
mangled::resolv_set_nameservers_for_net,
&symbols_.resolv_set_nameservers_for_net, &last_error_)) {
return false;
}
if (!resolve_vtable(mangled::vtable_RequestContextConfig,
&symbols_.vtable_RequestContextConfig, &last_error_)) {
return false;
}
if (!resolve_vtable(mangled::vtable_CredentialsResponse,
&symbols_.vtable_CredentialsResponse, &last_error_)) {
return false;
}
if (!resolve_vtable(mangled::vtable_ProtocolDialogResponse,
&symbols_.vtable_ProtocolDialogResponse, &last_error_)) {
return false;
}
if (!resolve_vtable(mangled::vtable_HTTPMessage,
&symbols_.vtable_HTTPMessage, &last_error_)) {
return false;
}
#define RESOLVE(field, name) \
if (!resolve(RTLD_DEFAULT, mangled::name, &symbols_.field, &last_error_)) return false
RESOLVE(FootHillConfig_config, FootHillConfig_config);
RESOLVE(DeviceGUID_instance, DeviceGUID_instance);
RESOLVE(DeviceGUID_configure, DeviceGUID_configure);
RESOLVE(make_shared_RequestContext, make_shared_RequestContext);
RESOLVE(RequestContextConfig_ctor, RequestContextConfig_ctor);
RESOLVE(RCC_setBaseDirectoryPath, RCC_setBaseDirectoryPath);
RESOLVE(RCC_setClientIdentifier, RCC_setClientIdentifier);
RESOLVE(RCC_setVersionIdentifier, RCC_setVersionIdentifier);
RESOLVE(RCC_setPlatformIdentifier, RCC_setPlatformIdentifier);
RESOLVE(RCC_setProductVersion, RCC_setProductVersion);
RESOLVE(RCC_setDeviceModel, RCC_setDeviceModel);
RESOLVE(RCC_setBuildVersion, RCC_setBuildVersion);
RESOLVE(RCC_setLocaleIdentifier, RCC_setLocaleIdentifier);
RESOLVE(RCC_setLanguageIdentifier, RCC_setLanguageIdentifier);
RESOLVE(RequestContextManager_configure, RequestContextManager_configure);
RESOLVE(RequestContext_init, RequestContext_init);
RESOLVE(RequestContext_setFairPlayDirectoryPath, RequestContext_setFairPlayDirectoryPath);
RESOLVE(make_shared_AndroidPresentationInterface, make_shared_AndroidPresentationInterface);
RESOLVE(API_setCredentialsHandler, API_setCredentialsHandler);
RESOLVE(API_setDialogHandler, API_setDialogHandler);
RESOLVE(API_handleCredentialsResponse, API_handleCredentialsResponse);
RESOLVE(API_handleProtocolDialogResponse, API_handleProtocolDialogResponse);
RESOLVE(RequestContext_setPresentationInterface, RequestContext_setPresentationInterface);
RESOLVE(ProtocolDialog_title, ProtocolDialog_title);
RESOLVE(ProtocolDialog_message, ProtocolDialog_message);
RESOLVE(ProtocolDialog_buttons, ProtocolDialog_buttons);
RESOLVE(ProtocolButton_title, ProtocolButton_title);
RESOLVE(ProtocolDialogResponse_ctor, ProtocolDialogResponse_ctor);
RESOLVE(ProtocolDialogResponse_setSelectedButton, ProtocolDialogResponse_setSelectedButton);
RESOLVE(CR_requiresHSA2VerificationCode, CR_requiresHSA2VerificationCode);
RESOLVE(CR_title, CR_title);
RESOLVE(CR_message, CR_message);
RESOLVE(CredentialsResponse_ctor, CredentialsResponse_ctor);
RESOLVE(CredentialsResponse_setUserName, CredentialsResponse_setUserName);
RESOLVE(CredentialsResponse_setPassword, CredentialsResponse_setPassword);
RESOLVE(CredentialsResponse_setResponseType, CredentialsResponse_setResponseType);
RESOLVE(make_shared_AuthenticateFlow, make_shared_AuthenticateFlow);
RESOLVE(AuthenticateFlow_run, AuthenticateFlow_run);
RESOLVE(AuthenticateFlow_response, AuthenticateFlow_response);
RESOLVE(AR_responseType, AR_responseType);
RESOLVE(AR_customerMessage, AR_customerMessage);
RESOLVE(AR_error, AR_error);
RESOLVE(SEC_errorCode, SEC_errorCode);
RESOLVE(SEC_what, SEC_what);
RESOLVE(DeviceGUID_guid, DeviceGUID_guid);
RESOLVE(Data_bytes, Data_bytes);
RESOLVE(HTTPMessage_ctor, HTTPMessage_ctor);
// C1 is optional: arm64 builds may need the complete-object ctor
// when HTTPMessage has virtual bases. If it’s absent we fall back to C2.
{
dlerror();
void* sym = dlsym(RTLD_DEFAULT, abi::mangled::HTTPMessage_ctor_c1);
if (sym != nullptr && dlerror() == nullptr) {
symbols_.HTTPMessage_ctor_c1 = reinterpret_cast<abi::fn_HTTPMessage_ctor_c1>(sym);
}
}
RESOLVE(HTTPMessage_setHeader, HTTPMessage_setHeader);
RESOLVE(HTTPMessage_setBodyData, HTTPMessage_setBodyData);
RESOLVE(URLRequest_ctor, URLRequest_ctor);
RESOLVE(URLRequest_setRequestParameter, URLRequest_setRequestParameter);
RESOLVE(URLRequest_run, URLRequest_run);
RESOLVE(URLRequest_error, URLRequest_error);
RESOLVE(URLRequest_response, URLRequest_response);
RESOLVE(URLResponse_underlyingResponse, URLResponse_underlyingResponse);
// ---- PurchaseRequest + protocolDictionary ----
RESOLVE(PurchaseRequest_ctor, PurchaseRequest_ctor);
RESOLVE(PurchaseRequest_setProcessDialogActions, PurchaseRequest_setProcessDialogActions);
RESOLVE(PurchaseRequest_setURLBagKey, PurchaseRequest_setURLBagKey);
RESOLVE(PurchaseRequest_setBuyParameters, PurchaseRequest_setBuyParameters);
RESOLVE(PurchaseRequest_run, PurchaseRequest_run);
RESOLVE(PurchaseRequest_response, PurchaseRequest_response);
RESOLVE(PurchaseResponse_error, PurchaseResponse_error);
RESOLVE(PurchaseResponse_items, PurchaseResponse_items);
RESOLVE(PurchaseItem_dictionary, PurchaseItem_dictionary);
RESOLVE(URLRequest_setURLResponsePreprocessor, URLRequest_setURLResponsePreprocessor);
RESOLVE(URLResponse_protocolDictionary, URLResponse_protocolDictionary);
RESOLVE(RequestContext_storeFrontIdentifier, RequestContext_storeFrontIdentifier);
#undef RESOLVE
// CoreFoundation symbols are plain C, name = symbol. They are loaded
// transitively (libCoreFoundation.so is a DT_NEEDED of libstoreservicescore.so);
// RTLD_DEFAULT picks them up.
if (!resolve(RTLD_DEFAULT, "CFRetain",
&symbols_.CFRetain, &last_error_)) return false;
if (!resolve(RTLD_DEFAULT, "CFRelease",
&symbols_.CFRelease, &last_error_)) return false;
if (!resolve(RTLD_DEFAULT, "CFDataGetBytePtr",
&symbols_.CFDataGetBytePtr, &last_error_)) return false;
if (!resolve(RTLD_DEFAULT, "CFDataGetLength",
&symbols_.CFDataGetLength, &last_error_)) return false;
if (!resolve(RTLD_DEFAULT, "CFPropertyListCreateData",
&symbols_.CFPropertyListCreateData, &last_error_)) return false;
if (!resolve(RTLD_DEFAULT, "CFStringCreateWithCString",
&symbols_.CFStringCreateWithCString, &last_error_)) return false;
if (!resolve(RTLD_DEFAULT, "CFArrayCreate",
&symbols_.CFArrayCreate, &last_error_)) return false;
if (!resolve(RTLD_DEFAULT, "CFDictionaryCreate",
&symbols_.CFDictionaryCreate, &last_error_)) return false;
// CF callback structs are exported as data symbols; dlsym returns the
// address of the global. Store as `const void*` and pass-through.
if (!resolve(RTLD_DEFAULT, "kCFTypeArrayCallBacks",
&symbols_.kCFTypeArrayCallBacks, &last_error_)) return false;
if (!resolve(RTLD_DEFAULT, "kCFTypeDictionaryKeyCallBacks",
&symbols_.kCFTypeDictionaryKeyCallBacks, &last_error_)) return false;
if (!resolve(RTLD_DEFAULT, "kCFTypeDictionaryValueCallBacks",
&symbols_.kCFTypeDictionaryValueCallBacks, &last_error_)) return false;
// CF type-introspection symbols (powering the CF->JSON walker).
#define RESOLVE_CF(name) \
if (!resolve(RTLD_DEFAULT, #name, &symbols_.name, &last_error_)) return false
RESOLVE_CF(CFGetTypeID);
RESOLVE_CF(CFStringGetTypeID);
RESOLVE_CF(CFNumberGetTypeID);
RESOLVE_CF(CFBooleanGetTypeID);
RESOLVE_CF(CFArrayGetTypeID);
RESOLVE_CF(CFDictionaryGetTypeID);
RESOLVE_CF(CFDataGetTypeID);
RESOLVE_CF(CFDateGetTypeID);
RESOLVE_CF(CFNullGetTypeID);
RESOLVE_CF(CFStringGetCStringPtr);
RESOLVE_CF(CFStringGetLength);
RESOLVE_CF(CFStringGetCString);
RESOLVE_CF(CFStringGetMaximumSizeForEncoding);
RESOLVE_CF(CFNumberGetType);
RESOLVE_CF(CFNumberGetValue);
RESOLVE_CF(CFNumberIsFloatType);
RESOLVE_CF(CFBooleanGetValue);
RESOLVE_CF(CFArrayGetCount);
RESOLVE_CF(CFArrayGetValueAtIndex);
RESOLVE_CF(CFDictionaryGetCount);
RESOLVE_CF(CFDictionaryGetKeysAndValues);
RESOLVE_CF(CFDateGetAbsoluteTime);
#undef RESOLVE_CF
clear_fairplay_symbols(&symbols_);
std::string fp_err;
// Match zhaarey/apple-music-downloader agent.js: FootHill + lease symbols are
// resolved from libandroidappmusic.so exports. Try that handle first, then
// RTLD_DEFAULT (bionic/global lookup can miss symbols visible via the DSO).
auto resolve_fp = [&](const char* mangled_name, auto* out_slot) -> bool {
void* const handles[] = {
h_libandroidappmusic_,
h_libmediaplatform_,
h_libstoreservicescore_,
nullptr,
};
for (int i = 0; handles[i] != nullptr; ++i) {
if (resolve(handles[i], mangled_name, out_slot, &fp_err)) return true;
}
return resolve(RTLD_DEFAULT, mangled_name, out_slot, &fp_err);
};
#define RESOLVE_FP(field, name) fp_ok &= resolve_fp(mangled::name, &symbols_.field)
bool fp_ok = true;
fp_ok &= RESOLVE_FP(SVPlaybackLeaseManager_ctor, SVPlaybackLeaseManager_ctor);
fp_ok &= RESOLVE_FP(SVPlaybackLeaseManager_refreshLeaseAutomatically,
SVPlaybackLeaseManager_refreshLeaseAutomatically);
fp_ok &= RESOLVE_FP(SVPlaybackLeaseManager_requestLease, SVPlaybackLeaseManager_requestLease);
fp_ok &= RESOLVE_FP(SVFootHillSessionCtrl_instance, SVFootHillSessionCtrl_instance);
foot_hill_persistent_key_fn_ = nullptr;
foot_hill_persistent_key_abi8_ = false;
{
abi::fn_SVFootHillSessionCtrl_getPersistentKey pk8 = nullptr;
abi::fn_SVFootHillSessionCtrl_getPersistentKey7 pk7 = nullptr;
bool pk = false;
if (resolve_fp(mangled::SVFootHillSessionCtrl_getPersistentKey_8str, &pk8)) {
foot_hill_persistent_key_fn_ = reinterpret_cast<void*>(pk8);
foot_hill_persistent_key_abi8_ = true;
pk = true;
} else if (resolve_fp(mangled::SVFootHillSessionCtrl_getPersistentKey_7str, &pk7)) {
foot_hill_persistent_key_fn_ = reinterpret_cast<void*>(pk7);
foot_hill_persistent_key_abi8_ = false;
pk = true;
}
fp_ok &= pk;
}
fp_ok &= RESOLVE_FP(SVFootHillSessionCtrl_decryptContext,
SVFootHillSessionCtrl_decryptContext);
fp_ok &= RESOLVE_FP(SVFootHillPContext_kdContext, SVFootHillPContext_kdContext);
fp_ok &= RESOLVE_FP(fp_sample_decrypt, fp_sample_decrypt);
fp_ok &= RESOLVE_FP(SVFootHillSessionCtrl_resetAllContexts,
SVFootHillSessionCtrl_resetAllContexts);
fp_ok &= RESOLVE_FP(shared_ptr_SVFootHillPContext_dtor,
shared_ptr_SVFootHillPContext_dtor);
#undef RESOLVE_FP
if (foot_hill_persistent_key_fn_ != nullptr) {
trace(foot_hill_persistent_key_abi8_
? "FairPlay getPersistentKey ABI=8-string"
: "FairPlay getPersistentKey ABI=7-string");
}
if (!fp_ok) {
clear_fairplay_symbols(&symbols_);
foot_hill_persistent_key_fn_ = nullptr;
foot_hill_persistent_key_abi8_ = false;
fairplay_decrypt_available_ = false;
std::fprintf(stderr,
"loader: FairPlay decrypt symbols unavailable (%s); "
"POST /decrypt disabled\n",
fp_err.c_str());
} else {
fairplay_decrypt_available_ = true;
}
ok_ = true;
last_error_.clear();
return true;
}
void Loader::close() {
ok_ = false;
fairplay_decrypt_available_ = false;
symbols_ = Symbols{};
foot_hill_persistent_key_fn_ = nullptr;
foot_hill_persistent_key_abi8_ = false;
h_libstoreservicescore_ = nullptr;
h_libmediaplatform_ = nullptr;
h_libandroidappmusic_ = nullptr;
h_libcorefp_ = nullptr;
h_libcorelskd_ = nullptr;
}
void Loader::foot_hill_get_persistent_key(abi::shared_ptr* ret,
void* foothill_instance,
abi::std_string* adam_id,
abi::std_string* key_uri,
abi::std_string* key_format,
abi::std_string* key_format_ver,
abi::std_string* server_uri,
abi::std_string* protocol_type,
abi::std_string* fps_cert) const {
if (foot_hill_persistent_key_fn_ == nullptr) return;
if (foot_hill_persistent_key_abi8_) {
auto* fn = reinterpret_cast<abi::fn_SVFootHillSessionCtrl_getPersistentKey>(
foot_hill_persistent_key_fn_);
aarch64_sret::svfoot_get_persistent_key(
ret, foothill_instance, adam_id, adam_id, key_uri, key_format,
key_format_ver, server_uri, protocol_type, fps_cert, fn);
} else {
auto* fn = reinterpret_cast<abi::fn_SVFootHillSessionCtrl_getPersistentKey7>(
foot_hill_persistent_key_fn_);
aarch64_sret::svfoot_get_persistent_key_7str(
ret, foothill_instance, adam_id, key_uri, key_format,
key_format_ver, server_uri, protocol_type, fps_cert, fn);
}
}
} // namespace wrapper::apple
@@ -0,0 +1,254 @@
// Apple Music native-library loader.
//
// `Loader::open()` dlopens Apple's JNI stack (mediaplatform, storeservicescore,
// androidappmusic) and optional FairPlay helpers (CoreFP / CoreLSKD), then
// resolves mangled symbols into `Symbols`.
//
// The daemon ELF itself has *no* DT_NEEDED reference to Apple's libs -
// this is what lets us start the daemon in stub mode even when the libs
// have not been staged. If core libs are missing or a non-FairPlay
// symbol fails to resolve, `open()` returns false. FairPlay-only symbols
// may fail separately; see `fairplay_decrypt_available()`.
#pragma once
#include <string>
#include "apple/abi.hpp"
namespace wrapper::apple {
// Function-pointer table populated by Loader::open(). Core auth/token
// fields are non-null after ok()==true. FairPlay decrypt pointers (lease,
// FootHill, fp_sample_decrypt) are set only when fairplay_decrypt_available()
// is true; otherwise they may be null even if ok().
struct Symbols {
// Vtables for std::__shared_ptr_emplace<T, ...>. Stored as `void**`
// so callers can do the upstream-style pointer arithmetic (skip
// past type_info + dtor slots).
void** vtable_RequestContextConfig = nullptr;
void** vtable_CredentialsResponse = nullptr;
void** vtable_ProtocolDialogResponse = nullptr;
void** vtable_HTTPMessage = nullptr;
abi::fn_resolv_set_nameservers_for_net resolv_set_nameservers_for_net = nullptr;
abi::fn_FootHillConfig_config FootHillConfig_config = nullptr;
abi::fn_DeviceGUID_instance DeviceGUID_instance = nullptr;
abi::fn_DeviceGUID_configure DeviceGUID_configure = nullptr;
abi::fn_make_shared_RequestContext make_shared_RequestContext = nullptr;
abi::fn_RequestContextConfig_ctor RequestContextConfig_ctor = nullptr;
abi::fn_RCC_set_string RCC_setBaseDirectoryPath = nullptr;
abi::fn_RCC_set_string RCC_setClientIdentifier = nullptr;
abi::fn_RCC_set_string RCC_setVersionIdentifier = nullptr;
abi::fn_RCC_set_string RCC_setPlatformIdentifier = nullptr;
abi::fn_RCC_set_string RCC_setProductVersion = nullptr;
abi::fn_RCC_set_string RCC_setDeviceModel = nullptr;
abi::fn_RCC_set_string RCC_setBuildVersion = nullptr;
abi::fn_RCC_set_string RCC_setLocaleIdentifier = nullptr;
abi::fn_RCC_set_string RCC_setLanguageIdentifier = nullptr;
abi::fn_RequestContextManager_configure RequestContextManager_configure = nullptr;
abi::fn_RequestContext_init RequestContext_init = nullptr;
abi::fn_RCC_set_string RequestContext_setFairPlayDirectoryPath = nullptr;
// ---- AndroidPresentationInterface + auth flow ----
abi::fn_make_shared_AndroidPresentationInterface make_shared_AndroidPresentationInterface = nullptr;
abi::fn_API_setCredentialsHandler API_setCredentialsHandler = nullptr;
abi::fn_API_setDialogHandler API_setDialogHandler = nullptr;
abi::fn_API_handleCredentialsResponse API_handleCredentialsResponse = nullptr;
abi::fn_API_handleProtocolDialogResponse API_handleProtocolDialogResponse = nullptr;
abi::fn_RequestContext_setPresentationInterface RequestContext_setPresentationInterface = nullptr;
abi::fn_ProtocolDialog_title ProtocolDialog_title = nullptr;
abi::fn_ProtocolDialog_message ProtocolDialog_message = nullptr;
abi::fn_ProtocolDialog_buttons ProtocolDialog_buttons = nullptr;
abi::fn_ProtocolButton_title ProtocolButton_title = nullptr;
abi::fn_ProtocolDialogResponse_ctor ProtocolDialogResponse_ctor = nullptr;
abi::fn_ProtocolDialogResponse_setSelectedButton ProtocolDialogResponse_setSelectedButton = nullptr;
abi::fn_CR_requiresHSA2VerificationCode CR_requiresHSA2VerificationCode = nullptr;
abi::fn_CR_title CR_title = nullptr;
abi::fn_CR_message CR_message = nullptr;
abi::fn_CredentialsResponse_ctor CredentialsResponse_ctor = nullptr;
abi::fn_CredentialsResponse_set_string CredentialsResponse_setUserName = nullptr;
abi::fn_CredentialsResponse_set_string CredentialsResponse_setPassword = nullptr;
abi::fn_CredentialsResponse_setResponseType CredentialsResponse_setResponseType = nullptr;
abi::fn_make_shared_AuthenticateFlow make_shared_AuthenticateFlow = nullptr;
abi::fn_AuthenticateFlow_run AuthenticateFlow_run = nullptr;
abi::fn_AuthenticateFlow_response AuthenticateFlow_response = nullptr;
abi::fn_AR_responseType AR_responseType = nullptr;
abi::fn_AR_customerMessage AR_customerMessage = nullptr;
abi::fn_AR_error AR_error = nullptr;
abi::fn_SEC_errorCode SEC_errorCode = nullptr;
abi::fn_SEC_what SEC_what = nullptr;
// ---- token harvest ----
abi::fn_DeviceGUID_guid DeviceGUID_guid = nullptr;
abi::fn_Data_bytes Data_bytes = nullptr;
abi::fn_HTTPMessage_ctor HTTPMessage_ctor = nullptr;
abi::fn_HTTPMessage_ctor_c1 HTTPMessage_ctor_c1 = nullptr; // arm64 fallback
abi::fn_HTTPMessage_setHeader HTTPMessage_setHeader = nullptr;
abi::fn_HTTPMessage_setBodyData HTTPMessage_setBodyData = nullptr;
abi::fn_URLRequest_ctor URLRequest_ctor = nullptr;
abi::fn_URLRequest_setRequestParameter URLRequest_setRequestParameter = nullptr;
abi::fn_URLRequest_run URLRequest_run = nullptr;
abi::fn_URLRequest_error URLRequest_error = nullptr;
abi::fn_URLRequest_response URLRequest_response = nullptr;
abi::fn_URLResponse_underlyingResponse URLResponse_underlyingResponse = nullptr;
// ---- PurchaseRequest / protocolDictionary / CoreFoundation ----
abi::fn_PurchaseRequest_ctor PurchaseRequest_ctor = nullptr;
abi::fn_PurchaseRequest_setProcessDialogActions PurchaseRequest_setProcessDialogActions = nullptr;
abi::fn_PurchaseRequest_set_string PurchaseRequest_setURLBagKey = nullptr;
abi::fn_PurchaseRequest_set_string PurchaseRequest_setBuyParameters = nullptr;
abi::fn_PurchaseRequest_run PurchaseRequest_run = nullptr;
abi::fn_PurchaseRequest_response PurchaseRequest_response = nullptr;
abi::fn_PurchaseResponse_error PurchaseResponse_error = nullptr;
abi::fn_PurchaseResponse_items PurchaseResponse_items = nullptr;
abi::fn_PurchaseItem_dictionary PurchaseItem_dictionary = nullptr;
abi::fn_URLRequest_setURLResponsePreprocessor URLRequest_setURLResponsePreprocessor = nullptr;
abi::fn_URLResponse_protocolDictionary URLResponse_protocolDictionary = nullptr;
// CoreFoundation: we serialize a CFDictionary* (either the URLResponse's
// protocolDictionary, or a synthetic dict we build from PurchaseItem
// dictionaries when the preprocessor hook doesn't fire) back to XML
// plist bytes for the HTTP response. CFRelease drops the retained refs.
// These are plain C symbols, no mangling.
void* (*CFRetain)(const void* cf) = nullptr;
void (*CFRelease)(const void* cf) = nullptr;
const unsigned char* (*CFDataGetBytePtr)(const void* data) = nullptr;
long (*CFDataGetLength)(const void* data) = nullptr;
// CFPropertyListCreateData(allocator, plist, format, options, error)
// format=100 is kCFPropertyListXMLFormat_v1_0.
void* (*CFPropertyListCreateData)(void* alloc, void* plist,
long format, unsigned long options,
void* error) = nullptr;
// Container builders for the synthetic root dict when /playback has
// to fall back from the preprocessor hook to PurchaseResponse::items().
void* (*CFStringCreateWithCString)(void* alloc, const char* cstr,
unsigned long encoding) = nullptr;
void* (*CFArrayCreate)(void* alloc, const void* const* values,
long numValues, const void* callbacks) = nullptr;
void* (*CFDictionaryCreate)(void* alloc, const void* const* keys,
const void* const* values, long numValues,
const void* key_cb, const void* val_cb) = nullptr;
const void* kCFTypeArrayCallBacks = nullptr;
const void* kCFTypeDictionaryKeyCallBacks = nullptr;
const void* kCFTypeDictionaryValueCallBacks = nullptr;
// CF type introspection (used by the CF->JSON walker that powers
// GET /playback's body). On x86_64 CFTypeID is unsigned long.
unsigned long (*CFGetTypeID)(const void* cf) = nullptr;
unsigned long (*CFStringGetTypeID)() = nullptr;
unsigned long (*CFNumberGetTypeID)() = nullptr;
unsigned long (*CFBooleanGetTypeID)() = nullptr;
unsigned long (*CFArrayGetTypeID)() = nullptr;
unsigned long (*CFDictionaryGetTypeID)() = nullptr;
unsigned long (*CFDataGetTypeID)() = nullptr;
unsigned long (*CFDateGetTypeID)() = nullptr;
unsigned long (*CFNullGetTypeID)() = nullptr;
const char* (*CFStringGetCStringPtr)(const void* str,
unsigned long encoding) = nullptr;
long (*CFStringGetLength)(const void* str) = nullptr;
unsigned char (*CFStringGetCString)(const void* str, char* buf,
long buf_size,
unsigned long encoding) = nullptr;
long (*CFStringGetMaximumSizeForEncoding)(long length,
unsigned long enc) = nullptr;
long (*CFNumberGetType)(const void* num) = nullptr;
unsigned char (*CFNumberGetValue)(const void* num, long type,
void* out) = nullptr;
unsigned char (*CFNumberIsFloatType)(const void* num) = nullptr;
unsigned char (*CFBooleanGetValue)(const void* b) = nullptr;
long (*CFArrayGetCount)(const void* arr) = nullptr;
const void* (*CFArrayGetValueAtIndex)(const void* arr, long idx) = nullptr;
long (*CFDictionaryGetCount)(const void* d) = nullptr;
void (*CFDictionaryGetKeysAndValues)(const void* d,
const void** keys,
const void** values) = nullptr;
double (*CFDateGetAbsoluteTime)(const void* date) = nullptr;
abi::fn_RequestContext_storeFrontIdentifier RequestContext_storeFrontIdentifier = nullptr;
// ---- FairPlay decrypt ----
abi::fn_SVPlaybackLeaseManager_ctor SVPlaybackLeaseManager_ctor = nullptr;
abi::fn_SVPlaybackLeaseManager_refreshLeaseAutomatically SVPlaybackLeaseManager_refreshLeaseAutomatically = nullptr;
abi::fn_SVPlaybackLeaseManager_requestLease SVPlaybackLeaseManager_requestLease = nullptr;
abi::fn_SVFootHillSessionCtrl_instance SVFootHillSessionCtrl_instance = nullptr;
abi::fn_SVFootHillSessionCtrl_decryptContext SVFootHillSessionCtrl_decryptContext = nullptr;
abi::fn_SVFootHillPContext_kdContext SVFootHillPContext_kdContext = nullptr;
abi::fn_fp_sample_decrypt fp_sample_decrypt = nullptr;
abi::fn_SVFootHillSessionCtrl_resetAllContexts SVFootHillSessionCtrl_resetAllContexts = nullptr;
abi::fn_shared_ptr_SVFootHillPContext_dtor shared_ptr_SVFootHillPContext_dtor = nullptr;
};
class Loader {
public:
Loader() = default;
Loader(const Loader&) = delete;
Loader& operator=(const Loader&) = delete;
// Try to dlopen Apple's libs from `libs_dir` (typically /system/lib64
// inside the chroot). On success, resolves all symbols into the
// internal Symbols table and returns true. On failure, leaves the
// loader in a closed state and returns false; subsequent ok() calls
// return false.
bool open(const std::string& libs_dir);
// Release all dlopen handles. Symbols become invalid.
void close();
bool ok() const { return ok_; }
// True when FairPlay decrypt dlsyms all succeeded (lease + FootHill + sample op).
bool fairplay_decrypt_available() const { return fairplay_decrypt_available_; }
const Symbols& sym() const { return symbols_; }
// Last failure description (path of the lib, symbol name, dlerror()).
// Empty if no failure has been recorded.
const std::string& last_error() const { return last_error_; }
// SVFootHillSessionCtrl::getPersistentKey — two possible ABIs; resolved in
// open(), dispatched with AArch64 sret thunks in loader.cpp.
void foot_hill_get_persistent_key(abi::shared_ptr* ret,
void* foothill_instance,
abi::std_string* adam_id,
abi::std_string* key_uri,
abi::std_string* key_format,
abi::std_string* key_format_ver,
abi::std_string* server_uri,
abi::std_string* protocol_type,
abi::std_string* fps_cert) const;
private:
void* h_libstoreservicescore_ = nullptr;
void* h_libmediaplatform_ = nullptr;
void* h_libandroidappmusic_ = nullptr;
void* h_libcorefp_ = nullptr;
void* h_libcorelskd_ = nullptr;
Symbols symbols_;
bool ok_{false};
bool fairplay_decrypt_available_{false};
std::string last_error_;
void* foot_hill_persistent_key_fn_ = nullptr;
bool foot_hill_persistent_key_abi8_ = false;
};
} // namespace wrapper::apple
@@ -0,0 +1,384 @@
#include "apple/playback.hpp"
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <functional>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
#include "apple/abi.hpp"
#include "apple/aarch64_sret_thunks.hpp"
#include "apple/loader.hpp"
#include "apple/runtime.hpp"
namespace wrapper::apple {
namespace {
// Forward declaration so we can construct a libc++ std::function whose
// mangled signature contains storeservicescore::URLResponse. Apple ships
// the same libc++ runtime as the NDK we cross-compile with, so the
// std::function and std::shared_ptr we build here are binary-compatible
// with the one URLRequest::setURLResponsePreprocessor expects.
struct URLResponseFwd;
using URLResponseSp = std::shared_ptr<URLResponseFwd>;
using URLRespPreprocFn = std::function<void(const URLResponseSp&)>;
constexpr unsigned long kCFStringEncodingUTF8 = 0x08000100;
constexpr long kCFNumberSInt64Type = 4;
constexpr long kCFNumberFloat64Type = 6;
// CFAbsoluteTime is seconds since 2001-01-01 00:00:00 UTC. Unix epoch
// (1970-01-01) is 978307200 seconds earlier.
constexpr double kCFAbsoluteTimeIntervalSince1970 = 978307200.0;
// Standard base64 alphabet (no URL-safe variant). Used to encode CFData
// payloads so the JSON document stays string-friendly.
std::string base64_encode(const unsigned char* data, std::size_t len) {
static const char tbl[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string ret;
ret.reserve(((len + 2) / 3) * 4);
unsigned int val = 0;
int valb = -6;
for (std::size_t i = 0; i < len; ++i) {
val = (val << 8) + data[i];
valb += 8;
while (valb >= 0) {
ret.push_back(tbl[(val >> valb) & 0x3F]);
valb -= 6;
}
}
if (valb > -6) {
ret.push_back(tbl[((val << 8) >> (valb + 8)) & 0x3F]);
}
while (ret.size() % 4 != 0) ret.push_back('=');
return ret;
}
// Read a CFString out as UTF-8. Tries the fast pointer-aliased path first
// (CFStringGetCStringPtr) and falls back to a copy via CFStringGetCString
// for strings whose internal encoding is not UTF-8.
std::string read_cf_string(const Symbols& s, const void* str) {
if (str == nullptr) return std::string();
const char* fast = s.CFStringGetCStringPtr(str, kCFStringEncodingUTF8);
if (fast != nullptr) return std::string(fast);
long len = s.CFStringGetLength(str);
if (len <= 0) return std::string();
long max = s.CFStringGetMaximumSizeForEncoding(len, kCFStringEncodingUTF8) + 1;
std::vector<char> buf(static_cast<std::size_t>(max), 0);
if (s.CFStringGetCString(str, buf.data(), max, kCFStringEncodingUTF8) == 0) {
return std::string();
}
return std::string(buf.data());
}
// Recursive CFPropertyList -> nlohmann::json walker. Handles the seven
// PList types Apple actually uses in its store responses; anything else
// falls through to JSON null. CFData is base64-encoded; CFDate is
// rendered as ISO-8601 UTC.
nlohmann::json cf_to_json(const Symbols& s, const void* cf) {
if (cf == nullptr) return nullptr;
const unsigned long tid = s.CFGetTypeID(cf);
if (tid == s.CFDictionaryGetTypeID()) {
nlohmann::json obj = nlohmann::json::object();
long n = s.CFDictionaryGetCount(cf);
if (n > 0) {
std::vector<const void*> keys(static_cast<std::size_t>(n));
std::vector<const void*> vals(static_cast<std::size_t>(n));
s.CFDictionaryGetKeysAndValues(cf, keys.data(), vals.data());
for (long i = 0; i < n; ++i) {
if (keys[i] == nullptr) continue;
if (s.CFGetTypeID(keys[i]) != s.CFStringGetTypeID()) {
// Apple's MZ-protocol plists only ever use string keys;
// skipping a non-string key is the closest sane mapping.
continue;
}
std::string k = read_cf_string(s, keys[i]);
if (k.empty()) continue;
obj[k] = cf_to_json(s, vals[i]);
}
}
return obj;
}
if (tid == s.CFArrayGetTypeID()) {
nlohmann::json arr = nlohmann::json::array();
long n = s.CFArrayGetCount(cf);
for (long i = 0; i < n; ++i) {
const void* el = s.CFArrayGetValueAtIndex(cf, i);
arr.push_back(cf_to_json(s, el));
}
return arr;
}
if (tid == s.CFStringGetTypeID()) {
return read_cf_string(s, cf);
}
if (tid == s.CFNumberGetTypeID()) {
if (s.CFNumberIsFloatType(cf) != 0) {
double v = 0;
s.CFNumberGetValue(cf, kCFNumberFloat64Type, &v);
return v;
}
std::int64_t v = 0;
s.CFNumberGetValue(cf, kCFNumberSInt64Type, &v);
return v;
}
if (tid == s.CFBooleanGetTypeID()) {
return s.CFBooleanGetValue(cf) != 0;
}
if (tid == s.CFDataGetTypeID()) {
const unsigned char* p = s.CFDataGetBytePtr(cf);
long n = s.CFDataGetLength(cf);
if (p == nullptr || n <= 0) return std::string();
return base64_encode(p, static_cast<std::size_t>(n));
}
if (tid == s.CFDateGetTypeID()) {
double abs_t = s.CFDateGetAbsoluteTime(cf);
double unix_secs = abs_t + kCFAbsoluteTimeIntervalSince1970;
std::time_t t = static_cast<std::time_t>(unix_secs);
std::tm tm_utc{};
#if defined(_WIN32)
gmtime_s(&tm_utc, &t);
#else
gmtime_r(&t, &tm_utc);
#endif
char buf[32];
std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm_utc);
return std::string(buf);
}
if (tid == s.CFNullGetTypeID()) {
return nullptr;
}
return nullptr;
}
// PurchaseItem is a thin wrapper: its ctor stores a retained CFDictionaryRef
// at object offset 0 (libstoreservicescore PurchaseItem(__CFDictionary const*)).
// In the pinned APK, PurchaseItem::dictionary() const is compiled as "return this"
// (disassembly: mov %rdi,%rax; ret) — not the plist pointer. CFGetTypeID(this)
// is undefined and often yields 0. The real dispatch plist is *(void**)item.
void* purchase_item_cfdictionary(const Symbols& s, void* item_obj) {
if (item_obj == nullptr) return nullptr;
void* from_member = *static_cast<void**>(item_obj);
if (s.PurchaseItem_dictionary != nullptr) {
void* ret = s.PurchaseItem_dictionary(item_obj);
if (ret != nullptr && ret != item_obj
&& s.CFGetTypeID(ret) == s.CFDictionaryGetTypeID()) {
return ret;
}
}
return from_member;
}
// Format-string buy parameters that match upstream wrapper main.c's
// get_m3u8_method_download. The "S" productType + "SUBS" pricing
// parameters are what tells the storefront we want the subscriber
// download dispatch (full plist with every variant), not the streaming
// HLS asset only.
std::string make_buy_parameters(const std::string& adam_id) {
// adam_id is validated as digits-only by the caller; concatenate
// directly to avoid sprintf format-string risk.
std::string out;
out.reserve(64 + adam_id.size());
out.append("salableAdamId=");
out.append(adam_id);
out.append("&price=0&pricingParameters=SUBS&productType=S");
return out;
}
} // namespace
PlaybackResult fetch_playback_json(const Loader& loader,
Runtime& runtime,
std::string adam_id) {
PlaybackResult out;
if (!loader.ok()) {
out.error = "Apple libs not loaded";
return out;
}
if (!runtime.initialized()) {
out.error = "Apple runtime not initialized";
return out;
}
if (adam_id.empty()) {
out.error = "adam_id is required";
return out;
}
for (char c : adam_id) {
if (c < '0' || c > '9') {
out.error = "adam_id must be a numeric string";
return out;
}
}
const Symbols& s = loader.sym();
abi::shared_ptr req_ctx = runtime.request_ctx_copy();
if (req_ctx.obj == nullptr) {
out.error = "RequestContext unavailable";
return out;
}
// Captured dict + mutex outlive the lambda copies that std::function
// makes; the lambda holds them by pointer so all copies share state.
struct Capture {
std::mutex mu;
void* cf_dict = nullptr; // CFDictionaryRef (retained)
int hits = 0;
};
Capture cap;
URLRespPreprocFn preproc = [cap_ptr = &cap, &s](const URLResponseSp& sp) {
void* url_resp = static_cast<void*>(sp.get());
if (url_resp == nullptr) return;
if (s.URLResponse_protocolDictionary == nullptr) return;
void* dict = s.URLResponse_protocolDictionary(url_resp);
if (dict == nullptr) return;
// CFRetain so the dict outlives Apple's URLResponse, which may be
// dropped immediately after the preprocessor returns.
void* retained = s.CFRetain(dict);
std::lock_guard<std::mutex> g(cap_ptr->mu);
if (cap_ptr->cf_dict != nullptr) {
// Multiple hits should not happen for a single PurchaseRequest
// run, but if it did we'd leak the previous; release first.
s.CFRelease(cap_ptr->cf_dict);
}
cap_ptr->cf_dict = retained != nullptr ? retained : dict;
++cap_ptr->hits;
};
// PurchaseRequest is heavy-ish; upstream uses malloc(1024) and never frees.
// Keep one 8 KiB thread-local buffer and skip destruction, matching the
// upstream lifetime model while leaving extra room for arm64 object size
// and alignment.
alignas(16) static thread_local std::uint8_t pr_buf[8192];
std::memset(pr_buf, 0, sizeof(pr_buf));
s.PurchaseRequest_ctor(pr_buf, &req_ctx);
std::uint8_t one = 1;
s.PurchaseRequest_setProcessDialogActions(pr_buf, one);
auto url_bag_key = abi::make_string_view("subDownload");
s.PurchaseRequest_setURLBagKey(pr_buf, &url_bag_key);
std::string buy = make_buy_parameters(adam_id);
auto buy_view = abi::make_string_view(buy.c_str());
s.PurchaseRequest_setBuyParameters(pr_buf, &buy_view);
// PurchaseRequest derives from URLRequest with URLRequest as the first
// base (offset 0 - confirmed by upstream's pattern of calling URLRequest
// methods on a PurchaseRequest* directly). Passing pr_buf as the
// URLRequest::setURLResponsePreprocessor `this` is therefore safe.
s.URLRequest_setURLResponsePreprocessor(pr_buf, &preproc);
s.PurchaseRequest_run(pr_buf);
// After run() returns, the preprocessor has either captured the dict
// or didn't fire (the request failed before reaching the parsed-response
// stage). Either way, no more async invocations are possible.
void* dict = nullptr;
{
std::lock_guard<std::mutex> g(cap.mu);
dict = cap.cf_dict;
cap.cf_dict = nullptr;
}
// Pull the PurchaseResponse for two things:
// (1) Surface Apple's StoreErrorCondition (if any) for diagnostics.
// (2) Fallback: when the preprocessor didn't fire (PurchaseRequest::run
// bypasses URLRequest::_performActionsForResponse), walk
// PurchaseResponse::items() and serialize each item's dictionary
// into a synthetic { "songList": [...] } root dict.
void* purchase_response_obj = nullptr;
std::string apple_err;
if (s.PurchaseRequest_response != nullptr) {
abi::shared_ptr* resp_sp = s.PurchaseRequest_response(pr_buf);
if (resp_sp != nullptr && resp_sp->obj != nullptr) {
purchase_response_obj = resp_sp->obj;
if (s.PurchaseResponse_error != nullptr) {
abi::shared_ptr* err_sp =
s.PurchaseResponse_error(purchase_response_obj);
if (err_sp != nullptr && err_sp->obj != nullptr) {
int code = (s.SEC_errorCode != nullptr)
? s.SEC_errorCode(err_sp->obj) : 0;
const char* what = (s.SEC_what != nullptr)
? s.SEC_what(err_sp->obj) : nullptr;
apple_err = "Apple store error code=" + std::to_string(code);
if (what != nullptr && *what != '\0') {
apple_err += ": ";
apple_err += what;
}
}
}
}
}
// Preferred path: preprocessor captured the full protocol dict.
if (dict != nullptr) {
out.body = cf_to_json(s, dict);
s.CFRelease(dict);
out.ok = true;
return out;
}
// Fallback: walk PurchaseResponse::items() and build the JSON document
// directly (one cf_to_json per item, then wrap in {"songList": [...]}).
// This is the expected path - PurchaseRequest::run does not fire
// URLRequest::_performActionsForResponse.
if (purchase_response_obj != nullptr
&& s.PurchaseResponse_items != nullptr) {
abi::std_vector items{};
aarch64_sret::purchase_response_items(&items, purchase_response_obj,
s.PurchaseResponse_items);
const auto* begin = static_cast<abi::shared_ptr*>(items.begin);
const auto* end = static_cast<abi::shared_ptr*>(items.end);
const std::size_t count = (end > begin)
? static_cast<std::size_t>(end - begin) : 0;
nlohmann::json song_list = nlohmann::json::array();
for (std::size_t i = 0; i < count; ++i) {
void* item_obj = begin[i].obj;
if (item_obj == nullptr) {
continue;
}
void* item_dict = purchase_item_cfdictionary(s, item_obj);
if (item_dict == nullptr) continue;
song_list.push_back(cf_to_json(s, item_dict));
}
// Note: we deliberately leak `items` (a std::vector with N shared_ptr
// elements). Calling its destructor would require the vector's dtor
// symbol; same shortcut upstream wrapper takes with similar return-
// by-value results. ~16 bytes per item.
if (!song_list.empty()) {
out.body = nlohmann::json::object();
out.body["songList"] = std::move(song_list);
out.ok = true;
return out;
}
}
if (!apple_err.empty()) {
out.error = std::move(apple_err);
} else if (purchase_response_obj == nullptr) {
out.error = "PurchaseRequest produced no response object "
"(likely an HTTP error before parsing)";
} else {
out.error = "PurchaseResponse has no items and no error "
"(Apple returned an empty subDownload payload)";
}
return out;
}
} // namespace wrapper::apple
@@ -0,0 +1,36 @@
// Playback dispatch retrieval (GET /playback).
//
// Mirrors upstream wrapper main.c:get_m3u8_method_download but returns
// the *whole* MZ playback dispatch instead of just one m3u8 URL.
// Implementation drives Apple's storeservicescore::PurchaseRequest with
// urlBagKey="subDownload"; PurchaseRequest::run() builds a parsed
// PurchaseResponse whose items each expose a __CFDictionary* of the
// raw plist payload. We walk those dicts and convert CF types to JSON
// (string/number/bool/array/object/base64-data/iso8601-date) so the
// client receives a native nlohmann::json document.
#pragma once
#include <string>
#include <nlohmann/json.hpp>
namespace wrapper::apple {
class Loader;
class Runtime;
struct PlaybackResult {
bool ok = false;
nlohmann::json body; // JSON document (application/json response body)
std::string error; // populated when ok == false
};
// Blocking. Caller must hold runtime.playback_mutex() (Apple's
// PurchaseRequest dispatch uses process-global state we share with
// /decrypt). Requires runtime.initialized() and an authenticated account.
PlaybackResult fetch_playback_json(const Loader& loader,
Runtime& runtime,
std::string adam_id);
} // namespace wrapper::apple
@@ -0,0 +1,495 @@
// Port of upstream main.c::init() + init_ctx() to C++.
//
// Behavioral diffs vs. upstream:
// - We install a presentation interface with credential + dialog
// handlers. The dialog handler auto-selects "Use Existing Apple ID"
// when the title is "Sign In" (same as upstream), and always calls
// handleProtocolDialogResponse so AuthenticateFlow does not stall.
// - We install SVPlaybackLeaseManager + requestLease before exposing
// POST /decrypt, matching upstream main() after init_ctx.
// - Errors during init log to stderr; credential + ProtocolDialog
// callbacks log a single line each to stderr (no secrets).
//
// All Apple calls are unchecked: there are no error returns to
// validate. If an Apple call crashes mid-init, we want the process to
// die rather than mask the failure with a half-initialized
// RequestContext.
#include "apple/runtime.hpp"
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <functional>
#include <new>
#include <sstream>
#include <string>
#include "apple/aarch64_sret_thunks.hpp"
#include "apple/auth.hpp"
namespace wrapper::apple {
namespace {
constexpr int kDeviceInfoPartCount = 9;
// Stage tracing: writes a single stderr line and flushes so the line is on
// disk before the next Apple call. Gated on WRAPPER_RUNTIME_TRACE=1 (or the
// env var being any non-empty non-"0" value).
bool runtime_trace_enabled() {
static const bool v = []() {
const char* x = std::getenv("WRAPPER_RUNTIME_TRACE");
if (x == nullptr || *x == '\0') return false;
if (std::strcmp(x, "0") == 0) return false;
return true;
}();
return v;
}
void trace(const char* stage) {
if (!runtime_trace_enabled()) return;
std::fprintf(stderr, "runtime: trace %s\n", stage);
std::fflush(stderr);
}
void end_lease_cb(const int& code) {
std::fprintf(stderr, "runtime: playback lease ended (code=%d)\n", code);
}
void pb_err_cb(void* /*unused*/) {
std::fprintf(stderr, "runtime: playback StoreErrorCondition callback\n");
}
std::function<void(const int&)> g_end_lease_fn(end_lease_cb);
std::function<void(void*)> g_pb_err_fn(pb_err_cb);
// Apple's CredentialsResponse setters can retain references to string storage
// past the callback return. Upstream passes global char buffers; keep equivalent
// process-lifetime storage here rather than local std::string::c_str() pointers.
std::string g_credential_username;
std::string g_credential_password;
// Split "a/b/c/d/.../i" into 9 string parts. Returns empty vector
// if the count doesn't match (the runtime considers that a hard
// error: Apple's libs assume all 9 fields are present).
std::vector<std::string> split_device_info(const std::string& s) {
std::vector<std::string> out;
out.reserve(kDeviceInfoPartCount);
std::stringstream ss(s);
std::string part;
while (std::getline(ss, part, '/')) {
out.push_back(std::move(part));
}
if (static_cast<int>(out.size()) != kDeviceInfoPartCount) {
out.clear();
}
return out;
}
std::string read_apple_string(const abi::std_string* s) {
if (s == nullptr) return {};
const bool is_long = (s->_long.cap & 1u) != 0;
if (is_long) {
if (s->_long.data == nullptr || s->_long.size == 0) return {};
return std::string(s->_long.data, s->_long.size);
}
const std::size_t len = s->_short.mark >> 1;
return std::string(s->_short.str, len);
}
// Single-line stderr (no raw newlines / control chars).
std::string sanitize_log_chunk(std::string x, std::size_t max_len) {
for (char& c : x) {
if (static_cast<unsigned char>(c) < 0x20) c = ' ';
}
if (x.size() > max_len) {
x.resize(max_len);
x += "...";
}
return x;
}
// ----------------------------------------------------------------------
// Callbacks registered on AndroidPresentationInterface.
//
// Apple's setDialogHandler / setCredentialsHandler store a function pointer
// typed as taking std::shared_ptr<T> by value. On x86_64 Itanium ABI,
// std::shared_ptr has a non-trivial copy ctor/dtor, so it is passed
// *indirectly*: the caller puts a pointer to a temporary in %rdi/%rsi.
// Declaring the callbacks with explicit shared_ptr* parameters matches that
// calling convention exactly — same as the upstream C wrapper (main.c).
// Do NOT use by-value abi::shared_ptr here: abi::shared_ptr is trivially
// copyable so the compiler would expect its fields in registers, creating
// a fatal ABI mismatch.
// ----------------------------------------------------------------------
extern "C" void wrapper_dialog_handler(long dialog_id,
abi::shared_ptr* dialog,
abi::shared_ptr* /* response_handler */) {
Runtime& rt = Runtime::instance();
const Loader* loader = rt.loader();
if (loader == nullptr || !loader->ok()) return;
if (dialog == nullptr || dialog->obj == nullptr) return;
const Symbols& sy = loader->sym();
std::string title = read_apple_string(sy.ProtocolDialog_title(dialog->obj));
abi::shared_ptr apInf = rt.presentation_interface_copy();
if (apInf.obj == nullptr) return;
// Allocate with ::operator new so libc++'s shared_ptr control-block
// teardown matches the allocator (malloc + operator delete is UB).
auto* const emplace = static_cast<std::uint8_t*>(::operator new(72));
std::memset(emplace + 8, 0, 16);
*reinterpret_cast<void**>(emplace) = sy.vtable_ProtocolDialogResponse + 2;
abi::shared_ptr diag_resp;
diag_resp.obj = emplace + 24;
diag_resp.ctrl_blk = emplace;
sy.ProtocolDialogResponse_ctor(diag_resp.obj);
std::vector<std::string> button_labels;
bool auto_picked = false;
abi::std_vector* buttons = sy.ProtocolDialog_buttons(dialog->obj);
if (buttons != nullptr && buttons->begin != nullptr && buttons->end != nullptr) {
auto* b_begin = static_cast<abi::shared_ptr*>(buttons->begin);
auto* b_end = static_cast<abi::shared_ptr*>(buttons->end);
for (auto* b = b_begin; b != b_end; ++b) {
if (b->obj == nullptr) continue;
std::string bt = read_apple_string(sy.ProtocolButton_title(b->obj));
const bool is_existing = (bt == "Use Existing Apple ID");
button_labels.push_back(sanitize_log_chunk(std::move(bt), 80));
if (!auto_picked && title == "Sign In" && is_existing) {
sy.ProtocolDialogResponse_setSelectedButton(diag_resp.obj, b);
auto_picked = true;
}
}
}
{
std::string joined;
for (std::size_t i = 0; i < button_labels.size(); ++i) {
if (i != 0) joined += ", ";
joined += button_labels[i];
}
joined = sanitize_log_chunk(std::move(joined), 400);
std::string title_log = sanitize_log_chunk(std::move(title), 200);
std::fprintf(stderr,
"runtime: ProtocolDialog id=%ld title=\"%s\" buttons=[%s] "
"picked_existing_apple_id=%s\n",
static_cast<long>(dialog_id), title_log.c_str(), joined.c_str(),
auto_picked ? "yes" : "no");
}
long id_arg = dialog_id;
sy.API_handleProtocolDialogResponse(apInf.obj, &id_arg, &diag_resp);
// Ownership of emplace follows Apple's shared_ptr refcount; do not free here.
}
extern "C" void wrapper_credential_handler(abi::shared_ptr* cred_request,
abi::shared_ptr* /* cred_response_handler */) {
Runtime& rt = Runtime::instance();
const Loader* loader = rt.loader();
if (loader == nullptr || !loader->ok()) return;
const Symbols& s = loader->sym();
abi::shared_ptr apInf = rt.presentation_interface_copy();
if (apInf.obj == nullptr) return;
const bool need_2fa =
(cred_request != nullptr && cred_request->obj != nullptr)
? (s.CR_requiresHSA2VerificationCode(cred_request->obj) != 0)
: false;
std::fprintf(stderr,
"runtime: CredentialsRequest HSA2=%s\n",
need_2fa ? "yes" : "no");
std::string username, password;
Account::instance().fetch_credentials_blocking(&username, &password, need_2fa);
g_credential_username = std::move(username);
g_credential_password = std::move(password);
auto* const emplace = static_cast<std::uint8_t*>(::operator new(80));
std::memset(emplace + 8, 0, 16);
*reinterpret_cast<void**>(emplace) = s.vtable_CredentialsResponse + 2;
abi::shared_ptr cred_resp;
cred_resp.obj = emplace + 24;
cred_resp.ctrl_blk = emplace;
s.CredentialsResponse_ctor(cred_resp.obj);
auto user_view = abi::make_string_view(g_credential_username.c_str());
s.CredentialsResponse_setUserName(cred_resp.obj, &user_view);
auto pass_view = abi::make_string_view(g_credential_password.c_str());
s.CredentialsResponse_setPassword(cred_resp.obj, &pass_view);
s.CredentialsResponse_setResponseType(cred_resp.obj, /*kSubmit=*/2);
s.API_handleCredentialsResponse(apInf.obj, &cred_resp);
// Ownership of emplace follows Apple's shared_ptr refcount; do not free here.
}
} // namespace
Runtime& Runtime::instance() {
static Runtime r;
return r;
}
bool Runtime::init_playback_session(const Symbols& s) {
trace("init_playback_session: memset lease_mgr_");
std::memset(lease_mgr_, 0, sizeof(lease_mgr_));
trace("init_playback_session: SVPlaybackLeaseManager_ctor");
s.SVPlaybackLeaseManager_ctor(lease_mgr_, &g_end_lease_fn, &g_pb_err_fn);
std::uint8_t autom = 1;
trace("init_playback_session: refreshLeaseAutomatically");
s.SVPlaybackLeaseManager_refreshLeaseAutomatically(lease_mgr_, &autom);
trace("init_playback_session: requestLease");
s.SVPlaybackLeaseManager_requestLease(lease_mgr_, &autom);
trace("init_playback_session: SVFootHillSessionCtrl::instance");
foothill_ = s.SVFootHillSessionCtrl_instance();
if (foothill_ == nullptr) {
std::fprintf(stderr,
"runtime: SVFootHillSessionCtrl::instance returned null\n");
return false;
}
trace("init_playback_session: done");
return true;
}
void Runtime::refresh_playback_lease() {
if (loader_ == nullptr || !loader_->ok() || !playback_ready_) {
return;
}
const Symbols& s = loader_->sym();
std::uint8_t autom = 1;
std::lock_guard<std::mutex> lock(playback_mutex_);
s.SVPlaybackLeaseManager_requestLease(lease_mgr_, &autom);
}
bool Runtime::initialize(const Loader& loader, const RuntimeConfig& cfg) {
std::lock_guard<std::mutex> g(mu_);
if (initialized_.load(std::memory_order_relaxed)) return true;
if (!loader.ok()) {
std::fprintf(stderr,
"runtime: cannot initialize, loader is not ok (last_error=%s)\n",
loader.last_error().c_str());
return false;
}
auto parts = split_device_info(cfg.device_info);
if (parts.empty()) {
std::fprintf(stderr,
"runtime: WRAPPER_DEVICE_INFO must have %d slash-separated "
"parts, got %zu (raw=%.200s)\n",
kDeviceInfoPartCount,
std::count(cfg.device_info.begin(), cfg.device_info.end(), '/') + 1,
cfg.device_info.c_str());
return false;
}
const Symbols& s = loader.sym();
trace("initialize: init_dns_and_foothill");
if (!init_dns_and_foothill(s, parts)) return false;
trace("initialize: init_request_context");
if (!init_request_context(s, cfg, parts)) return false;
trace("initialize: init_presentation_interface");
if (!init_presentation_interface(s)) return false;
trace("initialize: core context ready");
base_dir_ = cfg.base_dir;
device_info_ = cfg.device_info;
loader_ = &loader;
if (!loader.fairplay_decrypt_available()) {
playback_ready_ = false;
std::fprintf(stderr,
"runtime: FairPlay decrypt chain not loaded; POST /decrypt unavailable\n");
} else {
playback_ready_ = init_playback_session(s);
if (!playback_ready_) {
std::fprintf(stderr,
"runtime: warning: FairPlay playback init failed; "
"POST /decrypt unavailable\n");
}
}
initialized_.store(true, std::memory_order_release);
return true;
}
bool Runtime::init_dns_and_foothill(const Symbols& s,
const std::vector<std::string>& parts) {
// Bionic's resolver inside the chroot has no /etc/resolv.conf to
// read. Set Alibaba's public DNS so the bundled libcurl can
// reach api.itunes.apple.com etc. Upstream uses the same hosts.
trace("init_dns_and_foothill: resolv_set_nameservers_for_net");
static const char* resolvers[2] = {"223.5.5.5", "223.6.6.6"};
s.resolv_set_nameservers_for_net(0, resolvers, 2, ".");
// FootHillConfig::config(android_id). Android ID is part 8 of
// the device-info tuple.
trace("init_dns_and_foothill: FootHillConfig::config");
auto android_id = abi::make_string_view(parts[8].c_str());
s.FootHillConfig_config(&android_id);
// DeviceGUID::instance() -> shared_ptr<DeviceGUID>
trace("init_dns_and_foothill: DeviceGUID::instance");
aarch64_sret::device_guid_instance(&device_guid_, s.DeviceGUID_instance);
if (device_guid_.obj == nullptr) {
std::fprintf(stderr, "runtime: DeviceGUID::instance returned null\n");
return false;
}
// DeviceGUID::configure(android_id, "", 29, true). The first arg
// is the hidden return slot for the (88-byte) Configuration<>
// value Apple discards.
// Hidden return for DeviceGUID::configure; must be 16-byte aligned for
// AArch64 NEON / libc++ stores into the sret slot.
alignas(16) static std::uint8_t cfg_ret[88];
static const unsigned int cfg_kind = 29;
static const std::uint8_t cfg_flag = 1;
auto empty = abi::make_string_view("");
trace("init_dns_and_foothill: DeviceGUID::configure");
aarch64_sret::device_guid_configure(
&cfg_ret, device_guid_.obj, &android_id, &empty, &cfg_kind, &cfg_flag,
s.DeviceGUID_configure);
trace("init_dns_and_foothill: done");
return true;
}
bool Runtime::init_request_context(const Symbols& s,
const RuntimeConfig& cfg,
const std::vector<std::string>& parts) {
// make_shared<RequestContext>(mpl_db_path).
std::string mpl_db = cfg.base_dir + "/mpl_db";
auto mpl_db_view = abi::make_string_view(mpl_db.c_str());
trace("init_request_context: make_shared<RequestContext>");
aarch64_sret::make_shared_request_context(&request_ctx_, &mpl_db_view,
s.make_shared_RequestContext);
if (request_ctx_.obj == nullptr) {
std::fprintf(stderr, "runtime: make_shared<RequestContext> returned null\n");
return false;
}
// RequestContextConfig: emplaced in a buffer with a hand-rolled vtable
// pointer for the shared_ptr control block. Pattern from upstream
// init_ctx(), with extra slack so Release builds do not stomp adjacent
// storage if Apple's object is larger than the historical constant.
alignas(16) static std::uint8_t rcc_buf[2048];
*reinterpret_cast<void**>(rcc_buf) =
s.vtable_RequestContextConfig + 2; // skip past vtable header (type_info + dtor slots)
abi::shared_ptr rcc;
rcc.obj = rcc_buf + 32;
rcc.ctrl_blk = rcc_buf;
trace("init_request_context: RequestContextConfig_ctor");
s.RequestContextConfig_ctor(rcc.obj);
// Setters: order matches upstream init_ctx().
trace("init_request_context: RCC setters");
s.RCC_setBaseDirectoryPath(rcc.obj, &mpl_db_view);
auto s0 = abi::make_string_view(parts[0].c_str());
s.RCC_setClientIdentifier(rcc.obj, &s0);
auto s1 = abi::make_string_view(parts[1].c_str());
s.RCC_setVersionIdentifier(rcc.obj, &s1);
auto s2 = abi::make_string_view(parts[2].c_str());
s.RCC_setPlatformIdentifier(rcc.obj, &s2);
auto s3 = abi::make_string_view(parts[3].c_str());
s.RCC_setProductVersion(rcc.obj, &s3);
auto s4 = abi::make_string_view(parts[4].c_str());
s.RCC_setDeviceModel(rcc.obj, &s4);
auto s5 = abi::make_string_view(parts[5].c_str());
s.RCC_setBuildVersion(rcc.obj, &s5);
auto s6 = abi::make_string_view(parts[6].c_str());
s.RCC_setLocaleIdentifier(rcc.obj, &s6);
auto s7 = abi::make_string_view(parts[7].c_str());
s.RCC_setLanguageIdentifier(rcc.obj, &s7);
// Wire the RequestContext into the singleton manager.
trace("init_request_context: RequestContextManager::configure");
s.RequestContextManager_configure(&request_ctx_);
// RequestContext::init(rcc). Upstream uses an 88-byte hidden
// return slot - match it.
// RequestContext::init hidden return buffer (same alignment as cfg_ret).
alignas(16) static std::uint8_t rci_ret[88];
trace("init_request_context: RequestContext::init");
aarch64_sret::request_context_init(&rci_ret, request_ctx_.obj, &rcc,
s.RequestContext_init);
auto fp_dir = abi::make_string_view(cfg.base_dir.c_str());
trace("init_request_context: setFairPlayDirectoryPath");
s.RequestContext_setFairPlayDirectoryPath(request_ctx_.obj, &fp_dir);
trace("init_request_context: done");
return true;
}
bool Runtime::init_presentation_interface(const Symbols& s) {
// make_shared<AndroidPresentationInterface>()
trace("init_presentation_interface: make_shared<AndroidPresentationInterface>");
aarch64_sret::make_shared_android_presentation_interface(
&presentation_interface_, s.make_shared_AndroidPresentationInterface);
if (presentation_interface_.obj == nullptr) {
std::fprintf(stderr,
"runtime: make_shared<AndroidPresentationInterface> returned null\n");
return false;
}
trace("init_presentation_interface: setDialogHandler");
s.API_setDialogHandler(presentation_interface_.obj, &wrapper_dialog_handler);
trace("init_presentation_interface: setCredentialsHandler");
s.API_setCredentialsHandler(presentation_interface_.obj, &wrapper_credential_handler);
// Wire the apInf into the RequestContext so AuthenticateFlow can
// surface credential prompts back to us.
trace("init_presentation_interface: setPresentationInterface");
s.RequestContext_setPresentationInterface(request_ctx_.obj, &presentation_interface_);
trace("init_presentation_interface: done");
return true;
}
std::string Runtime::base_dir() const {
if (!initialized_.load(std::memory_order_acquire)) return {};
std::lock_guard<std::mutex> g(mu_);
return base_dir_;
}
std::string Runtime::device_info() const {
if (!initialized_.load(std::memory_order_acquire)) return {};
std::lock_guard<std::mutex> g(mu_);
return device_info_;
}
abi::shared_ptr Runtime::request_ctx_copy() const {
if (!initialized_.load(std::memory_order_acquire)) return {};
std::lock_guard<std::mutex> g(mu_);
return request_ctx_;
}
abi::shared_ptr Runtime::device_guid_copy() const {
if (!initialized_.load(std::memory_order_acquire)) return {};
std::lock_guard<std::mutex> g(mu_);
return device_guid_;
}
abi::shared_ptr Runtime::presentation_interface_copy() const {
if (!initialized_.load(std::memory_order_acquire)) return {};
std::lock_guard<std::mutex> g(mu_);
return presentation_interface_;
}
} // namespace wrapper::apple
@@ -0,0 +1,111 @@
// Apple Music native runtime initialization.
//
// `Runtime::initialize()` runs the equivalent of upstream main.c's
// `init()` and `init_ctx()` in one go, then wires SVPlaybackLeaseManager
// + `SVFootHillSessionCtrl::instance()` for FairPlay decrypt. After it
// returns true, the process holds a configured `RequestContext` shared_ptr
// that downstream Apple-API calls (storefront lookup, m3u8, decrypt) need.
//
// The runtime is process-wide and single-instance: Apple's libs use process-global
// state and are not safe to re-initialize.
#pragma once
#include <atomic>
#include <cstddef>
#include <mutex>
#include <string>
#include <string_view>
#include <vector>
#include "apple/abi.hpp"
#include "apple/loader.hpp"
namespace wrapper::apple {
// Configuration sourced from environment variables. See
// .env.example for defaults.
struct RuntimeConfig {
// Filesystem path Apple's libs will use for FairPlay key cache,
// mpl_db, etc. Mapped to upstream's --base-dir / -B flag.
std::string base_dir = "/data/data/com.apple.android.music/files";
// Slash-separated 9-tuple identifying the fake "Apple Music
// Android client" we present to Apple's servers. Order:
// ClientIdentifier / VersionIdentifier / PlatformIdentifier /
// ProductVersion / DeviceModel / BuildVersion /
// LocaleIdentifier / LanguageIdentifier / AndroidID
std::string device_info =
"Music/4.9/Android/10/Samsung S9/7663313/en-US/en-US/dc28071e981c439e";
};
// Singleton: Apple's libs share process-global state, so only one
// Runtime instance per process is ever valid.
class Runtime {
public:
static Runtime& instance();
// Idempotent: subsequent calls after the first successful one
// are no-ops returning true. On failure the runtime stays
// uninitialized and subsequent calls will retry. Errors are
// logged to stderr with a `runtime: ` prefix. The Loader must
// outlive the runtime - we cache a pointer for credential
// trampoline access.
bool initialize(const Loader& loader, const RuntimeConfig& cfg);
bool initialized() const { return initialized_.load(std::memory_order_acquire); }
// Snapshot of the configuration that was used for initialize().
// Empty string if not initialized yet.
std::string base_dir() const;
std::string device_info() const;
// Read-only access used by the auth worker thread.
// Both methods return zero-initialized values if not initialized.
abi::shared_ptr request_ctx_copy() const;
abi::shared_ptr device_guid_copy() const;
abi::shared_ptr presentation_interface_copy() const;
// Loader pointer captured at initialize() time. Used by the C-style
// credential handler trampoline which can't carry a user-data
// pointer through Apple's callback ABI.
const Loader* loader() const { return loader_; }
// FairPlay sample decrypt (POST /decrypt). Requires successful
// SVPlaybackLeaseManager + SVFootHillSessionCtrl init at startup.
bool playback_ready() const { return playback_ready_; }
void* foothill_session() const { return foothill_; }
std::mutex& playback_mutex() { return playback_mutex_; }
void refresh_playback_lease();
private:
Runtime() = default;
Runtime(const Runtime&) = delete;
Runtime& operator=(const Runtime&) = delete;
bool init_dns_and_foothill(const Symbols& s,
const std::vector<std::string>& parts);
bool init_request_context(const Symbols& s,
const RuntimeConfig& cfg,
const std::vector<std::string>& parts);
bool init_presentation_interface(const Symbols& s);
bool init_playback_session(const Symbols& s);
mutable std::mutex mu_;
std::atomic<bool> initialized_{false};
const Loader* loader_ = nullptr;
abi::shared_ptr request_ctx_{};
abi::shared_ptr device_guid_{};
abi::shared_ptr presentation_interface_{};
std::string base_dir_;
std::string device_info_;
std::mutex playback_mutex_;
// Placement buffer for SVPlaybackLeaseManager (ctor + instance methods).
// Must cover the real object size; 16 was far too small and UB on arm64.
alignas(16) unsigned char lease_mgr_[4096]{};
void* foothill_ = nullptr;
bool playback_ready_ = false;
};
} // namespace wrapper::apple
@@ -0,0 +1,403 @@
#include "apple/tokens.hpp"
#include <chrono>
#include <cstdio>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <string>
#include <string_view>
#include <vector>
#include <nlohmann/json.hpp>
#include "apple/aarch64_sret_thunks.hpp"
#include "apple/auth.hpp"
#include "apple/loader.hpp"
namespace wrapper::apple::tokens {
namespace {
// Apple's URLResponse body occasionally contains bytes after the first JSON
// object (garbage or a second payload). nlohmann rejects trailing non-space.
// Upstream cJSON is sometimes more permissive; we parse only the first
// balanced `{...}` slice.
std::string normalize_apple_json_body(std::string_view raw) {
std::string s(raw);
if (s.size() >= 3 && static_cast<unsigned char>(s[0]) == 0xEF &&
static_cast<unsigned char>(s[1]) == 0xBB &&
static_cast<unsigned char>(s[2]) == 0xBF) {
s.erase(0, 3);
}
if (s.size() >= 4 && s[0] == ')' && s[1] == ']' && s[2] == '}' &&
s[3] == '\'') {
const auto nl = s.find('\n');
if (nl != std::string::npos) {
s.erase(0, nl + 1);
} else {
s.erase(0, 4);
}
}
return s;
}
std::string_view first_json_object(std::string_view s) {
const std::size_t start = s.find('{');
if (start == std::string_view::npos) return {};
int depth = 0;
bool in_str = false;
bool esc = false;
for (std::size_t j = start; j < s.size(); ++j) {
const unsigned char c = static_cast<unsigned char>(s[j]);
if (in_str) {
if (esc) {
esc = false;
} else if (c == '\\') {
esc = true;
} else if (c == '"') {
in_str = false;
}
} else {
if (c == '"') {
in_str = true;
} else if (c == '{') {
++depth;
} else if (c == '}') {
--depth;
if (depth == 0) {
return s.substr(start, j - start + 1);
}
}
}
}
return {};
}
bool parse_apple_response_json(const std::string& body, nlohmann::json* out) {
const std::string norm = normalize_apple_json_body(body);
const std::string_view obj = first_json_object(norm);
if (obj.empty()) {
return false;
}
try {
*out = nlohmann::json::parse(obj);
return true;
} catch (const std::exception&) {
return false;
}
}
// Read a std::__ndk1::basic_string out of Apple-land into a host string.
std::string read_std_string(const abi::std_string& s) {
const bool is_long = (s._long.cap & 1u) != 0;
if (is_long) {
if (s._long.data == nullptr || s._long.size == 0) return {};
return std::string(s._long.data, s._long.size);
}
const std::size_t len = s._short.mark >> 1;
return std::string(s._short.str, len);
}
// HTTPMessage: manual __shared_ptr_emplace layout; +32 is the HTTPMessage
// object. The emplace/control-block storage must outlive this C++ stack frame:
// URLRequest / HTTPRequest can keep shared/weak references after run() returns,
// and a later request may release them. If this storage lives on the stack, the
// second token request can end up touching a reused/zeroed control block and
// crash through a null vtable (arm64 commonly reports fault_addr=0x10).
struct HTTPMessageHolder {
static constexpr std::size_t kSize = 4096;
std::uint8_t* buf = nullptr;
abi::shared_ptr sp{};
void init(void** vtable) {
void* raw = nullptr;
if (::posix_memalign(&raw, 16, kSize) != 0 || raw == nullptr) {
std::fprintf(stderr, "tokens: HTTPMessageHolder allocation failed\n");
std::fflush(stderr);
std::abort();
}
buf = static_cast<std::uint8_t*>(raw);
std::memset(buf, 0, kSize);
*reinterpret_cast<void**>(buf) = vtable + 2;
sp.obj = buf + 32;
sp.ctrl_blk = buf;
}
};
// JWT base64url decoder. Returns an empty string on malformed input.
std::string base64url_decode(const std::string& in) {
static const int8_t T[256] = {
// 0..63 = legal char, else -1
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,62,-1,-1, 52,53,54,55,56,57,58,59,60,61,-1,-1,-1, 0,-1,-1,
-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10,11,12,13,14, 15,16,17,18,19,20,21,22,23,24,25,-1,-1,-1,-1,63,
-1,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40, 41,42,43,44,45,46,47,48,49,50,51,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
};
std::string out;
out.reserve(in.size() * 3 / 4);
int bits = 0, val = 0;
for (unsigned char c : in) {
int v = T[c];
if (v < 0) {
if (c == '=' || c == '\r' || c == '\n') continue;
return {};
}
val = (val << 6) | v;
bits += 6;
if (bits >= 8) {
bits -= 8;
out.push_back(static_cast<char>((val >> bits) & 0xff));
}
}
return out;
}
void set_str_header(const Symbols& s, void* http_msg_obj,
const char* name, const char* value) {
auto n = abi::make_string_view(name);
auto v = abi::make_string_view(value);
s.HTTPMessage_setHeader(http_msg_obj, &n, &v);
}
void set_url_param(const Symbols& s, void* url_req_obj,
const char* name, const char* value) {
auto n = abi::make_string_view(name);
auto v = abi::make_string_view(value);
s.URLRequest_setRequestParameter(url_req_obj, &n, &v);
}
void construct_http_message(const Symbols& s,
HTTPMessageHolder& msg,
const char* url,
const char* method) {
// HTTPMessage(url, method) takes std::string by value. Use real
// std::string temporaries so libc++ constructs/destructs valid objects
// instead of treating an abi::std_string view as an owned string.
if (s.HTTPMessage_ctor_c1 != nullptr) {
s.HTTPMessage_ctor_c1(msg.sp.obj, std::string(url), std::string(method));
} else {
s.HTTPMessage_ctor(msg.sp.obj, std::string(url), std::string(method));
}
}
struct UrlRequestResult {
std::string body;
int error_code = 0;
std::string error_what;
bool ok = false;
};
// Run an HTTPMessage through URLRequest with the given RequestContext
// and return the response body. Optional `params` populates GET-style
// query parameters via URLRequest::setRequestParameter.
UrlRequestResult run_request(
const Symbols& s,
abi::shared_ptr req_ctx,
HTTPMessageHolder& msg,
const std::vector<std::pair<std::string, std::string>>& params) {
UrlRequestResult r;
// Process-lifetime storage: URLRequest/HTTPRequest can retain references
// beyond run(), so do not back this with a stack buffer.
constexpr std::size_t kUrlReqSize = 8192;
void* url_req_raw = nullptr;
if (::posix_memalign(&url_req_raw, 16, kUrlReqSize) != 0 || url_req_raw == nullptr) {
std::fprintf(stderr, "tokens: run_request: posix_memalign failed\n");
std::fflush(stderr);
return r;
}
std::uint8_t* url_req = static_cast<std::uint8_t*>(url_req_raw);
std::memset(url_req, 0, kUrlReqSize);
s.URLRequest_ctor(url_req, &msg.sp, &req_ctx);
for (const auto& p : params) {
set_url_param(s, url_req, p.first.c_str(), p.second.c_str());
}
aarch64_sret::urlrequest_run(url_req, s.URLRequest_run);
abi::shared_ptr* err = s.URLRequest_error(url_req);
if (err != nullptr && err->obj != nullptr) {
r.error_code = s.SEC_errorCode(err->obj);
const char* what = s.SEC_what(err->obj);
if (what != nullptr) r.error_what = what;
return r;
}
abi::shared_ptr* url_resp = s.URLRequest_response(url_req);
if (url_resp == nullptr || url_resp->obj == nullptr) return r;
abi::shared_ptr* inner = s.URLResponse_underlyingResponse(url_resp->obj);
if (inner == nullptr || inner->obj == nullptr) return r;
// Upstream pattern: at offset +48 from HTTPMessage there's a
// pointer to a mediaplatform::Data whose bytes() yields the body.
void* http_msg_obj = inner->obj;
void** data_slot = reinterpret_cast<void**>(static_cast<std::uint8_t*>(http_msg_obj) + 48);
void* data_obj = *data_slot;
if (data_obj == nullptr) return r;
const char* bytes = s.Data_bytes(data_obj);
if (bytes == nullptr) return r;
r.body = std::string(bytes);
r.ok = true;
return r;
}
// POST JSON body via URLRequest (uses Apple's signing headers on req_ctx).
std::string post_json(const Symbols& s,
abi::shared_ptr req_ctx,
HTTPMessageHolder& msg,
const std::string& body_json) {
// Upstream uses a malloc'd + snprintf buffer and frees immediately after
// setBodyData. libc++ std::string::data() is null-terminated, so we pass
// the std::string buffer directly instead of copying into std::vector<char>
// (which is NOT null-terminated and may cause strlen-based corruption).
if (!body_json.empty()) {
s.HTTPMessage_setBodyData(msg.sp.obj, body_json.data(), body_json.size());
}
auto r = run_request(s, req_ctx, msg, {});
if (!r.ok) return {};
return r.body;
}
} // namespace
std::string harvest_storefront(const Symbols& s, abi::shared_ptr req_ctx) {
abi::std_string out{};
abi::shared_ptr null_url_bag{};
aarch64_sret::request_context_store_front_identifier(
&out, req_ctx.obj, &null_url_bag, s.RequestContext_storeFrontIdentifier);
return read_std_string(out);
}
std::string device_guid_string(const Symbols& s, abi::shared_ptr device_guid) {
if (device_guid.obj == nullptr) return {};
// DeviceGUID::guid() returns a (Data*, void*) pair into a 16-byte
// hidden first arg. The first 8 bytes are the Data* whose bytes()
// we want.
void* ret[2] = {nullptr, nullptr};
aarch64_sret::device_guid_guid(ret, device_guid.obj, s.DeviceGUID_guid);
if (ret[0] == nullptr) return {};
const char* bytes = s.Data_bytes(ret[0]);
if (bytes == nullptr) return {};
return std::string(bytes);
}
std::string harvest_dev_token(const Symbols& s, abi::shared_ptr req_ctx) {
// Matches upstream get_dev_token(): GET + query params; JSON field is "token".
HTTPMessageHolder msg;
msg.init(s.vtable_HTTPMessage);
construct_http_message(s, msg,
"https://sf-api-token-service.itunes.apple.com/apiToken",
"GET");
std::vector<std::pair<std::string, std::string>> params;
params.emplace_back("clientId", "musicAndroid");
params.emplace_back("version", "1");
auto r = run_request(s, req_ctx, msg, params);
if (!r.ok || r.body.empty()) return {};
nlohmann::json j;
if (!parse_apple_response_json(r.body, &j)) return {};
if (j.contains("token") && j["token"].is_string()) {
return j["token"].get<std::string>();
}
return {};
}
std::string harvest_music_user_token(const Symbols& s,
abi::shared_ptr req_ctx,
const std::string& guid,
const std::string& dev_token) {
HTTPMessageHolder msg;
msg.init(s.vtable_HTTPMessage);
construct_http_message(s, msg,
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/createMusicToken",
"POST");
set_str_header(s, msg.sp.obj, "Content-Type", "application/json; charset=UTF-8");
set_str_header(s, msg.sp.obj, "Expect", "");
set_str_header(s, msg.sp.obj, "X-Apple-Requesting-Bundle-Id", "com.apple.android.music");
set_str_header(s, msg.sp.obj, "X-Apple-Requesting-Bundle-Version",
"Music/4.9 Android/10 model/Samsung S9 build/7663313 (dt:66)");
auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
std::string body;
body.reserve(512);
body += "{\"guid\":\""; body += guid;
body += "\",\"assertion\":\""; body += dev_token;
body += "\",\"tcc-acceptance-date\":\""; body += std::to_string(now_ms);
body += "\"}";
std::string resp_body = post_json(s, req_ctx, msg, body);
if (resp_body.empty()) return {};
nlohmann::json j;
if (!parse_apple_response_json(resp_body, &j)) return {};
if (j.contains("music_token") && j["music_token"].is_string()) {
return j["music_token"].get<std::string>();
}
return {};
}
std::optional<std::string> extract_dsid_from_jwt(const std::string& jwt) {
auto dot1 = jwt.find('.');
if (dot1 == std::string::npos) return std::nullopt;
auto dot2 = jwt.find('.', dot1 + 1);
if (dot2 == std::string::npos) return std::nullopt;
std::string payload_b64 = jwt.substr(dot1 + 1, dot2 - dot1 - 1);
std::string payload = base64url_decode(payload_b64);
if (payload.empty()) return std::nullopt;
try {
auto j = nlohmann::json::parse(payload);
if (j.contains("dsid")) {
const auto& v = j["dsid"];
if (v.is_string()) return v.get<std::string>();
if (v.is_number_integer()) return std::to_string(v.get<long long>());
if (v.is_number_unsigned()) return std::to_string(v.get<unsigned long long>());
}
// some Apple tokens spell the claim "iss" or "sub" - try those too
for (const char* k : {"sub", "iss"}) {
if (j.contains(k) && j[k].is_string()) {
auto cand = j[k].get<std::string>();
// dsid looks like "1234567890" - return only if numeric
bool numeric = !cand.empty();
for (char c : cand) numeric = numeric && (c >= '0' && c <= '9');
if (numeric) return cand;
}
}
} catch (...) {
// fall through
}
return std::nullopt;
}
bool harvest_all(const Symbols& s,
abi::shared_ptr req_ctx,
abi::shared_ptr device_guid,
Tokens* out) {
out->storefront = harvest_storefront(s, req_ctx);
if (out->storefront.empty()) return false;
out->dev_token = harvest_dev_token(s, req_ctx);
if (out->dev_token.empty()) return false;
std::string guid = device_guid_string(s, device_guid);
if (guid.empty()) return false;
out->music_user_token = harvest_music_user_token(s, req_ctx, guid, out->dev_token);
if (out->music_user_token.empty()) return false;
if (auto d = extract_dsid_from_jwt(out->dev_token)) {
out->dsid = std::move(*d);
}
return true;
}
} // namespace wrapper::apple::tokens
@@ -0,0 +1,53 @@
// Token-harvest helpers.
//
// Post-login, Apple's `RequestContext` carries the iTunes-side auth
// state (DSID + signed device blobs). We use that to:
// - Read the storefront identifier directly from RequestContext.
// - GET sf-api-token-service.itunes.apple.com/apiToken?clientId=musicAndroid
// &version=1 for the developer token (JWT; JSON key `token`).
// - POST to play.itunes.apple.com/.../createMusicToken for the
// Music User Token (MusicKit-side token derived from this login).
// - Decode the `dsid` claim out of the dev-token JWT.
//
// Everything goes through Apple's URLRequest so requests are
// transparently signed with X-Dsid / X-Token / X-iTunes-Storefront
// headers; we don't have to reimplement that. The URL signing
// machinery lives in libstoreservicescore.so and consumes the same
// RequestContext that AuthenticateFlow just populated.
#pragma once
#include <optional>
#include <string>
#include "apple/abi.hpp"
namespace wrapper::apple {
struct Symbols;
struct Tokens;
namespace tokens {
// One-shot orchestrator used by the worker thread. Populates the
// storefront / dev_token / music_user_token / dsid fields on `out`
// (the caller is responsible for apple_id and logged_in_at). Returns
// false on the first hard failure with stderr-logged context.
bool harvest_all(const Symbols& s,
abi::shared_ptr req_ctx,
abi::shared_ptr device_guid,
Tokens* out);
// Individual stages, exposed for testing and finer-grained reuse.
std::string harvest_storefront(const Symbols& s, abi::shared_ptr req_ctx);
std::string harvest_dev_token(const Symbols& s, abi::shared_ptr req_ctx);
std::string harvest_music_user_token(const Symbols& s,
abi::shared_ptr req_ctx,
const std::string& guid,
const std::string& dev_token);
std::string device_guid_string(const Symbols& s, abi::shared_ptr device_guid);
std::optional<std::string> extract_dsid_from_jwt(const std::string& jwt);
} // namespace tokens
} // namespace wrapper::apple
+345
View File
@@ -0,0 +1,345 @@
// wrapper-v2 daemon entry point.
//
// The daemon starts in LoggedOut state, expects credentials via HTTP,
// and drives Apple's AuthenticateFlow under the hood:
//
// GET /health
// GET /me
// POST /login body: { "apple_id": "...", "password": "..." }
// POST /login/2fa body: { "code": "123456" }
// DELETE /login
//
// Persistence: mount WRAPPER_BASE_DIR so Apple keeps mpl_db across
// restarts. After a prior POST /login (or first-time -L style login),
// startup may restore tokens from that session without password
// (WRAPPER_RESTORE_SESSION=1, default).
//
// Configuration is environment-only. Optional argv: --help. All knobs
// use the WRAPPER_ prefix:
//
// WRAPPER_HOST Bind address (default 0.0.0.0)
// WRAPPER_PORT Bind port (default 80)
// WRAPPER_MODE supervisor (default) or worker
// WRAPPER_WORKER_PORT Private supervisor->worker port (default 18080)
// WRAPPER_BASE_DIR Apple-lib working dir (default
// /data/data/com.apple.android.music/files)
// WRAPPER_DEVICE_INFO 9-tuple device identifier
// WRAPPER_APPLE_INIT "0" to skip Apple lib init at startup
// (useful for /health-only smoke tests).
// WRAPPER_RESTORE_SESSION "0" skips on-disk session restore after init.
// WRAPPER_APPLE_ID Optional label for GET /me apple_id after restore
// (not sent to Apple).
// WRAPPER_USERNAME With WRAPPER_PASSWORD, run password sign-in at
// startup if not already authenticated (same as
// POST /login username field — Apple ID email).
// WRAPPER_PASSWORD App-specific password for WRAPPER_USERNAME auto-login.
#include <atomic>
#include <chrono>
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <string_view>
#include <signal.h>
#include <ucontext.h>
#include <unistd.h>
#include <httplib.h>
#include "apple/auth.hpp"
#include "apple/loader.hpp"
#include "apple/runtime.hpp"
#include "server.hpp"
#include "supervisor.hpp"
namespace {
constexpr const char* kDefaultHost = "0.0.0.0";
constexpr int kDefaultPort = 80;
constexpr int kDefaultWorkerPort = 18080;
constexpr const char* kVersion = "0.0.1";
std::atomic<httplib::Server*> g_server{nullptr};
enum class ProgramMode {
Supervisor,
Worker,
};
// Minimal async-signal-safe crash line (no /proc parsing — avoids huge logs).
void on_crash(int sig, siginfo_t* info, void* ctx) {
auto* uc = static_cast<ucontext_t*>(ctx);
void* fault_addr = info ? info->si_addr : nullptr;
void* rip = nullptr;
#ifdef __x86_64__
if (uc) rip = reinterpret_cast<void*>(uc->uc_mcontext.gregs[REG_RIP]);
#elif defined(__aarch64__)
if (uc) rip = reinterpret_cast<void*>(uc->uc_mcontext.pc);
#endif
char buf[128];
int n = snprintf(buf, sizeof(buf),
"wrapper-v2: fatal signal %d fault_addr=%p rip=%p\n",
sig, fault_addr, rip);
(void)write(STDERR_FILENO, buf, n > 0 ? n : 0);
_exit(128 + sig);
}
void on_signal(int sig) {
auto* s = g_server.load();
if (s != nullptr) {
std::fprintf(stderr, "wrapper-v2: caught signal %d, stopping server\n", sig);
s->stop();
}
}
std::string env_or(const char* name, std::string fallback) {
const char* v = std::getenv(name);
if (v == nullptr || *v == '\0') return fallback;
return std::string(v);
}
bool env_bool(const char* name, bool fallback) {
const char* v = std::getenv(name);
if (v == nullptr || *v == '\0') return fallback;
if (std::strcmp(v, "0") == 0 || std::strcmp(v, "false") == 0
|| std::strcmp(v, "no") == 0) {
return false;
}
return true;
}
int env_int(const char* name, int fallback) {
int v = std::atoi(env_or(name, std::to_string(fallback)).c_str());
return v > 0 ? v : fallback;
}
bool consume_argv(int argc, char** argv, ProgramMode* mode) {
std::string mode_env = env_or("WRAPPER_MODE", "supervisor");
if (mode_env == "worker") {
*mode = ProgramMode::Worker;
} else if (mode_env == "supervisor") {
*mode = ProgramMode::Supervisor;
} else {
std::fprintf(stderr,
"wrapper-v2: invalid WRAPPER_MODE='%s' "
"(expected 'supervisor' or 'worker')\n",
mode_env.c_str());
std::exit(2);
}
for (int i = 1; i < argc; ++i) {
std::string_view x = argv[i];
if (x == "--help" || x == "-h") {
std::printf(
"wrapper-v2 daemon (%s)\n"
"Usage: %s\n"
"\n"
"Default mode is a public supervisor HTTP server. Set\n"
"WRAPPER_MODE=worker to start the private Apple runtime server\n"
"used by the supervisor.\n"
"\n"
"Supervisor bind address and port are set with WRAPPER_HOST /\n"
"WRAPPER_PORT (defaults %s / %d). Worker port is set with\n"
"WRAPPER_WORKER_PORT (default %d).\n"
"\n"
"Environment:\n"
" WRAPPER_HOST bind address\n"
" WRAPPER_PORT bind port\n"
" WRAPPER_MODE supervisor or worker\n"
" WRAPPER_WORKER_PORT private worker bind port\n"
" WRAPPER_BASE_DIR Apple-lib working dir\n"
" WRAPPER_DEVICE_INFO 9-tuple device identifier\n"
" WRAPPER_APPLE_INIT set to 0 to skip Apple lib init\n"
" WRAPPER_RESTORE_SESSION set to 0 to skip session restore\n"
" WRAPPER_APPLE_ID optional /me label after restore\n"
" WRAPPER_USERNAME Apple ID for env auto-login (+WRAPPER_PASSWORD)\n"
" WRAPPER_PASSWORD app password for env auto-login\n",
kVersion, argv[0], kDefaultHost, kDefaultPort, kDefaultWorkerPort);
return false;
}
std::fprintf(stderr,
"wrapper-v2: unexpected argument '%s' "
"(use environment variables; try '%s --help')\n",
argv[i], argv[0]);
std::exit(2);
}
return true;
}
void maybe_auto_login_from_env(wrapper::apple::Account& account,
const wrapper::apple::Loader& loader,
const wrapper::apple::Runtime& runtime,
bool apple_init_enabled) {
if (!apple_init_enabled || !runtime.initialized()) {
return;
}
std::string user = env_or("WRAPPER_USERNAME", "");
const char* pw = std::getenv("WRAPPER_PASSWORD");
if (user.empty() || pw == nullptr || *pw == '\0') {
if ((pw != nullptr && *pw != '\0') && user.empty()) {
std::fprintf(stderr,
"wrapper-v2: WRAPPER_PASSWORD is set but WRAPPER_USERNAME "
"is missing; skipping env auto-login\n");
}
return;
}
if (account.state() == wrapper::apple::LoginState::Authenticated) {
return;
}
std::string password(pw);
if (!account.start_login(loader, runtime, std::move(user), std::move(password))) {
std::fprintf(stderr,
"wrapper-v2: env auto-login could not start (state=%s)\n",
wrapper::apple::to_string(account.state()));
return;
}
auto st = account.wait_for_settled_state(std::chrono::milliseconds(30000));
if (st == wrapper::apple::LoginState::Authenticated) {
std::fprintf(stderr, "wrapper-v2: env auto-login succeeded\n");
} else if (st == wrapper::apple::LoginState::Awaiting2FA) {
std::fprintf(stderr,
"wrapper-v2: env auto-login needs 2FA; POST /login/2fa with "
"{\"code\":\"...\"}\n");
} else if (st == wrapper::apple::LoginState::Failed) {
auto snap = account.public_snapshot();
std::fprintf(stderr, "wrapper-v2: env auto-login failed: %s (code %d)\n",
snap.last_error.c_str(), snap.last_error_code);
} else {
std::fprintf(stderr,
"wrapper-v2: env auto-login still in progress or timed out "
"(state=%s)\n",
wrapper::apple::to_string(st));
}
}
} // namespace
int main(int argc, char** argv) {
// Force unbuffered stderr so any prints made before a SIGSEGV are visible.
// Bionic typically does this already, but be defensive — we have lost
// diagnostics to flushing before, especially on aarch64 where DT_INIT
// crashes during dlopen kill the process before fclose() runs.
std::setvbuf(stderr, nullptr, _IONBF, 0);
std::fprintf(stderr,
"wrapper-v2: daemon starting (argv0=%s, pid=%ld)\n",
argc > 0 ? argv[0] : "?", static_cast<long>(getpid()));
ProgramMode mode = ProgramMode::Supervisor;
if (!consume_argv(argc, argv, &mode)) {
return 0;
}
std::string listen_host = env_or("WRAPPER_HOST", kDefaultHost);
int listen_port = env_int("WRAPPER_PORT", kDefaultPort);
const int worker_port = env_int("WRAPPER_WORKER_PORT", kDefaultWorkerPort);
std::signal(SIGINT, on_signal);
std::signal(SIGTERM, on_signal);
std::signal(SIGPIPE, SIG_IGN);
// Install crash handler for a single stderr line with fault_addr + PC/RIP.
{
struct sigaction sa{};
sa.sa_sigaction = on_crash;
sa.sa_flags = SA_SIGINFO | SA_RESETHAND;
sigaction(SIGSEGV, &sa, nullptr);
sigaction(SIGABRT, &sa, nullptr);
}
if (mode == ProgramMode::Supervisor) {
wrapper::Supervisor supervisor(argc > 0 ? argv[0] : "/system/bin/main",
kVersion, worker_port);
httplib::Server svr;
g_server.store(&svr);
supervisor.mount(svr);
std::fprintf(stderr,
"wrapper-v2: %s supervisor listening on %s:%d "
"(worker 127.0.0.1:%d)\n",
kVersion, listen_host.c_str(), listen_port, worker_port);
if (!svr.listen(listen_host, listen_port)) {
std::fprintf(stderr, "wrapper-v2: supervisor bind failed on %s:%d\n",
listen_host.c_str(), listen_port);
return 1;
}
supervisor.stop_worker();
return 0;
}
std::fprintf(stderr, "wrapper-v2: worker mode starting\n");
wrapper::ServerInfo info;
info.version = kVersion;
info.apple_init_enabled = env_bool("WRAPPER_APPLE_INIT", true);
auto& account = wrapper::apple::Account::instance();
wrapper::apple::Loader loader;
auto& runtime = wrapper::apple::Runtime::instance();
std::string libs_dir = env_or("WRAPPER_LIBS_DIR", "/system/lib64");
if (info.apple_init_enabled) {
std::fprintf(stderr, "wrapper-v2: opening Apple libs from %s\n",
libs_dir.c_str());
if (loader.open(libs_dir)) {
wrapper::apple::RuntimeConfig rcfg;
rcfg.base_dir = env_or("WRAPPER_BASE_DIR", rcfg.base_dir);
rcfg.device_info = env_or("WRAPPER_DEVICE_INFO", rcfg.device_info);
if (runtime.initialize(loader, rcfg)) {
if (env_bool("WRAPPER_RESTORE_SESSION", true)) {
const bool restored =
account.try_restore_cached_session(loader, runtime);
if (restored) {
std::fprintf(stderr,
"wrapper-v2: session restored from Apple cache "
"(GET /me without POST /login)\n");
}
} else {
std::fprintf(stderr,
"wrapper-v2: WRAPPER_RESTORE_SESSION=0, skipping "
"cached session probe\n");
}
} else {
std::fprintf(stderr,
"wrapper-v2: Apple runtime init failed after dlopen "
"succeeded; the loaded libs may be from an unexpected "
"Apple Music native library version. Continuing in "
"stub mode.\n");
}
} else {
std::fprintf(stderr,
"wrapper-v2: Apple lib load failed (libs_dir=%s, error=%s). "
"Continuing in stub mode; HTTP API works but Apple-backed "
"endpoints will return 503.\n",
libs_dir.c_str(), loader.last_error().c_str());
}
} else {
std::fprintf(stderr,
"wrapper-v2: WRAPPER_APPLE_INIT=0, skipping Apple lib init "
"(stub mode: /health only; POST /login returns 503)\n");
}
maybe_auto_login_from_env(account, loader, runtime, info.apple_init_enabled);
httplib::Server svr;
g_server.store(&svr);
wrapper::Server server(svr, runtime, loader, account, info);
server.mount();
std::fprintf(stderr, "wrapper-v2: %s worker listening on %s:%d\n", kVersion,
listen_host.c_str(), listen_port);
if (!svr.listen(listen_host, listen_port)) {
std::fprintf(stderr, "wrapper-v2: bind failed on %s:%d\n",
listen_host.c_str(), listen_port);
return 1;
}
return 0;
}
+626
View File
@@ -0,0 +1,626 @@
#include "server.hpp"
#include <atomic>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <exception>
#include <memory>
#include <string>
#include <thread>
#include <vector>
#include <nlohmann/json.hpp>
#include "apple/decrypt.hpp"
#include "apple/playback.hpp"
namespace wrapper {
namespace {
using nlohmann::json;
using namespace std::chrono_literals;
// How long POST /login is willing to block waiting for AuthenticateFlow
// to settle to a terminal state (Authenticated/Failed) or to Awaiting2FA.
// Apple's flow normally completes well within a couple of seconds when
// no 2FA is needed; the credentialHandler dispatch is what controls
// when this returns.
constexpr auto kLoginTimeout = 30s;
// How long POST /login/2fa is willing to block after submitting the
// code. The flow has to make a network round-trip back to Apple.
constexpr auto kLogin2faTimeout = 60s;
constexpr auto kDecryptWatchdogTimeout = 45s;
constexpr int kRestartExitCode = 75;
void respond_json(httplib::Response& res, int status, json body) {
res.status = status;
res.set_content(body.dump(), "application/json");
}
void access_log(const char* method, const httplib::Request& req) {
const std::string& peer = req.remote_addr;
const char* p = peer.empty() ? "-" : peer.c_str();
std::fprintf(stderr, "http: %s %s client=%s\n", method, req.path.c_str(), p);
}
void schedule_process_restart(std::string reason) {
std::thread([reason = std::move(reason)] {
std::this_thread::sleep_for(750ms);
std::fprintf(stderr, "wrapper-v2: hard restart requested: %s\n", reason.c_str());
std::fflush(stderr);
std::_Exit(kRestartExitCode);
}).detach();
}
std::shared_ptr<std::atomic_bool> start_decrypt_watchdog() {
auto done = std::make_shared<std::atomic_bool>(false);
std::thread([done] {
std::this_thread::sleep_for(kDecryptWatchdogTimeout);
if (done->load(std::memory_order_acquire)) return;
std::fprintf(stderr, "wrapper-v2: hard restart requested: POST /decrypt watchdog timeout\n");
std::fflush(stderr);
std::_Exit(kRestartExitCode);
}).detach();
return done;
}
std::string iso8601_utc(std::chrono::system_clock::time_point tp) {
if (tp.time_since_epoch().count() == 0) return {};
auto t = std::chrono::system_clock::to_time_t(tp);
char buf[32];
std::tm tm_utc{};
#if defined(_WIN32)
gmtime_s(&tm_utc, &t);
#else
gmtime_r(&t, &tm_utc);
#endif
std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm_utc);
return buf;
}
json snapshot_to_json(const apple::AccountSnapshot& snap) {
json out;
out["state"] = apple::to_string(snap.state);
if (!snap.apple_id.empty()) {
out["apple_id"] = snap.apple_id;
out["username"] = snap.apple_id;
}
if (snap.state == apple::LoginState::Authenticated) {
if (!snap.storefront.empty()) out["storefront"] = snap.storefront;
if (!snap.dsid.empty()) out["dsid"] = snap.dsid;
if (!snap.music_user_token.empty()) out["music_user_token"] = snap.music_user_token;
if (!snap.dev_token.empty()) out["dev_token"] = snap.dev_token;
out["logged_in_at"] = iso8601_utc(snap.logged_in_at);
}
if (snap.state == apple::LoginState::Failed) {
if (!snap.last_error.empty()) out["error"] = snap.last_error;
if (snap.last_error_code != 0) out["error_code"] = snap.last_error_code;
}
return out;
}
json runtime_to_json(const apple::Loader& loader,
const apple::Runtime& rt,
const ServerInfo& info) {
json runtime = {
{"apple_init_enabled", info.apple_init_enabled},
{"loader_ok", loader.ok()},
{"initialized", rt.initialized()},
{"playback_ready", rt.playback_ready()},
};
if (!loader.ok() && !loader.last_error().empty()) {
runtime["loader_error"] = loader.last_error();
}
if (rt.initialized()) {
runtime["base_dir"] = rt.base_dir();
runtime["device_info"] = rt.device_info();
}
return runtime;
}
bool restore_session_enabled() {
const char* v = std::getenv("WRAPPER_RESTORE_SESSION");
if (v == nullptr || *v == '\0') return true;
return std::strcmp(v, "0") != 0
&& std::strcmp(v, "false") != 0
&& std::strcmp(v, "no") != 0;
}
bool authenticated_or_restored(apple::Account& account,
const apple::Loader& loader,
const apple::Runtime& rt) {
if (account.state() == apple::LoginState::Authenticated) {
return true;
}
if (!restore_session_enabled()) {
return false;
}
return account.try_restore_cached_session(loader, rt)
|| account.state() == apple::LoginState::Authenticated;
}
int http_status_for(apple::LoginState s) {
switch (s) {
case apple::LoginState::Authenticated: return 200;
case apple::LoginState::Awaiting2FA: return 202;
case apple::LoginState::Failed: return 401;
case apple::LoginState::InProgress: return 504;
case apple::LoginState::LoggedOut: return 400;
}
return 500;
}
std::uint32_t read_u32_be(const std::uint8_t* p) {
return (static_cast<std::uint32_t>(p[0]) << 24)
| (static_cast<std::uint32_t>(p[1]) << 16)
| (static_cast<std::uint32_t>(p[2]) << 8)
| static_cast<std::uint32_t>(p[3]);
}
void append_u32_be(std::string* out, std::uint32_t v) {
out->push_back(static_cast<char>((v >> 24) & 0xff));
out->push_back(static_cast<char>((v >> 16) & 0xff));
out->push_back(static_cast<char>((v >> 8) & 0xff));
out->push_back(static_cast<char>(v & 0xff));
}
struct SampleDecryptFrame {
std::string adam_id;
std::string uri;
std::vector<std::vector<std::uint8_t>> samples;
};
bool parse_sample_decrypt_frame(const std::string& body,
SampleDecryptFrame* out,
std::string* error) {
constexpr std::uint32_t kMaxSamples = 100000;
constexpr std::uint32_t kMaxFieldLen = 1024 * 1024;
constexpr std::uint64_t kMaxBodyLen = 256ull * 1024ull * 1024ull;
const auto* data = reinterpret_cast<const std::uint8_t*>(body.data());
const std::size_t size = body.size();
if (size > kMaxBodyLen) {
*error = "request body too large";
return false;
}
if (size < 12) {
*error = "frame too short";
return false;
}
const std::uint32_t adam_len = read_u32_be(data);
const std::uint32_t uri_len = read_u32_be(data + 4);
const std::uint32_t sample_count = read_u32_be(data + 8);
if (adam_len == 0 || uri_len == 0) {
*error = "adam_id and uri must be non-empty";
return false;
}
if (adam_len > kMaxFieldLen || uri_len > kMaxFieldLen) {
*error = "adam_id or uri is too large";
return false;
}
if (sample_count == 0 || sample_count > kMaxSamples) {
*error = "sample_count must be between 1 and 100000";
return false;
}
const std::uint64_t table_bytes = static_cast<std::uint64_t>(sample_count) * 4u;
const std::uint64_t fixed = 12ull + table_bytes + adam_len + uri_len;
if (fixed > size) {
*error = "frame length table exceeds request body";
return false;
}
std::vector<std::uint32_t> lengths;
lengths.reserve(sample_count);
std::uint64_t sample_bytes = 0;
const std::uint8_t* lenp = data + 12;
for (std::uint32_t i = 0; i < sample_count; ++i) {
const std::uint32_t n = read_u32_be(lenp + (static_cast<std::size_t>(i) * 4u));
if (n == 0) {
*error = "sample length must be non-zero";
return false;
}
sample_bytes += n;
if (sample_bytes > kMaxBodyLen) {
*error = "sample payload too large";
return false;
}
lengths.push_back(n);
}
if (fixed + sample_bytes != size) {
*error = "frame size does not match declared lengths";
return false;
}
const char* p = body.data() + 12 + static_cast<std::size_t>(table_bytes);
out->adam_id.assign(p, adam_len);
p += adam_len;
out->uri.assign(p, uri_len);
p += uri_len;
out->samples.clear();
out->samples.reserve(sample_count);
for (std::uint32_t n : lengths) {
const auto* b = reinterpret_cast<const std::uint8_t*>(p);
out->samples.emplace_back(b, b + n);
p += n;
}
return true;
}
bool build_sample_decrypt_frame(const std::vector<std::vector<std::uint8_t>>& samples,
std::string* out) {
if (samples.size() > 0xffffffffu) return false;
std::uint64_t size = 4ull + (static_cast<std::uint64_t>(samples.size()) * 4ull);
for (const auto& sample : samples) {
if (sample.size() > 0xffffffffu) return false;
size += sample.size();
}
if (size > static_cast<std::uint64_t>(out->max_size())) return false;
out->clear();
out->reserve(static_cast<std::size_t>(size));
append_u32_be(out, static_cast<std::uint32_t>(samples.size()));
for (const auto& sample : samples) {
append_u32_be(out, static_cast<std::uint32_t>(sample.size()));
}
for (const auto& sample : samples) {
out->append(reinterpret_cast<const char*>(sample.data()), sample.size());
}
return true;
}
} // namespace
Server::Server(httplib::Server& svr,
apple::Runtime& rt,
apple::Loader& loader,
apple::Account& account,
ServerInfo info)
: svr_(svr), rt_(rt), loader_(loader), account_(account), info_(std::move(info)) {}
void Server::mount() {
// ---- GET /health ----
// Liveness + runtime debug info. Always returns 200 if the
// process is up; consumers should treat runtime.initialized==false
// as a soft failure (auth/decrypt endpoints won't work) rather than a hard one.
svr_.Get("/health", [this](const httplib::Request& req, httplib::Response& res) {
access_log("GET", req);
json runtime = {
{"apple_init_enabled", info_.apple_init_enabled},
{"loader_ok", loader_.ok()},
{"initialized", rt_.initialized()},
{"playback_ready", rt_.playback_ready()},
};
if (!loader_.ok() && !loader_.last_error().empty()) {
runtime["loader_error"] = loader_.last_error();
}
if (rt_.initialized()) {
runtime["base_dir"] = rt_.base_dir();
runtime["device_info"] = rt_.device_info();
}
respond_json(res, 200, json{
{"status", "ok"},
{"version", info_.version},
{"runtime", std::move(runtime)},
});
});
// ---- GET /me ----
// Combined daemon snapshot: version, runtime probe (same facts as
// /health.runtime), and auth (Apple ID state + harvested tokens after
// a successful POST /login). iTunes account-token / X-Token are NOT
// exposed — only dev_token, music_user_token, storefront, dsid.
svr_.Get("/me", [this](const httplib::Request& req, httplib::Response& res) {
access_log("GET", req);
if (rt_.initialized() && restore_session_enabled()) {
(void)authenticated_or_restored(account_, loader_, rt_);
}
json body = {
{"version", info_.version},
{"runtime", runtime_to_json(loader_, rt_, info_)},
{"auth", snapshot_to_json(account_.public_snapshot())},
};
respond_json(res, 200, std::move(body));
});
// ---- POST /login ----
// Body: { "username": "...", "password": "..." }
// or { "apple_id": "...", "password": "..." } (synonyms)
// Returns:
// 200 if AuthenticateFlow completed (state=authenticated, tokens present)
// 202 if Apple asked for HSA2 (state=awaiting_2fa) - follow up with
// POST /login/2fa
// 401 if Apple rejected credentials (state=failed)
// 409 if a login is already in progress
// 503 if the runtime is not initialized
// 504 if the flow has not produced any state inside kLoginTimeout
svr_.Post("/login", [this](const httplib::Request& req, httplib::Response& res) {
access_log("POST", req);
if (!rt_.initialized()) {
respond_json(res, 503, json{
{"error", "runtime_not_initialized"},
{"detail", "Apple lib init has not completed; check /health"},
});
return;
}
json body;
try {
body = json::parse(req.body);
} catch (const std::exception& e) {
respond_json(res, 400, json{{"error", "invalid_json"}, {"detail", e.what()}});
return;
}
if (!body.is_object() || !body.contains("password")
|| !body["password"].is_string()) {
respond_json(res, 400, json{
{"error", "missing_field"},
{"detail", "expected JSON object with string 'password' and "
"'username' or 'apple_id'"},
});
return;
}
std::string login_name;
const bool has_apple =
body.contains("apple_id") && body["apple_id"].is_string();
const bool has_user =
body.contains("username") && body["username"].is_string();
if (has_apple && has_user) {
std::string a = body["apple_id"].get<std::string>();
std::string u = body["username"].get<std::string>();
if (a != u) {
respond_json(res, 400, json{
{"error", "conflicting_identifiers"},
{"detail", "'username' and 'apple_id' must match if both are sent"},
});
return;
}
login_name = std::move(a);
} else if (has_apple) {
login_name = body["apple_id"].get<std::string>();
} else if (has_user) {
login_name = body["username"].get<std::string>();
} else {
respond_json(res, 400, json{
{"error", "missing_field"},
{"detail", "expected 'username' or 'apple_id' string"},
});
return;
}
std::string password = body["password"].get<std::string>();
if (login_name.empty() || password.empty()) {
respond_json(res, 400, json{
{"error", "empty_field"},
{"detail", "username/apple_id and password must be non-empty"},
});
return;
}
if (!account_.start_login(loader_, rt_, std::move(login_name), std::move(password))) {
respond_json(res, 409, json{
{"error", "already_in_progress"},
{"detail", "a login flow is already running; DELETE /login to abort"},
});
return;
}
auto state = account_.wait_for_settled_state(kLoginTimeout);
respond_json(res, http_status_for(state), snapshot_to_json(account_.public_snapshot()));
});
// ---- POST /login/2fa ----
// Body: { "code": "123456" }
// Returns 200 / 401 / 409 / 504 with the same shape as /login.
svr_.Post("/login/2fa", [this](const httplib::Request& req, httplib::Response& res) {
access_log("POST", req);
json body;
try {
body = json::parse(req.body);
} catch (const std::exception& e) {
respond_json(res, 400, json{{"error", "invalid_json"}, {"detail", e.what()}});
return;
}
if (!body.is_object() || !body.contains("code") || !body["code"].is_string()) {
respond_json(res, 400, json{
{"error", "missing_field"},
{"detail", "expected JSON object with string 'code'"},
});
return;
}
std::string code = body["code"].get<std::string>();
if (code.empty()) {
respond_json(res, 400, json{
{"error", "empty_code"},
{"detail", "code must be non-empty"},
});
return;
}
if (!account_.submit_2fa(std::move(code))) {
respond_json(res, 409, json{
{"error", "not_awaiting_2fa"},
{"detail", "no login is currently waiting for a 2FA code; "
"start one with POST /login"},
});
return;
}
auto state = account_.wait_for_settled_state(kLogin2faTimeout);
respond_json(res, http_status_for(state), snapshot_to_json(account_.public_snapshot()));
});
// ---- GET /playback ----
// Returns Apple's full MZ-protocol playback dispatch as native JSON
// (the CFDictionary plist tree walked into nlohmann::json: dict ->
// object, array -> array, string/number/bool -> matching JSON types,
// CFData -> base64 string, CFDate -> ISO 8601). Driven by
// storeservicescore::PurchaseRequest with urlBagKey="subDownload"
// (matching upstream wrapper's get_m3u8_method_download). Unlike
// upstream which extracts just the last asset's URL, we hand back
// every flavor / key URI / metadata field Apple included.
svr_.Get("/playback", [this](const httplib::Request& req, httplib::Response& res) {
access_log("GET", req);
if (!rt_.initialized()) {
respond_json(res, 503, json{
{"error", "runtime_not_initialized"},
{"detail", "Apple lib init has not completed; check /health"},
});
return;
}
if (!authenticated_or_restored(account_, loader_, rt_)) {
respond_json(res, 401, json{
{"error", "not_authenticated"},
{"detail", "POST /login or restore a session first"},
});
return;
}
std::string adam_id;
if (req.has_param("adam_id")) {
adam_id = req.get_param_value("adam_id");
} else if (req.has_param("adamId")) {
adam_id = req.get_param_value("adamId");
} else {
respond_json(res, 400, json{
{"error", "missing_field"},
{"detail", "expected query string '?adam_id=<numeric store id>'"},
});
return;
}
if (adam_id.empty()) {
respond_json(res, 400, json{
{"error", "empty_field"},
{"detail", "adam_id must be non-empty"},
});
return;
}
apple::PlaybackResult pr;
{
// PurchaseRequest drives Apple's URLBag dispatcher; same global
// state /decrypt touches, so share the playback mutex to avoid
// overlap with a concurrent decrypt request.
std::lock_guard<std::mutex> lock(rt_.playback_mutex());
pr = apple::fetch_playback_json(loader_, rt_, std::move(adam_id));
}
if (!pr.ok) {
respond_json(res, 502, json{
{"error", "playback_dispatch_failed"},
{"detail", pr.error},
});
return;
}
respond_json(res, 200, std::move(pr.body));
});
// ---- POST /decrypt ----
// FairPlay sample decrypt. Binary request:
// u32be adam_id_len, u32be uri_len, u32be sample_count,
// u32be sample_len[sample_count], adam_id bytes, uri bytes,
// concatenated ciphertext samples.
// Binary response:
// u32be sample_count, u32be sample_len[sample_count],
// concatenated plaintext samples.
// Requires authenticated + playback_ready.
svr_.Post("/decrypt", [this](const httplib::Request& req, httplib::Response& res) {
access_log("POST", req);
if (!rt_.initialized()) {
respond_json(res, 503, json{
{"error", "runtime_not_initialized"},
{"detail", "Apple lib init has not completed; check /health"},
});
return;
}
if (!rt_.playback_ready()) {
respond_json(res, 503, json{
{"error", "decrypt_unavailable"},
{"detail", "FairPlay playback stack did not initialize; check /health.runtime"},
});
return;
}
if (!authenticated_or_restored(account_, loader_, rt_)) {
respond_json(res, 401, json{
{"error", "not_authenticated"},
{"detail", "POST /login or restore a session first"},
});
return;
}
SampleDecryptFrame frame;
std::string parse_error;
if (!parse_sample_decrypt_frame(req.body, &frame, &parse_error)) {
respond_json(res, 400, json{{"error", "invalid_frame"}, {"detail", parse_error}});
return;
}
auto decrypt_done = start_decrypt_watchdog();
apple::DecryptResult dr;
{
std::lock_guard<std::mutex> lock(rt_.playback_mutex());
dr = apple::decrypt_samples(loader_, rt_, std::move(frame.adam_id), std::move(frame.uri),
std::move(frame.samples));
}
decrypt_done->store(true, std::memory_order_release);
if (!dr.ok) {
schedule_process_restart("POST /decrypt FairPlay failure: " + dr.error);
respond_json(res, 502, json{
{"error", "decrypt_failed"},
{"detail", dr.error},
{"restarting", true},
});
return;
}
std::string response_body;
if (!build_sample_decrypt_frame(dr.plaintexts, &response_body)) {
respond_json(res, 500, json{
{"error", "response_too_large"},
{"detail", "decrypted sample frame is too large"},
});
return;
}
res.status = 200;
res.set_content(response_body, "application/octet-stream");
});
// ---- DELETE /login ----
// Clears in-memory tokens and (if a flow is running) signals the
// worker thread to abort. Apple's kvs.sqlitedb cache is NOT
// touched; the next POST /login will reuse it if still valid.
svr_.Delete("/login", [this](const httplib::Request& req, httplib::Response& res) {
access_log("DELETE", req);
auto prev = account_.state();
account_.logout();
respond_json(res, 200, json{
{"state", apple::to_string(account_.state())},
{"was", apple::to_string(prev)},
});
});
// ---- exception fallback ----
svr_.set_exception_handler([](const httplib::Request& req, httplib::Response& res,
std::exception_ptr ep) {
std::string what = "unknown";
try {
if (ep) std::rethrow_exception(ep);
} catch (const std::exception& e) {
what = e.what();
} catch (...) {
}
std::fprintf(stderr, "http: exception %s %s: %s\n",
req.method.c_str(), req.path.c_str(), what.c_str());
respond_json(res, 500, json{{"error", "internal"}, {"detail", what}});
});
}
} // namespace wrapper
@@ -0,0 +1,48 @@
// HTTP server wiring.
//
// The Server class owns an httplib::Server and mounts the routes
// wrapper-v2 exposes. References to the runtime modules are
// captured by reference - the Server does not own them.
#pragma once
#include <string>
#include <httplib.h>
#include "apple/auth.hpp"
#include "apple/loader.hpp"
#include "apple/runtime.hpp"
namespace wrapper {
struct ServerInfo {
// Free-form version string surfaced via /health and /me.
std::string version = "0.0.1";
// True iff Apple lib initialization is enabled (controlled by
// WRAPPER_APPLE_INIT). Surfaced via /health so it is obvious when
// the daemon is running in stub-only mode.
bool apple_init_enabled = true;
};
class Server {
public:
Server(httplib::Server& svr,
apple::Runtime& rt,
apple::Loader& loader,
apple::Account& account,
ServerInfo info);
// Mount all routes onto the underlying httplib::Server.
void mount();
private:
httplib::Server& svr_;
apple::Runtime& rt_;
apple::Loader& loader_;
apple::Account& account_;
ServerInfo info_;
};
} // namespace wrapper
@@ -0,0 +1,451 @@
#include "supervisor.hpp"
#include <chrono>
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <exception>
#include <mutex>
#include <string>
#include <thread>
#include <utility>
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>
#include <nlohmann/json.hpp>
namespace wrapper {
namespace {
using nlohmann::json;
using namespace std::chrono_literals;
constexpr const char* kWorkerHost = "127.0.0.1";
constexpr int kMaxRecoveryAttempts = 5;
constexpr int kMaxConsecutiveWorkerStartFailures = kMaxRecoveryAttempts;
constexpr int kSupervisorFatalExitCode = 76;
constexpr auto kMaxRecoveryRetryDelay = 8s;
struct WorkerHttpResponse {
bool transport_ok = false;
int status = 502;
std::string body;
std::string content_type = "application/json";
std::string error;
};
} // namespace
class Supervisor::Impl {
public:
Impl(std::string argv0, std::string version, int worker_port)
: argv0_(std::move(argv0)),
version_(std::move(version)),
worker_port_(worker_port) {}
~Impl() {
stop_worker();
}
void mount(httplib::Server& svr) {
svr.Get("/health", [this](const httplib::Request&, httplib::Response& res) {
WorkerHttpResponse wh = request("GET", "/health", "", "", 5s);
json body = {
{"status", "ok"},
{"version", version_},
{"mode", "supervisor"},
{"worker", {
{"reachable", wh.transport_ok},
{"status", wh.transport_ok ? wh.status : 0},
}},
};
if (!wh.transport_ok) body["worker"]["error"] = wh.error;
res.status = 200;
res.set_content(body.dump(), "application/json");
});
svr.Get("/me", [this](const httplib::Request& req, httplib::Response& res) {
proxy_response(res, request("GET", req.target, "", "", 10s));
});
svr.Post("/login", [this](const httplib::Request& req, httplib::Response& res) {
proxy_response(res, request_with_login_recovery(
req.target, req.body,
req.get_header_value("Content-Type")));
});
svr.Post("/login/2fa", [this](const httplib::Request& req, httplib::Response& res) {
proxy_response(res, request("POST", req.target, req.body,
req.get_header_value("Content-Type"), 65s));
});
svr.Delete("/login", [this](const httplib::Request& req, httplib::Response& res) {
proxy_response(res, request("DELETE", req.target, "", "", 10s));
});
svr.Get("/playback", [this](const httplib::Request& req, httplib::Response& res) {
proxy_response(res, request("GET", req.target, "", "", 45s));
});
svr.Post("/decrypt", [this](const httplib::Request& req, httplib::Response& res) {
proxy_response(res, request_with_decrypt_recovery(
req.target, req.body,
req.get_header_value("Content-Type")));
});
svr.set_exception_handler([](const httplib::Request& req, httplib::Response& res,
std::exception_ptr ep) {
std::string what = "unknown";
try {
if (ep) std::rethrow_exception(ep);
} catch (const std::exception& e) {
what = e.what();
} catch (...) {
}
std::fprintf(stderr, "supervisor: exception %s %s: %s\n",
req.method.c_str(), req.path.c_str(), what.c_str());
json body = {{"error", "internal"}, {"detail", what}};
res.status = 500;
res.set_content(body.dump(), "application/json");
});
}
void stop_worker() {
std::lock_guard<std::mutex> lock(mu_);
stop_locked();
}
private:
bool ensure_started() {
std::lock_guard<std::mutex> lock(mu_);
reap_locked();
if (pid_ > 0 && wait_ready_locked(1s)) {
return true;
}
if (pid_ > 0) {
stop_locked();
}
const bool ok = start_locked() && wait_ready_locked(45s);
note_worker_start_result_locked(ok);
return ok;
}
bool restart() {
std::lock_guard<std::mutex> lock(mu_);
stop_locked();
const bool ok = start_locked() && wait_ready_locked(45s);
note_worker_start_result_locked(ok);
return ok;
}
WorkerHttpResponse request(const std::string& method,
const std::string& path,
const std::string& body,
const std::string& content_type,
std::chrono::seconds read_timeout) {
std::lock_guard<std::mutex> lock(request_mu_);
return request_with_transport_recovery_locked(method, path, body, content_type,
read_timeout);
}
WorkerHttpResponse request_with_decrypt_recovery(const std::string& path,
const std::string& body,
const std::string& content_type) {
std::lock_guard<std::mutex> lock(request_mu_);
WorkerHttpResponse last;
for (int attempt = 1; attempt <= kMaxRecoveryAttempts; ++attempt) {
last = request_with_transport_recovery_locked("POST", path, body,
content_type, 50s);
if (last.transport_ok && !should_restart_after_decrypt(last)) {
return last;
}
if (!last.transport_ok || attempt == kMaxRecoveryAttempts) {
return last;
}
std::fprintf(stderr,
"supervisor: restarting worker after decrypt response "
"status=%d (attempt %d/%d)\n",
last.status, attempt, kMaxRecoveryAttempts);
if (!restart_after_delay("decrypt response", attempt)) {
WorkerHttpResponse out;
out.error = "worker restart failed after decrypt response";
return out;
}
}
return last;
}
WorkerHttpResponse request_with_login_recovery(const std::string& path,
const std::string& body,
const std::string& content_type) {
std::lock_guard<std::mutex> lock(request_mu_);
WorkerHttpResponse last;
for (int attempt = 1; attempt <= kMaxRecoveryAttempts; ++attempt) {
last = request_with_transport_recovery_locked("POST", path, body,
content_type, 35s);
if (last.transport_ok && !should_restart_after_login(last)) {
return last;
}
if (!last.transport_ok || attempt == kMaxRecoveryAttempts) {
return last;
}
std::fprintf(stderr,
"supervisor: restarting worker after login response "
"status=%d (attempt %d/%d)\n",
last.status, attempt, kMaxRecoveryAttempts);
if (!restart_after_delay("login response", attempt)) {
WorkerHttpResponse out;
out.error = "worker restart failed after login response";
return out;
}
}
return last;
}
WorkerHttpResponse request_with_transport_recovery_locked(
const std::string& method,
const std::string& path,
const std::string& body,
const std::string& content_type,
std::chrono::seconds read_timeout) {
WorkerHttpResponse out;
for (int attempt = 1; attempt <= kMaxRecoveryAttempts; ++attempt) {
if (!ensure_started()) {
out.error = "worker did not become ready";
} else {
out = request_once(method, path, body, content_type, read_timeout);
if (out.transport_ok) return out;
}
if (attempt == kMaxRecoveryAttempts) {
return out;
}
std::fprintf(stderr,
"supervisor: worker transport failed (%s), restarting "
"(attempt %d/%d)\n",
out.error.c_str(), attempt, kMaxRecoveryAttempts);
const std::string previous_error = out.error;
if (!restart_after_delay("transport error", attempt)) {
WorkerHttpResponse restart_error;
restart_error.error = "worker restart failed after transport error: "
+ previous_error;
return restart_error;
}
}
return out;
}
bool restart_after_delay(const char* reason, int failed_attempt) {
auto delay = std::chrono::seconds(1 << (failed_attempt - 1));
if (delay > kMaxRecoveryRetryDelay) {
delay = kMaxRecoveryRetryDelay;
}
std::fprintf(stderr, "supervisor: waiting %lds before retry after %s\n",
static_cast<long>(delay.count()), reason);
std::this_thread::sleep_for(delay);
return restart();
}
bool start_locked() {
if (pid_ > 0) return true;
pid_t child = fork();
if (child < 0) {
std::fprintf(stderr, "supervisor: fork worker failed\n");
return false;
}
if (child == 0) {
setenv("WRAPPER_HOST", kWorkerHost, 1);
setenv("WRAPPER_PORT", std::to_string(worker_port_).c_str(), 1);
setenv("WRAPPER_MODE", "worker", 1);
execl(argv0_.c_str(), argv0_.c_str(), static_cast<char*>(nullptr));
std::fprintf(stderr, "supervisor: exec worker failed: %s\n", argv0_.c_str());
_exit(127);
}
pid_ = child;
std::fprintf(stderr, "supervisor: started worker pid=%ld port=%d\n",
static_cast<long>(pid_), worker_port_);
return true;
}
void stop_locked() {
if (pid_ <= 0) return;
const pid_t old = pid_;
kill(old, SIGTERM);
for (int i = 0; i < 30; ++i) {
int status = 0;
pid_t r = waitpid(old, &status, WNOHANG);
if (r == old) {
pid_ = -1;
return;
}
usleep(100000);
}
kill(old, SIGKILL);
int status = 0;
(void)waitpid(old, &status, 0);
pid_ = -1;
}
void reap_locked() {
if (pid_ <= 0) return;
int status = 0;
pid_t r = waitpid(pid_, &status, WNOHANG);
if (r == pid_) {
std::fprintf(stderr, "supervisor: worker exited status=%d\n", status);
pid_ = -1;
}
}
bool wait_ready_locked(std::chrono::seconds timeout) {
const auto deadline = std::chrono::steady_clock::now() + timeout;
while (std::chrono::steady_clock::now() < deadline) {
reap_locked();
if (pid_ <= 0) return false;
WorkerHttpResponse r = request_once_unlocked("GET", "/health", "", "", 2s);
if (r.transport_ok) return true;
usleep(200000);
}
return false;
}
void note_worker_start_result_locked(bool ok) {
if (ok) {
consecutive_worker_start_failures_ = 0;
return;
}
++consecutive_worker_start_failures_;
std::fprintf(stderr,
"supervisor: worker start/restart failed (%d/%d)\n",
consecutive_worker_start_failures_,
kMaxConsecutiveWorkerStartFailures);
if (consecutive_worker_start_failures_ >= kMaxConsecutiveWorkerStartFailures) {
std::fprintf(stderr,
"supervisor: worker failed too many times; exiting supervisor\n");
std::fflush(stderr);
std::_Exit(kSupervisorFatalExitCode);
}
}
WorkerHttpResponse request_once(const std::string& method,
const std::string& path,
const std::string& body,
const std::string& content_type,
std::chrono::seconds read_timeout) {
std::lock_guard<std::mutex> lock(mu_);
reap_locked();
if (pid_ <= 0) {
WorkerHttpResponse out;
out.error = "worker is not running";
return out;
}
return request_once_unlocked(method, path, body, content_type, read_timeout);
}
WorkerHttpResponse request_once_unlocked(const std::string& method,
const std::string& path,
const std::string& body,
const std::string& content_type,
std::chrono::seconds read_timeout) {
httplib::Client cli(kWorkerHost, worker_port_);
cli.set_connection_timeout(2, 0);
cli.set_read_timeout(static_cast<time_t>(read_timeout.count()), 0);
cli.set_write_timeout(10, 0);
auto finish = [](httplib::Result res) {
WorkerHttpResponse out;
if (!res) {
out.error = "httplib error " + std::to_string(static_cast<int>(res.error()));
return out;
}
out.transport_ok = true;
out.status = res->status;
out.body = res->body;
auto ct = res->get_header_value("Content-Type");
if (!ct.empty()) out.content_type = std::move(ct);
return out;
};
if (method == "GET") {
return finish(cli.Get(path));
}
if (method == "DELETE") {
return finish(cli.Delete(path));
}
if (method == "POST") {
const std::string ct = content_type.empty() ? "application/octet-stream" : content_type;
return finish(cli.Post(path, body, ct));
}
WorkerHttpResponse out;
out.error = "unsupported proxy method";
return out;
}
static bool should_restart_after_decrypt(const WorkerHttpResponse& r) {
if (!r.transport_ok) return true;
if (r.status != 502 && r.status != 500 && r.status != 504) return false;
if (r.body.find("decrypt_failed") != std::string::npos) return true;
if (r.body.find("FairPlay") != std::string::npos) return true;
if (r.body.find("CKC") != std::string::npos) return true;
if (r.body.find("KDCanProcess") != std::string::npos) return true;
return false;
}
static bool should_restart_after_login(const WorkerHttpResponse& r) {
if (!r.transport_ok) return true;
if (r.status != 401) return false;
return r.body.find("Apple returned response type 4") != std::string::npos;
}
static void proxy_response(httplib::Response& res, const WorkerHttpResponse& wr) {
if (!wr.transport_ok) {
res.status = 503;
json body = {
{"error", "worker_unavailable"},
{"detail", wr.error},
};
res.set_content(body.dump(), "application/json");
return;
}
res.status = wr.status;
res.set_content(wr.body, wr.content_type);
}
std::string argv0_;
std::string version_;
int worker_port_ = 18080;
pid_t pid_ = -1;
int consecutive_worker_start_failures_ = 0;
std::mutex mu_;
std::mutex request_mu_;
};
Supervisor::Supervisor(std::string argv0, std::string version, int worker_port)
: impl_(new Impl(std::move(argv0), std::move(version), worker_port)) {}
Supervisor::~Supervisor() {
delete impl_;
}
void Supervisor::mount(httplib::Server& svr) {
impl_->mount(svr);
}
void Supervisor::stop_worker() {
impl_->stop_worker();
}
} // namespace wrapper
@@ -0,0 +1,27 @@
// Public HTTP supervisor for the private Apple-runtime worker process.
#pragma once
#include <string>
#include <httplib.h>
namespace wrapper {
class Supervisor {
public:
Supervisor(std::string argv0, std::string version, int worker_port);
~Supervisor();
Supervisor(const Supervisor&) = delete;
Supervisor& operator=(const Supervisor&) = delete;
void mount(httplib::Server& svr);
void stop_worker();
private:
class Impl;
Impl* impl_;
};
} // namespace wrapper
+209
View File
@@ -0,0 +1,209 @@
/*
* wrapper-v2 chroot launcher.
*
* Sets up a minimal chroot inside ./rootfs/ that exposes the Android dynamic
* linker (linker64) and Apple Music's native libs to a daemon binary at
* /system/bin/main, then execs the daemon.
*
* Layout (/system/bin/linker64, /system/lib64) is the same for x86_64 and
* arm64-v8a rootfs trees; the host binary must match the image arch (see Dockerfile).
*
* This is the host-Linux-side launcher. It is intentionally tiny and has no
* dependency on the daemon's HTTP code or on the Apple libs.
*
* Differences vs upstream wrapper.c:
* - No gengetopt; the daemon reads configuration from the environment
* (WRAPPER_*). The launcher forwards argv unchanged (use --help only).
* - --base-dir handling is handled by the daemon itself; the launcher
* mkdir -p's WRAPPER_BASE_DIR (or the same default path as the daemon)
* plus mpl_db before exec.
* - SIGTERM is forwarded to the daemon (upstream only handled SIGINT).
*/
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <sched.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mount.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
extern char** environ;
#define CAP_SYS_ADMIN_IDX 21
#define CAP_SYS_ADMIN_BIT (1ULL << CAP_SYS_ADMIN_IDX)
#define ROOTFS "./rootfs"
#define DAEMON_PATH "/system/bin/main"
#define LINKER_PATH "/system/bin/linker64"
/* Same default as daemon RuntimeConfig (runtime.hpp). */
static const char k_default_base_dir[] = "/data/data/com.apple.android.music/files";
static volatile pid_t g_child = -1;
static void on_signal(int sig) {
if (g_child > 0) {
kill(g_child, sig);
}
}
static int has_cap_sys_admin(void) {
FILE* fp = fopen("/proc/self/status", "r");
if (!fp) return 0;
char line[256];
int found = 0;
while (fgets(line, sizeof(line), fp)) {
if (strncmp(line, "CapEff:", 7) == 0) {
char* p = line + 7;
while (*p == ' ' || *p == '\t') ++p;
unsigned long long cap = strtoull(p, NULL, 16);
found = (cap & CAP_SYS_ADMIN_BIT) ? 1 : 0;
break;
}
}
fclose(fp);
return found;
}
static int ensure_dir(const char* path, mode_t mode) {
if (mkdir(path, mode) == 0) return 0;
if (errno == EEXIST) return 0;
fprintf(stderr, "wrapper: mkdir %s: %s\n", path, strerror(errno));
return -1;
}
static int ensure_dir_p(const char* path, mode_t mode) {
if (path == NULL || *path == '\0') return -1;
char buf[1024];
size_t n = strlen(path);
if (n >= sizeof(buf)) {
fprintf(stderr, "wrapper: path too long for mkdir -p\n");
return -1;
}
memcpy(buf, path, n + 1);
for (char* p = buf + 1; *p; ++p) {
if (*p == '/') {
*p = '\0';
if (ensure_dir(buf, mode) != 0) return -1;
*p = '/';
}
}
return ensure_dir(buf, mode);
}
int main(int argc, char* argv[], char* envp[]) {
(void)envp;
struct sigaction sa = {0};
sa.sa_handler = on_signal;
sigaction(SIGINT, &sa, NULL);
sigaction(SIGTERM, &sa, NULL);
if (ensure_dir(ROOTFS "/dev", 0755) != 0) return 1;
int fd = open(ROOTFS "/dev/urandom", O_CREAT | O_RDWR, 0666);
if (fd < 0) {
fprintf(stderr, "wrapper: open %s/dev/urandom: %s\n", ROOTFS, strerror(errno));
return 1;
}
close(fd);
if (mount("/dev/urandom", ROOTFS "/dev/urandom", NULL, MS_BIND, NULL) != 0) {
fprintf(stderr, "wrapper: bind-mount /dev/urandom: %s\n", strerror(errno));
return 1;
}
if (chdir(ROOTFS) != 0) {
fprintf(stderr, "wrapper: chdir " ROOTFS ": %s\n", strerror(errno));
return 1;
}
if (chroot("./") != 0) {
fprintf(stderr, "wrapper: chroot: %s (need CAP_SYS_CHROOT or root)\n", strerror(errno));
return 1;
}
if (ensure_dir("/proc", 0755) != 0) return 1;
chmod(LINKER_PATH, 0755);
chmod(DAEMON_PATH, 0755);
if (has_cap_sys_admin()) {
if (unshare(CLONE_NEWPID) != 0) {
fprintf(stderr, "wrapper: unshare(CLONE_NEWPID): %s\n", strerror(errno));
return 1;
}
}
g_child = fork();
if (g_child < 0) {
fprintf(stderr, "wrapper: fork: %s\n", strerror(errno));
return 1;
}
if (g_child > 0) {
int status = 0;
if (waitpid(g_child, &status, 0) < 0) {
fprintf(stderr, "wrapper: waitpid: %s\n", strerror(errno));
return 1;
}
if (WIFEXITED(status)) return WEXITSTATUS(status);
if (WIFSIGNALED(status)) return 128 + WTERMSIG(status);
return 1;
}
if (mount("proc", "/proc", "proc", 0, NULL) != 0) {
fprintf(stderr, "wrapper: mount proc: %s\n", strerror(errno));
return 1;
}
const char* base_dir = getenv("WRAPPER_BASE_DIR");
if (base_dir == NULL || base_dir[0] == '\0') {
base_dir = k_default_base_dir;
}
if (ensure_dir_p(base_dir, 0777) != 0) return 1;
char db_dir[1024];
if (snprintf(db_dir, sizeof(db_dir), "%s/mpl_db", base_dir) >= (int)sizeof(db_dir)) {
fprintf(stderr, "wrapper: mpl_db path too long\n");
return 1;
}
if (ensure_dir_p(db_dir, 0777) != 0) return 1;
/* Bionic DNS resolver behavior; upstream wrapper sets this in init(). */
setenv("ANDROID_DNS_MODE", "local", 1);
/* Bionic internals (timezone, SSL cert paths, etc.) expect these. */
setenv("ANDROID_DATA", "/data", 0);
setenv("ANDROID_ROOT", "/system", 0);
/* Point libcurl / OpenSSL at the CA bundle so SSL verification works. */
setenv("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt", 0);
setenv("CURL_CA_BUNDLE", "/etc/ssl/certs/ca-certificates.crt", 0);
/* Pass environ so setenv() changes are visible (execve's envp is static). */
/* execve leaves argv[0] as the host path (e.g. /app/wrapper); set argv[0]
* to the daemon so bionic diagnostics and any argv[0]-based logic match. */
const int max_args = 256;
if (argc >= max_args) {
fprintf(stderr, "wrapper: too many arguments (%d max)\n", max_args - 1);
return 1;
}
char* fixed_argv[max_args + 1];
fixed_argv[0] = (char*)DAEMON_PATH;
for (int i = 1; i < argc; ++i) {
fixed_argv[i] = argv[i];
}
/* argv list must be NULL-terminated (C null pointer, not C++ nullptr). */
fixed_argv[argc > 0 ? argc : 1] = 0;
execve(DAEMON_PATH, fixed_argv, environ);
fprintf(stderr, "wrapper: execve %s: %s\n", DAEMON_PATH, strerror(errno));
return 1;
}
+283
View File
@@ -0,0 +1,283 @@
#!/bin/bash
# extract-libs.sh - Extract Apple native libraries from an APKMirror .apkm bundle
# or a standalone arch split .apk, and verify each .so against LIBS_VERSION.json.
#
# The bundle/APK file itself is not hashed; only extracted libraries are checked.
#
# Usage:
# extract-libs.sh --bundle <path-to-.apkm|.apk> [--arch <x86_64|arm64-v8a>] [--out <dir>] [--ignore-hash]
#
# Options:
# --arch <x86_64|arm64-v8a> Which arch's libs to extract (default x86_64)
# --out <directory> Where to drop the .so files (default: <repo>/rootfs/system/lib64)
# --ignore-hash Copy expected files without SHA-256 verification
set -euo pipefail
SCRIPT_DIR="${BASH_SOURCE[0]%/*}"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
LIBS_VERSION="$REPO_ROOT/LIBS_VERSION.json"
BUNDLE=""
ARCH="x86_64"
OUT=""
SENTINEL="libandroidappmusic.so"
IGNORE_HASH=0
while [[ $# -gt 0 ]]; do
case "$1" in
--bundle) BUNDLE="$2"; shift 2 ;;
--arch) ARCH="$2"; shift 2 ;;
--out) OUT="$2"; shift 2 ;;
--ignore-hash) IGNORE_HASH=1; shift ;;
-h|--help)
sed -n '2,13p' "$0"
exit 0
;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
done
if [[ -z "$BUNDLE" ]]; then
echo "extract-libs: missing --bundle <path-to-.apkm|.apk>" >&2
exit 2
fi
if [[ ! -f "$BUNDLE" ]]; then
echo "extract-libs: not a file: $BUNDLE" >&2
exit 2
fi
if [[ -z "$OUT" ]]; then
OUT="$REPO_ROOT/rootfs/system/lib64"
fi
case "$ARCH" in
x86_64) PREFERRED_SPLIT="split_config.x86_64.apk" ;;
arm64-v8a) PREFERRED_SPLIT="split_config.arm64_v8a.apk" ;;
*) echo "extract-libs: unsupported arch '$ARCH'" >&2; exit 2 ;;
esac
for c in jq unzip install; do
command -v "$c" >/dev/null || { echo "extract-libs: $c is required" >&2; exit 3; }
done
sha256_file() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
echo "extract-libs: sha256sum or shasum is required" >&2
return 3
fi
}
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# Return 0 when $1 lists lib/<abi>/$SENTINEL inside the zip at $2.
apk_has_sentinel() {
local lib_dir="$1"
local apk="$2"
unzip -Z1 "$apk" 2>/dev/null | tr -d '\r' | grep -qF "${lib_dir}/${SENTINEL}"
}
# Print lib/<abi> when $SENTINEL is present (first match), else return 1.
detect_lib_dir_in_apk() {
local apk="$1"
local so_path
so_path="$(
unzip -Z1 "$apk" 2>/dev/null | tr -d '\r' |
grep -E "^lib/[^/]+/${SENTINEL}\$" |
head -n1
)"
if [[ -z "$so_path" ]]; then
return 1
fi
echo "${so_path%/$SENTINEL}"
}
# Prefer lib dirs that match the requested --arch, then any lib/* with the sentinel.
pick_lib_dir_for_arch() {
local apk="$1"
local -a candidates=()
case "$ARCH" in
x86_64) candidates=("lib/x86_64" "lib/x86") ;;
arm64-v8a) candidates=("lib/arm64-v8a" "lib/arm64") ;;
esac
local dir
for dir in "${candidates[@]}"; do
if apk_has_sentinel "$dir" "$apk"; then
echo "$dir"
return 0
fi
done
detect_lib_dir_in_apk "$apk"
}
# Extract one zip member from $1 into a unique dir under $TMP; print the file path.
extract_zip_member() {
local zip="$1"
local member="$2"
local dest="$TMP/$(echo "$member" | tr '/: ' '___')"
mkdir -p "$dest"
unzip -qq "$zip" "$member" -d "$dest"
local base
base="$(basename "$member")"
local found
found="$(find "$dest" -name "$base" -type f | head -n1)"
if [[ -z "$found" || ! -f "$found" ]]; then
echo "extract-libs: failed to extract $member from $zip" >&2
return 1
fi
echo "$found"
}
# Score a zip member name for arch relevance (higher = better).
split_name_score() {
local name="$1"
local base="${name##*/}"
local score=0
case "$ARCH" in
x86_64)
[[ "$base" == "$PREFERRED_SPLIT" ]] && score=100
[[ "$base" == *x86_64* || "$base" == *x86-64* ]] && score=$((score + 50))
[[ "$base" == *x86* ]] && score=$((score + 10))
;;
arm64-v8a)
[[ "$base" == "$PREFERRED_SPLIT" ]] && score=100
[[ "$base" == *arm64_v8a* || "$base" == *arm64-v8a* || "$base" == *arm64* ]] && score=$((score + 50))
;;
esac
[[ "$base" == base.apk ]] && score=$((score - 20))
echo "$score"
}
# From an .apkm, pick the inner .apk that actually ships native libs for --arch.
resolve_apk_from_apkm() {
local bundle="$1"
local -a members=()
local member_name
while IFS= read -r member_name; do
members+=("$member_name")
done < <(unzip -Z1 "$bundle" 2>/dev/null | tr -d '\r' | grep -E '\.apk$' || true)
if [[ ${#members[@]} -eq 0 ]]; then
echo "extract-libs: bundle contains no .apk members: $bundle" >&2
return 1
fi
local best_member="" best_score=-1 best_apk="" best_lib=""
local member score lib_dir apk_path
for member in "${members[@]}"; do
score="$(split_name_score "$member")"
[[ "$score" -le 0 ]] && continue
apk_path="$(extract_zip_member "$bundle" "$member")" || continue
lib_dir="$(pick_lib_dir_for_arch "$apk_path" || true)"
if [[ -z "$lib_dir" ]]; then
continue
fi
if [[ "$score" -gt "$best_score" ]]; then
best_score="$score"
best_member="$member"
best_apk="$apk_path"
best_lib="$lib_dir"
fi
done
if [[ -z "$best_apk" ]]; then
# Last resort: any inner .apk that contains the sentinel (any lib/*).
for member in "${members[@]}"; do
apk_path="$(extract_zip_member "$bundle" "$member")" || continue
lib_dir="$(detect_lib_dir_in_apk "$apk_path" || true)"
if [[ -n "$lib_dir" ]]; then
best_member="$member"
best_apk="$apk_path"
best_lib="$lib_dir"
break
fi
done
fi
if [[ -z "$best_apk" ]]; then
echo "extract-libs: no .apk in bundle contains $SENTINEL under lib/<abi>/ (arch=$ARCH)" >&2
echo "extract-libs: members seen:" >&2
printf ' %s\n' "${members[@]}" >&2
return 1
fi
if [[ "$best_member" != "$PREFERRED_SPLIT" ]]; then
echo "extract-libs: using $best_member (not $PREFERRED_SPLIT; that split has no native libs)" >&2
fi
if [[ "$best_lib" != "lib/$ARCH" ]]; then
echo "extract-libs: using lib dir $best_lib inside $(basename "$best_apk")" >&2
fi
APK="$best_apk"
APK_LIB_DIR="$best_lib"
}
case "$BUNDLE" in
*.[aA][pP][kK][mM])
resolve_apk_from_apkm "$BUNDLE"
;;
*.[aA][pP][kK])
APK="$BUNDLE"
APK_LIB_DIR="$(pick_lib_dir_for_arch "$APK" || true)"
if [[ -z "$APK_LIB_DIR" ]]; then
echo "extract-libs: $APK has no lib/<abi>/$SENTINEL (wrong split or --arch?)" >&2
echo "extract-libs: if this is an .apkm, pass the .apkm file instead of a single split" >&2
exit 6
fi
if [[ "$APK_LIB_DIR" != "lib/$ARCH" ]]; then
echo "extract-libs: using lib dir $APK_LIB_DIR" >&2
fi
;;
*)
echo "extract-libs: expected .apkm or .apk extension: $BUNDLE" >&2
exit 2
;;
esac
mkdir -p "$OUT"
LIB_TMP="$TMP/libs"
mkdir -p "$LIB_TMP"
unzip -qq "$APK" "$APK_LIB_DIR/*" -d "$LIB_TMP"
# `jq.exe` on msys2/MSVC emits CRLF on Windows; strip CR defensively before
# iterating, otherwise lib names get a stray \r appended and every lookup fails.
EXPECTED_LIBS=()
while IFS= read -r so; do
EXPECTED_LIBS+=("$so")
done < <(jq -r --arg arch "$ARCH" '.libs[$arch] | keys[]' "$LIBS_VERSION" | tr -d '\r')
[[ ${#EXPECTED_LIBS[@]} -gt 0 ]] || {
echo "extract-libs: no libs pin for arch '$ARCH' in LIBS_VERSION.json" >&2
exit 4
}
ok=0
fail=0
for so in "${EXPECTED_LIBS[@]}"; do
src="$LIB_TMP/$APK_LIB_DIR/$so"
if [[ ! -f "$src" ]]; then
echo "extract-libs: missing in APK: $so" >&2
fail=$((fail+1))
continue
fi
if [[ "$IGNORE_HASH" -eq 0 ]]; then
expect="$(jq -r --arg arch "$ARCH" --arg so "$so" '.libs[$arch][$so]' "$LIBS_VERSION" | tr -d '\r')"
actual="$(sha256_file "$src")"
if [[ "$expect" != "$actual" ]]; then
echo "extract-libs: SHA-256 mismatch on $so" >&2
echo " expected: $expect" >&2
echo " actual: $actual" >&2
fail=$((fail+1))
continue
fi
fi
install -m 0644 "$src" "$OUT/$so"
ok=$((ok+1))
done
if [[ "$IGNORE_HASH" -eq 1 ]]; then
echo "extract-libs: $ok copied, $fail failed (arch=$ARCH out=$OUT from $(basename "$APK") $APK_LIB_DIR hash=ignored)"
else
echo "extract-libs: $ok ok, $fail failed (arch=$ARCH out=$OUT from $(basename "$APK") $APK_LIB_DIR)"
fi
[[ $fail -eq 0 ]]
+100
View File
@@ -0,0 +1,100 @@
#!/bin/bash
# stage-system.sh - Verify and copy committed Android system binaries into
# rootfs/system/. These binaries (linker64 + bionic + a few AOSP libs) are
# committed under vendor/android-system/<arch>/ with SHA-256 pins in
# LIBS_VERSION.json.
#
# Usage:
# tools/stage-system.sh [--arch <x86_64|arm64-v8a>] [--rootfs <path>] [--ignore-hash]
#
# Defaults: --arch x86_64, --rootfs <repo>/rootfs
set -euo pipefail
SCRIPT_DIR="${BASH_SOURCE[0]%/*}"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
LIBS_VERSION="$REPO_ROOT/LIBS_VERSION.json"
ARCH="x86_64"
ROOTFS="$REPO_ROOT/rootfs"
IGNORE_HASH=0
while [[ $# -gt 0 ]]; do
case "$1" in
--arch) ARCH="$2"; shift 2 ;;
--rootfs) ROOTFS="$2"; shift 2 ;;
--ignore-hash) IGNORE_HASH=1; shift ;;
-h|--help)
sed -n '2,10p' "$0"
exit 0
;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
done
VENDOR="$REPO_ROOT/vendor/android-system/$ARCH"
if [[ ! -d "$VENDOR" ]]; then
echo "stage-system: vendor dir does not exist: $VENDOR" >&2
echo " (current arch '$ARCH' may not have committed binaries yet)" >&2
exit 4
fi
for c in jq install; do
command -v "$c" >/dev/null || { echo "stage-system: $c is required" >&2; exit 3; }
done
sha256_file() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
echo "stage-system: sha256sum or shasum is required" >&2
return 3
fi
}
# `jq.exe` on msys2/MSVC emits CRLF on Windows; strip CR defensively before
# iterating, otherwise paths get a stray \r appended and every lookup fails.
EXPECTED=()
while IFS= read -r rel; do
EXPECTED+=("$rel")
done < <(jq -r --arg arch "$ARCH" '.android_system[$arch] | keys[]' "$LIBS_VERSION" | tr -d '\r')
[[ ${#EXPECTED[@]} -gt 0 ]] || { echo "stage-system: no .android_system.$ARCH section in LIBS_VERSION.json" >&2; exit 4; }
ok=0
fail=0
for rel in "${EXPECTED[@]}"; do
src="$VENDOR/$rel"
if [[ ! -f "$src" ]]; then
echo "stage-system: missing committed file: vendor/android-system/$ARCH/$rel" >&2
fail=$((fail+1))
continue
fi
if [[ "$IGNORE_HASH" -eq 0 ]]; then
expect="$(jq -r --arg arch "$ARCH" --arg p "$rel" '.android_system[$arch][$p]' "$LIBS_VERSION" | tr -d '\r')"
actual="$(sha256_file "$src")"
if [[ "$expect" != "$actual" ]]; then
echo "stage-system: SHA-256 mismatch on $rel" >&2
echo " expected: $expect" >&2
echo " actual: $actual" >&2
fail=$((fail+1))
continue
fi
fi
case "$rel" in
bin/*) mode=0755 ;;
lib64/*) mode=0644 ;;
*) mode=0644 ;;
esac
dest="$ROOTFS/system/$rel"
mkdir -p "$(dirname "$dest")"
install -m "$mode" "$src" "$dest"
ok=$((ok+1))
done
if [[ "$IGNORE_HASH" -eq 1 ]]; then
echo "stage-system: $ok copied, $fail failed (arch=$ARCH rootfs=$ROOTFS hash=ignored)"
else
echo "stage-system: $ok ok, $fail failed (arch=$ARCH rootfs=$ROOTFS)"
fi
[[ $fail -eq 0 ]]
+61
View File
@@ -0,0 +1,61 @@
# vendor/android-system
Android system binaries needed at runtime by Apple's `.so` files inside the
chroot. Committed here so the build is fully self-contained except for the
non-redistributable Apple libraries.
## What's included
Per arch under `<arch>/`:
| File | Source | Why |
| --------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `bin/linker64` | AOSP `/system/bin/` | Android dynamic loader; resolves all `.so` dependencies. |
| `lib64/libc.so` | bionic | Mandatory C runtime. |
| `lib64/libm.so` | bionic | Math. |
| `lib64/libstdc++.so` | bionic | Tiny stub. `libCoreFP`, `libCoreLSKD`, `libcurl` link against it. |
| `lib64/liblog.so` | AOSP | Android logging. |
| `lib64/libz.so` | AOSP | zlib. |
| `lib64/libandroid.so` | AOSP | NDK runtime API (loaded by 6 Apple libs). |
| `lib64/libOpenSLES.so` | AOSP | Stub satisfying `DT_NEEDED` of `libandroidappmusic`. We never call into it. |
| `usr/share/zoneinfo/tzdata` | AOSP | Bionic timezone database. Optional; only suppresses `__bionic_open_tzdata` warnings on every `localtime`/`gmtime` call. Extract from any factory image at the same path (`simg2img system.img system.raw && debugfs -R "dump usr/share/zoneinfo/tzdata /tmp/tzdata" system.raw`). |
`libdl.so` is intentionally absent. Android's `linker64` itself exports the
`dl_*` symbols, so a separate `libdl.so` file is not required at runtime.
This matches how AOSP system images are laid out.
## License
These binaries are Apache-2.0 (AOSP). Redistribution is permitted; see
`LICENSE` at the repo root for the wrapper-v2 project license.
## Provenance
The **x86_64** binaries were taken verbatim from
[`zhaarey/wrapper`](https://github.com/zhaarey/wrapper) `rootfs/system/`,
which sourced them from an Android x86_64 system image (likely API 21-29
era based on file sizes and exported symbols).
The **arm64-v8a** set was taken verbatim from
[`WorldObservationLog/wrapper`](https://github.com/WorldObservationLog/wrapper)
branch **`arm64`** `rootfs/system/` (same layout as x86: `bin/linker64`,
`lib64/*.so`, etc.). The IANA timezone blob `usr/share/zoneinfo/tzdata` is
the same bytes on both arches (single file path; identical SHA-256 pin).
SHA-256 digests for both arches are pinned in
[`LIBS_VERSION.json`](../../LIBS_VERSION.json) under `.android_system.<arch>`
and verified by `tools/stage-system.sh`.
**Future work:** when reproducible provenance becomes important, replace
these blobs with a `tools/fetch-android-system.sh` step that pulls a
specific Google emulator system image (e.g.
`https://dl.google.com/android/repository/sys-img/android/x86_64-29_r07.zip`),
runs `simg2img` + `debugfs` to extract the same eight files, and compares
the result against the pins committed here. The pins should not need to
change.
## Adding a new arch
For any new ABI, add `vendor/android-system/<arch>/` with the same relative
paths as the existing arches, plus a matching `.android_system.<arch>` entry
in `LIBS_VERSION.json`.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.