diff --git a/docker-compose.yml b/docker-compose.yml index e830d7c8..6f66ace1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -182,7 +182,7 @@ services: gamdl-aio: build: - context: ../gamdl-aio + context: ./gamdl-aio args: TARGET_ARCH: arm64-v8a RUNTIME_PLATFORM: linux/arm64 diff --git a/gamdl-aio/Dockerfile b/gamdl-aio/Dockerfile new file mode 100644 index 00000000..5ff71675 --- /dev/null +++ b/gamdl-aio/Dockerfile @@ -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"] diff --git a/gamdl-aio/compose.yaml b/gamdl-aio/compose.yaml new file mode 100644 index 00000000..ea9d0e27 --- /dev/null +++ b/gamdl-aio/compose.yaml @@ -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 diff --git a/gamdl-aio/gamdl/.gitignore b/gamdl-aio/gamdl/.gitignore new file mode 100644 index 00000000..e5fd28c7 --- /dev/null +++ b/gamdl-aio/gamdl/.gitignore @@ -0,0 +1,8 @@ +/* +__pycache__ +!gamdl +!.gitignore +!.python-version +!pyproject.toml +!README.md +!uv.lock diff --git a/gamdl-aio/gamdl/.python-version b/gamdl-aio/gamdl/.python-version new file mode 100644 index 00000000..c8cfe395 --- /dev/null +++ b/gamdl-aio/gamdl/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/gamdl-aio/gamdl/README.md b/gamdl-aio/gamdl/README.md new file mode 100644 index 00000000..06d3b384 --- /dev/null +++ b/gamdl-aio/gamdl/README.md @@ -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:** + +## ✨ 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 | `/.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. diff --git a/gamdl-aio/gamdl/gamdl/__init__.py b/gamdl-aio/gamdl/gamdl/__init__.py new file mode 100644 index 00000000..4c9dba56 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/__init__.py @@ -0,0 +1 @@ +__version__ = "3.8" diff --git a/gamdl-aio/gamdl/gamdl/__main__.py b/gamdl-aio/gamdl/gamdl/__main__.py new file mode 100644 index 00000000..34c69a11 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/__main__.py @@ -0,0 +1,3 @@ +from .cli.cli import main + +main() diff --git a/gamdl-aio/gamdl/gamdl/api/__init__.py b/gamdl-aio/gamdl/gamdl/api/__init__.py new file mode 100644 index 00000000..8d167537 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/api/__init__.py @@ -0,0 +1,4 @@ +from .apple_music import AppleMusicApi +from .exceptions import * +from .itunes import ItunesApi +from .wrapper import WrapperApi diff --git a/gamdl-aio/gamdl/gamdl/api/apple_music.py b/gamdl-aio/gamdl/gamdl/api/apple_music.py new file mode 100644 index 00000000..3fc798e6 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/api/apple_music.py @@ -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 diff --git a/gamdl-aio/gamdl/gamdl/api/constants.py b/gamdl-aio/gamdl/gamdl/api/constants.py new file mode 100644 index 00000000..fb2ffbf6 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/api/constants.py @@ -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}" diff --git a/gamdl-aio/gamdl/gamdl/api/exceptions.py b/gamdl-aio/gamdl/gamdl/api/exceptions.py new file mode 100644 index 00000000..07bbfd39 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/api/exceptions.py @@ -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) diff --git a/gamdl-aio/gamdl/gamdl/api/itunes.py b/gamdl-aio/gamdl/gamdl/api/itunes.py new file mode 100644 index 00000000..e9ac0ad0 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/api/itunes.py @@ -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 diff --git a/gamdl-aio/gamdl/gamdl/api/wrapper.py b/gamdl-aio/gamdl/gamdl/api/wrapper.py new file mode 100644 index 00000000..aa9f9c8b --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/api/wrapper.py @@ -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 diff --git a/gamdl-aio/gamdl/gamdl/cli/__init__.py b/gamdl-aio/gamdl/gamdl/cli/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gamdl-aio/gamdl/gamdl/cli/cli.py b/gamdl-aio/gamdl/gamdl/cli/cli.py new file mode 100644 index 00000000..a53e84d3 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/cli/cli.py @@ -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)") diff --git a/gamdl-aio/gamdl/gamdl/cli/cli_config.py b/gamdl-aio/gamdl/gamdl/cli/cli_config.py new file mode 100644 index 00000000..1dc8bb79 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/cli/cli_config.py @@ -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, + ), + ] diff --git a/gamdl-aio/gamdl/gamdl/cli/config_file.py b/gamdl-aio/gamdl/gamdl/cli/config_file.py new file mode 100644 index 00000000..34e7b950 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/cli/config_file.py @@ -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 diff --git a/gamdl-aio/gamdl/gamdl/cli/constants.py b/gamdl-aio/gamdl/gamdl/cli/constants.py new file mode 100644 index 00000000..a07301d4 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/cli/constants.py @@ -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 "{}"' diff --git a/gamdl-aio/gamdl/gamdl/cli/database.py b/gamdl-aio/gamdl/gamdl/cli/database.py new file mode 100644 index 00000000..2e6fce2c --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/cli/database.py @@ -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 + ) diff --git a/gamdl-aio/gamdl/gamdl/cli/interactive_prompts.py b/gamdl-aio/gamdl/gamdl/cli/interactive_prompts.py new file mode 100644 index 00000000..4094dc1e --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/cli/interactive_prompts.py @@ -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 diff --git a/gamdl-aio/gamdl/gamdl/cli/utils.py b/gamdl-aio/gamdl/gamdl/cli/utils.py new file mode 100644 index 00000000..8e808596 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/cli/utils.py @@ -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 diff --git a/gamdl-aio/gamdl/gamdl/downloader/__init__.py b/gamdl-aio/gamdl/gamdl/downloader/__init__.py new file mode 100644 index 00000000..f39497ce --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/downloader/__init__.py @@ -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 diff --git a/gamdl-aio/gamdl/gamdl/downloader/amdecrypt.py b/gamdl-aio/gamdl/gamdl/downloader/amdecrypt.py new file mode 100644 index 00000000..52f66207 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/downloader/amdecrypt.py @@ -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 diff --git a/gamdl-aio/gamdl/gamdl/downloader/base.py b/gamdl-aio/gamdl/gamdl/downloader/base.py new file mode 100644 index 00000000..5618c83e --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/downloader/base.py @@ -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 diff --git a/gamdl-aio/gamdl/gamdl/downloader/constants.py b/gamdl-aio/gamdl/gamdl/downloader/constants.py new file mode 100644 index 00000000..d8a517c2 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/downloader/constants.py @@ -0,0 +1,3 @@ +TEMP_PATH_TEMPLATE = "gamdl_temp_{}" +ILLEGAL_CHARS_RE = r'[\\/:*?"<>|;]' +ILLEGAL_CHAR_REPLACEMENT = "_" diff --git a/gamdl-aio/gamdl/gamdl/downloader/downloader.py b/gamdl-aio/gamdl/gamdl/downloader/downloader.py new file mode 100644 index 00000000..e285bcea --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/downloader/downloader.py @@ -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") diff --git a/gamdl-aio/gamdl/gamdl/downloader/enums.py b/gamdl-aio/gamdl/gamdl/downloader/enums.py new file mode 100644 index 00000000..f7cc5ebd --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/downloader/enums.py @@ -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" diff --git a/gamdl-aio/gamdl/gamdl/downloader/exceptions.py b/gamdl-aio/gamdl/gamdl/downloader/exceptions.py new file mode 100644 index 00000000..4561b5c7 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/downloader/exceptions.py @@ -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}") diff --git a/gamdl-aio/gamdl/gamdl/downloader/music_video.py b/gamdl-aio/gamdl/gamdl/downloader/music_video.py new file mode 100644 index 00000000..8252898b --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/downloader/music_video.py @@ -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, + ) diff --git a/gamdl-aio/gamdl/gamdl/downloader/song.py b/gamdl-aio/gamdl/gamdl/downloader/song.py new file mode 100644 index 00000000..567375ff --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/downloader/song.py @@ -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, + ) diff --git a/gamdl-aio/gamdl/gamdl/downloader/types.py b/gamdl-aio/gamdl/gamdl/downloader/types.py new file mode 100644 index 00000000..a049d6e6 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/downloader/types.py @@ -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 diff --git a/gamdl-aio/gamdl/gamdl/downloader/uploaded_video.py b/gamdl-aio/gamdl/gamdl/downloader/uploaded_video.py new file mode 100644 index 00000000..0b88cfef --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/downloader/uploaded_video.py @@ -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, + ) diff --git a/gamdl-aio/gamdl/gamdl/interface/__init__.py b/gamdl-aio/gamdl/gamdl/interface/__init__.py new file mode 100644 index 00000000..31dddfe8 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/interface/__init__.py @@ -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 diff --git a/gamdl-aio/gamdl/gamdl/interface/base.py b/gamdl-aio/gamdl/gamdl/interface/base.py new file mode 100644 index 00000000..562a75ff --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/interface/base.py @@ -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 diff --git a/gamdl-aio/gamdl/gamdl/interface/constants.py b/gamdl-aio/gamdl/gamdl/interface/constants.py new file mode 100644 index 00000000..1a71292d --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/interface/constants.py @@ -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[a-z]{2})" + r"/(?Partist|album|playlist|song|music-video|post)" + r"(?:/(?P[^\s/]+))?" + r"/(?P[0-9]+|pl\.[0-9a-z]{32}|pl\.u-[a-zA-Z0-9]+)" + r"(?:\?i=(?P[0-9]+))?" + r"|" + r"(?:/(?P[a-z]{2}))?" + r"/library/(?Pplaylist|albums|songs|music-videos)" + r"/(?P[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", +} diff --git a/gamdl-aio/gamdl/gamdl/interface/enums.py b/gamdl-aio/gamdl/gamdl/interface/enums.py new file mode 100644 index 00000000..89280396 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/interface/enums.py @@ -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] diff --git a/gamdl-aio/gamdl/gamdl/interface/exceptions.py b/gamdl-aio/gamdl/gamdl/interface/exceptions.py new file mode 100644 index 00000000..01d0b2e7 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/interface/exceptions.py @@ -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 diff --git a/gamdl-aio/gamdl/gamdl/interface/interface.py b/gamdl-aio/gamdl/gamdl/interface/interface.py new file mode 100644 index 00000000..d9678e47 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/interface/interface.py @@ -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 diff --git a/gamdl-aio/gamdl/gamdl/interface/music_video.py b/gamdl-aio/gamdl/gamdl/interface/music_video.py new file mode 100644 index 00000000..49eee418 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/interface/music_video.py @@ -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 diff --git a/gamdl-aio/gamdl/gamdl/interface/song.py b/gamdl-aio/gamdl/gamdl/interface/song.py new file mode 100644 index 00000000..3d1b73a3 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/interface/song.py @@ -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 diff --git a/gamdl-aio/gamdl/gamdl/interface/types.py b/gamdl-aio/gamdl/gamdl/interface/types.py new file mode 100644 index 00000000..3dce8351 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/interface/types.py @@ -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 diff --git a/gamdl-aio/gamdl/gamdl/interface/uploaded_video.py b/gamdl-aio/gamdl/gamdl/interface/uploaded_video.py new file mode 100644 index 00000000..a05880b0 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/interface/uploaded_video.py @@ -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 diff --git a/gamdl-aio/gamdl/gamdl/interface/wvd.py b/gamdl-aio/gamdl/gamdl/interface/wvd.py new file mode 100644 index 00000000..1f5f21f9 --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/interface/wvd.py @@ -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==""" diff --git a/gamdl-aio/gamdl/gamdl/utils.py b/gamdl-aio/gamdl/gamdl/utils.py new file mode 100644 index 00000000..adfd829b --- /dev/null +++ b/gamdl-aio/gamdl/gamdl/utils.py @@ -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 diff --git a/gamdl-aio/gamdl/pyproject.toml b/gamdl-aio/gamdl/pyproject.toml new file mode 100644 index 00000000..7ac0ebc4 --- /dev/null +++ b/gamdl-aio/gamdl/pyproject.toml @@ -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", +] diff --git a/gamdl-aio/gamdl/uv.lock b/gamdl-aio/gamdl/uv.lock new file mode 100644 index 00000000..42588a0e --- /dev/null +++ b/gamdl-aio/gamdl/uv.lock @@ -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" }, +] diff --git a/gamdl-aio/main.py b/gamdl-aio/main.py new file mode 100644 index 00000000..a134cc37 --- /dev/null +++ b/gamdl-aio/main.py @@ -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) diff --git a/gamdl-aio/requirements.txt b/gamdl-aio/requirements.txt new file mode 100644 index 00000000..db589623 --- /dev/null +++ b/gamdl-aio/requirements.txt @@ -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 diff --git a/gamdl-aio/wrapper-v2/.dockerignore b/gamdl-aio/wrapper-v2/.dockerignore new file mode 100644 index 00000000..2991f94b --- /dev/null +++ b/gamdl-aio/wrapper-v2/.dockerignore @@ -0,0 +1,17 @@ +.git/ +.github/ +.tmp/ +.cache/ +build/ +build-*/ +cmake-build-*/ + +*.apk +*.apkm +*.md +!README.md + +.vscode/ +.idea/ +.DS_Store +Thumbs.db diff --git a/gamdl-aio/wrapper-v2/.editorconfig b/gamdl-aio/wrapper-v2/.editorconfig new file mode 100644 index 00000000..0e91ae12 --- /dev/null +++ b/gamdl-aio/wrapper-v2/.editorconfig @@ -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 diff --git a/gamdl-aio/wrapper-v2/.env b/gamdl-aio/wrapper-v2/.env new file mode 100644 index 00000000..1d119512 --- /dev/null +++ b/gamdl-aio/wrapper-v2/.env @@ -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 diff --git a/gamdl-aio/wrapper-v2/.env.example b/gamdl-aio/wrapper-v2/.env.example new file mode 100644 index 00000000..95ed3282 --- /dev/null +++ b/gamdl-aio/wrapper-v2/.env.example @@ -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 diff --git a/gamdl-aio/wrapper-v2/.gitattributes b/gamdl-aio/wrapper-v2/.gitattributes new file mode 100644 index 00000000..8040ceaa --- /dev/null +++ b/gamdl-aio/wrapper-v2/.gitattributes @@ -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 diff --git a/gamdl-aio/wrapper-v2/.gitignore b/gamdl-aio/wrapper-v2/.gitignore new file mode 100644 index 00000000..df69b9f8 --- /dev/null +++ b/gamdl-aio/wrapper-v2/.gitignore @@ -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 diff --git a/gamdl-aio/wrapper-v2/CMakeLists.txt b/gamdl-aio/wrapper-v2/CMakeLists.txt new file mode 100644 index 00000000..1bb3ad46 --- /dev/null +++ b/gamdl-aio/wrapper-v2/CMakeLists.txt @@ -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 + $ + ${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=$,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() diff --git a/gamdl-aio/wrapper-v2/Dockerfile b/gamdl-aio/wrapper-v2/Dockerfile new file mode 100644 index 00000000..87d361aa --- /dev/null +++ b/gamdl-aio/wrapper-v2/Dockerfile @@ -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"] diff --git a/gamdl-aio/wrapper-v2/LIBS_VERSION.json b/gamdl-aio/wrapper-v2/LIBS_VERSION.json new file mode 100644 index 00000000..51706d86 --- /dev/null +++ b/gamdl-aio/wrapper-v2/LIBS_VERSION.json @@ -0,0 +1,225 @@ +{ + "$comment": "Pinned native library hashes. Apple .so files are not committed; when staged manually they should match .libs.. 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//. 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" + } + } +} diff --git a/gamdl-aio/wrapper-v2/LICENSE b/gamdl-aio/wrapper-v2/LICENSE new file mode 100644 index 00000000..0f0d1de1 --- /dev/null +++ b/gamdl-aio/wrapper-v2/LICENSE @@ -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 diff --git a/gamdl-aio/wrapper-v2/README.md b/gamdl-aio/wrapper-v2/README.md new file mode 100644 index 00000000..40a13263 --- /dev/null +++ b/gamdl-aio/wrapper-v2/README.md @@ -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=`. 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. diff --git a/gamdl-aio/wrapper-v2/compose.yaml b/gamdl-aio/wrapper-v2/compose.yaml new file mode 100644 index 00000000..04196360 --- /dev/null +++ b/gamdl-aio/wrapper-v2/compose.yaml @@ -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 diff --git a/gamdl-aio/wrapper-v2/src/daemon/CMakeLists.txt b/gamdl-aio/wrapper-v2/src/daemon/CMakeLists.txt new file mode 100644 index 00000000..8d7f83f1 --- /dev/null +++ b/gamdl-aio/wrapper-v2/src/daemon/CMakeLists.txt @@ -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 ". 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=/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}" +) diff --git a/gamdl-aio/wrapper-v2/src/daemon/apple/aarch64_sret_thunks.hpp b/gamdl-aio/wrapper-v2/src/daemon/apple/aarch64_sret_thunks.hpp new file mode 100644 index 00000000..38ad0ed9 --- /dev/null +++ b/gamdl-aio/wrapper-v2/src/daemon/apple/aarch64_sret_thunks.hpp @@ -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 + +#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(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(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(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(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(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(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(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(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(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(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(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(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(fn); + // 32-byte scratch buffer for the sret return value (we discard it). + alignas(8) std::uint8_t scratch[32] = {}; + void* sret = reinterpret_cast(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 diff --git a/gamdl-aio/wrapper-v2/src/daemon/apple/abi.hpp b/gamdl-aio/wrapper-v2/src/daemon/apple/abi.hpp new file mode 100644 index 00000000..a70948eb --- /dev/null +++ b/gamdl-aio/wrapper-v2/src/daemon/apple/abi.hpp @@ -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 +#include +#include +#include + +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 +// etc. Mismatching this layout WILL miscompile silently; tread +// carefully. + +// std::__ndk1::shared_ptr 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 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 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 +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::make_shared(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 const&) +using fn_RequestContextManager_configure = void (*)(shared_ptr* req_ctx); + +// storeservicescore::RequestContext::init(shared_ptr 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::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 const&) +using fn_API_handleCredentialsResponse = + void (*)(void* this_, shared_ptr* cred_response); + +// AndroidPresentationInterface::handleProtocolDialogResponse( +// long const&, shared_ptr 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 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::make_shared(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 +// 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; +// 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 const&, +// shared_ptr 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 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 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* +// (returns a pointer to an internal member; same shape as URLRequest::response) +using fn_PurchaseRequest_response = shared_ptr* (*)(void* this_); + +// PurchaseResponse::error() -> shared_ptr* +// (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> +// 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 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 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 const&, +// std::function +// const&)> const&) [upstream main.cpp uses std::function 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 diff --git a/gamdl-aio/wrapper-v2/src/daemon/apple/auth.cpp b/gamdl-aio/wrapper-v2/src/daemon/apple/auth.cpp new file mode 100644 index 00000000..b1b35939 --- /dev/null +++ b/gamdl-aio/wrapper-v2/src/daemon/apple/auth.cpp @@ -0,0 +1,383 @@ +#include "apple/auth.hpp" + +#include +#include +#include +#include + +#include +#include + +#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 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 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 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 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 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 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 g(mu_); + aborted_ = true; + cv_2fa_.notify_all(); + cv_state_.notify_all(); + } + if (worker_.joinable()) { + worker_.join(); + } + std::lock_guard 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 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 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 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 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 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 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 diff --git a/gamdl-aio/wrapper-v2/src/daemon/apple/auth.hpp b/gamdl-aio/wrapper-v2/src/daemon/apple/auth.hpp new file mode 100644 index 00000000..be27cdcf --- /dev/null +++ b/gamdl-aio/wrapper-v2/src/daemon/apple/auth.hpp @@ -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 +#include +#include +#include +#include +#include +#include +#include + +#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 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 diff --git a/gamdl-aio/wrapper-v2/src/daemon/apple/decrypt.cpp b/gamdl-aio/wrapper-v2/src/daemon/apple/decrypt.cpp new file mode 100644 index 00000000..c9d730d3 --- /dev/null +++ b/gamdl-aio/wrapper-v2/src/daemon/apple/decrypt.cpp @@ -0,0 +1,198 @@ +#include "apple/decrypt.hpp" + +#include +#include +#include +#include +#include +#include + +#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 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 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 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 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> 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> 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(*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 diff --git a/gamdl-aio/wrapper-v2/src/daemon/apple/decrypt.hpp b/gamdl-aio/wrapper-v2/src/daemon/apple/decrypt.hpp new file mode 100644 index 00000000..17218eba --- /dev/null +++ b/gamdl-aio/wrapper-v2/src/daemon/apple/decrypt.hpp @@ -0,0 +1,29 @@ +// FairPlay FPS sample decryption (POST /decrypt). Mirrors upstream +// wrapper main.c:getKdContext + NfcRKVnxuKZy04KWbdFu71Ou. + +#pragma once + +#include +#include +#include + +namespace wrapper::apple { + +class Loader; +class Runtime; + +struct DecryptResult { + bool ok = false; + std::string error; + std::vector> 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> ciphertexts); + +} // namespace wrapper::apple diff --git a/gamdl-aio/wrapper-v2/src/daemon/apple/fairplay_cert.inc b/gamdl-aio/wrapper-v2/src/daemon/apple/fairplay_cert.inc new file mode 100644 index 00000000..2ece4bb3 --- /dev/null +++ b/gamdl-aio/wrapper-v2/src/daemon/apple/fairplay_cert.inc @@ -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"; diff --git a/gamdl-aio/wrapper-v2/src/daemon/apple/loader.cpp b/gamdl-aio/wrapper-v2/src/daemon/apple/loader.cpp new file mode 100644 index 00000000..f734cbd9 --- /dev/null +++ b/gamdl-aio/wrapper-v2/src/daemon/apple/loader.cpp @@ -0,0 +1,450 @@ +#include "apple/loader.hpp" + +#include "apple/aarch64_sret_thunks.hpp" + +#include +#include +#include +#include + +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 +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(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(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(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(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(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( + 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( + 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 diff --git a/gamdl-aio/wrapper-v2/src/daemon/apple/loader.hpp b/gamdl-aio/wrapper-v2/src/daemon/apple/loader.hpp new file mode 100644 index 00000000..6f02978b --- /dev/null +++ b/gamdl-aio/wrapper-v2/src/daemon/apple/loader.hpp @@ -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 + +#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. 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 diff --git a/gamdl-aio/wrapper-v2/src/daemon/apple/playback.cpp b/gamdl-aio/wrapper-v2/src/daemon/apple/playback.cpp new file mode 100644 index 00000000..35fc7f34 --- /dev/null +++ b/gamdl-aio/wrapper-v2/src/daemon/apple/playback.cpp @@ -0,0 +1,384 @@ +#include "apple/playback.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#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; +using URLRespPreprocFn = std::function; + +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 buf(static_cast(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 keys(static_cast(n)); + std::vector vals(static_cast(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(n)); + } + if (tid == s.CFDateGetTypeID()) { + double abs_t = s.CFDateGetAbsoluteTime(cf); + double unix_secs = abs_t + kCFAbsoluteTimeIntervalSince1970; + std::time_t t = static_cast(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(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(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 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 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(items.begin); + const auto* end = static_cast(items.end); + const std::size_t count = (end > begin) + ? static_cast(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 diff --git a/gamdl-aio/wrapper-v2/src/daemon/apple/playback.hpp b/gamdl-aio/wrapper-v2/src/daemon/apple/playback.hpp new file mode 100644 index 00000000..25cebb9d --- /dev/null +++ b/gamdl-aio/wrapper-v2/src/daemon/apple/playback.hpp @@ -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 + +#include + +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 diff --git a/gamdl-aio/wrapper-v2/src/daemon/apple/runtime.cpp b/gamdl-aio/wrapper-v2/src/daemon/apple/runtime.cpp new file mode 100644 index 00000000..5f919dd7 --- /dev/null +++ b/gamdl-aio/wrapper-v2/src/daemon/apple/runtime.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include + +#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 g_end_lease_fn(end_lease_cb); +std::function 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 split_device_info(const std::string& s) { + std::vector 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(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(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 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(::operator new(72)); + std::memset(emplace + 8, 0, 16); + *reinterpret_cast(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 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(buttons->begin); + auto* b_end = static_cast(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(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(::operator new(80)); + std::memset(emplace + 8, 0, 16); + *reinterpret_cast(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 lock(playback_mutex_); + s.SVPlaybackLeaseManager_requestLease(lease_mgr_, &autom); +} + +bool Runtime::initialize(const Loader& loader, const RuntimeConfig& cfg) { + std::lock_guard 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& 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 + 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& parts) { + // make_shared(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"); + 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 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(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() + trace("init_presentation_interface: make_shared"); + aarch64_sret::make_shared_android_presentation_interface( + &presentation_interface_, s.make_shared_AndroidPresentationInterface); + if (presentation_interface_.obj == nullptr) { + std::fprintf(stderr, + "runtime: make_shared 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 g(mu_); + return base_dir_; +} + +std::string Runtime::device_info() const { + if (!initialized_.load(std::memory_order_acquire)) return {}; + std::lock_guard g(mu_); + return device_info_; +} + +abi::shared_ptr Runtime::request_ctx_copy() const { + if (!initialized_.load(std::memory_order_acquire)) return {}; + std::lock_guard g(mu_); + return request_ctx_; +} + +abi::shared_ptr Runtime::device_guid_copy() const { + if (!initialized_.load(std::memory_order_acquire)) return {}; + std::lock_guard g(mu_); + return device_guid_; +} + +abi::shared_ptr Runtime::presentation_interface_copy() const { + if (!initialized_.load(std::memory_order_acquire)) return {}; + std::lock_guard g(mu_); + return presentation_interface_; +} + +} // namespace wrapper::apple diff --git a/gamdl-aio/wrapper-v2/src/daemon/apple/runtime.hpp b/gamdl-aio/wrapper-v2/src/daemon/apple/runtime.hpp new file mode 100644 index 00000000..2ddf6e64 --- /dev/null +++ b/gamdl-aio/wrapper-v2/src/daemon/apple/runtime.hpp @@ -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 +#include +#include +#include +#include +#include + +#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& parts); + bool init_request_context(const Symbols& s, + const RuntimeConfig& cfg, + const std::vector& parts); + bool init_presentation_interface(const Symbols& s); + bool init_playback_session(const Symbols& s); + + mutable std::mutex mu_; + std::atomic 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 diff --git a/gamdl-aio/wrapper-v2/src/daemon/apple/tokens.cpp b/gamdl-aio/wrapper-v2/src/daemon/apple/tokens.cpp new file mode 100644 index 00000000..94f622bf --- /dev/null +++ b/gamdl-aio/wrapper-v2/src/daemon/apple/tokens.cpp @@ -0,0 +1,403 @@ +#include "apple/tokens.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#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(s[0]) == 0xEF && + static_cast(s[1]) == 0xBB && + static_cast(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(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(raw); + std::memset(buf, 0, kSize); + *reinterpret_cast(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((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>& 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(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(static_cast(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 + // (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> 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(); + } + 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::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(); + } + return {}; +} + +std::optional 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(); + if (v.is_number_integer()) return std::to_string(v.get()); + if (v.is_number_unsigned()) return std::to_string(v.get()); + } + // 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(); + // 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 diff --git a/gamdl-aio/wrapper-v2/src/daemon/apple/tokens.hpp b/gamdl-aio/wrapper-v2/src/daemon/apple/tokens.hpp new file mode 100644 index 00000000..ce84518b --- /dev/null +++ b/gamdl-aio/wrapper-v2/src/daemon/apple/tokens.hpp @@ -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 +#include + +#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 extract_dsid_from_jwt(const std::string& jwt); + +} // namespace tokens + +} // namespace wrapper::apple diff --git a/gamdl-aio/wrapper-v2/src/daemon/main.cpp b/gamdl-aio/wrapper-v2/src/daemon/main.cpp new file mode 100644 index 00000000..7e78a779 --- /dev/null +++ b/gamdl-aio/wrapper-v2/src/daemon/main.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +#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 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(ctx); + void* fault_addr = info ? info->si_addr : nullptr; + void* rip = nullptr; +#ifdef __x86_64__ + if (uc) rip = reinterpret_cast(uc->uc_mcontext.gregs[REG_RIP]); +#elif defined(__aarch64__) + if (uc) rip = reinterpret_cast(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(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; +} diff --git a/gamdl-aio/wrapper-v2/src/daemon/server.cpp b/gamdl-aio/wrapper-v2/src/daemon/server.cpp new file mode 100644 index 00000000..3a8af6c2 --- /dev/null +++ b/gamdl-aio/wrapper-v2/src/daemon/server.cpp @@ -0,0 +1,626 @@ +#include "server.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#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 start_decrypt_watchdog() { + auto done = std::make_shared(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(p[0]) << 24) + | (static_cast(p[1]) << 16) + | (static_cast(p[2]) << 8) + | static_cast(p[3]); +} + +void append_u32_be(std::string* out, std::uint32_t v) { + out->push_back(static_cast((v >> 24) & 0xff)); + out->push_back(static_cast((v >> 16) & 0xff)); + out->push_back(static_cast((v >> 8) & 0xff)); + out->push_back(static_cast(v & 0xff)); +} + +struct SampleDecryptFrame { + std::string adam_id; + std::string uri; + std::vector> 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(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(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 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(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(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(p); + out->samples.emplace_back(b, b + n); + p += n; + } + return true; +} + +bool build_sample_decrypt_frame(const std::vector>& samples, + std::string* out) { + if (samples.size() > 0xffffffffu) return false; + std::uint64_t size = 4ull + (static_cast(samples.size()) * 4ull); + for (const auto& sample : samples) { + if (sample.size() > 0xffffffffu) return false; + size += sample.size(); + } + if (size > static_cast(out->max_size())) return false; + + out->clear(); + out->reserve(static_cast(size)); + append_u32_be(out, static_cast(samples.size())); + for (const auto& sample : samples) { + append_u32_be(out, static_cast(sample.size())); + } + for (const auto& sample : samples) { + out->append(reinterpret_cast(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 u = body["username"].get(); + 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(); + } else if (has_user) { + login_name = body["username"].get(); + } else { + respond_json(res, 400, json{ + {"error", "missing_field"}, + {"detail", "expected 'username' or 'apple_id' string"}, + }); + return; + } + std::string password = body["password"].get(); + 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(); + 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='"}, + }); + 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 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 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 diff --git a/gamdl-aio/wrapper-v2/src/daemon/server.hpp b/gamdl-aio/wrapper-v2/src/daemon/server.hpp new file mode 100644 index 00000000..eb616af3 --- /dev/null +++ b/gamdl-aio/wrapper-v2/src/daemon/server.hpp @@ -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 + +#include + +#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 diff --git a/gamdl-aio/wrapper-v2/src/daemon/supervisor.cpp b/gamdl-aio/wrapper-v2/src/daemon/supervisor.cpp new file mode 100644 index 00000000..df794dad --- /dev/null +++ b/gamdl-aio/wrapper-v2/src/daemon/supervisor.cpp @@ -0,0 +1,451 @@ +#include "supervisor.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +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 lock(mu_); + stop_locked(); + } + +private: + bool ensure_started() { + std::lock_guard 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 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 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 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 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(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(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(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 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(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(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 diff --git a/gamdl-aio/wrapper-v2/src/daemon/supervisor.hpp b/gamdl-aio/wrapper-v2/src/daemon/supervisor.hpp new file mode 100644 index 00000000..09809fb1 --- /dev/null +++ b/gamdl-aio/wrapper-v2/src/daemon/supervisor.hpp @@ -0,0 +1,27 @@ +// Public HTTP supervisor for the private Apple-runtime worker process. + +#pragma once + +#include + +#include + +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 diff --git a/gamdl-aio/wrapper-v2/src/launcher/wrapper.c b/gamdl-aio/wrapper-v2/src/launcher/wrapper.c new file mode 100644 index 00000000..cbdacc77 --- /dev/null +++ b/gamdl-aio/wrapper-v2/src/launcher/wrapper.c @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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; +} diff --git a/gamdl-aio/wrapper-v2/tools/extract-libs.sh b/gamdl-aio/wrapper-v2/tools/extract-libs.sh new file mode 100644 index 00000000..dd7c4864 --- /dev/null +++ b/gamdl-aio/wrapper-v2/tools/extract-libs.sh @@ -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 [--arch ] [--out ] [--ignore-hash] +# +# Options: +# --arch Which arch's libs to extract (default x86_64) +# --out Where to drop the .so files (default: /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 " >&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//$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/ 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// (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//$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 ]] diff --git a/gamdl-aio/wrapper-v2/tools/stage-system.sh b/gamdl-aio/wrapper-v2/tools/stage-system.sh new file mode 100644 index 00000000..9edc0b58 --- /dev/null +++ b/gamdl-aio/wrapper-v2/tools/stage-system.sh @@ -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// with SHA-256 pins in +# LIBS_VERSION.json. +# +# Usage: +# tools/stage-system.sh [--arch ] [--rootfs ] [--ignore-hash] +# +# Defaults: --arch x86_64, --rootfs /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 ]] diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/README.md b/gamdl-aio/wrapper-v2/vendor/android-system/README.md new file mode 100644 index 00000000..dcbd7f90 --- /dev/null +++ b/gamdl-aio/wrapper-v2/vendor/android-system/README.md @@ -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 `/`: + +| 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.` +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//` with the same relative +paths as the existing arches, plus a matching `.android_system.` entry +in `LIBS_VERSION.json`. diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libEGL.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libEGL.so new file mode 100644 index 00000000..0e33bb51 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libEGL.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libETC1.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libETC1.so new file mode 100644 index 00000000..33377a97 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libETC1.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libGLES_trace.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libGLES_trace.so new file mode 100644 index 00000000..d6c10d3d Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libGLES_trace.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libGLESv1_CM.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libGLESv1_CM.so new file mode 100644 index 00000000..b60f8634 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libGLESv1_CM.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libGLESv2.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libGLESv2.so new file mode 100644 index 00000000..0d3d0a2e Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libGLESv2.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libLLVM.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libLLVM.so new file mode 100644 index 00000000..d5f012ba Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libLLVM.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libOpenSLES.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libOpenSLES.so new file mode 100644 index 00000000..ac7e2e74 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libOpenSLES.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libRS.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libRS.so new file mode 100644 index 00000000..808bc4ea Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libRS.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libRScpp.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libRScpp.so new file mode 100644 index 00000000..1688df01 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libRScpp.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libandroid.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libandroid.so new file mode 100644 index 00000000..8c62affa Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libandroid.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libandroid_runtime.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libandroid_runtime.so new file mode 100644 index 00000000..29ce3c23 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libandroid_runtime.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libandroidfw.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libandroidfw.so new file mode 100644 index 00000000..7f6b9a38 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libandroidfw.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libaudioutils.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libaudioutils.so new file mode 100644 index 00000000..a8f84207 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libaudioutils.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libbacktrace.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libbacktrace.so new file mode 100644 index 00000000..8e64db61 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libbacktrace.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libbcc.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libbcc.so new file mode 100644 index 00000000..aae12b96 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libbcc.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libbcinfo.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libbcinfo.so new file mode 100644 index 00000000..50bc48f6 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libbcinfo.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libbinder.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libbinder.so new file mode 100644 index 00000000..86df9dc9 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libbinder.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libc++.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libc++.so new file mode 100644 index 00000000..c359a9b9 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libc++.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libc.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libc.so new file mode 100644 index 00000000..83dd4565 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libc.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libcamera_client.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libcamera_client.so new file mode 100644 index 00000000..03caf38b Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libcamera_client.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libcamera_metadata.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libcamera_metadata.so new file mode 100644 index 00000000..4971a453 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libcamera_metadata.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libcommon_time_client.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libcommon_time_client.so new file mode 100644 index 00000000..dc3b2d51 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libcommon_time_client.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libcrypto.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libcrypto.so new file mode 100644 index 00000000..638852d6 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libcrypto.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libcutils.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libcutils.so new file mode 100644 index 00000000..48ca3fe9 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libcutils.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libdrmframework.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libdrmframework.so new file mode 100644 index 00000000..c8745614 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libdrmframework.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libeffects.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libeffects.so new file mode 100644 index 00000000..055c9b4c Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libeffects.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libexpat.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libexpat.so new file mode 100644 index 00000000..2e90e3ef Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libexpat.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libft2.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libft2.so new file mode 100644 index 00000000..a2ffb761 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libft2.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libgabi++.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libgabi++.so new file mode 100644 index 00000000..cbd7c308 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libgabi++.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libgccdemangle.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libgccdemangle.so new file mode 100644 index 00000000..98cd502d Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libgccdemangle.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libgui.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libgui.so new file mode 100644 index 00000000..4575ac7d Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libgui.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libhardware.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libhardware.so new file mode 100644 index 00000000..6ac29f85 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libhardware.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libhardware_legacy.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libhardware_legacy.so new file mode 100644 index 00000000..04d69a97 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libhardware_legacy.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libharfbuzz_ng.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libharfbuzz_ng.so new file mode 100644 index 00000000..09f6ddc4 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libharfbuzz_ng.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libhwui.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libhwui.so new file mode 100644 index 00000000..db22d17a Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libhwui.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libicui18n.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libicui18n.so new file mode 100644 index 00000000..54ca17ce Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libicui18n.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libicuuc.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libicuuc.so new file mode 100644 index 00000000..d556e464 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libicuuc.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libimg_utils.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libimg_utils.so new file mode 100644 index 00000000..3b9a5746 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libimg_utils.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libinput.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libinput.so new file mode 100644 index 00000000..12ddc3a4 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libinput.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libinputflinger.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libinputflinger.so new file mode 100644 index 00000000..4063afb6 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libinputflinger.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libjpeg.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libjpeg.so new file mode 100644 index 00000000..1666b266 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libjpeg.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/liblog.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/liblog.so new file mode 100644 index 00000000..732af3c3 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/liblog.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libm.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libm.so new file mode 100644 index 00000000..c38b20da Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libm.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libmedia.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libmedia.so new file mode 100644 index 00000000..61b74518 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libmedia.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libmemtrack.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libmemtrack.so new file mode 100644 index 00000000..e5f46105 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libmemtrack.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libminikin.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libminikin.so new file mode 100644 index 00000000..83de2ec5 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libminikin.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libnativebridge.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libnativebridge.so new file mode 100644 index 00000000..de845699 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libnativebridge.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libnativehelper.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libnativehelper.so new file mode 100644 index 00000000..1b900a5a Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libnativehelper.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libnbaio.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libnbaio.so new file mode 100644 index 00000000..e6389efe Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libnbaio.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libnetd_client.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libnetd_client.so new file mode 100644 index 00000000..d4602b36 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libnetd_client.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libnetutils.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libnetutils.so new file mode 100644 index 00000000..67275004 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libnetutils.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libopus.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libopus.so new file mode 100644 index 00000000..4713b8a1 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libopus.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libpcre.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libpcre.so new file mode 100644 index 00000000..000bd546 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libpcre.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libpdfium.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libpdfium.so new file mode 100644 index 00000000..832e01e6 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libpdfium.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libpng.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libpng.so new file mode 100644 index 00000000..4b60ea5c Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libpng.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libpowermanager.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libpowermanager.so new file mode 100644 index 00000000..d1f40ff3 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libpowermanager.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libprocessgroup.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libprocessgroup.so new file mode 100644 index 00000000..1e333cbe Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libprocessgroup.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libselinux.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libselinux.so new file mode 100644 index 00000000..284e3a83 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libselinux.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libskia.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libskia.so new file mode 100644 index 00000000..71259008 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libskia.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libsonivox.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libsonivox.so new file mode 100644 index 00000000..fd8ce567 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libsonivox.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libsoundtrigger.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libsoundtrigger.so new file mode 100644 index 00000000..8efdb2ee Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libsoundtrigger.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libspeexresampler.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libspeexresampler.so new file mode 100644 index 00000000..1df57228 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libspeexresampler.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libsqlite.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libsqlite.so new file mode 100644 index 00000000..8595548f Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libsqlite.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libssl.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libssl.so new file mode 100644 index 00000000..dd3447c8 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libssl.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstagefright.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstagefright.so new file mode 100644 index 00000000..2b1da8e8 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstagefright.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstagefright_avc_common.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstagefright_avc_common.so new file mode 100644 index 00000000..17245585 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstagefright_avc_common.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstagefright_enc_common.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstagefright_enc_common.so new file mode 100644 index 00000000..cc9e8519 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstagefright_enc_common.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstagefright_foundation.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstagefright_foundation.so new file mode 100644 index 00000000..465c42c4 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstagefright_foundation.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstagefright_http_support.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstagefright_http_support.so new file mode 100644 index 00000000..c743d3cc Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstagefright_http_support.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstagefright_omx.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstagefright_omx.so new file mode 100644 index 00000000..95ec6093 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstagefright_omx.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstagefright_yuv.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstagefright_yuv.so new file mode 100644 index 00000000..060f23d5 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstagefright_yuv.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstdc++.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstdc++.so new file mode 100644 index 00000000..d5309c1e Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstdc++.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstlport.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstlport.so new file mode 100644 index 00000000..910e7a6b Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libstlport.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libsync.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libsync.so new file mode 100644 index 00000000..ae988d89 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libsync.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libui.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libui.so new file mode 100644 index 00000000..5216857a Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libui.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libunwind-ptrace.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libunwind-ptrace.so new file mode 100644 index 00000000..ed362784 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libunwind-ptrace.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libunwind.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libunwind.so new file mode 100644 index 00000000..f6ac9cc9 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libunwind.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libusbhost.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libusbhost.so new file mode 100644 index 00000000..e3540780 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libusbhost.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libutils.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libutils.so new file mode 100644 index 00000000..0e07275c Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libutils.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libvorbisidec.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libvorbisidec.so new file mode 100644 index 00000000..c24a4c2e Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libvorbisidec.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libwilhelm.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libwilhelm.so new file mode 100644 index 00000000..6687a73c Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libwilhelm.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libz.so b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libz.so new file mode 100644 index 00000000..ca9fab88 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/lib64/libz.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/usr/share/zoneinfo/tzdata b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/usr/share/zoneinfo/tzdata new file mode 100644 index 00000000..cb45b2ff Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/arm64-v8a/usr/share/zoneinfo/tzdata differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libEGL.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libEGL.so new file mode 100644 index 00000000..40fe15d7 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libEGL.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libETC1.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libETC1.so new file mode 100644 index 00000000..fd2b2df0 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libETC1.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libGLES_trace.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libGLES_trace.so new file mode 100644 index 00000000..962ef9a9 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libGLES_trace.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libGLESv1_CM.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libGLESv1_CM.so new file mode 100644 index 00000000..e0381ce4 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libGLESv1_CM.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libGLESv2.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libGLESv2.so new file mode 100644 index 00000000..7e42a7cd Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libGLESv2.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libLLVM.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libLLVM.so new file mode 100644 index 00000000..f2561858 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libLLVM.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libOpenSLES.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libOpenSLES.so new file mode 100644 index 00000000..c8f833f9 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libOpenSLES.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libRS.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libRS.so new file mode 100644 index 00000000..9b16bd67 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libRS.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libRScpp.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libRScpp.so new file mode 100644 index 00000000..4276e8f6 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libRScpp.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libandroid.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libandroid.so new file mode 100644 index 00000000..d2964a3a Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libandroid.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libandroid_runtime.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libandroid_runtime.so new file mode 100644 index 00000000..a2fc72e0 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libandroid_runtime.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libandroidfw.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libandroidfw.so new file mode 100644 index 00000000..2dbc177d Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libandroidfw.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libaudioutils.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libaudioutils.so new file mode 100644 index 00000000..e29059fc Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libaudioutils.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libbacktrace.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libbacktrace.so new file mode 100644 index 00000000..bf031243 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libbacktrace.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libbcc.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libbcc.so new file mode 100644 index 00000000..76697380 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libbcc.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libbcinfo.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libbcinfo.so new file mode 100644 index 00000000..023e8863 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libbcinfo.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libbinder.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libbinder.so new file mode 100644 index 00000000..869114b6 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libbinder.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libc++.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libc++.so new file mode 100644 index 00000000..7655a32d Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libc++.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libc.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libc.so new file mode 100644 index 00000000..694fa15b Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libc.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libcamera_client.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libcamera_client.so new file mode 100644 index 00000000..92608039 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libcamera_client.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libcamera_metadata.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libcamera_metadata.so new file mode 100644 index 00000000..cc90eb77 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libcamera_metadata.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libcommon_time_client.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libcommon_time_client.so new file mode 100644 index 00000000..8ce2debc Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libcommon_time_client.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libcrypto.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libcrypto.so new file mode 100644 index 00000000..244238b4 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libcrypto.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libcutils.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libcutils.so new file mode 100644 index 00000000..5a8b5c45 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libcutils.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libdrmframework.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libdrmframework.so new file mode 100644 index 00000000..b2aa0ed6 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libdrmframework.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libeffects.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libeffects.so new file mode 100644 index 00000000..852c491c Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libeffects.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libexpat.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libexpat.so new file mode 100644 index 00000000..b0ab3a93 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libexpat.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libft2.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libft2.so new file mode 100644 index 00000000..391ebfec Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libft2.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libgabi++.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libgabi++.so new file mode 100644 index 00000000..c643ee18 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libgabi++.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libgccdemangle.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libgccdemangle.so new file mode 100644 index 00000000..7ea73ca7 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libgccdemangle.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libgui.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libgui.so new file mode 100644 index 00000000..625c777f Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libgui.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libhardware.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libhardware.so new file mode 100644 index 00000000..a96c2fde Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libhardware.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libhardware_legacy.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libhardware_legacy.so new file mode 100644 index 00000000..f93112e9 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libhardware_legacy.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libharfbuzz_ng.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libharfbuzz_ng.so new file mode 100644 index 00000000..4da38542 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libharfbuzz_ng.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libhwui.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libhwui.so new file mode 100644 index 00000000..1ff90036 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libhwui.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libicui18n.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libicui18n.so new file mode 100644 index 00000000..f671030d Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libicui18n.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libicuuc.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libicuuc.so new file mode 100644 index 00000000..21fc4d29 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libicuuc.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libimg_utils.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libimg_utils.so new file mode 100644 index 00000000..7bdb00dd Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libimg_utils.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libinput.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libinput.so new file mode 100644 index 00000000..8eca6167 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libinput.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libinputflinger.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libinputflinger.so new file mode 100644 index 00000000..8a7a2f15 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libinputflinger.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libjpeg.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libjpeg.so new file mode 100644 index 00000000..d5d35ffb Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libjpeg.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/liblog.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/liblog.so new file mode 100644 index 00000000..39863e3a Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/liblog.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libm.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libm.so new file mode 100644 index 00000000..941b0687 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libm.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libmedia.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libmedia.so new file mode 100644 index 00000000..b35c6fde Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libmedia.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libmemtrack.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libmemtrack.so new file mode 100644 index 00000000..42592788 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libmemtrack.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libminikin.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libminikin.so new file mode 100644 index 00000000..b66f73f6 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libminikin.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libnativebridge.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libnativebridge.so new file mode 100644 index 00000000..7d1a72ba Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libnativebridge.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libnativehelper.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libnativehelper.so new file mode 100644 index 00000000..2c9fe04b Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libnativehelper.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libnbaio.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libnbaio.so new file mode 100644 index 00000000..cd22d716 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libnbaio.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libnetd_client.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libnetd_client.so new file mode 100644 index 00000000..8a2929d0 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libnetd_client.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libnetutils.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libnetutils.so new file mode 100644 index 00000000..fb567e71 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libnetutils.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libopus.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libopus.so new file mode 100644 index 00000000..d0e944f7 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libopus.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libpdfium.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libpdfium.so new file mode 100644 index 00000000..1a6b18b1 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libpdfium.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libpng.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libpng.so new file mode 100644 index 00000000..9cbb2b25 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libpng.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libpowermanager.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libpowermanager.so new file mode 100644 index 00000000..f4dd3b85 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libpowermanager.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libprocessgroup.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libprocessgroup.so new file mode 100644 index 00000000..1ebd8173 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libprocessgroup.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libselinux.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libselinux.so new file mode 100644 index 00000000..e1041a6b Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libselinux.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libskia.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libskia.so new file mode 100644 index 00000000..ffc1ecdd Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libskia.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libsonivox.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libsonivox.so new file mode 100644 index 00000000..d2f42b3b Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libsonivox.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libsoundtrigger.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libsoundtrigger.so new file mode 100644 index 00000000..38cf8613 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libsoundtrigger.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libspeexresampler.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libspeexresampler.so new file mode 100644 index 00000000..0817e4fa Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libspeexresampler.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libsqlite.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libsqlite.so new file mode 100644 index 00000000..ff3cb924 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libsqlite.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libssl.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libssl.so new file mode 100644 index 00000000..21513398 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libssl.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstagefright.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstagefright.so new file mode 100644 index 00000000..1e4203fe Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstagefright.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstagefright_avc_common.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstagefright_avc_common.so new file mode 100644 index 00000000..94adda93 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstagefright_avc_common.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstagefright_enc_common.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstagefright_enc_common.so new file mode 100644 index 00000000..64f507dd Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstagefright_enc_common.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstagefright_foundation.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstagefright_foundation.so new file mode 100644 index 00000000..f25027b9 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstagefright_foundation.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstagefright_http_support.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstagefright_http_support.so new file mode 100644 index 00000000..df537448 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstagefright_http_support.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstagefright_omx.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstagefright_omx.so new file mode 100644 index 00000000..5fc378aa Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstagefright_omx.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstagefright_yuv.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstagefright_yuv.so new file mode 100644 index 00000000..e26ae779 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstagefright_yuv.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstdc++.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstdc++.so new file mode 100644 index 00000000..f6ef9a75 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstdc++.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstlport.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstlport.so new file mode 100644 index 00000000..92fbaf56 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libstlport.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libsync.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libsync.so new file mode 100644 index 00000000..26390b95 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libsync.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libui.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libui.so new file mode 100644 index 00000000..c23cae01 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libui.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libunwind-ptrace.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libunwind-ptrace.so new file mode 100644 index 00000000..9e908d07 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libunwind-ptrace.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libunwind.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libunwind.so new file mode 100644 index 00000000..40a06cbd Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libunwind.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libusbhost.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libusbhost.so new file mode 100644 index 00000000..4f74f329 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libusbhost.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libutils.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libutils.so new file mode 100644 index 00000000..d4db2c92 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libutils.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libvorbisidec.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libvorbisidec.so new file mode 100644 index 00000000..5e9c0604 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libvorbisidec.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libwilhelm.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libwilhelm.so new file mode 100644 index 00000000..88bb4796 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libwilhelm.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libz.so b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libz.so new file mode 100644 index 00000000..e9d19358 Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/lib64/libz.so differ diff --git a/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/usr/share/zoneinfo/tzdata b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/usr/share/zoneinfo/tzdata new file mode 100644 index 00000000..cb45b2ff Binary files /dev/null and b/gamdl-aio/wrapper-v2/vendor/android-system/x86_64/usr/share/zoneinfo/tzdata differ