mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
feat(apple-music): embed gamdl-aio folder directly inside allstarr project
This commit is contained in:
251 files changed
+19241
-1
No files matched your search
+1
-1
@@ -182,7 +182,7 @@ services:
|
||||
|
||||
gamdl-aio:
|
||||
build:
|
||||
context: ../gamdl-aio
|
||||
context: ./gamdl-aio
|
||||
args:
|
||||
TARGET_ARCH: arm64-v8a
|
||||
RUNTIME_PLATFORM: linux/arm64
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
__pycache__
|
||||
!gamdl
|
||||
!.gitignore
|
||||
!.python-version
|
||||
!pyproject.toml
|
||||
!README.md
|
||||
!uv.lock
|
||||
@@ -0,0 +1 @@
|
||||
3.10
|
||||
@@ -0,0 +1,380 @@
|
||||
# Gamdl (Glomatico's Apple Music Downloader)
|
||||
|
||||
[](https://pypi.org/project/gamdl/)
|
||||
[](https://pypi.org/project/gamdl/)
|
||||
[](https://github.com/glomatico/gamdl/blob/main/LICENSE)
|
||||
[](https://pypi.org/project/gamdl/)
|
||||
|
||||
A command-line app for downloading Apple Music songs, music videos and post videos.
|
||||
|
||||
**Join our Discord Server:** <https://discord.gg/aBjMEZ9tnq>
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🎵 **High-Quality Songs** - Download songs in AAC 256kbps and other codecs
|
||||
- 🎬 **High-Quality Music Videos** - Download music videos in resolutions up to 4K
|
||||
- 📝 **Synced Lyrics** - Download synced lyrics in LRC, SRT, or TTML formats
|
||||
- 🏷️ **Rich Metadata** - Automatic tagging with comprehensive metadata
|
||||
- 🎤 **Artist Support** - Download all albums or music videos from an artist
|
||||
- ⚙️ **Highly Customizable** - Extensive configuration options for advanced users
|
||||
|
||||
## 📋 Prerequisites
|
||||
|
||||
### Required
|
||||
|
||||
- **Python 3.10 or higher**
|
||||
- **Active Apple Music subscription**
|
||||
- **Apple Music Cookies** - export your browser cookies in Netscape format while logged in at [Apple Music](https://music.apple.com):
|
||||
- **Firefox**: [Export Cookies](https://addons.mozilla.org/addon/export-cookies-txt)
|
||||
- **Chromium**: [Get cookies.txt LOCALLY](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
|
||||
|
||||
### Optional Dependencies
|
||||
|
||||
#### Wrapper
|
||||
|
||||
Run the [Wrapper v2](https://github.com/glomatico/wrapper-v2) server for wrapper-backed account, playback, and decryption requests. Enable it with `--use-wrapper` or `use_wrapper = true`, and configure the base URL with `--wrapper-url` or `wrapper_url`.
|
||||
|
||||
The wrapper is recommended when using the `alac` song codec. ALAC can be attempted without wrapper, but it probably won't work due to API limitations.
|
||||
|
||||
**Note:**
|
||||
|
||||
- When using the Wrapper, you'll be asked to insert your credentials to login if you haven't already.
|
||||
- Song codecs other than `alac` do not require the wrapper.
|
||||
- Cookies can be skipped when using the wrapper.
|
||||
|
||||
#### N_m3u8DL-RE
|
||||
|
||||
Use [N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest) as a faster download alternative to the default yt-dlp download mode. Enable it with `--download-mode nm3u8dlre` or `download_mode = nm3u8dlre`.
|
||||
|
||||
If the executable is not available in your system PATH, set its location with `--nm3u8dlre-path` or `nm3u8dlre_path`.
|
||||
|
||||
N_m3u8DL-RE also needs FFmpeg. If the FFmpeg executable is not available in your system PATH, set its location with `--ffmpeg-path` or `ffmpeg_path`.
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
1. **Install Gamdl via pip:**
|
||||
|
||||
```bash
|
||||
pip install gamdl
|
||||
```
|
||||
|
||||
2. **Set up the cookies file:**
|
||||
- Place the cookies file in the working directory as `cookies.txt`, or
|
||||
- Specify the path using `--cookies-path` or in the config file
|
||||
|
||||
3. **Optional: Set up dependencies** (only if you need the functionality)
|
||||
See the [Optional Dependencies](#optional-dependencies) section to determine which optional tools you need.
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
```bash
|
||||
gamdl [OPTIONS] URLS...
|
||||
```
|
||||
|
||||
### Supported URL Types
|
||||
|
||||
- Songs (Catalog/Library)
|
||||
- Albums (Catalog/Library)
|
||||
- Playlists (Catalog/Library)
|
||||
- Music Videos (Catalog/Library)
|
||||
- Artists
|
||||
- Post Videos
|
||||
- Apple Music Classical
|
||||
|
||||
### Examples
|
||||
|
||||
**Download a song:**
|
||||
|
||||
```bash
|
||||
gamdl "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
|
||||
```
|
||||
|
||||
**Download an album:**
|
||||
|
||||
```bash
|
||||
gamdl "https://music.apple.com/us/album/whenever-you-need-somebody-2022-remaster/1624945511"
|
||||
```
|
||||
|
||||
**Download from an artist:**
|
||||
|
||||
```bash
|
||||
gamdl "https://music.apple.com/us/artist/rick-astley/669771"
|
||||
```
|
||||
|
||||
**Interactive Prompt Controls:**
|
||||
|
||||
| Key | Action |
|
||||
| -------------- | ----------------- |
|
||||
| **Arrow keys** | Move selection |
|
||||
| **Space** | Toggle selection |
|
||||
| **Ctrl + A** | Select all |
|
||||
| **Enter** | Confirm selection |
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
Configure Gamdl using command-line arguments or a config file.
|
||||
|
||||
**Config file location:**
|
||||
|
||||
- Linux: `~/.gamdl/config.ini`
|
||||
- Windows: `%USERPROFILE%\.gamdl\config.ini`
|
||||
|
||||
The file is created automatically on first run. Command-line arguments override config values.
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Option | Description | Default |
|
||||
| ------------------------------- | ----------------------------------------------------------------- | ----------------------------- |
|
||||
| **General Options** | | |
|
||||
| `--read-urls-as-txt`, `-r` | Read URLs from text files | `false` |
|
||||
| `--config-path` | Config file path | `<home>/.gamdl/config.ini` |
|
||||
| `--log-level` | Logging level | `INFO` |
|
||||
| `--log-file` | Log file path | - |
|
||||
| `--no-exceptions` | Don't print exceptions | `false` |
|
||||
| `--artist-auto-select` | Automatically select artist content to download (artist URLs) | - |
|
||||
| `--database-path` | Path to the SQLite database file for registering downloaded media | - |
|
||||
| `--no-config-file`, `-n` | Don't use a config file | `false` |
|
||||
| **Apple Music Options** | | |
|
||||
| `--cookies-path`, `-c` | Cookies file path | `./cookies.txt` |
|
||||
| `--wrapper-url` | Wrapper base URL | `http://127.0.0.1` |
|
||||
| `--language`, `-l` | Metadata language | `en-US` |
|
||||
| **Interface Options** | | |
|
||||
| `--cover-format` | Cover format | `jpg` |
|
||||
| `--cover-size` | Cover size in pixels | `1200` |
|
||||
| `--wvd-path` | .wvd file path | - |
|
||||
| `--use-wrapper` | Use wrapper for account, playback, and decryption requests | `false` |
|
||||
| **Song Options** | | |
|
||||
| `--synced-lyrics-format` | Synced lyrics format | `lrc` |
|
||||
| `--song-codec-priority` | Comma-separated codec priority | `aac-web` |
|
||||
| `--use-album-date` | Use album release date for songs | `false` |
|
||||
| `--no-synced-lyrics` | Don't download synced lyrics | `false` |
|
||||
| `--synced-lyrics-only` | Download only synced lyrics | `false` |
|
||||
| **Music Video Options** | | |
|
||||
| `--music-video-resolution` | Max music video resolution | `1080p` |
|
||||
| `--music-video-codec-priority` | Comma-separated codec priority | `h264,h265` |
|
||||
| `--music-video-remux-format` | Music video remux format | `m4v` |
|
||||
| **Post Video Options** | | |
|
||||
| `--uploaded-video-quality` | Post video quality | `best` |
|
||||
| **Download & Path Options** | | |
|
||||
| `--output-path`, `-o` | Output directory path | `./Apple Music` |
|
||||
| `--temp-path` | Temporary directory path | `.` |
|
||||
| `--nm3u8dlre-path` | N_m3u8DL-RE executable path | `N_m3u8DL-RE` |
|
||||
| `--ffmpeg-path` | FFmpeg executable path | `ffmpeg` |
|
||||
| `--download-mode` | Download mode | `ytdlp` |
|
||||
| **Template Options** | | |
|
||||
| `--album-folder-template` | Album folder template | `{album_artist}/{album}` |
|
||||
| `--compilation-folder-template` | Compilation folder template | `Compilations/{album}` |
|
||||
| `--no-album-folder-template` | No album folder template | `{artist}/Unknown Album` |
|
||||
| `--playlist-folder-template` | Playlist folder template | `Playlists/{playlist_artist}` |
|
||||
| `--single-disc-file-template` | Single disc file template | `{track:02d} {title}` |
|
||||
| `--multi-disc-file-template` | Multi disc file template | `{disc}-{track:02d} {title}` |
|
||||
| `--no-album-file-template` | No album file template | `{title}` |
|
||||
| `--playlist-file-template` | Playlist file template | `{playlist_title}` |
|
||||
| `--date-tag-template` | Date tag template | `%Y-%m-%dT%H:%M:%SZ` |
|
||||
| `--exclude-tags` | Comma-separated tags to exclude | - |
|
||||
| `--truncate` | Max filename length | - |
|
||||
| **File Output Options** | | |
|
||||
| `--overwrite` | Overwrite existing files | `false` |
|
||||
| `--save-cover`, `-s` | Save cover as separate file | `false` |
|
||||
| `--save-playlist` | Save M3U8 playlist file | `false` |
|
||||
|
||||
### Template Variables
|
||||
|
||||
**Tags for templates and exclude-tags:**
|
||||
|
||||
- `album`, `album_artist`, `album_id`
|
||||
- `artist`, `artist_id`
|
||||
- `composer`, `composer_id`
|
||||
- `date` (supports strftime format: `{date:%Y}`)
|
||||
- `disc`, `disc_total`
|
||||
- `media_type`
|
||||
- `playlist_artist`, `playlist_id`, `playlist_title`, `playlist_track`
|
||||
- `title`, `title_id`
|
||||
- `track`, `track_total`
|
||||
|
||||
**Tags for exclude-tags only:**
|
||||
|
||||
- `album_sort`, `artist_sort`, `composer_sort`, `title_sort`
|
||||
- `comment`, `compilation`, `copyright`, `cover`, `gapless`, `genre`, `genre_id`, `lyrics`, `rating`, `storefront`, `xid`
|
||||
- `all` (special: skip all tagging)
|
||||
|
||||
### Logging Level
|
||||
|
||||
- `DEBUG`, `INFO`, `WARNING`, `ERROR`
|
||||
|
||||
### Download Mode
|
||||
|
||||
- `ytdlp`, `nm3u8dlre`
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> - **yt-dlp is only used as a file download library**. Media is still fetched directly from Apple Music's servers, and yt-dlp is only responsible for handling the file download process.
|
||||
|
||||
### Cover Format
|
||||
|
||||
- `jpg`
|
||||
- `png`
|
||||
- `raw` - Raw format as provided by the artist (requires `save_cover` to be enabled as it doesn't embed covers into files)
|
||||
|
||||
### Metadata Language
|
||||
|
||||
Use ISO 639-1 language codes (e.g., `en-US`, `es-ES`, `ja-JP`, `pt-BR`). Don't always work for music videos.
|
||||
|
||||
### Song Codecs
|
||||
|
||||
**Web:**
|
||||
|
||||
- `aac-web` - AAC 256kbps 44.1kHz
|
||||
- `aac-he-web` - AAC-HE 64kbps 44.1kHz
|
||||
|
||||
**Non-web** (`alac` can be attempted without wrapper, but it probably won't work due to API limitations):
|
||||
|
||||
- `aac` - AAC 256kbps up to 48kHz
|
||||
- `aac-he` - AAC-HE 64kbps up to 48kHz
|
||||
- `aac-binaural` - AAC 256kbps binaural
|
||||
- `aac-downmix` - AAC 256kbps downmix
|
||||
- `aac-he-binaural` - AAC-HE 64kbps binaural
|
||||
- `aac-he-downmix` - AAC-HE 64kbps downmix
|
||||
- `atmos` - Dolby Atmos 768kbps
|
||||
- `ac3` - AC3 640kbps
|
||||
- `alac` - ALAC up to 24-bit/192kHz
|
||||
- `ask` - Interactive codec selection
|
||||
|
||||
### Synced Lyrics Format
|
||||
|
||||
- `lrc`
|
||||
- `srt` - SubRip subtitle format (more accurate timing)
|
||||
- `ttml` - Native Apple Music format (not compatible with most media players)
|
||||
|
||||
### Music Video Codecs
|
||||
|
||||
- `h264`
|
||||
- `h265`
|
||||
- `ask` - Interactive codec selection
|
||||
|
||||
### Music Video Resolutions
|
||||
|
||||
- H.264: `240p`, `360p`, `480p`, `540p`, `720p`, `1080p`
|
||||
- H.265 only: `1440p`, `2160p`
|
||||
|
||||
### Music Video Remux Formats
|
||||
|
||||
- `m4v`, `mp4`
|
||||
|
||||
### Post Video Quality
|
||||
|
||||
- `best` - Up to 1080p with AAC 256kbps
|
||||
- `ask` - Interactive quality selection
|
||||
|
||||
### Artist Auto-Select Options
|
||||
|
||||
- `main-albums`
|
||||
- `compilation-albums`
|
||||
- `live-albums`
|
||||
- `singles-eps`
|
||||
- `all-albums`
|
||||
- `top-songs`
|
||||
- `music-videos`
|
||||
|
||||
## 🐍 Embedding
|
||||
|
||||
Use Gamdl as a library in your Python projects:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
from gamdl.api import AppleMusicApi
|
||||
from gamdl.downloader import (
|
||||
AppleMusicBaseDownloader,
|
||||
AppleMusicDownloader,
|
||||
AppleMusicMusicVideoDownloader,
|
||||
AppleMusicSongDownloader,
|
||||
AppleMusicUploadedVideoDownloader,
|
||||
)
|
||||
from gamdl.interface import (
|
||||
AppleMusicBaseInterface,
|
||||
AppleMusicInterface,
|
||||
AppleMusicMusicVideoInterface,
|
||||
AppleMusicSongInterface,
|
||||
AppleMusicUploadedVideoInterface,
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
# Create AppleMusicApi instance from cookies
|
||||
apple_music_api = await AppleMusicApi.create_from_netscape_cookies(
|
||||
cookies_path="cookies.txt",
|
||||
)
|
||||
|
||||
# Check subscription
|
||||
if not apple_music_api.active_subscription:
|
||||
print("No active Apple Music subscription")
|
||||
return
|
||||
|
||||
# Create base interface
|
||||
base_interface = await AppleMusicBaseInterface.create(
|
||||
apple_music_api=apple_music_api,
|
||||
)
|
||||
|
||||
# Create specialized interfaces
|
||||
song_interface = AppleMusicSongInterface(
|
||||
base=base_interface,
|
||||
)
|
||||
music_video_interface = AppleMusicMusicVideoInterface(
|
||||
base=base_interface,
|
||||
)
|
||||
uploaded_video_interface = AppleMusicUploadedVideoInterface(
|
||||
base=base_interface,
|
||||
)
|
||||
|
||||
# Create main interface
|
||||
interface = AppleMusicInterface(
|
||||
song=song_interface,
|
||||
music_video=music_video_interface,
|
||||
uploaded_video=uploaded_video_interface,
|
||||
)
|
||||
|
||||
# Create base downloader
|
||||
base_downloader = AppleMusicBaseDownloader(
|
||||
interface=interface,
|
||||
)
|
||||
|
||||
# Create specialized downloaders
|
||||
song_downloader = AppleMusicSongDownloader(base=base_downloader)
|
||||
music_video_downloader = AppleMusicMusicVideoDownloader(
|
||||
base=base_downloader,
|
||||
)
|
||||
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(base=base_downloader)
|
||||
|
||||
# Create main downloader
|
||||
downloader = AppleMusicDownloader(
|
||||
song=song_downloader,
|
||||
music_video=music_video_downloader,
|
||||
uploaded_video=uploaded_video_downloader,
|
||||
)
|
||||
|
||||
# Download from URL
|
||||
url = "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
|
||||
|
||||
download_queue = []
|
||||
async for media in downloader.get_download_item_from_url(url):
|
||||
download_queue.append(media)
|
||||
|
||||
for download_item in download_queue:
|
||||
try:
|
||||
await downloader.download(download_item)
|
||||
except Exception as e:
|
||||
print(f"Error downloading: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) file for details
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Currently, I'm not interested in reviewing pull requests that change or add features. Only critical bug fixes will be considered. However, feel free to open issues for bugs or feature requests.
|
||||
@@ -0,0 +1 @@
|
||||
__version__ = "3.8"
|
||||
@@ -0,0 +1,3 @@
|
||||
from .cli.cli import main
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,4 @@
|
||||
from .apple_music import AppleMusicApi
|
||||
from .exceptions import *
|
||||
from .itunes import ItunesApi
|
||||
from .wrapper import WrapperApi
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,272 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import struct
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TypeVar
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
from .exceptions import GamdlApiResponseError
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
CredentialsFunc = (
|
||||
Callable[[], tuple[str, str]] | Callable[[], Awaitable[tuple[str, str]]]
|
||||
)
|
||||
TwoFactorCodeFunc = Callable[[], str] | Callable[[], Awaitable[str]]
|
||||
|
||||
|
||||
async def _invoke(func: Callable[[], T | Awaitable[T]]) -> T:
|
||||
result = func()
|
||||
if inspect.isawaitable(result):
|
||||
return await result
|
||||
return result
|
||||
|
||||
|
||||
class WrapperApi:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
client: httpx.AsyncClient,
|
||||
me: dict,
|
||||
):
|
||||
self.base_url = base_url
|
||||
self.client = client
|
||||
self.me = me
|
||||
|
||||
@staticmethod
|
||||
def build_decrypt_sample_frame(
|
||||
adam_id: str,
|
||||
skd_uri: str,
|
||||
ciphertexts: list[bytes],
|
||||
) -> bytes:
|
||||
"""Build wrapper-v2 /decrypt binary request frame."""
|
||||
adam_id_bytes = adam_id.encode("utf-8")
|
||||
skd_uri_bytes = skd_uri.encode("utf-8")
|
||||
if not adam_id_bytes:
|
||||
raise ValueError("wrapper-v2: adam_id must not be empty")
|
||||
if not skd_uri_bytes:
|
||||
raise ValueError("wrapper-v2: skd_uri must not be empty")
|
||||
if not ciphertexts:
|
||||
raise ValueError("wrapper-v2: ciphertext batch must not be empty")
|
||||
|
||||
frame = bytearray()
|
||||
frame += struct.pack(
|
||||
">III",
|
||||
len(adam_id_bytes),
|
||||
len(skd_uri_bytes),
|
||||
len(ciphertexts),
|
||||
)
|
||||
for ciphertext in ciphertexts:
|
||||
frame += struct.pack(">I", len(ciphertext))
|
||||
frame += adam_id_bytes
|
||||
frame += skd_uri_bytes
|
||||
for ciphertext in ciphertexts:
|
||||
frame += ciphertext
|
||||
return bytes(frame)
|
||||
|
||||
@staticmethod
|
||||
def parse_decrypt_sample_frame(data: bytes, expected_count: int) -> list[bytes]:
|
||||
"""Parse wrapper-v2 /decrypt binary response frame."""
|
||||
if len(data) < 4:
|
||||
raise IOError("wrapper-v2: POST /decrypt returned a truncated response")
|
||||
(sample_count,) = struct.unpack_from(">I", data, 0)
|
||||
if sample_count != expected_count:
|
||||
raise IOError(
|
||||
f"wrapper-v2: expected {expected_count} samples in response, "
|
||||
f"got {sample_count}"
|
||||
)
|
||||
|
||||
table_end = 4 + sample_count * 4
|
||||
if len(data) < table_end:
|
||||
raise IOError("wrapper-v2: POST /decrypt returned a truncated length table")
|
||||
|
||||
lengths = [
|
||||
struct.unpack_from(">I", data, 4 + i * 4)[0] for i in range(sample_count)
|
||||
]
|
||||
offset = table_end
|
||||
out: list[bytes] = []
|
||||
for i, length in enumerate(lengths):
|
||||
end = offset + length
|
||||
if end > len(data):
|
||||
raise IOError(
|
||||
f"wrapper-v2: POST /decrypt returned truncated sample {i}"
|
||||
)
|
||||
out.append(data[offset:end])
|
||||
offset = end
|
||||
|
||||
if offset != len(data):
|
||||
raise IOError("wrapper-v2: POST /decrypt returned trailing bytes")
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
async def create(
|
||||
cls,
|
||||
base_url: str = "http://127.0.0.1",
|
||||
get_credentials_func: CredentialsFunc | None = None,
|
||||
get_2fa_code: TwoFactorCodeFunc | None = None,
|
||||
) -> WrapperApi:
|
||||
client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(600.0, connect=30.0),
|
||||
)
|
||||
|
||||
base_url = base_url.rstrip("/")
|
||||
|
||||
me = await cls.get_me(client, base_url)
|
||||
if get_credentials_func is not None and me["auth"]["state"] == "logged_out":
|
||||
username, password = await _invoke(get_credentials_func)
|
||||
await cls.login(
|
||||
client,
|
||||
base_url,
|
||||
username,
|
||||
password,
|
||||
get_2fa_code,
|
||||
)
|
||||
me = await cls.get_me(client, base_url)
|
||||
|
||||
if me.get("auth", {}).get("state") == "logged_out":
|
||||
raise GamdlApiResponseError(
|
||||
"Wrapper is not authenticated. "
|
||||
"Provide get_credentials_func or log in via the wrapper.",
|
||||
)
|
||||
|
||||
return cls(base_url, client, me)
|
||||
|
||||
@staticmethod
|
||||
async def login(
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
username: str,
|
||||
password: str,
|
||||
get_2fa_code: TwoFactorCodeFunc | None = None,
|
||||
) -> None:
|
||||
base_url = base_url.rstrip("/")
|
||||
response = await client.post(
|
||||
f"{base_url}/login",
|
||||
json={"username": username, "password": password},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return
|
||||
|
||||
if response.status_code == 202:
|
||||
if get_2fa_code is None:
|
||||
raise GamdlApiResponseError(
|
||||
"Wrapper login requires 2FA; provide get_2fa_code",
|
||||
status_code=202,
|
||||
)
|
||||
code = await _invoke(get_2fa_code)
|
||||
tfa_response = await client.post(
|
||||
f"{base_url}/login/2fa",
|
||||
json={"code": code},
|
||||
)
|
||||
if tfa_response.is_error:
|
||||
raise GamdlApiResponseError(
|
||||
"Wrapper 2FA login failed",
|
||||
content=tfa_response.text,
|
||||
status_code=tfa_response.status_code,
|
||||
)
|
||||
return
|
||||
|
||||
raise GamdlApiResponseError(
|
||||
"Wrapper login failed",
|
||||
content=response.text,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_me(client: httpx.AsyncClient, base_url: str) -> dict:
|
||||
log = logger.bind(action="wrapper_get_me")
|
||||
|
||||
response = None
|
||||
|
||||
try:
|
||||
response = await client.get(f"{base_url}/me")
|
||||
response.raise_for_status()
|
||||
account_info = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching wrapper account info",
|
||||
content=getattr(response, "text", None),
|
||||
status_code=getattr(response, "status_code", None),
|
||||
)
|
||||
|
||||
log.debug("success", account_info=account_info)
|
||||
|
||||
return account_info
|
||||
|
||||
async def get_playback(self, media_id: str) -> dict:
|
||||
log = logger.bind(action="wrapper_get_playback", media_id=media_id)
|
||||
|
||||
response = None
|
||||
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.base_url}/playback",
|
||||
params={"adam_id": media_id},
|
||||
)
|
||||
response.raise_for_status()
|
||||
playback = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching wrapper playback",
|
||||
content=getattr(response, "text", None),
|
||||
status_code=getattr(response, "status_code", None),
|
||||
)
|
||||
|
||||
log.debug("success", playback=playback)
|
||||
|
||||
return playback
|
||||
|
||||
async def decrypt(
|
||||
self,
|
||||
adam_id: str,
|
||||
skd_uri: str,
|
||||
ciphertexts: list[bytes],
|
||||
) -> list[bytes]:
|
||||
"""Decrypt one POST /decrypt batch; plaintexts match ciphertext order."""
|
||||
log = logger.bind(
|
||||
action="wrapper_decrypt",
|
||||
adam_id=adam_id,
|
||||
sample_count=len(ciphertexts),
|
||||
)
|
||||
|
||||
frame = self.build_decrypt_sample_frame(adam_id, skd_uri, ciphertexts)
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/decrypt",
|
||||
content=frame,
|
||||
headers={
|
||||
"content-type": "application/octet-stream",
|
||||
"accept": "application/octet-stream",
|
||||
},
|
||||
)
|
||||
if response.status_code == 401:
|
||||
raise IOError(
|
||||
"wrapper-v2: POST /decrypt returned 401 — log in with POST /login "
|
||||
"or restore a session on the daemon first"
|
||||
)
|
||||
if response.status_code == 503:
|
||||
raise IOError(
|
||||
"wrapper-v2: decrypt unavailable (503) — check daemon logs /health "
|
||||
"for playback_ready and Apple lib init"
|
||||
)
|
||||
if response.status_code != 200:
|
||||
detail = ""
|
||||
try:
|
||||
j = response.json()
|
||||
detail = (j.get("detail") or j.get("error") or str(j)) or ""
|
||||
except Exception:
|
||||
detail = (response.text or "")[:500]
|
||||
raise IOError(
|
||||
f"wrapper-v2: POST /decrypt failed HTTP {response.status_code}: {detail}"
|
||||
)
|
||||
|
||||
plaintexts = self.parse_decrypt_sample_frame(
|
||||
response.content,
|
||||
len(ciphertexts),
|
||||
)
|
||||
log.debug("success")
|
||||
return plaintexts
|
||||
Whitespace-only changes.
@@ -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)")
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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 "{}"'
|
||||
@@ -0,0 +1,58 @@
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(
|
||||
self,
|
||||
path: Path,
|
||||
overwrite: bool,
|
||||
):
|
||||
self.overwrite = overwrite
|
||||
|
||||
self.connection = sqlite3.connect(path)
|
||||
self.cursor = self.connection.cursor()
|
||||
self._create_tables()
|
||||
|
||||
def _create_tables(self) -> None:
|
||||
self.cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS media (
|
||||
id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
self.connection.commit()
|
||||
|
||||
def get(self, media_id: str) -> str | None:
|
||||
self.cursor.execute("SELECT path FROM media WHERE id = ?", (media_id,))
|
||||
row = self.cursor.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
def add(self, media_id: str, path: str) -> None:
|
||||
self.cursor.execute(
|
||||
"INSERT OR REPLACE INTO media (id, path) VALUES (?, ?)",
|
||||
(media_id, str(Path(path).absolute())),
|
||||
)
|
||||
self.connection.commit()
|
||||
|
||||
def remove(self, media_id: str) -> None:
|
||||
self.cursor.execute("DELETE FROM media WHERE id = ?", (media_id,))
|
||||
self.connection.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
self.connection.close()
|
||||
|
||||
def flat_filter(self, media_metadata: dict) -> str | None:
|
||||
media_id = media_metadata["id"]
|
||||
result = self.get(media_id)
|
||||
|
||||
if not result:
|
||||
return None
|
||||
|
||||
return (
|
||||
"Registered in database"
|
||||
if Path(result).exists() and not self.overwrite
|
||||
else None
|
||||
)
|
||||
@@ -0,0 +1,248 @@
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
import m3u8
|
||||
from ..interface import ArtistMediaType
|
||||
|
||||
|
||||
class InteractivePrompts:
|
||||
def __init__(
|
||||
self,
|
||||
artist_auto_select: ArtistMediaType | None = None,
|
||||
):
|
||||
self.artist_auto_select = artist_auto_select
|
||||
|
||||
@staticmethod
|
||||
def millis_to_min_sec(millis) -> str:
|
||||
minutes, seconds = divmod(millis // 1000, 60)
|
||||
return f"{minutes:02}:{seconds:02}"
|
||||
|
||||
@staticmethod
|
||||
async def get_wrapper_credentials() -> tuple[str, str]:
|
||||
username = await inquirer.text(
|
||||
message="Apple ID:",
|
||||
).execute_async()
|
||||
password = await inquirer.secret(
|
||||
message="Password:",
|
||||
).execute_async()
|
||||
return username, password
|
||||
|
||||
@staticmethod
|
||||
async def get_wrapper_2fa_code() -> str:
|
||||
return await inquirer.text(
|
||||
message="Two-factor authentication code:",
|
||||
).execute_async()
|
||||
|
||||
@staticmethod
|
||||
async def ask_song_codec(
|
||||
playlists: list[dict],
|
||||
) -> dict:
|
||||
choices = [
|
||||
Choice(
|
||||
name=playlist["stream_info"]["audio"],
|
||||
value=playlist,
|
||||
)
|
||||
for playlist in playlists
|
||||
]
|
||||
|
||||
return await inquirer.select(
|
||||
message="Select which codec to download:",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
|
||||
@staticmethod
|
||||
async def ask_music_video_video_codec_function(
|
||||
playlists: list[m3u8.Playlist],
|
||||
) -> dict:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
playlist.stream_info.codecs[:4],
|
||||
"x".join(str(v) for v in playlist.stream_info.resolution),
|
||||
str(playlist.stream_info.bandwidth),
|
||||
]
|
||||
),
|
||||
value=playlist,
|
||||
)
|
||||
for playlist in playlists
|
||||
]
|
||||
|
||||
return await inquirer.select(
|
||||
message="Select which video codec to download: (Codec | Resolution | Bitrate)",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
|
||||
@staticmethod
|
||||
async def ask_music_video_audio_codec_function(
|
||||
playlists: list[dict],
|
||||
) -> dict:
|
||||
choices = [
|
||||
Choice(
|
||||
name=playlist["group_id"],
|
||||
value=playlist,
|
||||
)
|
||||
for playlist in playlists
|
||||
]
|
||||
|
||||
selected = await inquirer.select(
|
||||
message="Select which audio codec to download:",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
|
||||
return selected
|
||||
|
||||
@staticmethod
|
||||
async def ask_uploaded_video_quality_function(
|
||||
available_qualities: dict[str, str],
|
||||
) -> str:
|
||||
qualities = list(available_qualities.keys())
|
||||
choices = [
|
||||
Choice(
|
||||
name=quality,
|
||||
value=quality,
|
||||
)
|
||||
for quality in qualities
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which quality to download:",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
|
||||
return available_qualities[selected]
|
||||
|
||||
async def ask_artist_media_type(
|
||||
self,
|
||||
media_types: list[ArtistMediaType],
|
||||
artist_metadata: dict,
|
||||
) -> ArtistMediaType:
|
||||
if self.artist_auto_select:
|
||||
return self.artist_auto_select
|
||||
|
||||
available_choices = []
|
||||
for media_types in media_types:
|
||||
available_choices.append(
|
||||
Choice(
|
||||
name=str(media_types),
|
||||
value=(media_types,),
|
||||
),
|
||||
)
|
||||
|
||||
(media_type,) = await inquirer.select(
|
||||
message=f'Select which type to download for artist "{artist_metadata["attributes"]["name"]}":',
|
||||
choices=available_choices,
|
||||
validate=lambda result: artist_metadata.get(result[0].path_key[0], {})
|
||||
.get(result[0].path_key[1], {})
|
||||
.get("data"),
|
||||
).execute_async()
|
||||
|
||||
return media_type
|
||||
|
||||
async def ask_artist_select_items(
|
||||
self,
|
||||
media_type: ArtistMediaType,
|
||||
items: list[dict],
|
||||
) -> list[dict]:
|
||||
if media_type in {
|
||||
ArtistMediaType.MAIN_ALBUMS,
|
||||
ArtistMediaType.COMPILATION_ALBUMS,
|
||||
ArtistMediaType.LIVE_ALBUMS,
|
||||
ArtistMediaType.SINGLES_EPS,
|
||||
ArtistMediaType.ALL_ALBUMS,
|
||||
}:
|
||||
return await self._ask_artist_select_albums(items)
|
||||
elif media_type == ArtistMediaType.TOP_SONGS:
|
||||
return await self._ask_artist_select_songs(
|
||||
items,
|
||||
)
|
||||
elif media_type == ArtistMediaType.MUSIC_VIDEOS:
|
||||
return await self._ask_artist_select_music_videos(items)
|
||||
|
||||
async def _ask_artist_select_albums(
|
||||
self,
|
||||
albums: list[dict],
|
||||
) -> list[dict]:
|
||||
if self.artist_auto_select:
|
||||
return albums
|
||||
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
f'{album["attributes"]["trackCount"]:03d}',
|
||||
f'{album["attributes"]["releaseDate"]:<10}',
|
||||
f'{album["attributes"].get("contentRating", "None").title():<8}',
|
||||
f'{album["attributes"]["name"]}',
|
||||
]
|
||||
),
|
||||
value=album,
|
||||
)
|
||||
for album in albums
|
||||
if album.get("attributes")
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which albums to download: (Track Count | Release Date | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute_async()
|
||||
|
||||
return selected
|
||||
|
||||
async def _ask_artist_select_songs(
|
||||
self,
|
||||
songs: list[dict],
|
||||
) -> list[dict]:
|
||||
if self.artist_auto_select:
|
||||
return songs
|
||||
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
self.millis_to_min_sec(song["attributes"]["durationInMillis"]),
|
||||
f'{song["attributes"].get("contentRating", "None").title():<8}',
|
||||
song["attributes"]["name"],
|
||||
],
|
||||
),
|
||||
value=song,
|
||||
)
|
||||
for song in songs
|
||||
if song.get("attributes")
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which songs to download: (Duration | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute_async()
|
||||
|
||||
return selected
|
||||
|
||||
async def _ask_artist_select_music_videos(
|
||||
self,
|
||||
music_videos: list[dict],
|
||||
) -> list[dict]:
|
||||
if self.artist_auto_select:
|
||||
return music_videos
|
||||
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
self.millis_to_min_sec(
|
||||
music_video["attributes"]["durationInMillis"]
|
||||
),
|
||||
f'{music_video["attributes"].get("contentRating", "None").title():<8}',
|
||||
music_video["attributes"]["name"],
|
||||
],
|
||||
),
|
||||
value=music_video,
|
||||
)
|
||||
for music_video in music_videos
|
||||
if music_video.get("attributes")
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which music videos to download: (Duration | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute_async()
|
||||
|
||||
return selected
|
||||
@@ -0,0 +1,123 @@
|
||||
import atexit
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
|
||||
class Csv(click.ParamType):
|
||||
name = "csv"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
subtype: Enum,
|
||||
) -> None:
|
||||
self.subtype = subtype
|
||||
|
||||
def convert(
|
||||
self,
|
||||
value: str,
|
||||
param: click.Parameter,
|
||||
ctx: click.Context,
|
||||
) -> list[Enum]:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
|
||||
items = [v.strip() for v in value.split(",") if v.strip()]
|
||||
result = []
|
||||
|
||||
for item in items:
|
||||
try:
|
||||
result.append(self.subtype(item))
|
||||
except ValueError as e:
|
||||
self.fail(
|
||||
f"'{item}' is not a valid value for {self.subtype.__name__}",
|
||||
param,
|
||||
ctx,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class CustomOutputWriter:
|
||||
def __init__(
|
||||
self,
|
||||
streams: list[Any] = [sys.stdout],
|
||||
):
|
||||
self.streams = streams
|
||||
|
||||
def add_file(self, path: str):
|
||||
file_stream = open(path, "a", encoding="utf-8")
|
||||
atexit.register(file_stream.close)
|
||||
self.streams.append(file_stream)
|
||||
|
||||
def write(self, message: str):
|
||||
for stream in self.streams:
|
||||
stream.write(message)
|
||||
|
||||
def flush(self):
|
||||
for stream in self.streams:
|
||||
stream.flush()
|
||||
|
||||
|
||||
def custom_structlog_formatter(
|
||||
logger: Any,
|
||||
name: str,
|
||||
event_dict: dict[str, Any],
|
||||
) -> str:
|
||||
level = event_dict.pop("level", "INFO").upper()
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
level_colors = {
|
||||
"DEBUG": "cyan",
|
||||
"INFO": "green",
|
||||
"WARNING": "yellow",
|
||||
"ERROR": "red",
|
||||
"CRITICAL": "red",
|
||||
}
|
||||
|
||||
color = level_colors.get(level, "white")
|
||||
prefix = click.style(f"[{level:<8} {timestamp}]", fg=color)
|
||||
|
||||
action = event_dict.pop("action", None)
|
||||
if action:
|
||||
prefix += click.style(f" [{action}]", dim=True)
|
||||
|
||||
if level in {"INFO", "WARNING", "ERROR", "CRITICAL"}:
|
||||
message = event_dict.pop("event", "")
|
||||
return f"{prefix} {message}"
|
||||
else:
|
||||
return f"{prefix} {event_dict}"
|
||||
|
||||
|
||||
def prompt_path(
|
||||
input_path: str,
|
||||
is_dir: bool = False,
|
||||
) -> str:
|
||||
path_validator = click.Path(
|
||||
exists=True,
|
||||
file_okay=not is_dir,
|
||||
dir_okay=is_dir,
|
||||
)
|
||||
path_type = "directory" if is_dir else "file"
|
||||
|
||||
while True:
|
||||
try:
|
||||
result_path = path_validator.convert(input_path, None, None)
|
||||
break
|
||||
except click.BadParameter as e:
|
||||
input_path = click.prompt(
|
||||
(
|
||||
f'{path_type.capitalize()} "{Path(input_path).absolute()}" does not exist. '
|
||||
f"Create the {path_type} at the specified path, "
|
||||
f"type a new path or drag and drop the {path_type} here. "
|
||||
"Then, press enter to continue"
|
||||
),
|
||||
default=input_path,
|
||||
show_default=False,
|
||||
)
|
||||
input_path = input_path.strip('"')
|
||||
|
||||
return result_path
|
||||
@@ -0,0 +1,9 @@
|
||||
from .amdecrypt import decrypt_file_hex, decrypt_wrapper, write_decrypted_media
|
||||
from .base import AppleMusicBaseDownloader
|
||||
from .downloader import AppleMusicDownloader
|
||||
from .enums import *
|
||||
from .exceptions import *
|
||||
from .music_video import AppleMusicMusicVideoDownloader
|
||||
from .song import AppleMusicSongDownloader
|
||||
from .types import *
|
||||
from .uploaded_video import AppleMusicUploadedVideoDownloader
|
||||
@@ -0,0 +1,3546 @@
|
||||
"""
|
||||
This is a modified version of https://github.com/sn0wst0rm/st0rmMusicPlayer/blob/main/scripts/amdecrypt.py
|
||||
All the modifications made here were AI generated
|
||||
|
||||
FairPlay sample decryption talks to wrapper-v2 over HTTP POST /decrypt
|
||||
(binary frame), not the legacy raw TCP port used by the original wrapper (e.g. 10020).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import os
|
||||
import struct
|
||||
import tempfile
|
||||
import time
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from typing import BinaryIO, List, Optional
|
||||
|
||||
import structlog
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
from ..api.wrapper import WrapperApi
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Default decryption key for songs without per-sample keys (legacy AAC)
|
||||
DEFAULT_SONG_DECRYPTION_KEY = b"2\xb8\xad\xe1v\x9e&\xb1\xff\xb8\x98cRy?\xc6"
|
||||
|
||||
# Pre-fetch key used for first sample description
|
||||
PREFETCH_KEY = "skd://itunes.apple.com/P000000000/s1/e1"
|
||||
|
||||
# Max ciphertext blobs per POST /decrypt (same adam_id + uri). Increase for fewer
|
||||
# round-trips; set to 1 if a given wrapper build mis-handles CBC between chunks.
|
||||
WRAPPER_DECRYPT_BATCH_SIZE = 128
|
||||
|
||||
# wrapper-v2: use one SKD segment per ``adam_id``+``uri`` (do not interleave prefetch
|
||||
# and main keys in one request). Flush pending ciphertexts before switching keys.
|
||||
|
||||
|
||||
def _cbcs_ciphertext_for_sample(sample: SampleInfo) -> Optional[tuple[bytes, bytes]]:
|
||||
"""Payload for FairPlay CBC: aligned decrypt input and trailing pass-through bytes.
|
||||
|
||||
Matches ``decrypt_samples_hex``: all encrypted subsample runs in one MP4 sample
|
||||
are **concatenated**, then ``len & ~0xF`` bytes are decrypted as **one** CBC
|
||||
stream; the remainder is copied (see hex path). Clear subsample bytes must never
|
||||
be fed to the decryptor.
|
||||
|
||||
Returns:
|
||||
``None`` — no AES input for this sample (emit literal ``sample.data``).
|
||||
``(aligned, tail)`` — ``aligned`` may be empty if only a short tail exists;
|
||||
then ``tail`` is the full encrypted concatenation (pass-through).
|
||||
"""
|
||||
data = sample.data
|
||||
if not sample.subsamples:
|
||||
n = len(data)
|
||||
t = n & ~0xF
|
||||
tail = data[t:] if t < n else b""
|
||||
if t > 0:
|
||||
return (data[:t], tail)
|
||||
return None
|
||||
|
||||
enc_cat = bytearray()
|
||||
offset = 0
|
||||
for clear_b, enc_b in sample.subsamples:
|
||||
offset += clear_b
|
||||
if enc_b:
|
||||
enc_cat.extend(data[offset : offset + enc_b])
|
||||
offset += enc_b
|
||||
if not enc_cat:
|
||||
return None
|
||||
blob = bytes(enc_cat)
|
||||
cbc_len = len(blob) & ~0xF
|
||||
tail = blob[cbc_len:]
|
||||
aligned = blob[:cbc_len]
|
||||
return (aligned, tail)
|
||||
|
||||
|
||||
def _reassemble_cbcs_sample(sample: SampleInfo, plain: bytes, tail: bytes) -> bytes:
|
||||
"""Place decrypted CBCS bytes back into the original MP4 sample layout."""
|
||||
full_dec = plain + tail
|
||||
data = sample.data
|
||||
if not sample.subsamples:
|
||||
if len(full_dec) != len(data):
|
||||
raise IOError(
|
||||
f"decrypted sample length mismatch: expected {len(data)}, got {len(full_dec)}"
|
||||
)
|
||||
return full_dec
|
||||
|
||||
encrypted_total = sum(enc_b for _, enc_b in sample.subsamples)
|
||||
if len(full_dec) != encrypted_total:
|
||||
raise IOError(
|
||||
"decrypted subsample length mismatch: "
|
||||
f"expected {encrypted_total}, got {len(full_dec)}"
|
||||
)
|
||||
|
||||
out = bytearray()
|
||||
dec_off = 0
|
||||
offset = 0
|
||||
for clear_b, enc_b in sample.subsamples:
|
||||
if clear_b:
|
||||
out.extend(data[offset : offset + clear_b])
|
||||
offset += clear_b
|
||||
if enc_b:
|
||||
out.extend(full_dec[dec_off : dec_off + enc_b])
|
||||
dec_off += enc_b
|
||||
offset += enc_b
|
||||
if offset < len(data):
|
||||
out.extend(data[offset:])
|
||||
if len(out) != len(data):
|
||||
raise IOError(
|
||||
f"reassembled sample length mismatch: expected {len(data)}, got {len(out)}"
|
||||
)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _append_reassembled_sample(
|
||||
decrypted_data: bytearray, sample: SampleInfo, plain: bytes, tail: bytes
|
||||
) -> None:
|
||||
"""Append one decrypted CBCS sample to the output stream."""
|
||||
decrypted_data.extend(_reassemble_cbcs_sample(sample, plain, tail))
|
||||
|
||||
|
||||
def _sample_size(sample: SampleInfo) -> int:
|
||||
"""Return sample payload size even after payload bytes have been released."""
|
||||
return sample.size or len(sample.data)
|
||||
|
||||
|
||||
def _sample_data(sample: SampleInfo) -> bytes:
|
||||
"""Return sample payload bytes, loading file-backed samples on demand."""
|
||||
if sample.data:
|
||||
return sample.data
|
||||
if sample.data_path and sample.size:
|
||||
with open(sample.data_path, "rb") as f:
|
||||
f.seek(sample.data_offset)
|
||||
data = f.read(sample.size)
|
||||
if len(data) != sample.size:
|
||||
raise IOError(
|
||||
f"unexpected EOF while reading sample at {sample.data_offset} "
|
||||
f"from {sample.data_path}"
|
||||
)
|
||||
return data
|
||||
return sample.data
|
||||
|
||||
|
||||
def _with_sample_data(sample: SampleInfo, data: bytes) -> SampleInfo:
|
||||
"""Return a copy of a sample with materialized payload bytes."""
|
||||
return SampleInfo(
|
||||
data=data,
|
||||
duration=sample.duration,
|
||||
desc_index=sample.desc_index,
|
||||
iv=sample.iv,
|
||||
subsamples=sample.subsamples,
|
||||
composition_time_offset=sample.composition_time_offset,
|
||||
sample_flags=sample.sample_flags,
|
||||
is_sync=sample.is_sync,
|
||||
size=sample.size or len(data),
|
||||
data_path=sample.data_path,
|
||||
data_offset=sample.data_offset,
|
||||
)
|
||||
|
||||
|
||||
def _decrypt_cbcs_sample_with_key(
|
||||
sample: SampleInfo, key: bytes, enc_info: EncryptionInfo
|
||||
) -> bytes:
|
||||
"""Decrypt one CBCS sample with a raw AES key."""
|
||||
if enc_info.crypt_byte_block and enc_info.skip_byte_block:
|
||||
return _decrypt_cbcs_sample_with_pattern(sample, key, enc_info)
|
||||
|
||||
parts = _cbcs_ciphertext_for_sample(sample)
|
||||
if parts is None:
|
||||
return sample.data
|
||||
|
||||
aligned, tail = parts
|
||||
plain = b""
|
||||
if aligned:
|
||||
iv = sample.iv if sample.iv else enc_info.constant_iv
|
||||
if len(iv) < 16:
|
||||
iv = iv + b"\x00" * (16 - len(iv))
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv=iv)
|
||||
plain = cipher.decrypt(aligned)
|
||||
return _reassemble_cbcs_sample(sample, plain, tail)
|
||||
|
||||
|
||||
def _decrypt_cbcs_protected_range_with_pattern(
|
||||
protected_data: bytes, key: bytes, iv: bytes, crypt_blocks: int, skip_blocks: int
|
||||
) -> bytes:
|
||||
"""Decrypt one CBCS pattern-encrypted protected byte stream."""
|
||||
if crypt_blocks <= 0:
|
||||
return protected_data
|
||||
if len(iv) < 16:
|
||||
iv = iv + b"\x00" * (16 - len(iv))
|
||||
|
||||
out = bytearray()
|
||||
offset = 0
|
||||
crypt_bytes = crypt_blocks * 16
|
||||
skip_bytes = skip_blocks * 16
|
||||
next_iv = iv
|
||||
|
||||
while offset < len(protected_data):
|
||||
remaining = len(protected_data) - offset
|
||||
crypt_window = min(crypt_bytes, remaining)
|
||||
aligned_crypt_len = crypt_window & ~0xF
|
||||
|
||||
if aligned_crypt_len:
|
||||
ciphertext = protected_data[offset : offset + aligned_crypt_len]
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv=next_iv)
|
||||
out.extend(cipher.decrypt(ciphertext))
|
||||
next_iv = ciphertext[-16:]
|
||||
offset += aligned_crypt_len
|
||||
|
||||
crypt_tail_len = crypt_window - aligned_crypt_len
|
||||
if crypt_tail_len:
|
||||
out.extend(protected_data[offset : offset + crypt_tail_len])
|
||||
offset += crypt_tail_len
|
||||
|
||||
if offset >= len(protected_data):
|
||||
break
|
||||
|
||||
clear_len = min(skip_bytes, len(protected_data) - offset)
|
||||
if clear_len:
|
||||
out.extend(protected_data[offset : offset + clear_len])
|
||||
offset += clear_len
|
||||
else:
|
||||
# Avoid spinning forever if a malformed tenc reports no skip phase.
|
||||
remaining = len(protected_data) - offset
|
||||
aligned_crypt_len = remaining & ~0xF
|
||||
if aligned_crypt_len:
|
||||
ciphertext = protected_data[offset : offset + aligned_crypt_len]
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv=next_iv)
|
||||
out.extend(cipher.decrypt(ciphertext))
|
||||
next_iv = ciphertext[-16:]
|
||||
offset += aligned_crypt_len
|
||||
if offset < len(protected_data):
|
||||
out.extend(protected_data[offset:])
|
||||
break
|
||||
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _decrypt_cbcs_sample_with_pattern(
|
||||
sample: SampleInfo, key: bytes, enc_info: EncryptionInfo
|
||||
) -> bytes:
|
||||
"""Decrypt CBCS pattern-encrypted samples, preserving skipped video blocks.
|
||||
|
||||
CBCS pattern state and AES-CBC state reset for each protected subsample
|
||||
range. Carrying either one into the next range corrupts later video regions.
|
||||
"""
|
||||
iv = sample.iv if sample.iv else enc_info.constant_iv
|
||||
if not sample.subsamples:
|
||||
return _decrypt_cbcs_protected_range_with_pattern(
|
||||
sample.data,
|
||||
key,
|
||||
iv,
|
||||
enc_info.crypt_byte_block,
|
||||
enc_info.skip_byte_block,
|
||||
)
|
||||
|
||||
out = bytearray()
|
||||
offset = 0
|
||||
for clear_bytes, encrypted_bytes in sample.subsamples:
|
||||
if clear_bytes:
|
||||
out.extend(sample.data[offset : offset + clear_bytes])
|
||||
offset += clear_bytes
|
||||
if encrypted_bytes:
|
||||
protected_data = sample.data[offset : offset + encrypted_bytes]
|
||||
out.extend(
|
||||
_decrypt_cbcs_protected_range_with_pattern(
|
||||
protected_data,
|
||||
key,
|
||||
iv,
|
||||
enc_info.crypt_byte_block,
|
||||
enc_info.skip_byte_block,
|
||||
)
|
||||
)
|
||||
offset += encrypted_bytes
|
||||
if offset < len(sample.data):
|
||||
out.extend(sample.data[offset:])
|
||||
return bytes(out)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SampleInfo:
|
||||
"""Information about a single media sample."""
|
||||
|
||||
data: bytes
|
||||
duration: int
|
||||
desc_index: int
|
||||
iv: bytes = b"" # Per-sample IV from senc (empty if constant IV)
|
||||
subsamples: List[tuple] = field(
|
||||
default_factory=list
|
||||
) # [(clear_bytes, encrypted_bytes), ...]
|
||||
composition_time_offset: int = 0
|
||||
sample_flags: int = 0
|
||||
is_sync: bool = True
|
||||
size: int = 0
|
||||
data_path: Optional[str] = None
|
||||
data_offset: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class EncryptionInfo:
|
||||
"""Encryption scheme info extracted from sinf/schm + sinf/schi/tenc."""
|
||||
|
||||
scheme_type: str = "cbcs" # 'cenc' or 'cbcs'
|
||||
crypt_byte_block: int = 0 # CBCS pattern encrypted 16-byte blocks
|
||||
skip_byte_block: int = 0 # CBCS pattern clear 16-byte blocks
|
||||
per_sample_iv_size: int = 0 # 0, 8, or 16
|
||||
constant_iv: bytes = b"" # Constant IV (when per_sample_iv_size == 0)
|
||||
kid: bytes = b"" # Default Key ID (16 bytes)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SongInfo:
|
||||
"""Extracted song information from MP4 file."""
|
||||
|
||||
samples: List[SampleInfo] = field(default_factory=list)
|
||||
moov_data: bytes = b""
|
||||
ftyp_data: bytes = b""
|
||||
encryption_info: Optional[EncryptionInfo] = None
|
||||
handler_type: bytes = b"soun"
|
||||
track_id: int = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class DecryptedTrack:
|
||||
"""A decrypted media track and the source metadata needed to write it."""
|
||||
|
||||
input_path: str
|
||||
track_info: SongInfo
|
||||
data: bytes = b""
|
||||
data_path: Optional[str] = None
|
||||
data_size: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class DecryptedMedia:
|
||||
"""Decrypted audio, optional video, and optional timed text tracks."""
|
||||
|
||||
audio: DecryptedTrack
|
||||
video: Optional[DecryptedTrack] = None
|
||||
captions: List[DecryptedTrack] = field(default_factory=list)
|
||||
|
||||
|
||||
def _format_handler_type(handler_type: bytes) -> str:
|
||||
return handler_type.decode("ascii", errors="replace")
|
||||
|
||||
|
||||
def read_box_header(f: BinaryIO) -> tuple[int, str, int]:
|
||||
"""Read MP4 box header, return (size, type, header_size)."""
|
||||
header = f.read(8)
|
||||
if len(header) < 8:
|
||||
return 0, "", 0
|
||||
|
||||
size = struct.unpack(">I", header[:4])[0]
|
||||
box_type = header[4:8].decode("ascii", errors="replace")
|
||||
header_size = 8
|
||||
|
||||
if size == 1: # Extended size
|
||||
ext_size = f.read(8)
|
||||
size = struct.unpack(">Q", ext_size)[0]
|
||||
header_size = 16
|
||||
elif size == 0: # Box extends to end of file
|
||||
pos = f.tell()
|
||||
f.seek(0, 2) # Seek to end
|
||||
size = f.tell() - pos + header_size
|
||||
f.seek(pos)
|
||||
|
||||
return size, box_type, header_size
|
||||
|
||||
|
||||
def find_box(data: bytes, box_path: List[str]) -> Optional[bytes]:
|
||||
"""Find a box in MP4 data by path (e.g., ['moov', 'trak', 'mdia'])."""
|
||||
f = io.BytesIO(data)
|
||||
|
||||
for target_type in box_path:
|
||||
found = False
|
||||
while True:
|
||||
pos = f.tell()
|
||||
size, box_type, header_size = read_box_header(f)
|
||||
if size == 0:
|
||||
break
|
||||
|
||||
if box_type == target_type:
|
||||
f.seek(pos + header_size) # Skip past header
|
||||
found = True
|
||||
break
|
||||
else:
|
||||
f.seek(pos + size) # Skip this box
|
||||
|
||||
if not found:
|
||||
return None
|
||||
|
||||
# Return remaining data from current position
|
||||
return f.read()
|
||||
|
||||
|
||||
def extract_song(
|
||||
input_path: str,
|
||||
handler_type: bytes = b"soun",
|
||||
file_backed_samples: bool = False,
|
||||
) -> SongInfo:
|
||||
"""
|
||||
Extract media samples and metadata from encrypted MP4 file.
|
||||
|
||||
This parses the MP4 structure to extract:
|
||||
- ftyp and moov boxes (for reassembly)
|
||||
- Individual audio samples from mdat boxes
|
||||
- Sample durations and description indices from moof boxes
|
||||
"""
|
||||
song_info = SongInfo(handler_type=handler_type)
|
||||
|
||||
# First pass: collect all top-level boxes
|
||||
boxes = []
|
||||
if file_backed_samples:
|
||||
file_size = os.path.getsize(input_path)
|
||||
with open(input_path, "rb") as f:
|
||||
offset = 0
|
||||
while offset + 8 <= file_size:
|
||||
f.seek(offset)
|
||||
header = f.read(8)
|
||||
if len(header) < 8:
|
||||
break
|
||||
size = struct.unpack(">I", header[:4])[0]
|
||||
box_type = header[4:8].decode("ascii", errors="replace")
|
||||
header_size = 8
|
||||
if size == 0:
|
||||
size = file_size - offset
|
||||
elif size == 1:
|
||||
ext_size = f.read(8)
|
||||
if len(ext_size) < 8:
|
||||
break
|
||||
size = struct.unpack(">Q", ext_size)[0]
|
||||
header_size = 16
|
||||
if size < header_size or offset + size > file_size:
|
||||
break
|
||||
|
||||
data = b""
|
||||
if box_type in ("ftyp", "moov", "moof"):
|
||||
f.seek(offset)
|
||||
data = f.read(size)
|
||||
boxes.append(
|
||||
{
|
||||
"offset": offset,
|
||||
"size": size,
|
||||
"type": box_type,
|
||||
"header_size": header_size,
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
offset += size
|
||||
else:
|
||||
with open(input_path, "rb") as f:
|
||||
raw_data = f.read()
|
||||
|
||||
offset = 0
|
||||
while offset < len(raw_data) - 8:
|
||||
size = struct.unpack(">I", raw_data[offset : offset + 4])[0]
|
||||
box_type = raw_data[offset + 4 : offset + 8].decode(
|
||||
"ascii", errors="replace"
|
||||
)
|
||||
|
||||
header_size = 8
|
||||
if size == 0:
|
||||
break
|
||||
if size == 1:
|
||||
# Extended size
|
||||
if offset + 16 > len(raw_data):
|
||||
break
|
||||
size = struct.unpack(">Q", raw_data[offset + 8 : offset + 16])[0]
|
||||
header_size = 16
|
||||
|
||||
boxes.append(
|
||||
{
|
||||
"offset": offset,
|
||||
"size": size,
|
||||
"type": box_type,
|
||||
"header_size": header_size,
|
||||
"data": raw_data[offset : offset + size],
|
||||
}
|
||||
)
|
||||
offset += size
|
||||
|
||||
# Extract ftyp and moov
|
||||
for box in boxes:
|
||||
if box["type"] == "ftyp":
|
||||
song_info.ftyp_data = box["data"]
|
||||
elif box["type"] == "moov":
|
||||
song_info.moov_data = box["data"]
|
||||
|
||||
# Determine which track carries the requested media handler.
|
||||
track_id = (
|
||||
_extract_track_id(song_info.moov_data, handler_type, 0)
|
||||
if song_info.moov_data
|
||||
else 0
|
||||
)
|
||||
song_info.track_id = track_id
|
||||
if track_id == 0:
|
||||
return song_info
|
||||
|
||||
# Get default sample info from trex (inside moov/mvex)
|
||||
trex_defaults = (
|
||||
_extract_trex_defaults(song_info.moov_data, track_id)
|
||||
if song_info.moov_data
|
||||
else None
|
||||
)
|
||||
if trex_defaults:
|
||||
default_sample_duration = trex_defaults["default_sample_duration"]
|
||||
default_sample_size = trex_defaults["default_sample_size"]
|
||||
default_sample_flags = trex_defaults["default_sample_flags"]
|
||||
else:
|
||||
# Fallback defaults. ALAC typically uses 4096 samples per frame,
|
||||
# while AAC uses 1024. Default to 4096 if the track contains 'alac'.
|
||||
is_alac = (
|
||||
handler_type == b"soun"
|
||||
and song_info.moov_data
|
||||
and b"alac" in song_info.moov_data
|
||||
)
|
||||
default_sample_duration = (
|
||||
4096 if is_alac else (1024 if handler_type == b"soun" else 0)
|
||||
)
|
||||
default_sample_size = 0
|
||||
default_sample_flags = 0
|
||||
# Extract encryption scheme info from moov (sinf/schm + sinf/schi/tenc)
|
||||
if song_info.moov_data:
|
||||
song_info.encryption_info = _extract_encryption_info(
|
||||
song_info.moov_data, handler_type
|
||||
)
|
||||
|
||||
# Parse moof/mdat pairs
|
||||
moof_box = None
|
||||
for box in boxes:
|
||||
if box["type"] == "moof":
|
||||
moof_box = box
|
||||
elif box["type"] == "mdat" and moof_box is not None:
|
||||
# Parse this moof/mdat pair
|
||||
moof_data = moof_box["data"]
|
||||
if file_backed_samples:
|
||||
mdat_data = b""
|
||||
mdat_data_size = box["size"] - box["header_size"]
|
||||
else:
|
||||
mdat_data = box["data"][box["header_size"] :] # Skip mdat header
|
||||
mdat_data_size = len(mdat_data)
|
||||
|
||||
# Parse moof for tfhd (sample description index, defaults) and trun (entries)
|
||||
_iv_size = (
|
||||
song_info.encryption_info.per_sample_iv_size
|
||||
if song_info.encryption_info
|
||||
else 0
|
||||
)
|
||||
samples_from_pair = _parse_moof_mdat(
|
||||
moof_data,
|
||||
mdat_data,
|
||||
default_sample_duration,
|
||||
default_sample_size,
|
||||
default_sample_flags,
|
||||
audio_track_id=track_id,
|
||||
moof_offset=moof_box["offset"],
|
||||
mdat_data_offset=box["offset"] + box["header_size"],
|
||||
per_sample_iv_size=_iv_size,
|
||||
mdat_data_size=mdat_data_size,
|
||||
mdat_source_path=input_path if file_backed_samples else None,
|
||||
)
|
||||
song_info.samples.extend(samples_from_pair)
|
||||
moof_box = None
|
||||
|
||||
# Post-process samples: if this is ALAC, ensure all samples have duration 4096.
|
||||
# Apple Music fragments often report 1024 in trex/tfhd defaults, but
|
||||
# ALAC frames are actually 4096 samples long. This mismatch is the
|
||||
# root cause of the 1:16 duration reporting for 5-minute tracks.
|
||||
is_alac = (
|
||||
handler_type == b"soun"
|
||||
and song_info.moov_data
|
||||
and (b"alac" in song_info.moov_data or b"ALAC" in song_info.moov_data)
|
||||
)
|
||||
if is_alac:
|
||||
for sample in song_info.samples:
|
||||
# Only override if it was 0 or the common incorrect default of 1024
|
||||
if sample.duration in (0, 1024):
|
||||
sample.duration = 4096
|
||||
|
||||
return song_info
|
||||
|
||||
|
||||
def _parse_moof_mdat(
|
||||
moof_data: bytes,
|
||||
mdat_data: bytes,
|
||||
default_sample_duration: int,
|
||||
default_sample_size: int,
|
||||
default_sample_flags: int = 0,
|
||||
audio_track_id: int = 1,
|
||||
moof_offset: int = 0,
|
||||
mdat_data_offset: int = 0,
|
||||
per_sample_iv_size: int = 0,
|
||||
mdat_data_size: Optional[int] = None,
|
||||
mdat_source_path: Optional[str] = None,
|
||||
) -> List[SampleInfo]:
|
||||
"""Parse a moof box and extract samples from corresponding mdat.
|
||||
|
||||
Handles multi-track fragmented MP4s by only extracting samples from
|
||||
the traf matching the audio track ID.
|
||||
|
||||
Args:
|
||||
audio_track_id: Track ID of the audio track to extract.
|
||||
moof_offset: Absolute file offset of the moof box.
|
||||
mdat_data_offset: Absolute file offset of the mdat content (after header).
|
||||
per_sample_iv_size: IV size per sample from tenc (0, 8, or 16).
|
||||
"""
|
||||
samples = []
|
||||
available_mdat_bytes = len(mdat_data) if mdat_data_size is None else mdat_data_size
|
||||
|
||||
# Simple box parsing inside moof
|
||||
offset = 8 # Skip moof header
|
||||
while offset < len(moof_data) - 8:
|
||||
size = struct.unpack(">I", moof_data[offset : offset + 4])[0]
|
||||
box_type = moof_data[offset + 4 : offset + 8].decode("ascii", errors="replace")
|
||||
|
||||
if size == 0 or offset + size > len(moof_data):
|
||||
break
|
||||
|
||||
if box_type == "traf":
|
||||
# Parse inside traf with per-traf state
|
||||
tfhd_info = {
|
||||
"track_id": 0,
|
||||
"desc_index": 0,
|
||||
"default_duration": default_sample_duration,
|
||||
"default_size": default_sample_size,
|
||||
"default_sample_flags": default_sample_flags,
|
||||
"flags": 0,
|
||||
"base_data_offset": None,
|
||||
}
|
||||
# Each 'trun' has its own optional data_offset; concatenating entry lists
|
||||
# and keeping only the first data_offset breaks multi-trun fragments.
|
||||
trun_runs: List[tuple] = []
|
||||
raw_senc_data: bytes | None = None
|
||||
|
||||
traf_offset = offset + 8
|
||||
traf_end = offset + size
|
||||
while traf_offset < traf_end - 8:
|
||||
inner_size = struct.unpack(
|
||||
">I", moof_data[traf_offset : traf_offset + 4]
|
||||
)[0]
|
||||
inner_type = moof_data[traf_offset + 4 : traf_offset + 8].decode(
|
||||
"ascii", errors="replace"
|
||||
)
|
||||
|
||||
if inner_size == 0:
|
||||
break
|
||||
|
||||
if inner_type == "tfhd":
|
||||
_parse_tfhd(
|
||||
moof_data[traf_offset + 8 : traf_offset + inner_size], tfhd_info
|
||||
)
|
||||
elif inner_type == "trun":
|
||||
entries, data_off = _parse_trun(
|
||||
moof_data[traf_offset + 8 : traf_offset + inner_size], tfhd_info
|
||||
)
|
||||
trun_runs.append((entries, data_off))
|
||||
elif inner_type == "senc":
|
||||
raw_senc_data = moof_data[
|
||||
traf_offset + 8 : traf_offset + inner_size
|
||||
]
|
||||
|
||||
traf_offset += inner_size
|
||||
|
||||
# Only process this traf if it matches the audio track
|
||||
if tfhd_info["track_id"] != audio_track_id:
|
||||
offset += size
|
||||
continue
|
||||
|
||||
base = tfhd_info.get("base_data_offset")
|
||||
if base is None:
|
||||
base = moof_offset # Default: first byte of containing moof
|
||||
|
||||
desc_index = tfhd_info["desc_index"]
|
||||
if desc_index > 0:
|
||||
desc_index -= 1 # Convert to 0-indexed
|
||||
|
||||
sample_sizes = [
|
||||
entry.get("size", tfhd_info["default_size"])
|
||||
for trun_entries, _ in trun_runs
|
||||
for entry in trun_entries
|
||||
]
|
||||
senc_entries = (
|
||||
_parse_senc_for_sample_sizes(
|
||||
raw_senc_data,
|
||||
sample_sizes,
|
||||
per_sample_iv_size,
|
||||
)
|
||||
if raw_senc_data is not None
|
||||
else []
|
||||
)
|
||||
|
||||
mdat_pos: Optional[int] = None
|
||||
sample_index_in_traf = 0
|
||||
|
||||
for trun_entries, trun_data_off in trun_runs:
|
||||
if trun_data_off is not None:
|
||||
mdat_pos = base + trun_data_off - mdat_data_offset
|
||||
elif mdat_pos is None:
|
||||
# Legacy behaviour: first trun without data_offset starts at mdat payload 0.
|
||||
mdat_pos = 0
|
||||
mdat_pos = max(0, mdat_pos)
|
||||
mdat_read_offset = mdat_pos
|
||||
|
||||
for entry in trun_entries:
|
||||
sample_size = entry.get("size", tfhd_info["default_size"])
|
||||
sample_duration = entry.get(
|
||||
"duration", tfhd_info["default_duration"]
|
||||
)
|
||||
sample_flags = entry.get(
|
||||
"sample_flags", tfhd_info["default_sample_flags"]
|
||||
)
|
||||
|
||||
if (
|
||||
sample_size > 0
|
||||
and mdat_read_offset + sample_size <= available_mdat_bytes
|
||||
):
|
||||
sample_iv = b""
|
||||
sample_subsamples: List[tuple] = []
|
||||
if sample_index_in_traf < len(senc_entries):
|
||||
sample_iv = senc_entries[sample_index_in_traf]["iv"]
|
||||
sample_subsamples = senc_entries[sample_index_in_traf][
|
||||
"subsamples"
|
||||
]
|
||||
if mdat_source_path:
|
||||
sample_data = b""
|
||||
sample_data_offset = mdat_data_offset + mdat_read_offset
|
||||
else:
|
||||
sample_data = mdat_data[
|
||||
mdat_read_offset : mdat_read_offset + sample_size
|
||||
]
|
||||
sample_data_offset = 0
|
||||
|
||||
sample = SampleInfo(
|
||||
data=sample_data,
|
||||
duration=sample_duration,
|
||||
desc_index=desc_index,
|
||||
iv=sample_iv,
|
||||
subsamples=sample_subsamples,
|
||||
composition_time_offset=entry.get(
|
||||
"composition_time_offset", 0
|
||||
),
|
||||
sample_flags=sample_flags,
|
||||
is_sync=not bool(sample_flags & 0x10000),
|
||||
size=sample_size,
|
||||
data_path=mdat_source_path,
|
||||
data_offset=sample_data_offset,
|
||||
)
|
||||
samples.append(sample)
|
||||
mdat_read_offset += sample_size
|
||||
# One senc row per trun sample entry (even if size 0 or read fails)
|
||||
sample_index_in_traf += 1
|
||||
|
||||
mdat_pos = mdat_read_offset
|
||||
|
||||
offset += size
|
||||
|
||||
return samples
|
||||
|
||||
|
||||
def _parse_tfhd(data: bytes, tfhd_info: dict):
|
||||
"""Parse track fragment header box (FullBox: version + flags + content)."""
|
||||
if len(data) < 8: # version(1) + flags(3) + track_id(4)
|
||||
return
|
||||
|
||||
# FullBox: version(1) + flags(3)
|
||||
version = data[0]
|
||||
flags = struct.unpack(">I", b"\x00" + data[1:4])[0]
|
||||
tfhd_info["flags"] = flags
|
||||
|
||||
# After version+flags is track_id(4)
|
||||
tfhd_info["track_id"] = struct.unpack(">I", data[4:8])[0]
|
||||
offset = 4 + 4 # version+flags + track_id
|
||||
|
||||
if flags & 0x01 and offset + 8 <= len(data): # base_data_offset
|
||||
tfhd_info["base_data_offset"] = struct.unpack(">Q", data[offset : offset + 8])[
|
||||
0
|
||||
]
|
||||
offset += 8
|
||||
if flags & 0x02 and offset + 4 <= len(data): # sample_description_index
|
||||
tfhd_info["desc_index"] = struct.unpack(">I", data[offset : offset + 4])[0]
|
||||
offset += 4
|
||||
if flags & 0x08 and offset + 4 <= len(data): # default_sample_duration
|
||||
tfhd_info["default_duration"] = struct.unpack(">I", data[offset : offset + 4])[
|
||||
0
|
||||
]
|
||||
offset += 4
|
||||
if flags & 0x10 and offset + 4 <= len(data): # default_sample_size
|
||||
tfhd_info["default_size"] = struct.unpack(">I", data[offset : offset + 4])[0]
|
||||
offset += 4
|
||||
if flags & 0x20 and offset + 4 <= len(data): # default_sample_flags
|
||||
tfhd_info["default_sample_flags"] = struct.unpack(
|
||||
">I", data[offset : offset + 4]
|
||||
)[0]
|
||||
|
||||
|
||||
def _parse_trun(data: bytes, tfhd_info: dict) -> tuple[List[dict], Optional[int]]:
|
||||
"""Parse track run box to get sample entries and data_offset.
|
||||
|
||||
Returns:
|
||||
Tuple of (entries, data_offset). data_offset is the signed offset from
|
||||
base_data_offset to the first sample's data, or None if not present.
|
||||
"""
|
||||
entries = []
|
||||
data_offset_value = None
|
||||
if len(data) < 8: # version(1) + flags(3) + sample_count(4)
|
||||
return entries, data_offset_value
|
||||
|
||||
# FullBox: version(1) + flags(3)
|
||||
version = data[0]
|
||||
flags = struct.unpack(">I", b"\x00" + data[1:4])[0]
|
||||
sample_count = struct.unpack(">I", data[4:8])[0]
|
||||
|
||||
# Start reading entries after header fields
|
||||
offset = 8 # version+flags(4) + sample_count(4)
|
||||
if flags & 0x01: # data_offset present
|
||||
data_offset_value = struct.unpack(">i", data[offset : offset + 4])[0]
|
||||
offset += 4
|
||||
first_sample_flags = None
|
||||
if flags & 0x04: # first_sample_flags present
|
||||
first_sample_flags = struct.unpack(">I", data[offset : offset + 4])[0]
|
||||
offset += 4
|
||||
|
||||
for sample_index in range(sample_count):
|
||||
entry = {}
|
||||
if flags & 0x100 and offset + 4 <= len(data): # sample_duration
|
||||
entry["duration"] = struct.unpack(">I", data[offset : offset + 4])[0]
|
||||
offset += 4
|
||||
if flags & 0x200 and offset + 4 <= len(data): # sample_size
|
||||
entry["size"] = struct.unpack(">I", data[offset : offset + 4])[0]
|
||||
offset += 4
|
||||
if flags & 0x400 and offset + 4 <= len(data): # sample_flags
|
||||
entry["sample_flags"] = struct.unpack(">I", data[offset : offset + 4])[0]
|
||||
offset += 4
|
||||
elif sample_index == 0 and first_sample_flags is not None:
|
||||
entry["sample_flags"] = first_sample_flags
|
||||
if flags & 0x800 and offset + 4 <= len(data): # sample_composition_time_offset
|
||||
if version == 1:
|
||||
entry["composition_time_offset"] = struct.unpack(
|
||||
">i", data[offset : offset + 4]
|
||||
)[0]
|
||||
else:
|
||||
entry["composition_time_offset"] = struct.unpack(
|
||||
">I", data[offset : offset + 4]
|
||||
)[0]
|
||||
offset += 4
|
||||
entries.append(entry)
|
||||
|
||||
return entries, data_offset_value
|
||||
|
||||
|
||||
def _parse_senc(data: bytes, per_sample_iv_size: int) -> List[dict]:
|
||||
"""Parse Sample Encryption Box (senc) content (after box header).
|
||||
|
||||
Returns a list of dicts, one per sample:
|
||||
{"iv": bytes, "subsamples": [(clear_bytes, encrypted_bytes), ...]}
|
||||
|
||||
The data starts after the 8-byte box header (size+type) but includes
|
||||
the FullBox header (version 1 byte + flags 3 bytes).
|
||||
|
||||
per_sample_iv_size can be 0 (cbcs with constant IV from tenc) — in that case
|
||||
there are 0 IV bytes per sample but subsample info may still be present.
|
||||
"""
|
||||
if len(data) < 8:
|
||||
return []
|
||||
|
||||
version = data[0]
|
||||
flags = struct.unpack(">I", b"\x00" + data[1:4])[0]
|
||||
sample_count = struct.unpack(">I", data[4:8])[0]
|
||||
|
||||
entries: List[dict] = []
|
||||
offset = 8
|
||||
for _ in range(sample_count):
|
||||
# Read per-sample IV (0 bytes when per_sample_iv_size == 0)
|
||||
iv = b""
|
||||
if per_sample_iv_size > 0:
|
||||
if offset + per_sample_iv_size > len(data):
|
||||
break
|
||||
iv = data[offset : offset + per_sample_iv_size]
|
||||
offset += per_sample_iv_size
|
||||
|
||||
subsamples = []
|
||||
if flags & 0x02:
|
||||
# Subsample encryption info present
|
||||
if offset + 2 > len(data):
|
||||
break
|
||||
subsample_count = struct.unpack(">H", data[offset : offset + 2])[0]
|
||||
offset += 2
|
||||
for _ in range(subsample_count):
|
||||
if offset + 6 > len(data):
|
||||
break
|
||||
clear_bytes = struct.unpack(">H", data[offset : offset + 2])[0]
|
||||
encrypted_bytes = struct.unpack(">I", data[offset + 2 : offset + 6])[0]
|
||||
subsamples.append((clear_bytes, encrypted_bytes))
|
||||
offset += 6
|
||||
|
||||
entries.append({"iv": iv, "subsamples": subsamples})
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def _parse_senc_strict(
|
||||
data: bytes,
|
||||
per_sample_iv_size: int,
|
||||
sample_sizes: List[int],
|
||||
) -> tuple[List[dict], bool, str]:
|
||||
"""Parse senc and reject rows whose subsamples cannot fit their samples."""
|
||||
if len(data) < 8:
|
||||
return [], False, "truncated_header"
|
||||
|
||||
flags = struct.unpack(">I", b"\x00" + data[1:4])[0]
|
||||
sample_count = struct.unpack(">I", data[4:8])[0]
|
||||
if sample_count > len(sample_sizes):
|
||||
return [], False, f"sample_count_gt_trun:{sample_count}>{len(sample_sizes)}"
|
||||
|
||||
entries: List[dict] = []
|
||||
offset = 8
|
||||
for sample_index in range(sample_count):
|
||||
iv = b""
|
||||
if per_sample_iv_size > 0:
|
||||
if offset + per_sample_iv_size > len(data):
|
||||
return [], False, f"truncated_iv:index={sample_index}"
|
||||
iv = data[offset : offset + per_sample_iv_size]
|
||||
offset += per_sample_iv_size
|
||||
|
||||
subsamples = []
|
||||
if flags & 0x02:
|
||||
if offset + 2 > len(data):
|
||||
return [], False, f"truncated_subsample_count:index={sample_index}"
|
||||
subsample_count = struct.unpack(">H", data[offset : offset + 2])[0]
|
||||
offset += 2
|
||||
sample_size = sample_sizes[sample_index]
|
||||
total_bytes = 0
|
||||
for _ in range(subsample_count):
|
||||
if offset + 6 > len(data):
|
||||
return [], False, f"truncated_subsample:index={sample_index}"
|
||||
clear_bytes = struct.unpack(">H", data[offset : offset + 2])[0]
|
||||
encrypted_bytes = struct.unpack(">I", data[offset + 2 : offset + 6])[0]
|
||||
total_bytes += clear_bytes + encrypted_bytes
|
||||
if total_bytes > sample_size:
|
||||
return (
|
||||
[],
|
||||
False,
|
||||
"subsample_total_gt_sample:"
|
||||
f"index={sample_index},total={total_bytes},sample={sample_size},"
|
||||
f"clear={clear_bytes},enc={encrypted_bytes},count={subsample_count}",
|
||||
)
|
||||
subsamples.append((clear_bytes, encrypted_bytes))
|
||||
offset += 6
|
||||
|
||||
entries.append({"iv": iv, "subsamples": subsamples})
|
||||
|
||||
return entries, True, "ok"
|
||||
|
||||
|
||||
def _parse_senc_for_sample_sizes(
|
||||
data: bytes,
|
||||
sample_sizes: List[int],
|
||||
preferred_iv_size: int,
|
||||
) -> List[dict]:
|
||||
"""Parse senc using the preferred IV size, falling back when it is impossible."""
|
||||
iv_size_candidates = []
|
||||
for iv_size in (preferred_iv_size, 8, 16, 0):
|
||||
if iv_size not in iv_size_candidates:
|
||||
iv_size_candidates.append(iv_size)
|
||||
|
||||
for iv_size in iv_size_candidates:
|
||||
entries, valid, reason = _parse_senc_strict(data, iv_size, sample_sizes)
|
||||
if valid:
|
||||
return entries
|
||||
|
||||
logger.warning(
|
||||
"senc_parse_failed_validation",
|
||||
preferred_iv_size=preferred_iv_size,
|
||||
data_len=len(data),
|
||||
sample_count=len(sample_sizes),
|
||||
last_reason=reason,
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
async def decrypt_samples(
|
||||
wrapper_api: WrapperApi,
|
||||
track_id: str,
|
||||
fairplay_key: str,
|
||||
samples: List[SampleInfo],
|
||||
encryption_info: EncryptionInfo,
|
||||
encryption_info_per_desc: Optional[dict] = None,
|
||||
*,
|
||||
use_single_content_key: bool = False,
|
||||
progress_callback=None,
|
||||
decrypted_data_path: Optional[str] = None,
|
||||
) -> bytes:
|
||||
"""
|
||||
Send track-key samples to wrapper-v2 (HTTP POST /decrypt) for CBCS
|
||||
decryption and decrypt default prefetch-key samples locally.
|
||||
|
||||
Ciphertext is sent in batches of up to :data:`WRAPPER_DECRYPT_BATCH_SIZE` MP4 samples
|
||||
per request (same ``adam_id`` and ``uri``). Literal or tail-only samples are applied
|
||||
between batches so output order matches the input.
|
||||
|
||||
Subsample layout matches ``decrypt_samples_hex`` (concat encrypted runs, decrypt
|
||||
``len & ~0xF``, reassemble).
|
||||
|
||||
Requires an authenticated wrapper-v2 session (POST /login or restored session).
|
||||
|
||||
Args:
|
||||
wrapper_api: Authenticated :class:`~gamdl.api.wrapper.WrapperApi` client
|
||||
use_single_content_key: When ``False`` (default), description 0 uses the built-in
|
||||
prefetch key locally and description 1+ uses ``fairplay_key`` via the wrapper.
|
||||
When ``True``, every sample uses ``fairplay_key`` through the wrapper only.
|
||||
progress_callback: Optional callback(current_sample, total_samples, bytes_processed, speed)
|
||||
"""
|
||||
keys = [fairplay_key] if use_single_content_key else [PREFETCH_KEY, fairplay_key]
|
||||
decrypted_data = bytearray()
|
||||
decrypted_output = open(decrypted_data_path, "wb") if decrypted_data_path else None
|
||||
decrypted_bytes = 0
|
||||
last_desc_index: int = 255
|
||||
total_samples = len(samples)
|
||||
bytes_processed = 0
|
||||
start_time = time.time()
|
||||
last_progress_time = start_time
|
||||
|
||||
segment_adam: Optional[str] = None
|
||||
segment_uri: Optional[str] = None
|
||||
# Pending (sample, aligned_cbc, tail) for one SKD segment, flushed in batches.
|
||||
crypto_batch: List[tuple] = []
|
||||
|
||||
def emit(data: bytes) -> None:
|
||||
nonlocal decrypted_bytes
|
||||
if decrypted_output:
|
||||
decrypted_output.write(data)
|
||||
else:
|
||||
decrypted_data.extend(data)
|
||||
decrypted_bytes += len(data)
|
||||
|
||||
async def flush_crypto_batch() -> None:
|
||||
if not crypto_batch:
|
||||
return
|
||||
if segment_adam is None or segment_uri is None:
|
||||
raise IOError("wrapper-v2: internal error (segment without adam/uri)")
|
||||
chunks = [t[1] for t in crypto_batch]
|
||||
tails = [t[2] for t in crypto_batch]
|
||||
sources = [t[0] for t in crypto_batch]
|
||||
plains = await wrapper_api.decrypt(segment_adam, segment_uri, chunks)
|
||||
if len(plains) != len(chunks):
|
||||
raise IOError("wrapper-v2: plaintext batch count mismatch")
|
||||
for s, plain, tail in zip(sources, plains, tails):
|
||||
emit(_reassemble_cbcs_sample(s, plain, tail))
|
||||
crypto_batch.clear()
|
||||
|
||||
try:
|
||||
for i, original_sample in enumerate(samples):
|
||||
sample = (
|
||||
_with_sample_data(original_sample, _sample_data(original_sample))
|
||||
if not original_sample.data and original_sample.data_path
|
||||
else original_sample
|
||||
)
|
||||
if last_desc_index != sample.desc_index:
|
||||
await flush_crypto_batch()
|
||||
if use_single_content_key:
|
||||
segment_adam = track_id
|
||||
segment_uri = fairplay_key
|
||||
else:
|
||||
key_uri = keys[min(sample.desc_index, len(keys) - 1)]
|
||||
segment_adam = "0" if key_uri == PREFETCH_KEY else track_id
|
||||
segment_uri = key_uri
|
||||
last_desc_index = sample.desc_index
|
||||
|
||||
if not use_single_content_key and segment_adam == "0":
|
||||
await flush_crypto_batch()
|
||||
enc_info = (
|
||||
encryption_info_per_desc.get(sample.desc_index)
|
||||
if encryption_info_per_desc
|
||||
and sample.desc_index in encryption_info_per_desc
|
||||
else encryption_info
|
||||
)
|
||||
emit(
|
||||
_decrypt_cbcs_sample_with_key(
|
||||
sample, DEFAULT_SONG_DECRYPTION_KEY, enc_info
|
||||
)
|
||||
)
|
||||
bytes_processed += _sample_size(sample)
|
||||
now = time.time()
|
||||
if progress_callback and (
|
||||
i % 50 == 0
|
||||
or now - last_progress_time > 0.5
|
||||
or i == total_samples - 1
|
||||
):
|
||||
elapsed = now - start_time
|
||||
speed = bytes_processed / elapsed if elapsed > 0 else 0
|
||||
progress_callback(i + 1, total_samples, bytes_processed, speed)
|
||||
last_progress_time = now
|
||||
continue
|
||||
|
||||
enc_info = (
|
||||
encryption_info_per_desc.get(sample.desc_index)
|
||||
if encryption_info_per_desc
|
||||
and sample.desc_index in encryption_info_per_desc
|
||||
else encryption_info
|
||||
)
|
||||
if enc_info.crypt_byte_block and enc_info.skip_byte_block:
|
||||
raise IOError(
|
||||
"wrapper-v2 pattern CBCS decrypt is not supported by gamdl's "
|
||||
"batch decrypt path; use hex-key decrypt for this track"
|
||||
)
|
||||
|
||||
parts = _cbcs_ciphertext_for_sample(sample)
|
||||
if parts is None:
|
||||
await flush_crypto_batch()
|
||||
emit(sample.data)
|
||||
else:
|
||||
aligned, tail = parts
|
||||
if len(aligned) == 0:
|
||||
await flush_crypto_batch()
|
||||
emit(_reassemble_cbcs_sample(sample, b"", tail))
|
||||
else:
|
||||
crypto_batch.append((sample, aligned, tail))
|
||||
if len(crypto_batch) >= WRAPPER_DECRYPT_BATCH_SIZE:
|
||||
await flush_crypto_batch()
|
||||
|
||||
bytes_processed += _sample_size(sample)
|
||||
|
||||
now = time.time()
|
||||
if progress_callback and (
|
||||
i % 50 == 0 or now - last_progress_time > 0.5 or i == total_samples - 1
|
||||
):
|
||||
elapsed = now - start_time
|
||||
speed = bytes_processed / elapsed if elapsed > 0 else 0
|
||||
progress_callback(i + 1, total_samples, bytes_processed, speed)
|
||||
last_progress_time = now
|
||||
|
||||
await flush_crypto_batch()
|
||||
finally:
|
||||
if decrypted_output:
|
||||
decrypted_output.close()
|
||||
|
||||
logger.debug(f"Decrypted {len(samples)} samples ({decrypted_bytes} bytes)")
|
||||
return bytes(decrypted_data)
|
||||
|
||||
|
||||
def write_decrypted_m4a(
|
||||
output_path: str,
|
||||
song_info: SongInfo,
|
||||
decrypted_data: bytes,
|
||||
original_path: str = None,
|
||||
decrypted_data_path: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Write decrypted MP4 file as non-fragmented MP4.
|
||||
|
||||
Creates a new MP4 from scratch with:
|
||||
- ftyp box (M4A compatible)
|
||||
- moov box with proper sample tables (stts, stsc, stsz, stco)
|
||||
- Single mdat box with all decrypted samples
|
||||
|
||||
This matches the output format of Go's amdecrypt which is required
|
||||
for ALAC playback.
|
||||
"""
|
||||
# Extract original boxes for faithful reproduction
|
||||
# Note: _extract_stsd_content automatically cleans encryption metadata
|
||||
stsd_content = None
|
||||
orig_mvhd = None
|
||||
orig_tkhd = None
|
||||
orig_mdhd = None
|
||||
orig_smhd = None
|
||||
orig_dinf = None
|
||||
# We will use the actual audio sample rate from the stsd as our
|
||||
# master timescale to ensure 100% duration consistency.
|
||||
orig_hdlr = None
|
||||
timescale = 44100 # Default fallback
|
||||
preferred_desc_index = _preferred_sample_description_index(song_info.samples)
|
||||
|
||||
if song_info.moov_data:
|
||||
orig_data = song_info.ftyp_data + song_info.moov_data
|
||||
elif original_path:
|
||||
with open(original_path, "rb") as f:
|
||||
orig_data = f.read()
|
||||
else:
|
||||
orig_data = None
|
||||
|
||||
if orig_data:
|
||||
stsd_content = _extract_stsd_content(orig_data, preferred_desc_index)
|
||||
# Extract the REAL sample rate from the codec configuration
|
||||
timescale = _extract_sample_rate_from_stsd(stsd_content) or _extract_timescale(
|
||||
orig_data
|
||||
)
|
||||
|
||||
# Find moov box and extract child boxes
|
||||
moov_idx = orig_data.find(b"moov")
|
||||
if moov_idx >= 4:
|
||||
moov_size = struct.unpack(">I", orig_data[moov_idx - 4 : moov_idx])[0]
|
||||
moov_data = orig_data[moov_idx - 4 : moov_idx - 4 + moov_size]
|
||||
|
||||
orig_mvhd = _find_child_box(moov_data, b"mvhd")
|
||||
|
||||
audio_trak = _find_audio_trak(moov_data)
|
||||
if audio_trak:
|
||||
orig_tkhd = _find_child_box(audio_trak, b"tkhd")
|
||||
mdia = _find_child_box(audio_trak, b"mdia")
|
||||
if mdia:
|
||||
orig_mdhd = _find_child_box(mdia, b"mdhd")
|
||||
orig_hdlr = _find_child_box(mdia, b"hdlr")
|
||||
minf = _find_child_box(mdia, b"minf")
|
||||
if minf:
|
||||
orig_smhd = _find_child_box(minf, b"smhd")
|
||||
orig_dinf = _find_child_box(minf, b"dinf")
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
# Write ftyp
|
||||
_write_ftyp(f)
|
||||
|
||||
# Calculate total duration
|
||||
total_duration = sum(s.duration for s in song_info.samples)
|
||||
|
||||
# Write moov with sample tables
|
||||
_write_moov(
|
||||
f,
|
||||
song_info.samples,
|
||||
total_duration,
|
||||
timescale,
|
||||
stsd_content,
|
||||
decrypted_data,
|
||||
orig_mvhd=orig_mvhd,
|
||||
orig_tkhd=orig_tkhd,
|
||||
orig_mdhd=orig_mdhd,
|
||||
orig_hdlr=orig_hdlr,
|
||||
orig_smhd=orig_smhd,
|
||||
orig_dinf=orig_dinf,
|
||||
)
|
||||
|
||||
# Write mdat
|
||||
if decrypted_data_path:
|
||||
_write_mdat_from_sources(
|
||||
f,
|
||||
[(decrypted_data_path, 0, os.path.getsize(decrypted_data_path))],
|
||||
)
|
||||
else:
|
||||
_write_mdat(f, decrypted_data)
|
||||
|
||||
logger.debug(f"Wrote decrypted file to {output_path}")
|
||||
|
||||
|
||||
def write_decrypted_mp4_track(
|
||||
output_path: str,
|
||||
track_info: SongInfo,
|
||||
decrypted_data: bytes,
|
||||
original_path: str = None,
|
||||
decrypted_data_path: str | None = None,
|
||||
) -> None:
|
||||
"""Write one decrypted audio or video track as a flat MP4 file."""
|
||||
stsd_content = None
|
||||
orig_mvhd = None
|
||||
orig_tkhd = None
|
||||
orig_mdhd = None
|
||||
orig_hdlr = None
|
||||
orig_smhd = None
|
||||
orig_vmhd = None
|
||||
orig_nmhd = None
|
||||
orig_dinf = None
|
||||
timescale = 44100 if track_info.handler_type == b"soun" else 90000
|
||||
preferred_desc_index = _preferred_sample_description_index(track_info.samples)
|
||||
|
||||
if track_info.moov_data:
|
||||
orig_data = track_info.ftyp_data + track_info.moov_data
|
||||
elif original_path:
|
||||
with open(original_path, "rb") as f:
|
||||
orig_data = f.read()
|
||||
else:
|
||||
orig_data = None
|
||||
|
||||
if orig_data:
|
||||
stsd_content = _extract_stsd_content(
|
||||
orig_data,
|
||||
preferred_desc_index,
|
||||
track_info.handler_type,
|
||||
)
|
||||
if track_info.handler_type == b"soun":
|
||||
timescale = _extract_sample_rate_from_stsd(
|
||||
stsd_content
|
||||
) or _extract_track_timescale(orig_data, track_info.handler_type, timescale)
|
||||
else:
|
||||
timescale = _extract_track_timescale(
|
||||
orig_data, track_info.handler_type, timescale
|
||||
)
|
||||
|
||||
moov_idx = orig_data.find(b"moov")
|
||||
if moov_idx >= 4:
|
||||
moov_size = struct.unpack(">I", orig_data[moov_idx - 4 : moov_idx])[0]
|
||||
moov_data = orig_data[moov_idx - 4 : moov_idx - 4 + moov_size]
|
||||
|
||||
orig_mvhd = _find_child_box(moov_data, b"mvhd")
|
||||
trak = _find_track_by_handler(moov_data, track_info.handler_type)
|
||||
if trak:
|
||||
orig_tkhd = _find_child_box(trak, b"tkhd")
|
||||
mdia = _find_child_box(trak, b"mdia")
|
||||
if mdia:
|
||||
orig_mdhd = _find_child_box(mdia, b"mdhd")
|
||||
orig_hdlr = _find_child_box(mdia, b"hdlr")
|
||||
minf = _find_child_box(mdia, b"minf")
|
||||
if minf:
|
||||
orig_smhd = _find_child_box(minf, b"smhd")
|
||||
orig_vmhd = _find_child_box(minf, b"vmhd")
|
||||
orig_nmhd = _find_child_box(minf, b"nmhd")
|
||||
orig_dinf = _find_child_box(minf, b"dinf")
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
if track_info.handler_type == b"soun":
|
||||
_write_ftyp(f)
|
||||
else:
|
||||
_write_ftyp_mp4(f)
|
||||
|
||||
total_duration = sum(s.duration for s in track_info.samples)
|
||||
_write_moov(
|
||||
f,
|
||||
track_info.samples,
|
||||
total_duration,
|
||||
timescale,
|
||||
stsd_content,
|
||||
decrypted_data,
|
||||
orig_mvhd=orig_mvhd,
|
||||
orig_tkhd=orig_tkhd,
|
||||
orig_mdhd=orig_mdhd,
|
||||
orig_hdlr=orig_hdlr,
|
||||
orig_smhd=orig_smhd,
|
||||
orig_vmhd=orig_vmhd,
|
||||
orig_nmhd=orig_nmhd,
|
||||
orig_dinf=orig_dinf,
|
||||
handler_type=track_info.handler_type,
|
||||
)
|
||||
if decrypted_data_path:
|
||||
_write_mdat_from_sources(
|
||||
f,
|
||||
[(decrypted_data_path, 0, os.path.getsize(decrypted_data_path))],
|
||||
)
|
||||
else:
|
||||
_write_mdat(f, decrypted_data)
|
||||
|
||||
logger.debug(f"Wrote decrypted track file to {output_path}")
|
||||
|
||||
|
||||
def _build_decrypted_track_moov(
|
||||
track_info: SongInfo,
|
||||
original_path: str | None = None,
|
||||
) -> bytes:
|
||||
"""Build a single-track moov box for a decrypted track without writing mdat."""
|
||||
stsd_content = None
|
||||
orig_mvhd = None
|
||||
orig_tkhd = None
|
||||
orig_mdhd = None
|
||||
orig_hdlr = None
|
||||
orig_smhd = None
|
||||
orig_vmhd = None
|
||||
orig_nmhd = None
|
||||
orig_dinf = None
|
||||
timescale = 44100 if track_info.handler_type == b"soun" else 90000
|
||||
preferred_desc_index = _preferred_sample_description_index(track_info.samples)
|
||||
|
||||
if track_info.moov_data:
|
||||
orig_data = track_info.ftyp_data + track_info.moov_data
|
||||
elif original_path:
|
||||
with open(original_path, "rb") as f:
|
||||
orig_data = f.read()
|
||||
else:
|
||||
orig_data = None
|
||||
|
||||
if orig_data:
|
||||
stsd_content = _extract_stsd_content(
|
||||
orig_data,
|
||||
preferred_desc_index,
|
||||
track_info.handler_type,
|
||||
)
|
||||
if track_info.handler_type == b"soun":
|
||||
timescale = _extract_sample_rate_from_stsd(
|
||||
stsd_content
|
||||
) or _extract_track_timescale(orig_data, track_info.handler_type, timescale)
|
||||
else:
|
||||
timescale = _extract_track_timescale(
|
||||
orig_data, track_info.handler_type, timescale
|
||||
)
|
||||
|
||||
moov_idx = orig_data.find(b"moov")
|
||||
if moov_idx >= 4:
|
||||
moov_size = struct.unpack(">I", orig_data[moov_idx - 4 : moov_idx])[0]
|
||||
moov_data = orig_data[moov_idx - 4 : moov_idx - 4 + moov_size]
|
||||
|
||||
orig_mvhd = _find_child_box(moov_data, b"mvhd")
|
||||
trak = _find_track_by_handler(moov_data, track_info.handler_type)
|
||||
if trak:
|
||||
orig_tkhd = _find_child_box(trak, b"tkhd")
|
||||
mdia = _find_child_box(trak, b"mdia")
|
||||
if mdia:
|
||||
orig_mdhd = _find_child_box(mdia, b"mdhd")
|
||||
orig_hdlr = _find_child_box(mdia, b"hdlr")
|
||||
minf = _find_child_box(mdia, b"minf")
|
||||
if minf:
|
||||
orig_smhd = _find_child_box(minf, b"smhd")
|
||||
orig_vmhd = _find_child_box(minf, b"vmhd")
|
||||
orig_nmhd = _find_child_box(minf, b"nmhd")
|
||||
orig_dinf = _find_child_box(minf, b"dinf")
|
||||
|
||||
buf = io.BytesIO()
|
||||
total_duration = sum(s.duration for s in track_info.samples)
|
||||
_write_moov(
|
||||
buf,
|
||||
track_info.samples,
|
||||
total_duration,
|
||||
timescale,
|
||||
stsd_content,
|
||||
b"",
|
||||
orig_mvhd=orig_mvhd,
|
||||
orig_tkhd=orig_tkhd,
|
||||
orig_mdhd=orig_mdhd,
|
||||
orig_hdlr=orig_hdlr,
|
||||
orig_smhd=orig_smhd,
|
||||
orig_vmhd=orig_vmhd,
|
||||
orig_nmhd=orig_nmhd,
|
||||
orig_dinf=orig_dinf,
|
||||
handler_type=track_info.handler_type,
|
||||
)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _decrypted_track_payload_source(track: DecryptedTrack):
|
||||
"""Return an mdat source tuple for a decrypted track."""
|
||||
if track.data_path:
|
||||
size = track.data_size or os.path.getsize(track.data_path)
|
||||
return (track.data_path, 0, size)
|
||||
return (None, 0, len(track.data), track.data)
|
||||
|
||||
|
||||
def _sample_payload_bytes(samples: List[SampleInfo]) -> bytes:
|
||||
"""Materialize only the payload bytes for the given samples."""
|
||||
return b"".join(_sample_data(sample) for sample in samples)
|
||||
|
||||
|
||||
def mux_decrypted_media_direct(
|
||||
decrypted_media: DecryptedMedia,
|
||||
output_path: str,
|
||||
m4v_brand: bool = False,
|
||||
) -> None:
|
||||
"""Mux decrypted media directly to the final file without temp MP4 tracks."""
|
||||
if decrypted_media.video is None:
|
||||
raise ValueError("direct AV mux requires a video track")
|
||||
|
||||
video_moov = _build_decrypted_track_moov(decrypted_media.video.track_info)
|
||||
audio_moov = _build_decrypted_track_moov(decrypted_media.audio.track_info)
|
||||
extra_track_files = [
|
||||
(
|
||||
_build_decrypted_track_moov(caption.track_info),
|
||||
_decrypted_track_payload_source(caption),
|
||||
)
|
||||
for caption in decrypted_media.captions
|
||||
]
|
||||
|
||||
mvhd = _find_child_box(video_moov, b"mvhd")
|
||||
video_trak = _find_track_by_handler(video_moov, b"vide")
|
||||
audio_trak = _find_track_by_handler(audio_moov, b"soun")
|
||||
if not mvhd or not video_trak or not audio_trak:
|
||||
raise IOError("mux: missing required audio/video track metadata")
|
||||
|
||||
movie_timescale = _extract_mvhd_timescale(mvhd)
|
||||
audio_trak = _patch_trak_track_id(audio_trak, 2)
|
||||
audio_trak = _patch_trak_duration_to_movie_timescale(audio_trak, movie_timescale)
|
||||
extra_traks = []
|
||||
for index, (extra_moov, extra_source) in enumerate(extra_track_files, start=3):
|
||||
extra_trak = _find_first_trak(extra_moov)
|
||||
if extra_trak:
|
||||
extra_trak = _patch_trak_track_id(extra_trak, index)
|
||||
extra_trak = _patch_trak_duration_to_movie_timescale(
|
||||
extra_trak, movie_timescale
|
||||
)
|
||||
extra_traks.append((extra_trak, extra_source))
|
||||
|
||||
ftyp = _build_ftyp_m4v_bytes() if m4v_brand else _build_ftyp_mp4_bytes()
|
||||
moov = _build_muxed_moov(
|
||||
mvhd, [video_trak, audio_trak] + [t for t, _ in extra_traks]
|
||||
)
|
||||
mdat_data_offset = len(ftyp) + len(moov) + 8
|
||||
|
||||
video_source = _decrypted_track_payload_source(decrypted_media.video)
|
||||
audio_source = _decrypted_track_payload_source(decrypted_media.audio)
|
||||
video_trak = _patch_first_chunk_offset(video_trak, mdat_data_offset)
|
||||
next_mdat_offset = mdat_data_offset + video_source[2]
|
||||
audio_trak = _patch_first_chunk_offset(audio_trak, next_mdat_offset)
|
||||
next_mdat_offset += audio_source[2]
|
||||
patched_extra_traks = []
|
||||
for extra_trak, extra_source in extra_traks:
|
||||
patched_extra_traks.append(
|
||||
_patch_first_chunk_offset(extra_trak, next_mdat_offset)
|
||||
)
|
||||
next_mdat_offset += extra_source[2]
|
||||
|
||||
moov = _build_muxed_moov(mvhd, [video_trak, audio_trak] + patched_extra_traks)
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(ftyp)
|
||||
f.write(moov)
|
||||
_write_mdat_from_sources(
|
||||
f,
|
||||
[video_source, audio_source] + [source for _, source in extra_traks],
|
||||
)
|
||||
|
||||
logger.debug(f"Muxed decrypted AV file to {output_path}")
|
||||
|
||||
|
||||
def mux_decrypted_mp4_tracks(
|
||||
input_path_video: str,
|
||||
input_path_audio: str,
|
||||
output_path: str,
|
||||
input_path_extra_tracks: Optional[List[str]] = None,
|
||||
m4v_brand: bool = False,
|
||||
) -> None:
|
||||
"""Mux one flat video MP4 and one flat audio MP4 into a single MP4/M4V."""
|
||||
with open(input_path_video, "rb") as f:
|
||||
video_data = f.read()
|
||||
with open(input_path_audio, "rb") as f:
|
||||
audio_data = f.read()
|
||||
input_path_extra_tracks = input_path_extra_tracks or []
|
||||
extra_track_files = []
|
||||
for input_path_extra_track in input_path_extra_tracks:
|
||||
with open(input_path_extra_track, "rb") as f:
|
||||
extra_track_data = f.read()
|
||||
extra_track_files.append(
|
||||
(
|
||||
_extract_top_level_box(extra_track_data, b"moov"),
|
||||
_extract_mdat_payload(extra_track_data),
|
||||
)
|
||||
)
|
||||
|
||||
video_moov = _extract_top_level_box(video_data, b"moov")
|
||||
audio_moov = _extract_top_level_box(audio_data, b"moov")
|
||||
video_mdat_payload = _extract_mdat_payload(video_data)
|
||||
audio_mdat_payload = _extract_mdat_payload(audio_data)
|
||||
if not video_moov or not audio_moov:
|
||||
raise IOError("mux: missing moov box in decrypted track file")
|
||||
|
||||
mvhd = _find_child_box(video_moov, b"mvhd")
|
||||
video_trak = _find_track_by_handler(video_moov, b"vide")
|
||||
audio_trak = _find_track_by_handler(audio_moov, b"soun")
|
||||
if not mvhd or not video_trak or not audio_trak:
|
||||
raise IOError("mux: missing required audio/video track metadata")
|
||||
|
||||
movie_timescale = _extract_mvhd_timescale(mvhd)
|
||||
audio_trak = _patch_trak_track_id(audio_trak, 2)
|
||||
audio_trak = _patch_trak_duration_to_movie_timescale(audio_trak, movie_timescale)
|
||||
extra_traks = []
|
||||
for index, (extra_moov, extra_mdat_payload) in enumerate(
|
||||
extra_track_files, start=3
|
||||
):
|
||||
if not extra_moov:
|
||||
continue
|
||||
extra_trak = _find_first_trak(extra_moov)
|
||||
if extra_trak:
|
||||
extra_trak = _patch_trak_track_id(extra_trak, index)
|
||||
extra_trak = _patch_trak_duration_to_movie_timescale(
|
||||
extra_trak, movie_timescale
|
||||
)
|
||||
extra_traks.append((extra_trak, extra_mdat_payload))
|
||||
ftyp = _build_ftyp_m4v_bytes() if m4v_brand else _build_ftyp_mp4_bytes()
|
||||
|
||||
moov = _build_muxed_moov(
|
||||
mvhd, [video_trak, audio_trak] + [t for t, _ in extra_traks]
|
||||
)
|
||||
mdat_data_offset = len(ftyp) + len(moov) + 8
|
||||
video_trak = _patch_first_chunk_offset(video_trak, mdat_data_offset)
|
||||
next_mdat_offset = mdat_data_offset + len(video_mdat_payload)
|
||||
audio_trak = _patch_first_chunk_offset(audio_trak, next_mdat_offset)
|
||||
next_mdat_offset += len(audio_mdat_payload)
|
||||
patched_extra_traks = []
|
||||
for extra_trak, extra_mdat_payload in extra_traks:
|
||||
patched_extra_traks.append(
|
||||
_patch_first_chunk_offset(extra_trak, next_mdat_offset)
|
||||
)
|
||||
next_mdat_offset += len(extra_mdat_payload)
|
||||
moov = _build_muxed_moov(mvhd, [video_trak, audio_trak] + patched_extra_traks)
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(ftyp)
|
||||
f.write(moov)
|
||||
_write_mdat(
|
||||
f,
|
||||
video_mdat_payload
|
||||
+ audio_mdat_payload
|
||||
+ b"".join(payload for _, payload in extra_traks),
|
||||
)
|
||||
|
||||
logger.debug(f"Muxed decrypted AV file to {output_path}")
|
||||
|
||||
|
||||
def _encryption_info_for_hex_decrypt(
|
||||
track_info: SongInfo,
|
||||
*,
|
||||
use_cenc: bool,
|
||||
) -> EncryptionInfo:
|
||||
"""Resolve encryption scheme for hex decrypt (moov metadata or defaults)."""
|
||||
base = track_info.encryption_info or EncryptionInfo(scheme_type="cbcs")
|
||||
if not use_cenc:
|
||||
return base
|
||||
return EncryptionInfo(
|
||||
scheme_type="cenc",
|
||||
crypt_byte_block=0,
|
||||
skip_byte_block=0,
|
||||
per_sample_iv_size=base.per_sample_iv_size,
|
||||
constant_iv=base.constant_iv,
|
||||
kid=base.kid,
|
||||
)
|
||||
|
||||
|
||||
async def _decrypt_track_hex(
|
||||
input_path: str,
|
||||
decryption_key: str,
|
||||
handler_type: bytes,
|
||||
*,
|
||||
use_cenc: bool = False,
|
||||
use_single_content_key: bool = False,
|
||||
file_backed: bool = False,
|
||||
) -> DecryptedTrack:
|
||||
"""Decrypt one audio/video/text track with a raw AES key.
|
||||
|
||||
``use_single_content_key``:
|
||||
``False`` (default for catalog audio): description 0 uses the built-in
|
||||
prefetch AES key, description 1+ uses ``decryption_key`` (Apple CBCS layout).
|
||||
``True`` (web AAC, muxed MV audio): every sample description uses
|
||||
``decryption_key``.
|
||||
"""
|
||||
track_info = await asyncio.to_thread(
|
||||
extract_song, input_path, handler_type, file_backed
|
||||
)
|
||||
track_key = bytes.fromhex(decryption_key)
|
||||
|
||||
if use_single_content_key:
|
||||
keys = {sample.desc_index: track_key for sample in track_info.samples}
|
||||
elif handler_type == b"soun":
|
||||
keys = {0: DEFAULT_SONG_DECRYPTION_KEY, 1: track_key}
|
||||
else:
|
||||
keys = {sample.desc_index: track_key for sample in track_info.samples}
|
||||
|
||||
enc_info = _encryption_info_for_hex_decrypt(track_info, use_cenc=use_cenc)
|
||||
enc_info_per_desc = None
|
||||
if track_info.moov_data:
|
||||
enc_info_per_desc = await asyncio.to_thread(
|
||||
_extract_encryption_info_per_stsd,
|
||||
track_info.moov_data,
|
||||
handler_type,
|
||||
)
|
||||
|
||||
if file_backed:
|
||||
temp_file = tempfile.NamedTemporaryFile(
|
||||
prefix="gamdl_decrypted_", suffix=".bin", delete=False
|
||||
)
|
||||
temp_path = temp_file.name
|
||||
temp_file.close()
|
||||
try:
|
||||
data_size = decrypt_samples_hex_to_file(
|
||||
track_info.samples,
|
||||
keys,
|
||||
enc_info,
|
||||
temp_path,
|
||||
enc_info_per_desc,
|
||||
release_sample_data=True,
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
return DecryptedTrack(
|
||||
input_path,
|
||||
track_info,
|
||||
data_path=temp_path,
|
||||
data_size=data_size,
|
||||
)
|
||||
|
||||
decrypted_data = decrypt_samples_hex(
|
||||
track_info.samples,
|
||||
keys,
|
||||
enc_info,
|
||||
enc_info_per_desc,
|
||||
)
|
||||
return DecryptedTrack(
|
||||
input_path,
|
||||
track_info,
|
||||
decrypted_data,
|
||||
data_size=len(decrypted_data),
|
||||
)
|
||||
|
||||
|
||||
async def decrypt_file_hex(
|
||||
decryption_key_audio: str,
|
||||
input_audio_path: str,
|
||||
decryption_key_video: str | None = None,
|
||||
input_video_path: str | None = None,
|
||||
*,
|
||||
use_cenc: bool = False,
|
||||
use_single_content_key: bool = False,
|
||||
) -> DecryptedMedia:
|
||||
"""Decrypt audio and optional video with raw AES hex keys."""
|
||||
audio = await _decrypt_track_hex(
|
||||
input_audio_path,
|
||||
decryption_key_audio,
|
||||
b"soun",
|
||||
use_cenc=use_cenc,
|
||||
use_single_content_key=use_single_content_key or input_video_path is not None,
|
||||
file_backed=input_video_path is not None,
|
||||
)
|
||||
if input_video_path is None:
|
||||
return DecryptedMedia(audio=audio)
|
||||
|
||||
video_key = decryption_key_video or decryption_key_audio
|
||||
video_task = asyncio.create_task(
|
||||
_decrypt_track_hex(
|
||||
input_video_path,
|
||||
video_key,
|
||||
b"vide",
|
||||
use_cenc=use_cenc,
|
||||
file_backed=True,
|
||||
)
|
||||
)
|
||||
caption_tracks = [
|
||||
track
|
||||
for track in await asyncio.gather(
|
||||
asyncio.to_thread(extract_song, input_video_path, b"clcp", True),
|
||||
asyncio.to_thread(extract_song, input_video_path, b"text", True),
|
||||
asyncio.to_thread(extract_song, input_video_path, b"sbtl", True),
|
||||
asyncio.to_thread(extract_song, input_video_path, b"subt", True),
|
||||
)
|
||||
if track.samples
|
||||
]
|
||||
captions = []
|
||||
for caption_track in caption_tracks:
|
||||
caption_data = _sample_payload_bytes(caption_track.samples)
|
||||
if caption_track.encryption_info:
|
||||
caption_key = bytes.fromhex(video_key)
|
||||
caption_enc_info_per_desc = await asyncio.to_thread(
|
||||
_extract_encryption_info_per_stsd,
|
||||
caption_track.moov_data,
|
||||
caption_track.handler_type,
|
||||
)
|
||||
caption_data = decrypt_samples_hex(
|
||||
caption_track.samples,
|
||||
{sample.desc_index: caption_key for sample in caption_track.samples},
|
||||
caption_track.encryption_info,
|
||||
caption_enc_info_per_desc,
|
||||
)
|
||||
captions.append(DecryptedTrack(input_video_path, caption_track, caption_data))
|
||||
|
||||
return DecryptedMedia(
|
||||
audio=audio,
|
||||
video=await video_task,
|
||||
captions=captions,
|
||||
)
|
||||
|
||||
|
||||
async def write_decrypted_media(
|
||||
decrypted_media: DecryptedMedia,
|
||||
output_path: str,
|
||||
m4v_brand: bool = False,
|
||||
) -> None:
|
||||
"""Write decrypted audio as M4A, or mux decrypted audio/video/text tracks."""
|
||||
if decrypted_media.video is None:
|
||||
await asyncio.to_thread(
|
||||
write_decrypted_m4a,
|
||||
output_path,
|
||||
decrypted_media.audio.track_info,
|
||||
decrypted_media.audio.data,
|
||||
decrypted_media.audio.input_path,
|
||||
decrypted_media.audio.data_path,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
mux_decrypted_media_direct,
|
||||
decrypted_media,
|
||||
output_path,
|
||||
m4v_brand,
|
||||
)
|
||||
finally:
|
||||
for track in (
|
||||
decrypted_media.audio,
|
||||
decrypted_media.video,
|
||||
*decrypted_media.captions,
|
||||
):
|
||||
if track and track.data_path:
|
||||
try:
|
||||
os.remove(track.data_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
track.data_path = None
|
||||
|
||||
|
||||
async def decrypt_av_files_hex(
|
||||
input_path_video: str,
|
||||
input_path_audio: str,
|
||||
output_path: str,
|
||||
decryption_key_video: str,
|
||||
decryption_key_audio: str,
|
||||
m4v_brand: bool = False,
|
||||
) -> None:
|
||||
"""Decrypt separate encrypted video/audio MP4s and mux them in Python."""
|
||||
decrypted_media = await decrypt_file_hex(
|
||||
decryption_key_audio,
|
||||
input_path_audio,
|
||||
decryption_key_video,
|
||||
input_path_video,
|
||||
)
|
||||
await write_decrypted_media(decrypted_media, output_path, m4v_brand)
|
||||
|
||||
|
||||
def _preferred_sample_description_index(samples: List[SampleInfo]) -> int:
|
||||
"""Return the 0-based sample description index to keep in flattened output."""
|
||||
counts = Counter(sample.desc_index for sample in samples if _sample_size(sample))
|
||||
if not counts:
|
||||
return 0
|
||||
return counts.most_common(1)[0][0]
|
||||
|
||||
|
||||
def _write_box(f, box_type: bytes, content: bytes):
|
||||
"""Write a simple MP4 box."""
|
||||
size = len(content) + 8
|
||||
f.write(struct.pack(">I", size))
|
||||
f.write(box_type)
|
||||
f.write(content)
|
||||
|
||||
|
||||
def _write_ftyp(f):
|
||||
"""Write ftyp box for M4A."""
|
||||
content = b"M4A " + struct.pack(">I", 0) # major brand + minor version
|
||||
content += b"M4A mp42isom\x00\x00\x00\x00" # compatible brands
|
||||
_write_box(f, b"ftyp", content)
|
||||
|
||||
|
||||
def _write_ftyp_mp4(f):
|
||||
"""Write ftyp box for MP4/M4V video."""
|
||||
content = b"mp42" + struct.pack(">I", 0)
|
||||
content += b"mp42isomiso6avc1hvc1"
|
||||
_write_box(f, b"ftyp", content)
|
||||
|
||||
|
||||
def _write_ftyp_m4v(f):
|
||||
"""Write an iTunes-like ftyp box for M4V outputs."""
|
||||
content = b"M4V " + struct.pack(">I", 0)
|
||||
content += b"M4V mp42isom"
|
||||
_write_box(f, b"ftyp", content)
|
||||
|
||||
|
||||
def _build_ftyp_mp4_bytes() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
_write_ftyp_mp4(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _build_ftyp_m4v_bytes() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
_write_ftyp_m4v(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _write_fullbox(f, box_type: bytes, version: int, flags: int, content: bytes):
|
||||
"""Write a FullBox (with version and flags)."""
|
||||
size = len(content) + 12
|
||||
f.write(struct.pack(">I", size))
|
||||
f.write(box_type)
|
||||
f.write(struct.pack("B", version))
|
||||
f.write(struct.pack(">I", flags)[1:]) # 3 bytes for flags
|
||||
f.write(content)
|
||||
|
||||
|
||||
def _write_moov(
|
||||
f,
|
||||
samples: List[SampleInfo],
|
||||
total_duration: int,
|
||||
timescale: int,
|
||||
stsd_content: bytes,
|
||||
decrypted_data: bytes,
|
||||
orig_mvhd: Optional[bytes] = None,
|
||||
orig_tkhd: Optional[bytes] = None,
|
||||
orig_mdhd: Optional[bytes] = None,
|
||||
orig_hdlr: Optional[bytes] = None,
|
||||
orig_smhd: Optional[bytes] = None,
|
||||
orig_vmhd: Optional[bytes] = None,
|
||||
orig_nmhd: Optional[bytes] = None,
|
||||
orig_dinf: Optional[bytes] = None,
|
||||
handler_type: bytes = b"soun",
|
||||
):
|
||||
"""Write moov box with sample tables.
|
||||
|
||||
When original box data is available, it is copied verbatim (with duration
|
||||
fields patched) to faithfully reproduce the source file's metadata.
|
||||
This matches the Go amdecrypt behavior of copying boxes from the original.
|
||||
"""
|
||||
moov_start = f.tell()
|
||||
f.write(b"\x00" * 8) # moov header placeholder
|
||||
|
||||
# mvhd (movie header)
|
||||
if orig_mvhd:
|
||||
f.write(_patch_mvhd_duration(orig_mvhd, total_duration, timescale))
|
||||
else:
|
||||
mvhd_content = struct.pack(">II", 0, 0) # creation, modification
|
||||
mvhd_content += struct.pack(">I", timescale)
|
||||
mvhd_content += struct.pack(">I", total_duration)
|
||||
mvhd_content += struct.pack(">I", 0x00010000) # rate (1.0)
|
||||
mvhd_content += struct.pack(">H", 0x0100) # volume (1.0)
|
||||
mvhd_content += b"\x00" * 10 # reserved
|
||||
mvhd_content += struct.pack(
|
||||
">9I", 0x00010000, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000
|
||||
) # matrix
|
||||
mvhd_content += b"\x00" * 24 # pre_defined
|
||||
mvhd_content += struct.pack(">I", 2) # next_track_id
|
||||
_write_fullbox(f, b"mvhd", 0, 0, mvhd_content)
|
||||
|
||||
# trak (track)
|
||||
trak_start = f.tell()
|
||||
f.write(b"\x00" * 8) # trak header placeholder
|
||||
|
||||
# tkhd (track header)
|
||||
if orig_tkhd:
|
||||
f.write(_patch_tkhd_duration(orig_tkhd, total_duration))
|
||||
else:
|
||||
tkhd_content = struct.pack(">II", 0, 0) # creation, modification
|
||||
tkhd_content += struct.pack(">I", 1) # track_id
|
||||
tkhd_content += struct.pack(">I", 0) # reserved
|
||||
tkhd_content += struct.pack(">I", total_duration)
|
||||
tkhd_content += b"\x00" * 8 # reserved
|
||||
tkhd_content += struct.pack(">HH", 0, 0) # layer, alternate_group
|
||||
tkhd_content += struct.pack(">H", 0x0100) # volume
|
||||
tkhd_content += struct.pack(">H", 0) # reserved
|
||||
tkhd_content += struct.pack(
|
||||
">9I", 0x00010000, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000
|
||||
) # matrix
|
||||
tkhd_content += struct.pack(">II", 0, 0) # width, height
|
||||
_write_fullbox(f, b"tkhd", 0, 7, tkhd_content)
|
||||
|
||||
# mdia (media)
|
||||
mdia_start = f.tell()
|
||||
f.write(b"\x00" * 8)
|
||||
|
||||
# mdhd (media header) - preserves original language code
|
||||
if orig_mdhd:
|
||||
f.write(_patch_mdhd_duration(orig_mdhd, total_duration, timescale))
|
||||
else:
|
||||
mdhd_content = struct.pack(">II", 0, 0) # creation, modification
|
||||
mdhd_content += struct.pack(">I", timescale)
|
||||
mdhd_content += struct.pack(">I", total_duration)
|
||||
mdhd_content += struct.pack(">H", 0x55C4) # language (und)
|
||||
mdhd_content += struct.pack(">H", 0) # quality
|
||||
_write_fullbox(f, b"mdhd", 0, 0, mdhd_content)
|
||||
|
||||
# hdlr (handler) - preserves original handler name (e.g. "Core Media Audio")
|
||||
if orig_hdlr:
|
||||
f.write(orig_hdlr)
|
||||
else:
|
||||
hdlr_content = struct.pack(">I", 0) # pre_defined
|
||||
hdlr_content += b"soun" # handler_type
|
||||
hdlr_content += b"\x00" * 12 # reserved
|
||||
handler_name = b"SoundHandler"
|
||||
hdlr_content += struct.pack("B", len(handler_name)) + handler_name + b"\x00"
|
||||
_write_fullbox(f, b"hdlr", 0, 0, hdlr_content)
|
||||
|
||||
# minf (media info)
|
||||
minf_start = f.tell()
|
||||
f.write(b"\x00" * 8)
|
||||
|
||||
# media header
|
||||
if handler_type == b"vide" and orig_vmhd:
|
||||
f.write(orig_vmhd)
|
||||
elif handler_type == b"soun" and orig_smhd:
|
||||
f.write(orig_smhd)
|
||||
elif orig_nmhd:
|
||||
f.write(orig_nmhd)
|
||||
elif handler_type == b"vide":
|
||||
vmhd_content = struct.pack(">HHHH", 0, 0, 0, 0)
|
||||
_write_fullbox(f, b"vmhd", 0, 1, vmhd_content)
|
||||
elif handler_type not in (b"soun", b"vide"):
|
||||
_write_fullbox(f, b"nmhd", 0, 0, b"")
|
||||
else:
|
||||
smhd_content = struct.pack(">HH", 0, 0) # balance, reserved
|
||||
_write_fullbox(f, b"smhd", 0, 0, smhd_content)
|
||||
|
||||
# dinf + dref
|
||||
if orig_dinf:
|
||||
f.write(orig_dinf)
|
||||
else:
|
||||
dinf_start = f.tell()
|
||||
f.write(b"\x00" * 8)
|
||||
dref_content = struct.pack(">I", 1) # entry_count
|
||||
dref_content += (
|
||||
struct.pack(">I", 12) + b"url " + struct.pack(">I", 1)
|
||||
) # url entry (self-contained)
|
||||
_write_fullbox(f, b"dref", 0, 0, dref_content)
|
||||
_fixup_box_size(f, dinf_start, b"dinf")
|
||||
|
||||
# stbl (sample table)
|
||||
stbl_start = f.tell()
|
||||
f.write(b"\x00" * 8)
|
||||
|
||||
# stsd (sample description) - use content from original file
|
||||
_write_stsd(f, stsd_content)
|
||||
|
||||
# stts (time-to-sample)
|
||||
_write_stts(f, samples)
|
||||
|
||||
# ctts (composition time-to-sample) preserves B-frame presentation timing.
|
||||
_write_ctts(f, samples)
|
||||
|
||||
# stss (sync samples) helps players seek/decode video keyframes correctly.
|
||||
if handler_type == b"vide":
|
||||
_write_stss(f, samples)
|
||||
|
||||
# stsc (sample-to-chunk) - all samples in one chunk
|
||||
stsc_content = struct.pack(">I", 1) # entry_count
|
||||
stsc_content += struct.pack(
|
||||
">III", 1, len(samples), 1
|
||||
) # first_chunk, samples_per_chunk, sample_description_index
|
||||
_write_fullbox(f, b"stsc", 0, 0, stsc_content)
|
||||
|
||||
# stsz (sample size)
|
||||
stsz_content = struct.pack(">I", 0) # sample_size (0 = variable)
|
||||
stsz_content += struct.pack(">I", len(samples)) # sample_count
|
||||
for sample in samples:
|
||||
stsz_content += struct.pack(">I", _sample_size(sample))
|
||||
_write_fullbox(f, b"stsz", 0, 0, stsz_content)
|
||||
|
||||
# stco (chunk offset) - will be fixed up later
|
||||
stco_pos = f.tell()
|
||||
stco_content = struct.pack(">I", 1) # entry_count
|
||||
stco_content += struct.pack(">I", 0) # chunk_offset (placeholder)
|
||||
_write_fullbox(f, b"stco", 0, 0, stco_content)
|
||||
|
||||
_fixup_box_size(f, stbl_start, b"stbl")
|
||||
_fixup_box_size(f, minf_start, b"minf")
|
||||
_fixup_box_size(f, mdia_start, b"mdia")
|
||||
_fixup_box_size(f, trak_start, b"trak")
|
||||
|
||||
# udta > meta > hdlr(mdir) + ilst - metadata container
|
||||
_write_udta(f)
|
||||
|
||||
_fixup_box_size(f, moov_start, b"moov")
|
||||
|
||||
# Fix up stco with correct mdat offset
|
||||
mdat_offset = f.tell() + 8 # +8 for mdat header
|
||||
f.seek(stco_pos + 16) # +12 for box header + version/flags, +4 for entry_count
|
||||
f.write(struct.pack(">I", mdat_offset))
|
||||
f.seek(0, 2) # Back to end
|
||||
|
||||
|
||||
def _extract_sample_rate_from_stsd(stsd_content: bytes) -> Optional[int]:
|
||||
"""Extract the actual audio sample rate from the stsd box content."""
|
||||
# Header: version(1)+flags(3)+count(4) + Entry: size(4)+type(4) = 16 bytes
|
||||
# AudioSampleEntry v0: reserved(6)+dref(2)+ver(2)+rev(2)+vend(4)+chan(2)+size(2)+comp(2)+pack(2)+rate(4)
|
||||
# The fixed-point sample_rate field is at offset 16 + 24 = 40.
|
||||
if not stsd_content or len(stsd_content) < 44:
|
||||
return None
|
||||
|
||||
samplerate_offset = 40
|
||||
sample_rate_fixed = struct.unpack(
|
||||
">I", stsd_content[samplerate_offset : samplerate_offset + 4]
|
||||
)[0]
|
||||
sample_rate = sample_rate_fixed >> 16
|
||||
|
||||
# Sanity check: standard audio sample rates should be between 8000 and 384000
|
||||
if 8000 <= sample_rate <= 384000:
|
||||
return sample_rate
|
||||
return None
|
||||
|
||||
|
||||
def _write_stsd(f, stsd_content: bytes):
|
||||
"""Write sample description box using content from original file.
|
||||
This preserves the original codec info (ALAC, EC-3, AAC, etc.).
|
||||
"""
|
||||
if stsd_content:
|
||||
# Write the full stsd box with its content from the source file
|
||||
size = len(stsd_content) + 8
|
||||
f.write(struct.pack(">I", size))
|
||||
f.write(b"stsd")
|
||||
f.write(stsd_content)
|
||||
else:
|
||||
# Fallback: write a basic ALAC stsd if no source content available
|
||||
_write_stsd_alac_fallback(f)
|
||||
|
||||
|
||||
def _write_stsd_alac_fallback(f):
|
||||
"""Write a default ALAC sample description box (fallback)."""
|
||||
stsd_start = f.tell()
|
||||
f.write(b"\x00" * 12) # box header + version/flags placeholder
|
||||
|
||||
f.write(struct.pack(">I", 1)) # entry_count
|
||||
|
||||
# alac sample entry
|
||||
alac_start = f.tell()
|
||||
f.write(b"\x00" * 8) # alac box header placeholder
|
||||
|
||||
f.write(b"\x00" * 6) # reserved
|
||||
f.write(struct.pack(">H", 1)) # data_reference_index
|
||||
f.write(b"\x00" * 8) # reserved
|
||||
f.write(struct.pack(">H", 2)) # channel_count
|
||||
f.write(struct.pack(">H", 16)) # sample_size (bits)
|
||||
f.write(struct.pack(">H", 0)) # pre_defined
|
||||
f.write(struct.pack(">H", 0)) # reserved
|
||||
f.write(struct.pack(">I", 44100 << 16)) # sample_rate (16.16 fixed point)
|
||||
|
||||
# alac magic cookie box
|
||||
# Default ALAC config for 44.1kHz stereo 24-bit
|
||||
default_config = bytes(
|
||||
[
|
||||
0x00,
|
||||
0x00,
|
||||
0x10,
|
||||
0x00, # frame_length
|
||||
0x00, # compatible_version
|
||||
0x18, # bit_depth (24)
|
||||
0x28,
|
||||
0x28,
|
||||
0x0A, # pb, mb, kb
|
||||
0x02, # num_channels
|
||||
0x00,
|
||||
0x00, # max_run
|
||||
0x00,
|
||||
0x00,
|
||||
0xFF,
|
||||
0xFF, # max_frame_bytes
|
||||
0x00,
|
||||
0x0D,
|
||||
0x00,
|
||||
0x80, # avg_bit_rate
|
||||
0x00,
|
||||
0x00,
|
||||
0xAC,
|
||||
0x44, # sample_rate
|
||||
]
|
||||
)
|
||||
_write_box(f, b"alac", default_config)
|
||||
|
||||
_fixup_box_size(f, alac_start, b"alac")
|
||||
|
||||
# Fix stsd size
|
||||
end_pos = f.tell()
|
||||
size = end_pos - stsd_start
|
||||
f.seek(stsd_start)
|
||||
f.write(struct.pack(">I", size))
|
||||
f.write(b"stsd")
|
||||
f.write(struct.pack(">I", 0)) # version + flags
|
||||
f.seek(end_pos)
|
||||
|
||||
|
||||
def _write_stts(f, samples: List[SampleInfo]):
|
||||
"""Write time-to-sample box (run-length encoded)."""
|
||||
# Run-length encode durations
|
||||
entries = []
|
||||
for sample in samples:
|
||||
if entries and entries[-1][1] == sample.duration:
|
||||
entries[-1] = (entries[-1][0] + 1, sample.duration)
|
||||
else:
|
||||
entries.append((1, sample.duration))
|
||||
|
||||
content = struct.pack(">I", len(entries))
|
||||
for count, delta in entries:
|
||||
content += struct.pack(">II", count, delta)
|
||||
_write_fullbox(f, b"stts", 0, 0, content)
|
||||
|
||||
|
||||
def _write_ctts(f, samples: List[SampleInfo]):
|
||||
"""Write composition time-to-sample box when samples have composition offsets."""
|
||||
if not any(sample.composition_time_offset for sample in samples):
|
||||
return
|
||||
|
||||
entries = []
|
||||
for sample in samples:
|
||||
offset = sample.composition_time_offset
|
||||
if entries and entries[-1][1] == offset:
|
||||
entries[-1] = (entries[-1][0] + 1, offset)
|
||||
else:
|
||||
entries.append((1, offset))
|
||||
|
||||
version = 1 if any(offset < 0 for _, offset in entries) else 0
|
||||
content = struct.pack(">I", len(entries))
|
||||
for count, offset in entries:
|
||||
if version == 1:
|
||||
content += struct.pack(">Ii", count, offset)
|
||||
else:
|
||||
content += struct.pack(">II", count, offset)
|
||||
_write_fullbox(f, b"ctts", version, 0, content)
|
||||
|
||||
|
||||
def _write_stss(f, samples: List[SampleInfo]):
|
||||
"""Write sync sample box when video sample flags identify non-sync samples."""
|
||||
if not samples or all(sample.is_sync for sample in samples):
|
||||
return
|
||||
|
||||
sync_sample_numbers = [
|
||||
index for index, sample in enumerate(samples, start=1) if sample.is_sync
|
||||
]
|
||||
if not sync_sample_numbers:
|
||||
return
|
||||
|
||||
content = struct.pack(">I", len(sync_sample_numbers))
|
||||
for sample_number in sync_sample_numbers:
|
||||
content += struct.pack(">I", sample_number)
|
||||
_write_fullbox(f, b"stss", 0, 0, content)
|
||||
|
||||
|
||||
def _fixup_box_size(f, start_pos: int, box_type: bytes):
|
||||
"""Fix up the size field of a box that was written with placeholder."""
|
||||
end_pos = f.tell()
|
||||
size = end_pos - start_pos
|
||||
f.seek(start_pos)
|
||||
f.write(struct.pack(">I", size))
|
||||
f.write(box_type)
|
||||
f.seek(end_pos)
|
||||
|
||||
|
||||
def _write_mdat(f, data: bytes):
|
||||
"""Write mdat box with decrypted data."""
|
||||
size = len(data) + 8
|
||||
f.write(struct.pack(">I", size))
|
||||
f.write(b"mdat")
|
||||
f.write(data)
|
||||
|
||||
|
||||
def _copy_file_range(
|
||||
output_file: BinaryIO,
|
||||
input_path: str,
|
||||
offset: int,
|
||||
size: int,
|
||||
chunk_size: int = 1024 * 1024,
|
||||
) -> None:
|
||||
"""Copy a byte range without loading it all into memory."""
|
||||
remaining = size
|
||||
with open(input_path, "rb") as input_file:
|
||||
input_file.seek(offset)
|
||||
while remaining > 0:
|
||||
chunk = input_file.read(min(chunk_size, remaining))
|
||||
if not chunk:
|
||||
raise IOError(f"unexpected EOF while reading {input_path}")
|
||||
output_file.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
|
||||
|
||||
def _write_mdat_from_sources(f, sources: List[tuple]) -> None:
|
||||
"""Write mdat by streaming file-backed sources and small in-memory sources."""
|
||||
payload_size = sum(source[2] for source in sources)
|
||||
if payload_size + 8 > 0xFFFFFFFF:
|
||||
raise IOError("mux: mdat too large for 32-bit box size")
|
||||
f.write(struct.pack(">I", payload_size + 8))
|
||||
f.write(b"mdat")
|
||||
for source in sources:
|
||||
input_path, offset, size = source[:3]
|
||||
if input_path is None:
|
||||
data = source[3]
|
||||
if len(data) != size:
|
||||
raise IOError("mux: in-memory mdat source size mismatch")
|
||||
f.write(data)
|
||||
else:
|
||||
_copy_file_range(f, input_path, offset, size)
|
||||
|
||||
|
||||
def _extract_top_level_box(data: bytes, box_type: bytes) -> Optional[bytes]:
|
||||
offset = 0
|
||||
while offset + 8 <= len(data):
|
||||
size = struct.unpack(">I", data[offset : offset + 4])[0]
|
||||
current_type = data[offset + 4 : offset + 8]
|
||||
header_size = 8
|
||||
if size == 1:
|
||||
if offset + 16 > len(data):
|
||||
return None
|
||||
size = struct.unpack(">Q", data[offset + 8 : offset + 16])[0]
|
||||
header_size = 16
|
||||
elif size == 0:
|
||||
size = len(data) - offset
|
||||
if size < header_size or offset + size > len(data):
|
||||
return None
|
||||
if current_type == box_type:
|
||||
return data[offset : offset + size]
|
||||
offset += size
|
||||
return None
|
||||
|
||||
|
||||
def _extract_mdat_payload(data: bytes) -> bytes:
|
||||
mdat = _extract_top_level_box(data, b"mdat")
|
||||
if not mdat:
|
||||
raise IOError("mux: missing mdat box in decrypted track file")
|
||||
size = struct.unpack(">I", mdat[:4])[0]
|
||||
header_size = 16 if size == 1 else 8
|
||||
return mdat[header_size:]
|
||||
|
||||
|
||||
def _build_muxed_moov(mvhd: bytes, traks: List[bytes]) -> bytes:
|
||||
payload = bytearray()
|
||||
payload.extend(_patch_mvhd_next_track_id(mvhd, len(traks) + 1))
|
||||
for trak in traks:
|
||||
payload.extend(trak)
|
||||
udta_buf = io.BytesIO()
|
||||
_write_udta(udta_buf)
|
||||
payload.extend(udta_buf.getvalue())
|
||||
return struct.pack(">I", len(payload) + 8) + b"moov" + bytes(payload)
|
||||
|
||||
|
||||
def _patch_mvhd_next_track_id(mvhd_data: bytes, next_track_id: int) -> bytes:
|
||||
"""Return a copy of mvhd with next_track_id set past the muxed tracks."""
|
||||
data = bytearray(mvhd_data)
|
||||
if len(data) < 112:
|
||||
return bytes(data)
|
||||
version = data[8]
|
||||
next_track_id_offset = 108 if version == 0 else 120
|
||||
if next_track_id_offset + 4 <= len(data):
|
||||
struct.pack_into(">I", data, next_track_id_offset, next_track_id)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def _extract_mvhd_timescale(mvhd_data: bytes) -> int:
|
||||
"""Extract movie timescale from an mvhd box."""
|
||||
if len(mvhd_data) < 32:
|
||||
return 1000
|
||||
version = mvhd_data[8]
|
||||
if version == 0 and len(mvhd_data) >= 24:
|
||||
return struct.unpack(">I", mvhd_data[20:24])[0]
|
||||
if version == 1 and len(mvhd_data) >= 32:
|
||||
return struct.unpack(">I", mvhd_data[28:32])[0]
|
||||
return 1000
|
||||
|
||||
|
||||
def _extract_mdhd_duration_timescale(trak_data: bytes) -> tuple[int, int]:
|
||||
"""Extract media duration and timescale from a trak's mdhd box."""
|
||||
mdhd_offset = _find_box_offset_recursive(trak_data, b"mdhd")
|
||||
if mdhd_offset < 0 or mdhd_offset + 32 > len(trak_data):
|
||||
return 0, 1
|
||||
version = trak_data[mdhd_offset + 8]
|
||||
if version == 0:
|
||||
timescale = struct.unpack(">I", trak_data[mdhd_offset + 20 : mdhd_offset + 24])[
|
||||
0
|
||||
]
|
||||
duration = struct.unpack(">I", trak_data[mdhd_offset + 24 : mdhd_offset + 28])[
|
||||
0
|
||||
]
|
||||
else:
|
||||
timescale = struct.unpack(">I", trak_data[mdhd_offset + 28 : mdhd_offset + 32])[
|
||||
0
|
||||
]
|
||||
duration = struct.unpack(">Q", trak_data[mdhd_offset + 32 : mdhd_offset + 40])[
|
||||
0
|
||||
]
|
||||
return duration, timescale or 1
|
||||
|
||||
|
||||
def _patch_trak_duration_to_movie_timescale(
|
||||
trak_data: bytes, movie_timescale: int
|
||||
) -> bytes:
|
||||
"""Patch tkhd duration to movie timescale while preserving mdhd duration."""
|
||||
media_duration, media_timescale = _extract_mdhd_duration_timescale(trak_data)
|
||||
if media_duration <= 0:
|
||||
return trak_data
|
||||
movie_duration = round(media_duration * movie_timescale / media_timescale)
|
||||
return _patch_trak_tkhd_duration(trak_data, movie_duration)
|
||||
|
||||
|
||||
def _patch_trak_tkhd_duration(trak_data: bytes, duration: int) -> bytes:
|
||||
"""Patch the nested tkhd duration inside a full trak box."""
|
||||
data = bytearray(trak_data)
|
||||
tkhd_offset = _find_box_offset_recursive(data, b"tkhd")
|
||||
if tkhd_offset < 0:
|
||||
return bytes(data)
|
||||
version = data[tkhd_offset + 8]
|
||||
data[tkhd_offset + 9 : tkhd_offset + 12] = struct.pack(">I", 7)[1:]
|
||||
if version == 0:
|
||||
duration_offset = tkhd_offset + 28
|
||||
if duration_offset + 4 <= len(data):
|
||||
struct.pack_into(">I", data, duration_offset, duration)
|
||||
else:
|
||||
duration_offset = tkhd_offset + 36
|
||||
if duration_offset + 8 <= len(data):
|
||||
struct.pack_into(">Q", data, duration_offset, duration)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def _find_first_trak(moov_data: bytes) -> Optional[bytes]:
|
||||
offset = 8
|
||||
while offset + 8 <= len(moov_data):
|
||||
size = struct.unpack(">I", moov_data[offset : offset + 4])[0]
|
||||
box_type = moov_data[offset + 4 : offset + 8]
|
||||
if size < 8 or offset + size > len(moov_data):
|
||||
break
|
||||
if box_type == b"trak":
|
||||
return moov_data[offset : offset + size]
|
||||
offset += size
|
||||
return None
|
||||
|
||||
|
||||
def _find_box_offset_recursive(
|
||||
data: bytes,
|
||||
target_type: bytes,
|
||||
start: int = 0,
|
||||
end: Optional[int] = None,
|
||||
) -> int:
|
||||
"""Find a box by walking MP4 box boundaries instead of byte substrings."""
|
||||
end = len(data) if end is None else min(end, len(data))
|
||||
offset = start
|
||||
container_types = {
|
||||
b"moov",
|
||||
b"trak",
|
||||
b"mdia",
|
||||
b"minf",
|
||||
b"stbl",
|
||||
b"dinf",
|
||||
b"edts",
|
||||
b"udta",
|
||||
b"meta",
|
||||
}
|
||||
|
||||
while offset + 8 <= end:
|
||||
size = struct.unpack(">I", data[offset : offset + 4])[0]
|
||||
box_type = bytes(data[offset + 4 : offset + 8])
|
||||
header_size = 8
|
||||
if size == 1:
|
||||
if offset + 16 > end:
|
||||
break
|
||||
size = struct.unpack(">Q", data[offset + 8 : offset + 16])[0]
|
||||
header_size = 16
|
||||
elif size == 0:
|
||||
size = end - offset
|
||||
|
||||
if size < header_size or offset + size > end:
|
||||
break
|
||||
if box_type == target_type:
|
||||
return offset
|
||||
if box_type in container_types:
|
||||
child_start = offset + header_size
|
||||
if box_type == b"meta":
|
||||
child_start += 4
|
||||
found = _find_box_offset_recursive(
|
||||
data, target_type, child_start, offset + size
|
||||
)
|
||||
if found >= 0:
|
||||
return found
|
||||
offset += size
|
||||
|
||||
return -1
|
||||
|
||||
|
||||
def _patch_first_chunk_offset(trak_data: bytes, chunk_offset: int) -> bytes:
|
||||
data = bytearray(trak_data)
|
||||
stco_offset = _find_box_offset_recursive(data, b"stco")
|
||||
co64_offset = _find_box_offset_recursive(data, b"co64")
|
||||
if stco_offset >= 0:
|
||||
entry_count_offset = stco_offset + 12
|
||||
first_entry_offset = stco_offset + 16
|
||||
if first_entry_offset + 4 <= len(data):
|
||||
entry_count = struct.unpack(
|
||||
">I", data[entry_count_offset:first_entry_offset]
|
||||
)[0]
|
||||
if entry_count > 0:
|
||||
struct.pack_into(">I", data, first_entry_offset, chunk_offset)
|
||||
return bytes(data)
|
||||
if co64_offset >= 0:
|
||||
entry_count_offset = co64_offset + 12
|
||||
first_entry_offset = co64_offset + 16
|
||||
if first_entry_offset + 8 <= len(data):
|
||||
entry_count = struct.unpack(
|
||||
">I", data[entry_count_offset:first_entry_offset]
|
||||
)[0]
|
||||
if entry_count > 0:
|
||||
struct.pack_into(">Q", data, first_entry_offset, chunk_offset)
|
||||
return bytes(data)
|
||||
raise IOError("mux: unable to patch chunk offset")
|
||||
|
||||
|
||||
def _patch_trak_track_id(trak_data: bytes, track_id: int) -> bytes:
|
||||
data = bytearray(trak_data)
|
||||
tkhd_offset = _find_box_offset_recursive(data, b"tkhd")
|
||||
if tkhd_offset < 0:
|
||||
return bytes(data)
|
||||
version = data[tkhd_offset + 8]
|
||||
track_id_offset = tkhd_offset + 20 if version == 0 else tkhd_offset + 28
|
||||
if track_id_offset + 4 <= len(data):
|
||||
struct.pack_into(">I", data, track_id_offset, track_id)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def _extract_stsd_content(
|
||||
data: bytes,
|
||||
preferred_desc_index: Optional[int] = None,
|
||||
handler_type: bytes = b"soun",
|
||||
) -> Optional[bytes]:
|
||||
"""Extract cleaned stsd box content from moov box (supports any codec)."""
|
||||
moov_idx = data.find(b"moov")
|
||||
if moov_idx < 4:
|
||||
return None
|
||||
|
||||
moov_size = struct.unpack(">I", data[moov_idx - 4 : moov_idx])[0]
|
||||
if moov_size < 8 or moov_idx - 4 + moov_size > len(data):
|
||||
return None
|
||||
moov_data = data[moov_idx - 4 : moov_idx - 4 + moov_size]
|
||||
trak_data = _find_track_by_handler(moov_data, handler_type)
|
||||
if trak_data is None:
|
||||
return None
|
||||
|
||||
mdia = _find_child_box(trak_data, b"mdia")
|
||||
minf = _find_child_box(mdia, b"minf") if mdia else None
|
||||
stbl = _find_child_box(minf, b"stbl") if minf else None
|
||||
stsd = _find_child_box(stbl, b"stsd") if stbl else None
|
||||
if stsd is None or len(stsd) < 16:
|
||||
return None
|
||||
|
||||
# Return stsd content (after box header = size + type)
|
||||
raw_content = stsd[8:]
|
||||
|
||||
# Clean the stsd content to remove encryption metadata
|
||||
return _clean_stsd_content(raw_content, preferred_desc_index)
|
||||
|
||||
|
||||
def _clean_stsd_content(
|
||||
stsd_content: bytes, preferred_desc_index: Optional[int] = None
|
||||
) -> bytes:
|
||||
"""
|
||||
Clean stsd content by removing encryption metadata.
|
||||
|
||||
This replaces the mp4decrypt cleanup step. For encrypted files, the stsd
|
||||
contains encrypted sample entries (enca, encv) with sinf boxes that describe
|
||||
the encryption scheme. We need to:
|
||||
1. Convert encrypted entries (enca -> original format from frma)
|
||||
2. Remove sinf boxes entirely
|
||||
"""
|
||||
if len(stsd_content) < 8:
|
||||
return stsd_content
|
||||
|
||||
# stsd content: version(1) + flags(3) + entry_count(4) + entries...
|
||||
version_flags = stsd_content[:4]
|
||||
entry_count = struct.unpack(">I", stsd_content[4:8])[0]
|
||||
|
||||
# Parse and clean each sample entry.
|
||||
cleaned_entries = []
|
||||
offset = 8
|
||||
|
||||
for _ in range(entry_count):
|
||||
if offset + 8 > len(stsd_content):
|
||||
break
|
||||
|
||||
entry_size = struct.unpack(">I", stsd_content[offset : offset + 4])[0]
|
||||
entry_type = stsd_content[offset + 4 : offset + 8]
|
||||
|
||||
if entry_size < 8 or offset + entry_size > len(stsd_content):
|
||||
break
|
||||
|
||||
entry_data = stsd_content[offset : offset + entry_size]
|
||||
|
||||
# Check if this is an encrypted entry
|
||||
if entry_type in (b"enca", b"encv", b"encs", b"encm"):
|
||||
# Clean the encrypted entry
|
||||
cleaned_entry = _clean_encrypted_sample_entry(entry_data)
|
||||
cleaned_entries.append(cleaned_entry)
|
||||
else:
|
||||
# Keep as-is but still remove any sinf boxes that might be present
|
||||
cleaned_entry = _remove_sinf_from_entry(entry_data)
|
||||
cleaned_entries.append(cleaned_entry)
|
||||
|
||||
offset += entry_size
|
||||
|
||||
# The writer emits one chunk and one stsc entry with sample_description_index=1.
|
||||
# Keep only the dominant source description so that the flattened MP4's sample
|
||||
# table and stsd agree. iTunes is stricter about this than many players.
|
||||
if preferred_desc_index is not None and cleaned_entries:
|
||||
if 0 <= preferred_desc_index < len(cleaned_entries):
|
||||
cleaned_entries = [cleaned_entries[preferred_desc_index]]
|
||||
else:
|
||||
cleaned_entries = [cleaned_entries[0]]
|
||||
|
||||
# Rebuild stsd content
|
||||
result = version_flags + struct.pack(">I", len(cleaned_entries))
|
||||
for entry in cleaned_entries:
|
||||
result += entry
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _sample_entry_header_size(entry_type: bytes) -> int:
|
||||
"""Return fixed sample-entry bytes before child boxes."""
|
||||
if entry_type in (b"encv", b"avc1", b"avc3", b"hvc1", b"hev1", b"dvh1", b"dvhe"):
|
||||
return 86
|
||||
if entry_type in (b"enca", b"mp4a", b"alac", b"ac-3", b"ec-3"):
|
||||
return 36
|
||||
if entry_type in (b"c608", b"c708", b"text", b"tx3g", b"wvtt", b"stpp"):
|
||||
return 8
|
||||
return 36
|
||||
|
||||
|
||||
def _clean_encrypted_sample_entry(entry_data: bytes) -> bytes:
|
||||
"""
|
||||
Clean an encrypted sample entry (enca, encv, etc.).
|
||||
|
||||
Structure of encrypted audio entry (enca):
|
||||
- Box header (8 bytes): size + 'enca'
|
||||
- Reserved (6 bytes)
|
||||
- Data reference index (2 bytes)
|
||||
- Audio specific data (varies by codec)
|
||||
- Child boxes including:
|
||||
- sinf (protection scheme info) - REMOVE THIS
|
||||
- Original codec box (alac, esds, etc.) - KEEP
|
||||
|
||||
We need to:
|
||||
1. Find frma box inside sinf to get original format
|
||||
2. Replace 'enca' with original format
|
||||
3. Remove sinf box
|
||||
"""
|
||||
if len(entry_data) < 16:
|
||||
return entry_data
|
||||
|
||||
entry_size = struct.unpack(">I", entry_data[:4])[0]
|
||||
entry_type = entry_data[4:8]
|
||||
sample_entry_header_size = _sample_entry_header_size(entry_type)
|
||||
if len(entry_data) < sample_entry_header_size:
|
||||
return entry_data
|
||||
|
||||
# Find the original format from sinf/frma
|
||||
original_format = _find_original_format(entry_data)
|
||||
if not original_format:
|
||||
# If we can't find frma, try common mappings
|
||||
if entry_type == b"enca":
|
||||
original_format = b"mp4a" # Default to AAC
|
||||
elif entry_type == b"encv":
|
||||
original_format = b"avc1" # Default to H.264
|
||||
else:
|
||||
original_format = entry_type # Keep as-is
|
||||
|
||||
# Copy the fixed header part, replacing the type
|
||||
new_entry = (
|
||||
entry_data[:4] + original_format + entry_data[8:sample_entry_header_size]
|
||||
)
|
||||
|
||||
# Process child boxes, removing sinf
|
||||
child_offset = sample_entry_header_size
|
||||
while child_offset + 8 <= len(entry_data):
|
||||
child_size = struct.unpack(">I", entry_data[child_offset : child_offset + 4])[0]
|
||||
child_type = entry_data[child_offset + 4 : child_offset + 8]
|
||||
|
||||
if child_size < 8 or child_offset + child_size > len(entry_data):
|
||||
break
|
||||
|
||||
# Skip sinf box (encryption metadata)
|
||||
if child_type != b"sinf":
|
||||
new_entry += entry_data[child_offset : child_offset + child_size]
|
||||
|
||||
child_offset += child_size
|
||||
|
||||
# Update the entry size
|
||||
new_size = len(new_entry)
|
||||
new_entry = struct.pack(">I", new_size) + new_entry[4:]
|
||||
|
||||
return new_entry
|
||||
|
||||
|
||||
def _find_original_format(entry_data: bytes) -> Optional[bytes]:
|
||||
"""
|
||||
Find the original format (frma) from sinf box in an encrypted entry.
|
||||
|
||||
sinf box contains:
|
||||
- frma: original format (4 bytes codec type)
|
||||
- schm: scheme type
|
||||
- schi: scheme info (contains tenc for CENC)
|
||||
"""
|
||||
# Look for sinf box
|
||||
sinf_idx = entry_data.find(b"sinf")
|
||||
if sinf_idx < 4:
|
||||
return None
|
||||
|
||||
sinf_size = struct.unpack(">I", entry_data[sinf_idx - 4 : sinf_idx])[0]
|
||||
if sinf_size < 16 or sinf_idx + sinf_size > len(entry_data) + 4:
|
||||
return None
|
||||
|
||||
sinf_data = entry_data[sinf_idx - 4 : sinf_idx - 4 + sinf_size]
|
||||
|
||||
# Look for frma box inside sinf
|
||||
frma_idx = sinf_data.find(b"frma")
|
||||
if frma_idx < 4:
|
||||
return None
|
||||
|
||||
frma_size = struct.unpack(">I", sinf_data[frma_idx - 4 : frma_idx])[0]
|
||||
if frma_size != 12: # frma is always 12 bytes: size(4) + type(4) + format(4)
|
||||
return None
|
||||
|
||||
# Extract the original format
|
||||
return sinf_data[frma_idx + 4 : frma_idx + 8]
|
||||
|
||||
|
||||
def _remove_sinf_from_entry(entry_data: bytes) -> bytes:
|
||||
"""
|
||||
Remove sinf box from a sample entry (if present).
|
||||
Used for non-encrypted entries that might still have protection info.
|
||||
"""
|
||||
if len(entry_data) < 16:
|
||||
return entry_data
|
||||
sample_entry_header_size = _sample_entry_header_size(entry_data[4:8])
|
||||
if len(entry_data) < sample_entry_header_size:
|
||||
return entry_data
|
||||
|
||||
# Check if sinf exists
|
||||
if b"sinf" not in entry_data:
|
||||
return entry_data
|
||||
|
||||
# Rebuild entry without sinf
|
||||
new_entry = entry_data[:sample_entry_header_size]
|
||||
|
||||
child_offset = sample_entry_header_size
|
||||
while child_offset + 8 <= len(entry_data):
|
||||
child_size = struct.unpack(">I", entry_data[child_offset : child_offset + 4])[0]
|
||||
child_type = entry_data[child_offset + 4 : child_offset + 8]
|
||||
|
||||
if child_size < 8 or child_offset + child_size > len(entry_data):
|
||||
break
|
||||
|
||||
if child_type != b"sinf":
|
||||
new_entry += entry_data[child_offset : child_offset + child_size]
|
||||
|
||||
child_offset += child_size
|
||||
|
||||
# Update size
|
||||
new_size = len(new_entry)
|
||||
new_entry = struct.pack(">I", new_size) + new_entry[4:]
|
||||
|
||||
return new_entry
|
||||
|
||||
|
||||
def _extract_alac_config(data: bytes) -> Optional[bytes]:
|
||||
"""Extract ALAC configuration from moov/stsd box (for backwards compatibility)."""
|
||||
# Simple search for 'alac' box in data
|
||||
idx = data.find(b"alac")
|
||||
if idx < 4:
|
||||
return None
|
||||
|
||||
# Check if it's inside stsd (look for full structure)
|
||||
# The 'alac' cookie box follows the sample entry
|
||||
alac_idx = idx
|
||||
while alac_idx < len(data) - 100:
|
||||
if data[alac_idx : alac_idx + 4] == b"alac":
|
||||
size = struct.unpack(">I", data[alac_idx - 4 : alac_idx])[0]
|
||||
if 20 < size < 100: # Reasonable ALAC config size
|
||||
return data[alac_idx + 4 : alac_idx - 4 + size]
|
||||
alac_idx += 1
|
||||
if alac_idx > idx + 200:
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
def _extract_timescale(data: bytes) -> int:
|
||||
"""Extract timescale from moov/mvhd or mdhd box."""
|
||||
# Look for mdhd box (media header has the audio timescale)
|
||||
idx = data.find(b"mdhd")
|
||||
if idx > 0 and idx + 28 < len(data):
|
||||
# mdhd: size(4) + type(b'mdhd') + version(1) + flags(3)
|
||||
version = data[idx + 4]
|
||||
if version == 0:
|
||||
# v0: ver+flags(4) + creation(4) + modification(4) + timescale(4)
|
||||
return struct.unpack(">I", data[idx + 16 : idx + 20])[0]
|
||||
else:
|
||||
# v1: ver+flags(4) + creation(8) + modification(8) + timescale(4)
|
||||
return struct.unpack(">I", data[idx + 24 : idx + 28])[0]
|
||||
return 44100 # Default fallback
|
||||
|
||||
|
||||
def _extract_track_timescale(
|
||||
data: bytes, handler_type: bytes = b"soun", default: int = 44100
|
||||
) -> int:
|
||||
"""Extract mdhd timescale from the selected track."""
|
||||
moov_idx = data.find(b"moov")
|
||||
if moov_idx < 4:
|
||||
return default
|
||||
moov_size = struct.unpack(">I", data[moov_idx - 4 : moov_idx])[0]
|
||||
if moov_size < 8 or moov_idx - 4 + moov_size > len(data):
|
||||
return default
|
||||
moov_data = data[moov_idx - 4 : moov_idx - 4 + moov_size]
|
||||
trak = _find_track_by_handler(moov_data, handler_type)
|
||||
mdia = _find_child_box(trak, b"mdia") if trak else None
|
||||
mdhd = _find_child_box(mdia, b"mdhd") if mdia else None
|
||||
if not mdhd or len(mdhd) < 28:
|
||||
return default
|
||||
version = mdhd[8]
|
||||
if version == 0 and len(mdhd) >= 24:
|
||||
return struct.unpack(">I", mdhd[20:24])[0]
|
||||
if version == 1 and len(mdhd) >= 32:
|
||||
return struct.unpack(">I", mdhd[28:32])[0]
|
||||
return default
|
||||
|
||||
|
||||
def _find_child_box(
|
||||
container_data: bytes, target_type: bytes, skip_header: int = 8
|
||||
) -> Optional[bytes]:
|
||||
"""Find a direct child box in container data.
|
||||
|
||||
Args:
|
||||
container_data: Raw bytes of the container box (including its own header).
|
||||
target_type: 4-byte box type to search for.
|
||||
skip_header: Bytes to skip at start (8 for plain box, 12 for FullBox).
|
||||
|
||||
Returns:
|
||||
Full box bytes (size + type + content) or None.
|
||||
"""
|
||||
offset = skip_header
|
||||
while offset + 8 <= len(container_data):
|
||||
size = struct.unpack(">I", container_data[offset : offset + 4])[0]
|
||||
box_type = container_data[offset + 4 : offset + 8]
|
||||
if size < 8 or offset + size > len(container_data):
|
||||
break
|
||||
if box_type == target_type:
|
||||
return container_data[offset : offset + size]
|
||||
offset += size
|
||||
return None
|
||||
|
||||
|
||||
def _find_track_by_handler(moov_data: bytes, handler_type: bytes) -> Optional[bytes]:
|
||||
"""Find the trak box in moov data for a media handler.
|
||||
|
||||
Iterates trak children and returns the first one whose mdia/hdlr has
|
||||
the requested handler type. Returns full trak box bytes or None.
|
||||
"""
|
||||
offset = 8 # Skip moov header
|
||||
while offset + 8 <= len(moov_data):
|
||||
size = struct.unpack(">I", moov_data[offset : offset + 4])[0]
|
||||
box_type = moov_data[offset + 4 : offset + 8]
|
||||
if size < 8 or offset + size > len(moov_data):
|
||||
break
|
||||
if box_type == b"trak":
|
||||
trak_data = moov_data[offset : offset + size]
|
||||
hdlr_idx = trak_data.find(b"hdlr")
|
||||
if hdlr_idx > 0:
|
||||
# hdlr FullBox: version+flags(4) + pre_defined(4) + handler_type(4)
|
||||
handler_offset = hdlr_idx + 4 + 4 + 4
|
||||
if handler_offset + 4 <= len(trak_data):
|
||||
if trak_data[handler_offset : handler_offset + 4] == handler_type:
|
||||
return trak_data
|
||||
offset += size
|
||||
return None
|
||||
|
||||
|
||||
def _find_audio_trak(moov_data: bytes) -> Optional[bytes]:
|
||||
"""Find the audio trak box in moov data."""
|
||||
return _find_track_by_handler(moov_data, b"soun")
|
||||
|
||||
|
||||
def _patch_mvhd_duration(box_data: bytes, duration: int, timescale: int) -> bytes:
|
||||
"""Return a copy of the mvhd box with its duration and timescale fields patched."""
|
||||
data = bytearray(box_data)
|
||||
version = data[8] # After size(4) + type(4)
|
||||
if version == 0:
|
||||
# v0: ver+flags(4) + creation(4) + modification(4) + timescale(4) + duration(4)
|
||||
struct.pack_into(">I", data, 20, timescale)
|
||||
struct.pack_into(">I", data, 24, duration)
|
||||
else:
|
||||
# v1: ver+flags(4) + creation(8) + modification(8) + timescale(4) + duration(8)
|
||||
struct.pack_into(">I", data, 28, timescale)
|
||||
struct.pack_into(">Q", data, 32, duration)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def _patch_tkhd_duration(box_data: bytes, duration: int) -> bytes:
|
||||
"""Return a copy of the tkhd box with duration patched and flags set to 7."""
|
||||
data = bytearray(box_data)
|
||||
version = data[8]
|
||||
# Set flags = 7 (enabled | in_movie | in_preview)
|
||||
data[9:12] = struct.pack(">I", 7)[1:] # 3-byte flags
|
||||
if version == 0:
|
||||
# v0: ver+flags(4) + creation(4) + modification(4) + track_id(4) + reserved(4) + duration(4)
|
||||
struct.pack_into(">I", data, 28, duration)
|
||||
else:
|
||||
# v1: ver+flags(4) + creation(8) + modification(8) + track_id(4) + reserved(4) + duration(8)
|
||||
struct.pack_into(">Q", data, 36, duration)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def _patch_mdhd_duration(box_data: bytes, duration: int, timescale: int) -> bytes:
|
||||
"""Return a copy of the mdhd box with its duration and timescale fields patched."""
|
||||
data = bytearray(box_data)
|
||||
version = data[8]
|
||||
if version == 0:
|
||||
# v0: ver+flags(4) + creation(4) + modification(4) + timescale(4) + duration(4)
|
||||
struct.pack_into(">I", data, 20, timescale)
|
||||
struct.pack_into(">I", data, 24, duration)
|
||||
else:
|
||||
# v1: ver+flags(4) + creation(8) + modification(8) + timescale(4) + duration(8)
|
||||
struct.pack_into(">I", data, 28, timescale)
|
||||
struct.pack_into(">Q", data, 32, duration)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def _write_udta(f):
|
||||
"""Write udta > meta > hdlr(mdir) + ilst metadata container.
|
||||
|
||||
This matches the Go amdecrypt output which creates an empty metadata
|
||||
container so tools can find and write metadata atoms.
|
||||
"""
|
||||
udta_start = f.tell()
|
||||
f.write(b"\x00" * 8) # udta placeholder
|
||||
|
||||
meta_start = f.tell()
|
||||
f.write(b"\x00" * 8) # meta placeholder
|
||||
# meta is a FullBox: version(1) + flags(3)
|
||||
f.write(struct.pack(">I", 0)) # version + flags
|
||||
|
||||
# hdlr for metadata (handler_type = 'mdir', reserved = 'appl' + zeros)
|
||||
hdlr_content = struct.pack(">I", 0) # pre_defined
|
||||
hdlr_content += b"mdir" # handler_type
|
||||
hdlr_content += struct.pack(">III", 0x6170706C, 0, 0) # reserved ('appl', 0, 0)
|
||||
hdlr_content += b"\x00" # empty name (null terminator)
|
||||
_write_fullbox(f, b"hdlr", 0, 0, hdlr_content)
|
||||
|
||||
# ilst (empty)
|
||||
_write_box(f, b"ilst", b"")
|
||||
|
||||
_fixup_box_size(f, meta_start, b"meta")
|
||||
_fixup_box_size(f, udta_start, b"udta")
|
||||
|
||||
|
||||
def _extract_trex_defaults(moov_data: bytes, target_track_id: int = 0) -> dict:
|
||||
"""Extract default sample values from moov/mvex/trex box.
|
||||
|
||||
The trex (Track Extends) box provides default values for sample duration,
|
||||
size, description index, and flags used by track fragments (traf/trun)
|
||||
when those fields are not explicitly present.
|
||||
|
||||
Args:
|
||||
moov_data: Raw bytes of the moov box.
|
||||
target_track_id: If > 0, only return defaults for this track.
|
||||
If 0, return the first trex found.
|
||||
|
||||
Returns:
|
||||
Dict with keys: default_sample_duration, default_sample_size,
|
||||
default_sample_description_index, default_sample_flags.
|
||||
"""
|
||||
# Determine fallback duration based on codec
|
||||
# ALAC frames are 4096 samples, AAC frames are 1024 samples
|
||||
is_alac = b"alac" in moov_data or b"ALAC" in moov_data
|
||||
fallback_duration = 4096 if is_alac else 1024
|
||||
|
||||
defaults = {
|
||||
"default_sample_duration": fallback_duration,
|
||||
"default_sample_size": 0,
|
||||
"default_sample_description_index": 1,
|
||||
"default_sample_flags": 0,
|
||||
}
|
||||
|
||||
# Find mvex box inside moov
|
||||
mvex = _find_child_box(moov_data, b"mvex")
|
||||
if mvex is None:
|
||||
return defaults
|
||||
|
||||
# Iterate trex children inside mvex
|
||||
offset = 8 # Skip mvex box header
|
||||
while offset + 8 <= len(mvex):
|
||||
size = struct.unpack(">I", mvex[offset : offset + 4])[0]
|
||||
box_type = mvex[offset + 4 : offset + 8]
|
||||
if size < 8 or offset + size > len(mvex):
|
||||
break
|
||||
if box_type == b"trex" and size >= 32:
|
||||
# trex FullBox: size(4) + type(4) + version(1) + flags(3)
|
||||
# + track_id(4) + default_sample_description_index(4)
|
||||
# + default_sample_duration(4) + default_sample_size(4)
|
||||
# + default_sample_flags(4)
|
||||
trex_data = mvex[offset : offset + size]
|
||||
track_id = struct.unpack(">I", trex_data[12:16])[0]
|
||||
if target_track_id == 0 or track_id == target_track_id:
|
||||
defaults["default_sample_description_index"] = struct.unpack(
|
||||
">I", trex_data[16:20]
|
||||
)[0]
|
||||
|
||||
# Extract duration and protect against Apple's dummy values
|
||||
parsed_duration = struct.unpack(">I", trex_data[20:24])[0]
|
||||
|
||||
# Override if the provider wrote 0, or if they incorrectly wrote 1024 for an ALAC track
|
||||
if parsed_duration == 0 or (is_alac and parsed_duration == 1024):
|
||||
defaults["default_sample_duration"] = fallback_duration
|
||||
else:
|
||||
defaults["default_sample_duration"] = parsed_duration
|
||||
|
||||
defaults["default_sample_size"] = struct.unpack(">I", trex_data[24:28])[
|
||||
0
|
||||
]
|
||||
defaults["default_sample_flags"] = struct.unpack(
|
||||
">I", trex_data[28:32]
|
||||
)[0]
|
||||
break
|
||||
offset += size
|
||||
|
||||
return defaults
|
||||
|
||||
|
||||
def _extract_encryption_info(
|
||||
moov_data: bytes, handler_type: bytes = b"soun"
|
||||
) -> Optional[EncryptionInfo]:
|
||||
"""Extract encryption scheme info from the selected track's sinf box.
|
||||
|
||||
Walks moov → trak (audio) → mdia → minf → stbl → stsd → sample_entry → sinf,
|
||||
then reads sinf/schm for scheme_type and sinf/schi/tenc for IV size, constant IV,
|
||||
and default KID.
|
||||
|
||||
Returns EncryptionInfo or None if no sinf is found.
|
||||
"""
|
||||
trak_data = _find_track_by_handler(moov_data, handler_type)
|
||||
if trak_data is None:
|
||||
return None
|
||||
|
||||
# Navigate trak → mdia → minf → stbl → stsd
|
||||
mdia = _find_child_box(trak_data, b"mdia")
|
||||
if mdia is None:
|
||||
return None
|
||||
minf = _find_child_box(mdia, b"minf")
|
||||
if minf is None:
|
||||
return None
|
||||
stbl = _find_child_box(minf, b"stbl")
|
||||
if stbl is None:
|
||||
return None
|
||||
stsd = _find_child_box(stbl, b"stsd")
|
||||
if stsd is None:
|
||||
return None
|
||||
|
||||
# stsd is a FullBox: 4 (size) + 4 (type) + 4 (version+flags) + 4 (entry_count)
|
||||
# Then the first sample entry immediately follows
|
||||
if len(stsd) < 16:
|
||||
return None
|
||||
entry_offset = 16 # past header+version+flags+entry_count
|
||||
if entry_offset + 8 > len(stsd):
|
||||
return None
|
||||
entry_size = struct.unpack(">I", stsd[entry_offset : entry_offset + 4])[0]
|
||||
entry_data = stsd[entry_offset : entry_offset + entry_size]
|
||||
|
||||
# Find sinf inside this sample entry
|
||||
sample_entry_header_size = _sample_entry_header_size(entry_data[4:8])
|
||||
sinf = _find_child_box(entry_data, b"sinf", skip_header=sample_entry_header_size)
|
||||
if sinf is None:
|
||||
return None
|
||||
|
||||
info = EncryptionInfo()
|
||||
|
||||
# Parse schm (Scheme Type Box) inside sinf
|
||||
schm = _find_child_box(sinf, b"schm")
|
||||
if schm and len(schm) >= 20:
|
||||
# schm: 4(size) + 4(type) + 4(ver+flags) + 4(scheme_type) + 4(scheme_version)
|
||||
info.scheme_type = schm[12:16].decode("ascii", errors="replace")
|
||||
|
||||
# Parse tenc (Track Encryption Box) inside sinf/schi
|
||||
schi = _find_child_box(sinf, b"schi")
|
||||
if schi:
|
||||
tenc = _find_child_box(schi, b"tenc")
|
||||
if tenc and len(tenc) >= 32:
|
||||
# tenc FullBox layout (offsets include 8-byte box header):
|
||||
# [0:4] size
|
||||
# [4:8] type "tenc"
|
||||
# [8] version
|
||||
# [9:12] flags (3 bytes)
|
||||
# [12] reserved
|
||||
# [13] reserved (v0) / crypt_byte_block|skip_byte_block (v1)
|
||||
# [14] default_isProtected
|
||||
# [15] default_Per_Sample_IV_Size
|
||||
# [16:32] default_KID (16 bytes)
|
||||
# if per_sample_iv_size==0:
|
||||
# [32] default_constant_IV_size
|
||||
# [33..] default_constant_IV
|
||||
tenc_version = tenc[8]
|
||||
if tenc_version > 0:
|
||||
pattern = tenc[13]
|
||||
info.crypt_byte_block = pattern >> 4
|
||||
info.skip_byte_block = pattern & 0x0F
|
||||
per_sample_iv_size = tenc[15]
|
||||
kid = tenc[16:32]
|
||||
constant_iv_offset = 32
|
||||
else:
|
||||
per_sample_iv_size = tenc[15]
|
||||
kid = tenc[16:32]
|
||||
constant_iv_offset = 32
|
||||
info.per_sample_iv_size = per_sample_iv_size
|
||||
info.kid = kid
|
||||
|
||||
# If per_sample_iv_size is 0, a constant IV follows the KID
|
||||
if per_sample_iv_size == 0 and len(tenc) > constant_iv_offset:
|
||||
constant_iv_size = tenc[constant_iv_offset]
|
||||
if len(tenc) >= constant_iv_offset + 1 + constant_iv_size:
|
||||
info.constant_iv = tenc[
|
||||
constant_iv_offset
|
||||
+ 1 : constant_iv_offset
|
||||
+ 1
|
||||
+ constant_iv_size
|
||||
]
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def _extract_encryption_info_per_stsd(
|
||||
moov_data: bytes, handler_type: bytes = b"soun"
|
||||
) -> Optional[dict]:
|
||||
"""Extract encryption scheme info for each stsd entry (sample description).
|
||||
|
||||
Returns a dict mapping desc_index (0-based) → EncryptionInfo, or None if no
|
||||
encryption found. This handles cases where different sample descriptions have
|
||||
different encryption parameters (e.g., different IVs or key schemes).
|
||||
"""
|
||||
trak_data = _find_track_by_handler(moov_data, handler_type)
|
||||
if trak_data is None:
|
||||
return None
|
||||
|
||||
# Navigate trak → mdia → minf → stbl → stsd
|
||||
mdia = _find_child_box(trak_data, b"mdia")
|
||||
if mdia is None:
|
||||
return None
|
||||
minf = _find_child_box(mdia, b"minf")
|
||||
if minf is None:
|
||||
return None
|
||||
stbl = _find_child_box(minf, b"stbl")
|
||||
if stbl is None:
|
||||
return None
|
||||
stsd = _find_child_box(stbl, b"stsd")
|
||||
if stsd is None:
|
||||
return None
|
||||
|
||||
if len(stsd) < 16:
|
||||
return None
|
||||
|
||||
entry_count = struct.unpack(">I", stsd[12:16])[0]
|
||||
if entry_count == 0:
|
||||
return None
|
||||
|
||||
encryption_info_per_desc = {}
|
||||
entry_offset = 16 # past header+version+flags+entry_count
|
||||
|
||||
for desc_idx in range(entry_count):
|
||||
if entry_offset + 8 > len(stsd):
|
||||
break
|
||||
|
||||
entry_size = struct.unpack(">I", stsd[entry_offset : entry_offset + 4])[0]
|
||||
if entry_size < 8 or entry_offset + entry_size > len(stsd):
|
||||
break
|
||||
|
||||
entry_data = stsd[entry_offset : entry_offset + entry_size]
|
||||
|
||||
sample_entry_header_size = _sample_entry_header_size(entry_data[4:8])
|
||||
sinf = _find_child_box(
|
||||
entry_data, b"sinf", skip_header=sample_entry_header_size
|
||||
)
|
||||
if sinf is not None:
|
||||
# Extract encryption info for this stsd entry
|
||||
info = EncryptionInfo()
|
||||
|
||||
# Parse schm
|
||||
schm = _find_child_box(sinf, b"schm")
|
||||
if schm and len(schm) >= 20:
|
||||
info.scheme_type = schm[12:16].decode("ascii", errors="replace")
|
||||
|
||||
# Parse tenc
|
||||
schi = _find_child_box(sinf, b"schi")
|
||||
if schi:
|
||||
tenc = _find_child_box(schi, b"tenc")
|
||||
if tenc and len(tenc) >= 32:
|
||||
tenc_version = tenc[8]
|
||||
|
||||
if tenc_version > 0:
|
||||
pattern = tenc[13]
|
||||
info.crypt_byte_block = pattern >> 4
|
||||
info.skip_byte_block = pattern & 0x0F
|
||||
per_sample_iv_size = tenc[15]
|
||||
kid = tenc[16:32]
|
||||
constant_iv_offset = 32
|
||||
else:
|
||||
per_sample_iv_size = tenc[15]
|
||||
kid = tenc[16:32]
|
||||
constant_iv_offset = 32
|
||||
info.per_sample_iv_size = per_sample_iv_size
|
||||
info.kid = kid
|
||||
|
||||
# If per_sample_iv_size is 0, extract constant IV
|
||||
if per_sample_iv_size == 0 and len(tenc) > constant_iv_offset:
|
||||
constant_iv_size = tenc[constant_iv_offset]
|
||||
if len(tenc) >= constant_iv_offset + 1 + constant_iv_size:
|
||||
info.constant_iv = tenc[
|
||||
constant_iv_offset
|
||||
+ 1 : constant_iv_offset
|
||||
+ 1
|
||||
+ constant_iv_size
|
||||
]
|
||||
|
||||
encryption_info_per_desc[desc_idx] = info
|
||||
|
||||
entry_offset += entry_size
|
||||
|
||||
return encryption_info_per_desc if encryption_info_per_desc else None
|
||||
|
||||
|
||||
def _extract_track_id(
|
||||
moov_data: bytes, handler_type: bytes = b"soun", default_track_id: int = 1
|
||||
) -> int:
|
||||
"""Extract the track ID for the requested handler from the moov box.
|
||||
|
||||
Parses trak boxes in moov to find one with the requested handler, then
|
||||
returns its track_id from tkhd.
|
||||
"""
|
||||
offset = 8 # Skip moov box header
|
||||
while offset < len(moov_data) - 8:
|
||||
size = struct.unpack(">I", moov_data[offset : offset + 4])[0]
|
||||
box_type = moov_data[offset + 4 : offset + 8]
|
||||
|
||||
if size < 8 or offset + size > len(moov_data):
|
||||
break
|
||||
|
||||
if box_type == b"trak":
|
||||
trak_data = moov_data[offset : offset + size]
|
||||
|
||||
# Check handler type in hdlr box
|
||||
hdlr_idx = trak_data.find(b"hdlr")
|
||||
if hdlr_idx > 0:
|
||||
# hdlr FullBox: after 'hdlr' type comes version+flags(4) + pre_defined(4) + handler_type(4)
|
||||
handler_offset = hdlr_idx + 4 + 4 + 4
|
||||
if handler_offset + 4 <= len(trak_data):
|
||||
parsed_handler_type = trak_data[handler_offset : handler_offset + 4]
|
||||
if parsed_handler_type == handler_type:
|
||||
# Found requested track, extract track_id from tkhd
|
||||
tkhd_idx = trak_data.find(b"tkhd")
|
||||
if tkhd_idx > 0:
|
||||
version = trak_data[tkhd_idx + 4]
|
||||
if version == 0:
|
||||
# v0: ver+flags(4) + creation(4) + modification(4) + track_id(4)
|
||||
tid_offset = tkhd_idx + 4 + 4 + 4 + 4
|
||||
else:
|
||||
# v1: ver+flags(4) + creation(8) + modification(8) + track_id(4)
|
||||
tid_offset = tkhd_idx + 4 + 4 + 8 + 8
|
||||
if tid_offset + 4 <= len(trak_data):
|
||||
return struct.unpack(
|
||||
">I", trak_data[tid_offset : tid_offset + 4]
|
||||
)[0]
|
||||
|
||||
offset += size
|
||||
|
||||
return default_track_id
|
||||
|
||||
|
||||
def _extract_audio_track_id(moov_data: bytes) -> int:
|
||||
"""Extract the track ID of the audio track from the moov box."""
|
||||
return _extract_track_id(moov_data, b"soun", 1)
|
||||
|
||||
|
||||
async def _decrypt_track_wrapper(
|
||||
wrapper_api: WrapperApi,
|
||||
track_id: str,
|
||||
fairplay_key: str,
|
||||
input_path: str,
|
||||
handler_type: bytes = b"soun",
|
||||
*,
|
||||
use_single_content_key: bool = False,
|
||||
file_backed: bool = False,
|
||||
progress_callback=None,
|
||||
) -> DecryptedTrack:
|
||||
"""Decrypt one track through wrapper-v2 (CBCS via FairPlay SKD)."""
|
||||
song_info = await asyncio.to_thread(
|
||||
extract_song, input_path, handler_type, file_backed
|
||||
)
|
||||
enc_info = song_info.encryption_info or EncryptionInfo(scheme_type="cbcs")
|
||||
enc_info_per_desc = None
|
||||
if song_info.moov_data:
|
||||
enc_info_per_desc = await asyncio.to_thread(
|
||||
_extract_encryption_info_per_stsd,
|
||||
song_info.moov_data,
|
||||
handler_type,
|
||||
)
|
||||
|
||||
temp_path = None
|
||||
if file_backed:
|
||||
temp_file = tempfile.NamedTemporaryFile(
|
||||
prefix="gamdl_decrypted_", suffix=".bin", delete=False
|
||||
)
|
||||
temp_path = temp_file.name
|
||||
temp_file.close()
|
||||
try:
|
||||
decrypted_data = await decrypt_samples(
|
||||
wrapper_api,
|
||||
track_id,
|
||||
fairplay_key,
|
||||
song_info.samples,
|
||||
enc_info,
|
||||
enc_info_per_desc,
|
||||
use_single_content_key=use_single_content_key,
|
||||
progress_callback=progress_callback,
|
||||
decrypted_data_path=temp_path,
|
||||
)
|
||||
except Exception:
|
||||
if temp_path:
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
if temp_path:
|
||||
return DecryptedTrack(
|
||||
input_path,
|
||||
song_info,
|
||||
data_path=temp_path,
|
||||
data_size=os.path.getsize(temp_path),
|
||||
)
|
||||
return DecryptedTrack(input_path, song_info, decrypted_data)
|
||||
|
||||
|
||||
async def decrypt_wrapper(
|
||||
wrapper_api: WrapperApi,
|
||||
track_id: str,
|
||||
input_audio_path: str,
|
||||
input_video_path: str | None = None,
|
||||
fairplay_key_video: str | None = None,
|
||||
*,
|
||||
fairplay_key_audio: str | None = None,
|
||||
use_single_content_key: bool = False,
|
||||
progress_callback=None,
|
||||
) -> DecryptedMedia:
|
||||
"""Decrypt audio and optional video through wrapper-v2 (CBCS)."""
|
||||
if fairplay_key_audio is None:
|
||||
if input_video_path is None and fairplay_key_video is not None:
|
||||
fairplay_key_audio = fairplay_key_video
|
||||
else:
|
||||
raise ValueError("fairplay_key_audio is required for wrapper audio decrypt")
|
||||
|
||||
audio = await _decrypt_track_wrapper(
|
||||
wrapper_api,
|
||||
track_id,
|
||||
fairplay_key_audio,
|
||||
input_audio_path,
|
||||
b"soun",
|
||||
use_single_content_key=use_single_content_key,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
if input_video_path is None:
|
||||
return DecryptedMedia(audio=audio)
|
||||
|
||||
if fairplay_key_video is None:
|
||||
raise ValueError("fairplay_key_video is required for wrapper video decrypt")
|
||||
|
||||
video_task = asyncio.create_task(
|
||||
_decrypt_track_wrapper(
|
||||
wrapper_api,
|
||||
track_id,
|
||||
fairplay_key_video,
|
||||
input_video_path,
|
||||
b"vide",
|
||||
use_single_content_key=use_single_content_key,
|
||||
file_backed=True,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
)
|
||||
caption_tracks = [
|
||||
track
|
||||
for track in await asyncio.gather(
|
||||
asyncio.to_thread(extract_song, input_video_path, b"clcp", True),
|
||||
asyncio.to_thread(extract_song, input_video_path, b"text", True),
|
||||
asyncio.to_thread(extract_song, input_video_path, b"sbtl", True),
|
||||
asyncio.to_thread(extract_song, input_video_path, b"subt", True),
|
||||
)
|
||||
if track.samples
|
||||
]
|
||||
captions = [
|
||||
DecryptedTrack(
|
||||
input_video_path,
|
||||
caption_track,
|
||||
_sample_payload_bytes(caption_track.samples),
|
||||
)
|
||||
for caption_track in caption_tracks
|
||||
]
|
||||
|
||||
return DecryptedMedia(
|
||||
audio=audio,
|
||||
video=await video_task,
|
||||
captions=captions,
|
||||
)
|
||||
|
||||
|
||||
decrypt_file = decrypt_wrapper
|
||||
|
||||
|
||||
def decrypt_samples_hex(
|
||||
samples: List[SampleInfo],
|
||||
keys: dict,
|
||||
encryption_info: EncryptionInfo,
|
||||
encryption_info_per_desc: Optional[dict] = None,
|
||||
) -> bytes:
|
||||
"""Decrypt samples using hex AES keys (no wrapper needed).
|
||||
|
||||
Supports both CENC (AES-128-CTR) and CBCS (AES-128-CBC) schemes.
|
||||
|
||||
Args:
|
||||
samples: List of SampleInfo with data, desc_index, iv, subsamples.
|
||||
keys: Mapping of desc_index (int) → AES key (16 bytes, raw).
|
||||
encryption_info: EncryptionInfo with scheme_type, constant_iv, etc.
|
||||
encryption_info_per_desc: Optional dict mapping desc_index → EncryptionInfo
|
||||
(used when different stsd entries have different params).
|
||||
|
||||
Returns:
|
||||
Concatenated decrypted sample data.
|
||||
"""
|
||||
decrypted = bytearray()
|
||||
|
||||
for sample in samples:
|
||||
key = keys.get(sample.desc_index)
|
||||
if key is None:
|
||||
# No key for this desc_index — keep data as-is (shouldn't happen)
|
||||
decrypted.extend(_sample_data(sample))
|
||||
continue
|
||||
|
||||
# Get encryption info for this sample's desc_index (if per-description info exists)
|
||||
if encryption_info_per_desc and sample.desc_index in encryption_info_per_desc:
|
||||
enc_info = encryption_info_per_desc[sample.desc_index]
|
||||
else:
|
||||
enc_info = encryption_info
|
||||
|
||||
if not sample.data and sample.data_path:
|
||||
sample = _with_sample_data(sample, _sample_data(sample))
|
||||
|
||||
is_cenc = enc_info.scheme_type == "cenc"
|
||||
if is_cenc:
|
||||
# AES-128-CTR: per-sample IV from senc, zero-padded to 16 bytes
|
||||
data = sample.data
|
||||
iv = sample.iv
|
||||
if len(iv) < 16:
|
||||
iv = iv + b"\x00" * (16 - len(iv))
|
||||
cipher = AES.new(key, AES.MODE_CTR, nonce=b"", initial_value=iv)
|
||||
|
||||
if sample.subsamples:
|
||||
plaintext = bytearray()
|
||||
offset = 0
|
||||
for clear_bytes, encrypted_bytes in sample.subsamples:
|
||||
plaintext.extend(data[offset : offset + clear_bytes])
|
||||
offset += clear_bytes
|
||||
plaintext.extend(
|
||||
cipher.decrypt(data[offset : offset + encrypted_bytes])
|
||||
)
|
||||
offset += encrypted_bytes
|
||||
plaintext.extend(data[offset:])
|
||||
decrypted.extend(plaintext)
|
||||
else:
|
||||
decrypted.extend(cipher.decrypt(data))
|
||||
|
||||
else:
|
||||
# CBCS (AES-128-CBC): constant IV or per-sample IV
|
||||
if enc_info.crypt_byte_block and enc_info.skip_byte_block:
|
||||
decrypted.extend(
|
||||
_decrypt_cbcs_sample_with_pattern(sample, key, enc_info)
|
||||
)
|
||||
continue
|
||||
|
||||
iv = sample.iv if sample.iv else enc_info.constant_iv
|
||||
if len(iv) < 16:
|
||||
iv = iv + b"\x00" * (16 - len(iv))
|
||||
|
||||
parts = _cbcs_ciphertext_for_sample(sample)
|
||||
if parts is None:
|
||||
decrypted.extend(sample.data)
|
||||
continue
|
||||
|
||||
aligned, tail = parts
|
||||
plain = b""
|
||||
if aligned:
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv=iv)
|
||||
plain = cipher.decrypt(aligned)
|
||||
decrypted.extend(_reassemble_cbcs_sample(sample, plain, tail))
|
||||
|
||||
return bytes(decrypted)
|
||||
|
||||
|
||||
def _decrypt_sample_hex(
|
||||
sample: SampleInfo,
|
||||
key: Optional[bytes],
|
||||
encryption_info: EncryptionInfo,
|
||||
) -> bytes:
|
||||
"""Decrypt one sample with a raw AES key."""
|
||||
data = _sample_data(sample)
|
||||
if data is not sample.data:
|
||||
sample = _with_sample_data(sample, data)
|
||||
|
||||
if key is None:
|
||||
return data
|
||||
|
||||
is_cenc = encryption_info.scheme_type == "cenc"
|
||||
if is_cenc:
|
||||
iv = sample.iv
|
||||
if len(iv) < 16:
|
||||
iv = iv + b"\x00" * (16 - len(iv))
|
||||
cipher = AES.new(key, AES.MODE_CTR, nonce=b"", initial_value=iv)
|
||||
|
||||
if not sample.subsamples:
|
||||
return cipher.decrypt(data)
|
||||
|
||||
plaintext = bytearray()
|
||||
offset = 0
|
||||
for clear_bytes, encrypted_bytes in sample.subsamples:
|
||||
plaintext.extend(data[offset : offset + clear_bytes])
|
||||
offset += clear_bytes
|
||||
plaintext.extend(
|
||||
cipher.decrypt(data[offset : offset + encrypted_bytes])
|
||||
)
|
||||
offset += encrypted_bytes
|
||||
plaintext.extend(data[offset:])
|
||||
return bytes(plaintext)
|
||||
|
||||
if encryption_info.crypt_byte_block and encryption_info.skip_byte_block:
|
||||
return _decrypt_cbcs_sample_with_pattern(sample, key, encryption_info)
|
||||
|
||||
iv = sample.iv if sample.iv else encryption_info.constant_iv
|
||||
if len(iv) < 16:
|
||||
iv = iv + b"\x00" * (16 - len(iv))
|
||||
|
||||
parts = _cbcs_ciphertext_for_sample(sample)
|
||||
if parts is None:
|
||||
return sample.data
|
||||
|
||||
aligned, tail = parts
|
||||
plain = b""
|
||||
if aligned:
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv=iv)
|
||||
plain = cipher.decrypt(aligned)
|
||||
return _reassemble_cbcs_sample(sample, plain, tail)
|
||||
|
||||
|
||||
def decrypt_samples_hex_to_file(
|
||||
samples: List[SampleInfo],
|
||||
keys: dict,
|
||||
encryption_info: EncryptionInfo,
|
||||
output_path: str,
|
||||
encryption_info_per_desc: Optional[dict] = None,
|
||||
release_sample_data: bool = False,
|
||||
) -> int:
|
||||
"""Decrypt samples to a raw payload file without building one large bytes object."""
|
||||
bytes_written = 0
|
||||
with open(output_path, "wb") as f:
|
||||
for sample in samples:
|
||||
enc_info = (
|
||||
encryption_info_per_desc[sample.desc_index]
|
||||
if encryption_info_per_desc
|
||||
and sample.desc_index in encryption_info_per_desc
|
||||
else encryption_info
|
||||
)
|
||||
decrypted_sample = _decrypt_sample_hex(
|
||||
sample,
|
||||
keys.get(sample.desc_index),
|
||||
enc_info,
|
||||
)
|
||||
f.write(decrypted_sample)
|
||||
sample.size = len(decrypted_sample)
|
||||
bytes_written += len(decrypted_sample)
|
||||
if release_sample_data:
|
||||
sample.data = b""
|
||||
sample.subsamples = []
|
||||
sample.iv = b""
|
||||
return bytes_written
|
||||
@@ -0,0 +1,436 @@
|
||||
import asyncio
|
||||
import multiprocessing
|
||||
import queue
|
||||
import re
|
||||
import shutil
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from mutagen.mp4 import MP4, MP4Cover
|
||||
from yt_dlp import YoutubeDL
|
||||
from yt_dlp.downloader.hls import HlsFD
|
||||
from yt_dlp.downloader.http import HttpFD
|
||||
|
||||
from ..interface.enums import CoverFormat
|
||||
from ..interface.interface import AppleMusicInterface
|
||||
from ..interface.types import MediaTags, PlaylistTags
|
||||
from ..utils import CustomStringFormatter, async_subprocess
|
||||
from .constants import ILLEGAL_CHAR_REPLACEMENT, ILLEGAL_CHARS_RE, TEMP_PATH_TEMPLATE
|
||||
from .enums import DownloadMode
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _download_ytdlp_process(
|
||||
stream_url: str,
|
||||
download_path: str,
|
||||
silent: bool,
|
||||
result_queue,
|
||||
) -> None:
|
||||
try:
|
||||
Path(download_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with YoutubeDL(
|
||||
{
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"overwrites": True,
|
||||
"noprogress": silent,
|
||||
"allow_unplayable_formats": True,
|
||||
"concurrent_fragment_downloads": 8,
|
||||
}
|
||||
) as ydl:
|
||||
if stream_url.split("?")[0].endswith(".m3u8"):
|
||||
hls_downloader = HlsFD(ydl, ydl.params)
|
||||
success, _ = hls_downloader.download(
|
||||
download_path,
|
||||
{
|
||||
"url": stream_url,
|
||||
"ext": "mp4",
|
||||
"protocol": "m3u8",
|
||||
},
|
||||
)
|
||||
if not success:
|
||||
raise RuntimeError("yt-dlp HLS download failed")
|
||||
else:
|
||||
http_downloader = HttpFD(ydl, ydl.params)
|
||||
success, _ = http_downloader.download(
|
||||
download_path,
|
||||
{
|
||||
"url": stream_url,
|
||||
},
|
||||
)
|
||||
if not success:
|
||||
raise RuntimeError("yt-dlp HTTP download failed")
|
||||
except Exception as e:
|
||||
result_queue.put(("error", repr(e), traceback.format_exc()))
|
||||
|
||||
|
||||
class AppleMusicBaseDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
interface: AppleMusicInterface,
|
||||
output_path: str = "./Apple Music",
|
||||
temp_path: str = ".",
|
||||
nm3u8dlre_path: str = "N_m3u8DL-RE",
|
||||
ffmpeg_path: str = "ffmpeg",
|
||||
download_mode: DownloadMode = DownloadMode.YTDLP,
|
||||
album_folder_template: str = "{album_artist}/{album}",
|
||||
compilation_folder_template: str = "Compilations/{album}",
|
||||
no_album_folder_template: str = "{artist}/Unknown Album",
|
||||
playlist_folder_template: str = "Playlists/{playlist_artist}",
|
||||
single_disc_file_template: str = "{track:02d} {title}",
|
||||
multi_disc_file_template: str = "{disc}-{track:02d} {title}",
|
||||
no_album_file_template: str = "{title}",
|
||||
playlist_file_template: str = "{playlist_title}",
|
||||
date_tag_template: str = "%Y-%m-%dT%H:%M:%SZ",
|
||||
exclude_tags: list[str] = None,
|
||||
truncate: int = None,
|
||||
silent: bool = False,
|
||||
):
|
||||
self.interface = interface
|
||||
self.output_path = output_path
|
||||
self.temp_path = temp_path
|
||||
self.nm3u8dlre_path = nm3u8dlre_path
|
||||
self.ffmpeg_path = ffmpeg_path
|
||||
self.download_mode = download_mode
|
||||
self.album_folder_template = album_folder_template
|
||||
self.compilation_folder_template = compilation_folder_template
|
||||
self.no_album_folder_template = no_album_folder_template
|
||||
self.single_disc_file_template = single_disc_file_template
|
||||
self.multi_disc_file_template = multi_disc_file_template
|
||||
self.playlist_folder_template = playlist_folder_template
|
||||
self.no_album_file_template = no_album_file_template
|
||||
self.playlist_file_template = playlist_file_template
|
||||
self.date_tag_template = date_tag_template
|
||||
self.exclude_tags = exclude_tags
|
||||
self.truncate = truncate
|
||||
self.silent = silent
|
||||
|
||||
self._initialize_binary_paths()
|
||||
|
||||
def _initialize_binary_paths(self):
|
||||
log = logger.bind(action="initialize_binary_paths")
|
||||
|
||||
self.full_nm3u8dlre_path = shutil.which(self.nm3u8dlre_path)
|
||||
self.full_ffmpeg_path = shutil.which(self.ffmpeg_path)
|
||||
|
||||
log = log.debug(
|
||||
"success",
|
||||
full_nm3u8dlre_path=self.full_nm3u8dlre_path,
|
||||
full_ffmpeg_path=self.full_ffmpeg_path,
|
||||
)
|
||||
|
||||
def get_temp_path(
|
||||
self,
|
||||
media_id: str,
|
||||
folder_tag: str,
|
||||
file_tag: str,
|
||||
file_extension: str,
|
||||
) -> str:
|
||||
log = logger.bind(action="get_temp_path")
|
||||
|
||||
temp_path = str(
|
||||
Path(self.temp_path)
|
||||
/ TEMP_PATH_TEMPLATE.format(folder_tag)
|
||||
/ (f"{media_id}_{file_tag}" + file_extension)
|
||||
)
|
||||
|
||||
log.debug("success", temp_path=temp_path)
|
||||
|
||||
return temp_path
|
||||
|
||||
def _sanitize_string(
|
||||
self,
|
||||
dirty_string: str,
|
||||
file_ext: str = None,
|
||||
) -> str:
|
||||
sanitized_string = re.sub(
|
||||
ILLEGAL_CHARS_RE,
|
||||
ILLEGAL_CHAR_REPLACEMENT,
|
||||
dirty_string,
|
||||
)
|
||||
|
||||
if file_ext is None:
|
||||
sanitized_string = sanitized_string[: self.truncate]
|
||||
if sanitized_string.endswith("."):
|
||||
sanitized_string = sanitized_string[:-1] + ILLEGAL_CHAR_REPLACEMENT
|
||||
else:
|
||||
if self.truncate is not None:
|
||||
sanitized_string = sanitized_string[: self.truncate - len(file_ext)]
|
||||
sanitized_string += file_ext
|
||||
|
||||
return sanitized_string.strip()
|
||||
|
||||
def get_final_path(
|
||||
self,
|
||||
tags: MediaTags,
|
||||
file_extension: str,
|
||||
playlist_tags: PlaylistTags | None,
|
||||
) -> str:
|
||||
log = logger.bind(action="get_final_path")
|
||||
|
||||
if tags.album:
|
||||
template_folder_parts = (
|
||||
self.compilation_folder_template.split("/")
|
||||
if tags.compilation
|
||||
else self.album_folder_template.split("/")
|
||||
)
|
||||
else:
|
||||
template_folder_parts = self.no_album_folder_template.split("/")
|
||||
|
||||
if tags.album:
|
||||
template_file_parts = (
|
||||
self.multi_disc_file_template.split("/")
|
||||
if isinstance(tags.disc_total, int) and tags.disc_total > 1
|
||||
else self.single_disc_file_template.split("/")
|
||||
)
|
||||
else:
|
||||
template_file_parts = self.no_album_file_template.split("/")
|
||||
|
||||
template_parts = template_folder_parts + template_file_parts
|
||||
formatted_parts = []
|
||||
|
||||
for i, part in enumerate(template_parts):
|
||||
is_folder = i < len(template_parts) - 1
|
||||
formatted_part = CustomStringFormatter().format(
|
||||
part,
|
||||
album=(tags.album, "Unknown Album"),
|
||||
album_artist=(tags.album_artist, "Unknown Artist"),
|
||||
album_id=(tags.album_id, "Unknown Album ID"),
|
||||
artist=(tags.artist, "Unknown Artist"),
|
||||
artist_id=(tags.artist_id, "Unknown Artist ID"),
|
||||
composer=(tags.composer, "Unknown Composer"),
|
||||
composer_id=(tags.composer_id, "Unknown Composer ID"),
|
||||
date=(tags.date, "Unknown Date"),
|
||||
disc=(tags.disc, ""),
|
||||
disc_total=(tags.disc_total, ""),
|
||||
media_type=(tags.media_type, "Unknown Media Type"),
|
||||
playlist_artist=(
|
||||
(playlist_tags.artist if playlist_tags else None),
|
||||
"Unknown Playlist Artist",
|
||||
),
|
||||
playlist_id=(
|
||||
(playlist_tags.playlist_id if playlist_tags else None),
|
||||
"Unknown Playlist ID",
|
||||
),
|
||||
playlist_title=(
|
||||
(playlist_tags.title if playlist_tags else None),
|
||||
"Unknown Playlist Title",
|
||||
),
|
||||
playlist_track=(
|
||||
(playlist_tags.track if playlist_tags else None),
|
||||
"",
|
||||
),
|
||||
title=(tags.title, "Unknown Title"),
|
||||
title_id=(tags.title_id, "Unknown Title ID"),
|
||||
track=(tags.track, ""),
|
||||
track_total=(tags.track_total, ""),
|
||||
)
|
||||
sanitized_formatted_part = self._sanitize_string(
|
||||
formatted_part,
|
||||
file_extension if not is_folder else None,
|
||||
)
|
||||
formatted_parts.append(sanitized_formatted_part)
|
||||
|
||||
final_path = str(Path(self.output_path, *formatted_parts))
|
||||
|
||||
log.debug("success", final_path=final_path)
|
||||
|
||||
return final_path
|
||||
|
||||
async def download_stream(
|
||||
self,
|
||||
stream_url: str,
|
||||
download_path: str,
|
||||
):
|
||||
log = logger.bind(
|
||||
action="download_stream", stream_url=stream_url, download_path=download_path
|
||||
)
|
||||
|
||||
stream_url_stripped = stream_url.split("?")[0]
|
||||
|
||||
if (
|
||||
self.download_mode == DownloadMode.YTDLP
|
||||
or not stream_url_stripped.endswith(".m3u8")
|
||||
):
|
||||
await self._download_ytdlp_async(
|
||||
stream_url,
|
||||
download_path,
|
||||
)
|
||||
|
||||
elif self.download_mode == DownloadMode.NM3U8DLRE:
|
||||
await self._download_nm3u8dlre(stream_url, download_path)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
async def _download_ytdlp_async(
|
||||
self,
|
||||
stream_url: str,
|
||||
download_path: str,
|
||||
) -> None:
|
||||
ctx = multiprocessing.get_context()
|
||||
result_queue = ctx.Queue()
|
||||
process = ctx.Process(
|
||||
target=_download_ytdlp_process,
|
||||
args=(stream_url, download_path, self.silent, result_queue),
|
||||
)
|
||||
process.start()
|
||||
|
||||
try:
|
||||
while process.is_alive():
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
process.join()
|
||||
|
||||
try:
|
||||
status, error_repr, error_traceback = result_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
status = None
|
||||
|
||||
if status == "error":
|
||||
raise RuntimeError(
|
||||
f"yt-dlp failed: {error_repr}\n{error_traceback}"
|
||||
) from None
|
||||
|
||||
if process.exitcode != 0:
|
||||
raise RuntimeError(f"yt-dlp exited with code {process.exitcode}")
|
||||
finally:
|
||||
if process.is_alive():
|
||||
process.terminate()
|
||||
await asyncio.to_thread(process.join, 5)
|
||||
if process.is_alive():
|
||||
process.kill()
|
||||
await asyncio.to_thread(process.join)
|
||||
process.close()
|
||||
|
||||
async def _download_nm3u8dlre(self, stream_url: str, download_path: str):
|
||||
download_path_obj = Path(download_path)
|
||||
|
||||
download_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
||||
await async_subprocess(
|
||||
self.full_nm3u8dlre_path,
|
||||
stream_url,
|
||||
"--binary-merge",
|
||||
"--no-log",
|
||||
"--log-level",
|
||||
"off",
|
||||
"--ffmpeg-binary-path",
|
||||
self.full_ffmpeg_path,
|
||||
"--save-name",
|
||||
download_path_obj.stem,
|
||||
"--save-dir",
|
||||
download_path_obj.parent,
|
||||
"--tmp-dir",
|
||||
download_path_obj.parent,
|
||||
silent=self.silent,
|
||||
)
|
||||
|
||||
async def apply_tags(
|
||||
self,
|
||||
media_path: str,
|
||||
tags: MediaTags,
|
||||
cover_bytes: bytes | None,
|
||||
):
|
||||
log = logger.bind(action="apply_tags", media_path=media_path)
|
||||
|
||||
exclude_tags = self.exclude_tags or []
|
||||
|
||||
filtered_tags = MediaTags(
|
||||
**{
|
||||
k: v
|
||||
for k, v in tags.__dict__.items()
|
||||
if v is not None and k not in exclude_tags
|
||||
}
|
||||
)
|
||||
mp4_tags = filtered_tags.as_mp4_tags(self.date_tag_template)
|
||||
|
||||
skip_tagging = "all" in exclude_tags
|
||||
|
||||
await asyncio.to_thread(
|
||||
self._apply_mp4_tags,
|
||||
media_path,
|
||||
mp4_tags,
|
||||
cover_bytes,
|
||||
skip_tagging,
|
||||
)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
def _apply_mp4_tags(
|
||||
self,
|
||||
media_path: str,
|
||||
tags: dict,
|
||||
cover_bytes: bytes | None,
|
||||
skip_tagging: bool,
|
||||
):
|
||||
mp4 = MP4(media_path)
|
||||
|
||||
if not skip_tagging:
|
||||
if cover_bytes is not None:
|
||||
mp4["covr"] = [
|
||||
MP4Cover(
|
||||
data=cover_bytes,
|
||||
imageformat=(
|
||||
MP4Cover.FORMAT_JPEG
|
||||
if self.interface.base.cover_format == CoverFormat.JPG
|
||||
else MP4Cover.FORMAT_PNG
|
||||
),
|
||||
)
|
||||
]
|
||||
mp4.update(tags)
|
||||
|
||||
mp4.save()
|
||||
|
||||
async def _apply_cover(
|
||||
self,
|
||||
mp4: MP4,
|
||||
cover_bytes: bytes | None,
|
||||
) -> None:
|
||||
if cover_bytes is None:
|
||||
return
|
||||
|
||||
mp4["covr"] = [
|
||||
MP4Cover(
|
||||
data=cover_bytes,
|
||||
imageformat=(
|
||||
MP4Cover.FORMAT_JPEG
|
||||
if self.interface.base.cover_format == CoverFormat.JPG
|
||||
else MP4Cover.FORMAT_PNG
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
def get_playlist_file_path(
|
||||
self,
|
||||
tags: PlaylistTags,
|
||||
) -> str:
|
||||
log = logger.bind(action="get_playlist_file_path")
|
||||
|
||||
template_folder_parts = self.playlist_folder_template.split("/")
|
||||
template_file_parts = self.playlist_file_template.split("/")
|
||||
template_parts = template_folder_parts + template_file_parts
|
||||
formatted_parts = []
|
||||
|
||||
for i, part in enumerate(template_parts):
|
||||
is_folder = i < len(template_parts) - 1
|
||||
formatted_part = CustomStringFormatter().format(
|
||||
part,
|
||||
playlist_artist=(tags.artist, "Unknown Playlist Artist"),
|
||||
playlist_id=(tags.playlist_id, "Unknown Playlist ID"),
|
||||
playlist_title=(tags.title, "Unknown Playlist Title"),
|
||||
playlist_track=(tags.track, ""),
|
||||
)
|
||||
file_ext = None if is_folder else ".m3u"
|
||||
sanitized_formatted_part = self._sanitize_string(
|
||||
formatted_part,
|
||||
file_ext,
|
||||
)
|
||||
formatted_parts.append(sanitized_formatted_part)
|
||||
|
||||
final_path = str(Path(self.output_path, *formatted_parts))
|
||||
|
||||
log.debug("success", playlist_file_path=final_path)
|
||||
|
||||
return final_path
|
||||
@@ -0,0 +1,3 @@
|
||||
TEMP_PATH_TEMPLATE = "gamdl_temp_{}"
|
||||
ILLEGAL_CHARS_RE = r'[\\/:*?"<>|;]'
|
||||
ILLEGAL_CHAR_REPLACEMENT = "_"
|
||||
@@ -0,0 +1,254 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import structlog
|
||||
|
||||
from ..interface.types import AppleMusicMedia
|
||||
from .constants import TEMP_PATH_TEMPLATE
|
||||
from .enums import DownloadMode
|
||||
from .exceptions import (
|
||||
GamdlDownloaderDependencyNotFoundError,
|
||||
GamdlDownloaderMediaFileExistsError,
|
||||
GamdlDownloaderSyncedLyricsOnlyError,
|
||||
)
|
||||
from .music_video import AppleMusicMusicVideoDownloader
|
||||
from .song import AppleMusicSongDownloader
|
||||
from .types import DownloadItem
|
||||
from .uploaded_video import AppleMusicUploadedVideoDownloader
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
song: AppleMusicSongDownloader,
|
||||
music_video: AppleMusicMusicVideoDownloader,
|
||||
uploaded_video: AppleMusicUploadedVideoDownloader,
|
||||
overwrite: bool = False,
|
||||
save_cover: bool = False,
|
||||
save_playlist: bool = False,
|
||||
no_synced_lyrics: bool = False,
|
||||
synced_lyrics_only: bool = False,
|
||||
skip_cleanup: bool = False,
|
||||
skip_processing: bool = False,
|
||||
):
|
||||
self.song = song
|
||||
self.music_video = music_video
|
||||
self.uploaded_video = uploaded_video
|
||||
self.overwrite = overwrite
|
||||
self.save_cover = save_cover
|
||||
self.save_playlist = save_playlist
|
||||
self.no_synced_lyrics = no_synced_lyrics
|
||||
self.synced_lyrics_only = synced_lyrics_only
|
||||
self.skip_cleanup = skip_cleanup
|
||||
self.skip_processing = skip_processing
|
||||
|
||||
self.base = song.base
|
||||
|
||||
async def get_download_item_from_url(
|
||||
self,
|
||||
url: str,
|
||||
) -> AsyncGenerator[DownloadItem, None]:
|
||||
async for media in self.base.interface.get_media_from_url(url):
|
||||
yield await self.parse_download_item(media)
|
||||
|
||||
async def parse_download_item(
|
||||
self,
|
||||
media: AppleMusicMedia,
|
||||
) -> DownloadItem:
|
||||
if media.error:
|
||||
return DownloadItem(media)
|
||||
|
||||
if media.partial:
|
||||
return DownloadItem(media)
|
||||
|
||||
elif media.media_metadata["type"] in {"songs", "library-songs"}:
|
||||
return await self.song.get_download_item(media)
|
||||
|
||||
elif media.media_metadata["type"] in {
|
||||
"music-videos",
|
||||
"library-music-videos",
|
||||
}:
|
||||
return await self.music_video.get_download_item(media)
|
||||
|
||||
elif media.media_metadata["type"] in {"uploaded-videos"}:
|
||||
return await self.uploaded_video.get_download_item(media)
|
||||
|
||||
async def download(self, item: DownloadItem) -> None:
|
||||
try:
|
||||
if item.media.error:
|
||||
raise item.media.error
|
||||
|
||||
if item.media.partial:
|
||||
return
|
||||
|
||||
await self._initial_processing(item)
|
||||
await self._download(item)
|
||||
await self._final_processing(item)
|
||||
finally:
|
||||
if not self.skip_cleanup:
|
||||
self._cleanup_temp(item.uuid_)
|
||||
|
||||
def _update_playlist_file(
|
||||
self,
|
||||
playlist_file_path: str,
|
||||
final_path: str,
|
||||
playlist_track: int,
|
||||
) -> None:
|
||||
log = logger.bind(
|
||||
action="update_playlist_file",
|
||||
playlist_file_path=playlist_file_path,
|
||||
final_path=final_path,
|
||||
playlist_track=playlist_track,
|
||||
)
|
||||
|
||||
playlist_file_path_obj = Path(playlist_file_path)
|
||||
final_path_obj = Path(final_path)
|
||||
output_dir_obj = Path(self.base.output_path)
|
||||
|
||||
playlist_file_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
||||
playlist_file_path_parent_parts_len = len(playlist_file_path_obj.parent.parts)
|
||||
output_path_parts_len = len(output_dir_obj.parts)
|
||||
|
||||
final_path_relative = Path(
|
||||
("../" * (playlist_file_path_parent_parts_len - output_path_parts_len)),
|
||||
*final_path_obj.parts[output_path_parts_len:],
|
||||
)
|
||||
playlist_file_lines = (
|
||||
playlist_file_path_obj.open("r", encoding="utf8").readlines()
|
||||
if playlist_file_path_obj.exists()
|
||||
else []
|
||||
)
|
||||
if len(playlist_file_lines) < playlist_track:
|
||||
playlist_file_lines.extend(
|
||||
"\n" for _ in range(playlist_track - len(playlist_file_lines))
|
||||
)
|
||||
|
||||
playlist_file_lines[playlist_track - 1] = final_path_relative.as_posix() + "\n"
|
||||
with playlist_file_path_obj.open("w", encoding="utf8") as playlist_file:
|
||||
playlist_file.writelines(playlist_file_lines)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
def _write_cover(self, cover_path: str, cover_bytes: bytes) -> None:
|
||||
log = logger.bind(action="write_cover_file", cover_path=cover_path)
|
||||
|
||||
Path(cover_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(cover_path, "wb") as f:
|
||||
f.write(cover_bytes)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
def _write_synced_lyrics(self, synced_lyrics_path: str, lyrics: str) -> None:
|
||||
log = logger.bind(
|
||||
action="write_synced_lyrics",
|
||||
synced_lyrics_path=synced_lyrics_path,
|
||||
)
|
||||
|
||||
Path(synced_lyrics_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(synced_lyrics_path, "w", encoding="utf-8") as f:
|
||||
f.write(lyrics)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
async def _initial_processing(self, item: DownloadItem) -> None:
|
||||
if self.skip_processing:
|
||||
return
|
||||
|
||||
if item.playlist_file_path and item.final_path and self.save_playlist:
|
||||
self._update_playlist_file(
|
||||
item.playlist_file_path,
|
||||
item.final_path,
|
||||
item.media.playlist_tags.track,
|
||||
)
|
||||
|
||||
if item.cover_path and self.save_cover and item.media.cover.url:
|
||||
cover_bytes = await self.base.interface.base.get_cover_bytes(
|
||||
item.media.cover.url,
|
||||
)
|
||||
if cover_bytes and (self.overwrite or not Path(item.cover_path).exists()):
|
||||
self._write_cover(
|
||||
item.cover_path,
|
||||
cover_bytes,
|
||||
)
|
||||
|
||||
if (
|
||||
item.synced_lyrics_path
|
||||
and not self.no_synced_lyrics
|
||||
and item.media.lyrics
|
||||
and item.media.lyrics.synced
|
||||
and (self.overwrite or not Path(item.synced_lyrics_path).exists())
|
||||
):
|
||||
self._write_synced_lyrics(
|
||||
item.synced_lyrics_path,
|
||||
item.media.lyrics.synced,
|
||||
)
|
||||
|
||||
async def _download(self, item: DownloadItem) -> None:
|
||||
if item.media.error:
|
||||
raise item.media.error
|
||||
|
||||
if self.synced_lyrics_only:
|
||||
raise GamdlDownloaderSyncedLyricsOnlyError()
|
||||
|
||||
if Path(item.final_path).exists() and not self.overwrite:
|
||||
raise GamdlDownloaderMediaFileExistsError(item.final_path)
|
||||
|
||||
if item.media.media_metadata["type"] in {
|
||||
"music-videos",
|
||||
"library-music-videos",
|
||||
"songs",
|
||||
"library-songs",
|
||||
}:
|
||||
if (
|
||||
self.base.download_mode == DownloadMode.NM3U8DLRE
|
||||
and not self.base.full_nm3u8dlre_path
|
||||
):
|
||||
raise GamdlDownloaderDependencyNotFoundError("N_m3u8DL-RE")
|
||||
|
||||
if item.media.media_metadata["type"] in {"songs", "library-songs"}:
|
||||
await self.song.download(item)
|
||||
|
||||
elif item.media.media_metadata["type"] in {
|
||||
"music-videos",
|
||||
"library-music-videos",
|
||||
}:
|
||||
await self.music_video.download(item)
|
||||
|
||||
elif item.media.media_metadata["type"] in {"uploaded-videos"}:
|
||||
await self.uploaded_video.download(item)
|
||||
|
||||
def _move_to_final_path(self, staged_path: str, final_path: str) -> None:
|
||||
log = logger.bind(
|
||||
action="move_to_final_path",
|
||||
staged_path=staged_path,
|
||||
final_path=final_path,
|
||||
)
|
||||
|
||||
Path(final_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(staged_path, final_path)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
async def _final_processing(
|
||||
self,
|
||||
item: DownloadItem,
|
||||
) -> None:
|
||||
if self.skip_processing:
|
||||
return
|
||||
|
||||
if Path(item.staged_path).exists():
|
||||
self._move_to_final_path(
|
||||
item.staged_path,
|
||||
item.final_path,
|
||||
)
|
||||
|
||||
def _cleanup_temp(self, folder_tag: str) -> None:
|
||||
log = logger.bind(action="cleanup_temp", folder_tag=folder_tag)
|
||||
|
||||
temp_path = Path(self.base.temp_path) / TEMP_PATH_TEMPLATE.format(folder_tag)
|
||||
if temp_path.exists() and temp_path.is_dir():
|
||||
shutil.rmtree(temp_path, ignore_errors=True)
|
||||
log.debug("success")
|
||||
@@ -0,0 +1,16 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class DownloadMode(Enum):
|
||||
YTDLP = "ytdlp"
|
||||
NM3U8DLRE = "nm3u8dlre"
|
||||
|
||||
|
||||
class RemuxMode(Enum):
|
||||
FFMPEG = "ffmpeg"
|
||||
MP4BOX = "mp4box"
|
||||
|
||||
|
||||
class RemuxFormatMusicVideo(Enum):
|
||||
M4V = "m4v"
|
||||
MP4 = "mp4"
|
||||
@@ -0,0 +1,20 @@
|
||||
from ..utils import GamdlError
|
||||
|
||||
|
||||
class GamdlDownloaderError(GamdlError):
|
||||
pass
|
||||
|
||||
|
||||
class GamdlDownloaderSyncedLyricsOnlyError(GamdlDownloaderError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Download mode is set to synced lyrics only")
|
||||
|
||||
|
||||
class GamdlDownloaderMediaFileExistsError(GamdlDownloaderError):
|
||||
def __init__(self, file_path: str) -> None:
|
||||
super().__init__(f"Media file already exists: {file_path}")
|
||||
|
||||
|
||||
class GamdlDownloaderDependencyNotFoundError(GamdlDownloaderError):
|
||||
def __init__(self, dependency_name: str) -> None:
|
||||
super().__init__(f"Required dependency not found: {dependency_name}")
|
||||
@@ -0,0 +1,123 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ..interface.enums import CoverFormat
|
||||
from ..interface.types import AppleMusicMedia, DecryptionKeyAv
|
||||
from .amdecrypt import decrypt_file_hex, write_decrypted_media
|
||||
from .base import AppleMusicBaseDownloader
|
||||
from .enums import RemuxFormatMusicVideo, RemuxMode
|
||||
from .types import DownloadItem
|
||||
|
||||
|
||||
class AppleMusicMusicVideoDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
base: AppleMusicBaseDownloader,
|
||||
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
|
||||
):
|
||||
self.base = base
|
||||
self.remux_format = remux_format
|
||||
|
||||
async def stage(
|
||||
self,
|
||||
encrypted_path_video: str,
|
||||
encrypted_path_audio: str,
|
||||
staged_path: str,
|
||||
decryption_key: DecryptionKeyAv,
|
||||
is_m4v: bool = False,
|
||||
):
|
||||
decrypted_media = await decrypt_file_hex(
|
||||
decryption_key.audio_track.key,
|
||||
encrypted_path_audio,
|
||||
decryption_key.video_track.key,
|
||||
encrypted_path_video,
|
||||
)
|
||||
await write_decrypted_media(
|
||||
decrypted_media,
|
||||
staged_path,
|
||||
m4v_brand=is_m4v,
|
||||
)
|
||||
|
||||
def get_cover_path(
|
||||
self,
|
||||
final_path: str,
|
||||
file_extension: str,
|
||||
) -> str:
|
||||
return str(Path(final_path).with_suffix(file_extension))
|
||||
|
||||
async def get_download_item(
|
||||
self,
|
||||
media: AppleMusicMedia,
|
||||
) -> DownloadItem:
|
||||
download_item = DownloadItem(media)
|
||||
|
||||
download_item.staged_path = self.base.get_temp_path(
|
||||
media.media_metadata["id"],
|
||||
download_item.uuid_,
|
||||
"staged",
|
||||
"." + media.stream_info.file_format.value,
|
||||
)
|
||||
|
||||
download_item.final_path = self.base.get_final_path(
|
||||
media.tags,
|
||||
"." + media.stream_info.file_format.value,
|
||||
media.playlist_tags,
|
||||
)
|
||||
|
||||
if media.playlist_tags:
|
||||
download_item.playlist_file_path = self.base.get_playlist_file_path(
|
||||
media.playlist_tags,
|
||||
)
|
||||
|
||||
download_item.cover_path = self.get_cover_path(
|
||||
download_item.final_path,
|
||||
media.cover.file_extension,
|
||||
)
|
||||
|
||||
return download_item
|
||||
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
encrypted_path_video = self.base.get_temp_path(
|
||||
download_item.media.media_metadata["id"],
|
||||
download_item.uuid_,
|
||||
"encrypted_video",
|
||||
".mp4",
|
||||
)
|
||||
encrypted_path_audio = self.base.get_temp_path(
|
||||
download_item.media.media_metadata["id"],
|
||||
download_item.uuid_,
|
||||
"encrypted_audio",
|
||||
".m4a",
|
||||
)
|
||||
|
||||
await self.base.download_stream(
|
||||
download_item.media.stream_info.video_track.stream_url,
|
||||
encrypted_path_video,
|
||||
)
|
||||
await self.base.download_stream(
|
||||
download_item.media.stream_info.audio_track.stream_url,
|
||||
encrypted_path_audio,
|
||||
)
|
||||
|
||||
await self.stage(
|
||||
encrypted_path_video,
|
||||
encrypted_path_audio,
|
||||
download_item.staged_path,
|
||||
download_item.media.decryption_key,
|
||||
download_item.staged_path.endswith(".m4v"),
|
||||
)
|
||||
|
||||
cover_bytes = (
|
||||
await self.base.interface.base.get_cover_bytes(
|
||||
download_item.media.cover.url
|
||||
)
|
||||
if self.base.interface.base.cover_format != CoverFormat.RAW
|
||||
else None
|
||||
)
|
||||
await self.base.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media.tags,
|
||||
cover_bytes,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from ..interface.types import AppleMusicMedia
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadItem:
|
||||
media: AppleMusicMedia
|
||||
uuid_: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
|
||||
staged_path: str = None
|
||||
final_path: str = None
|
||||
playlist_file_path: str = None
|
||||
synced_lyrics_path: str = None
|
||||
cover_path: str = None
|
||||
@@ -0,0 +1,65 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ..interface.enums import CoverFormat
|
||||
from ..interface.types import AppleMusicMedia
|
||||
from .base import AppleMusicBaseDownloader
|
||||
from .types import DownloadItem
|
||||
|
||||
|
||||
class AppleMusicUploadedVideoDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
base: AppleMusicBaseDownloader,
|
||||
):
|
||||
self.base = base
|
||||
|
||||
def get_cover_path(self, final_path: str, file_extension: str) -> str:
|
||||
return str(Path(final_path).with_suffix(file_extension))
|
||||
|
||||
async def get_download_item(
|
||||
self,
|
||||
media: AppleMusicMedia,
|
||||
) -> DownloadItem:
|
||||
download_item = DownloadItem(media)
|
||||
|
||||
download_item.staged_path = self.base.get_temp_path(
|
||||
media.media_metadata["id"],
|
||||
download_item.uuid_,
|
||||
"staged",
|
||||
"." + media.stream_info.file_format.value,
|
||||
)
|
||||
|
||||
download_item.final_path = self.base.get_final_path(
|
||||
media.tags,
|
||||
"." + media.stream_info.file_format.value,
|
||||
media.playlist_tags,
|
||||
)
|
||||
|
||||
download_item.cover_path = self.get_cover_path(
|
||||
download_item.final_path,
|
||||
media.cover.file_extension,
|
||||
)
|
||||
|
||||
return download_item
|
||||
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
await self.base.download_stream(
|
||||
download_item.media.stream_info.video_track.stream_url,
|
||||
download_item.staged_path,
|
||||
)
|
||||
|
||||
cover_bytes = (
|
||||
await self.base.interface.base.get_cover_bytes(
|
||||
download_item.media.cover.url
|
||||
)
|
||||
if self.base.interface.base.cover_format != CoverFormat.RAW
|
||||
else None
|
||||
)
|
||||
await self.base.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media.tags,
|
||||
cover_bytes,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
from .base import AppleMusicBaseInterface
|
||||
from .enums import *
|
||||
from .exceptions import *
|
||||
from .interface import AppleMusicInterface
|
||||
from .music_video import AppleMusicMusicVideoInterface
|
||||
from .song import AppleMusicSongInterface
|
||||
from .types import *
|
||||
from .uploaded_video import AppleMusicUploadedVideoInterface
|
||||
@@ -0,0 +1,407 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime
|
||||
import re
|
||||
from io import BytesIO
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from async_lru import alru_cache
|
||||
from PIL import Image
|
||||
from pywidevine import PSSH, Cdm, Device
|
||||
from pywidevine.license_protocol_pb2 import WidevinePsshData
|
||||
|
||||
from gamdl.interface.wvd import WVD
|
||||
|
||||
from ..api.apple_music import AppleMusicApi
|
||||
from ..api.itunes import ItunesApi
|
||||
from ..api.wrapper import WrapperApi
|
||||
from .constants import IMAGE_FILE_EXTENSION_MAP
|
||||
from .enums import CoverFormat
|
||||
from .types import Cover, DecryptionKey, MediaRating, MediaTags, MediaType, PlaylistTags
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicBaseInterface:
|
||||
def __init__(
|
||||
self,
|
||||
apple_music_api: AppleMusicApi,
|
||||
itunes_api: ItunesApi,
|
||||
wrapper_api: WrapperApi | None,
|
||||
cover_format: CoverFormat,
|
||||
cover_size: int,
|
||||
cdm: Cdm,
|
||||
) -> None:
|
||||
self.apple_music_api = apple_music_api
|
||||
self.itunes_api = itunes_api
|
||||
self.cover_format = cover_format
|
||||
self.cover_size = cover_size
|
||||
self.cdm = cdm
|
||||
self.wrapper_api = wrapper_api
|
||||
|
||||
@staticmethod
|
||||
def create_cdm(wvd_path: str | None = None) -> Cdm:
|
||||
if wvd_path:
|
||||
cdm = Cdm.from_device(Device.load(wvd_path))
|
||||
else:
|
||||
cdm = Cdm.from_device(Device.loads(WVD))
|
||||
cdm.MAX_NUM_OF_SESSIONS = float("inf")
|
||||
|
||||
return cdm
|
||||
|
||||
@staticmethod
|
||||
def is_media_streamable(
|
||||
media_metadata: dict,
|
||||
) -> bool:
|
||||
return bool(media_metadata["attributes"].get("playParams"))
|
||||
|
||||
@staticmethod
|
||||
def parse_media_id_from_url(media_metadata: dict) -> str | None:
|
||||
media_url = media_metadata["attributes"].get("url")
|
||||
if media_url is None:
|
||||
return None
|
||||
|
||||
url_media_id = media_url.split("/")[-1].split("?")[0]
|
||||
|
||||
return url_media_id
|
||||
|
||||
@staticmethod
|
||||
def parse_date(date: str) -> datetime.datetime:
|
||||
return datetime.datetime.fromisoformat(date.split("Z")[0])
|
||||
|
||||
@staticmethod
|
||||
def reconstruct_pssh(pssh: str) -> bytes:
|
||||
pssh = pssh.split(",")[-1]
|
||||
|
||||
decoded_pssh = base64.b64decode(pssh)
|
||||
if len(decoded_pssh) > 30:
|
||||
return pssh
|
||||
|
||||
widevine_pssh_data = WidevinePsshData(
|
||||
algorithm=1,
|
||||
key_ids=[decoded_pssh],
|
||||
)
|
||||
|
||||
return widevine_pssh_data.SerializeToString()
|
||||
|
||||
@staticmethod
|
||||
async def get_response(
|
||||
url: str,
|
||||
valid_responses: list[int] = [200],
|
||||
) -> httpx.Response:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
try:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code in valid_responses:
|
||||
return e.response
|
||||
raise e
|
||||
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def format_cover(
|
||||
template_cover_url: str,
|
||||
cover_size: int,
|
||||
cover_format: CoverFormat,
|
||||
) -> str:
|
||||
return re.sub(
|
||||
r"/\{w\}x\{h\}([a-z]{2})\.jpg",
|
||||
f"/{cover_size}x{cover_size}bb.{cover_format.value}",
|
||||
template_cover_url,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_catalog_metadata_from_library(library_metadata: dict) -> dict | None:
|
||||
data = library_metadata.get("relationships", {}).get("catalog", {}).get("data")
|
||||
if not data:
|
||||
return None
|
||||
|
||||
return data[0]
|
||||
|
||||
@classmethod
|
||||
async def create(
|
||||
cls,
|
||||
apple_music_api: AppleMusicApi,
|
||||
cover_format: CoverFormat = CoverFormat.JPG,
|
||||
cover_size: int = 1200,
|
||||
wvd_path: str | None = None,
|
||||
itunes_api: ItunesApi | None = None,
|
||||
wrapper_api: WrapperApi | None = None,
|
||||
):
|
||||
itunes_api = itunes_api or await ItunesApi.create(
|
||||
storefront=apple_music_api.storefront,
|
||||
language=apple_music_api.language,
|
||||
**(
|
||||
{"storefront_id": None}
|
||||
if apple_music_api.storefront.lower() != "us"
|
||||
else {}
|
||||
),
|
||||
)
|
||||
cdm = cls.create_cdm(wvd_path)
|
||||
|
||||
base = cls(
|
||||
apple_music_api=apple_music_api,
|
||||
itunes_api=itunes_api,
|
||||
cover_format=cover_format,
|
||||
cover_size=cover_size,
|
||||
cdm=cdm,
|
||||
wrapper_api=wrapper_api,
|
||||
)
|
||||
return base
|
||||
|
||||
@alru_cache()
|
||||
async def get_album_cached(
|
||||
self,
|
||||
album_id: int,
|
||||
) -> dict | None:
|
||||
return (await self.apple_music_api.get_album(album_id))["data"][0]
|
||||
|
||||
async def get_decryption_key(
|
||||
self,
|
||||
pssh: str,
|
||||
track_id: str,
|
||||
) -> DecryptionKey:
|
||||
log = logger.bind(action="get_decryption_key", track_id=track_id)
|
||||
|
||||
reconstructed_pssh = self.reconstruct_pssh(pssh)
|
||||
cdm_session = self.cdm.open()
|
||||
|
||||
try:
|
||||
pssh_obj = PSSH(reconstructed_pssh)
|
||||
|
||||
challenge = base64.b64encode(
|
||||
await asyncio.to_thread(
|
||||
self.cdm.get_license_challenge, cdm_session, pssh_obj
|
||||
)
|
||||
).decode()
|
||||
license = await self.apple_music_api.get_license_exchange(
|
||||
track_id,
|
||||
pssh,
|
||||
challenge,
|
||||
)
|
||||
|
||||
await asyncio.to_thread(
|
||||
self.cdm.parse_license, cdm_session, license["license"]
|
||||
)
|
||||
decryption_key_info = next(
|
||||
i for i in self.cdm.get_keys(cdm_session) if i.type == "CONTENT"
|
||||
)
|
||||
finally:
|
||||
self.cdm.close(cdm_session)
|
||||
|
||||
decryption_key = DecryptionKey(
|
||||
key=decryption_key_info.key.hex(),
|
||||
kid=decryption_key_info.kid.hex,
|
||||
)
|
||||
|
||||
log.debug("success", decryption_key=decryption_key)
|
||||
|
||||
return decryption_key
|
||||
|
||||
@alru_cache()
|
||||
async def get_cover_bytes(self, cover_url: str) -> bytes | None:
|
||||
log = logger.bind(action="get_cover_bytes", cover_url=cover_url)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(cover_url, follow_redirects=True)
|
||||
|
||||
if response.status_code == 404:
|
||||
log.debug("cover_not_found")
|
||||
return None
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
return response.content
|
||||
|
||||
def _get_cover_template_url(self, metadata: dict) -> str:
|
||||
if self.cover_format == CoverFormat.RAW:
|
||||
cover_template_url = self._get_raw_cover_url(
|
||||
metadata["attributes"]["artwork"]["url"]
|
||||
)
|
||||
else:
|
||||
cover_template_url = metadata["attributes"]["artwork"]["url"]
|
||||
|
||||
return cover_template_url
|
||||
|
||||
def _get_raw_cover_url(self, cover_url_template: str) -> str:
|
||||
return re.sub(
|
||||
r"/\{w\}x\{h\}bb\.jpg",
|
||||
"",
|
||||
re.sub(
|
||||
r"image/thumb/",
|
||||
"",
|
||||
re.sub(
|
||||
r"is1-ssl",
|
||||
"a1",
|
||||
cover_url_template,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@alru_cache()
|
||||
async def _get_cover_file_extension(
|
||||
self,
|
||||
cover_url: str,
|
||||
) -> str | None:
|
||||
log = logger.bind(action="get_cover_file_extension", cover_url=cover_url)
|
||||
if self.cover_format != CoverFormat.RAW:
|
||||
return f".{self.cover_format.value}"
|
||||
|
||||
cover_bytes = await self.get_cover_bytes(cover_url)
|
||||
if cover_bytes is None:
|
||||
log.debug("cover_bytes_empty")
|
||||
return None
|
||||
|
||||
image_obj = Image.open(BytesIO(cover_bytes))
|
||||
image_format = image_obj.format.lower()
|
||||
return IMAGE_FILE_EXTENSION_MAP.get(
|
||||
image_format,
|
||||
f".{image_format.lower()}",
|
||||
)
|
||||
|
||||
async def get_cover(
|
||||
self,
|
||||
metadata: dict,
|
||||
) -> str:
|
||||
log = logger.bind(action="get_cover", media_id=metadata["id"])
|
||||
|
||||
template_url = self._get_cover_template_url(metadata)
|
||||
|
||||
if self.cover_format == CoverFormat.RAW:
|
||||
cover_url = template_url
|
||||
else:
|
||||
cover_url = self.format_cover(
|
||||
template_url,
|
||||
self.cover_size,
|
||||
self.cover_format,
|
||||
)
|
||||
|
||||
cover_file_extension = await self._get_cover_file_extension(cover_url)
|
||||
|
||||
cover = Cover(
|
||||
template_url=template_url,
|
||||
url=cover_url,
|
||||
file_extension=cover_file_extension,
|
||||
)
|
||||
|
||||
log.debug("success", cover=cover)
|
||||
|
||||
return cover
|
||||
|
||||
@alru_cache()
|
||||
async def get_media_date(
|
||||
self,
|
||||
media_id: str,
|
||||
) -> datetime.datetime | None:
|
||||
log = logger.bind(action="get_media_date", media_id=media_id)
|
||||
|
||||
lookup_result = await self.itunes_api.get_lookup_result(media_id)
|
||||
if not lookup_result["results"]:
|
||||
log.debug("no_media_id")
|
||||
return None
|
||||
|
||||
release_date = lookup_result["results"][0].get("releaseDate")
|
||||
if not release_date:
|
||||
log.debug("no_release_date")
|
||||
return None
|
||||
|
||||
parsed_date = self.parse_date(release_date)
|
||||
|
||||
log.debug("success", release_date=parsed_date)
|
||||
|
||||
return parsed_date
|
||||
|
||||
def get_playlist_tags(
|
||||
self,
|
||||
playlist_metadata: dict,
|
||||
playlist_track: int,
|
||||
) -> PlaylistTags:
|
||||
log = logger.bind(
|
||||
action="get_playlist_tags",
|
||||
playlist_id=playlist_metadata["id"],
|
||||
)
|
||||
|
||||
playlist_tags = PlaylistTags(
|
||||
artist=playlist_metadata["attributes"].get("curatorName", "Unknown"),
|
||||
playlist_id=playlist_metadata["attributes"]["playParams"]["id"],
|
||||
title=playlist_metadata["attributes"]["name"],
|
||||
track=playlist_track,
|
||||
)
|
||||
|
||||
log.debug("success", playlist_tags=playlist_tags)
|
||||
|
||||
return playlist_tags
|
||||
|
||||
async def get_tags_from_asset_info(
|
||||
self,
|
||||
asset_data: dict,
|
||||
lyrics: str | None = None,
|
||||
use_album_date: bool = False,
|
||||
) -> MediaTags:
|
||||
log = logger.bind(
|
||||
action="get_tags_from_asset_info", asset_id=asset_data["itemId"]
|
||||
)
|
||||
|
||||
date = None
|
||||
|
||||
if use_album_date:
|
||||
if asset_data.get("playlistId"):
|
||||
date = await self.get_media_date(asset_data["playlistId"])
|
||||
else:
|
||||
log.debug("no_playlist_id_for_album_date")
|
||||
|
||||
if date is None and asset_data.get("releaseDate"):
|
||||
date = self.parse_date(asset_data["releaseDate"])
|
||||
|
||||
tags = MediaTags(
|
||||
album=asset_data.get("playlistName"),
|
||||
album_artist=asset_data.get("playlistArtistName"),
|
||||
album_id=(
|
||||
int(asset_data["playlistId"]) if asset_data.get("playlistId") else None
|
||||
),
|
||||
album_sort=asset_data.get("sort-album"),
|
||||
artist=asset_data["artistName"],
|
||||
artist_id=(
|
||||
int(asset_data["artistId"]) if asset_data.get("artistId") else None
|
||||
),
|
||||
artist_sort=asset_data["sort-artist"],
|
||||
comment=asset_data.get("comments"),
|
||||
compilation=asset_data.get("compilation"),
|
||||
composer=asset_data.get("composerName"),
|
||||
composer_id=(
|
||||
int(asset_data.get("composerId"))
|
||||
if asset_data.get("composerId")
|
||||
else None
|
||||
),
|
||||
composer_sort=asset_data.get("sort-composer"),
|
||||
copyright=asset_data.get("copyright"),
|
||||
date=date,
|
||||
disc=asset_data.get("discNumber"),
|
||||
disc_total=asset_data.get("discCount"),
|
||||
gapless=asset_data.get("gapless"),
|
||||
genre=asset_data.get("genre"),
|
||||
genre_id=(
|
||||
int(asset_data["genreId"]) if asset_data.get("genreId") else None
|
||||
),
|
||||
lyrics=lyrics if lyrics else None,
|
||||
media_type=(
|
||||
MediaType.SONG
|
||||
if asset_data["kind"] == "song"
|
||||
else MediaType.MUSIC_VIDEO
|
||||
),
|
||||
rating=MediaRating(asset_data["explicit"]),
|
||||
storefront=(int(asset_data["s"]) if asset_data.get("s") else None),
|
||||
title=asset_data["itemName"],
|
||||
title_id=int(asset_data["itemId"]),
|
||||
title_sort=asset_data["sort-name"],
|
||||
track=asset_data.get("trackNumber"),
|
||||
track_total=asset_data.get("trackCount"),
|
||||
xid=asset_data.get("xid"),
|
||||
)
|
||||
|
||||
log.debug("success", tags=tags)
|
||||
|
||||
return tags
|
||||
@@ -0,0 +1,108 @@
|
||||
import re
|
||||
|
||||
MEDIA_TYPE_STR_MAP = {
|
||||
1: "Song",
|
||||
6: "Music Video",
|
||||
}
|
||||
|
||||
MEDIA_RATING_STR_MAP = {
|
||||
0: "None",
|
||||
1: "Explicit",
|
||||
2: "Clean",
|
||||
}
|
||||
|
||||
DRM_DEFAULT_KEY_MAPPING = {
|
||||
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed": (
|
||||
"data:text/plain;base64,AAAAOHBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAABgSEAAAAAA"
|
||||
"AAAAAczEvZTEgICBI88aJmwY="
|
||||
),
|
||||
"com.microsoft.playready": (
|
||||
"data:text/plain;charset=UTF-16;base64,vgEAAAEAAQC0ATwAVwBSAE0ASABFAEEARABF"
|
||||
"AFIAIAB4AG0AbABuAHMAPQAiAGgAdAB0AHAAOgAvAC8AcwBjAGgAZQBtAGEAcwAuAG0AaQBjAH"
|
||||
"IAbwBzAG8AZgB0AC4AYwBvAG0ALwBEAFIATQAvADIAMAAwADcALwAwADMALwBQAGwAYQB5AFIA"
|
||||
"ZQBhAGQAeQBIAGUAYQBkAGUAcgAiACAAdgBlAHIAcwBpAG8AbgA9ACIANAAuADMALgAwAC4AMA"
|
||||
"AiAD4APABEAEEAVABBAD4APABQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsASQBEAFMAPgA8"
|
||||
"AEsASQBEACAAQQBMAEcASQBEAD0AIgBBAEUAUwBDAEIAQwAiACAAVgBBAEwAVQBFAD0AIgBBAE"
|
||||
"EAQQBBAEEAQQBBAEEAQQBBAEIAegBNAFMAOQBsAE0AUwBBAGcASQBBAD0APQAiAD4APAAvAEsA"
|
||||
"SQBEAD4APAAvAEsASQBEAFMAPgA8AC8AUABSAE8AVABFAEMAVABJAE4ARgBPAD4APAAvAEQAQQ"
|
||||
"BUAEEAPgA8AC8AVwBSAE0ASABFAEEARABFAFIAPgA="
|
||||
),
|
||||
"com.apple.streamingkeydelivery": "skd://itunes.apple.com/P000000000/s1/e1",
|
||||
}
|
||||
MP4_FORMAT_CODECS = ["ec-3", "hvc1", "audio-atmos", "audio-ec3"]
|
||||
SONG_CODEC_REGEX_MAP = {
|
||||
"aac": r"audio-stereo-\d+",
|
||||
"aac-he": r"audio-HE-stereo-\d+",
|
||||
"aac-binaural": r"audio-stereo-\d+-binaural",
|
||||
"aac-downmix": r"audio-stereo-\d+-downmix",
|
||||
"aac-he-binaural": r"audio-HE-stereo-\d+-binaural",
|
||||
"aac-he-downmix": r"audio-HE-stereo-\d+-downmix",
|
||||
"atmos": r"audio-atmos-.*",
|
||||
"ac3": r"audio-ac3-.*",
|
||||
"alac": r"audio-alac-.*",
|
||||
"alac-16-44": r"audio-alac-stereo-1644100",
|
||||
"alac-24-44": r"audio-alac-stereo-2444100",
|
||||
"alac-24-48": r"audio-alac-stereo-2448000",
|
||||
"alac-24-96": r"audio-alac-stereo-2496000",
|
||||
"alac-24-192": r"audio-alac-stereo-24192000",
|
||||
}
|
||||
|
||||
FOURCC_MAP = {
|
||||
"h264": "avc1",
|
||||
"h265": "hvc1",
|
||||
}
|
||||
|
||||
UPLOADED_VIDEO_QUALITY_RANK = [
|
||||
"1080pHdVideo",
|
||||
"720pHdVideo",
|
||||
"sdVideoWithPlusAudio",
|
||||
"sdVideo",
|
||||
"sd480pVideo",
|
||||
"provisionalUploadVideo",
|
||||
]
|
||||
|
||||
IMAGE_FILE_EXTENSION_MAP = {
|
||||
"jpeg": ".jpg",
|
||||
"tiff": ".tif",
|
||||
}
|
||||
|
||||
VALID_URL_PATTERN = re.compile(
|
||||
r"https://(?:classical\.)?music\.apple\.com"
|
||||
r"(?:"
|
||||
r"/(?P<storefront>[a-z]{2})"
|
||||
r"/(?P<type>artist|album|playlist|song|music-video|post)"
|
||||
r"(?:/(?P<slug>[^\s/]+))?"
|
||||
r"/(?P<id>[0-9]+|pl\.[0-9a-z]{32}|pl\.u-[a-zA-Z0-9]+)"
|
||||
r"(?:\?i=(?P<sub_id>[0-9]+))?"
|
||||
r"|"
|
||||
r"(?:/(?P<library_storefront>[a-z]{2}))?"
|
||||
r"/library/(?P<library_type>playlist|albums|songs|music-videos)"
|
||||
r"/(?P<library_id>[pli]\.[a-zA-Z0-9]+)"
|
||||
r")"
|
||||
)
|
||||
|
||||
ARTIST_AUTO_SELECT_KEY_MAP = {
|
||||
"main-albums": ("views", "full-albums"),
|
||||
"compilation-albums": ("views", "compilation-albums"),
|
||||
"live-albums": ("views", "live-albums"),
|
||||
"singles-eps": ("views", "singles"),
|
||||
"all-albums": ("relationships", "albums"),
|
||||
"top-songs": ("views", "top-songs"),
|
||||
"music-videos": ("relationships", "music-videos"),
|
||||
}
|
||||
ARTIST_AUTO_SELECT_STR_MAP = {
|
||||
"main-albums": "Main Albums",
|
||||
"compilation-albums": "Compilation Albums",
|
||||
"live-albums": "Live Albums",
|
||||
"singles-eps": "Singles & EPs",
|
||||
"all-albums": "All Albums",
|
||||
"top-songs": "Top Songs",
|
||||
"music-videos": "Music Videos",
|
||||
}
|
||||
|
||||
MEDIA_CODEC_FLAVOR_MAP = {
|
||||
"aac-web": "28:ctrp256",
|
||||
"aac-he-web": "32:ctrp64",
|
||||
"aac-fps-web": "30:cbcp256",
|
||||
"aac-he-fps-web": "34:cbcp64",
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
from enum import Enum
|
||||
|
||||
from .constants import (
|
||||
ARTIST_AUTO_SELECT_KEY_MAP,
|
||||
ARTIST_AUTO_SELECT_STR_MAP,
|
||||
FOURCC_MAP,
|
||||
MEDIA_RATING_STR_MAP,
|
||||
MEDIA_TYPE_STR_MAP,
|
||||
MEDIA_CODEC_FLAVOR_MAP,
|
||||
)
|
||||
|
||||
|
||||
class SyncedLyricsFormat(Enum):
|
||||
LRC = "lrc"
|
||||
SRT = "srt"
|
||||
TTML = "ttml"
|
||||
|
||||
|
||||
class MediaType(Enum):
|
||||
SONG = 1
|
||||
MUSIC_VIDEO = 6
|
||||
|
||||
def __str__(self) -> str:
|
||||
return MEDIA_TYPE_STR_MAP[self.value]
|
||||
|
||||
def __int__(self) -> int:
|
||||
return self.value
|
||||
|
||||
|
||||
class MediaRating(Enum):
|
||||
NONE = 0
|
||||
EXPLICIT = 1
|
||||
CLEAN = 2
|
||||
|
||||
def __str__(self) -> str:
|
||||
return MEDIA_RATING_STR_MAP[self.value]
|
||||
|
||||
def __int__(self) -> int:
|
||||
return self.value
|
||||
|
||||
|
||||
class MediaFileFormat(Enum):
|
||||
MP4 = "mp4"
|
||||
M4V = "m4v"
|
||||
M4A = "m4a"
|
||||
|
||||
|
||||
class SongCodec(Enum):
|
||||
AAC_WEB = "aac-web"
|
||||
AAC_HE_WEB = "aac-he-web"
|
||||
# doesnt work with wrapper, gives ckc error
|
||||
# AAC_FPS_WEB = "aac-fps-web"
|
||||
# AAC_HE_FPS_WEB = "aac-he-fps-web"
|
||||
AAC = "aac"
|
||||
AAC_HE = "aac-he"
|
||||
AAC_BINAURAL = "aac-binaural"
|
||||
AAC_DOWNMIX = "aac-downmix"
|
||||
AAC_HE_BINAURAL = "aac-he-binaural"
|
||||
AAC_HE_DOWNMIX = "aac-he-downmix"
|
||||
ATMOS = "atmos"
|
||||
AC3 = "ac3"
|
||||
ALAC = "alac"
|
||||
ALAC_16_44 = "alac-16-44"
|
||||
ALAC_24_44 = "alac-24-44"
|
||||
ALAC_24_48 = "alac-24-48"
|
||||
ALAC_24_96 = "alac-24-96"
|
||||
ALAC_24_192 = "alac-24-192"
|
||||
ASK = "ask"
|
||||
|
||||
@property
|
||||
def is_web(self) -> bool:
|
||||
return self.value.endswith("-web")
|
||||
|
||||
@property
|
||||
def flavor(self) -> str | None:
|
||||
return MEDIA_CODEC_FLAVOR_MAP.get(self.value)
|
||||
|
||||
@property
|
||||
def is_cenc(self) -> bool:
|
||||
return self.flavor is not None and "ctrp" in self.flavor
|
||||
|
||||
|
||||
class MusicVideoCodec(Enum):
|
||||
H264 = "h264"
|
||||
H265 = "h265"
|
||||
ASK = "ask"
|
||||
|
||||
@property
|
||||
def fourcc(self) -> str | None:
|
||||
return FOURCC_MAP.get(self.value)
|
||||
|
||||
|
||||
class MusicVideoResolution(Enum):
|
||||
R240P = "240p"
|
||||
R360P = "360p"
|
||||
R480P = "480p"
|
||||
R540P = "540p"
|
||||
R720P = "720p"
|
||||
R1080P = "1080p"
|
||||
R1440P = "1440p"
|
||||
R2160P = "2160p"
|
||||
|
||||
def __int__(self) -> int:
|
||||
return int(self.value[:-1])
|
||||
|
||||
|
||||
class UploadedVideoQuality(Enum):
|
||||
BEST = "best"
|
||||
ASK = "ask"
|
||||
|
||||
|
||||
class CoverFormat(Enum):
|
||||
JPG = "jpg"
|
||||
PNG = "png"
|
||||
RAW = "raw"
|
||||
|
||||
|
||||
class ArtistMediaType(Enum):
|
||||
MAIN_ALBUMS = "main-albums"
|
||||
COMPILATION_ALBUMS = "compilation-albums"
|
||||
LIVE_ALBUMS = "live-albums"
|
||||
SINGLES_EPS = "singles-eps"
|
||||
ALL_ALBUMS = "all-albums"
|
||||
TOP_SONGS = "top-songs"
|
||||
MUSIC_VIDEOS = "music-videos"
|
||||
|
||||
@property
|
||||
def path_key(self) -> tuple[str, str]:
|
||||
return ARTIST_AUTO_SELECT_KEY_MAP[self.value]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return ARTIST_AUTO_SELECT_STR_MAP[self.value]
|
||||
@@ -0,0 +1,53 @@
|
||||
from ..utils import GamdlError
|
||||
from typing import Any
|
||||
|
||||
|
||||
class GamdlInterfaceError(GamdlError):
|
||||
pass
|
||||
|
||||
|
||||
class GamdlInterfaceMediaNotStreamableError(GamdlInterfaceError):
|
||||
def __init__(self, media_id: str):
|
||||
super().__init__(f"Media is not streamable: {media_id}")
|
||||
|
||||
|
||||
class GamdlInterfaceFormatNotAvailableError(GamdlInterfaceError):
|
||||
def __init__(self, media_id: str, codec: Any | None = None):
|
||||
super().__init__(
|
||||
f"Requested format is not available (media ID: {media_id}): {codec}"
|
||||
)
|
||||
|
||||
|
||||
class GamdlInterfaceDecryptionNotAvailableError(GamdlInterfaceError):
|
||||
def __init__(self, media_id: str):
|
||||
super().__init__(f"Decryption is not available for media ID: {media_id}")
|
||||
|
||||
|
||||
class GamdlInterfaceMediaNotAllowedError(GamdlInterfaceError):
|
||||
def __init__(self, media_type: str, media_id: str | None = None):
|
||||
message = "Media type is disallowed"
|
||||
if media_id:
|
||||
message += f" (media ID: {media_id})"
|
||||
|
||||
super().__init__(f"{message}: {media_type}")
|
||||
|
||||
|
||||
class GamdlInterfaceUrlParseError(GamdlInterfaceError):
|
||||
def __init__(self, url: str):
|
||||
super().__init__(f"URL is not valid or supported: {url}")
|
||||
|
||||
|
||||
class GamdlInterfaceArtistMediaTypeError(GamdlInterfaceError):
|
||||
def __init__(self, media_id: str, media_type: str):
|
||||
super().__init__(
|
||||
f"Artist has no media of type (media ID: {media_id}): {media_type}"
|
||||
)
|
||||
|
||||
|
||||
class GamdlInterfaceFlatFilterExcludedError(GamdlInterfaceError):
|
||||
def __init__(self, media_id: str, result: Any):
|
||||
super().__init__(
|
||||
f"Media excluded by flat filter (media ID: {media_id}): {result}"
|
||||
)
|
||||
|
||||
self.result = result
|
||||
@@ -0,0 +1,480 @@
|
||||
import asyncio
|
||||
from typing import Any, AsyncGenerator, Callable
|
||||
|
||||
import structlog
|
||||
|
||||
from ..utils import safe_gather
|
||||
from .constants import VALID_URL_PATTERN
|
||||
from .enums import ArtistMediaType
|
||||
from .exceptions import (
|
||||
GamdlInterfaceMediaNotAllowedError,
|
||||
GamdlInterfaceUrlParseError,
|
||||
GamdlInterfaceArtistMediaTypeError,
|
||||
GamdlInterfaceFlatFilterExcludedError,
|
||||
)
|
||||
from .music_video import AppleMusicMusicVideoInterface
|
||||
from .song import AppleMusicSongInterface
|
||||
from .types import AppleMusicMedia, AppleMusicUrlInfo
|
||||
from .uploaded_video import AppleMusicUploadedVideoInterface
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicInterface:
|
||||
def __init__(
|
||||
self,
|
||||
song: AppleMusicSongInterface,
|
||||
music_video: AppleMusicMusicVideoInterface,
|
||||
uploaded_video: AppleMusicUploadedVideoInterface,
|
||||
artist_select_media_type_function: (
|
||||
Callable[[list[ArtistMediaType], dict], ArtistMediaType | None] | None
|
||||
) = None,
|
||||
artist_select_items_function: (
|
||||
Callable[[ArtistMediaType, list[dict]], list[dict] | None] | None
|
||||
) = None,
|
||||
flat_filter_function: Callable[[dict], Any] | None = None,
|
||||
concurrency: int = 1,
|
||||
disallowed_media_types: list[str] | None = None,
|
||||
) -> None:
|
||||
self.song = song
|
||||
self.music_video = music_video
|
||||
self.uploaded_video = uploaded_video
|
||||
self.artist_select_media_type_function = artist_select_media_type_function
|
||||
self.artist_select_items_function = artist_select_items_function
|
||||
self.flat_filter_function = flat_filter_function
|
||||
self.concurrency = concurrency
|
||||
self.disallowed_media_types = disallowed_media_types
|
||||
|
||||
self.base = song.base
|
||||
|
||||
@staticmethod
|
||||
def get_url_info(url: str) -> AppleMusicUrlInfo | None:
|
||||
log = logger.bind(action="get_url_info", url=url)
|
||||
|
||||
match = VALID_URL_PATTERN.match(url)
|
||||
if not match:
|
||||
log.debug("invalid_url_pattern")
|
||||
|
||||
return None
|
||||
|
||||
url_match = AppleMusicUrlInfo(
|
||||
**match.groupdict(),
|
||||
)
|
||||
|
||||
log.debug("success", url_info=url_match)
|
||||
|
||||
return url_match
|
||||
|
||||
async def _run_flat_filter(self, media: AppleMusicMedia) -> None:
|
||||
if not self.flat_filter_function or not media.partial:
|
||||
return
|
||||
|
||||
result = self.flat_filter_function(media.media_metadata)
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
|
||||
if result:
|
||||
raise GamdlInterfaceFlatFilterExcludedError(media.media_id, result)
|
||||
|
||||
def _run_media_type_filter(self, media: AppleMusicMedia) -> None:
|
||||
if not self.disallowed_media_types or not media.partial:
|
||||
return
|
||||
|
||||
if media.media_metadata["type"] in self.disallowed_media_types:
|
||||
raise GamdlInterfaceMediaNotAllowedError(
|
||||
media.media_metadata["type"],
|
||||
media.media_id,
|
||||
)
|
||||
|
||||
async def _collect_generator(
|
||||
self, generator_or_coroutine: AsyncGenerator[AppleMusicMedia, None]
|
||||
) -> list[AppleMusicMedia]:
|
||||
results = []
|
||||
async for result in generator_or_coroutine:
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
async def _get_song_media(
|
||||
self,
|
||||
media_id: str,
|
||||
index: int | None = None,
|
||||
total: int | None = None,
|
||||
media_metadata: dict | None = None,
|
||||
playlist_metadata: dict | None = None,
|
||||
is_library: bool = False,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
media = AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
is_library=is_library,
|
||||
index=index,
|
||||
total=total,
|
||||
media_metadata=media_metadata,
|
||||
playlist_metadata=playlist_metadata,
|
||||
)
|
||||
try:
|
||||
async for media in self.song.get_media(media):
|
||||
yield media
|
||||
|
||||
self._run_media_type_filter(media)
|
||||
await self._run_flat_filter(media)
|
||||
except Exception as e:
|
||||
media.partial = False
|
||||
media.error = e
|
||||
yield media
|
||||
return
|
||||
|
||||
async def _get_music_video_media(
|
||||
self,
|
||||
media_id: str,
|
||||
index: int | None = None,
|
||||
total: int | None = None,
|
||||
media_metadata: dict | None = None,
|
||||
playlist_metadata: dict | None = None,
|
||||
is_library: bool = False,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
media = AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
is_library=is_library,
|
||||
index=index,
|
||||
total=total,
|
||||
media_metadata=media_metadata,
|
||||
playlist_metadata=playlist_metadata,
|
||||
)
|
||||
|
||||
media.media_metadata = media_metadata
|
||||
media.playlist_metadata = playlist_metadata
|
||||
|
||||
try:
|
||||
async for media in self.music_video.get_media(media):
|
||||
yield media
|
||||
|
||||
self._run_media_type_filter(media)
|
||||
await self._run_flat_filter(media)
|
||||
except Exception as e:
|
||||
media.partial = False
|
||||
media.error = e
|
||||
yield media
|
||||
return
|
||||
|
||||
async def _get_uploaded_video_media(
|
||||
self,
|
||||
media_id: str,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
media = AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
)
|
||||
|
||||
try:
|
||||
async for media in self.uploaded_video.get_media(media):
|
||||
yield media
|
||||
|
||||
self._run_media_type_filter(media)
|
||||
await self._run_flat_filter(media)
|
||||
except Exception as e:
|
||||
media.partial = False
|
||||
media.error = e
|
||||
yield media
|
||||
return
|
||||
|
||||
async def _get_album_media(
|
||||
self,
|
||||
media_id: str,
|
||||
is_library: bool = False,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
base_media = AppleMusicMedia(media_id)
|
||||
|
||||
try:
|
||||
base_media.media_metadata = (
|
||||
await (
|
||||
self.base.apple_music_api.get_library_album(
|
||||
media_id,
|
||||
)
|
||||
if is_library
|
||||
else self.base.apple_music_api.get_album(
|
||||
media_id,
|
||||
)
|
||||
)
|
||||
)["data"][0]
|
||||
|
||||
self._run_media_type_filter(base_media)
|
||||
await self._run_flat_filter(base_media)
|
||||
except Exception as e:
|
||||
base_media.partial = False
|
||||
base_media.error = e
|
||||
yield base_media
|
||||
return
|
||||
|
||||
yield base_media
|
||||
|
||||
tracks = base_media.media_metadata["relationships"]["tracks"]["data"]
|
||||
tasks = [
|
||||
(
|
||||
self._get_song_media(
|
||||
media_id=track["id"],
|
||||
index=index,
|
||||
total=base_media.media_metadata["attributes"]["trackCount"],
|
||||
media_metadata=track,
|
||||
is_library=is_library,
|
||||
)
|
||||
if track["type"] in {"songs", "library-songs"}
|
||||
else self._get_music_video_media(
|
||||
media_id=track["id"],
|
||||
index=index,
|
||||
total=base_media.media_metadata["attributes"]["trackCount"],
|
||||
media_metadata=track,
|
||||
is_library=is_library,
|
||||
)
|
||||
)
|
||||
for index, track in enumerate(tracks)
|
||||
]
|
||||
|
||||
if self.concurrency == 1:
|
||||
for task in tasks:
|
||||
async for media in task:
|
||||
yield media
|
||||
else:
|
||||
collected_tasks = [self._collect_generator(task) for task in tasks]
|
||||
batches = await safe_gather(*collected_tasks, limit=self.concurrency)
|
||||
for batch in batches:
|
||||
for media in batch:
|
||||
yield media
|
||||
|
||||
async def _get_playlist_media(
|
||||
self,
|
||||
media_id: str,
|
||||
is_library: bool = False,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
base_media = AppleMusicMedia(media_id)
|
||||
|
||||
try:
|
||||
base_media.media_metadata = (
|
||||
await (
|
||||
self.base.apple_music_api.get_library_playlist(
|
||||
media_id,
|
||||
)
|
||||
if is_library
|
||||
else self.base.apple_music_api.get_playlist(
|
||||
media_id,
|
||||
)
|
||||
)
|
||||
)["data"][0]
|
||||
|
||||
self._run_media_type_filter(base_media)
|
||||
await self._run_flat_filter(base_media)
|
||||
|
||||
tracks = base_media.media_metadata["relationships"]["tracks"]["data"]
|
||||
next_uri = base_media.media_metadata["relationships"]["tracks"].get("next")
|
||||
href_uri = base_media.media_metadata["relationships"]["tracks"].get("href")
|
||||
while next_uri:
|
||||
extended_data = await self.base.apple_music_api.get_extended_api_data(
|
||||
next_uri,
|
||||
href_uri,
|
||||
)
|
||||
tracks.extend(extended_data["data"])
|
||||
next_uri = extended_data.get("next")
|
||||
except Exception as e:
|
||||
base_media.partial = False
|
||||
base_media.error = e
|
||||
yield base_media
|
||||
return
|
||||
|
||||
yield base_media
|
||||
|
||||
tasks = [
|
||||
(
|
||||
self._get_song_media(
|
||||
media_id=track["id"],
|
||||
index=index,
|
||||
media_metadata=track,
|
||||
playlist_metadata=base_media.media_metadata,
|
||||
is_library=is_library,
|
||||
)
|
||||
if track["type"] in {"songs", "library-songs"}
|
||||
else self._get_music_video_media(
|
||||
media_id=track["id"],
|
||||
index=index,
|
||||
media_metadata=track,
|
||||
playlist_metadata=base_media.media_metadata,
|
||||
is_library=is_library,
|
||||
)
|
||||
)
|
||||
for index, track in enumerate(tracks)
|
||||
]
|
||||
|
||||
if self.concurrency == 1:
|
||||
for task in tasks:
|
||||
async for media in task:
|
||||
yield media
|
||||
else:
|
||||
collected_tasks = [self._collect_generator(task) for task in tasks]
|
||||
batches = await safe_gather(*collected_tasks, limit=self.concurrency)
|
||||
for batch in batches:
|
||||
for media in batch:
|
||||
yield media
|
||||
|
||||
async def _get_artist_media(
|
||||
self,
|
||||
media_id: str,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
base_media = AppleMusicMedia(media_id)
|
||||
|
||||
try:
|
||||
base_media.media_metadata = (
|
||||
await self.base.apple_music_api.get_artist(
|
||||
media_id,
|
||||
)
|
||||
)["data"][0]
|
||||
|
||||
self._run_media_type_filter(base_media)
|
||||
await self._run_flat_filter(base_media)
|
||||
|
||||
if self.artist_select_media_type_function:
|
||||
artist_media_type = self.artist_select_media_type_function(
|
||||
list(ArtistMediaType),
|
||||
base_media.media_metadata,
|
||||
)
|
||||
if asyncio.iscoroutine(artist_media_type):
|
||||
artist_media_type = await artist_media_type
|
||||
else:
|
||||
artist_media_type = list(ArtistMediaType)[0]
|
||||
|
||||
relation_key, type_key = artist_media_type.path_key
|
||||
|
||||
items_relation = base_media.media_metadata.get(relation_key, {}).get(
|
||||
type_key, {}
|
||||
)
|
||||
items = items_relation.get("data", [])
|
||||
if not items:
|
||||
raise GamdlInterfaceArtistMediaTypeError(
|
||||
base_media.media_id,
|
||||
str(artist_media_type),
|
||||
)
|
||||
|
||||
next_uri = items_relation.get("next")
|
||||
href_uri = items_relation.get("href")
|
||||
while next_uri:
|
||||
extended_data = await self.base.apple_music_api.get_extended_api_data(
|
||||
next_uri,
|
||||
href_uri,
|
||||
)
|
||||
items.extend(extended_data.get("data", []))
|
||||
next_uri = extended_data.get("next")
|
||||
except Exception as e:
|
||||
yield AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
media_metadata=None,
|
||||
error=e,
|
||||
)
|
||||
return
|
||||
|
||||
yield base_media
|
||||
|
||||
if self.artist_select_items_function:
|
||||
selected_items = self.artist_select_items_function(
|
||||
artist_media_type,
|
||||
items,
|
||||
)
|
||||
if asyncio.iscoroutine(selected_items):
|
||||
selected_items = await selected_items
|
||||
else:
|
||||
selected_items = items[:1]
|
||||
|
||||
tasks = []
|
||||
for index, item in enumerate(selected_items):
|
||||
if item["type"] in {"songs", "library-songs"}:
|
||||
tasks.append(
|
||||
self._get_song_media(
|
||||
media_id=item["id"],
|
||||
index=index,
|
||||
total=len(selected_items),
|
||||
media_metadata=item,
|
||||
)
|
||||
)
|
||||
elif item["type"] in {"albums", "library-albums"}:
|
||||
tasks.append(
|
||||
self._get_album_media(
|
||||
media_id=item["id"],
|
||||
)
|
||||
)
|
||||
else:
|
||||
tasks.append(
|
||||
self._get_music_video_media(
|
||||
media_id=item["id"],
|
||||
index=index,
|
||||
total=len(selected_items),
|
||||
media_metadata=item,
|
||||
)
|
||||
)
|
||||
|
||||
if self.concurrency == 1:
|
||||
for task in tasks:
|
||||
async for media in task:
|
||||
yield media
|
||||
else:
|
||||
collected_tasks = [self._collect_generator(task) for task in tasks]
|
||||
batches = await safe_gather(*collected_tasks, limit=self.concurrency)
|
||||
for batch in batches:
|
||||
for media in batch:
|
||||
yield media
|
||||
|
||||
async def get_media_from_url(
|
||||
self,
|
||||
url: str,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
url_info = self.get_url_info(url)
|
||||
|
||||
if not url_info:
|
||||
raise GamdlInterfaceUrlParseError(url)
|
||||
|
||||
if self.disallowed_media_types and url_info.type in self.disallowed_media_types:
|
||||
raise GamdlInterfaceMediaNotAllowedError(
|
||||
url_info.type,
|
||||
)
|
||||
|
||||
if (
|
||||
url_info.type == "song"
|
||||
or url_info.library_type == "songs"
|
||||
or url_info.sub_id
|
||||
):
|
||||
async for media in self._get_song_media(
|
||||
media_id=url_info.sub_id or url_info.id or url_info.library_id,
|
||||
index=0,
|
||||
total=1,
|
||||
is_library=bool(url_info.library_type),
|
||||
):
|
||||
yield media
|
||||
|
||||
elif url_info.type == "music-video" or url_info.library_type == "music-videos":
|
||||
async for media in self._get_music_video_media(
|
||||
media_id=url_info.id or url_info.library_id,
|
||||
index=0,
|
||||
total=1,
|
||||
is_library=bool(url_info.library_type),
|
||||
):
|
||||
yield media
|
||||
|
||||
elif url_info.type == "album" or url_info.library_type == "albums":
|
||||
async for media in self._get_album_media(
|
||||
media_id=url_info.id or url_info.library_id,
|
||||
is_library=bool(url_info.library_type),
|
||||
):
|
||||
yield media
|
||||
|
||||
elif url_info.type == "playlist" or url_info.library_type == "playlist":
|
||||
async for media in self._get_playlist_media(
|
||||
media_id=url_info.id or url_info.library_id,
|
||||
is_library=bool(url_info.library_type),
|
||||
):
|
||||
yield media
|
||||
|
||||
elif url_info.type == "post":
|
||||
async for media in self._get_uploaded_video_media(
|
||||
media_id=url_info.id,
|
||||
):
|
||||
yield media
|
||||
|
||||
elif url_info.type == "artist":
|
||||
async for media in self._get_artist_media(
|
||||
media_id=url_info.id,
|
||||
):
|
||||
yield media
|
||||
@@ -0,0 +1,504 @@
|
||||
import asyncio
|
||||
import urllib.parse
|
||||
from typing import AsyncGenerator, Callable
|
||||
|
||||
import m3u8
|
||||
import structlog
|
||||
|
||||
from .base import AppleMusicBaseInterface
|
||||
from .constants import MP4_FORMAT_CODECS
|
||||
from .enums import MediaRating, MediaType, MusicVideoCodec, MusicVideoResolution
|
||||
from .exceptions import (
|
||||
GamdlInterfaceDecryptionNotAvailableError,
|
||||
GamdlInterfaceFormatNotAvailableError,
|
||||
GamdlInterfaceMediaNotStreamableError,
|
||||
)
|
||||
from .types import (
|
||||
AppleMusicMedia,
|
||||
DecryptionKeyAv,
|
||||
MediaFileFormat,
|
||||
MediaTags,
|
||||
StreamInfo,
|
||||
StreamInfoAv,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicMusicVideoInterface:
|
||||
def __init__(
|
||||
self,
|
||||
base: AppleMusicBaseInterface,
|
||||
resolution: MusicVideoResolution = MusicVideoResolution.R1080P,
|
||||
codec_priority: list[MusicVideoCodec] = [
|
||||
MusicVideoCodec.H264,
|
||||
MusicVideoCodec.H265,
|
||||
],
|
||||
ask_video_codec_function: (
|
||||
Callable[[list[m3u8.Playlist]], dict | None] | None
|
||||
) = None,
|
||||
ask_audio_codec_function: Callable[[list[dict]], dict | None] | None = None,
|
||||
):
|
||||
self.base = base
|
||||
self.resolution = resolution
|
||||
self.codec_priority = codec_priority
|
||||
self.ask_video_codec_function = ask_video_codec_function
|
||||
self.ask_audio_codec_function = ask_audio_codec_function
|
||||
|
||||
async def get_itunes_page_metadata(
|
||||
self,
|
||||
music_video_metadata: dict,
|
||||
) -> dict:
|
||||
url_media_id = self.base.parse_media_id_from_url(music_video_metadata)
|
||||
itunes_page = await self.base.itunes_api.get_itunes_page(
|
||||
"music-video",
|
||||
url_media_id,
|
||||
)
|
||||
return itunes_page["storePlatformData"]["product-dv"]["results"][url_media_id]
|
||||
|
||||
def _get_m3u8_master_url_from_webplayback(self, webplayback: dict) -> str:
|
||||
log = logger.bind(action="get_m3u8_master_url_from_webplayback")
|
||||
|
||||
m3u8_master_url = webplayback["hls-playlist-url"]
|
||||
|
||||
log.debug("success", m3u8_master_url=m3u8_master_url)
|
||||
|
||||
return m3u8_master_url
|
||||
|
||||
def _get_m3u8_master_url_from_itunes_page_metadata(
|
||||
self,
|
||||
itunes_page_metadata: dict,
|
||||
) -> str | None:
|
||||
log = logger.bind(action="get_m3u8_master_url_from_itunes_page_metadata")
|
||||
|
||||
stream_url = itunes_page_metadata["offers"][0]["assets"][0].get("hlsUrl")
|
||||
if not stream_url:
|
||||
return None
|
||||
|
||||
url_parts = urllib.parse.urlparse(stream_url)
|
||||
query = urllib.parse.parse_qs(url_parts.query, keep_blank_values=True)
|
||||
query.update({"aec": "HD", "dsid": "1"})
|
||||
|
||||
m3u8_master_url = url_parts._replace(
|
||||
query=urllib.parse.urlencode(query, doseq=True)
|
||||
).geturl()
|
||||
|
||||
m3u8_master_url = m3u8_master_url.replace(
|
||||
"play-edge.itunes.apple.com",
|
||||
"play.itunes.apple.com",
|
||||
).replace(
|
||||
"MZPlayLocal.woa",
|
||||
"MZPlay.woa",
|
||||
)
|
||||
|
||||
log.debug("success", m3u8_master_url=m3u8_master_url)
|
||||
|
||||
return m3u8_master_url
|
||||
|
||||
async def get_tags(
|
||||
self,
|
||||
metadata: dict,
|
||||
itunes_page_metadata: dict,
|
||||
) -> MediaTags:
|
||||
log = logger.bind(
|
||||
action="get_music_video_tags",
|
||||
media_id=metadata["id"],
|
||||
)
|
||||
|
||||
url_media_id = self.base.parse_media_id_from_url(metadata)
|
||||
lookup_metadata = (await self.base.itunes_api.get_lookup_result(url_media_id))[
|
||||
"results"
|
||||
]
|
||||
|
||||
explicitness = lookup_metadata[0]["trackExplicitness"]
|
||||
if explicitness == "notExplicit":
|
||||
rating = MediaRating.NONE
|
||||
elif explicitness == "explicit":
|
||||
rating = MediaRating.EXPLICIT
|
||||
else:
|
||||
rating = MediaRating.CLEAN
|
||||
|
||||
tags = MediaTags(
|
||||
artist=lookup_metadata[0]["artistName"],
|
||||
artist_id=int(lookup_metadata[0]["artistId"]),
|
||||
copyright=itunes_page_metadata.get("copyright"),
|
||||
date=self.base.parse_date(lookup_metadata[0]["releaseDate"]),
|
||||
genre=lookup_metadata[0]["primaryGenreName"],
|
||||
genre_id=int(itunes_page_metadata["genres"][0]["genreId"]),
|
||||
media_type=MediaType.MUSIC_VIDEO,
|
||||
storefront=self.base.itunes_api.storefront_id,
|
||||
title=lookup_metadata[0]["trackName"],
|
||||
title_sort=lookup_metadata[0]["trackCensoredName"],
|
||||
title_id=int(metadata["id"]),
|
||||
rating=rating,
|
||||
)
|
||||
|
||||
if len(lookup_metadata) > 1:
|
||||
album = await self.base.get_album_cached(
|
||||
itunes_page_metadata["collectionId"]
|
||||
)
|
||||
if not album:
|
||||
return tags
|
||||
|
||||
tags.album = lookup_metadata[1]["collectionName"]
|
||||
tags.album_sort = lookup_metadata[1]["collectionCensoredName"]
|
||||
tags.album_artist = lookup_metadata[1]["artistName"]
|
||||
tags.album_id = int(itunes_page_metadata["collectionId"])
|
||||
tags.disc = lookup_metadata[0]["discNumber"]
|
||||
tags.disc_total = lookup_metadata[0]["discCount"]
|
||||
tags.compilation = album["attributes"]["isCompilation"]
|
||||
tags.track = lookup_metadata[0]["trackNumber"]
|
||||
tags.track_total = lookup_metadata[0]["trackCount"]
|
||||
|
||||
log.debug("success", tags=tags)
|
||||
|
||||
return tags
|
||||
|
||||
async def get_m3u8_master_url(
|
||||
self,
|
||||
metadata: dict,
|
||||
itunes_page_metadata: dict,
|
||||
) -> str | None:
|
||||
url_media_id = self.base.parse_media_id_from_url(metadata)
|
||||
|
||||
if url_media_id == metadata["id"]:
|
||||
return self._get_m3u8_master_url_from_itunes_page_metadata(
|
||||
itunes_page_metadata,
|
||||
)
|
||||
|
||||
webplayback_response = await self.base.apple_music_api.get_webplayback(
|
||||
metadata["id"]
|
||||
)
|
||||
return self._get_m3u8_master_url_from_webplayback(
|
||||
webplayback_response["songList"][0],
|
||||
)
|
||||
|
||||
async def _get_stream_info(
|
||||
self,
|
||||
m3u8_master_url: str | None,
|
||||
codec: MusicVideoCodec,
|
||||
) -> StreamInfoAv | None:
|
||||
log = logger.bind(
|
||||
action="get_music_video_stream_info",
|
||||
m3u8_master_url=m3u8_master_url,
|
||||
codec=codec.value,
|
||||
)
|
||||
|
||||
if not m3u8_master_url:
|
||||
log.debug("no_m3u8_master_url")
|
||||
return None
|
||||
|
||||
playlist_master_m3u8_obj = m3u8.loads(
|
||||
(await self.base.get_response(m3u8_master_url)).text
|
||||
)
|
||||
playlist_master_m3u8_obj.base_uri = m3u8_master_url.rpartition("/")[0]
|
||||
stream_info_video = await self._get_stream_info_video(
|
||||
playlist_master_m3u8_obj,
|
||||
codec,
|
||||
)
|
||||
stream_info_audio = await self._get_stream_info_audio(
|
||||
playlist_master_m3u8_obj.data,
|
||||
codec,
|
||||
)
|
||||
if not stream_info_video or not stream_info_audio:
|
||||
return None
|
||||
|
||||
use_mp4 = any(
|
||||
stream_info_video.codec.startswith(codec) for codec in MP4_FORMAT_CODECS
|
||||
) or any(
|
||||
stream_info_audio.codec.startswith(codec) for codec in MP4_FORMAT_CODECS
|
||||
)
|
||||
if use_mp4:
|
||||
file_format = MediaFileFormat.MP4
|
||||
else:
|
||||
file_format = MediaFileFormat.M4V
|
||||
|
||||
stream_info = StreamInfoAv(
|
||||
video_track=stream_info_video,
|
||||
audio_track=stream_info_audio,
|
||||
file_format=file_format,
|
||||
)
|
||||
|
||||
log.debug("success", stream_info=stream_info)
|
||||
|
||||
return stream_info
|
||||
|
||||
def _get_video_playlist_from_resolution(
|
||||
self,
|
||||
video_playlists: list[m3u8.Playlist],
|
||||
codec: MusicVideoCodec,
|
||||
) -> m3u8.Playlist | None:
|
||||
playlist_results = [
|
||||
playlist
|
||||
for playlist in video_playlists
|
||||
if playlist.stream_info.codecs.startswith(codec.fourcc)
|
||||
]
|
||||
|
||||
if not playlist_results:
|
||||
return None
|
||||
|
||||
def sort_key(
|
||||
playlist: m3u8.Playlist,
|
||||
) -> tuple[bool, int, int, int]:
|
||||
playlist_resolution = playlist.stream_info.resolution[-1]
|
||||
bandwidth = playlist.stream_info.bandwidth
|
||||
exceeds_resolution = playlist_resolution > int(self.resolution)
|
||||
resolution_difference = abs(playlist_resolution - int(self.resolution))
|
||||
|
||||
return (
|
||||
exceeds_resolution,
|
||||
resolution_difference,
|
||||
-playlist_resolution,
|
||||
-bandwidth,
|
||||
)
|
||||
|
||||
playlist_results.sort(key=sort_key)
|
||||
return playlist_results[0]
|
||||
|
||||
def _get_best_stereo_audio_playlist(
|
||||
self,
|
||||
playlist_master_data: dict,
|
||||
) -> dict | None:
|
||||
audio_playlist = next(
|
||||
(
|
||||
media
|
||||
for media in playlist_master_data["media"]
|
||||
if media["group_id"] == "audio-stereo-256"
|
||||
),
|
||||
None,
|
||||
)
|
||||
return audio_playlist
|
||||
|
||||
async def _get_video_playlist_from_user(
|
||||
self,
|
||||
video_playlists: list[m3u8.Playlist],
|
||||
) -> m3u8.Playlist | None:
|
||||
if self.ask_video_codec_function:
|
||||
video_playlist = self.ask_video_codec_function(video_playlists)
|
||||
if asyncio.iscoroutine(video_playlist):
|
||||
video_playlist = await video_playlist
|
||||
|
||||
return video_playlist
|
||||
|
||||
return None
|
||||
|
||||
async def _get_audio_playlist_from_user(
|
||||
self,
|
||||
playlist_master_data: dict,
|
||||
) -> dict | None:
|
||||
if self.ask_audio_codec_function:
|
||||
audio_playlist = self.ask_audio_codec_function(
|
||||
[
|
||||
playlist
|
||||
for playlist in playlist_master_data["media"]
|
||||
if playlist.get("uri")
|
||||
]
|
||||
)
|
||||
if asyncio.iscoroutine(audio_playlist):
|
||||
audio_playlist = await audio_playlist
|
||||
|
||||
return audio_playlist
|
||||
|
||||
return None
|
||||
|
||||
def _get_key_by_format(
|
||||
self,
|
||||
m3u8_obj: m3u8.M3U8,
|
||||
key_format: str,
|
||||
) -> str:
|
||||
return next(
|
||||
(key for key in m3u8_obj.keys if key.keyformat == key_format),
|
||||
None,
|
||||
).uri
|
||||
|
||||
def _get_widevine_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
return self._get_key_by_format(
|
||||
m3u8_obj,
|
||||
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
|
||||
)
|
||||
|
||||
def _get_playready_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
return self._get_key_by_format(
|
||||
m3u8_obj,
|
||||
"com.microsoft.playready",
|
||||
)
|
||||
|
||||
def _get_fairplay_key(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
return self._get_key_by_format(
|
||||
m3u8_obj,
|
||||
"com.apple.streamingkeydelivery",
|
||||
)
|
||||
|
||||
async def _get_stream_info_video(
|
||||
self,
|
||||
playlist_master_m3u8_obj: m3u8.M3U8,
|
||||
codec: MusicVideoCodec,
|
||||
) -> StreamInfo | None:
|
||||
stream_info = StreamInfo()
|
||||
|
||||
if codec != MusicVideoCodec.ASK:
|
||||
playlist = self._get_video_playlist_from_resolution(
|
||||
playlist_master_m3u8_obj.playlists,
|
||||
codec,
|
||||
)
|
||||
else:
|
||||
playlist = await self._get_video_playlist_from_user(
|
||||
playlist_master_m3u8_obj.playlists
|
||||
)
|
||||
|
||||
if not playlist:
|
||||
return None
|
||||
|
||||
stream_info.stream_url = playlist.uri
|
||||
stream_info.codec = playlist.stream_info.codecs
|
||||
stream_info.width, stream_info.height = playlist.stream_info.resolution
|
||||
|
||||
playlist_m3u8_obj = m3u8.loads(
|
||||
(await self.base.get_response(stream_info.stream_url)).text
|
||||
)
|
||||
stream_info.widevine_pssh = self._get_widevine_pssh(playlist_m3u8_obj)
|
||||
stream_info.fairplay_key = self._get_fairplay_key(playlist_m3u8_obj)
|
||||
stream_info.playready_pssh = self._get_playready_pssh(playlist_m3u8_obj)
|
||||
|
||||
return stream_info
|
||||
|
||||
async def _get_stream_info_audio(
|
||||
self,
|
||||
playlist_master_data: dict,
|
||||
codec: MusicVideoCodec,
|
||||
) -> StreamInfo | None:
|
||||
stream_info = StreamInfo()
|
||||
|
||||
if codec != MusicVideoCodec.ASK:
|
||||
playlist = self._get_best_stereo_audio_playlist(playlist_master_data)
|
||||
else:
|
||||
playlist = await self._get_audio_playlist_from_user(playlist_master_data)
|
||||
|
||||
if not playlist:
|
||||
return None
|
||||
|
||||
stream_info.stream_url = playlist["uri"]
|
||||
stream_info.codec = playlist["group_id"]
|
||||
|
||||
playlist_m3u8_obj = m3u8.loads(
|
||||
(await self.base.get_response(stream_info.stream_url)).text
|
||||
)
|
||||
stream_info.widevine_pssh = self._get_widevine_pssh(playlist_m3u8_obj)
|
||||
stream_info.fairplay_key = self._get_fairplay_key(playlist_m3u8_obj)
|
||||
stream_info.playready_pssh = self._get_playready_pssh(playlist_m3u8_obj)
|
||||
|
||||
return stream_info
|
||||
|
||||
async def get_stream_info(
|
||||
self,
|
||||
media_id: str,
|
||||
m3u8_master_url: str | None,
|
||||
) -> StreamInfoAv:
|
||||
stream_info = None
|
||||
|
||||
for codec in self.codec_priority:
|
||||
stream_info = await self._get_stream_info(m3u8_master_url, codec)
|
||||
|
||||
if stream_info:
|
||||
break
|
||||
|
||||
if not stream_info:
|
||||
raise GamdlInterfaceFormatNotAvailableError(
|
||||
media_id=media_id,
|
||||
codec=[codec.value for codec in self.codec_priority],
|
||||
)
|
||||
|
||||
return stream_info
|
||||
|
||||
async def get_decryption_key(
|
||||
self,
|
||||
stream_info: StreamInfoAv,
|
||||
) -> DecryptionKeyAv:
|
||||
decryption_key_video, decryption_key_audio = await asyncio.gather(
|
||||
self.base.get_decryption_key(
|
||||
stream_info.video_track.widevine_pssh,
|
||||
stream_info.media_id,
|
||||
),
|
||||
self.base.get_decryption_key(
|
||||
stream_info.audio_track.widevine_pssh,
|
||||
stream_info.media_id,
|
||||
),
|
||||
)
|
||||
|
||||
return DecryptionKeyAv(
|
||||
video_track=decryption_key_video,
|
||||
audio_track=decryption_key_audio,
|
||||
)
|
||||
|
||||
async def get_media(
|
||||
self,
|
||||
media: AppleMusicMedia,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
if not media.media_metadata:
|
||||
media.media_metadata = (
|
||||
await (
|
||||
self.base.apple_music_api.get_library_music_video(media.media_id)
|
||||
if media.is_library
|
||||
else self.base.apple_music_api.get_music_video(media.media_id)
|
||||
)
|
||||
)["data"][0]
|
||||
|
||||
if media.media_metadata["attributes"].get("playParams", {}).get("isLibrary"):
|
||||
catalog_metadata = self.base.get_catalog_metadata_from_library(
|
||||
media.media_metadata
|
||||
)
|
||||
if catalog_metadata:
|
||||
media.media_id = catalog_metadata["id"]
|
||||
media.is_library = False
|
||||
media.media_metadata = catalog_metadata
|
||||
|
||||
if media.is_library:
|
||||
raise GamdlInterfaceMediaNotStreamableError(media.media_id)
|
||||
|
||||
yield media
|
||||
|
||||
if not self.base.is_media_streamable(media.media_metadata):
|
||||
raise GamdlInterfaceMediaNotStreamableError(media.media_id)
|
||||
|
||||
if media.playlist_metadata:
|
||||
media.playlist_tags = self.base.get_playlist_tags(
|
||||
media.playlist_metadata,
|
||||
media.index,
|
||||
)
|
||||
|
||||
media.cover = await self.base.get_cover(media.media_metadata)
|
||||
|
||||
itunes_page_metadata = await self.get_itunes_page_metadata(media.media_metadata)
|
||||
|
||||
if self.base.wrapper_api:
|
||||
playback = await self.base.wrapper_api.get_playback(media.media_id)
|
||||
media.tags = await self.base.get_tags_from_asset_info(
|
||||
playback["songList"][0]["assets"][0]["metadata"],
|
||||
)
|
||||
else:
|
||||
playback = None
|
||||
media.tags = await self.get_tags(
|
||||
media.media_metadata,
|
||||
itunes_page_metadata,
|
||||
)
|
||||
|
||||
m3u8_master_url = await self.get_m3u8_master_url(
|
||||
media.media_metadata,
|
||||
itunes_page_metadata,
|
||||
)
|
||||
media.stream_info = await self.get_stream_info(
|
||||
media.media_id,
|
||||
m3u8_master_url,
|
||||
)
|
||||
|
||||
if (
|
||||
not media.stream_info.video_track.widevine_pssh
|
||||
or not media.stream_info.audio_track.widevine_pssh
|
||||
):
|
||||
raise GamdlInterfaceDecryptionNotAvailableError(media.media_id)
|
||||
|
||||
media.decryption_key = await self.get_decryption_key(media.stream_info)
|
||||
|
||||
media.partial = False
|
||||
|
||||
yield media
|
||||
@@ -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
|
||||
@@ -0,0 +1,185 @@
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .enums import MediaFileFormat, MediaRating, MediaType
|
||||
|
||||
|
||||
@dataclass
|
||||
class Lyrics:
|
||||
synced: str = None
|
||||
unsynced: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaTags:
|
||||
album: str = None
|
||||
album_artist: str = None
|
||||
album_id: int = None
|
||||
album_sort: str = None
|
||||
artist: str = None
|
||||
artist_id: int = None
|
||||
artist_sort: str = None
|
||||
comment: str = None
|
||||
compilation: bool = None
|
||||
composer: str = None
|
||||
composer_id: int = None
|
||||
composer_sort: str = None
|
||||
copyright: str = None
|
||||
date: datetime.date | str = None
|
||||
disc: int = None
|
||||
disc_total: int = None
|
||||
gapless: bool = None
|
||||
genre: str = None
|
||||
genre_id: int = None
|
||||
lyrics: str = None
|
||||
media_type: MediaType = None
|
||||
rating: MediaRating = None
|
||||
storefront: str = None
|
||||
title: str = None
|
||||
title_id: int = None
|
||||
title_sort: str = None
|
||||
track: int = None
|
||||
track_total: int = None
|
||||
xid: str = None
|
||||
|
||||
def as_mp4_tags(self, date_format: str = None) -> dict:
|
||||
disc_mp4 = [
|
||||
self.disc if self.disc is not None else 0,
|
||||
self.disc_total if self.disc_total is not None else 0,
|
||||
]
|
||||
if disc_mp4[0] == 0 and disc_mp4[1] == 0:
|
||||
disc_mp4 = None
|
||||
|
||||
track_mp4 = [
|
||||
self.track if self.track is not None else 0,
|
||||
self.track_total if self.track_total is not None else 0,
|
||||
]
|
||||
if track_mp4[0] == 0 and track_mp4[1] == 0:
|
||||
track_mp4 = None
|
||||
|
||||
if isinstance(self.date, datetime.date):
|
||||
if date_format is None:
|
||||
date_mp4 = self.date.isoformat()
|
||||
else:
|
||||
date_mp4 = self.date.strftime(date_format)
|
||||
elif isinstance(self.date, str):
|
||||
date_mp4 = self.date
|
||||
else:
|
||||
date_mp4 = None
|
||||
|
||||
mp4_tags = {
|
||||
"\xa9alb": self.album,
|
||||
"aART": self.album_artist,
|
||||
"plID": self.album_id,
|
||||
"soal": self.album_sort,
|
||||
"\xa9ART": self.artist,
|
||||
"atID": self.artist_id,
|
||||
"soar": self.artist_sort,
|
||||
"\xa9cmt": self.comment,
|
||||
"cpil": bool(self.compilation) if self.compilation is not None else None,
|
||||
"\xa9wrt": self.composer,
|
||||
"cmID": self.composer_id,
|
||||
"soco": self.composer_sort,
|
||||
"cprt": self.copyright,
|
||||
"\xa9day": date_mp4,
|
||||
"disk": disc_mp4,
|
||||
"pgap": bool(self.gapless) if self.gapless is not None else None,
|
||||
"\xa9gen": self.genre,
|
||||
"\xa9lyr": self.lyrics,
|
||||
"geID": self.genre_id,
|
||||
"stik": int(self.media_type) if self.media_type is not None else None,
|
||||
"rtng": int(self.rating) if self.rating is not None else None,
|
||||
"sfID": self.storefront,
|
||||
"\xa9nam": self.title,
|
||||
"cnID": self.title_id,
|
||||
"sonm": self.title_sort,
|
||||
"trkn": track_mp4,
|
||||
"xid ": self.xid,
|
||||
}
|
||||
|
||||
return {
|
||||
k: ([v] if not isinstance(v, bool) else v)
|
||||
for k, v in mp4_tags.items()
|
||||
if v is not None
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaylistTags:
|
||||
artist: str = None
|
||||
playlist_id: int = None
|
||||
title: str = None
|
||||
track: int = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamInfo:
|
||||
stream_url: str = None
|
||||
widevine_pssh: str = None
|
||||
playready_pssh: str = None
|
||||
fairplay_key: str = None
|
||||
codec: str = None
|
||||
width: int = None
|
||||
height: int = None
|
||||
drm_free: bool = False
|
||||
use_cenc: bool = False
|
||||
use_single_content_key: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamInfoAv:
|
||||
media_id: str = None
|
||||
video_track: StreamInfo = None
|
||||
audio_track: StreamInfo = None
|
||||
file_format: MediaFileFormat = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DecryptionKey:
|
||||
kid: str = None
|
||||
key: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DecryptionKeyAv:
|
||||
video_track: DecryptionKey = None
|
||||
audio_track: DecryptionKey = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Cover:
|
||||
template_url: str = None
|
||||
file_extension: str = None
|
||||
url: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppleMusicMedia:
|
||||
media_id: str
|
||||
is_library: bool = False
|
||||
index: int = 0
|
||||
total: int = 0
|
||||
partial: bool = True
|
||||
media_metadata: dict | None = None
|
||||
error: BaseException | None = None
|
||||
playlist_metadata: dict | None = None
|
||||
playlist_tags: PlaylistTags | None = None
|
||||
extra_tags: dict | None = None
|
||||
cover: Cover | None = None
|
||||
lyrics: Lyrics | None = None
|
||||
tags: MediaTags | None = None
|
||||
stream_info: StreamInfoAv | None = None
|
||||
decryption_key: DecryptionKeyAv | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppleMusicUrlInfo:
|
||||
storefront: str = None
|
||||
type: str = None
|
||||
slug: str = None
|
||||
id: str = None
|
||||
sub_id: str = None
|
||||
library_storefront: str = None
|
||||
library_type: str = None
|
||||
library_id: str = None
|
||||
@@ -0,0 +1,134 @@
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import structlog
|
||||
|
||||
from .base import AppleMusicBaseInterface
|
||||
from .constants import UPLOADED_VIDEO_QUALITY_RANK
|
||||
from .enums import UploadedVideoQuality
|
||||
from .exceptions import (
|
||||
GamdlInterfaceFormatNotAvailableError,
|
||||
GamdlInterfaceMediaNotStreamableError,
|
||||
)
|
||||
from .types import AppleMusicMedia, MediaFileFormat, MediaTags, StreamInfo, StreamInfoAv
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicUploadedVideoInterface:
|
||||
def __init__(
|
||||
self,
|
||||
base: AppleMusicBaseInterface,
|
||||
quality: UploadedVideoQuality = UploadedVideoQuality.BEST,
|
||||
ask_quality_function: Callable[[dict], dict | None] | None = None,
|
||||
):
|
||||
self.base = base
|
||||
self.quality = quality
|
||||
self.ask_quality_function = ask_quality_function
|
||||
|
||||
def _get_best_stream_url(self, metadata: dict) -> str:
|
||||
best_quality = next(
|
||||
(
|
||||
quality
|
||||
for quality in UPLOADED_VIDEO_QUALITY_RANK
|
||||
if metadata["attributes"]["assetTokens"].get(quality)
|
||||
),
|
||||
None,
|
||||
)
|
||||
return metadata["attributes"]["assetTokens"][best_quality]
|
||||
|
||||
async def _get_stream_url_from_user(self, metadata: dict) -> str | None:
|
||||
if self.ask_quality_function:
|
||||
selected_quality = self.ask_quality_function(
|
||||
metadata["attributes"]["assetTokens"]
|
||||
)
|
||||
if asyncio.iscoroutine(selected_quality):
|
||||
selected_quality = await selected_quality
|
||||
return selected_quality
|
||||
|
||||
return None
|
||||
|
||||
async def _get_stream_url(
|
||||
self,
|
||||
metadata: dict,
|
||||
) -> str | None:
|
||||
if self.quality == UploadedVideoQuality.BEST:
|
||||
stream_url = self._get_best_stream_url(metadata)
|
||||
|
||||
if self.quality == UploadedVideoQuality.ASK:
|
||||
stream_url = await self._get_stream_url_from_user(metadata)
|
||||
|
||||
return stream_url
|
||||
|
||||
async def get_stream_info(
|
||||
self,
|
||||
metadata: dict,
|
||||
) -> StreamInfo | None:
|
||||
log = logger.bind(
|
||||
action="get_uploaded_video_stream_info", media_id=metadata["id"]
|
||||
)
|
||||
|
||||
stream_url = await self._get_stream_url(metadata)
|
||||
if not stream_url:
|
||||
log.debug("no_stream_url_available")
|
||||
|
||||
return None
|
||||
|
||||
stream_info = StreamInfoAv(
|
||||
file_format=MediaFileFormat.M4V,
|
||||
video_track=StreamInfo(
|
||||
stream_url=stream_url,
|
||||
drm_free=True,
|
||||
),
|
||||
)
|
||||
|
||||
log.debug("success", stream_info=stream_info)
|
||||
|
||||
return stream_info
|
||||
|
||||
def get_tags(self, metadata: dict) -> MediaTags:
|
||||
log = logger.bind(action="get_uploaded_video_tags", media_id=metadata["id"])
|
||||
|
||||
attributes = metadata["attributes"]
|
||||
upload_date = attributes.get("uploadDate")
|
||||
|
||||
tags = MediaTags(
|
||||
artist=attributes.get("artistName"),
|
||||
date=self.base.parse_date(upload_date) if upload_date else None,
|
||||
title=attributes.get("name"),
|
||||
title_id=int(metadata["id"]),
|
||||
storefront=self.base.itunes_api.storefront_id,
|
||||
)
|
||||
|
||||
log.debug("success", tags=tags)
|
||||
|
||||
return tags
|
||||
|
||||
async def get_media(
|
||||
self,
|
||||
media: AppleMusicMedia,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
if not media.media_metadata:
|
||||
media.media_metadata = (
|
||||
await self.base.apple_music_api.get_uploaded_video(media.media_id)
|
||||
)["data"][0]
|
||||
|
||||
media.media_id = media.media_metadata["id"]
|
||||
|
||||
yield media
|
||||
|
||||
if not self.base.is_media_streamable(media.media_metadata):
|
||||
raise GamdlInterfaceMediaNotStreamableError(media.media_id)
|
||||
|
||||
media.cover = await self.base.get_cover(media.media_metadata)
|
||||
|
||||
media.stream_info = await self.get_stream_info(media.media_metadata)
|
||||
if not media.stream_info:
|
||||
raise GamdlInterfaceFormatNotAvailableError(media.media_id)
|
||||
|
||||
media.tags = self.get_tags(media.media_metadata)
|
||||
|
||||
media.partial = False
|
||||
|
||||
yield media
|
||||
@@ -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=="""
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
Generated
+828
@@ -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" },
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,17 @@
|
||||
.git/
|
||||
.github/
|
||||
.tmp/
|
||||
.cache/
|
||||
build/
|
||||
build-*/
|
||||
cmake-build-*/
|
||||
|
||||
*.apk
|
||||
*.apkm
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,130 @@
|
||||
cmake_minimum_required(VERSION 3.22)
|
||||
|
||||
# wrapper-v2 builds two binaries:
|
||||
# wrapper - native-Linux chroot launcher (built with the host toolchain)
|
||||
# main - the daemon, cross-compiled with the Android NDK so that it can
|
||||
# be exec'd inside a chroot whose dynamic loader is Android's
|
||||
# linker64 and whose .so dependencies are Apple Music's libs.
|
||||
#
|
||||
# These two targets cannot share a single CMake configuration because they
|
||||
# target different sysroots, ABIs, and toolchains. The top-level project here
|
||||
# only ever builds the launcher; it then invokes a sub-build for the daemon
|
||||
# using the NDK toolchain file. This keeps each target's flags clean and
|
||||
# avoids contaminating either build with the other's compiler.
|
||||
#
|
||||
# Usage:
|
||||
# cmake -S . -B build -DTARGET_ARCH=x86_64 # or arm64-v8a
|
||||
# cmake --build build -j
|
||||
#
|
||||
# Required input:
|
||||
# ANDROID_NDK_HOME or NDK_PATH must point at a usable NDK r23b install.
|
||||
|
||||
project(wrapper-v2 LANGUAGES C CXX)
|
||||
|
||||
set(CMAKE_C_STANDARD 11)
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Options
|
||||
# -----------------------------------------------------------------------------
|
||||
set(TARGET_ARCH "x86_64" CACHE STRING "Target ABI for the chrooted daemon (matches extract-libs/stage-system --arch)")
|
||||
set_property(CACHE TARGET_ARCH PROPERTY STRINGS "x86_64" "arm64-v8a")
|
||||
|
||||
option(WRAPPER_BUILD_DAEMON "Drive the NDK sub-build for the chrooted daemon" ON)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Locate the NDK (only required when WRAPPER_BUILD_DAEMON is ON)
|
||||
# -----------------------------------------------------------------------------
|
||||
if(WRAPPER_BUILD_DAEMON)
|
||||
if(DEFINED ENV{ANDROID_NDK_HOME} AND NOT DEFINED NDK_PATH)
|
||||
set(NDK_PATH "$ENV{ANDROID_NDK_HOME}")
|
||||
endif()
|
||||
if(NOT DEFINED NDK_PATH)
|
||||
message(FATAL_ERROR
|
||||
"NDK_PATH not set. Provide -DNDK_PATH=/path/to/android-ndk-r23b "
|
||||
"or export ANDROID_NDK_HOME, or pass -DWRAPPER_BUILD_DAEMON=OFF "
|
||||
"to skip the daemon sub-build.")
|
||||
endif()
|
||||
if(NOT EXISTS "${NDK_PATH}/build/cmake/android.toolchain.cmake")
|
||||
message(FATAL_ERROR "NDK_PATH '${NDK_PATH}' does not look like an NDK install.")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Host build: the chroot launcher
|
||||
# -----------------------------------------------------------------------------
|
||||
add_executable(wrapper src/launcher/wrapper.c)
|
||||
target_compile_options(wrapper PRIVATE -Wall -Wextra -O2)
|
||||
set_target_properties(wrapper PROPERTIES
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}"
|
||||
)
|
||||
|
||||
# Place the launcher at the repo root after build (matches upstream layout
|
||||
# expected by entrypoint.sh and Docker COPY paths).
|
||||
add_custom_command(TARGET wrapper POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
$<TARGET_FILE:wrapper>
|
||||
${CMAKE_SOURCE_DIR}/wrapper
|
||||
COMMENT "Staging wrapper binary at repo root"
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Cross build: the chrooted daemon
|
||||
# -----------------------------------------------------------------------------
|
||||
# We invoke a fully separate CMake configure for src/daemon using the NDK
|
||||
# toolchain file. This produces rootfs/system/bin/main.
|
||||
if(WRAPPER_BUILD_DAEMON)
|
||||
set(_daemon_src "${CMAKE_SOURCE_DIR}/src/daemon")
|
||||
set(_daemon_build "${CMAKE_BINARY_DIR}/daemon-${TARGET_ARCH}")
|
||||
set(_daemon_output "${CMAKE_SOURCE_DIR}/rootfs/system/bin/main")
|
||||
|
||||
if(TARGET_ARCH STREQUAL "x86_64")
|
||||
set(_android_abi "x86_64")
|
||||
elseif(TARGET_ARCH STREQUAL "arm64-v8a")
|
||||
set(_android_abi "arm64-v8a")
|
||||
else()
|
||||
message(FATAL_ERROR "TARGET_ARCH '${TARGET_ARCH}' is not supported (use x86_64 or arm64-v8a).")
|
||||
endif()
|
||||
|
||||
file(MAKE_DIRECTORY "${_daemon_build}")
|
||||
|
||||
# ANDROID_PLATFORM is intentionally low: we ship vendored bionic/AOSP
|
||||
# binaries from an Android 5.0-era system image (no __register_atfork,
|
||||
# no DT_GNU_HASH support in the linker). Targeting API 21 makes NDK
|
||||
# r23b emit code that runs against that older bionic. Apple's libs
|
||||
# ship libc++_shared.so built the same way, so ABIs line up.
|
||||
add_custom_target(daemon ALL
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
-S "${_daemon_src}"
|
||||
-B "${_daemon_build}"
|
||||
-G "${CMAKE_GENERATOR}"
|
||||
-DCMAKE_TOOLCHAIN_FILE=${NDK_PATH}/build/cmake/android.toolchain.cmake
|
||||
-DANDROID_ABI=${_android_abi}
|
||||
-DANDROID_PLATFORM=android-21
|
||||
-DANDROID_STL=c++_shared
|
||||
-DCMAKE_BUILD_TYPE=$<IF:$<CONFIG:Debug>,Debug,Release>
|
||||
-DLIBS_DIR=${CMAKE_SOURCE_DIR}/rootfs/system/lib64
|
||||
COMMAND ${CMAKE_COMMAND} --build "${_daemon_build}" -j
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_SOURCE_DIR}/rootfs/system/bin"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${_daemon_build}/main"
|
||||
"${_daemon_output}"
|
||||
BYPRODUCTS "${_daemon_output}"
|
||||
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
|
||||
VERBATIM
|
||||
COMMENT "Building daemon (NDK, ABI=${_android_abi}) -> ${_daemon_output}"
|
||||
)
|
||||
endif()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Diagnostics
|
||||
# -----------------------------------------------------------------------------
|
||||
message(STATUS "wrapper-v2:")
|
||||
message(STATUS " TARGET_ARCH = ${TARGET_ARCH}")
|
||||
message(STATUS " WRAPPER_BUILD_DAEMON= ${WRAPPER_BUILD_DAEMON}")
|
||||
if(WRAPPER_BUILD_DAEMON)
|
||||
message(STATUS " NDK_PATH = ${NDK_PATH}")
|
||||
endif()
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,225 @@
|
||||
{
|
||||
"$comment": "Pinned native library hashes. Apple .so files are not committed; when staged manually they should match .libs.<arch>. AOSP paths match .android_system.",
|
||||
"apple_music": {
|
||||
"package": "com.apple.android.music",
|
||||
"version": "3.6.0-beta",
|
||||
"build": "1109",
|
||||
"ndk_version": "r23b"
|
||||
},
|
||||
"android_system": {
|
||||
"$comment": "Android system libraries needed at runtime by Apple's .so files. Committed to the repo under vendor/android-system/<arch>/. Apache-2.0 (AOSP). See vendor/android-system/README.md for provenance.",
|
||||
"x86_64": {
|
||||
"bin/linker64": "85982b4aa9a0a5f24f8c1dca285ba08eb6a5dcdbb9820ac719a7e3c77aa4c737",
|
||||
"lib64/libEGL.so": "3897b833e0bf0d56c5ec3895a28682d8273d7c4df1ced68ff06e62979827a94a",
|
||||
"lib64/libETC1.so": "a48f6370d303952bca9c3fd2c0b4c97a31a0a5930c9bfb0d974cff513ab6638e",
|
||||
"lib64/libGLES_trace.so": "9875cf00f54c17c26f7a1270f33e1ec7edddd0890cb700e37c9537692d99fd56",
|
||||
"lib64/libGLESv1_CM.so": "12cce12f3177ddadaf30b904f9cf426d3e9c71aad55d4c25c04e6f3fef90b0c0",
|
||||
"lib64/libGLESv2.so": "ee1d8a85e80f4805137888793f13906cc20dd2ee06dfba909fae0ecbf69ef49e",
|
||||
"lib64/libLLVM.so": "3cb17292f33af0b6ad2de8c44e6a9fcd25861f933d03a554b9671803bdd1232e",
|
||||
"lib64/libOpenSLES.so": "9bd7f62f3b5f047c6f60c7a97fb4be64b1caadc8ea7ce468c3a8e20be5cbb89f",
|
||||
"lib64/libRS.so": "959312aa5bc0ea5a898999c75b924805d5ea3960999633d2f5be1528e0d31c2a",
|
||||
"lib64/libRScpp.so": "83d0b08309a64ec9bf60652ef96b7f2579635039736e463247528352712de97c",
|
||||
"lib64/libandroid.so": "8e9ee27bc19bd3a3b9e1bc24301bf385a22ff18c9f97fff19679ce885f18742e",
|
||||
"lib64/libandroid_runtime.so": "6b7f3fe6db03e2f6e0118504f1c40a89fe28000ecd04d6d2a20961f102306d8d",
|
||||
"lib64/libandroidfw.so": "546535df9106b347525b749b68cbb1372939795215f32005b160563205d31ba2",
|
||||
"lib64/libaudioutils.so": "ad6657529259260ce78fb57a85f52f8f237e02f78c8d75d16588c8bfe807e39a",
|
||||
"lib64/libbacktrace.so": "97046a07be82e646f2da90dae7ed0cf9b69b076c3100f678622db93d72e4517b",
|
||||
"lib64/libbcc.so": "f6a519e0452b736733ae673cfa91dec3d526f27d03807531444177f6b0d6237c",
|
||||
"lib64/libbcinfo.so": "0f69323449c8053695b63c818aa0bdcb32dab1f1aa54f3df760211076e524c04",
|
||||
"lib64/libbinder.so": "b6c0890b23ab130961196e5c09d0e4afc5901a61f1f06f202aae6b63e55fac30",
|
||||
"lib64/libc++.so": "c854f749424bc2019d36bef9da63a185569c6a70613b3db8830d62b99e4c4e87",
|
||||
"lib64/libc.so": "421be2eaae5cd2c730048b4b8ea176d4287e21b126ead4075a52a9b9cc41e51e",
|
||||
"lib64/libcamera_client.so": "4b010eed0129633663298cc569790cdc930b389950ff6b4b567f67927237683a",
|
||||
"lib64/libcamera_metadata.so": "1681a88f4d5e3a0a997f03c052b5a1ea9738181b8149d5a6f16ba20031895ebf",
|
||||
"lib64/libcommon_time_client.so": "54e47ca25a77dd067bc1b331a30281a4217dd7e812f46bb889be168bf157e549",
|
||||
"lib64/libcrypto.so": "8cff426b8b966d465dda91984a87a1272cb23b8b405c1592d6e6e9a57879f7c5",
|
||||
"lib64/libcutils.so": "e50b38ead1b65a9910ee8274a207af1abda6db7880cd7722d2a8eb3507fd4b27",
|
||||
"lib64/libdrmframework.so": "0914786cbacc464409a3a25eae291568f98917d42b64f3f14a181769f7db6749",
|
||||
"lib64/libeffects.so": "3ee4c19e86a8a98426db97f210cccd98041a0a77bc1308f3faba3ffc2d6d2291",
|
||||
"lib64/libexpat.so": "7db72b51eee87b8162df047b5a4eaf086bd076bd8e37b88f5c84c567f75f721b",
|
||||
"lib64/libft2.so": "8ac64e7b7fe86be6fbb717f1981c96cf5974f092e364397c186f98017ce84195",
|
||||
"lib64/libgabi++.so": "332cdd74588f040422b21b37ad16264cabdb50c64f8c7a18b6184498a89b4c66",
|
||||
"lib64/libgccdemangle.so": "6724c3212b762c8c5dbcb0f76d485204f49542923540d97b4a27ee8a6b18a851",
|
||||
"lib64/libgui.so": "462d0013640d9074639ddb222c4ca6a4e798a9f9af0ed10dc734501fc8c90a28",
|
||||
"lib64/libhardware.so": "fdfe37ace293f0a766f13a632ff4b21223abb0897ea807c875e45846b12a7b30",
|
||||
"lib64/libhardware_legacy.so": "8842fa4376ce6388f7538a1a347f94cfa25f06ddc2c012265831a077d5a67336",
|
||||
"lib64/libharfbuzz_ng.so": "5435e3617594fd1e738d2b920cfdc4a1d22ef85363186a8ec8e2f70c3cb27679",
|
||||
"lib64/libhwui.so": "6b057a0892f6c333f15848ab124ba346d745bb8ba82314acc6a90ba3243270e2",
|
||||
"lib64/libicui18n.so": "ae6c3d0989db44b5b6c75f858c9d7e3021248a714b5569507f4f7b94ab4b1440",
|
||||
"lib64/libicuuc.so": "a59d30bfd8104bf23ffdae1f07b9f7e7316f65b5b3a3d5b81c45694d84b84a0c",
|
||||
"lib64/libimg_utils.so": "94ea3388b058103be175086fe664f8db6a3630d1e825b70fd720ef549e59f9f6",
|
||||
"lib64/libinput.so": "0c5a1f046291d390806927d9c9dcc44bddaa51e4ffb41671c83d44172855dbd4",
|
||||
"lib64/libinputflinger.so": "eccfdf5e7d4f7d853dec1fcec6f20e9088c3eb362163682aadf4d0e39f3c14da",
|
||||
"lib64/libjpeg.so": "e7a24eff0714521e37527dece9214cb1f19d46e2439411f2f8d49eb575d1c5fb",
|
||||
"lib64/liblog.so": "f312d563748c1e15cdc3ec3469b16331f7fdaa6ad9b922c7ccbbf1ad38840d02",
|
||||
"lib64/libm.so": "0aa4a18dcc9ae8091de4e6636e143ff62e04c800bffafe2f1315427a33a62e84",
|
||||
"lib64/libmedia.so": "ec9ddd6841221d362214fcec50450e7c0a8ae1d3f4af3788b3da381e2acb817c",
|
||||
"lib64/libmemtrack.so": "59ed8b60461b327ae1497178800788daa7253d1e736bae6005d22bdd31b31fc4",
|
||||
"lib64/libminikin.so": "6b89b31f2d803fb944610e33b3a3b018fb2c04ea3f25bd7a35fa9a4355308579",
|
||||
"lib64/libnativebridge.so": "2ef850ef9e41e7faa4677a528022b8350e2878b970412a1ad870c5a814856aa4",
|
||||
"lib64/libnativehelper.so": "1f7a91bb32248c0cba1af91a315543d25cb8d7d48365aeaa53884452e07103bf",
|
||||
"lib64/libnbaio.so": "020203924ddc35e0b1ca11b5a4d7e97b2ebe072ea094dfcb1704350ef94e7a69",
|
||||
"lib64/libnetd_client.so": "c3c8242fcfc83abcafaafb5f25016884ff98ba131711bb5b04c558b831a26d40",
|
||||
"lib64/libnetutils.so": "1f59f6798eb13528b71435d5ba0825847acff5edb3cebdfbb09337874a119c57",
|
||||
"lib64/libopus.so": "76b2e2dd4270200f846feac778397eecfa94e1dbf0b0c46a18fcd43b4e80b1cf",
|
||||
"lib64/libpdfium.so": "e2e03e9d4c617659705bac564664feb7fd881a725e6b767dfed4d101d9f2f1f1",
|
||||
"lib64/libpng.so": "f1c5bfc2d5d4270581e5e5b6e9a911742658136efb5f977af5d5930557a8bf62",
|
||||
"lib64/libpowermanager.so": "036fb1c24391a75b7017fe95c83dbd8f0623fa1e504903ccd2ca685ca8345eb0",
|
||||
"lib64/libprocessgroup.so": "6158400be15bc9d3ef89b1d0b0e79bb280d62fd4cf63307493b55762e5a9df00",
|
||||
"lib64/libselinux.so": "2f0569cb34e7b11302142b7cb8a34efefc8bb8472b6b1a6c7d39b7c23f20af09",
|
||||
"lib64/libskia.so": "5db2ed76d49a752b0d3538f1aa865a5eb71a0fd0e02ac4aaa54bc4d80cb956c5",
|
||||
"lib64/libsonivox.so": "f17ded03cdd3569cda816082e7b156861414b21dbe87f92545b8b3b22724fcf8",
|
||||
"lib64/libsoundtrigger.so": "225358c3a5f3c9b84e239c11505bf3317960aacf931f5baf2186645c175d1756",
|
||||
"lib64/libspeexresampler.so": "cccfaab64e58ce9e8d57539569c052c3acd49f58fced0c0ce3fc88a2b99b2106",
|
||||
"lib64/libsqlite.so": "d0b7e563c0b874a11ef0d1c48bd86d0dbd3216510dd27ab8f6db317eb4199963",
|
||||
"lib64/libssl.so": "8051495241833b0c86ceeca51bf0d360ff58c220d75d630ecf7dedde13026a63",
|
||||
"lib64/libstagefright.so": "0c7de7fffa5cb1d0d6998b5fc11ccf90e49e17b6949e0b7b8dd4ae42bfe97332",
|
||||
"lib64/libstagefright_avc_common.so": "f5354c23f43746c5a8d2050d1c2163a0755abd94c2bc059aa25fa257af3648b5",
|
||||
"lib64/libstagefright_enc_common.so": "74c1442a588683dd313ac96a024bf4ac9a4b2ec7f05ee56e2a89db542d5aeb64",
|
||||
"lib64/libstagefright_foundation.so": "39b828c6b05f5990f151d92ded661334ffe978887df936b5f24ca8bd0afa471f",
|
||||
"lib64/libstagefright_http_support.so": "120e2907561a0b72ae4a502352845209516950697b0c912f251d9da1eadfe69e",
|
||||
"lib64/libstagefright_omx.so": "0d9fed3e95ff112d7833994223e6197f7bebc17efec3befcb71e7e30ca5eb410",
|
||||
"lib64/libstagefright_yuv.so": "91b6a7bdd92e20b1a61f3722cde6730dc4530f2d37d0bf26c6e9dfb0ede823be",
|
||||
"lib64/libstdc++.so": "4d65dd2fe51ac841cb734ce198c6709d5dd2e76393964ac7a6ebc99306ff38d3",
|
||||
"lib64/libstlport.so": "a8a1b79a77aff5918477c2b52f14ebc3715efdf73c05c3a79396e03384f27bec",
|
||||
"lib64/libsync.so": "fdefe4820145826961a830f865c686b09eaad25a14640b92f04ef0a662d8b31b",
|
||||
"lib64/libui.so": "a7674dd0ccad7b55f60221961bda68862a527f3921951b65e89694a2f454abfc",
|
||||
"lib64/libunwind-ptrace.so": "481de65399c61d0671b43abc37086ec9550044f08800412cba9b52f5dfec8d6c",
|
||||
"lib64/libunwind.so": "93579f0545253735391b1dc27a69e44ca8b8237967ef66646afa028cfb093816",
|
||||
"lib64/libusbhost.so": "fc7f99a6b03889b31d70008e31a939941e2668281b91995131a7ad4f53cbf3fb",
|
||||
"lib64/libutils.so": "0697bc44481570774af63367e7082c01297997bec0ca86f488948e25a986e2b7",
|
||||
"lib64/libvorbisidec.so": "83eccc34fcb87cccd05422bda279d3af87e70e803d2dacb2f55fc2cfd2a5de5a",
|
||||
"lib64/libwilhelm.so": "ad6cbba01c6fb9cdf6d33082441ba1a04f6e4c030df6026403f59a1c93e8e6e2",
|
||||
"lib64/libz.so": "e953e12985e0d756445e49ace84bd7c0e25c48616d3a572b0037a4c920b38e22",
|
||||
"usr/share/zoneinfo/tzdata": "586f372496315b1563e62a1a28d18adf65dea5a7b7453d7e0a91df9dfc2c8482"
|
||||
},
|
||||
"arm64-v8a": {
|
||||
"bin/linker64": "869ca3c5781c1c32f35d9067ae627e075181babf4996f7c3a20ff32610efc094",
|
||||
"lib64/libEGL.so": "0cd67d47a531e1be95f1ebe1d55043e374d3f8ce498c752dae656a0bea8b8ae4",
|
||||
"lib64/libETC1.so": "9477838b36672691ad3071ac6dbac20dbca43d608663db9ac9b9ca830eecb5fd",
|
||||
"lib64/libGLES_trace.so": "7beec44ae8bef4e61e2c47d2f04e22dc48821b6102e0bfa8b8dcb61621af50b9",
|
||||
"lib64/libGLESv1_CM.so": "a64e796eb683007c157c74cf250d2604a35b7ec20784e7df352e7756b65f2ed9",
|
||||
"lib64/libGLESv2.so": "73d89aa60cb3e916ed6855942efa5fe943141814dbad857854ad575b244d812d",
|
||||
"lib64/libLLVM.so": "a655d05bd577b3380033fa84e66e5677478c5f24f9a213c89661f0ed1009fd8a",
|
||||
"lib64/libOpenSLES.so": "e6f9e61eb33dc78b04f43150124573d6ecdd911c4381585a468066de80c2ad0c",
|
||||
"lib64/libRS.so": "f5bbd1864c1d33f4a75010944c98f880916734513a662acba499edf396dc85ec",
|
||||
"lib64/libRScpp.so": "4a293203213421e0f9674849c4b32165795ca9a496bc2c1548722a76ef5e212c",
|
||||
"lib64/libandroid.so": "2e24d2fcd2800ee0e041bd0acb590c70b5c4c86aeb41a60db39481dbac8a878d",
|
||||
"lib64/libandroid_runtime.so": "c2749580dd52b1bf5b67d33f68a828b4c41e74bab9e211900ab38f0f57913fa5",
|
||||
"lib64/libandroidfw.so": "5ae6a507ddc0126cb8b3a0d75e3b3d2ef56ba69166f1cd89821b4c0d04fbf208",
|
||||
"lib64/libaudioutils.so": "28d73d0859d6a9db34f0359ab83fb6bee352d1c5fabb46910a9d4c07a26ad887",
|
||||
"lib64/libbacktrace.so": "137e0029678383e7efe715ee6e75dc4833f88c224e8ceef4b1297fdeb50edefe",
|
||||
"lib64/libbcc.so": "babd72ed6d5f8c7e221fc0f30e13529357683ad8fbaed781d77dd7c9955e326e",
|
||||
"lib64/libbcinfo.so": "ea0b40b279107af86a159536deb8819544f33dd478829d6cc582d01640424e8d",
|
||||
"lib64/libbinder.so": "5847ee51c3bc96af1ed08b8a2b23a91853185e4e91121adccbbed2c38c2540d2",
|
||||
"lib64/libc++.so": "e255ce426593c963283c3702b444c90b60c5c99b82ac95b78335a72bd6aaea9d",
|
||||
"lib64/libc.so": "659d21c18e2e3ea83621555227d7b931bf9db7c1a413caf10ee1fd4408ec7dee",
|
||||
"lib64/libcamera_client.so": "4c088e43c35fb210f21fd4f899c69db11d2ac1e4d409567c09ea65e789215593",
|
||||
"lib64/libcamera_metadata.so": "e49d55a4c767a0fcdf596d66e694007352e6369e41e7bee0e30df724f448ba1b",
|
||||
"lib64/libcommon_time_client.so": "654e69d7b891b6c6a2b5e370b0b5e47e09ed67a705626e2196b8c6ef3195a28c",
|
||||
"lib64/libcrypto.so": "e1bc23079146cdc600cbfeb82a4a084f3a6908c76b48f41fd21211f63c8da8af",
|
||||
"lib64/libcutils.so": "b5a40fa431f1841cda763ec4b10cb89af9df823fbd65836e0c79aee0cad7186e",
|
||||
"lib64/libdrmframework.so": "326b6d3197f76745ecb9e51817b7a4d031c9c84e28a37337f645286a8fe1df29",
|
||||
"lib64/libeffects.so": "e821b2dfb55abffc0d67948105ce042a70dbde905eb85d1597b5ba82f330a472",
|
||||
"lib64/libexpat.so": "0cad10b0f6770d5bd68075e486306ab5389c773b623d5d163abba54ebd0a3ec2",
|
||||
"lib64/libft2.so": "4420226d33f197f91e8e9eb5c1487f3060f334176759924d7660b5837d67eba0",
|
||||
"lib64/libgabi++.so": "dc11e7d6c0111598366dfdbd76251ce424336a8eec88299a31bf0988fb503145",
|
||||
"lib64/libgccdemangle.so": "7b96280ca7f72fea67abb83d7b45670899f5c2d165bff22082763843a6f8c907",
|
||||
"lib64/libgui.so": "23e282ee6aa752bd93f12d52fccd70a30660e98f35bdf14aee3b45cf4a1f2346",
|
||||
"lib64/libhardware.so": "e0adae13e59c1ef61e91841284e9a4d1edd0b8b377dafc9cb1c62e9ad2d84fdd",
|
||||
"lib64/libhardware_legacy.so": "8c99a78242f3e70e58717f39da6a775f21dedab124012786bbb7ab8ec0171b1f",
|
||||
"lib64/libharfbuzz_ng.so": "2ee7516e4b7000377137714db2734179a0e71f4afc74710df685cd4758416786",
|
||||
"lib64/libhwui.so": "1c0cbcc2292b30b4b49c641596cf91feae53f87e3560fc3c23a52b58b702fda0",
|
||||
"lib64/libicui18n.so": "b543a4a0ef8356b1095f91b3352556d0d91562b89b556d0bfce3e44cf3b2a0d9",
|
||||
"lib64/libicuuc.so": "4f20375fdeef3bfcde402ea7f2f93a88014222842d41679d61d411b0a455f281",
|
||||
"lib64/libimg_utils.so": "79769265c9bda7286dac7c34116a748e43bf8c0680f14712a24a33bcffd28073",
|
||||
"lib64/libinput.so": "01518687ee722baa1c79d392037409907a8f9aa1babb54aec4b36ae29ace4fde",
|
||||
"lib64/libinputflinger.so": "76f3b0fdab740ae407cc037a3dac3e259a8b127364fbc33af7a78906f1bf46d0",
|
||||
"lib64/libjpeg.so": "3470636fd1c7c303c59157f0cdc302308815e9b332e9ee335df7861e494b930c",
|
||||
"lib64/liblog.so": "2537da397e5417b925267cb62c313ce1f4b8ab62fd6989b0e8bdac7892051bc8",
|
||||
"lib64/libm.so": "e9dbef4b2902ca8da0c36c0b52a7fc8b4ab045308cbd1d03174286a8d142eccf",
|
||||
"lib64/libmedia.so": "2cdc4b27f625bb623f12fdd84ac9db276d2f6db7fb95026aabc79512370e22a7",
|
||||
"lib64/libmemtrack.so": "0c1241961d924c6ccd5af43497c8f684d8dcdb52f82847b4c1eaa5bfe01d6798",
|
||||
"lib64/libminikin.so": "a949d61f0f89c47c77a60e51b91d0355e907fa452d1056be108349c15517fa04",
|
||||
"lib64/libnativebridge.so": "359a8f005840445971de26490410b0c41f08aabce4b7eac39f341fd6ca289996",
|
||||
"lib64/libnativehelper.so": "a0f70bab4ddca7e9f1d350245f9912282b87eeea91df283066c348412ed84650",
|
||||
"lib64/libnbaio.so": "9753833073cea1e225ac2cb2f547241d250900efa08434468052cb76ddbe9fa5",
|
||||
"lib64/libnetd_client.so": "e68eadae0dd59e9cdc9cf8077ae458816788afacea68919ce6e51e830a98cd29",
|
||||
"lib64/libnetutils.so": "5ef491e362dde12c779e9342e863263249b226c7b385733f5f9faf19f8cf374a",
|
||||
"lib64/libopus.so": "d05d2ee653d00ffe53338d841b2b9fed2e979e40d5b3bbc2b438d0603ab3c096",
|
||||
"lib64/libpcre.so": "46229f7fd0dff4814aaecc8df2030d36ce1ac517880433b25054fb2285fd26bf",
|
||||
"lib64/libpdfium.so": "4cbb3799ebf90884fd8083f662c96d6f21483c9a3c8bb8ab49005012fbe99b64",
|
||||
"lib64/libpng.so": "309934f1ec93c028db323e4567d9fb80fdb9fa3fd56fe0fd0109ffabd289d6c7",
|
||||
"lib64/libpowermanager.so": "1c03f0a4f693a762c4e18421d2a1f8940daeec47cdcd2d86a10eb6bae6301cd7",
|
||||
"lib64/libprocessgroup.so": "7e30271e2edb356862bc119a968eee0f211db0ff28af491d9c31702b55beae7e",
|
||||
"lib64/libselinux.so": "b0037d63c28b455e605958c30e42d9378ae6824e79f29e3b182ae1004082f31d",
|
||||
"lib64/libskia.so": "1458e674cebd11b50c93fce4abdd258cff8fd3033e9c7c546b6088e0f842e3d0",
|
||||
"lib64/libsonivox.so": "c7a0ff605a1f50ada52891042639aa8cdcb355df7391a08347d33556ae1f1639",
|
||||
"lib64/libsoundtrigger.so": "b5f04e600b54516e411816e6deaca681328de861c515dc6b4808be9f0a057a8a",
|
||||
"lib64/libspeexresampler.so": "dceb2fd2c9e87e7f393846f92574161312a31c237132eacbc36bca930cc839ba",
|
||||
"lib64/libsqlite.so": "1c770ecfd71bfa8a994de857449363152f071b7eb24a8d9f8f7686c5b0a1b40d",
|
||||
"lib64/libssl.so": "1879b632f756c436fabb8ca4811ca9b8af49e4068f68ad7b261c9bbe8ded3332",
|
||||
"lib64/libstagefright.so": "00b94b0cd40f2b89a52a6a8b53e4abf23767fcfb0f7fd4cfc4880354571e4782",
|
||||
"lib64/libstagefright_avc_common.so": "ec3ec349b4c19930b3c4cec4e6723495c9cb92c4c28033e01e5019cfca4b15cb",
|
||||
"lib64/libstagefright_enc_common.so": "7424950dc6959db9ea73c082245174245dada99d6d80d1d47e6feeed3c8874cd",
|
||||
"lib64/libstagefright_foundation.so": "129d3d7bdc1341290e929cd6f2952a62f86c9785a1e2b2310d52073eb74a3813",
|
||||
"lib64/libstagefright_http_support.so": "f0ed814d40b2fc46de167cd081ce195338bb669011c49d79ff6bd2e4fd7113bd",
|
||||
"lib64/libstagefright_omx.so": "6e5f4704f05c7f2b6e6ce70ebb7a66e42466b2597b400de081416b53a0ae0662",
|
||||
"lib64/libstagefright_yuv.so": "2d03e5c45eaf206cf8229963ee1a69375fd87957d6378fe10138fc4035867a47",
|
||||
"lib64/libstdc++.so": "a1a7bbaa275dea74aa7aef20ee53f77c2b607bd035cb6bf28d2daaf6b60e7e1e",
|
||||
"lib64/libstlport.so": "cd3b5629c574c3340d5d277f22d3903aba0db5ca0704b044c0587b2f16d47825",
|
||||
"lib64/libsync.so": "5cc064479293de7a7e78cce715900b157ee98f0c3748377c0f5dac31f7fb3fe6",
|
||||
"lib64/libui.so": "d9bb04eee8399d89f38208e7f0d733c358b630c0c571a4addb39c953601784d4",
|
||||
"lib64/libunwind-ptrace.so": "d305606d1096a8c7325f7a6c87279306ed33f8cb33e696d183ce21badd56c71c",
|
||||
"lib64/libunwind.so": "c5fab447a096b570c267e714b864ae29d3bcd4eb0bb0f16a22404b159fef0b3c",
|
||||
"lib64/libusbhost.so": "5be0c5b127c2f9feccbeed65fd1a9fe91542bebae677a611d882243b875841ad",
|
||||
"lib64/libutils.so": "42f589f49912f34b25c7d579bf8a66764a1267b9e078619aa7d26faab014e66b",
|
||||
"lib64/libvorbisidec.so": "bfba62d8598022b778805482e5c1b4c6297e80bf7a58634ccfa3e3561927c3bb",
|
||||
"lib64/libwilhelm.so": "8418709da5ec21fae5dbfd73da7dce813d5b2635b7438a7346251392f27e769c",
|
||||
"lib64/libz.so": "26f0f06e40b212bb55f17eb4df812438b3e993e6485f6267bdd4225d2dfda8e1",
|
||||
"usr/share/zoneinfo/tzdata": "586f372496315b1563e62a1a28d18adf65dea5a7b7453d7e0a91df9dfc2c8482"
|
||||
}
|
||||
},
|
||||
"libs": {
|
||||
"x86_64": {
|
||||
"libBlocksRuntime.so": "9814b756ae173a546eb12bf1019746f14b17a6546806445ff67f75b0aba8856a",
|
||||
"libCoreADI.so": "b5b971cea0bbe828bdcfaf219fd91fc1e37cf8daee84001ef917d0b1c2b985ed",
|
||||
"libCoreFoundation.so": "c5727cecfa18833a6828caf2072aa6a4edf39b73752597e4de35854ea9a71ce0",
|
||||
"libCoreFP.so": "81ba0dbd857a9e76402434ee13756051a77de91ec18d637ea4350687e0da39b3",
|
||||
"libCoreLSKD.so": "bf813206d382bd8b27384bd546a5b9aa730df32c8543d709756b081b619a8b3a",
|
||||
"libandroidappmusic.so": "2fcdf31a0b3f0f8841ef63cf46efffcf9672a2cea0eef5b066c537d43e293083",
|
||||
"libc++_shared.so": "82ffc20ba230bbdd031b27853d6cfaaa27079aabe6dd478cafd9b6fa866a35c2",
|
||||
"libcurl.so": "922d6c70ab8a86a761046c70902d3d6c56b136fb2a3b21dbc5a4e3fe5f6130ae",
|
||||
"libdaapkit.so": "e70575f2f3279141d03c91dff7400151e321fb30051f170e25bdbeae862a7bbd",
|
||||
"libdispatch.so": "7d360c1e81c6982b70b32bdc4e343fd586d727722fd6562df211aaf1955b0e2c",
|
||||
"libicudata_sv_apple.so": "afe9021fd9fee67f1f2909f3bb603f0362e93be5ef761d4dee9812249b2aa0f0",
|
||||
"libicui18n_sv_apple.so": "7ae2e3c0e78e54e94a2ce2d7f3efbb4375e2505769f4ebeb43f05ac7d344ff76",
|
||||
"libicutest_sv_apple.so": "3bc7b14ca5a9eefcbfdbaa943a0ff81a9406832ecd6ac5d18fb818569a733750",
|
||||
"libicuuc_sv_apple.so": "cac055936358d0ea35021240a7a36a2110994f3c8883a47bb28fd2c634e6f069",
|
||||
"libmedialibrarycore.so": "3c42bf5f52d90931d65e463f44720a28c60f575416a6420ad71682e1be91e6b7",
|
||||
"libmediaplatform.so": "cb4ab591e0ea6c74cbcbe23b616566425172efc77f83933b6aa7a3ffcc88de6d",
|
||||
"libstoreservicescore.so": "0ba169fec7b5a0f70113ab01ba6c60b893c36f9aa080797b13d3830dd3784010",
|
||||
"libxml2.so": "41422a7384114f4a98fa7a09e019c9f2aec07c429d6d2ae4b63022b111325109"
|
||||
},
|
||||
"arm64-v8a": {
|
||||
"libBlocksRuntime.so": "f7ebfdcf5115c6bf2073c79c498bcf6652f2501a697774a053cab4f6cf3e638a",
|
||||
"libCoreADI.so": "db8779a9e1b026a087cd9a35c7d69f8af51418a29249ef325e6391ec560f6f47",
|
||||
"libCoreFoundation.so": "9ccf6cd1507a3682084ebd3f749932f588430c092ba8fa9fbfbd44fea4abc7d2",
|
||||
"libCoreFP.so": "27200468fbe5e4404edf466e3dd9d5f2fa01fe5835dc60aa76619d55721a447e",
|
||||
"libCoreLSKD.so": "36e49510c1c3b0c133b263cfc6996f79cda112320b51d5f9dbaa1175cab6d8d9",
|
||||
"libandroidappmusic.so": "3e0af0b8aa95bfc79a27d7dba54fc3ee77f6878347e63f4ea549713b0fdeb26e",
|
||||
"libc++_shared.so": "a94a44bc0abd5729ef55a43b3afcacbc064b7c40041c9df4c33950a60f36652b",
|
||||
"libcurl.so": "42226f2c2e9151d59e9db9020fb115dcbfe4396762946437c62b12a3c3000fa7",
|
||||
"libdaapkit.so": "e38a16d14979f3c6ab884fbb6510c526667d5b2e90e86fc96e9d7dbcc862fcae",
|
||||
"libdispatch.so": "d82bcc112e828fe23111e9de5359a5f242aa3791c891fefb3c13a08600c2dc86",
|
||||
"libicudata_sv_apple.so": "62f30712e07a85fcb2977a613cbb2fd95f6224029ca2438a749cc7390fb89e4e",
|
||||
"libicui18n_sv_apple.so": "d249ef1f5e5994a6800966daa2ec9a00316cadb4ad8e9954afeb66ae8aece0a4",
|
||||
"libicutest_sv_apple.so": "84c72c42616a4e7b403c83ec339a3035cbd8d4054ed7a117fa93af264a34a8d0",
|
||||
"libicuuc_sv_apple.so": "391fac15907aa57008ad4f61b5053b6f55f5d17946adb3bc949f7d58dc2fb885",
|
||||
"libmedialibrarycore.so": "8b04fc00c9936e8be774659896bff2d9e4434eb1b252df458c66e26dc8b88be3",
|
||||
"libmediaplatform.so": "6f5e1121441b21ec42b854576858fb27617e74441d420c46d824666ec152dc34",
|
||||
"libstoreservicescore.so": "a6b3440c5e2bc747cbc50cf0d337c7ff42dcd5ed5ddb8080889fa63c1263df16",
|
||||
"libxml2.so": "a7349a425f3cd59080d661b0507cee1c0057c7ce5db791c73078dac26bbc6dd5"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or distribute
|
||||
this software, either in source code form or as a compiled binary, for any
|
||||
purpose, commercial or non-commercial, and by any means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors of
|
||||
this software dedicate any and all copyright interest in the software to
|
||||
the public domain. We make this dedication for the benefit of the public
|
||||
at large and to the detriment of our heirs and successors. We intend this
|
||||
dedication to be an overt act of relinquishment in perpetuity of all
|
||||
present and future rights to this software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
|
||||
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <https://unlicense.org>
|
||||
@@ -0,0 +1,297 @@
|
||||
# wrapper-v2
|
||||
|
||||
A clean rewrite of the Apple Music FairPlay decryption wrapper, based on
|
||||
[`WorldObservationLog/wrapper`](https://github.com/WorldObservationLog/wrapper).
|
||||
|
||||
## Development note
|
||||
|
||||
This project has been developed with heavy AI assistance. The code should be
|
||||
treated as research-grade and reviewed carefully, especially around native ABI
|
||||
calls, FairPlay state handling, and experimental endpoints. AI-generated changes
|
||||
are not assumed to be correct just because they compile.
|
||||
|
||||
## What it is
|
||||
|
||||
A small daemon that exposes a local HTTP API for FairPlay key fetching and
|
||||
sample decryption, and gives downstream tooling (e.g.
|
||||
[`gamdl`](https://github.com/glomatico/gamdl)) a uniform interface that does
|
||||
not depend on platform or language.
|
||||
|
||||
At runtime the binary starts in **supervisor** mode by default. The supervisor
|
||||
owns the public HTTP port and starts a private `WRAPPER_MODE=worker` subprocess on
|
||||
`127.0.0.1:${WRAPPER_WORKER_PORT:-18080}`. Only the worker loads Apple Music's
|
||||
Android native libraries inside the Linux chroot. If FairPlay hangs or returns
|
||||
a CKC/KD-style decrypt error, the supervisor can kill the worker, start a fresh
|
||||
one, and retry the decrypt request without dropping the public HTTP server. If
|
||||
the worker cannot be started three consecutive times, the supervisor exits so
|
||||
the container supervisor can recreate the whole runtime.
|
||||
|
||||
The daemon ships _no_ Apple code. Apple Music native libraries must be supplied
|
||||
by the person building the image and staged into `rootfs/system/lib64/`; the
|
||||
expected `.so` SHA-256 digests are pinned in `LIBS_VERSION.json`.
|
||||
|
||||
## HTTP API
|
||||
|
||||
Most endpoints accept and return `application/json`. `POST /decrypt`
|
||||
uses `application/octet-stream` for successful request and response bodies;
|
||||
errors still return JSON.
|
||||
|
||||
| Method | Path | Description |
|
||||
| -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GET` | `/health` | Liveness probe. `{status, version, runtime}` — `runtime.playback_ready` is true when FairPlay decrypt is available. |
|
||||
| `GET` | `/me` | `{version, runtime, auth}` — same runtime flags as `/health`. |
|
||||
| `POST` | `/login` | Body: `{"username": "...", "password": "..."}` or `{"apple_id": "...", "password": "..."}` (synonyms). Drives Apple's `AuthenticateFlow`. Returns `200` + token snapshot, `202` if **2FA** is required (then `POST /login/2fa`), or `401` on failure. |
|
||||
| `POST` | `/login/2fa` | Body: `{"code": "123456"}`. Continues a login waiting for HSA2. |
|
||||
| `GET` | `/playback` | Query string `?adam_id=<numeric store id>`. Returns `200` with a JSON object `{"songList":[...]}` containing the **whole MZ playback dispatch** Apple's `subDownload` URL bag returns (every flavor, key URI, asset URL, metadata field). CFData fields are base64; CFDate fields are ISO 8601. Needs an **authenticated** session; otherwise `401` / `503`. Apple errors → `502`. |
|
||||
| `POST` | `/decrypt` | Binary FairPlay sample decrypt batch. Request frame contains `adam_id`, SKD `uri`, and one or more encrypted samples. Response frame contains plaintext samples. Needs **authenticated** session and `playback_ready`; otherwise `401` / `503`. On FairPlay errors or worker timeouts, the supervisor restarts the worker and retries once before returning the final result. |
|
||||
| `DELETE` | `/login` | Aborts an in-flight login or clears cached tokens from memory. Apple's on-disk `mpl_db` cache is unchanged. |
|
||||
|
||||
### `POST /decrypt` Binary Format
|
||||
|
||||
All integer fields are unsigned 32-bit big-endian.
|
||||
|
||||
Request body:
|
||||
|
||||
```text
|
||||
adam_id_len
|
||||
uri_len
|
||||
sample_count
|
||||
sample_len[0]
|
||||
...
|
||||
sample_len[sample_count - 1]
|
||||
adam_id bytes
|
||||
uri bytes
|
||||
sample[0] bytes
|
||||
...
|
||||
sample[sample_count - 1] bytes
|
||||
```
|
||||
|
||||
Response body:
|
||||
|
||||
```text
|
||||
sample_count
|
||||
sample_len[0]
|
||||
...
|
||||
sample_len[sample_count - 1]
|
||||
sample[0] bytes
|
||||
...
|
||||
sample[sample_count - 1] bytes
|
||||
```
|
||||
|
||||
The endpoint accepts and returns `application/octet-stream` on success.
|
||||
Validation and Apple/native errors use the normal JSON error envelope.
|
||||
|
||||
Sign-in matches the legacy wrapper model: you send **email (Apple ID) and password**
|
||||
to the daemon; it fills credentials through the native presentation interface.
|
||||
With a persistent `WRAPPER_BASE_DIR` volume, Apple keeps `mpl_db/kvs.sqlitedb` on
|
||||
disk. On each process start the daemon tries **session restore** (default
|
||||
`WRAPPER_RESTORE_SESSION=1`): if that session is still valid, `GET /me` can show
|
||||
**authenticated** and fresh tokens **without** another `POST /login`. Use
|
||||
`POST /login` when the volume is new, restore fails, or you need to re-auth.
|
||||
Optional `WRAPPER_APPLE_ID` only sets the `apple_id` label in `/me` after restore.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
.
|
||||
├── CMakeLists.txt top-level build (host launcher + NDK sub-build)
|
||||
├── Dockerfile multi-stage build
|
||||
├── compose.yaml docker compose entrypoint
|
||||
├── LIBS_VERSION.json per-.so SHA-256 digests
|
||||
├── src/
|
||||
│ ├── daemon/ C++ daemon (cross-compiled with the NDK)
|
||||
│ │ ├── CMakeLists.txt
|
||||
│ │ ├── main.cpp process entry: env parsing, lifecycle
|
||||
│ │ ├── server.{hpp,cpp} HTTP route mounting (cpp-httplib)
|
||||
│ │ └── apple/
|
||||
│ │ ├── abi.hpp Apple-lib mangled symbol declarations
|
||||
│ │ ├── auth.{hpp,cpp} Apple ID login + 2FA + token cache
|
||||
│ │ ├── loader.{hpp,cpp} dlopen / dlsym
|
||||
│ │ ├── runtime.{hpp,cpp} FootHillConfig + RequestContext + credential UI
|
||||
│ │ └── tokens.{hpp,cpp} dev token + music user token harvest
|
||||
│ └── launcher/
|
||||
│ └── wrapper.c host-Linux chroot launcher
|
||||
├── rootfs/ chroot tree assembled at build time
|
||||
│ └── system/
|
||||
│ ├── bin/ <- main, linker64 (staged)
|
||||
│ └── lib64/ <- Apple's .so + Android system .so (staged)
|
||||
├── tools/
|
||||
│ ├── extract-libs.sh optional local helper to extract and verify Apple .so files
|
||||
│ └── stage-system.sh copy committed Android binaries into rootfs/
|
||||
└── vendor/
|
||||
└── android-system/ linker64 + bionic + AOSP libs, SHA-pinned
|
||||
├── x86_64/
|
||||
│ ├── bin/linker64
|
||||
│ └── lib64/*.so
|
||||
└── arm64-v8a/
|
||||
├── bin/linker64
|
||||
└── lib64/*.so
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
### One-time setup
|
||||
|
||||
You need a working Docker installation. Apart from that, the entire build
|
||||
runs inside the image. There is no host toolchain prerequisite for the
|
||||
default workflow.
|
||||
|
||||
For the build to succeed, `rootfs/system/lib64/` must already contain the
|
||||
required Apple Music native libraries for your `TARGET_ARCH`. The recommended
|
||||
source version is Apple Music for Android **3.6.0-beta**. This repository does
|
||||
not provide the download for those files.
|
||||
|
||||
### Local build
|
||||
|
||||
#### 1. Extract Apple Music native libraries
|
||||
|
||||
Provide a local Apple Music `.apk` or `.apkm` for the target architecture. The
|
||||
default output is `rootfs/system/lib64/`, and every extracted `.so` must match
|
||||
the hashes in `LIBS_VERSION.json`.
|
||||
|
||||
```bash
|
||||
bash tools/extract-libs.sh --bundle path/to/local/apple-music.apk --arch x86_64
|
||||
```
|
||||
|
||||
`.apkm` bundles are also accepted:
|
||||
|
||||
```bash
|
||||
bash tools/extract-libs.sh --bundle path/to/local/apple-music.apkm --arch x86_64
|
||||
```
|
||||
|
||||
#### 2. Stage Android system binaries
|
||||
|
||||
This copies the committed Android linker and system libraries into `rootfs/`,
|
||||
verifying their SHA-256 hashes against `LIBS_VERSION.json`.
|
||||
|
||||
```bash
|
||||
bash tools/stage-system.sh --arch x86_64
|
||||
```
|
||||
|
||||
#### 3. Build and run
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
#### 4. Smoke test
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1/health
|
||||
curl http://127.0.0.1/me
|
||||
```
|
||||
|
||||
#### 5. Sign in
|
||||
|
||||
Use your real Apple ID. If the first request returns `202`, continue with the
|
||||
2FA request.
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1/login \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"username":"you@example.com","password":"your-app-specific-password"}'
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1/login/2fa \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"code":"123456"}'
|
||||
```
|
||||
|
||||
Check the current session or clear the in-memory login state:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1/me
|
||||
curl -X DELETE http://127.0.0.1/login
|
||||
```
|
||||
|
||||
The daemon binds port 80 inside the container and the compose file maps it
|
||||
to host port 80 by default. Override with `HTTP_PORT=8080 docker compose up`
|
||||
on machines that already have something on `:80`.
|
||||
|
||||
### arm64-v8a image (Apple Silicon / AArch64 Linux)
|
||||
|
||||
Stage **arm64-v8a** Android system binaries and Apple Music native libraries,
|
||||
then build a **linux/arm64** image so `wrapper`, the NDK daemon, and the staged
|
||||
`linker64` / `.so` set share the same ABI.
|
||||
|
||||
The Docker **compile** stage is always **linux/amd64** (Google ships the Linux NDK as an
|
||||
x86_64-host ZIP only). The image then cross-compiles `wrapper` for AArch64 when
|
||||
`TARGET_ARCH=arm64-v8a`. Set **runtime** platform to arm64; `BUILD_PLATFORM` in Compose is
|
||||
ignored but kept for compatibility.
|
||||
|
||||
Extract and stage the arm64 files:
|
||||
|
||||
```bash
|
||||
bash tools/extract-libs.sh --bundle path/to/local/apple-music.apk --arch arm64-v8a
|
||||
bash tools/stage-system.sh --arch arm64-v8a
|
||||
```
|
||||
|
||||
Or use a local `.apkm` bundle:
|
||||
|
||||
```bash
|
||||
bash tools/extract-libs.sh --bundle path/to/local/apple-music.apkm --arch arm64-v8a
|
||||
bash tools/stage-system.sh --arch arm64-v8a
|
||||
```
|
||||
|
||||
Build the arm64 image:
|
||||
|
||||
```bash
|
||||
TARGET_ARCH=arm64-v8a RUNTIME_PLATFORM=linux/arm64 \
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
On an **x86_64** host, `docker compose` / `docker run` need **QEMU** (binfmt) to run a
|
||||
`linux/arm64` container. On an **arm64** host, run the image **natively** (no emulation).
|
||||
|
||||
### Daemon configuration
|
||||
|
||||
The daemon reads `WRAPPER_*` environment variables (forwarded via
|
||||
`compose.yaml`). See `.env.example` for the full list. The most useful are:
|
||||
|
||||
- `WRAPPER_HOST`, `WRAPPER_PORT` - public supervisor bind address inside the
|
||||
chroot.
|
||||
- `WRAPPER_MODE` - process role. Default `supervisor`; the supervisor sets
|
||||
`worker` automatically for its private subprocess.
|
||||
- `WRAPPER_WORKER_PORT` - private loopback port used by the supervisor to talk
|
||||
to the Apple runtime worker. Default `18080`.
|
||||
- `WRAPPER_BASE_DIR` - filesystem dir Apple's libs use for the FairPlay
|
||||
key cache and `mpl_db`. The default matches upstream wrapper.
|
||||
- `WRAPPER_RESTORE_SESSION` - set to `0` to skip startup token harvest from
|
||||
an existing on-disk Apple session (default is restore on).
|
||||
- `WRAPPER_APPLE_ID` - optional display label for `apple_id` in `GET /me` after
|
||||
session restore only (not sent to Apple).
|
||||
- `WRAPPER_DEVICE_INFO` - 9-tuple identifying the fake Apple Music
|
||||
Android client. Same fingerprint upstream uses by default.
|
||||
- `WRAPPER_APPLE_INIT=0` - skip Apple lib initialization at startup.
|
||||
Lets you bring up the HTTP server alone for `/health` smoke tests
|
||||
even on builds where you have not staged the Apple libraries yet.
|
||||
- `WRAPPER_USERNAME` + `WRAPPER_PASSWORD` - if both are set and the runtime
|
||||
initialized, the daemon runs password sign-in at startup when not already
|
||||
authenticated (same semantics as `POST /login`; 2FA still needs
|
||||
`POST /login/2fa`). Treat these as secrets.
|
||||
|
||||
### CI build
|
||||
|
||||
The `.github/workflows/build.yml` workflow runs on **push** to `main`,
|
||||
on **pull_request** (same-repo only for the full job), and **workflow_dispatch**.
|
||||
It uses the same host steps as above plus a Docker build and `/health` smoke
|
||||
test, with one repository secret:
|
||||
|
||||
- `APK_URL` - private/local CI URL for a compatible Apple Music `.apk` or
|
||||
`.apkm`. The artifact is downloaded inside CI only, extracted with
|
||||
`tools/extract-libs.sh`, and is not committed.
|
||||
|
||||
**Matrix:** both `x86_64` and `arm64-v8a` jobs use `ubuntu-latest`. The arm64 image is
|
||||
`linux/arm64` at runtime; QEMU is enabled before the smoke `docker run` so the job works
|
||||
on amd64 GitHub runners. The compile stage stays **linux/amd64** for the official NDK ZIP.
|
||||
|
||||
Pull requests opened from forks skip the build job because they cannot read the
|
||||
secret.
|
||||
|
||||
## License
|
||||
|
||||
[Unlicense](./LICENSE) - public domain dedication.
|
||||
|
||||
This project is not affiliated with Apple Inc. The Apple-authored libraries
|
||||
it loads at runtime are not redistributed by this repository.
|
||||
@@ -0,0 +1,28 @@
|
||||
services:
|
||||
wrapper:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
TARGET_ARCH: ${TARGET_ARCH:-x86_64}
|
||||
BUILD_PLATFORM: ${BUILD_PLATFORM:-linux/amd64}
|
||||
RUNTIME_PLATFORM: ${RUNTIME_PLATFORM:-linux/amd64}
|
||||
image: wrapper-v2:latest
|
||||
container_name: wrapper-v2
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
- SYS_CHROOT
|
||||
- SYS_PTRACE
|
||||
security_opt:
|
||||
- apparmor:unconfined
|
||||
ports:
|
||||
- "${HTTP_PORT:-80}:80"
|
||||
environment:
|
||||
WRAPPER_USERNAME: ${WRAPPER_USERNAME:-}
|
||||
WRAPPER_PASSWORD: ${WRAPPER_PASSWORD:-}
|
||||
WRAPPER_APPLE_INIT: ${WRAPPER_APPLE_INIT:-}
|
||||
WRAPPER_RUNTIME_TRACE: ${WRAPPER_RUNTIME_TRACE:-}
|
||||
volumes:
|
||||
# Persist Apple state at the same path shape as the Android app sandbox.
|
||||
- ./data:/app/rootfs/data/data/com.apple.android.music/files
|
||||
@@ -0,0 +1,118 @@
|
||||
cmake_minimum_required(VERSION 3.22)
|
||||
project(daemon LANGUAGES CXX)
|
||||
|
||||
# This sub-build is invoked by the top-level CMakeLists.txt with the NDK
|
||||
# toolchain pre-configured. It produces the chrooted daemon binary 'main'.
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
||||
|
||||
include(FetchContent)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Third-party deps
|
||||
# -----------------------------------------------------------------------------
|
||||
# cpp-httplib: header-only HTTP/1.1 server. MIT license.
|
||||
FetchContent_Declare(
|
||||
httplib
|
||||
GIT_REPOSITORY https://github.com/yhirose/cpp-httplib.git
|
||||
GIT_TAG v0.18.5
|
||||
GIT_SHALLOW TRUE
|
||||
)
|
||||
set(HTTPLIB_COMPILE OFF CACHE BOOL "" FORCE)
|
||||
FetchContent_MakeAvailable(httplib)
|
||||
|
||||
# nlohmann::json: JSON parser/serializer. MIT license.
|
||||
FetchContent_Declare(
|
||||
nlohmann_json
|
||||
GIT_REPOSITORY https://github.com/nlohmann/json.git
|
||||
GIT_TAG v3.11.3
|
||||
GIT_SHALLOW TRUE
|
||||
)
|
||||
set(JSON_BuildTests OFF CACHE INTERNAL "")
|
||||
FetchContent_MakeAvailable(nlohmann_json)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Daemon executable
|
||||
# -----------------------------------------------------------------------------
|
||||
add_executable(main
|
||||
main.cpp
|
||||
server.cpp
|
||||
supervisor.cpp
|
||||
apple/auth.cpp
|
||||
apple/decrypt.cpp
|
||||
apple/loader.cpp
|
||||
apple/playback.cpp
|
||||
apple/runtime.cpp
|
||||
apple/tokens.cpp
|
||||
)
|
||||
|
||||
target_include_directories(main PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
)
|
||||
|
||||
target_link_libraries(main PRIVATE
|
||||
httplib::httplib
|
||||
nlohmann_json::nlohmann_json
|
||||
)
|
||||
|
||||
target_compile_options(main PRIVATE
|
||||
-Wall -Wextra -Wpedantic -Werror=return-type
|
||||
-fexceptions
|
||||
)
|
||||
|
||||
# RTTI is intentionally on. nlohmann::json compiles fine with or
|
||||
# without it but cpp-httplib's exception path uses typeid lookups,
|
||||
# and we share libc++_shared.so with Apple's libs which were also
|
||||
# built with RTTI. Disabling here would split the runtime.
|
||||
|
||||
# Our vendored `linker64` is from an older Android build that requires
|
||||
# DT_HASH and does not understand GNU-hash-only executables. NDK r23b's
|
||||
# default is `--hash-style=gnu`, which produces only DT_GNU_HASH and trips
|
||||
# the linker with "empty/missing DT_HASH in <argv[0]>". Force `both` so the
|
||||
# daemon ELF carries both hash sections - works with old and new linkers.
|
||||
target_link_options(main PRIVATE
|
||||
-Wl,--hash-style=both
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Apple native libraries
|
||||
# -----------------------------------------------------------------------------
|
||||
# Apple's libstoreservicescore / libmediaplatform / libandroidappmusic
|
||||
# are NOT in the daemon's DT_NEEDED chain. The Loader (apple/loader.cpp)
|
||||
# resolves them at runtime via dlopen/dlsym, which means the daemon can
|
||||
# start in stub mode even when the Apple libs (and their AOSP closure)
|
||||
# are absent.
|
||||
#
|
||||
# We DO link against libc++_shared.so here so that the daemon's own
|
||||
# C++ runtime SONAME matches what Apple's libs expect. At runtime the
|
||||
# dynamic linker resolves "libc++_shared.so" against /system/lib64/
|
||||
# (set via -rpath below) so the same .so file backs both the daemon
|
||||
# and Apple's libs once they get dlopen'd.
|
||||
if(NOT DEFINED LIBS_DIR)
|
||||
message(FATAL_ERROR
|
||||
"LIBS_DIR is not set. The top-level CMakeLists.txt should pass "
|
||||
"-DLIBS_DIR=<repo>/rootfs/system/lib64.")
|
||||
endif()
|
||||
|
||||
if(NOT EXISTS "${LIBS_DIR}/libc++_shared.so")
|
||||
message(FATAL_ERROR
|
||||
"${LIBS_DIR}/libc++_shared.so is missing. Stage the required Apple "
|
||||
"Music native libraries in rootfs/system/lib64/ before building.")
|
||||
endif()
|
||||
|
||||
target_link_libraries(main PRIVATE
|
||||
"${LIBS_DIR}/libc++_shared.so"
|
||||
log
|
||||
dl
|
||||
)
|
||||
|
||||
target_link_options(main PRIVATE
|
||||
-Wl,-rpath,/system/lib64
|
||||
)
|
||||
|
||||
set_target_properties(main PROPERTIES
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}"
|
||||
)
|
||||
@@ -0,0 +1,395 @@
|
||||
// AArch64 AAPCS places the hidden C++ "struct return" pointer in x8 and the
|
||||
// implicit `this` pointer in x0. Our function-pointer typedefs follow the x86_64
|
||||
// SysV layout (sret in rdi, `this` in rsi, ...), so a plain call would pass the
|
||||
// wrong registers. These thunks reorder into x8/x0/x1/... before blr.
|
||||
//
|
||||
// The callee address MUST live in a register other than x0–x8 while we set x8
|
||||
// (sret) and argument registers. If "blr %[callee]" shared a register with the sret
|
||||
// slot, Clang could place the target in x8, then `mov x8, %[sret]` would corrupt it
|
||||
// and we would branch into the return buffer (SIGSEGV at a small address).
|
||||
// Fix: load the target into x9 first, then set x8/x0–x7, then `blr x9`.
|
||||
//
|
||||
// On x86_64, call through the raw function pointer unchanged.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "apple/abi.hpp"
|
||||
|
||||
namespace wrapper::apple::aarch64_sret {
|
||||
|
||||
#if defined(__aarch64__) || defined(__AARCH64EL__)
|
||||
|
||||
#define WRAPPER_A64_CLOB_CALL \
|
||||
"memory", "cc", "x0", "x1", "x2", "x3", "x4", "x5", "x6", "x7", "x8", \
|
||||
"x9", "x10", "x11", "x12", "x13", "x14", "x15", "x16", "x17", "lr"
|
||||
|
||||
inline void device_guid_instance(abi::shared_ptr* out, abi::fn_DeviceGUID_instance fn) {
|
||||
void* callee = reinterpret_cast<void*>(fn);
|
||||
asm volatile(
|
||||
"mov x9, %[callee]\n\t"
|
||||
"mov x8, %[out]\n\t"
|
||||
"blr x9\n\t"
|
||||
:
|
||||
: [callee]"r"(callee), [out]"r"(out)
|
||||
: WRAPPER_A64_CLOB_CALL);
|
||||
}
|
||||
|
||||
inline void make_shared_request_context(abi::shared_ptr* out,
|
||||
abi::std_string* arg,
|
||||
abi::fn_make_shared_RequestContext fn) {
|
||||
void* callee = reinterpret_cast<void*>(fn);
|
||||
asm volatile(
|
||||
"mov x9, %[callee]\n\t"
|
||||
"mov x8, %[out]\n\t"
|
||||
"mov x0, %[arg]\n\t"
|
||||
"blr x9\n\t"
|
||||
:
|
||||
: [callee]"r"(callee), [out]"r"(out), [arg]"r"(arg)
|
||||
: WRAPPER_A64_CLOB_CALL);
|
||||
}
|
||||
|
||||
inline void make_shared_android_presentation_interface(
|
||||
abi::shared_ptr* out,
|
||||
abi::fn_make_shared_AndroidPresentationInterface fn) {
|
||||
void* callee = reinterpret_cast<void*>(fn);
|
||||
asm volatile(
|
||||
"mov x9, %[callee]\n\t"
|
||||
"mov x8, %[out]\n\t"
|
||||
"blr x9\n\t"
|
||||
:
|
||||
: [callee]"r"(callee), [out]"r"(out)
|
||||
: WRAPPER_A64_CLOB_CALL);
|
||||
}
|
||||
|
||||
inline void make_shared_authenticate_flow(abi::shared_ptr* out,
|
||||
abi::shared_ptr* req_ctx,
|
||||
abi::fn_make_shared_AuthenticateFlow fn) {
|
||||
void* callee = reinterpret_cast<void*>(fn);
|
||||
asm volatile(
|
||||
"mov x9, %[callee]\n\t"
|
||||
"mov x8, %[out]\n\t"
|
||||
"mov x0, %[req]\n\t"
|
||||
"blr x9\n\t"
|
||||
:
|
||||
: [callee]"r"(callee), [out]"r"(out), [req]"r"(req_ctx)
|
||||
: WRAPPER_A64_CLOB_CALL);
|
||||
}
|
||||
|
||||
inline void device_guid_configure(void* cfg_ret,
|
||||
void* this_,
|
||||
abi::std_string* a1,
|
||||
abi::std_string* a2,
|
||||
const unsigned int* a3,
|
||||
const std::uint8_t* a4,
|
||||
abi::fn_DeviceGUID_configure fn) {
|
||||
void* callee = reinterpret_cast<void*>(fn);
|
||||
asm volatile(
|
||||
"mov x9, %[callee]\n\t"
|
||||
"mov x8, %[sret]\n\t"
|
||||
"mov x0, %[ths]\n\t"
|
||||
"mov x1, %[a1]\n\t"
|
||||
"mov x2, %[a2]\n\t"
|
||||
"mov x3, %[a3]\n\t"
|
||||
"mov x4, %[a4]\n\t"
|
||||
"blr x9\n\t"
|
||||
:
|
||||
: [callee]"r"(callee),
|
||||
[sret]"r"(cfg_ret),
|
||||
[ths]"r"(this_),
|
||||
[a1]"r"(a1),
|
||||
[a2]"r"(a2),
|
||||
[a3]"r"(a3),
|
||||
[a4]"r"(a4)
|
||||
: WRAPPER_A64_CLOB_CALL);
|
||||
}
|
||||
|
||||
inline void request_context_init(void* rci_ret,
|
||||
void* this_,
|
||||
abi::shared_ptr* cfg,
|
||||
abi::fn_RequestContext_init fn) {
|
||||
void* callee = reinterpret_cast<void*>(fn);
|
||||
asm volatile(
|
||||
"mov x9, %[callee]\n\t"
|
||||
"mov x8, %[sret]\n\t"
|
||||
"mov x0, %[ths]\n\t"
|
||||
"mov x1, %[cfg]\n\t"
|
||||
"blr x9\n\t"
|
||||
:
|
||||
: [callee]"r"(callee),
|
||||
[sret]"r"(rci_ret),
|
||||
[ths]"r"(this_),
|
||||
[cfg]"r"(cfg)
|
||||
: WRAPPER_A64_CLOB_CALL);
|
||||
}
|
||||
|
||||
inline void device_guid_guid(void* hidden_pair, void* this_, abi::fn_DeviceGUID_guid fn) {
|
||||
void* callee = reinterpret_cast<void*>(fn);
|
||||
asm volatile(
|
||||
"mov x9, %[callee]\n\t"
|
||||
"mov x8, %[sret]\n\t"
|
||||
"mov x0, %[ths]\n\t"
|
||||
"blr x9\n\t"
|
||||
:
|
||||
: [callee]"r"(callee), [sret]"r"(hidden_pair), [ths]"r"(this_)
|
||||
: WRAPPER_A64_CLOB_CALL);
|
||||
}
|
||||
|
||||
inline void request_context_store_front_identifier(
|
||||
abi::std_string* out,
|
||||
void* this_,
|
||||
abi::shared_ptr* url_bag,
|
||||
abi::fn_RequestContext_storeFrontIdentifier fn) {
|
||||
void* callee = reinterpret_cast<void*>(fn);
|
||||
asm volatile(
|
||||
"mov x9, %[callee]\n\t"
|
||||
"mov x8, %[sret]\n\t"
|
||||
"mov x0, %[ths]\n\t"
|
||||
"mov x1, %[bag]\n\t"
|
||||
"blr x9\n\t"
|
||||
:
|
||||
: [callee]"r"(callee),
|
||||
[sret]"r"(out),
|
||||
[ths]"r"(this_),
|
||||
[bag]"r"(url_bag)
|
||||
: WRAPPER_A64_CLOB_CALL);
|
||||
}
|
||||
|
||||
inline void purchase_response_items(abi::std_vector* out,
|
||||
void* this_,
|
||||
abi::fn_PurchaseResponse_items fn) {
|
||||
void* callee = reinterpret_cast<void*>(fn);
|
||||
asm volatile(
|
||||
"mov x9, %[callee]\n\t"
|
||||
"mov x8, %[sret]\n\t"
|
||||
"mov x0, %[ths]\n\t"
|
||||
"blr x9\n\t"
|
||||
:
|
||||
: [callee]"r"(callee), [sret]"r"(out), [ths]"r"(this_)
|
||||
: WRAPPER_A64_CLOB_CALL);
|
||||
}
|
||||
|
||||
// Eight std::string parameters (seven S8_ in the mangled name): x1..x7 in regs,
|
||||
// eighth on stack at [sp]. Matches zhaarey agent-arm64.js and
|
||||
// WorldObservationLog/wrapper arm64 import.h.
|
||||
inline void svfoot_get_persistent_key(abi::shared_ptr* ret,
|
||||
void* fh,
|
||||
abi::std_string* a1,
|
||||
abi::std_string* a2,
|
||||
abi::std_string* a3,
|
||||
abi::std_string* a4,
|
||||
abi::std_string* a5,
|
||||
abi::std_string* a6,
|
||||
abi::std_string* a7,
|
||||
abi::std_string* a8_stack,
|
||||
abi::fn_SVFootHillSessionCtrl_getPersistentKey fn) {
|
||||
void* callee = reinterpret_cast<void*>(fn);
|
||||
asm volatile(
|
||||
"mov x9, %[callee]\n\t"
|
||||
"mov x8, %[sret]\n\t"
|
||||
"mov x0, %[ths]\n\t"
|
||||
"mov x1, %[a1]\n\t"
|
||||
"mov x2, %[a2]\n\t"
|
||||
"mov x3, %[a3]\n\t"
|
||||
"mov x4, %[a4]\n\t"
|
||||
"mov x5, %[a5]\n\t"
|
||||
"mov x6, %[a6]\n\t"
|
||||
"mov x7, %[a7]\n\t"
|
||||
"sub sp, sp, #0x10\n\t"
|
||||
"mov x10, %[a8]\n\t"
|
||||
"str x10, [sp]\n\t"
|
||||
"blr x9\n\t"
|
||||
"add sp, sp, #0x10\n\t"
|
||||
:
|
||||
: [callee]"r"(callee),
|
||||
[sret]"r"(ret),
|
||||
[ths]"r"(fh),
|
||||
[a1]"r"(a1),
|
||||
[a2]"r"(a2),
|
||||
[a3]"r"(a3),
|
||||
[a4]"r"(a4),
|
||||
[a5]"r"(a5),
|
||||
[a6]"r"(a6),
|
||||
[a7]"r"(a7),
|
||||
[a8]"r"(a8_stack)
|
||||
: WRAPPER_A64_CLOB_CALL);
|
||||
}
|
||||
|
||||
// Seven std::string parameters (six S8_): all in x1..x7; no stack argument.
|
||||
inline void svfoot_get_persistent_key_7str(abi::shared_ptr* ret,
|
||||
void* fh,
|
||||
abi::std_string* adam,
|
||||
abi::std_string* key_uri,
|
||||
abi::std_string* key_format,
|
||||
abi::std_string* key_format_ver,
|
||||
abi::std_string* server_uri,
|
||||
abi::std_string* protocol_type,
|
||||
abi::std_string* fps_cert,
|
||||
abi::fn_SVFootHillSessionCtrl_getPersistentKey7 fn) {
|
||||
void* callee = reinterpret_cast<void*>(fn);
|
||||
asm volatile(
|
||||
"mov x9, %[callee]\n\t"
|
||||
"mov x8, %[sret]\n\t"
|
||||
"mov x0, %[ths]\n\t"
|
||||
"mov x1, %[a1]\n\t"
|
||||
"mov x2, %[a2]\n\t"
|
||||
"mov x3, %[a3]\n\t"
|
||||
"mov x4, %[a4]\n\t"
|
||||
"mov x5, %[a5]\n\t"
|
||||
"mov x6, %[a6]\n\t"
|
||||
"mov x7, %[a7]\n\t"
|
||||
"blr x9\n\t"
|
||||
:
|
||||
: [callee]"r"(callee),
|
||||
[sret]"r"(ret),
|
||||
[ths]"r"(fh),
|
||||
[a1]"r"(adam),
|
||||
[a2]"r"(key_uri),
|
||||
[a3]"r"(key_format),
|
||||
[a4]"r"(key_format_ver),
|
||||
[a5]"r"(server_uri),
|
||||
[a6]"r"(protocol_type),
|
||||
[a7]"r"(fps_cert)
|
||||
: WRAPPER_A64_CLOB_CALL);
|
||||
}
|
||||
|
||||
inline void svfoot_decrypt_context(abi::shared_ptr* ret,
|
||||
void* fh,
|
||||
void* persist,
|
||||
abi::fn_SVFootHillSessionCtrl_decryptContext fn) {
|
||||
void* callee = reinterpret_cast<void*>(fn);
|
||||
asm volatile(
|
||||
"mov x9, %[callee]\n\t"
|
||||
"mov x8, %[sret]\n\t"
|
||||
"mov x0, %[ths]\n\t"
|
||||
"mov x1, %[pk]\n\t"
|
||||
"blr x9\n\t"
|
||||
:
|
||||
: [callee]"r"(callee),
|
||||
[sret]"r"(ret),
|
||||
[ths]"r"(fh),
|
||||
[pk]"r"(persist)
|
||||
: WRAPPER_A64_CLOB_CALL);
|
||||
}
|
||||
|
||||
// Keep an x8 scratch slot for URLRequest::run() on AArch64. Some Apple C++
|
||||
// entry points in this area return non-trivial values via the hidden result
|
||||
// register, and the scratch slot is harmless for void-returning builds.
|
||||
inline void urlrequest_run(void* this_, abi::fn_URLRequest_run fn) {
|
||||
void* callee = reinterpret_cast<void*>(fn);
|
||||
// 32-byte scratch buffer for the sret return value (we discard it).
|
||||
alignas(8) std::uint8_t scratch[32] = {};
|
||||
void* sret = reinterpret_cast<void*>(scratch);
|
||||
asm volatile(
|
||||
"mov x9, %[callee]\n\t"
|
||||
"mov x8, %[sret]\n\t"
|
||||
"mov x0, %[ths]\n\t"
|
||||
"blr x9\n\t"
|
||||
:
|
||||
: [callee]"r"(callee), [sret]"r"(sret), [ths]"r"(this_)
|
||||
: WRAPPER_A64_CLOB_CALL);
|
||||
}
|
||||
|
||||
#undef WRAPPER_A64_CLOB_CALL
|
||||
|
||||
#else
|
||||
|
||||
inline void device_guid_instance(abi::shared_ptr* out, abi::fn_DeviceGUID_instance fn) {
|
||||
fn(out);
|
||||
}
|
||||
|
||||
inline void make_shared_request_context(abi::shared_ptr* out,
|
||||
abi::std_string* arg,
|
||||
abi::fn_make_shared_RequestContext fn) {
|
||||
fn(out, arg);
|
||||
}
|
||||
|
||||
inline void make_shared_android_presentation_interface(
|
||||
abi::shared_ptr* out,
|
||||
abi::fn_make_shared_AndroidPresentationInterface fn) {
|
||||
fn(out);
|
||||
}
|
||||
|
||||
inline void make_shared_authenticate_flow(abi::shared_ptr* out,
|
||||
abi::shared_ptr* req_ctx,
|
||||
abi::fn_make_shared_AuthenticateFlow fn) {
|
||||
fn(out, req_ctx);
|
||||
}
|
||||
|
||||
inline void device_guid_configure(void* cfg_ret,
|
||||
void* this_,
|
||||
abi::std_string* a1,
|
||||
abi::std_string* a2,
|
||||
const unsigned int* a3,
|
||||
const std::uint8_t* a4,
|
||||
abi::fn_DeviceGUID_configure fn) {
|
||||
fn(cfg_ret, this_, a1, a2, a3, a4);
|
||||
}
|
||||
|
||||
inline void request_context_init(void* rci_ret,
|
||||
void* this_,
|
||||
abi::shared_ptr* cfg,
|
||||
abi::fn_RequestContext_init fn) {
|
||||
fn(rci_ret, this_, cfg);
|
||||
}
|
||||
|
||||
inline void device_guid_guid(void* hidden_pair, void* this_, abi::fn_DeviceGUID_guid fn) {
|
||||
fn(hidden_pair, this_);
|
||||
}
|
||||
|
||||
inline void request_context_store_front_identifier(
|
||||
abi::std_string* out,
|
||||
void* this_,
|
||||
abi::shared_ptr* url_bag,
|
||||
abi::fn_RequestContext_storeFrontIdentifier fn) {
|
||||
fn(out, this_, url_bag);
|
||||
}
|
||||
|
||||
inline void purchase_response_items(abi::std_vector* out,
|
||||
void* this_,
|
||||
abi::fn_PurchaseResponse_items fn) {
|
||||
fn(out, this_);
|
||||
}
|
||||
|
||||
inline void svfoot_get_persistent_key(abi::shared_ptr* ret,
|
||||
void* fh,
|
||||
abi::std_string* a1,
|
||||
abi::std_string* a2,
|
||||
abi::std_string* a3,
|
||||
abi::std_string* a4,
|
||||
abi::std_string* a5,
|
||||
abi::std_string* a6,
|
||||
abi::std_string* a7,
|
||||
abi::std_string* a8,
|
||||
abi::fn_SVFootHillSessionCtrl_getPersistentKey fn) {
|
||||
fn(ret, fh, a1, a2, a3, a4, a5, a6, a7, a8);
|
||||
}
|
||||
|
||||
inline void svfoot_get_persistent_key_7str(abi::shared_ptr* ret,
|
||||
void* fh,
|
||||
abi::std_string* adam,
|
||||
abi::std_string* key_uri,
|
||||
abi::std_string* key_format,
|
||||
abi::std_string* key_format_ver,
|
||||
abi::std_string* server_uri,
|
||||
abi::std_string* protocol_type,
|
||||
abi::std_string* fps_cert,
|
||||
abi::fn_SVFootHillSessionCtrl_getPersistentKey7 fn) {
|
||||
fn(ret, fh, adam, key_uri, key_format, key_format_ver, server_uri, protocol_type, fps_cert);
|
||||
}
|
||||
|
||||
inline void svfoot_decrypt_context(abi::shared_ptr* ret,
|
||||
void* fh,
|
||||
void* persist,
|
||||
abi::fn_SVFootHillSessionCtrl_decryptContext fn) {
|
||||
fn(ret, fh, persist);
|
||||
}
|
||||
|
||||
inline void urlrequest_run(void* this_, abi::fn_URLRequest_run fn) {
|
||||
fn(this_);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
} // namespace wrapper::apple::aarch64_sret
|
||||
@@ -0,0 +1,708 @@
|
||||
// Apple Music native-lib ABI bindings (x86_64 / arm64-v8a, APK 3.6.0-beta).
|
||||
//
|
||||
// Returning C++ objects by value from Apple code uses a hidden first parameter
|
||||
// (struct-return / sret). On x86_64 that slot is the first C argument; on
|
||||
// AArch64 it is passed in x8 while `this` stays in x0. Plain C calls into those
|
||||
// entry points break on arm64 — use aarch64_sret_thunks.hpp helpers there.
|
||||
//
|
||||
// We call into Apple's libstoreservicescore / libmediaplatform /
|
||||
// libandroidappmusic by way of the Itanium-mangled C++ symbols they
|
||||
// export. Each symbol below is declared as a function pointer type;
|
||||
// the Loader class (apple/loader.hpp) dlopens the libs at runtime
|
||||
// and resolves the symbols via dlsym.
|
||||
//
|
||||
// We deliberately treat all Apple types as opaque (`void*`) and pass
|
||||
// strings as pointers to a layout-compatible `std_string` union. This
|
||||
// is the pattern used by the upstream `wrapper` project; it avoids
|
||||
// compile-time inclusion of Apple headers we don't have, at the cost
|
||||
// of giving up type safety.
|
||||
//
|
||||
// Stability: the symbol names below are pinned to APK build 1109. If
|
||||
// the APK is changed, re-derive with `nm -D --defined libstoreservicescore.so`
|
||||
// and update LIBS_VERSION.json.
|
||||
//
|
||||
// The declarations cover runtime init, auth/token harvest, playback dispatch,
|
||||
// and FairPlay sample decrypt symbols.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
namespace wrapper::apple::abi {
|
||||
|
||||
// libc++ on the Android NDK uses an inline namespace `__ndk1`, which
|
||||
// shows up in mangled names. The C++ standard library types we touch
|
||||
// (`std::shared_ptr`, `std::basic_string`, `std::vector`) have a
|
||||
// well-known POD-ish layout we can fake without including <memory>
|
||||
// etc. Mismatching this layout WILL miscompile silently; tread
|
||||
// carefully.
|
||||
|
||||
// std::__ndk1::shared_ptr<T> on x86_64: { T* obj; control_block* ctl; }.
|
||||
// Total size 16 bytes. Apple methods returning shared_ptr take a
|
||||
// hidden first arg pointing at one of these.
|
||||
struct shared_ptr {
|
||||
void* obj = nullptr;
|
||||
void* ctrl_blk = nullptr;
|
||||
};
|
||||
|
||||
// std::__ndk1::basic_string<char> on x86_64 uses the libc++ "long" /
|
||||
// "short" union layout. Size: 24 bytes. Short mode uses the low bit
|
||||
// of the first byte as the tag (0 = short). Long mode stores
|
||||
// {capacity, size, data_ptr}.
|
||||
union std_string {
|
||||
struct {
|
||||
std::uint8_t mark; // bit 0 set => long mode
|
||||
char str[23]; // SSO buffer (22 chars + NUL)
|
||||
} _short;
|
||||
struct {
|
||||
std::size_t cap; // capacity (with bit 0 = 1 => long)
|
||||
std::size_t size; // length
|
||||
const char* data; // pointer to heap-allocated storage
|
||||
} _long;
|
||||
};
|
||||
|
||||
static_assert(sizeof(std_string) == 24, "std_string ABI mismatch");
|
||||
static_assert(sizeof(shared_ptr) == 16, "shared_ptr ABI mismatch");
|
||||
|
||||
// Pass a C string to an Apple method by reference. We deliberately do
|
||||
// NOT take ownership of `s`: the Apple call reads the pointer
|
||||
// directly. The caller must keep the underlying buffer live for the
|
||||
// duration of the call.
|
||||
inline std_string make_string_view(const char* s) {
|
||||
std_string out{};
|
||||
out._long.cap = 1; // any odd value => long mode
|
||||
out._long.size = std::strlen(s);
|
||||
out._long.data = s;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Empty std::vector<T> on x86_64: three null pointers (begin/end/capacity).
|
||||
struct std_vector {
|
||||
void* begin = nullptr;
|
||||
void* end = nullptr;
|
||||
void* end_capacity = nullptr;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Function pointer types for runtime-init symbols
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// `using fn_X = void (*)(...);` rather than direct extern "C" decls.
|
||||
// This keeps the daemon ELF free of unresolved Apple symbols at link
|
||||
// time so it can be started without the Apple libs available - useful
|
||||
// for stub-mode operation (WRAPPER_APPLE_INIT=0).
|
||||
|
||||
// bionic: void _resolv_set_nameservers_for_net(unsigned, const char**, int, const char*)
|
||||
using fn_resolv_set_nameservers_for_net =
|
||||
void (*)(unsigned netid, const char** servers, int count, const char* domains);
|
||||
|
||||
// FootHillConfig::config(std::string const&)
|
||||
using fn_FootHillConfig_config = void (*)(std_string* device_id);
|
||||
|
||||
// storeservicescore::DeviceGUID::instance() -> shared_ptr<DeviceGUID>
|
||||
using fn_DeviceGUID_instance = void (*)(shared_ptr* out);
|
||||
|
||||
// storeservicescore::DeviceGUID::configure(std::string const&,
|
||||
// std::string const&,
|
||||
// unsigned int const&,
|
||||
// bool const&)
|
||||
using fn_DeviceGUID_configure =
|
||||
void (*)(void* hidden_return,
|
||||
void* this_,
|
||||
std_string* arg1,
|
||||
std_string* arg2,
|
||||
const unsigned int* arg3,
|
||||
const std::uint8_t* arg4);
|
||||
|
||||
// std::shared_ptr<RequestContext>::make_shared<std::string&>(std::string&)
|
||||
using fn_make_shared_RequestContext =
|
||||
void (*)(shared_ptr* out, std_string* arg);
|
||||
|
||||
// storeservicescore::RequestContextConfig::RequestContextConfig()
|
||||
using fn_RequestContextConfig_ctor = void (*)(void* this_);
|
||||
|
||||
// All RequestContextConfig string setters have the same signature.
|
||||
using fn_RCC_set_string = void (*)(void* this_, std_string* s);
|
||||
|
||||
// RequestContextManager::configure(shared_ptr<RequestContext> const&)
|
||||
using fn_RequestContextManager_configure = void (*)(shared_ptr* req_ctx);
|
||||
|
||||
// storeservicescore::RequestContext::init(shared_ptr<RequestContextConfig> const&)
|
||||
using fn_RequestContext_init =
|
||||
void (*)(void* hidden_return, void* this_, shared_ptr* config);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AndroidPresentationInterface + callbacks
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// AuthenticateFlow uses a "presentation interface" object to ask the
|
||||
// host program for credentials and to surface UI dialogs. We satisfy
|
||||
// it with the Android-flavored implementation Apple ships
|
||||
// (`AndroidPresentationInterface`), and we register two handlers whose
|
||||
// signatures match Apple's C++ `std::shared_ptr` callback types. The
|
||||
// dialog handler logs each ProtocolDialog and must call
|
||||
// handleProtocolDialogResponse or login stalls.
|
||||
|
||||
// std::shared_ptr<AndroidPresentationInterface>::make_shared<>()
|
||||
using fn_make_shared_AndroidPresentationInterface = void (*)(shared_ptr* out);
|
||||
|
||||
// Apple's setCredentialsHandler / setDialogHandler store a function pointer
|
||||
// whose C++ type passes std::shared_ptr by value. On x86_64 Itanium ABI,
|
||||
// std::shared_ptr has a non-trivial copy ctor/dtor so it is passed *indirectly*
|
||||
// (the caller puts a pointer to a temporary in %rdi/%rsi). Declaring the
|
||||
// callback with explicit shared_ptr* parameters matches that exactly — which
|
||||
// is also what the upstream C wrapper does (struct shared_ptr *).
|
||||
using fn_credential_handler = void (*)(shared_ptr* cred_request,
|
||||
shared_ptr* cred_response_handler);
|
||||
|
||||
using fn_dialog_handler = void (*)(long dialog_id,
|
||||
shared_ptr* dialog,
|
||||
shared_ptr* response_handler);
|
||||
|
||||
// AndroidPresentationInterface::setCredentialsHandler(fn_credential_handler)
|
||||
using fn_API_setCredentialsHandler =
|
||||
void (*)(void* this_, fn_credential_handler cb);
|
||||
|
||||
// AndroidPresentationInterface::setDialogHandler(fn_dialog_handler)
|
||||
using fn_API_setDialogHandler =
|
||||
void (*)(void* this_, fn_dialog_handler cb);
|
||||
|
||||
// AndroidPresentationInterface::handleCredentialsResponse(
|
||||
// shared_ptr<CredentialsResponse> const&)
|
||||
using fn_API_handleCredentialsResponse =
|
||||
void (*)(void* this_, shared_ptr* cred_response);
|
||||
|
||||
// AndroidPresentationInterface::handleProtocolDialogResponse(
|
||||
// long const&, shared_ptr<ProtocolDialogResponse> const&)
|
||||
using fn_API_handleProtocolDialogResponse =
|
||||
void (*)(void* this_, const long* dialog_id, const shared_ptr* dialog_response);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ProtocolDialog / ProtocolDialogResponse (login UI)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
using fn_ProtocolDialog_title = std_string* (*)(void* this_);
|
||||
using fn_ProtocolDialog_message = std_string* (*)(void* this_);
|
||||
using fn_ProtocolDialog_buttons = std_vector* (*)(void* this_);
|
||||
using fn_ProtocolButton_title = std_string* (*)(void* this_);
|
||||
using fn_ProtocolDialogResponse_ctor = void (*)(void* this_);
|
||||
using fn_ProtocolDialogResponse_setSelectedButton =
|
||||
void (*)(void* this_, const shared_ptr* button);
|
||||
|
||||
// storeservicescore::RequestContext::setPresentationInterface(
|
||||
// shared_ptr<PresentationInterface> const&)
|
||||
using fn_RequestContext_setPresentationInterface =
|
||||
void (*)(void* this_, shared_ptr* presentation_interface);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CredentialsRequest / CredentialsResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// CredentialsRequest is what gets passed *to* the credential handler
|
||||
// (read-only metadata about why credentials are being asked for).
|
||||
// CredentialsResponse is what we construct and return.
|
||||
|
||||
// CredentialsRequest::requiresHSA2VerificationCode() const
|
||||
using fn_CR_requiresHSA2VerificationCode = std::uint8_t (*)(void* this_);
|
||||
|
||||
// CredentialsRequest::title() const / message() const - return a
|
||||
// pointer to a std::string we may read but must not free.
|
||||
using fn_CR_title = std_string* (*)(void* this_);
|
||||
using fn_CR_message = std_string* (*)(void* this_);
|
||||
|
||||
// CredentialsResponse::CredentialsResponse() - default constructor.
|
||||
using fn_CredentialsResponse_ctor = void (*)(void* this_);
|
||||
|
||||
// CredentialsResponse::setUserName / setPassword (std::string const&)
|
||||
using fn_CredentialsResponse_set_string = void (*)(void* this_, std_string* s);
|
||||
|
||||
// CredentialsResponse::setResponseType(ResponseType) - upstream passes
|
||||
// `2` after setting username+password to signal "submit credentials".
|
||||
using fn_CredentialsResponse_setResponseType = void (*)(void* this_, int response_type);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AuthenticateFlow / AuthenticateResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// std::shared_ptr<AuthenticateFlow>::make_shared<RequestContext&>(RequestContext&)
|
||||
using fn_make_shared_AuthenticateFlow =
|
||||
void (*)(shared_ptr* out, shared_ptr* req_ctx);
|
||||
|
||||
// AuthenticateFlow::run() - synchronous; fires credentialHandler internally.
|
||||
using fn_AuthenticateFlow_run = void (*)(void* this_);
|
||||
|
||||
// AuthenticateFlow::response() const - returns a shared_ptr<AuthenticateResponse>
|
||||
// owned by the flow.
|
||||
using fn_AuthenticateFlow_response = shared_ptr* (*)(void* this_);
|
||||
|
||||
// AuthenticateResponse::responseType() const - 6 means success;
|
||||
// other values indicate failure or cancellation.
|
||||
using fn_AR_responseType = int (*)(void* this_);
|
||||
|
||||
// AuthenticateResponse::customerMessage() const - returns pointer to
|
||||
// a std::string (might be empty).
|
||||
using fn_AR_customerMessage = std_string* (*)(void* this_);
|
||||
|
||||
// AuthenticateResponse::error() const - returns a shared_ptr<StoreErrorCondition>;
|
||||
// null if no error.
|
||||
using fn_AR_error = shared_ptr* (*)(void* this_);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StoreErrorCondition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
using fn_SEC_errorCode = int (*)(void* this_);
|
||||
using fn_SEC_what = const char* (*)(void* this_);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DeviceGUID accessor + mediaplatform::Data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// DeviceGUID::guid() - returns a (Data, ...) tuple via a hidden 16-byte
|
||||
// out param. The first 8 bytes are a pointer to mediaplatform::Data
|
||||
// whose bytes() yields the GUID string.
|
||||
using fn_DeviceGUID_guid = void (*)(void* hidden_return /* [2]void* */,
|
||||
void* this_);
|
||||
|
||||
// mediaplatform::Data::bytes() const -> char* (NUL-terminated for the
|
||||
// strings Apple's HTTP layer returns).
|
||||
using fn_Data_bytes = const char* (*)(void* this_);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URLRequest / URLResponse / HTTPMessage
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// To call out to Apple's iTunes endpoints (apiToken, createMusicToken)
|
||||
// we construct an HTTPMessage, wrap it in a URLRequest which is bound
|
||||
// to our RequestContext (so it gets signed with DSID + X-Token), call
|
||||
// run(), then unwrap URLResponse -> underlyingResponse -> raw bytes.
|
||||
|
||||
// HTTPMessage::HTTPMessage(std::string url, std::string method)
|
||||
//
|
||||
// The mangled constructor takes both strings by value, not by const&.
|
||||
// Keep these as real std::string parameters so the compiler emits the
|
||||
// correct C++ ABI for non-trivial by-value arguments (caller-created
|
||||
// temporaries passed indirectly on AArch64). Passing abi::std_string* here
|
||||
// is only safe for const& symbols and can corrupt/free string literals.
|
||||
using fn_HTTPMessage_ctor =
|
||||
void (*)(void* this_, std::string url, std::string method);
|
||||
|
||||
// C1 (complete object constructor) – used as a fallback on arm64 when
|
||||
// HTTPMessage has virtual bases that C2 does not initialise.
|
||||
using fn_HTTPMessage_ctor_c1 =
|
||||
void (*)(void* this_, std::string url, std::string method);
|
||||
|
||||
// HTTPMessage::setHeader(string name, string value)
|
||||
using fn_HTTPMessage_setHeader =
|
||||
void (*)(void* this_, std_string* name, std_string* value);
|
||||
|
||||
// HTTPMessage::setBodyData(char* body, size_t len)
|
||||
using fn_HTTPMessage_setBodyData =
|
||||
void (*)(void* this_, const char* body, std::size_t len);
|
||||
|
||||
// URLRequest::URLRequest(shared_ptr<HTTPMessage> const&,
|
||||
// shared_ptr<RequestContext> const&)
|
||||
using fn_URLRequest_ctor =
|
||||
void (*)(void* this_, shared_ptr* http_message, shared_ptr* req_ctx);
|
||||
|
||||
// URLRequest::setRequestParameter(std::string name, std::string value)
|
||||
// - the two-string overload (a vector<string> overload also exists).
|
||||
using fn_URLRequest_setRequestParameter =
|
||||
void (*)(void* this_, std_string* name, std_string* value);
|
||||
|
||||
// URLRequest::run(). Call through aarch64_sret::urlrequest_run so AArch64 gets
|
||||
// a harmless hidden-result scratch slot if the exported build expects one.
|
||||
using fn_URLRequest_run = void (*)(void* this_);
|
||||
using fn_URLRequest_error = shared_ptr* (*)(void* this_);
|
||||
using fn_URLRequest_response = shared_ptr* (*)(void* this_);
|
||||
|
||||
// URLResponse::underlyingResponse() const - returns the inner
|
||||
// HTTPMessage shared_ptr whose body bytes we read out at offset +48.
|
||||
using fn_URLResponse_underlyingResponse = shared_ptr* (*)(void* this_);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PurchaseRequest + URLResponse::protocolDictionary
|
||||
// (GET /playback "raw playback dispatch plist" path).
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// PurchaseRequest is the high-level storeservicescore object that wraps
|
||||
// the iTunes store "buyProduct" / "subDownload" URL bag dispatch. We
|
||||
// drive it the same way upstream wrapper main.c does:
|
||||
// 1. ctor on a sufficiently-sized buffer (PurchaseRequest is heavy;
|
||||
// we use a 4 KiB stack buffer, mirroring URLRequest).
|
||||
// 2. setProcessDialogActions(true)
|
||||
// 3. setURLBagKey("subDownload")
|
||||
// 4. setBuyParameters("salableAdamId=...&pricingParameters=SUBS&...")
|
||||
// 5. run() (blocking; uses our presentation interface for any UI).
|
||||
//
|
||||
// To capture the *raw* response plist instead of just the parsed
|
||||
// `PurchaseResponse` accessors, we install a URLResponsePreprocessor
|
||||
// hook on URLRequest (PurchaseRequest's base class). Apple invokes it
|
||||
// synchronously inside run() with the parsed URLResponse shared_ptr;
|
||||
// we call URLResponse::protocolDictionary() which returns a borrowed
|
||||
// CFDictionaryRef of the entire MZ-protocol envelope (cancel-batch
|
||||
// flags, songList, assets, key URIs, ...). We CFRetain it, then after
|
||||
// run() returns we re-serialize via CFPropertyListCreateData to XML.
|
||||
|
||||
// PurchaseRequest::PurchaseRequest(shared_ptr<RequestContext> const&)
|
||||
using fn_PurchaseRequest_ctor =
|
||||
void (*)(void* this_, shared_ptr* req_ctx);
|
||||
|
||||
// PurchaseRequest::setProcessDialogActions(bool)
|
||||
using fn_PurchaseRequest_setProcessDialogActions =
|
||||
void (*)(void* this_, std::uint8_t flag);
|
||||
|
||||
// PurchaseRequest::setURLBagKey / setBuyParameters - both take std::string const&.
|
||||
using fn_PurchaseRequest_set_string =
|
||||
void (*)(void* this_, std_string* s);
|
||||
|
||||
// PurchaseRequest::run() (blocking)
|
||||
using fn_PurchaseRequest_run = void (*)(void* this_);
|
||||
|
||||
// PurchaseRequest::response() const -> shared_ptr<PurchaseResponse>*
|
||||
// (returns a pointer to an internal member; same shape as URLRequest::response)
|
||||
using fn_PurchaseRequest_response = shared_ptr* (*)(void* this_);
|
||||
|
||||
// PurchaseResponse::error() -> shared_ptr<StoreErrorCondition>*
|
||||
// (non-const in Apple's lib despite being a getter; see nm output)
|
||||
using fn_PurchaseResponse_error = shared_ptr* (*)(void* this_);
|
||||
|
||||
// PurchaseResponse::items() const -> std::vector<shared_ptr<PurchaseItem>>
|
||||
// Vector is 24 bytes (3 pointers), so SysV ABI returns it via a hidden
|
||||
// first arg (the local std_vector the caller wants to fill).
|
||||
using fn_PurchaseResponse_items =
|
||||
void (*)(std_vector* out, void* this_);
|
||||
|
||||
// PurchaseItem::dictionary() const — in the pinned lib the body returns `this`
|
||||
// (not CFDictionaryRef); the plist lives at object offset 0. Caller should use
|
||||
// purchase_item_cfdictionary() in playback.cpp. Kept for dlsym + builds where
|
||||
// dictionary() returns the dict pointer directly.
|
||||
using fn_PurchaseItem_dictionary = void* (*)(void* this_);
|
||||
|
||||
// URLRequest::setURLResponsePreprocessor(
|
||||
// std::function<void(std::shared_ptr<URLResponse> const&)> const&)
|
||||
// We pass a pointer to a libc++ std::function we built; Apple copies it
|
||||
// into the request and invokes it during run().
|
||||
using fn_URLRequest_setURLResponsePreprocessor =
|
||||
void (*)(void* this_, const void* fn);
|
||||
|
||||
// URLResponse::protocolDictionary() -> CFDictionaryRef (borrowed, NOT
|
||||
// retained by us; CFRetain it inside the preprocessor before run()
|
||||
// returns or it may be freed when Apple drops the URLResponse).
|
||||
using fn_URLResponse_protocolDictionary = void* (*)(void* this_);
|
||||
|
||||
// RequestContext::storeFrontIdentifier(shared_ptr<URLBag> const&) const
|
||||
// - writes a std::string to the hidden first arg; the URLBag arg is
|
||||
// passed as a null shared_ptr (upstream pattern).
|
||||
using fn_RequestContext_storeFrontIdentifier =
|
||||
void (*)(std_string* out, void* this_, shared_ptr* url_bag);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVPlaybackLeaseManager + SVFootHillSessionCtrl (FairPlay decrypt)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SVPlaybackLeaseManager::SVPlaybackLeaseManager(
|
||||
// std::function<void(int const&)> const&,
|
||||
// std::function<void(std::shared_ptr<storeservicescore::StoreErrorCondition>
|
||||
// const&)> const&) [upstream main.cpp uses std::function<void(void*)> for 2nd]
|
||||
using fn_SVPlaybackLeaseManager_ctor = void (*)(
|
||||
void* this_,
|
||||
void* end_lease_std_function,
|
||||
void* pb_err_std_function);
|
||||
|
||||
using fn_SVPlaybackLeaseManager_refreshLeaseAutomatically = void (*)(void* this_,
|
||||
std::uint8_t* flag);
|
||||
using fn_SVPlaybackLeaseManager_requestLease = void (*)(void* this_,
|
||||
std::uint8_t* flag);
|
||||
|
||||
using fn_SVFootHillSessionCtrl_instance = void* (*)();
|
||||
|
||||
// getPersistentKey: Apple bumped the signature across Music app releases.
|
||||
// Itanium abbreviation S8_ is one trailing std::string const& per segment.
|
||||
// - 7× S8_ after the first RK… = 8 string refs (adam, prefetch, uri, …).
|
||||
// Used by zhaarey/apple-music-downloader agent-arm64.js and some APKs.
|
||||
// - 6× S8_ = 7 string refs (no separate prefetch slot). Older libandroidappmusic.
|
||||
// Loader resolves whichever symbol exists; decrypt dispatches to the matching thunk.
|
||||
using fn_SVFootHillSessionCtrl_getPersistentKey =
|
||||
void (*)(shared_ptr* ret,
|
||||
void* fh,
|
||||
std_string* adam_id,
|
||||
std_string* prefetch_adam_id,
|
||||
std_string* key_uri,
|
||||
std_string* key_format,
|
||||
std_string* key_format_ver,
|
||||
std_string* server_uri,
|
||||
std_string* protocol_type,
|
||||
std_string* fps_cert);
|
||||
|
||||
using fn_SVFootHillSessionCtrl_getPersistentKey7 =
|
||||
void (*)(shared_ptr* ret,
|
||||
void* fh,
|
||||
std_string* adam_id,
|
||||
std_string* key_uri,
|
||||
std_string* key_format,
|
||||
std_string* key_format_ver,
|
||||
std_string* server_uri,
|
||||
std_string* protocol_type,
|
||||
std_string* fps_cert);
|
||||
|
||||
// Third argument is the persistent-key handle (upstream passes persistK.obj).
|
||||
using fn_SVFootHillSessionCtrl_decryptContext = void (*)(shared_ptr* ret,
|
||||
void* fh,
|
||||
void* persistent_key_obj);
|
||||
|
||||
// Returns pointer to kdContext; upstream dereferences once.
|
||||
using fn_SVFootHillPContext_kdContext = void** (*)(void* pctx);
|
||||
|
||||
using fn_fp_sample_decrypt = long (*)(void* kd_context,
|
||||
std::uint32_t op,
|
||||
void* in_out,
|
||||
void* out_alias,
|
||||
std::size_t size);
|
||||
|
||||
using fn_SVFootHillSessionCtrl_resetAllContexts = void (*)(void* fh);
|
||||
|
||||
using fn_shared_ptr_SVFootHillPContext_dtor = void (*)(shared_ptr* this_);
|
||||
|
||||
// Mangled symbol names. These are the *strings* we feed to dlsym.
|
||||
// Kept centrally so that the Loader implementation does not embed
|
||||
// them inline (easier to audit and regenerate from `nm` output).
|
||||
namespace mangled {
|
||||
|
||||
inline constexpr const char* resolv_set_nameservers_for_net =
|
||||
"_resolv_set_nameservers_for_net";
|
||||
|
||||
inline constexpr const char* FootHillConfig_config =
|
||||
"_ZN14FootHillConfig6configERKNSt6__ndk112basic_stringIcNS0_11char_traitsIcEENS0_9allocatorIcEEEE";
|
||||
|
||||
inline constexpr const char* DeviceGUID_instance =
|
||||
"_ZN17storeservicescore10DeviceGUID8instanceEv";
|
||||
|
||||
inline constexpr const char* DeviceGUID_configure =
|
||||
"_ZN17storeservicescore10DeviceGUID9configureERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEES9_RKjRKb";
|
||||
|
||||
inline constexpr const char* make_shared_RequestContext =
|
||||
"_ZNSt6__ndk110shared_ptrIN17storeservicescore14RequestContextEE11make_sharedIJRNS_12basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEEEEES3_DpOT_";
|
||||
|
||||
inline constexpr const char* vtable_RequestContextConfig =
|
||||
"_ZTVNSt6__ndk120__shared_ptr_emplaceIN17storeservicescore20RequestContextConfigENS_9allocatorIS2_EEEE";
|
||||
|
||||
inline constexpr const char* RequestContextConfig_ctor =
|
||||
"_ZN17storeservicescore20RequestContextConfigC2Ev";
|
||||
|
||||
inline constexpr const char* RCC_setBaseDirectoryPath =
|
||||
"_ZN17storeservicescore20RequestContextConfig20setBaseDirectoryPathERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
|
||||
inline constexpr const char* RCC_setClientIdentifier =
|
||||
"_ZN17storeservicescore20RequestContextConfig19setClientIdentifierERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
|
||||
inline constexpr const char* RCC_setVersionIdentifier =
|
||||
"_ZN17storeservicescore20RequestContextConfig20setVersionIdentifierERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
|
||||
inline constexpr const char* RCC_setPlatformIdentifier =
|
||||
"_ZN17storeservicescore20RequestContextConfig21setPlatformIdentifierERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
|
||||
inline constexpr const char* RCC_setProductVersion =
|
||||
"_ZN17storeservicescore20RequestContextConfig17setProductVersionERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
|
||||
inline constexpr const char* RCC_setDeviceModel =
|
||||
"_ZN17storeservicescore20RequestContextConfig14setDeviceModelERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
|
||||
inline constexpr const char* RCC_setBuildVersion =
|
||||
"_ZN17storeservicescore20RequestContextConfig15setBuildVersionERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
|
||||
inline constexpr const char* RCC_setLocaleIdentifier =
|
||||
"_ZN17storeservicescore20RequestContextConfig19setLocaleIdentifierERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
|
||||
inline constexpr const char* RCC_setLanguageIdentifier =
|
||||
"_ZN17storeservicescore20RequestContextConfig21setLanguageIdentifierERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
|
||||
|
||||
inline constexpr const char* RequestContextManager_configure =
|
||||
"_ZN21RequestContextManager9configureERKNSt6__ndk110shared_ptrIN17storeservicescore14RequestContextEEE";
|
||||
inline constexpr const char* RequestContext_init =
|
||||
"_ZN17storeservicescore14RequestContext4initERKNSt6__ndk110shared_ptrINS_20RequestContextConfigEEE";
|
||||
inline constexpr const char* RequestContext_setFairPlayDirectoryPath =
|
||||
"_ZN17storeservicescore14RequestContext24setFairPlayDirectoryPathERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
|
||||
|
||||
// ---- AndroidPresentationInterface + auth flow ----
|
||||
|
||||
inline constexpr const char* make_shared_AndroidPresentationInterface =
|
||||
"_ZNSt6__ndk110shared_ptrIN20androidstoreservices28AndroidPresentationInterfaceEE11make_sharedIJEEES3_DpOT_";
|
||||
|
||||
inline constexpr const char* API_setCredentialsHandler =
|
||||
"_ZN20androidstoreservices28AndroidPresentationInterface21setCredentialsHandlerEPFvNSt6__ndk110shared_ptrIN17storeservicescore18CredentialsRequestEEENS2_INS_33AndroidCredentialsResponseHandlerEEEE";
|
||||
|
||||
inline constexpr const char* API_setDialogHandler =
|
||||
"_ZN20androidstoreservices28AndroidPresentationInterface16setDialogHandlerEPFvlNSt6__ndk110shared_ptrIN17storeservicescore14ProtocolDialogEEENS2_INS_36AndroidProtocolDialogResponseHandlerEEEE";
|
||||
|
||||
inline constexpr const char* API_handleCredentialsResponse =
|
||||
"_ZN20androidstoreservices28AndroidPresentationInterface25handleCredentialsResponseERKNSt6__ndk110shared_ptrIN17storeservicescore19CredentialsResponseEEE";
|
||||
inline constexpr const char* API_handleProtocolDialogResponse =
|
||||
"_ZN20androidstoreservices28AndroidPresentationInterface28handleProtocolDialogResponseERKlRKNSt6__ndk110shared_ptrIN17storeservicescore22ProtocolDialogResponseEEE";
|
||||
|
||||
inline constexpr const char* vtable_ProtocolDialogResponse =
|
||||
"_ZTVNSt6__ndk120__shared_ptr_emplaceIN17storeservicescore22ProtocolDialogResponseENS_9allocatorIS2_EEEE";
|
||||
inline constexpr const char* ProtocolDialogResponse_ctor =
|
||||
"_ZN17storeservicescore22ProtocolDialogResponseC1Ev";
|
||||
inline constexpr const char* ProtocolDialogResponse_setSelectedButton =
|
||||
"_ZN17storeservicescore22ProtocolDialogResponse17setSelectedButtonERKNSt6__ndk110shared_ptrINS_14ProtocolButtonEEE";
|
||||
inline constexpr const char* ProtocolDialog_title =
|
||||
"_ZNK17storeservicescore14ProtocolDialog5titleEv";
|
||||
inline constexpr const char* ProtocolDialog_message =
|
||||
"_ZNK17storeservicescore14ProtocolDialog7messageEv";
|
||||
inline constexpr const char* ProtocolDialog_buttons =
|
||||
"_ZNK17storeservicescore14ProtocolDialog7buttonsEv";
|
||||
inline constexpr const char* ProtocolButton_title =
|
||||
"_ZNK17storeservicescore14ProtocolButton5titleEv";
|
||||
|
||||
inline constexpr const char* RequestContext_setPresentationInterface =
|
||||
"_ZN17storeservicescore14RequestContext24setPresentationInterfaceERKNSt6__ndk110shared_ptrINS_21PresentationInterfaceEEE";
|
||||
|
||||
inline constexpr const char* CR_requiresHSA2VerificationCode =
|
||||
"_ZNK17storeservicescore18CredentialsRequest28requiresHSA2VerificationCodeEv";
|
||||
inline constexpr const char* CR_title =
|
||||
"_ZNK17storeservicescore18CredentialsRequest5titleEv";
|
||||
inline constexpr const char* CR_message =
|
||||
"_ZNK17storeservicescore18CredentialsRequest7messageEv";
|
||||
|
||||
inline constexpr const char* vtable_CredentialsResponse =
|
||||
"_ZTVNSt6__ndk120__shared_ptr_emplaceIN17storeservicescore19CredentialsResponseENS_9allocatorIS2_EEEE";
|
||||
inline constexpr const char* CredentialsResponse_ctor =
|
||||
"_ZN17storeservicescore19CredentialsResponseC1Ev";
|
||||
inline constexpr const char* CredentialsResponse_setUserName =
|
||||
"_ZN17storeservicescore19CredentialsResponse11setUserNameERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
|
||||
inline constexpr const char* CredentialsResponse_setPassword =
|
||||
"_ZN17storeservicescore19CredentialsResponse11setPasswordERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
|
||||
inline constexpr const char* CredentialsResponse_setResponseType =
|
||||
"_ZN17storeservicescore19CredentialsResponse15setResponseTypeENS0_12ResponseTypeE";
|
||||
|
||||
inline constexpr const char* make_shared_AuthenticateFlow =
|
||||
"_ZNSt6__ndk110shared_ptrIN17storeservicescore16AuthenticateFlowEE11make_sharedIJRNS0_INS1_14RequestContextEEEEEES3_DpOT_";
|
||||
inline constexpr const char* AuthenticateFlow_run =
|
||||
"_ZN17storeservicescore16AuthenticateFlow3runEv";
|
||||
inline constexpr const char* AuthenticateFlow_response =
|
||||
"_ZNK17storeservicescore16AuthenticateFlow8responseEv";
|
||||
|
||||
inline constexpr const char* AR_responseType =
|
||||
"_ZNK17storeservicescore20AuthenticateResponse12responseTypeEv";
|
||||
inline constexpr const char* AR_customerMessage =
|
||||
"_ZNK17storeservicescore20AuthenticateResponse15customerMessageEv";
|
||||
inline constexpr const char* AR_error =
|
||||
"_ZNK17storeservicescore20AuthenticateResponse5errorEv";
|
||||
|
||||
inline constexpr const char* SEC_errorCode =
|
||||
"_ZNK17storeservicescore19StoreErrorCondition9errorCodeEv";
|
||||
inline constexpr const char* SEC_what =
|
||||
"_ZNK17storeservicescore19StoreErrorCondition4whatEv";
|
||||
|
||||
// ---- token harvest ----
|
||||
|
||||
inline constexpr const char* DeviceGUID_guid =
|
||||
"_ZN17storeservicescore10DeviceGUID4guidEv";
|
||||
inline constexpr const char* Data_bytes =
|
||||
"_ZNK13mediaplatform4Data5bytesEv";
|
||||
|
||||
inline constexpr const char* vtable_HTTPMessage =
|
||||
"_ZTVNSt6__ndk120__shared_ptr_emplaceIN13mediaplatform11HTTPMessageENS_9allocatorIS2_EEEE";
|
||||
inline constexpr const char* HTTPMessage_ctor =
|
||||
"_ZN13mediaplatform11HTTPMessageC2ENSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEES7_";
|
||||
inline constexpr const char* HTTPMessage_ctor_c1 =
|
||||
"_ZN13mediaplatform11HTTPMessageC1ENSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEES7_";
|
||||
inline constexpr const char* HTTPMessage_setHeader =
|
||||
"_ZN13mediaplatform11HTTPMessage9setHeaderERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEES9_";
|
||||
inline constexpr const char* HTTPMessage_setBodyData =
|
||||
"_ZN13mediaplatform11HTTPMessage11setBodyDataEPcm";
|
||||
|
||||
inline constexpr const char* URLRequest_ctor =
|
||||
"_ZN17storeservicescore10URLRequestC2ERKNSt6__ndk110shared_ptrIN13mediaplatform11HTTPMessageEEERKNS2_INS_14RequestContextEEE";
|
||||
inline constexpr const char* URLRequest_setRequestParameter =
|
||||
"_ZN17storeservicescore10URLRequest19setRequestParameterERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEES9_";
|
||||
inline constexpr const char* URLRequest_run =
|
||||
"_ZN17storeservicescore10URLRequest3runEv";
|
||||
inline constexpr const char* URLRequest_error =
|
||||
"_ZNK17storeservicescore10URLRequest5errorEv";
|
||||
inline constexpr const char* URLRequest_response =
|
||||
"_ZNK17storeservicescore10URLRequest8responseEv";
|
||||
inline constexpr const char* URLResponse_underlyingResponse =
|
||||
"_ZNK17storeservicescore11URLResponse18underlyingResponseEv";
|
||||
|
||||
// ---- PurchaseRequest + URLResponse::protocolDictionary ----
|
||||
|
||||
inline constexpr const char* PurchaseRequest_ctor =
|
||||
"_ZN17storeservicescore15PurchaseRequestC2ERKNSt6__ndk110shared_ptrINS_14RequestContextEEE";
|
||||
inline constexpr const char* PurchaseRequest_setProcessDialogActions =
|
||||
"_ZN17storeservicescore15PurchaseRequest23setProcessDialogActionsEb";
|
||||
inline constexpr const char* PurchaseRequest_setURLBagKey =
|
||||
"_ZN17storeservicescore15PurchaseRequest12setURLBagKeyERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
|
||||
inline constexpr const char* PurchaseRequest_setBuyParameters =
|
||||
"_ZN17storeservicescore15PurchaseRequest16setBuyParametersERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE";
|
||||
inline constexpr const char* PurchaseRequest_run =
|
||||
"_ZN17storeservicescore15PurchaseRequest3runEv";
|
||||
inline constexpr const char* PurchaseRequest_response =
|
||||
"_ZNK17storeservicescore15PurchaseRequest8responseEv";
|
||||
inline constexpr const char* PurchaseResponse_error =
|
||||
"_ZN17storeservicescore16PurchaseResponse5errorEv";
|
||||
inline constexpr const char* PurchaseResponse_items =
|
||||
"_ZNK17storeservicescore16PurchaseResponse5itemsEv";
|
||||
inline constexpr const char* PurchaseItem_dictionary =
|
||||
"_ZNK17storeservicescore12PurchaseItem10dictionaryEv";
|
||||
|
||||
inline constexpr const char* URLRequest_setURLResponsePreprocessor =
|
||||
"_ZN17storeservicescore10URLRequest26setURLResponsePreprocessorERKNSt6__ndk18functionIFvRKNS1_10shared_ptrINS_11URLResponseEEEEEE";
|
||||
inline constexpr const char* URLResponse_protocolDictionary =
|
||||
"_ZN17storeservicescore11URLResponse18protocolDictionaryEv";
|
||||
|
||||
inline constexpr const char* RequestContext_storeFrontIdentifier =
|
||||
"_ZNK17storeservicescore14RequestContext20storeFrontIdentifierERKNSt6__ndk110shared_ptrINS_6URLBagEEE";
|
||||
|
||||
// ---- FairPlay decrypt / lease ----
|
||||
|
||||
inline constexpr const char* SVPlaybackLeaseManager_ctor =
|
||||
"_ZN22SVPlaybackLeaseManagerC2ERKNSt6__ndk18functionIFvRKiEEERKNS1_IFvRKNS0_10shared_ptrIN17storeservicescore19StoreErrorConditionEEEEEE";
|
||||
|
||||
inline constexpr const char* SVPlaybackLeaseManager_refreshLeaseAutomatically =
|
||||
"_ZN22SVPlaybackLeaseManager25refreshLeaseAutomaticallyERKb";
|
||||
|
||||
inline constexpr const char* SVPlaybackLeaseManager_requestLease =
|
||||
"_ZN22SVPlaybackLeaseManager12requestLeaseERKb";
|
||||
|
||||
inline constexpr const char* SVFootHillSessionCtrl_instance =
|
||||
"_ZN21SVFootHillSessionCtrl8instanceEv";
|
||||
|
||||
// dlsym is exact: count S8_ segments against `nm -D libandroidappmusic.so`.
|
||||
// In Itanium mangling, the first std::string arg is spelled out fully (the
|
||||
// `RKNSt6__ndk112basic_string...` chunk) and creates a substitution slot. Each
|
||||
// subsequent std::string arg is then `S8_`. So:
|
||||
// N string args == 1 explicit `RKNSt...` + (N-1) `S8_`.
|
||||
//
|
||||
// 8 string params (= seven `S8_`). Used by zhaarey/agent-arm64.js on builds
|
||||
// where Apple split adam_id into a separate prefetch slot (adam_id duplicated).
|
||||
inline constexpr const char* SVFootHillSessionCtrl_getPersistentKey_8str =
|
||||
"_ZN21SVFootHillSessionCtrl16getPersistentKeyERKNSt6__ndk112basic_stringIcNS0_11char_traitsIcEENS0_9allocatorIcEEEES8_S8_S8_S8_S8_S8_S8_";
|
||||
|
||||
// 7 string params (= six `S8_`). What `nm -D` shows on both arm64-v8a and
|
||||
// x86_64 splits of Apple Music 3.6.0-beta-1109. No prefetch slot — one
|
||||
// adam_id, six other strings (uri, key format, key format version, server
|
||||
// uri, protocol type, fps cert).
|
||||
inline constexpr const char* SVFootHillSessionCtrl_getPersistentKey_7str =
|
||||
"_ZN21SVFootHillSessionCtrl16getPersistentKeyERKNSt6__ndk112basic_stringIcNS0_11char_traitsIcEENS0_9allocatorIcEEEES8_S8_S8_S8_S8_S8_";
|
||||
|
||||
// Legacy alias points at the variant that actually exports on the pinned
|
||||
// APK (3.6.0-beta-1109): the 7-string / six-`S8_` symbol. Kept so unqualified
|
||||
// references to mangled::SVFootHillSessionCtrl_getPersistentKey resolve to
|
||||
// the working symbol; loader.cpp tries _8str first, then _7str regardless.
|
||||
inline constexpr const char* SVFootHillSessionCtrl_getPersistentKey =
|
||||
SVFootHillSessionCtrl_getPersistentKey_7str;
|
||||
|
||||
inline constexpr const char* SVFootHillSessionCtrl_decryptContext =
|
||||
"_ZN21SVFootHillSessionCtrl14decryptContextERKNSt6__ndk112basic_stringIcNS0_11char_traitsIcEENS0_9allocatorIcEEEERKN11SVDecryptor15SVDecryptorTypeERKb";
|
||||
|
||||
inline constexpr const char* SVFootHillPContext_kdContext =
|
||||
"_ZNK18SVFootHillPContext9kdContextEv";
|
||||
|
||||
inline constexpr const char* fp_sample_decrypt = "NfcRKVnxuKZy04KWbdFu71Ou";
|
||||
|
||||
inline constexpr const char* SVFootHillSessionCtrl_resetAllContexts =
|
||||
"_ZN21SVFootHillSessionCtrl16resetAllContextsEv";
|
||||
|
||||
inline constexpr const char* shared_ptr_SVFootHillPContext_dtor =
|
||||
"_ZNSt6__ndk110shared_ptrI18SVFootHillPContextED2Ev";
|
||||
|
||||
} // namespace mangled
|
||||
|
||||
} // namespace wrapper::apple::abi
|
||||
@@ -0,0 +1,383 @@
|
||||
#include "apple/auth.hpp"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <utility>
|
||||
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "apple/aarch64_sret_thunks.hpp"
|
||||
#include "apple/loader.hpp"
|
||||
#include "apple/runtime.hpp"
|
||||
#include "apple/tokens.hpp"
|
||||
|
||||
namespace wrapper::apple {
|
||||
|
||||
namespace {
|
||||
|
||||
void unlink_quiet(const char* path) {
|
||||
if (path == nullptr || *path == '\0') return;
|
||||
(void)::unlink(path);
|
||||
}
|
||||
|
||||
// Apple writes mpl_db/kvs.sqlitedb the first time AuthenticateFlow succeeds.
|
||||
// Without that file, RequestContext::storeFrontIdentifier on arm64 dereferences
|
||||
// uninitialized internal state at offset 0x10 and SIGSEGVs (x86_64 returns an
|
||||
// empty string instead). Gate the cached-session probe on this file so a cold
|
||||
// `./data` mount does not crash the daemon at startup.
|
||||
bool warm_session_present(const std::string& base_dir) {
|
||||
if (base_dir.empty()) return false;
|
||||
const std::string kvs = base_dir + "/mpl_db/kvs.sqlitedb";
|
||||
struct stat st{};
|
||||
if (::stat(kvs.c_str(), &st) != 0) return false;
|
||||
return S_ISREG(st.st_mode) && st.st_size > 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const char* to_string(LoginState s) {
|
||||
switch (s) {
|
||||
case LoginState::LoggedOut: return "logged_out";
|
||||
case LoginState::InProgress: return "in_progress";
|
||||
case LoginState::Awaiting2FA: return "awaiting_2fa";
|
||||
case LoginState::Authenticated: return "authenticated";
|
||||
case LoginState::Failed: return "failed";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
Account& Account::instance() {
|
||||
static Account a;
|
||||
return a;
|
||||
}
|
||||
|
||||
Account::~Account() {
|
||||
if (worker_.joinable()) {
|
||||
worker_.detach();
|
||||
}
|
||||
}
|
||||
|
||||
bool Account::start_login(const Loader& loader,
|
||||
const Runtime& runtime,
|
||||
std::string apple_id,
|
||||
std::string password) {
|
||||
std::unique_lock<std::mutex> g(mu_);
|
||||
LoginState s = state_.load(std::memory_order_acquire);
|
||||
if (s == LoginState::InProgress || s == LoginState::Awaiting2FA) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Drain a previous worker thread, if any. join() outside the lock
|
||||
// would risk re-entering Account from the worker; we take the
|
||||
// hit of joining under the lock since the worker has already
|
||||
// transitioned out of the busy states. This also allows POST /login
|
||||
// to refresh an existing Authenticated snapshot after a stale restore.
|
||||
if (worker_.joinable()) {
|
||||
worker_.join();
|
||||
}
|
||||
|
||||
loader_ = &loader;
|
||||
runtime_ = &runtime;
|
||||
apple_id_ = std::move(apple_id);
|
||||
password_ = std::move(password);
|
||||
twofa_code_.clear();
|
||||
twofa_submitted_ = false;
|
||||
aborted_ = false;
|
||||
tokens_ = Tokens{};
|
||||
last_error_.clear();
|
||||
last_error_code_ = 0;
|
||||
|
||||
state_.store(LoginState::InProgress, std::memory_order_release);
|
||||
cv_state_.notify_all();
|
||||
|
||||
worker_ = std::thread(&Account::worker_main, this);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Account::submit_2fa(std::string code) {
|
||||
std::lock_guard<std::mutex> g(mu_);
|
||||
if (state_.load(std::memory_order_acquire) != LoginState::Awaiting2FA) {
|
||||
return false;
|
||||
}
|
||||
twofa_code_ = std::move(code);
|
||||
twofa_submitted_ = true;
|
||||
state_.store(LoginState::InProgress, std::memory_order_release);
|
||||
cv_state_.notify_all();
|
||||
cv_2fa_.notify_all();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Account::try_restore_cached_session(const Loader& loader, const Runtime& runtime) {
|
||||
std::lock_guard<std::mutex> restore_guard(restore_mu_);
|
||||
|
||||
if (!loader.ok() || !runtime.initialized()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string bd = runtime.base_dir();
|
||||
bool warm = warm_session_present(bd);
|
||||
if (!warm) {
|
||||
std::fprintf(stderr,
|
||||
"auth: no warm Apple session at %s/mpl_db/kvs.sqlitedb; "
|
||||
"skipping cached-session restore\n",
|
||||
bd.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> g(mu_);
|
||||
if (state_.load(std::memory_order_acquire) != LoginState::LoggedOut) {
|
||||
return false;
|
||||
}
|
||||
if (worker_.joinable()) {
|
||||
worker_.join();
|
||||
}
|
||||
loader_ = &loader;
|
||||
runtime_ = &runtime;
|
||||
password_.clear();
|
||||
twofa_code_.clear();
|
||||
twofa_submitted_ = false;
|
||||
aborted_ = false;
|
||||
tokens_ = Tokens{};
|
||||
last_error_.clear();
|
||||
last_error_code_ = 0;
|
||||
state_.store(LoginState::InProgress, std::memory_order_release);
|
||||
cv_state_.notify_all();
|
||||
}
|
||||
|
||||
const Symbols& s = loader.sym();
|
||||
auto req_ctx = runtime.request_ctx_copy();
|
||||
if (req_ctx.obj == nullptr) {
|
||||
std::lock_guard<std::mutex> g(mu_);
|
||||
loader_ = nullptr;
|
||||
runtime_ = nullptr;
|
||||
state_.store(LoginState::LoggedOut, std::memory_order_release);
|
||||
cv_state_.notify_all();
|
||||
return false;
|
||||
}
|
||||
auto device_guid = runtime.device_guid_copy();
|
||||
Tokens t;
|
||||
if (!tokens::harvest_all(s, req_ctx, device_guid, &t)) {
|
||||
std::fprintf(stderr,
|
||||
"auth: cached-session restore found Apple session files "
|
||||
"but token harvest failed\n");
|
||||
std::lock_guard<std::mutex> g(mu_);
|
||||
loader_ = nullptr;
|
||||
runtime_ = nullptr;
|
||||
state_.store(LoginState::LoggedOut, std::memory_order_release);
|
||||
cv_state_.notify_all();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (const char* label = std::getenv("WRAPPER_APPLE_ID")) {
|
||||
if (label[0] != '\0') {
|
||||
t.apple_id = label;
|
||||
}
|
||||
}
|
||||
|
||||
finish_authenticated(std::move(t));
|
||||
return true;
|
||||
}
|
||||
|
||||
void Account::logout() {
|
||||
{
|
||||
std::lock_guard<std::mutex> g(mu_);
|
||||
aborted_ = true;
|
||||
cv_2fa_.notify_all();
|
||||
cv_state_.notify_all();
|
||||
}
|
||||
if (worker_.joinable()) {
|
||||
worker_.join();
|
||||
}
|
||||
std::lock_guard<std::mutex> g(mu_);
|
||||
state_.store(LoginState::LoggedOut, std::memory_order_release);
|
||||
apple_id_.clear();
|
||||
password_.clear();
|
||||
twofa_code_.clear();
|
||||
twofa_submitted_ = false;
|
||||
aborted_ = false;
|
||||
tokens_ = Tokens{};
|
||||
last_error_.clear();
|
||||
last_error_code_ = 0;
|
||||
cv_state_.notify_all();
|
||||
}
|
||||
|
||||
LoginState Account::wait_for_settled_state(std::chrono::milliseconds timeout) {
|
||||
std::unique_lock<std::mutex> g(mu_);
|
||||
cv_state_.wait_for(g, timeout, [this] {
|
||||
auto s = state_.load(std::memory_order_acquire);
|
||||
return s != LoginState::InProgress;
|
||||
});
|
||||
return state_.load(std::memory_order_acquire);
|
||||
}
|
||||
|
||||
LoginState Account::state() const {
|
||||
return state_.load(std::memory_order_acquire);
|
||||
}
|
||||
|
||||
AccountSnapshot Account::public_snapshot() const {
|
||||
std::lock_guard<std::mutex> g(mu_);
|
||||
AccountSnapshot snap;
|
||||
snap.state = state_.load(std::memory_order_acquire);
|
||||
snap.apple_id = apple_id_;
|
||||
if (snap.state == LoginState::Authenticated) {
|
||||
snap.storefront = tokens_.storefront;
|
||||
snap.dsid = tokens_.dsid;
|
||||
snap.music_user_token = tokens_.music_user_token;
|
||||
snap.dev_token = tokens_.dev_token;
|
||||
snap.logged_in_at = tokens_.logged_in_at;
|
||||
}
|
||||
if (snap.state == LoginState::Failed) {
|
||||
snap.last_error = last_error_;
|
||||
snap.last_error_code = last_error_code_;
|
||||
}
|
||||
return snap;
|
||||
}
|
||||
|
||||
bool Account::fetch_credentials_blocking(std::string* username,
|
||||
std::string* password,
|
||||
bool needs_2fa) {
|
||||
std::unique_lock<std::mutex> g(mu_);
|
||||
|
||||
if (!needs_2fa) {
|
||||
*username = apple_id_;
|
||||
*password = password_;
|
||||
return !apple_id_.empty();
|
||||
}
|
||||
|
||||
// Park here as Awaiting2FA. submit_2fa() or logout() will wake us.
|
||||
state_.store(LoginState::Awaiting2FA, std::memory_order_release);
|
||||
cv_state_.notify_all();
|
||||
|
||||
constexpr auto k2faTimeout = std::chrono::minutes(5);
|
||||
cv_2fa_.wait_for(g, k2faTimeout, [this] {
|
||||
return twofa_submitted_ || aborted_;
|
||||
});
|
||||
|
||||
state_.store(LoginState::InProgress, std::memory_order_release);
|
||||
cv_state_.notify_all();
|
||||
|
||||
*username = apple_id_;
|
||||
if (aborted_ || !twofa_submitted_) {
|
||||
// No code arrived in time. Returning empty creds lets the flow
|
||||
// resolve cleanly with an auth error rather than hang.
|
||||
*password = password_;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Upstream's quirk: AuthenticateFlow on Android expects the 2FA
|
||||
// code to be appended to the password field rather than delivered
|
||||
// as a separate input.
|
||||
*password = password_ + twofa_code_;
|
||||
return true;
|
||||
}
|
||||
|
||||
void Account::finish_authenticated(Tokens t) {
|
||||
std::lock_guard<std::mutex> g(mu_);
|
||||
tokens_ = std::move(t);
|
||||
tokens_.logged_in_at = std::chrono::system_clock::now();
|
||||
apple_id_ = tokens_.apple_id;
|
||||
// Wipe the password from memory now that the flow is done.
|
||||
password_.assign(password_.size(), '\0');
|
||||
password_.clear();
|
||||
twofa_code_.assign(twofa_code_.size(), '\0');
|
||||
twofa_code_.clear();
|
||||
twofa_submitted_ = false;
|
||||
state_.store(LoginState::Authenticated, std::memory_order_release);
|
||||
cv_state_.notify_all();
|
||||
}
|
||||
|
||||
void Account::finish_failed(std::string message, int code) {
|
||||
std::lock_guard<std::mutex> g(mu_);
|
||||
last_error_ = std::move(message);
|
||||
last_error_code_ = code;
|
||||
password_.assign(password_.size(), '\0');
|
||||
password_.clear();
|
||||
twofa_code_.assign(twofa_code_.size(), '\0');
|
||||
twofa_code_.clear();
|
||||
twofa_submitted_ = false;
|
||||
state_.store(LoginState::Failed, std::memory_order_release);
|
||||
cv_state_.notify_all();
|
||||
}
|
||||
|
||||
void Account::worker_main() {
|
||||
// Worker thread: drive AuthenticateFlow::run() to completion, then
|
||||
// harvest tokens or record the failure. All Apple calls below are
|
||||
// unchecked: a crash inside Apple's libs takes the daemon down
|
||||
// rather than masking the failure.
|
||||
if (loader_ == nullptr || !loader_->ok() || runtime_ == nullptr || !runtime_->initialized()) {
|
||||
finish_failed("runtime not initialized", -1);
|
||||
return;
|
||||
}
|
||||
const Symbols& s = loader_->sym();
|
||||
auto req_ctx = runtime_->request_ctx_copy();
|
||||
if (req_ctx.obj == nullptr) {
|
||||
finish_failed("request context unavailable", -2);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Upstream login() removes these before each AuthenticateFlow so stale
|
||||
session files do not confuse the flow. */
|
||||
{
|
||||
std::string bd = runtime_->base_dir();
|
||||
if (!bd.empty()) {
|
||||
unlink_quiet((bd + "/STOREFRONT_ID").c_str());
|
||||
unlink_quiet((bd + "/MUSIC_TOKEN").c_str());
|
||||
}
|
||||
}
|
||||
|
||||
abi::shared_ptr flow;
|
||||
aarch64_sret::make_shared_authenticate_flow(&flow, &req_ctx,
|
||||
s.make_shared_AuthenticateFlow);
|
||||
if (flow.obj == nullptr) {
|
||||
finish_failed("make_shared<AuthenticateFlow> returned null", -3);
|
||||
return;
|
||||
}
|
||||
s.AuthenticateFlow_run(flow.obj);
|
||||
|
||||
abi::shared_ptr* resp = s.AuthenticateFlow_response(flow.obj);
|
||||
if (resp == nullptr || resp->obj == nullptr) {
|
||||
finish_failed("AuthenticateFlow::response() returned null", -4);
|
||||
return;
|
||||
}
|
||||
|
||||
int resp_type = s.AR_responseType(resp->obj);
|
||||
if (resp_type != 6) {
|
||||
std::string msg;
|
||||
int code = 0;
|
||||
|
||||
abi::shared_ptr* err = s.AR_error(resp->obj);
|
||||
if (err != nullptr && err->obj != nullptr) {
|
||||
code = s.SEC_errorCode(err->obj);
|
||||
const char* what = s.SEC_what(err->obj);
|
||||
if (what != nullptr) msg = what;
|
||||
}
|
||||
if (msg.empty()) {
|
||||
abi::std_string* customer = s.AR_customerMessage(resp->obj);
|
||||
if (customer != nullptr) {
|
||||
bool is_long = (customer->_long.cap & 1) != 0;
|
||||
const char* p = is_long ? customer->_long.data
|
||||
: customer->_short.str;
|
||||
if (p && *p) msg = p;
|
||||
}
|
||||
}
|
||||
if (msg.empty()) {
|
||||
msg = "Apple returned response type " + std::to_string(resp_type);
|
||||
}
|
||||
finish_failed(std::move(msg), code);
|
||||
return;
|
||||
}
|
||||
|
||||
Tokens t;
|
||||
t.apple_id = apple_id_;
|
||||
if (!tokens::harvest_all(s, runtime_->request_ctx_copy(),
|
||||
runtime_->device_guid_copy(), &t)) {
|
||||
finish_failed("token harvest failed after flow.run() succeeded", -5);
|
||||
return;
|
||||
}
|
||||
finish_authenticated(std::move(t));
|
||||
}
|
||||
|
||||
} // namespace wrapper::apple
|
||||
@@ -0,0 +1,168 @@
|
||||
// Account / login state for wrapper-v2.
|
||||
//
|
||||
// Apple-ID password sign-in flow driven through Apple's AuthenticateFlow. The
|
||||
// flow is callback-based (Apple calls our credentialHandler inline
|
||||
// from AuthenticateFlow::run()), so we run it on a dedicated worker
|
||||
// thread and coordinate with the HTTP layer through a state machine
|
||||
// plus a condvar that parks the credentialHandler while we wait for
|
||||
// a 2FA code from POST /login/2fa.
|
||||
//
|
||||
// Threading model:
|
||||
// - One concurrent login flow at a time (the underlying RequestContext
|
||||
// is process-global; Apple's libs are not re-entrant on the auth
|
||||
// path).
|
||||
// - Account::instance() is a singleton so the static credentialHandler
|
||||
// trampoline (which has no user-data param) can reach state.
|
||||
// - All state mutation is mutex-protected; readers see consistent
|
||||
// snapshots.
|
||||
//
|
||||
// Persistence (mirrors upstream zhaarey/wrapper without -L):
|
||||
// - We never persist email/password. Apple writes mpl_db/kvs.sqlitedb
|
||||
// under WRAPPER_BASE_DIR when a real login has run at least once.
|
||||
// - On startup, try_restore_cached_session() may harvest storefront /
|
||||
// dev / music tokens from that persisted session (same as upstream
|
||||
// main() after skipping login()).
|
||||
// - Optional WRAPPER_APPLE_ID is only for labeling /me after restore;
|
||||
// it is not sent to Apple.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include "apple/abi.hpp"
|
||||
|
||||
namespace wrapper::apple {
|
||||
|
||||
class Loader;
|
||||
class Runtime;
|
||||
|
||||
// Coarse-grained login state surfaced via /me.
|
||||
enum class LoginState : std::uint8_t {
|
||||
LoggedOut, // No credentials presented yet.
|
||||
InProgress, // Worker thread is running AuthenticateFlow::run().
|
||||
Awaiting2FA, // credentialHandler is parked waiting for a 6-digit code.
|
||||
Authenticated, // flow.run() returned responseType=6 and tokens are cached.
|
||||
Failed, // flow.run() reported an error; see last_error / last_code.
|
||||
};
|
||||
|
||||
const char* to_string(LoginState s);
|
||||
|
||||
// Cached account-side data harvested after a successful flow.run().
|
||||
// Returned (with secrets) only via Account::tokens(); a sanitized
|
||||
// subset is exposed via Account::public_snapshot().
|
||||
struct Tokens {
|
||||
std::string apple_id;
|
||||
std::string storefront; // e.g. "143441" (US) or storefront/region string
|
||||
std::string dev_token; // sf-api-token-service apiToken
|
||||
std::string music_user_token; // createMusicToken result
|
||||
std::string dsid; // decoded from dev_token JWT (empty if not extractable)
|
||||
std::chrono::system_clock::time_point logged_in_at{};
|
||||
};
|
||||
|
||||
// Public-safe subset of Tokens for /me payloads.
|
||||
struct AccountSnapshot {
|
||||
LoginState state = LoginState::LoggedOut;
|
||||
std::string apple_id;
|
||||
std::string storefront;
|
||||
std::string dsid;
|
||||
std::string music_user_token;
|
||||
std::string dev_token;
|
||||
std::chrono::system_clock::time_point logged_in_at{};
|
||||
|
||||
// Set when state == Failed.
|
||||
std::string last_error;
|
||||
int last_error_code = 0;
|
||||
};
|
||||
|
||||
class Account {
|
||||
public:
|
||||
static Account& instance();
|
||||
|
||||
// Detach (rather than join) any still-running worker at process
|
||||
// exit. The worker is likely sitting inside Apple's libs and we
|
||||
// can't safely cancel that synchronously - the OS will reap it.
|
||||
~Account();
|
||||
|
||||
// Returns false if a login is already in progress (InProgress or
|
||||
// Awaiting2FA). A fresh login request is allowed to replace an existing
|
||||
// authenticated snapshot, which lets clients recover from stale restored
|
||||
// Apple sessions without a separate DELETE /login. On true, the worker
|
||||
// thread has been started.
|
||||
bool start_login(const Loader& loader,
|
||||
const Runtime& runtime,
|
||||
std::string apple_id,
|
||||
std::string password);
|
||||
|
||||
// Hand a 2FA code to the parked credentialHandler. Returns false if
|
||||
// the state is not Awaiting2FA.
|
||||
bool submit_2fa(std::string code);
|
||||
|
||||
// Reset to LoggedOut. If a worker thread is running, signals it to
|
||||
// exit at the next condvar wake. Tokens are wiped.
|
||||
void logout();
|
||||
|
||||
// If state is LoggedOut and Apple already has a warm session on disk
|
||||
// (same pattern as upstream: init_ctx() without -L), harvest tokens
|
||||
// and become Authenticated. Returns false if no usable session.
|
||||
bool try_restore_cached_session(const Loader& loader, const Runtime& runtime);
|
||||
|
||||
// Block (up to `timeout`) until state transitions out of InProgress.
|
||||
// Returns the resulting state. Used by POST /login and POST /login/2fa
|
||||
// to know when to send the HTTP response.
|
||||
LoginState wait_for_settled_state(std::chrono::milliseconds timeout);
|
||||
|
||||
// Public-safe snapshot for /me.
|
||||
AccountSnapshot public_snapshot() const;
|
||||
|
||||
LoginState state() const;
|
||||
|
||||
// ---- Internal: only the credentialHandler trampoline should call these ----
|
||||
// Pull the current login attempt's credentials. Returns false if
|
||||
// there is no active login (e.g. the daemon never received /login).
|
||||
// If 2FA is required, this method blocks (up to a generous timeout)
|
||||
// until submit_2fa() is called or logout() aborts, then returns the
|
||||
// password+code concatenation upstream's flow expects.
|
||||
bool fetch_credentials_blocking(std::string* username,
|
||||
std::string* password,
|
||||
bool needs_2fa);
|
||||
|
||||
private:
|
||||
Account() = default;
|
||||
|
||||
void worker_main();
|
||||
|
||||
// Set by the worker after flow.run() finishes (success or failure).
|
||||
void finish_authenticated(Tokens t);
|
||||
void finish_failed(std::string message, int code);
|
||||
|
||||
mutable std::mutex mu_;
|
||||
std::condition_variable cv_state_; // notified on any state change
|
||||
std::condition_variable cv_2fa_; // notified when 2FA code arrives or logout
|
||||
std::mutex restore_mu_; // serializes cached-session harvests
|
||||
std::atomic<LoginState> state_{LoginState::LoggedOut};
|
||||
|
||||
// Inputs for the active login attempt.
|
||||
const Loader* loader_ = nullptr;
|
||||
const Runtime* runtime_ = nullptr;
|
||||
std::string apple_id_;
|
||||
std::string password_;
|
||||
std::string twofa_code_;
|
||||
bool twofa_submitted_ = false;
|
||||
bool aborted_ = false;
|
||||
|
||||
// Outputs from a completed login.
|
||||
Tokens tokens_;
|
||||
std::string last_error_;
|
||||
int last_error_code_ = 0;
|
||||
|
||||
std::thread worker_;
|
||||
};
|
||||
|
||||
} // namespace wrapper::apple
|
||||
@@ -0,0 +1,198 @@
|
||||
#include "apple/decrypt.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <exception>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
|
||||
#include "apple/fairplay_cert.inc"
|
||||
#include "apple/aarch64_sret_thunks.hpp"
|
||||
#include "apple/loader.hpp"
|
||||
#include "apple/runtime.hpp"
|
||||
|
||||
namespace wrapper::apple {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr char kPrefetchAdam[] = "0";
|
||||
|
||||
// zhaarey/wrapper main.c getKdContext:
|
||||
// - Never destroys SVFootHillPContext (stack POD shared_ptr, no refcount decrement).
|
||||
// - preshareCtx: first successful adam=="0" path caches kd; later adam=="0" skips
|
||||
// getPersistentKey entirely (no URI check in upstream).
|
||||
// - This wrapper also caches regular track kd contexts by adam_id + key URI so
|
||||
// batch decrypt requests do not repeatedly process the same CKC.
|
||||
//
|
||||
// Calling shared_ptr_SVFootHillPContext_dtor breaks kd / Apple's FootHill state.
|
||||
|
||||
std::mutex g_kd_cache_mu;
|
||||
std::unordered_map<std::string, void*> g_kd_cache;
|
||||
|
||||
std::string kd_cache_key(const std::string& adam_id, const std::string& key_uri) {
|
||||
std::string key;
|
||||
key.reserve(adam_id.size() + 1 + key_uri.size());
|
||||
key.append(adam_id);
|
||||
key.push_back('\n');
|
||||
key.append(key_uri);
|
||||
return key;
|
||||
}
|
||||
|
||||
void* find_cached_kd(const std::string& adam_id, const std::string& key_uri) {
|
||||
std::lock_guard<std::mutex> lock(g_kd_cache_mu);
|
||||
auto it = g_kd_cache.find(kd_cache_key(adam_id, key_uri));
|
||||
if (it == g_kd_cache.end()) return nullptr;
|
||||
return it->second;
|
||||
}
|
||||
|
||||
void store_cached_kd(const std::string& adam_id, const std::string& key_uri, void* kd) {
|
||||
std::lock_guard<std::mutex> lock(g_kd_cache_mu);
|
||||
g_kd_cache[kd_cache_key(adam_id, key_uri)] = kd;
|
||||
}
|
||||
|
||||
void erase_cached_kd(const std::string& adam_id, const std::string& key_uri) {
|
||||
std::lock_guard<std::mutex> lock(g_kd_cache_mu);
|
||||
g_kd_cache.erase(kd_cache_key(adam_id, key_uri));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
DecryptResult decrypt_samples(const Loader& loader,
|
||||
Runtime& runtime,
|
||||
std::string adam_id,
|
||||
std::string key_uri,
|
||||
std::vector<std::vector<std::uint8_t>> ciphertexts) {
|
||||
DecryptResult out;
|
||||
if (!loader.ok() || !loader.fairplay_decrypt_available()) {
|
||||
out.error = "FairPlay decrypt chain not loaded";
|
||||
return out;
|
||||
}
|
||||
if (!runtime.playback_ready()) {
|
||||
out.error = "playback stack not ready";
|
||||
return out;
|
||||
}
|
||||
if (adam_id.empty() || key_uri.empty()) {
|
||||
out.error = "adam_id and uri are required";
|
||||
return out;
|
||||
}
|
||||
if (ciphertexts.empty()) {
|
||||
out.error = "at least one sample required";
|
||||
return out;
|
||||
}
|
||||
|
||||
const Symbols& s = loader.sym();
|
||||
void* fh = runtime.foothill_session();
|
||||
|
||||
auto decrypt_once = [&](bool allow_cache,
|
||||
std::vector<std::vector<std::uint8_t>> chunks,
|
||||
std::string* error) -> DecryptResult {
|
||||
DecryptResult attempt;
|
||||
void* kd = nullptr;
|
||||
|
||||
if (allow_cache) {
|
||||
kd = find_cached_kd(adam_id, key_uri);
|
||||
}
|
||||
|
||||
if (kd == nullptr && adam_id == kPrefetchAdam) {
|
||||
*error = "prefetch decrypt context is not cached; decrypt prefetch samples locally";
|
||||
return attempt;
|
||||
}
|
||||
|
||||
if (kd == nullptr) {
|
||||
std::fprintf(stderr, "decrypt: getPersistentKey adam=%s samples=%zu\n",
|
||||
adam_id.c_str(), chunks.size());
|
||||
auto default_id = abi::make_string_view(adam_id.c_str());
|
||||
auto uri = abi::make_string_view(key_uri.c_str());
|
||||
auto key_format = abi::make_string_view("com.apple.streamingkeydelivery");
|
||||
auto key_ver = abi::make_string_view("1");
|
||||
auto server_uri =
|
||||
abi::make_string_view("https://play.itunes.apple.com/WebObjects/MZPlay.woa/music/fps");
|
||||
auto protocol = abi::make_string_view("simplified");
|
||||
auto fps_cert = abi::make_string_view(kFairPlayCert);
|
||||
|
||||
abi::shared_ptr persist{};
|
||||
loader.foot_hill_get_persistent_key(
|
||||
&persist, fh,
|
||||
&default_id, &uri, &key_format, &key_ver,
|
||||
&server_uri, &protocol, &fps_cert);
|
||||
|
||||
if (persist.obj == nullptr) {
|
||||
*error = "getPersistentKey failed (key or lease?)";
|
||||
return attempt;
|
||||
}
|
||||
|
||||
std::fprintf(stderr, "decrypt: decryptContext adam=%s\n", adam_id.c_str());
|
||||
abi::shared_ptr sv_ctx{};
|
||||
aarch64_sret::svfoot_decrypt_context(&sv_ctx, fh, persist.obj,
|
||||
s.SVFootHillSessionCtrl_decryptContext);
|
||||
|
||||
if (sv_ctx.obj == nullptr) {
|
||||
*error = "decryptContext failed";
|
||||
return attempt;
|
||||
}
|
||||
|
||||
// Upstream main.c does TWO dereferences:
|
||||
// void* p = *kdContext_method(ctx); // *(void**) -> void*
|
||||
// ... NfcRKVn(*(void**)p, ...) // re-cast and deref again
|
||||
// i.e. fp_sample_decrypt receives **kdContext_method(ctx). Doing only
|
||||
// one deref passes the kd-handle struct pointer instead of the actual
|
||||
// engine state pointer; fp_sample_decrypt doesn't error but the
|
||||
// produced plaintext is garbage (audio plays back unplayable).
|
||||
void** kd_pp = s.SVFootHillPContext_kdContext(sv_ctx.obj);
|
||||
if (kd_pp == nullptr || *kd_pp == nullptr) {
|
||||
*error = "kdContext is null";
|
||||
return attempt;
|
||||
}
|
||||
kd = *reinterpret_cast<void**>(*kd_pp);
|
||||
if (kd == nullptr) {
|
||||
*error = "kdContext inner pointer is null";
|
||||
return attempt;
|
||||
}
|
||||
|
||||
store_cached_kd(adam_id, key_uri, kd);
|
||||
|
||||
// Intentionally no shared_ptr dtors — see block comment above.
|
||||
(void)persist;
|
||||
(void)sv_ctx;
|
||||
}
|
||||
|
||||
attempt.plaintexts.reserve(chunks.size());
|
||||
std::fprintf(stderr, "decrypt: fp_sample_decrypt samples=%zu\n", chunks.size());
|
||||
for (auto& chunk : chunks) {
|
||||
if (chunk.empty()) {
|
||||
*error = "empty sample";
|
||||
attempt.plaintexts.clear();
|
||||
return attempt;
|
||||
}
|
||||
const long status = s.fp_sample_decrypt(kd, 5u, chunk.data(), chunk.data(), chunk.size());
|
||||
if (status < 0) {
|
||||
*error = "FairPlay sample decrypt failed status=" + std::to_string(status);
|
||||
std::fprintf(stderr, "decrypt: fp_sample_decrypt failed status=%ld\n", status);
|
||||
attempt.plaintexts.clear();
|
||||
return attempt;
|
||||
}
|
||||
attempt.plaintexts.push_back(std::move(chunk));
|
||||
}
|
||||
|
||||
attempt.ok = true;
|
||||
return attempt;
|
||||
};
|
||||
|
||||
std::string first_error;
|
||||
try {
|
||||
out = decrypt_once(true, ciphertexts, &first_error);
|
||||
} catch (const std::exception& e) {
|
||||
first_error = e.what();
|
||||
} catch (...) {
|
||||
first_error = "native FairPlay decrypt threw an unknown exception";
|
||||
}
|
||||
if (out.ok) return out;
|
||||
|
||||
erase_cached_kd(adam_id, key_uri);
|
||||
out.error = "FairPlay decrypt failed";
|
||||
if (!first_error.empty()) out.error += " (first: " + first_error + ")";
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace wrapper::apple
|
||||
@@ -0,0 +1,29 @@
|
||||
// FairPlay FPS sample decryption (POST /decrypt). Mirrors upstream
|
||||
// wrapper main.c:getKdContext + NfcRKVnxuKZy04KWbdFu71Ou.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace wrapper::apple {
|
||||
|
||||
class Loader;
|
||||
class Runtime;
|
||||
|
||||
struct DecryptResult {
|
||||
bool ok = false;
|
||||
std::string error;
|
||||
std::vector<std::vector<std::uint8_t>> plaintexts;
|
||||
};
|
||||
|
||||
// Decrypt one or more ciphertext samples for the same (adam_id, uri) key.
|
||||
// Caller must hold runtime.playback_mutex() for the Apple calls (server does).
|
||||
DecryptResult decrypt_samples(const Loader& loader,
|
||||
Runtime& runtime,
|
||||
std::string adam_id,
|
||||
std::string key_uri,
|
||||
std::vector<std::vector<std::uint8_t>> ciphertexts);
|
||||
|
||||
} // namespace wrapper::apple
|
||||
@@ -0,0 +1,4 @@
|
||||
// Embedded Apple FPS leaf certificate blob (from upstream import.h).
|
||||
#pragma once
|
||||
inline constexpr const char kFairPlayCert[] =
|
||||
"MIIEzjCCA7agAwIBAgIIAXAVjHFZDjgwDQYJKoZIhvcNAQEFBQAwfzELMAkGA1UEBhMCVVMxEzARBgNVBAoMCkFwcGxlIEluYy4xJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MTMwMQYDVQQDDCpBcHBsZSBLZXkgU2VydmljZXMgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTIwNzI1MTgwMjU4WhcNMTQwNzI2MTgwMjU4WjAwMQswCQYDVQQGEwJVUzESMBAGA1UECgwJQXBwbGUgSW5jMQ0wCwYDVQQDDARGUFMxMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCqZ9IbMt0J0dTKQN4cUlfeQRY9bcnbnP95HFv9A16Yayh4xQzRLAQqVSmisZtBK2/nawZcDmcs+XapBojRb+jDM4Dzk6/Ygdqo8LoA+BE1zipVyalGLj8Y86hTC9QHX8i05oWNCDIlmabjjWvFBoEOk+ezOAPg8c0SET38x5u+TwIDAQABo4ICHzCCAhswHQYDVR0OBBYEFPP6sfTWpOQ5Sguf5W3Y0oibbEc3MAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUY+RHVMuFcVlGLIOszEQxZGcDLL4wgeIGA1UdIASB2jCB1zCB1AYJKoZIhvdjZAUBMIHGMIHDBggrBgEFBQcCAjCBtgyBs1JlbGlhbmNlIG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBjb25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZpY2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMDUGA1UdHwQuMCwwKqAooCaGJGh0dHA6Ly9jcmwuYXBwbGUuY29tL2tleXNlcnZpY2VzLmNybDAOBgNVHQ8BAf8EBAMCBSAwFAYLKoZIhvdjZAYNAQUBAf8EAgUAMBsGCyqGSIb3Y2QGDQEGAQH/BAkBAAAAAQAAAAEwKQYLKoZIhvdjZAYNAQMBAf8EFwF+bjsY57ASVFmeehD2bdu6HLGBxeC2MEEGCyqGSIb3Y2QGDQEEAQH/BC8BHrKviHJf/Se/ibc7T0/55Bt1GePzaYBVfgF3ZiNuV93z8P3qsawAqAXzzh9o5DANBgkqhkiG9w0BAQUFAAOCAQEAVGyCtuLYcYb/aPijBCtaemxuV0IokXJn3EgmwYHZynaR6HZmeGRUp9p3f8EXu6XPSekKCCQi+a86hXX9RfnGEjRdvtP+jts5MDSKuUIoaqce8cLX2dpUOZXdf3lR0IQM0kXHb5boNGBsmbTLVifqeMsexfZryGw2hE/4WDOJdGQm1gMJZU4jP1b/HSLNIUhHWAaMeWtcJTPRBucR4urAtvvtOWD88mriZNHG+veYw55b+qA36PSqDPMbku9xTY7fsMa6mxIRmwULQgi8nOk1wNhw3ZO0qUKtaCO3gSqWdloecxpxUQSZCSW7tWPkpXXwDZqegUkij9xMFS1pr37RIjCCBVAwggQ4oAMCAQICEEVKuaGraq1Cp4z6TFOeVfUwDQYJKoZIhvcNAQELBQAwUDEsMCoGA1UEAwwjQXBwbGUgRlAgU2VydmljZSBFbmFibGUgUlNBIENBIC0gRzExEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMB4XDTIwMDQwNzIwMjY0NFoXDTIyMDQwNzIwMjY0NFowWjEhMB8GA1UEAwwYZnBzMjA0OC5pdHVuZXMuYXBwbGUuY29tMRMwEQYDVQQLDApBcHBsZSBJbmMuMRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJNoUHuTRLafofQgIRgGa2TFIf+bsFDMjs+y3Ep1xCzFLE4QbnwG6OG0duKUl5IoGUsouzZk9iGsXz5k3ESLOWKz2BFrDTvGrzAcuLpH66jJHGsk/l+ZzsDOJaoQ22pu0JvzYzW8/yEKvpE6JF/2dsC6V9RDTri3VWFxrl5uh8czzncoEQoRcQsSatHzs4tw/QdHFtBIigqxqr4R7XiCaHbsQmqbP9h7oxRs/6W/DDA2BgkuFY1ocX/8dTjmH6szKPfGt3KaYCwy3fuRC+FibTyohtvmlXsYhm7AUzorwWIwN/MbiFQ0OHHtDomIy71wDcTNMnY0jZYtGmIlJETAgYcCAwEAAaOCAhowggIWMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUrI/yBkpV623/IeMrXzs8fC7VkZkwRQYIKwYBBQUHAQEEOTA3MDUGCCsGAQUFBzABhilodHRwOi8vb2NzcC5hcHBsZS5jb20vb2NzcDAzLWZwc3J2cnNhZzEwMzCBwwYDVR0gBIG7MIG4MIG1BgkqhkiG92NkBQEwgacwgaQGCCsGAQUFBwICMIGXDIGUUmVsaWFuY2Ugb24gdGhpcyBjZXJ0aWZpY2F0ZSBieSBhbnkgcGFydHkgYXNzdW1lcyBhY2NlcHRhbmNlIG9mIGFueSBhcHBsaWNhYmxlIHRlcm1zIGFuZCBjb25kaXRpb25zIG9mIHVzZSBhbmQvb3IgY2VydGlmaWNhdGlvbiBwcmFjdGljZSBzdGF0ZW1lbnRzLjAdBgNVHQ4EFgQU2RpCSSHFXeoZQQWxbwJuRZ9RrIEwDgYDVR0PAQH/BAQDAgUgMBQGCyqGSIb3Y2QGDQEFAQH/BAIFADAjBgsqhkiG92NkBg0BBgEB/wQRAQAAAAMAAAABAAAAAgAAAAMwOQYLKoZIhvdjZAYNAQMBAf8EJwG+pUeWbeZBUI0PikyFwSggL5dHaeugSDoQKwcP28csLuh5wplpATAzBgsqhkiG92NkBg0BBAEB/wQhAfl9TGjP/UY9TyQzYsn8sX9ZvHChok9QrrUhtAyWR1yCMA0GCSqGSIb3DQEBCwUAA4IBAQBNMzZ6llQ0laLXsrmyVieuoW9+pHeAaDJ7cBiQLjM3ZdIO3Gq5dkbWYYYwJwymdxZ74WGZMuVv3ueJKcxG1jAhCRhr0lb6QaPaQQSNW+xnoesb3CLA0RzrcgBp/9WFZNdttJOSyC93lQmiE0r5RqPpe/IWUzwoZxri8qnsghVFxCBEcMB+U4PJR8WeAkPrji8po2JLYurvgNRhGkDKcAFPuGEpXdF86hPts+07zazsP0fBjBSVgP3jqb8G31w5W+O+wBW0B9uCf3s0vXU4LuJTAywws2ImZ7O/AaY/uXWOyIUMUKPgL1/QJieB7pBoENIJ2CeJS2M3iv00ssmCmTEJ";
|
||||
@@ -0,0 +1,450 @@
|
||||
#include "apple/loader.hpp"
|
||||
|
||||
#include "apple/aarch64_sret_thunks.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <dlfcn.h>
|
||||
|
||||
namespace wrapper::apple {
|
||||
|
||||
namespace {
|
||||
|
||||
// Mirrors WRAPPER_RUNTIME_TRACE in runtime.cpp: when set to any non-empty
|
||||
// non-"0" value, the loader prints a single stderr line before each dlopen /
|
||||
// dlsym group so a SIGSEGV inside DT_INIT_ARRAY (or a missing symbol) can be
|
||||
// localized to the exact library group. Stays off by default since the
|
||||
// per-call tracing is verbose.
|
||||
bool loader_trace_enabled() {
|
||||
static const bool v = []() {
|
||||
const char* x = std::getenv("WRAPPER_RUNTIME_TRACE");
|
||||
if (x == nullptr || *x == '\0') return false;
|
||||
if (std::strcmp(x, "0") == 0) return false;
|
||||
return true;
|
||||
}();
|
||||
return v;
|
||||
}
|
||||
|
||||
void trace(const char* stage) {
|
||||
if (!loader_trace_enabled()) return;
|
||||
std::fprintf(stderr, "loader: trace %s\n", stage);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
|
||||
// Helper: dlopen with friendlier error reporting.
|
||||
void* open_lib(const std::string& path, std::string* err_out) {
|
||||
if (loader_trace_enabled()) {
|
||||
std::fprintf(stderr, "loader: dlopen %s ...\n", path.c_str());
|
||||
std::fflush(stderr);
|
||||
}
|
||||
void* h = dlopen(path.c_str(), RTLD_NOW | RTLD_GLOBAL);
|
||||
if (h == nullptr) {
|
||||
const char* msg = dlerror();
|
||||
if (err_out != nullptr) {
|
||||
*err_out = "dlopen(" + path + "): " + (msg ? msg : "unknown error");
|
||||
}
|
||||
std::fprintf(stderr, "loader: %s\n", err_out ? err_out->c_str() : "");
|
||||
} else if (loader_trace_enabled()) {
|
||||
std::fprintf(stderr, "loader: dlopen %s -> %p\n", path.c_str(), h);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
void* open_lib_optional(const std::string& path) {
|
||||
if (loader_trace_enabled()) {
|
||||
std::fprintf(stderr, "loader: dlopen(optional) %s ...\n", path.c_str());
|
||||
std::fflush(stderr);
|
||||
}
|
||||
void* h = dlopen(path.c_str(), RTLD_NOW | RTLD_GLOBAL);
|
||||
if (h == nullptr) {
|
||||
const char* msg = dlerror();
|
||||
std::fprintf(stderr, "loader: optional dlopen %s: %s\n", path.c_str(),
|
||||
msg ? msg : "?");
|
||||
} else if (loader_trace_enabled()) {
|
||||
std::fprintf(stderr, "loader: dlopen(optional) %s -> %p\n", path.c_str(), h);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
// Helper: dlsym with friendlier error reporting. Handle may be a real
|
||||
// dlopen result OR RTLD_DEFAULT (which on bionic/x86_64 is the literal
|
||||
// value 0 - so a null-check on the handle here would be wrong).
|
||||
template <typename T>
|
||||
bool resolve(void* h, const char* name, T* out, std::string* err_out) {
|
||||
dlerror();
|
||||
void* sym = dlsym(h, name);
|
||||
const char* msg = dlerror();
|
||||
if (msg != nullptr || sym == nullptr) {
|
||||
if (err_out) {
|
||||
*err_out = std::string("dlsym(") + name + "): " + (msg ? msg : "not found");
|
||||
}
|
||||
std::fprintf(stderr, "loader: %s\n", err_out ? err_out->c_str() : "");
|
||||
return false;
|
||||
}
|
||||
*out = reinterpret_cast<T>(sym);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool resolve_vtable(const char* name, void*** out, std::string* err_out) {
|
||||
dlerror();
|
||||
void* sym = dlsym(RTLD_DEFAULT, name);
|
||||
const char* msg = dlerror();
|
||||
if (sym == nullptr || msg != nullptr) {
|
||||
if (err_out) {
|
||||
*err_out = std::string("dlsym(") + name + "): " + (msg ? msg : "not found");
|
||||
}
|
||||
std::fprintf(stderr, "loader: %s\n", err_out ? err_out->c_str() : "");
|
||||
return false;
|
||||
}
|
||||
*out = reinterpret_cast<void**>(sym);
|
||||
return true;
|
||||
}
|
||||
|
||||
void clear_fairplay_symbols(Symbols* s) {
|
||||
s->SVPlaybackLeaseManager_ctor = nullptr;
|
||||
s->SVPlaybackLeaseManager_refreshLeaseAutomatically = nullptr;
|
||||
s->SVPlaybackLeaseManager_requestLease = nullptr;
|
||||
s->SVFootHillSessionCtrl_instance = nullptr;
|
||||
s->SVFootHillSessionCtrl_decryptContext = nullptr;
|
||||
s->SVFootHillPContext_kdContext = nullptr;
|
||||
s->fp_sample_decrypt = nullptr;
|
||||
s->SVFootHillSessionCtrl_resetAllContexts = nullptr;
|
||||
s->shared_ptr_SVFootHillPContext_dtor = nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool Loader::open(const std::string& libs_dir) {
|
||||
if (ok_) return true;
|
||||
|
||||
auto load = [&](const std::string& path, void** dest) {
|
||||
*dest = open_lib(path, &last_error_);
|
||||
return *dest != nullptr;
|
||||
};
|
||||
|
||||
// libc++_shared.so is already a DT_NEEDED of the daemon; do not dlopen.
|
||||
//
|
||||
// Pre-load FairPlay helpers first so SVFootHill symbols resolve (some
|
||||
// builds only export the chain once CoreFP/CoreLSKD are in the process).
|
||||
trace("dlopen libCoreFP.so (optional)");
|
||||
h_libcorefp_ = open_lib_optional(libs_dir + "/libCoreFP.so");
|
||||
trace("dlopen libCoreLSKD.so (optional)");
|
||||
h_libcorelskd_ = open_lib_optional(libs_dir + "/libCoreLSKD.so");
|
||||
|
||||
// Match upstream CMake link order: androidappmusic, storeservicescore,
|
||||
// mediaplatform (after cxx). Dependencies still resolve transitively.
|
||||
trace("dlopen libandroidappmusic.so");
|
||||
if (!load(libs_dir + "/libandroidappmusic.so", &h_libandroidappmusic_)) {
|
||||
return false;
|
||||
}
|
||||
trace("dlopen libstoreservicescore.so");
|
||||
if (!load(libs_dir + "/libstoreservicescore.so", &h_libstoreservicescore_)) {
|
||||
return false;
|
||||
}
|
||||
trace("dlopen libmediaplatform.so");
|
||||
if (!load(libs_dir + "/libmediaplatform.so", &h_libmediaplatform_)) {
|
||||
return false;
|
||||
}
|
||||
trace("all core libs loaded; starting symbol resolution");
|
||||
|
||||
using namespace abi;
|
||||
|
||||
if (!resolve(RTLD_DEFAULT,
|
||||
mangled::resolv_set_nameservers_for_net,
|
||||
&symbols_.resolv_set_nameservers_for_net, &last_error_)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!resolve_vtable(mangled::vtable_RequestContextConfig,
|
||||
&symbols_.vtable_RequestContextConfig, &last_error_)) {
|
||||
return false;
|
||||
}
|
||||
if (!resolve_vtable(mangled::vtable_CredentialsResponse,
|
||||
&symbols_.vtable_CredentialsResponse, &last_error_)) {
|
||||
return false;
|
||||
}
|
||||
if (!resolve_vtable(mangled::vtable_ProtocolDialogResponse,
|
||||
&symbols_.vtable_ProtocolDialogResponse, &last_error_)) {
|
||||
return false;
|
||||
}
|
||||
if (!resolve_vtable(mangled::vtable_HTTPMessage,
|
||||
&symbols_.vtable_HTTPMessage, &last_error_)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
#define RESOLVE(field, name) \
|
||||
if (!resolve(RTLD_DEFAULT, mangled::name, &symbols_.field, &last_error_)) return false
|
||||
|
||||
RESOLVE(FootHillConfig_config, FootHillConfig_config);
|
||||
RESOLVE(DeviceGUID_instance, DeviceGUID_instance);
|
||||
RESOLVE(DeviceGUID_configure, DeviceGUID_configure);
|
||||
RESOLVE(make_shared_RequestContext, make_shared_RequestContext);
|
||||
RESOLVE(RequestContextConfig_ctor, RequestContextConfig_ctor);
|
||||
|
||||
RESOLVE(RCC_setBaseDirectoryPath, RCC_setBaseDirectoryPath);
|
||||
RESOLVE(RCC_setClientIdentifier, RCC_setClientIdentifier);
|
||||
RESOLVE(RCC_setVersionIdentifier, RCC_setVersionIdentifier);
|
||||
RESOLVE(RCC_setPlatformIdentifier, RCC_setPlatformIdentifier);
|
||||
RESOLVE(RCC_setProductVersion, RCC_setProductVersion);
|
||||
RESOLVE(RCC_setDeviceModel, RCC_setDeviceModel);
|
||||
RESOLVE(RCC_setBuildVersion, RCC_setBuildVersion);
|
||||
RESOLVE(RCC_setLocaleIdentifier, RCC_setLocaleIdentifier);
|
||||
RESOLVE(RCC_setLanguageIdentifier, RCC_setLanguageIdentifier);
|
||||
|
||||
RESOLVE(RequestContextManager_configure, RequestContextManager_configure);
|
||||
RESOLVE(RequestContext_init, RequestContext_init);
|
||||
RESOLVE(RequestContext_setFairPlayDirectoryPath, RequestContext_setFairPlayDirectoryPath);
|
||||
|
||||
RESOLVE(make_shared_AndroidPresentationInterface, make_shared_AndroidPresentationInterface);
|
||||
RESOLVE(API_setCredentialsHandler, API_setCredentialsHandler);
|
||||
RESOLVE(API_setDialogHandler, API_setDialogHandler);
|
||||
RESOLVE(API_handleCredentialsResponse, API_handleCredentialsResponse);
|
||||
RESOLVE(API_handleProtocolDialogResponse, API_handleProtocolDialogResponse);
|
||||
RESOLVE(RequestContext_setPresentationInterface, RequestContext_setPresentationInterface);
|
||||
|
||||
RESOLVE(ProtocolDialog_title, ProtocolDialog_title);
|
||||
RESOLVE(ProtocolDialog_message, ProtocolDialog_message);
|
||||
RESOLVE(ProtocolDialog_buttons, ProtocolDialog_buttons);
|
||||
RESOLVE(ProtocolButton_title, ProtocolButton_title);
|
||||
RESOLVE(ProtocolDialogResponse_ctor, ProtocolDialogResponse_ctor);
|
||||
RESOLVE(ProtocolDialogResponse_setSelectedButton, ProtocolDialogResponse_setSelectedButton);
|
||||
|
||||
RESOLVE(CR_requiresHSA2VerificationCode, CR_requiresHSA2VerificationCode);
|
||||
RESOLVE(CR_title, CR_title);
|
||||
RESOLVE(CR_message, CR_message);
|
||||
|
||||
RESOLVE(CredentialsResponse_ctor, CredentialsResponse_ctor);
|
||||
RESOLVE(CredentialsResponse_setUserName, CredentialsResponse_setUserName);
|
||||
RESOLVE(CredentialsResponse_setPassword, CredentialsResponse_setPassword);
|
||||
RESOLVE(CredentialsResponse_setResponseType, CredentialsResponse_setResponseType);
|
||||
|
||||
RESOLVE(make_shared_AuthenticateFlow, make_shared_AuthenticateFlow);
|
||||
RESOLVE(AuthenticateFlow_run, AuthenticateFlow_run);
|
||||
RESOLVE(AuthenticateFlow_response, AuthenticateFlow_response);
|
||||
|
||||
RESOLVE(AR_responseType, AR_responseType);
|
||||
RESOLVE(AR_customerMessage, AR_customerMessage);
|
||||
RESOLVE(AR_error, AR_error);
|
||||
|
||||
RESOLVE(SEC_errorCode, SEC_errorCode);
|
||||
RESOLVE(SEC_what, SEC_what);
|
||||
|
||||
RESOLVE(DeviceGUID_guid, DeviceGUID_guid);
|
||||
RESOLVE(Data_bytes, Data_bytes);
|
||||
|
||||
RESOLVE(HTTPMessage_ctor, HTTPMessage_ctor);
|
||||
// C1 is optional: arm64 builds may need the complete-object ctor
|
||||
// when HTTPMessage has virtual bases. If it’s absent we fall back to C2.
|
||||
{
|
||||
dlerror();
|
||||
void* sym = dlsym(RTLD_DEFAULT, abi::mangled::HTTPMessage_ctor_c1);
|
||||
if (sym != nullptr && dlerror() == nullptr) {
|
||||
symbols_.HTTPMessage_ctor_c1 = reinterpret_cast<abi::fn_HTTPMessage_ctor_c1>(sym);
|
||||
}
|
||||
}
|
||||
RESOLVE(HTTPMessage_setHeader, HTTPMessage_setHeader);
|
||||
RESOLVE(HTTPMessage_setBodyData, HTTPMessage_setBodyData);
|
||||
|
||||
RESOLVE(URLRequest_ctor, URLRequest_ctor);
|
||||
RESOLVE(URLRequest_setRequestParameter, URLRequest_setRequestParameter);
|
||||
RESOLVE(URLRequest_run, URLRequest_run);
|
||||
RESOLVE(URLRequest_error, URLRequest_error);
|
||||
RESOLVE(URLRequest_response, URLRequest_response);
|
||||
RESOLVE(URLResponse_underlyingResponse, URLResponse_underlyingResponse);
|
||||
|
||||
// ---- PurchaseRequest + protocolDictionary ----
|
||||
RESOLVE(PurchaseRequest_ctor, PurchaseRequest_ctor);
|
||||
RESOLVE(PurchaseRequest_setProcessDialogActions, PurchaseRequest_setProcessDialogActions);
|
||||
RESOLVE(PurchaseRequest_setURLBagKey, PurchaseRequest_setURLBagKey);
|
||||
RESOLVE(PurchaseRequest_setBuyParameters, PurchaseRequest_setBuyParameters);
|
||||
RESOLVE(PurchaseRequest_run, PurchaseRequest_run);
|
||||
RESOLVE(PurchaseRequest_response, PurchaseRequest_response);
|
||||
RESOLVE(PurchaseResponse_error, PurchaseResponse_error);
|
||||
RESOLVE(PurchaseResponse_items, PurchaseResponse_items);
|
||||
RESOLVE(PurchaseItem_dictionary, PurchaseItem_dictionary);
|
||||
RESOLVE(URLRequest_setURLResponsePreprocessor, URLRequest_setURLResponsePreprocessor);
|
||||
RESOLVE(URLResponse_protocolDictionary, URLResponse_protocolDictionary);
|
||||
|
||||
RESOLVE(RequestContext_storeFrontIdentifier, RequestContext_storeFrontIdentifier);
|
||||
|
||||
#undef RESOLVE
|
||||
|
||||
// CoreFoundation symbols are plain C, name = symbol. They are loaded
|
||||
// transitively (libCoreFoundation.so is a DT_NEEDED of libstoreservicescore.so);
|
||||
// RTLD_DEFAULT picks them up.
|
||||
if (!resolve(RTLD_DEFAULT, "CFRetain",
|
||||
&symbols_.CFRetain, &last_error_)) return false;
|
||||
if (!resolve(RTLD_DEFAULT, "CFRelease",
|
||||
&symbols_.CFRelease, &last_error_)) return false;
|
||||
if (!resolve(RTLD_DEFAULT, "CFDataGetBytePtr",
|
||||
&symbols_.CFDataGetBytePtr, &last_error_)) return false;
|
||||
if (!resolve(RTLD_DEFAULT, "CFDataGetLength",
|
||||
&symbols_.CFDataGetLength, &last_error_)) return false;
|
||||
if (!resolve(RTLD_DEFAULT, "CFPropertyListCreateData",
|
||||
&symbols_.CFPropertyListCreateData, &last_error_)) return false;
|
||||
if (!resolve(RTLD_DEFAULT, "CFStringCreateWithCString",
|
||||
&symbols_.CFStringCreateWithCString, &last_error_)) return false;
|
||||
if (!resolve(RTLD_DEFAULT, "CFArrayCreate",
|
||||
&symbols_.CFArrayCreate, &last_error_)) return false;
|
||||
if (!resolve(RTLD_DEFAULT, "CFDictionaryCreate",
|
||||
&symbols_.CFDictionaryCreate, &last_error_)) return false;
|
||||
// CF callback structs are exported as data symbols; dlsym returns the
|
||||
// address of the global. Store as `const void*` and pass-through.
|
||||
if (!resolve(RTLD_DEFAULT, "kCFTypeArrayCallBacks",
|
||||
&symbols_.kCFTypeArrayCallBacks, &last_error_)) return false;
|
||||
if (!resolve(RTLD_DEFAULT, "kCFTypeDictionaryKeyCallBacks",
|
||||
&symbols_.kCFTypeDictionaryKeyCallBacks, &last_error_)) return false;
|
||||
if (!resolve(RTLD_DEFAULT, "kCFTypeDictionaryValueCallBacks",
|
||||
&symbols_.kCFTypeDictionaryValueCallBacks, &last_error_)) return false;
|
||||
|
||||
// CF type-introspection symbols (powering the CF->JSON walker).
|
||||
#define RESOLVE_CF(name) \
|
||||
if (!resolve(RTLD_DEFAULT, #name, &symbols_.name, &last_error_)) return false
|
||||
|
||||
RESOLVE_CF(CFGetTypeID);
|
||||
RESOLVE_CF(CFStringGetTypeID);
|
||||
RESOLVE_CF(CFNumberGetTypeID);
|
||||
RESOLVE_CF(CFBooleanGetTypeID);
|
||||
RESOLVE_CF(CFArrayGetTypeID);
|
||||
RESOLVE_CF(CFDictionaryGetTypeID);
|
||||
RESOLVE_CF(CFDataGetTypeID);
|
||||
RESOLVE_CF(CFDateGetTypeID);
|
||||
RESOLVE_CF(CFNullGetTypeID);
|
||||
|
||||
RESOLVE_CF(CFStringGetCStringPtr);
|
||||
RESOLVE_CF(CFStringGetLength);
|
||||
RESOLVE_CF(CFStringGetCString);
|
||||
RESOLVE_CF(CFStringGetMaximumSizeForEncoding);
|
||||
|
||||
RESOLVE_CF(CFNumberGetType);
|
||||
RESOLVE_CF(CFNumberGetValue);
|
||||
RESOLVE_CF(CFNumberIsFloatType);
|
||||
|
||||
RESOLVE_CF(CFBooleanGetValue);
|
||||
RESOLVE_CF(CFArrayGetCount);
|
||||
RESOLVE_CF(CFArrayGetValueAtIndex);
|
||||
RESOLVE_CF(CFDictionaryGetCount);
|
||||
RESOLVE_CF(CFDictionaryGetKeysAndValues);
|
||||
RESOLVE_CF(CFDateGetAbsoluteTime);
|
||||
|
||||
#undef RESOLVE_CF
|
||||
|
||||
clear_fairplay_symbols(&symbols_);
|
||||
std::string fp_err;
|
||||
// Match zhaarey/apple-music-downloader agent.js: FootHill + lease symbols are
|
||||
// resolved from libandroidappmusic.so exports. Try that handle first, then
|
||||
// RTLD_DEFAULT (bionic/global lookup can miss symbols visible via the DSO).
|
||||
auto resolve_fp = [&](const char* mangled_name, auto* out_slot) -> bool {
|
||||
void* const handles[] = {
|
||||
h_libandroidappmusic_,
|
||||
h_libmediaplatform_,
|
||||
h_libstoreservicescore_,
|
||||
nullptr,
|
||||
};
|
||||
for (int i = 0; handles[i] != nullptr; ++i) {
|
||||
if (resolve(handles[i], mangled_name, out_slot, &fp_err)) return true;
|
||||
}
|
||||
return resolve(RTLD_DEFAULT, mangled_name, out_slot, &fp_err);
|
||||
};
|
||||
#define RESOLVE_FP(field, name) fp_ok &= resolve_fp(mangled::name, &symbols_.field)
|
||||
|
||||
bool fp_ok = true;
|
||||
fp_ok &= RESOLVE_FP(SVPlaybackLeaseManager_ctor, SVPlaybackLeaseManager_ctor);
|
||||
fp_ok &= RESOLVE_FP(SVPlaybackLeaseManager_refreshLeaseAutomatically,
|
||||
SVPlaybackLeaseManager_refreshLeaseAutomatically);
|
||||
fp_ok &= RESOLVE_FP(SVPlaybackLeaseManager_requestLease, SVPlaybackLeaseManager_requestLease);
|
||||
fp_ok &= RESOLVE_FP(SVFootHillSessionCtrl_instance, SVFootHillSessionCtrl_instance);
|
||||
foot_hill_persistent_key_fn_ = nullptr;
|
||||
foot_hill_persistent_key_abi8_ = false;
|
||||
{
|
||||
abi::fn_SVFootHillSessionCtrl_getPersistentKey pk8 = nullptr;
|
||||
abi::fn_SVFootHillSessionCtrl_getPersistentKey7 pk7 = nullptr;
|
||||
bool pk = false;
|
||||
if (resolve_fp(mangled::SVFootHillSessionCtrl_getPersistentKey_8str, &pk8)) {
|
||||
foot_hill_persistent_key_fn_ = reinterpret_cast<void*>(pk8);
|
||||
foot_hill_persistent_key_abi8_ = true;
|
||||
pk = true;
|
||||
} else if (resolve_fp(mangled::SVFootHillSessionCtrl_getPersistentKey_7str, &pk7)) {
|
||||
foot_hill_persistent_key_fn_ = reinterpret_cast<void*>(pk7);
|
||||
foot_hill_persistent_key_abi8_ = false;
|
||||
pk = true;
|
||||
}
|
||||
fp_ok &= pk;
|
||||
}
|
||||
fp_ok &= RESOLVE_FP(SVFootHillSessionCtrl_decryptContext,
|
||||
SVFootHillSessionCtrl_decryptContext);
|
||||
fp_ok &= RESOLVE_FP(SVFootHillPContext_kdContext, SVFootHillPContext_kdContext);
|
||||
fp_ok &= RESOLVE_FP(fp_sample_decrypt, fp_sample_decrypt);
|
||||
fp_ok &= RESOLVE_FP(SVFootHillSessionCtrl_resetAllContexts,
|
||||
SVFootHillSessionCtrl_resetAllContexts);
|
||||
fp_ok &= RESOLVE_FP(shared_ptr_SVFootHillPContext_dtor,
|
||||
shared_ptr_SVFootHillPContext_dtor);
|
||||
|
||||
#undef RESOLVE_FP
|
||||
|
||||
if (foot_hill_persistent_key_fn_ != nullptr) {
|
||||
trace(foot_hill_persistent_key_abi8_
|
||||
? "FairPlay getPersistentKey ABI=8-string"
|
||||
: "FairPlay getPersistentKey ABI=7-string");
|
||||
}
|
||||
|
||||
if (!fp_ok) {
|
||||
clear_fairplay_symbols(&symbols_);
|
||||
foot_hill_persistent_key_fn_ = nullptr;
|
||||
foot_hill_persistent_key_abi8_ = false;
|
||||
fairplay_decrypt_available_ = false;
|
||||
std::fprintf(stderr,
|
||||
"loader: FairPlay decrypt symbols unavailable (%s); "
|
||||
"POST /decrypt disabled\n",
|
||||
fp_err.c_str());
|
||||
} else {
|
||||
fairplay_decrypt_available_ = true;
|
||||
}
|
||||
|
||||
ok_ = true;
|
||||
last_error_.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
void Loader::close() {
|
||||
ok_ = false;
|
||||
fairplay_decrypt_available_ = false;
|
||||
symbols_ = Symbols{};
|
||||
foot_hill_persistent_key_fn_ = nullptr;
|
||||
foot_hill_persistent_key_abi8_ = false;
|
||||
h_libstoreservicescore_ = nullptr;
|
||||
h_libmediaplatform_ = nullptr;
|
||||
h_libandroidappmusic_ = nullptr;
|
||||
h_libcorefp_ = nullptr;
|
||||
h_libcorelskd_ = nullptr;
|
||||
}
|
||||
|
||||
void Loader::foot_hill_get_persistent_key(abi::shared_ptr* ret,
|
||||
void* foothill_instance,
|
||||
abi::std_string* adam_id,
|
||||
abi::std_string* key_uri,
|
||||
abi::std_string* key_format,
|
||||
abi::std_string* key_format_ver,
|
||||
abi::std_string* server_uri,
|
||||
abi::std_string* protocol_type,
|
||||
abi::std_string* fps_cert) const {
|
||||
if (foot_hill_persistent_key_fn_ == nullptr) return;
|
||||
if (foot_hill_persistent_key_abi8_) {
|
||||
auto* fn = reinterpret_cast<abi::fn_SVFootHillSessionCtrl_getPersistentKey>(
|
||||
foot_hill_persistent_key_fn_);
|
||||
aarch64_sret::svfoot_get_persistent_key(
|
||||
ret, foothill_instance, adam_id, adam_id, key_uri, key_format,
|
||||
key_format_ver, server_uri, protocol_type, fps_cert, fn);
|
||||
} else {
|
||||
auto* fn = reinterpret_cast<abi::fn_SVFootHillSessionCtrl_getPersistentKey7>(
|
||||
foot_hill_persistent_key_fn_);
|
||||
aarch64_sret::svfoot_get_persistent_key_7str(
|
||||
ret, foothill_instance, adam_id, key_uri, key_format,
|
||||
key_format_ver, server_uri, protocol_type, fps_cert, fn);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace wrapper::apple
|
||||
@@ -0,0 +1,254 @@
|
||||
// Apple Music native-library loader.
|
||||
//
|
||||
// `Loader::open()` dlopens Apple's JNI stack (mediaplatform, storeservicescore,
|
||||
// androidappmusic) and optional FairPlay helpers (CoreFP / CoreLSKD), then
|
||||
// resolves mangled symbols into `Symbols`.
|
||||
//
|
||||
// The daemon ELF itself has *no* DT_NEEDED reference to Apple's libs -
|
||||
// this is what lets us start the daemon in stub mode even when the libs
|
||||
// have not been staged. If core libs are missing or a non-FairPlay
|
||||
// symbol fails to resolve, `open()` returns false. FairPlay-only symbols
|
||||
// may fail separately; see `fairplay_decrypt_available()`.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "apple/abi.hpp"
|
||||
|
||||
namespace wrapper::apple {
|
||||
|
||||
// Function-pointer table populated by Loader::open(). Core auth/token
|
||||
// fields are non-null after ok()==true. FairPlay decrypt pointers (lease,
|
||||
// FootHill, fp_sample_decrypt) are set only when fairplay_decrypt_available()
|
||||
// is true; otherwise they may be null even if ok().
|
||||
struct Symbols {
|
||||
// Vtables for std::__shared_ptr_emplace<T, ...>. Stored as `void**`
|
||||
// so callers can do the upstream-style pointer arithmetic (skip
|
||||
// past type_info + dtor slots).
|
||||
void** vtable_RequestContextConfig = nullptr;
|
||||
void** vtable_CredentialsResponse = nullptr;
|
||||
void** vtable_ProtocolDialogResponse = nullptr;
|
||||
void** vtable_HTTPMessage = nullptr;
|
||||
|
||||
abi::fn_resolv_set_nameservers_for_net resolv_set_nameservers_for_net = nullptr;
|
||||
abi::fn_FootHillConfig_config FootHillConfig_config = nullptr;
|
||||
abi::fn_DeviceGUID_instance DeviceGUID_instance = nullptr;
|
||||
abi::fn_DeviceGUID_configure DeviceGUID_configure = nullptr;
|
||||
abi::fn_make_shared_RequestContext make_shared_RequestContext = nullptr;
|
||||
abi::fn_RequestContextConfig_ctor RequestContextConfig_ctor = nullptr;
|
||||
|
||||
abi::fn_RCC_set_string RCC_setBaseDirectoryPath = nullptr;
|
||||
abi::fn_RCC_set_string RCC_setClientIdentifier = nullptr;
|
||||
abi::fn_RCC_set_string RCC_setVersionIdentifier = nullptr;
|
||||
abi::fn_RCC_set_string RCC_setPlatformIdentifier = nullptr;
|
||||
abi::fn_RCC_set_string RCC_setProductVersion = nullptr;
|
||||
abi::fn_RCC_set_string RCC_setDeviceModel = nullptr;
|
||||
abi::fn_RCC_set_string RCC_setBuildVersion = nullptr;
|
||||
abi::fn_RCC_set_string RCC_setLocaleIdentifier = nullptr;
|
||||
abi::fn_RCC_set_string RCC_setLanguageIdentifier = nullptr;
|
||||
|
||||
abi::fn_RequestContextManager_configure RequestContextManager_configure = nullptr;
|
||||
abi::fn_RequestContext_init RequestContext_init = nullptr;
|
||||
abi::fn_RCC_set_string RequestContext_setFairPlayDirectoryPath = nullptr;
|
||||
|
||||
// ---- AndroidPresentationInterface + auth flow ----
|
||||
abi::fn_make_shared_AndroidPresentationInterface make_shared_AndroidPresentationInterface = nullptr;
|
||||
abi::fn_API_setCredentialsHandler API_setCredentialsHandler = nullptr;
|
||||
abi::fn_API_setDialogHandler API_setDialogHandler = nullptr;
|
||||
abi::fn_API_handleCredentialsResponse API_handleCredentialsResponse = nullptr;
|
||||
abi::fn_API_handleProtocolDialogResponse API_handleProtocolDialogResponse = nullptr;
|
||||
abi::fn_RequestContext_setPresentationInterface RequestContext_setPresentationInterface = nullptr;
|
||||
|
||||
abi::fn_ProtocolDialog_title ProtocolDialog_title = nullptr;
|
||||
abi::fn_ProtocolDialog_message ProtocolDialog_message = nullptr;
|
||||
abi::fn_ProtocolDialog_buttons ProtocolDialog_buttons = nullptr;
|
||||
abi::fn_ProtocolButton_title ProtocolButton_title = nullptr;
|
||||
abi::fn_ProtocolDialogResponse_ctor ProtocolDialogResponse_ctor = nullptr;
|
||||
abi::fn_ProtocolDialogResponse_setSelectedButton ProtocolDialogResponse_setSelectedButton = nullptr;
|
||||
|
||||
abi::fn_CR_requiresHSA2VerificationCode CR_requiresHSA2VerificationCode = nullptr;
|
||||
abi::fn_CR_title CR_title = nullptr;
|
||||
abi::fn_CR_message CR_message = nullptr;
|
||||
|
||||
abi::fn_CredentialsResponse_ctor CredentialsResponse_ctor = nullptr;
|
||||
abi::fn_CredentialsResponse_set_string CredentialsResponse_setUserName = nullptr;
|
||||
abi::fn_CredentialsResponse_set_string CredentialsResponse_setPassword = nullptr;
|
||||
abi::fn_CredentialsResponse_setResponseType CredentialsResponse_setResponseType = nullptr;
|
||||
|
||||
abi::fn_make_shared_AuthenticateFlow make_shared_AuthenticateFlow = nullptr;
|
||||
abi::fn_AuthenticateFlow_run AuthenticateFlow_run = nullptr;
|
||||
abi::fn_AuthenticateFlow_response AuthenticateFlow_response = nullptr;
|
||||
|
||||
abi::fn_AR_responseType AR_responseType = nullptr;
|
||||
abi::fn_AR_customerMessage AR_customerMessage = nullptr;
|
||||
abi::fn_AR_error AR_error = nullptr;
|
||||
|
||||
abi::fn_SEC_errorCode SEC_errorCode = nullptr;
|
||||
abi::fn_SEC_what SEC_what = nullptr;
|
||||
|
||||
// ---- token harvest ----
|
||||
abi::fn_DeviceGUID_guid DeviceGUID_guid = nullptr;
|
||||
abi::fn_Data_bytes Data_bytes = nullptr;
|
||||
|
||||
abi::fn_HTTPMessage_ctor HTTPMessage_ctor = nullptr;
|
||||
abi::fn_HTTPMessage_ctor_c1 HTTPMessage_ctor_c1 = nullptr; // arm64 fallback
|
||||
abi::fn_HTTPMessage_setHeader HTTPMessage_setHeader = nullptr;
|
||||
abi::fn_HTTPMessage_setBodyData HTTPMessage_setBodyData = nullptr;
|
||||
|
||||
abi::fn_URLRequest_ctor URLRequest_ctor = nullptr;
|
||||
abi::fn_URLRequest_setRequestParameter URLRequest_setRequestParameter = nullptr;
|
||||
abi::fn_URLRequest_run URLRequest_run = nullptr;
|
||||
abi::fn_URLRequest_error URLRequest_error = nullptr;
|
||||
abi::fn_URLRequest_response URLRequest_response = nullptr;
|
||||
abi::fn_URLResponse_underlyingResponse URLResponse_underlyingResponse = nullptr;
|
||||
|
||||
// ---- PurchaseRequest / protocolDictionary / CoreFoundation ----
|
||||
abi::fn_PurchaseRequest_ctor PurchaseRequest_ctor = nullptr;
|
||||
abi::fn_PurchaseRequest_setProcessDialogActions PurchaseRequest_setProcessDialogActions = nullptr;
|
||||
abi::fn_PurchaseRequest_set_string PurchaseRequest_setURLBagKey = nullptr;
|
||||
abi::fn_PurchaseRequest_set_string PurchaseRequest_setBuyParameters = nullptr;
|
||||
abi::fn_PurchaseRequest_run PurchaseRequest_run = nullptr;
|
||||
abi::fn_PurchaseRequest_response PurchaseRequest_response = nullptr;
|
||||
abi::fn_PurchaseResponse_error PurchaseResponse_error = nullptr;
|
||||
abi::fn_PurchaseResponse_items PurchaseResponse_items = nullptr;
|
||||
abi::fn_PurchaseItem_dictionary PurchaseItem_dictionary = nullptr;
|
||||
abi::fn_URLRequest_setURLResponsePreprocessor URLRequest_setURLResponsePreprocessor = nullptr;
|
||||
abi::fn_URLResponse_protocolDictionary URLResponse_protocolDictionary = nullptr;
|
||||
|
||||
// CoreFoundation: we serialize a CFDictionary* (either the URLResponse's
|
||||
// protocolDictionary, or a synthetic dict we build from PurchaseItem
|
||||
// dictionaries when the preprocessor hook doesn't fire) back to XML
|
||||
// plist bytes for the HTTP response. CFRelease drops the retained refs.
|
||||
// These are plain C symbols, no mangling.
|
||||
void* (*CFRetain)(const void* cf) = nullptr;
|
||||
void (*CFRelease)(const void* cf) = nullptr;
|
||||
const unsigned char* (*CFDataGetBytePtr)(const void* data) = nullptr;
|
||||
long (*CFDataGetLength)(const void* data) = nullptr;
|
||||
// CFPropertyListCreateData(allocator, plist, format, options, error)
|
||||
// format=100 is kCFPropertyListXMLFormat_v1_0.
|
||||
void* (*CFPropertyListCreateData)(void* alloc, void* plist,
|
||||
long format, unsigned long options,
|
||||
void* error) = nullptr;
|
||||
// Container builders for the synthetic root dict when /playback has
|
||||
// to fall back from the preprocessor hook to PurchaseResponse::items().
|
||||
void* (*CFStringCreateWithCString)(void* alloc, const char* cstr,
|
||||
unsigned long encoding) = nullptr;
|
||||
void* (*CFArrayCreate)(void* alloc, const void* const* values,
|
||||
long numValues, const void* callbacks) = nullptr;
|
||||
void* (*CFDictionaryCreate)(void* alloc, const void* const* keys,
|
||||
const void* const* values, long numValues,
|
||||
const void* key_cb, const void* val_cb) = nullptr;
|
||||
const void* kCFTypeArrayCallBacks = nullptr;
|
||||
const void* kCFTypeDictionaryKeyCallBacks = nullptr;
|
||||
const void* kCFTypeDictionaryValueCallBacks = nullptr;
|
||||
|
||||
// CF type introspection (used by the CF->JSON walker that powers
|
||||
// GET /playback's body). On x86_64 CFTypeID is unsigned long.
|
||||
unsigned long (*CFGetTypeID)(const void* cf) = nullptr;
|
||||
unsigned long (*CFStringGetTypeID)() = nullptr;
|
||||
unsigned long (*CFNumberGetTypeID)() = nullptr;
|
||||
unsigned long (*CFBooleanGetTypeID)() = nullptr;
|
||||
unsigned long (*CFArrayGetTypeID)() = nullptr;
|
||||
unsigned long (*CFDictionaryGetTypeID)() = nullptr;
|
||||
unsigned long (*CFDataGetTypeID)() = nullptr;
|
||||
unsigned long (*CFDateGetTypeID)() = nullptr;
|
||||
unsigned long (*CFNullGetTypeID)() = nullptr;
|
||||
|
||||
const char* (*CFStringGetCStringPtr)(const void* str,
|
||||
unsigned long encoding) = nullptr;
|
||||
long (*CFStringGetLength)(const void* str) = nullptr;
|
||||
unsigned char (*CFStringGetCString)(const void* str, char* buf,
|
||||
long buf_size,
|
||||
unsigned long encoding) = nullptr;
|
||||
long (*CFStringGetMaximumSizeForEncoding)(long length,
|
||||
unsigned long enc) = nullptr;
|
||||
|
||||
long (*CFNumberGetType)(const void* num) = nullptr;
|
||||
unsigned char (*CFNumberGetValue)(const void* num, long type,
|
||||
void* out) = nullptr;
|
||||
unsigned char (*CFNumberIsFloatType)(const void* num) = nullptr;
|
||||
|
||||
unsigned char (*CFBooleanGetValue)(const void* b) = nullptr;
|
||||
|
||||
long (*CFArrayGetCount)(const void* arr) = nullptr;
|
||||
const void* (*CFArrayGetValueAtIndex)(const void* arr, long idx) = nullptr;
|
||||
|
||||
long (*CFDictionaryGetCount)(const void* d) = nullptr;
|
||||
void (*CFDictionaryGetKeysAndValues)(const void* d,
|
||||
const void** keys,
|
||||
const void** values) = nullptr;
|
||||
|
||||
double (*CFDateGetAbsoluteTime)(const void* date) = nullptr;
|
||||
|
||||
abi::fn_RequestContext_storeFrontIdentifier RequestContext_storeFrontIdentifier = nullptr;
|
||||
|
||||
// ---- FairPlay decrypt ----
|
||||
abi::fn_SVPlaybackLeaseManager_ctor SVPlaybackLeaseManager_ctor = nullptr;
|
||||
abi::fn_SVPlaybackLeaseManager_refreshLeaseAutomatically SVPlaybackLeaseManager_refreshLeaseAutomatically = nullptr;
|
||||
abi::fn_SVPlaybackLeaseManager_requestLease SVPlaybackLeaseManager_requestLease = nullptr;
|
||||
abi::fn_SVFootHillSessionCtrl_instance SVFootHillSessionCtrl_instance = nullptr;
|
||||
abi::fn_SVFootHillSessionCtrl_decryptContext SVFootHillSessionCtrl_decryptContext = nullptr;
|
||||
abi::fn_SVFootHillPContext_kdContext SVFootHillPContext_kdContext = nullptr;
|
||||
abi::fn_fp_sample_decrypt fp_sample_decrypt = nullptr;
|
||||
abi::fn_SVFootHillSessionCtrl_resetAllContexts SVFootHillSessionCtrl_resetAllContexts = nullptr;
|
||||
abi::fn_shared_ptr_SVFootHillPContext_dtor shared_ptr_SVFootHillPContext_dtor = nullptr;
|
||||
};
|
||||
|
||||
class Loader {
|
||||
public:
|
||||
Loader() = default;
|
||||
|
||||
Loader(const Loader&) = delete;
|
||||
Loader& operator=(const Loader&) = delete;
|
||||
|
||||
// Try to dlopen Apple's libs from `libs_dir` (typically /system/lib64
|
||||
// inside the chroot). On success, resolves all symbols into the
|
||||
// internal Symbols table and returns true. On failure, leaves the
|
||||
// loader in a closed state and returns false; subsequent ok() calls
|
||||
// return false.
|
||||
bool open(const std::string& libs_dir);
|
||||
|
||||
// Release all dlopen handles. Symbols become invalid.
|
||||
void close();
|
||||
|
||||
bool ok() const { return ok_; }
|
||||
|
||||
// True when FairPlay decrypt dlsyms all succeeded (lease + FootHill + sample op).
|
||||
bool fairplay_decrypt_available() const { return fairplay_decrypt_available_; }
|
||||
|
||||
const Symbols& sym() const { return symbols_; }
|
||||
|
||||
// Last failure description (path of the lib, symbol name, dlerror()).
|
||||
// Empty if no failure has been recorded.
|
||||
const std::string& last_error() const { return last_error_; }
|
||||
|
||||
// SVFootHillSessionCtrl::getPersistentKey — two possible ABIs; resolved in
|
||||
// open(), dispatched with AArch64 sret thunks in loader.cpp.
|
||||
void foot_hill_get_persistent_key(abi::shared_ptr* ret,
|
||||
void* foothill_instance,
|
||||
abi::std_string* adam_id,
|
||||
abi::std_string* key_uri,
|
||||
abi::std_string* key_format,
|
||||
abi::std_string* key_format_ver,
|
||||
abi::std_string* server_uri,
|
||||
abi::std_string* protocol_type,
|
||||
abi::std_string* fps_cert) const;
|
||||
|
||||
private:
|
||||
void* h_libstoreservicescore_ = nullptr;
|
||||
void* h_libmediaplatform_ = nullptr;
|
||||
void* h_libandroidappmusic_ = nullptr;
|
||||
void* h_libcorefp_ = nullptr;
|
||||
void* h_libcorelskd_ = nullptr;
|
||||
|
||||
Symbols symbols_;
|
||||
bool ok_{false};
|
||||
bool fairplay_decrypt_available_{false};
|
||||
std::string last_error_;
|
||||
|
||||
void* foot_hill_persistent_key_fn_ = nullptr;
|
||||
bool foot_hill_persistent_key_abi8_ = false;
|
||||
};
|
||||
|
||||
} // namespace wrapper::apple
|
||||
@@ -0,0 +1,384 @@
|
||||
#include "apple/playback.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "apple/abi.hpp"
|
||||
#include "apple/aarch64_sret_thunks.hpp"
|
||||
#include "apple/loader.hpp"
|
||||
#include "apple/runtime.hpp"
|
||||
|
||||
namespace wrapper::apple {
|
||||
|
||||
namespace {
|
||||
|
||||
// Forward declaration so we can construct a libc++ std::function whose
|
||||
// mangled signature contains storeservicescore::URLResponse. Apple ships
|
||||
// the same libc++ runtime as the NDK we cross-compile with, so the
|
||||
// std::function and std::shared_ptr we build here are binary-compatible
|
||||
// with the one URLRequest::setURLResponsePreprocessor expects.
|
||||
struct URLResponseFwd;
|
||||
|
||||
using URLResponseSp = std::shared_ptr<URLResponseFwd>;
|
||||
using URLRespPreprocFn = std::function<void(const URLResponseSp&)>;
|
||||
|
||||
constexpr unsigned long kCFStringEncodingUTF8 = 0x08000100;
|
||||
constexpr long kCFNumberSInt64Type = 4;
|
||||
constexpr long kCFNumberFloat64Type = 6;
|
||||
|
||||
// CFAbsoluteTime is seconds since 2001-01-01 00:00:00 UTC. Unix epoch
|
||||
// (1970-01-01) is 978307200 seconds earlier.
|
||||
constexpr double kCFAbsoluteTimeIntervalSince1970 = 978307200.0;
|
||||
|
||||
// Standard base64 alphabet (no URL-safe variant). Used to encode CFData
|
||||
// payloads so the JSON document stays string-friendly.
|
||||
std::string base64_encode(const unsigned char* data, std::size_t len) {
|
||||
static const char tbl[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::string ret;
|
||||
ret.reserve(((len + 2) / 3) * 4);
|
||||
unsigned int val = 0;
|
||||
int valb = -6;
|
||||
for (std::size_t i = 0; i < len; ++i) {
|
||||
val = (val << 8) + data[i];
|
||||
valb += 8;
|
||||
while (valb >= 0) {
|
||||
ret.push_back(tbl[(val >> valb) & 0x3F]);
|
||||
valb -= 6;
|
||||
}
|
||||
}
|
||||
if (valb > -6) {
|
||||
ret.push_back(tbl[((val << 8) >> (valb + 8)) & 0x3F]);
|
||||
}
|
||||
while (ret.size() % 4 != 0) ret.push_back('=');
|
||||
return ret;
|
||||
}
|
||||
|
||||
// Read a CFString out as UTF-8. Tries the fast pointer-aliased path first
|
||||
// (CFStringGetCStringPtr) and falls back to a copy via CFStringGetCString
|
||||
// for strings whose internal encoding is not UTF-8.
|
||||
std::string read_cf_string(const Symbols& s, const void* str) {
|
||||
if (str == nullptr) return std::string();
|
||||
const char* fast = s.CFStringGetCStringPtr(str, kCFStringEncodingUTF8);
|
||||
if (fast != nullptr) return std::string(fast);
|
||||
long len = s.CFStringGetLength(str);
|
||||
if (len <= 0) return std::string();
|
||||
long max = s.CFStringGetMaximumSizeForEncoding(len, kCFStringEncodingUTF8) + 1;
|
||||
std::vector<char> buf(static_cast<std::size_t>(max), 0);
|
||||
if (s.CFStringGetCString(str, buf.data(), max, kCFStringEncodingUTF8) == 0) {
|
||||
return std::string();
|
||||
}
|
||||
return std::string(buf.data());
|
||||
}
|
||||
|
||||
// Recursive CFPropertyList -> nlohmann::json walker. Handles the seven
|
||||
// PList types Apple actually uses in its store responses; anything else
|
||||
// falls through to JSON null. CFData is base64-encoded; CFDate is
|
||||
// rendered as ISO-8601 UTC.
|
||||
nlohmann::json cf_to_json(const Symbols& s, const void* cf) {
|
||||
if (cf == nullptr) return nullptr;
|
||||
const unsigned long tid = s.CFGetTypeID(cf);
|
||||
|
||||
if (tid == s.CFDictionaryGetTypeID()) {
|
||||
nlohmann::json obj = nlohmann::json::object();
|
||||
long n = s.CFDictionaryGetCount(cf);
|
||||
if (n > 0) {
|
||||
std::vector<const void*> keys(static_cast<std::size_t>(n));
|
||||
std::vector<const void*> vals(static_cast<std::size_t>(n));
|
||||
s.CFDictionaryGetKeysAndValues(cf, keys.data(), vals.data());
|
||||
for (long i = 0; i < n; ++i) {
|
||||
if (keys[i] == nullptr) continue;
|
||||
if (s.CFGetTypeID(keys[i]) != s.CFStringGetTypeID()) {
|
||||
// Apple's MZ-protocol plists only ever use string keys;
|
||||
// skipping a non-string key is the closest sane mapping.
|
||||
continue;
|
||||
}
|
||||
std::string k = read_cf_string(s, keys[i]);
|
||||
if (k.empty()) continue;
|
||||
obj[k] = cf_to_json(s, vals[i]);
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
if (tid == s.CFArrayGetTypeID()) {
|
||||
nlohmann::json arr = nlohmann::json::array();
|
||||
long n = s.CFArrayGetCount(cf);
|
||||
for (long i = 0; i < n; ++i) {
|
||||
const void* el = s.CFArrayGetValueAtIndex(cf, i);
|
||||
arr.push_back(cf_to_json(s, el));
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
if (tid == s.CFStringGetTypeID()) {
|
||||
return read_cf_string(s, cf);
|
||||
}
|
||||
if (tid == s.CFNumberGetTypeID()) {
|
||||
if (s.CFNumberIsFloatType(cf) != 0) {
|
||||
double v = 0;
|
||||
s.CFNumberGetValue(cf, kCFNumberFloat64Type, &v);
|
||||
return v;
|
||||
}
|
||||
std::int64_t v = 0;
|
||||
s.CFNumberGetValue(cf, kCFNumberSInt64Type, &v);
|
||||
return v;
|
||||
}
|
||||
if (tid == s.CFBooleanGetTypeID()) {
|
||||
return s.CFBooleanGetValue(cf) != 0;
|
||||
}
|
||||
if (tid == s.CFDataGetTypeID()) {
|
||||
const unsigned char* p = s.CFDataGetBytePtr(cf);
|
||||
long n = s.CFDataGetLength(cf);
|
||||
if (p == nullptr || n <= 0) return std::string();
|
||||
return base64_encode(p, static_cast<std::size_t>(n));
|
||||
}
|
||||
if (tid == s.CFDateGetTypeID()) {
|
||||
double abs_t = s.CFDateGetAbsoluteTime(cf);
|
||||
double unix_secs = abs_t + kCFAbsoluteTimeIntervalSince1970;
|
||||
std::time_t t = static_cast<std::time_t>(unix_secs);
|
||||
std::tm tm_utc{};
|
||||
#if defined(_WIN32)
|
||||
gmtime_s(&tm_utc, &t);
|
||||
#else
|
||||
gmtime_r(&t, &tm_utc);
|
||||
#endif
|
||||
char buf[32];
|
||||
std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm_utc);
|
||||
return std::string(buf);
|
||||
}
|
||||
if (tid == s.CFNullGetTypeID()) {
|
||||
return nullptr;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// PurchaseItem is a thin wrapper: its ctor stores a retained CFDictionaryRef
|
||||
// at object offset 0 (libstoreservicescore PurchaseItem(__CFDictionary const*)).
|
||||
// In the pinned APK, PurchaseItem::dictionary() const is compiled as "return this"
|
||||
// (disassembly: mov %rdi,%rax; ret) — not the plist pointer. CFGetTypeID(this)
|
||||
// is undefined and often yields 0. The real dispatch plist is *(void**)item.
|
||||
void* purchase_item_cfdictionary(const Symbols& s, void* item_obj) {
|
||||
if (item_obj == nullptr) return nullptr;
|
||||
void* from_member = *static_cast<void**>(item_obj);
|
||||
if (s.PurchaseItem_dictionary != nullptr) {
|
||||
void* ret = s.PurchaseItem_dictionary(item_obj);
|
||||
if (ret != nullptr && ret != item_obj
|
||||
&& s.CFGetTypeID(ret) == s.CFDictionaryGetTypeID()) {
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
return from_member;
|
||||
}
|
||||
|
||||
// Format-string buy parameters that match upstream wrapper main.c's
|
||||
// get_m3u8_method_download. The "S" productType + "SUBS" pricing
|
||||
// parameters are what tells the storefront we want the subscriber
|
||||
// download dispatch (full plist with every variant), not the streaming
|
||||
// HLS asset only.
|
||||
std::string make_buy_parameters(const std::string& adam_id) {
|
||||
// adam_id is validated as digits-only by the caller; concatenate
|
||||
// directly to avoid sprintf format-string risk.
|
||||
std::string out;
|
||||
out.reserve(64 + adam_id.size());
|
||||
out.append("salableAdamId=");
|
||||
out.append(adam_id);
|
||||
out.append("&price=0&pricingParameters=SUBS&productType=S");
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PlaybackResult fetch_playback_json(const Loader& loader,
|
||||
Runtime& runtime,
|
||||
std::string adam_id) {
|
||||
PlaybackResult out;
|
||||
|
||||
if (!loader.ok()) {
|
||||
out.error = "Apple libs not loaded";
|
||||
return out;
|
||||
}
|
||||
if (!runtime.initialized()) {
|
||||
out.error = "Apple runtime not initialized";
|
||||
return out;
|
||||
}
|
||||
if (adam_id.empty()) {
|
||||
out.error = "adam_id is required";
|
||||
return out;
|
||||
}
|
||||
for (char c : adam_id) {
|
||||
if (c < '0' || c > '9') {
|
||||
out.error = "adam_id must be a numeric string";
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
const Symbols& s = loader.sym();
|
||||
|
||||
abi::shared_ptr req_ctx = runtime.request_ctx_copy();
|
||||
if (req_ctx.obj == nullptr) {
|
||||
out.error = "RequestContext unavailable";
|
||||
return out;
|
||||
}
|
||||
|
||||
// Captured dict + mutex outlive the lambda copies that std::function
|
||||
// makes; the lambda holds them by pointer so all copies share state.
|
||||
struct Capture {
|
||||
std::mutex mu;
|
||||
void* cf_dict = nullptr; // CFDictionaryRef (retained)
|
||||
int hits = 0;
|
||||
};
|
||||
Capture cap;
|
||||
|
||||
URLRespPreprocFn preproc = [cap_ptr = &cap, &s](const URLResponseSp& sp) {
|
||||
void* url_resp = static_cast<void*>(sp.get());
|
||||
if (url_resp == nullptr) return;
|
||||
if (s.URLResponse_protocolDictionary == nullptr) return;
|
||||
void* dict = s.URLResponse_protocolDictionary(url_resp);
|
||||
if (dict == nullptr) return;
|
||||
// CFRetain so the dict outlives Apple's URLResponse, which may be
|
||||
// dropped immediately after the preprocessor returns.
|
||||
void* retained = s.CFRetain(dict);
|
||||
std::lock_guard<std::mutex> g(cap_ptr->mu);
|
||||
if (cap_ptr->cf_dict != nullptr) {
|
||||
// Multiple hits should not happen for a single PurchaseRequest
|
||||
// run, but if it did we'd leak the previous; release first.
|
||||
s.CFRelease(cap_ptr->cf_dict);
|
||||
}
|
||||
cap_ptr->cf_dict = retained != nullptr ? retained : dict;
|
||||
++cap_ptr->hits;
|
||||
};
|
||||
|
||||
// PurchaseRequest is heavy-ish; upstream uses malloc(1024) and never frees.
|
||||
// Keep one 8 KiB thread-local buffer and skip destruction, matching the
|
||||
// upstream lifetime model while leaving extra room for arm64 object size
|
||||
// and alignment.
|
||||
alignas(16) static thread_local std::uint8_t pr_buf[8192];
|
||||
std::memset(pr_buf, 0, sizeof(pr_buf));
|
||||
|
||||
s.PurchaseRequest_ctor(pr_buf, &req_ctx);
|
||||
std::uint8_t one = 1;
|
||||
s.PurchaseRequest_setProcessDialogActions(pr_buf, one);
|
||||
|
||||
auto url_bag_key = abi::make_string_view("subDownload");
|
||||
s.PurchaseRequest_setURLBagKey(pr_buf, &url_bag_key);
|
||||
|
||||
std::string buy = make_buy_parameters(adam_id);
|
||||
auto buy_view = abi::make_string_view(buy.c_str());
|
||||
s.PurchaseRequest_setBuyParameters(pr_buf, &buy_view);
|
||||
|
||||
// PurchaseRequest derives from URLRequest with URLRequest as the first
|
||||
// base (offset 0 - confirmed by upstream's pattern of calling URLRequest
|
||||
// methods on a PurchaseRequest* directly). Passing pr_buf as the
|
||||
// URLRequest::setURLResponsePreprocessor `this` is therefore safe.
|
||||
s.URLRequest_setURLResponsePreprocessor(pr_buf, &preproc);
|
||||
|
||||
s.PurchaseRequest_run(pr_buf);
|
||||
|
||||
// After run() returns, the preprocessor has either captured the dict
|
||||
// or didn't fire (the request failed before reaching the parsed-response
|
||||
// stage). Either way, no more async invocations are possible.
|
||||
void* dict = nullptr;
|
||||
{
|
||||
std::lock_guard<std::mutex> g(cap.mu);
|
||||
dict = cap.cf_dict;
|
||||
cap.cf_dict = nullptr;
|
||||
}
|
||||
|
||||
// Pull the PurchaseResponse for two things:
|
||||
// (1) Surface Apple's StoreErrorCondition (if any) for diagnostics.
|
||||
// (2) Fallback: when the preprocessor didn't fire (PurchaseRequest::run
|
||||
// bypasses URLRequest::_performActionsForResponse), walk
|
||||
// PurchaseResponse::items() and serialize each item's dictionary
|
||||
// into a synthetic { "songList": [...] } root dict.
|
||||
void* purchase_response_obj = nullptr;
|
||||
std::string apple_err;
|
||||
if (s.PurchaseRequest_response != nullptr) {
|
||||
abi::shared_ptr* resp_sp = s.PurchaseRequest_response(pr_buf);
|
||||
if (resp_sp != nullptr && resp_sp->obj != nullptr) {
|
||||
purchase_response_obj = resp_sp->obj;
|
||||
if (s.PurchaseResponse_error != nullptr) {
|
||||
abi::shared_ptr* err_sp =
|
||||
s.PurchaseResponse_error(purchase_response_obj);
|
||||
if (err_sp != nullptr && err_sp->obj != nullptr) {
|
||||
int code = (s.SEC_errorCode != nullptr)
|
||||
? s.SEC_errorCode(err_sp->obj) : 0;
|
||||
const char* what = (s.SEC_what != nullptr)
|
||||
? s.SEC_what(err_sp->obj) : nullptr;
|
||||
apple_err = "Apple store error code=" + std::to_string(code);
|
||||
if (what != nullptr && *what != '\0') {
|
||||
apple_err += ": ";
|
||||
apple_err += what;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preferred path: preprocessor captured the full protocol dict.
|
||||
if (dict != nullptr) {
|
||||
out.body = cf_to_json(s, dict);
|
||||
s.CFRelease(dict);
|
||||
out.ok = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Fallback: walk PurchaseResponse::items() and build the JSON document
|
||||
// directly (one cf_to_json per item, then wrap in {"songList": [...]}).
|
||||
// This is the expected path - PurchaseRequest::run does not fire
|
||||
// URLRequest::_performActionsForResponse.
|
||||
if (purchase_response_obj != nullptr
|
||||
&& s.PurchaseResponse_items != nullptr) {
|
||||
abi::std_vector items{};
|
||||
aarch64_sret::purchase_response_items(&items, purchase_response_obj,
|
||||
s.PurchaseResponse_items);
|
||||
|
||||
const auto* begin = static_cast<abi::shared_ptr*>(items.begin);
|
||||
const auto* end = static_cast<abi::shared_ptr*>(items.end);
|
||||
const std::size_t count = (end > begin)
|
||||
? static_cast<std::size_t>(end - begin) : 0;
|
||||
|
||||
nlohmann::json song_list = nlohmann::json::array();
|
||||
for (std::size_t i = 0; i < count; ++i) {
|
||||
void* item_obj = begin[i].obj;
|
||||
if (item_obj == nullptr) {
|
||||
continue;
|
||||
}
|
||||
void* item_dict = purchase_item_cfdictionary(s, item_obj);
|
||||
if (item_dict == nullptr) continue;
|
||||
song_list.push_back(cf_to_json(s, item_dict));
|
||||
}
|
||||
|
||||
// Note: we deliberately leak `items` (a std::vector with N shared_ptr
|
||||
// elements). Calling its destructor would require the vector's dtor
|
||||
// symbol; same shortcut upstream wrapper takes with similar return-
|
||||
// by-value results. ~16 bytes per item.
|
||||
|
||||
if (!song_list.empty()) {
|
||||
out.body = nlohmann::json::object();
|
||||
out.body["songList"] = std::move(song_list);
|
||||
out.ok = true;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
if (!apple_err.empty()) {
|
||||
out.error = std::move(apple_err);
|
||||
} else if (purchase_response_obj == nullptr) {
|
||||
out.error = "PurchaseRequest produced no response object "
|
||||
"(likely an HTTP error before parsing)";
|
||||
} else {
|
||||
out.error = "PurchaseResponse has no items and no error "
|
||||
"(Apple returned an empty subDownload payload)";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace wrapper::apple
|
||||
@@ -0,0 +1,36 @@
|
||||
// Playback dispatch retrieval (GET /playback).
|
||||
//
|
||||
// Mirrors upstream wrapper main.c:get_m3u8_method_download but returns
|
||||
// the *whole* MZ playback dispatch instead of just one m3u8 URL.
|
||||
// Implementation drives Apple's storeservicescore::PurchaseRequest with
|
||||
// urlBagKey="subDownload"; PurchaseRequest::run() builds a parsed
|
||||
// PurchaseResponse whose items each expose a __CFDictionary* of the
|
||||
// raw plist payload. We walk those dicts and convert CF types to JSON
|
||||
// (string/number/bool/array/object/base64-data/iso8601-date) so the
|
||||
// client receives a native nlohmann::json document.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
namespace wrapper::apple {
|
||||
|
||||
class Loader;
|
||||
class Runtime;
|
||||
|
||||
struct PlaybackResult {
|
||||
bool ok = false;
|
||||
nlohmann::json body; // JSON document (application/json response body)
|
||||
std::string error; // populated when ok == false
|
||||
};
|
||||
|
||||
// Blocking. Caller must hold runtime.playback_mutex() (Apple's
|
||||
// PurchaseRequest dispatch uses process-global state we share with
|
||||
// /decrypt). Requires runtime.initialized() and an authenticated account.
|
||||
PlaybackResult fetch_playback_json(const Loader& loader,
|
||||
Runtime& runtime,
|
||||
std::string adam_id);
|
||||
|
||||
} // namespace wrapper::apple
|
||||
@@ -0,0 +1,495 @@
|
||||
// Port of upstream main.c::init() + init_ctx() to C++.
|
||||
//
|
||||
// Behavioral diffs vs. upstream:
|
||||
// - We install a presentation interface with credential + dialog
|
||||
// handlers. The dialog handler auto-selects "Use Existing Apple ID"
|
||||
// when the title is "Sign In" (same as upstream), and always calls
|
||||
// handleProtocolDialogResponse so AuthenticateFlow does not stall.
|
||||
// - We install SVPlaybackLeaseManager + requestLease before exposing
|
||||
// POST /decrypt, matching upstream main() after init_ctx.
|
||||
// - Errors during init log to stderr; credential + ProtocolDialog
|
||||
// callbacks log a single line each to stderr (no secrets).
|
||||
//
|
||||
// All Apple calls are unchecked: there are no error returns to
|
||||
// validate. If an Apple call crashes mid-init, we want the process to
|
||||
// die rather than mask the failure with a half-initialized
|
||||
// RequestContext.
|
||||
|
||||
#include "apple/runtime.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <functional>
|
||||
#include <new>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
#include "apple/aarch64_sret_thunks.hpp"
|
||||
#include "apple/auth.hpp"
|
||||
|
||||
namespace wrapper::apple {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kDeviceInfoPartCount = 9;
|
||||
|
||||
// Stage tracing: writes a single stderr line and flushes so the line is on
|
||||
// disk before the next Apple call. Gated on WRAPPER_RUNTIME_TRACE=1 (or the
|
||||
// env var being any non-empty non-"0" value).
|
||||
bool runtime_trace_enabled() {
|
||||
static const bool v = []() {
|
||||
const char* x = std::getenv("WRAPPER_RUNTIME_TRACE");
|
||||
if (x == nullptr || *x == '\0') return false;
|
||||
if (std::strcmp(x, "0") == 0) return false;
|
||||
return true;
|
||||
}();
|
||||
return v;
|
||||
}
|
||||
|
||||
void trace(const char* stage) {
|
||||
if (!runtime_trace_enabled()) return;
|
||||
std::fprintf(stderr, "runtime: trace %s\n", stage);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
|
||||
void end_lease_cb(const int& code) {
|
||||
std::fprintf(stderr, "runtime: playback lease ended (code=%d)\n", code);
|
||||
}
|
||||
|
||||
void pb_err_cb(void* /*unused*/) {
|
||||
std::fprintf(stderr, "runtime: playback StoreErrorCondition callback\n");
|
||||
}
|
||||
|
||||
std::function<void(const int&)> g_end_lease_fn(end_lease_cb);
|
||||
std::function<void(void*)> g_pb_err_fn(pb_err_cb);
|
||||
|
||||
// Apple's CredentialsResponse setters can retain references to string storage
|
||||
// past the callback return. Upstream passes global char buffers; keep equivalent
|
||||
// process-lifetime storage here rather than local std::string::c_str() pointers.
|
||||
std::string g_credential_username;
|
||||
std::string g_credential_password;
|
||||
|
||||
// Split "a/b/c/d/.../i" into 9 string parts. Returns empty vector
|
||||
// if the count doesn't match (the runtime considers that a hard
|
||||
// error: Apple's libs assume all 9 fields are present).
|
||||
std::vector<std::string> split_device_info(const std::string& s) {
|
||||
std::vector<std::string> out;
|
||||
out.reserve(kDeviceInfoPartCount);
|
||||
|
||||
std::stringstream ss(s);
|
||||
std::string part;
|
||||
while (std::getline(ss, part, '/')) {
|
||||
out.push_back(std::move(part));
|
||||
}
|
||||
if (static_cast<int>(out.size()) != kDeviceInfoPartCount) {
|
||||
out.clear();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string read_apple_string(const abi::std_string* s) {
|
||||
if (s == nullptr) return {};
|
||||
const bool is_long = (s->_long.cap & 1u) != 0;
|
||||
if (is_long) {
|
||||
if (s->_long.data == nullptr || s->_long.size == 0) return {};
|
||||
return std::string(s->_long.data, s->_long.size);
|
||||
}
|
||||
const std::size_t len = s->_short.mark >> 1;
|
||||
return std::string(s->_short.str, len);
|
||||
}
|
||||
|
||||
// Single-line stderr (no raw newlines / control chars).
|
||||
std::string sanitize_log_chunk(std::string x, std::size_t max_len) {
|
||||
for (char& c : x) {
|
||||
if (static_cast<unsigned char>(c) < 0x20) c = ' ';
|
||||
}
|
||||
if (x.size() > max_len) {
|
||||
x.resize(max_len);
|
||||
x += "...";
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Callbacks registered on AndroidPresentationInterface.
|
||||
//
|
||||
// Apple's setDialogHandler / setCredentialsHandler store a function pointer
|
||||
// typed as taking std::shared_ptr<T> by value. On x86_64 Itanium ABI,
|
||||
// std::shared_ptr has a non-trivial copy ctor/dtor, so it is passed
|
||||
// *indirectly*: the caller puts a pointer to a temporary in %rdi/%rsi.
|
||||
// Declaring the callbacks with explicit shared_ptr* parameters matches that
|
||||
// calling convention exactly — same as the upstream C wrapper (main.c).
|
||||
// Do NOT use by-value abi::shared_ptr here: abi::shared_ptr is trivially
|
||||
// copyable so the compiler would expect its fields in registers, creating
|
||||
// a fatal ABI mismatch.
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
extern "C" void wrapper_dialog_handler(long dialog_id,
|
||||
abi::shared_ptr* dialog,
|
||||
abi::shared_ptr* /* response_handler */) {
|
||||
Runtime& rt = Runtime::instance();
|
||||
const Loader* loader = rt.loader();
|
||||
if (loader == nullptr || !loader->ok()) return;
|
||||
if (dialog == nullptr || dialog->obj == nullptr) return;
|
||||
|
||||
const Symbols& sy = loader->sym();
|
||||
std::string title = read_apple_string(sy.ProtocolDialog_title(dialog->obj));
|
||||
abi::shared_ptr apInf = rt.presentation_interface_copy();
|
||||
if (apInf.obj == nullptr) return;
|
||||
|
||||
// Allocate with ::operator new so libc++'s shared_ptr control-block
|
||||
// teardown matches the allocator (malloc + operator delete is UB).
|
||||
auto* const emplace = static_cast<std::uint8_t*>(::operator new(72));
|
||||
std::memset(emplace + 8, 0, 16);
|
||||
*reinterpret_cast<void**>(emplace) = sy.vtable_ProtocolDialogResponse + 2;
|
||||
abi::shared_ptr diag_resp;
|
||||
diag_resp.obj = emplace + 24;
|
||||
diag_resp.ctrl_blk = emplace;
|
||||
sy.ProtocolDialogResponse_ctor(diag_resp.obj);
|
||||
|
||||
std::vector<std::string> button_labels;
|
||||
bool auto_picked = false;
|
||||
abi::std_vector* buttons = sy.ProtocolDialog_buttons(dialog->obj);
|
||||
if (buttons != nullptr && buttons->begin != nullptr && buttons->end != nullptr) {
|
||||
auto* b_begin = static_cast<abi::shared_ptr*>(buttons->begin);
|
||||
auto* b_end = static_cast<abi::shared_ptr*>(buttons->end);
|
||||
for (auto* b = b_begin; b != b_end; ++b) {
|
||||
if (b->obj == nullptr) continue;
|
||||
std::string bt = read_apple_string(sy.ProtocolButton_title(b->obj));
|
||||
const bool is_existing = (bt == "Use Existing Apple ID");
|
||||
button_labels.push_back(sanitize_log_chunk(std::move(bt), 80));
|
||||
if (!auto_picked && title == "Sign In" && is_existing) {
|
||||
sy.ProtocolDialogResponse_setSelectedButton(diag_resp.obj, b);
|
||||
auto_picked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
std::string joined;
|
||||
for (std::size_t i = 0; i < button_labels.size(); ++i) {
|
||||
if (i != 0) joined += ", ";
|
||||
joined += button_labels[i];
|
||||
}
|
||||
joined = sanitize_log_chunk(std::move(joined), 400);
|
||||
std::string title_log = sanitize_log_chunk(std::move(title), 200);
|
||||
std::fprintf(stderr,
|
||||
"runtime: ProtocolDialog id=%ld title=\"%s\" buttons=[%s] "
|
||||
"picked_existing_apple_id=%s\n",
|
||||
static_cast<long>(dialog_id), title_log.c_str(), joined.c_str(),
|
||||
auto_picked ? "yes" : "no");
|
||||
}
|
||||
|
||||
long id_arg = dialog_id;
|
||||
sy.API_handleProtocolDialogResponse(apInf.obj, &id_arg, &diag_resp);
|
||||
// Ownership of emplace follows Apple's shared_ptr refcount; do not free here.
|
||||
}
|
||||
|
||||
extern "C" void wrapper_credential_handler(abi::shared_ptr* cred_request,
|
||||
abi::shared_ptr* /* cred_response_handler */) {
|
||||
Runtime& rt = Runtime::instance();
|
||||
const Loader* loader = rt.loader();
|
||||
if (loader == nullptr || !loader->ok()) return;
|
||||
const Symbols& s = loader->sym();
|
||||
abi::shared_ptr apInf = rt.presentation_interface_copy();
|
||||
if (apInf.obj == nullptr) return;
|
||||
|
||||
const bool need_2fa =
|
||||
(cred_request != nullptr && cred_request->obj != nullptr)
|
||||
? (s.CR_requiresHSA2VerificationCode(cred_request->obj) != 0)
|
||||
: false;
|
||||
|
||||
std::fprintf(stderr,
|
||||
"runtime: CredentialsRequest HSA2=%s\n",
|
||||
need_2fa ? "yes" : "no");
|
||||
|
||||
std::string username, password;
|
||||
Account::instance().fetch_credentials_blocking(&username, &password, need_2fa);
|
||||
g_credential_username = std::move(username);
|
||||
g_credential_password = std::move(password);
|
||||
|
||||
auto* const emplace = static_cast<std::uint8_t*>(::operator new(80));
|
||||
std::memset(emplace + 8, 0, 16);
|
||||
*reinterpret_cast<void**>(emplace) = s.vtable_CredentialsResponse + 2;
|
||||
abi::shared_ptr cred_resp;
|
||||
cred_resp.obj = emplace + 24;
|
||||
cred_resp.ctrl_blk = emplace;
|
||||
|
||||
s.CredentialsResponse_ctor(cred_resp.obj);
|
||||
auto user_view = abi::make_string_view(g_credential_username.c_str());
|
||||
s.CredentialsResponse_setUserName(cred_resp.obj, &user_view);
|
||||
auto pass_view = abi::make_string_view(g_credential_password.c_str());
|
||||
s.CredentialsResponse_setPassword(cred_resp.obj, &pass_view);
|
||||
s.CredentialsResponse_setResponseType(cred_resp.obj, /*kSubmit=*/2);
|
||||
|
||||
s.API_handleCredentialsResponse(apInf.obj, &cred_resp);
|
||||
// Ownership of emplace follows Apple's shared_ptr refcount; do not free here.
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Runtime& Runtime::instance() {
|
||||
static Runtime r;
|
||||
return r;
|
||||
}
|
||||
|
||||
bool Runtime::init_playback_session(const Symbols& s) {
|
||||
trace("init_playback_session: memset lease_mgr_");
|
||||
std::memset(lease_mgr_, 0, sizeof(lease_mgr_));
|
||||
trace("init_playback_session: SVPlaybackLeaseManager_ctor");
|
||||
s.SVPlaybackLeaseManager_ctor(lease_mgr_, &g_end_lease_fn, &g_pb_err_fn);
|
||||
std::uint8_t autom = 1;
|
||||
trace("init_playback_session: refreshLeaseAutomatically");
|
||||
s.SVPlaybackLeaseManager_refreshLeaseAutomatically(lease_mgr_, &autom);
|
||||
trace("init_playback_session: requestLease");
|
||||
s.SVPlaybackLeaseManager_requestLease(lease_mgr_, &autom);
|
||||
trace("init_playback_session: SVFootHillSessionCtrl::instance");
|
||||
foothill_ = s.SVFootHillSessionCtrl_instance();
|
||||
if (foothill_ == nullptr) {
|
||||
std::fprintf(stderr,
|
||||
"runtime: SVFootHillSessionCtrl::instance returned null\n");
|
||||
return false;
|
||||
}
|
||||
trace("init_playback_session: done");
|
||||
return true;
|
||||
}
|
||||
|
||||
void Runtime::refresh_playback_lease() {
|
||||
if (loader_ == nullptr || !loader_->ok() || !playback_ready_) {
|
||||
return;
|
||||
}
|
||||
const Symbols& s = loader_->sym();
|
||||
std::uint8_t autom = 1;
|
||||
std::lock_guard<std::mutex> lock(playback_mutex_);
|
||||
s.SVPlaybackLeaseManager_requestLease(lease_mgr_, &autom);
|
||||
}
|
||||
|
||||
bool Runtime::initialize(const Loader& loader, const RuntimeConfig& cfg) {
|
||||
std::lock_guard<std::mutex> g(mu_);
|
||||
if (initialized_.load(std::memory_order_relaxed)) return true;
|
||||
|
||||
if (!loader.ok()) {
|
||||
std::fprintf(stderr,
|
||||
"runtime: cannot initialize, loader is not ok (last_error=%s)\n",
|
||||
loader.last_error().c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
auto parts = split_device_info(cfg.device_info);
|
||||
if (parts.empty()) {
|
||||
std::fprintf(stderr,
|
||||
"runtime: WRAPPER_DEVICE_INFO must have %d slash-separated "
|
||||
"parts, got %zu (raw=%.200s)\n",
|
||||
kDeviceInfoPartCount,
|
||||
std::count(cfg.device_info.begin(), cfg.device_info.end(), '/') + 1,
|
||||
cfg.device_info.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
const Symbols& s = loader.sym();
|
||||
trace("initialize: init_dns_and_foothill");
|
||||
if (!init_dns_and_foothill(s, parts)) return false;
|
||||
trace("initialize: init_request_context");
|
||||
if (!init_request_context(s, cfg, parts)) return false;
|
||||
trace("initialize: init_presentation_interface");
|
||||
if (!init_presentation_interface(s)) return false;
|
||||
trace("initialize: core context ready");
|
||||
|
||||
base_dir_ = cfg.base_dir;
|
||||
device_info_ = cfg.device_info;
|
||||
loader_ = &loader;
|
||||
|
||||
if (!loader.fairplay_decrypt_available()) {
|
||||
playback_ready_ = false;
|
||||
std::fprintf(stderr,
|
||||
"runtime: FairPlay decrypt chain not loaded; POST /decrypt unavailable\n");
|
||||
} else {
|
||||
playback_ready_ = init_playback_session(s);
|
||||
if (!playback_ready_) {
|
||||
std::fprintf(stderr,
|
||||
"runtime: warning: FairPlay playback init failed; "
|
||||
"POST /decrypt unavailable\n");
|
||||
}
|
||||
}
|
||||
|
||||
initialized_.store(true, std::memory_order_release);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Runtime::init_dns_and_foothill(const Symbols& s,
|
||||
const std::vector<std::string>& parts) {
|
||||
// Bionic's resolver inside the chroot has no /etc/resolv.conf to
|
||||
// read. Set Alibaba's public DNS so the bundled libcurl can
|
||||
// reach api.itunes.apple.com etc. Upstream uses the same hosts.
|
||||
trace("init_dns_and_foothill: resolv_set_nameservers_for_net");
|
||||
static const char* resolvers[2] = {"223.5.5.5", "223.6.6.6"};
|
||||
s.resolv_set_nameservers_for_net(0, resolvers, 2, ".");
|
||||
|
||||
// FootHillConfig::config(android_id). Android ID is part 8 of
|
||||
// the device-info tuple.
|
||||
trace("init_dns_and_foothill: FootHillConfig::config");
|
||||
auto android_id = abi::make_string_view(parts[8].c_str());
|
||||
s.FootHillConfig_config(&android_id);
|
||||
|
||||
// DeviceGUID::instance() -> shared_ptr<DeviceGUID>
|
||||
trace("init_dns_and_foothill: DeviceGUID::instance");
|
||||
aarch64_sret::device_guid_instance(&device_guid_, s.DeviceGUID_instance);
|
||||
if (device_guid_.obj == nullptr) {
|
||||
std::fprintf(stderr, "runtime: DeviceGUID::instance returned null\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
// DeviceGUID::configure(android_id, "", 29, true). The first arg
|
||||
// is the hidden return slot for the (88-byte) Configuration<>
|
||||
// value Apple discards.
|
||||
// Hidden return for DeviceGUID::configure; must be 16-byte aligned for
|
||||
// AArch64 NEON / libc++ stores into the sret slot.
|
||||
alignas(16) static std::uint8_t cfg_ret[88];
|
||||
static const unsigned int cfg_kind = 29;
|
||||
static const std::uint8_t cfg_flag = 1;
|
||||
auto empty = abi::make_string_view("");
|
||||
trace("init_dns_and_foothill: DeviceGUID::configure");
|
||||
aarch64_sret::device_guid_configure(
|
||||
&cfg_ret, device_guid_.obj, &android_id, &empty, &cfg_kind, &cfg_flag,
|
||||
s.DeviceGUID_configure);
|
||||
trace("init_dns_and_foothill: done");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Runtime::init_request_context(const Symbols& s,
|
||||
const RuntimeConfig& cfg,
|
||||
const std::vector<std::string>& parts) {
|
||||
// make_shared<RequestContext>(mpl_db_path).
|
||||
std::string mpl_db = cfg.base_dir + "/mpl_db";
|
||||
auto mpl_db_view = abi::make_string_view(mpl_db.c_str());
|
||||
trace("init_request_context: make_shared<RequestContext>");
|
||||
aarch64_sret::make_shared_request_context(&request_ctx_, &mpl_db_view,
|
||||
s.make_shared_RequestContext);
|
||||
|
||||
if (request_ctx_.obj == nullptr) {
|
||||
std::fprintf(stderr, "runtime: make_shared<RequestContext> returned null\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
// RequestContextConfig: emplaced in a buffer with a hand-rolled vtable
|
||||
// pointer for the shared_ptr control block. Pattern from upstream
|
||||
// init_ctx(), with extra slack so Release builds do not stomp adjacent
|
||||
// storage if Apple's object is larger than the historical constant.
|
||||
alignas(16) static std::uint8_t rcc_buf[2048];
|
||||
*reinterpret_cast<void**>(rcc_buf) =
|
||||
s.vtable_RequestContextConfig + 2; // skip past vtable header (type_info + dtor slots)
|
||||
|
||||
abi::shared_ptr rcc;
|
||||
rcc.obj = rcc_buf + 32;
|
||||
rcc.ctrl_blk = rcc_buf;
|
||||
|
||||
trace("init_request_context: RequestContextConfig_ctor");
|
||||
s.RequestContextConfig_ctor(rcc.obj);
|
||||
|
||||
// Setters: order matches upstream init_ctx().
|
||||
trace("init_request_context: RCC setters");
|
||||
s.RCC_setBaseDirectoryPath(rcc.obj, &mpl_db_view);
|
||||
|
||||
auto s0 = abi::make_string_view(parts[0].c_str());
|
||||
s.RCC_setClientIdentifier(rcc.obj, &s0);
|
||||
|
||||
auto s1 = abi::make_string_view(parts[1].c_str());
|
||||
s.RCC_setVersionIdentifier(rcc.obj, &s1);
|
||||
|
||||
auto s2 = abi::make_string_view(parts[2].c_str());
|
||||
s.RCC_setPlatformIdentifier(rcc.obj, &s2);
|
||||
|
||||
auto s3 = abi::make_string_view(parts[3].c_str());
|
||||
s.RCC_setProductVersion(rcc.obj, &s3);
|
||||
|
||||
auto s4 = abi::make_string_view(parts[4].c_str());
|
||||
s.RCC_setDeviceModel(rcc.obj, &s4);
|
||||
|
||||
auto s5 = abi::make_string_view(parts[5].c_str());
|
||||
s.RCC_setBuildVersion(rcc.obj, &s5);
|
||||
|
||||
auto s6 = abi::make_string_view(parts[6].c_str());
|
||||
s.RCC_setLocaleIdentifier(rcc.obj, &s6);
|
||||
|
||||
auto s7 = abi::make_string_view(parts[7].c_str());
|
||||
s.RCC_setLanguageIdentifier(rcc.obj, &s7);
|
||||
|
||||
// Wire the RequestContext into the singleton manager.
|
||||
trace("init_request_context: RequestContextManager::configure");
|
||||
s.RequestContextManager_configure(&request_ctx_);
|
||||
|
||||
// RequestContext::init(rcc). Upstream uses an 88-byte hidden
|
||||
// return slot - match it.
|
||||
// RequestContext::init hidden return buffer (same alignment as cfg_ret).
|
||||
alignas(16) static std::uint8_t rci_ret[88];
|
||||
trace("init_request_context: RequestContext::init");
|
||||
aarch64_sret::request_context_init(&rci_ret, request_ctx_.obj, &rcc,
|
||||
s.RequestContext_init);
|
||||
|
||||
auto fp_dir = abi::make_string_view(cfg.base_dir.c_str());
|
||||
trace("init_request_context: setFairPlayDirectoryPath");
|
||||
s.RequestContext_setFairPlayDirectoryPath(request_ctx_.obj, &fp_dir);
|
||||
|
||||
trace("init_request_context: done");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Runtime::init_presentation_interface(const Symbols& s) {
|
||||
// make_shared<AndroidPresentationInterface>()
|
||||
trace("init_presentation_interface: make_shared<AndroidPresentationInterface>");
|
||||
aarch64_sret::make_shared_android_presentation_interface(
|
||||
&presentation_interface_, s.make_shared_AndroidPresentationInterface);
|
||||
if (presentation_interface_.obj == nullptr) {
|
||||
std::fprintf(stderr,
|
||||
"runtime: make_shared<AndroidPresentationInterface> returned null\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
trace("init_presentation_interface: setDialogHandler");
|
||||
s.API_setDialogHandler(presentation_interface_.obj, &wrapper_dialog_handler);
|
||||
trace("init_presentation_interface: setCredentialsHandler");
|
||||
s.API_setCredentialsHandler(presentation_interface_.obj, &wrapper_credential_handler);
|
||||
|
||||
// Wire the apInf into the RequestContext so AuthenticateFlow can
|
||||
// surface credential prompts back to us.
|
||||
trace("init_presentation_interface: setPresentationInterface");
|
||||
s.RequestContext_setPresentationInterface(request_ctx_.obj, &presentation_interface_);
|
||||
trace("init_presentation_interface: done");
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string Runtime::base_dir() const {
|
||||
if (!initialized_.load(std::memory_order_acquire)) return {};
|
||||
std::lock_guard<std::mutex> g(mu_);
|
||||
return base_dir_;
|
||||
}
|
||||
|
||||
std::string Runtime::device_info() const {
|
||||
if (!initialized_.load(std::memory_order_acquire)) return {};
|
||||
std::lock_guard<std::mutex> g(mu_);
|
||||
return device_info_;
|
||||
}
|
||||
|
||||
abi::shared_ptr Runtime::request_ctx_copy() const {
|
||||
if (!initialized_.load(std::memory_order_acquire)) return {};
|
||||
std::lock_guard<std::mutex> g(mu_);
|
||||
return request_ctx_;
|
||||
}
|
||||
|
||||
abi::shared_ptr Runtime::device_guid_copy() const {
|
||||
if (!initialized_.load(std::memory_order_acquire)) return {};
|
||||
std::lock_guard<std::mutex> g(mu_);
|
||||
return device_guid_;
|
||||
}
|
||||
|
||||
abi::shared_ptr Runtime::presentation_interface_copy() const {
|
||||
if (!initialized_.load(std::memory_order_acquire)) return {};
|
||||
std::lock_guard<std::mutex> g(mu_);
|
||||
return presentation_interface_;
|
||||
}
|
||||
|
||||
} // namespace wrapper::apple
|
||||
@@ -0,0 +1,111 @@
|
||||
// Apple Music native runtime initialization.
|
||||
//
|
||||
// `Runtime::initialize()` runs the equivalent of upstream main.c's
|
||||
// `init()` and `init_ctx()` in one go, then wires SVPlaybackLeaseManager
|
||||
// + `SVFootHillSessionCtrl::instance()` for FairPlay decrypt. After it
|
||||
// returns true, the process holds a configured `RequestContext` shared_ptr
|
||||
// that downstream Apple-API calls (storefront lookup, m3u8, decrypt) need.
|
||||
//
|
||||
// The runtime is process-wide and single-instance: Apple's libs use process-global
|
||||
// state and are not safe to re-initialize.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <cstddef>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include "apple/abi.hpp"
|
||||
#include "apple/loader.hpp"
|
||||
|
||||
namespace wrapper::apple {
|
||||
|
||||
// Configuration sourced from environment variables. See
|
||||
// .env.example for defaults.
|
||||
struct RuntimeConfig {
|
||||
// Filesystem path Apple's libs will use for FairPlay key cache,
|
||||
// mpl_db, etc. Mapped to upstream's --base-dir / -B flag.
|
||||
std::string base_dir = "/data/data/com.apple.android.music/files";
|
||||
|
||||
// Slash-separated 9-tuple identifying the fake "Apple Music
|
||||
// Android client" we present to Apple's servers. Order:
|
||||
// ClientIdentifier / VersionIdentifier / PlatformIdentifier /
|
||||
// ProductVersion / DeviceModel / BuildVersion /
|
||||
// LocaleIdentifier / LanguageIdentifier / AndroidID
|
||||
std::string device_info =
|
||||
"Music/4.9/Android/10/Samsung S9/7663313/en-US/en-US/dc28071e981c439e";
|
||||
};
|
||||
|
||||
// Singleton: Apple's libs share process-global state, so only one
|
||||
// Runtime instance per process is ever valid.
|
||||
class Runtime {
|
||||
public:
|
||||
static Runtime& instance();
|
||||
|
||||
// Idempotent: subsequent calls after the first successful one
|
||||
// are no-ops returning true. On failure the runtime stays
|
||||
// uninitialized and subsequent calls will retry. Errors are
|
||||
// logged to stderr with a `runtime: ` prefix. The Loader must
|
||||
// outlive the runtime - we cache a pointer for credential
|
||||
// trampoline access.
|
||||
bool initialize(const Loader& loader, const RuntimeConfig& cfg);
|
||||
|
||||
bool initialized() const { return initialized_.load(std::memory_order_acquire); }
|
||||
|
||||
// Snapshot of the configuration that was used for initialize().
|
||||
// Empty string if not initialized yet.
|
||||
std::string base_dir() const;
|
||||
std::string device_info() const;
|
||||
|
||||
// Read-only access used by the auth worker thread.
|
||||
// Both methods return zero-initialized values if not initialized.
|
||||
abi::shared_ptr request_ctx_copy() const;
|
||||
abi::shared_ptr device_guid_copy() const;
|
||||
abi::shared_ptr presentation_interface_copy() const;
|
||||
|
||||
// Loader pointer captured at initialize() time. Used by the C-style
|
||||
// credential handler trampoline which can't carry a user-data
|
||||
// pointer through Apple's callback ABI.
|
||||
const Loader* loader() const { return loader_; }
|
||||
|
||||
// FairPlay sample decrypt (POST /decrypt). Requires successful
|
||||
// SVPlaybackLeaseManager + SVFootHillSessionCtrl init at startup.
|
||||
bool playback_ready() const { return playback_ready_; }
|
||||
void* foothill_session() const { return foothill_; }
|
||||
std::mutex& playback_mutex() { return playback_mutex_; }
|
||||
void refresh_playback_lease();
|
||||
|
||||
private:
|
||||
Runtime() = default;
|
||||
Runtime(const Runtime&) = delete;
|
||||
Runtime& operator=(const Runtime&) = delete;
|
||||
|
||||
bool init_dns_and_foothill(const Symbols& s,
|
||||
const std::vector<std::string>& parts);
|
||||
bool init_request_context(const Symbols& s,
|
||||
const RuntimeConfig& cfg,
|
||||
const std::vector<std::string>& parts);
|
||||
bool init_presentation_interface(const Symbols& s);
|
||||
bool init_playback_session(const Symbols& s);
|
||||
|
||||
mutable std::mutex mu_;
|
||||
std::atomic<bool> initialized_{false};
|
||||
const Loader* loader_ = nullptr;
|
||||
abi::shared_ptr request_ctx_{};
|
||||
abi::shared_ptr device_guid_{};
|
||||
abi::shared_ptr presentation_interface_{};
|
||||
std::string base_dir_;
|
||||
std::string device_info_;
|
||||
|
||||
std::mutex playback_mutex_;
|
||||
// Placement buffer for SVPlaybackLeaseManager (ctor + instance methods).
|
||||
// Must cover the real object size; 16 was far too small and UB on arm64.
|
||||
alignas(16) unsigned char lease_mgr_[4096]{};
|
||||
void* foothill_ = nullptr;
|
||||
bool playback_ready_ = false;
|
||||
};
|
||||
|
||||
} // namespace wrapper::apple
|
||||
@@ -0,0 +1,403 @@
|
||||
#include "apple/tokens.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "apple/aarch64_sret_thunks.hpp"
|
||||
#include "apple/auth.hpp"
|
||||
#include "apple/loader.hpp"
|
||||
|
||||
namespace wrapper::apple::tokens {
|
||||
|
||||
namespace {
|
||||
|
||||
// Apple's URLResponse body occasionally contains bytes after the first JSON
|
||||
// object (garbage or a second payload). nlohmann rejects trailing non-space.
|
||||
// Upstream cJSON is sometimes more permissive; we parse only the first
|
||||
// balanced `{...}` slice.
|
||||
std::string normalize_apple_json_body(std::string_view raw) {
|
||||
std::string s(raw);
|
||||
if (s.size() >= 3 && static_cast<unsigned char>(s[0]) == 0xEF &&
|
||||
static_cast<unsigned char>(s[1]) == 0xBB &&
|
||||
static_cast<unsigned char>(s[2]) == 0xBF) {
|
||||
s.erase(0, 3);
|
||||
}
|
||||
if (s.size() >= 4 && s[0] == ')' && s[1] == ']' && s[2] == '}' &&
|
||||
s[3] == '\'') {
|
||||
const auto nl = s.find('\n');
|
||||
if (nl != std::string::npos) {
|
||||
s.erase(0, nl + 1);
|
||||
} else {
|
||||
s.erase(0, 4);
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string_view first_json_object(std::string_view s) {
|
||||
const std::size_t start = s.find('{');
|
||||
if (start == std::string_view::npos) return {};
|
||||
int depth = 0;
|
||||
bool in_str = false;
|
||||
bool esc = false;
|
||||
for (std::size_t j = start; j < s.size(); ++j) {
|
||||
const unsigned char c = static_cast<unsigned char>(s[j]);
|
||||
if (in_str) {
|
||||
if (esc) {
|
||||
esc = false;
|
||||
} else if (c == '\\') {
|
||||
esc = true;
|
||||
} else if (c == '"') {
|
||||
in_str = false;
|
||||
}
|
||||
} else {
|
||||
if (c == '"') {
|
||||
in_str = true;
|
||||
} else if (c == '{') {
|
||||
++depth;
|
||||
} else if (c == '}') {
|
||||
--depth;
|
||||
if (depth == 0) {
|
||||
return s.substr(start, j - start + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
bool parse_apple_response_json(const std::string& body, nlohmann::json* out) {
|
||||
const std::string norm = normalize_apple_json_body(body);
|
||||
const std::string_view obj = first_json_object(norm);
|
||||
if (obj.empty()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
*out = nlohmann::json::parse(obj);
|
||||
return true;
|
||||
} catch (const std::exception&) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Read a std::__ndk1::basic_string out of Apple-land into a host string.
|
||||
std::string read_std_string(const abi::std_string& s) {
|
||||
const bool is_long = (s._long.cap & 1u) != 0;
|
||||
if (is_long) {
|
||||
if (s._long.data == nullptr || s._long.size == 0) return {};
|
||||
return std::string(s._long.data, s._long.size);
|
||||
}
|
||||
const std::size_t len = s._short.mark >> 1;
|
||||
return std::string(s._short.str, len);
|
||||
}
|
||||
|
||||
// HTTPMessage: manual __shared_ptr_emplace layout; +32 is the HTTPMessage
|
||||
// object. The emplace/control-block storage must outlive this C++ stack frame:
|
||||
// URLRequest / HTTPRequest can keep shared/weak references after run() returns,
|
||||
// and a later request may release them. If this storage lives on the stack, the
|
||||
// second token request can end up touching a reused/zeroed control block and
|
||||
// crash through a null vtable (arm64 commonly reports fault_addr=0x10).
|
||||
struct HTTPMessageHolder {
|
||||
static constexpr std::size_t kSize = 4096;
|
||||
std::uint8_t* buf = nullptr;
|
||||
abi::shared_ptr sp{};
|
||||
|
||||
void init(void** vtable) {
|
||||
void* raw = nullptr;
|
||||
if (::posix_memalign(&raw, 16, kSize) != 0 || raw == nullptr) {
|
||||
std::fprintf(stderr, "tokens: HTTPMessageHolder allocation failed\n");
|
||||
std::fflush(stderr);
|
||||
std::abort();
|
||||
}
|
||||
buf = static_cast<std::uint8_t*>(raw);
|
||||
std::memset(buf, 0, kSize);
|
||||
*reinterpret_cast<void**>(buf) = vtable + 2;
|
||||
sp.obj = buf + 32;
|
||||
sp.ctrl_blk = buf;
|
||||
}
|
||||
};
|
||||
|
||||
// JWT base64url decoder. Returns an empty string on malformed input.
|
||||
std::string base64url_decode(const std::string& in) {
|
||||
static const int8_t T[256] = {
|
||||
// 0..63 = legal char, else -1
|
||||
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
|
||||
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,62,-1,-1, 52,53,54,55,56,57,58,59,60,61,-1,-1,-1, 0,-1,-1,
|
||||
-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10,11,12,13,14, 15,16,17,18,19,20,21,22,23,24,25,-1,-1,-1,-1,63,
|
||||
-1,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40, 41,42,43,44,45,46,47,48,49,50,51,-1,-1,-1,-1,-1,
|
||||
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
|
||||
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
|
||||
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
|
||||
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
|
||||
};
|
||||
std::string out;
|
||||
out.reserve(in.size() * 3 / 4);
|
||||
int bits = 0, val = 0;
|
||||
for (unsigned char c : in) {
|
||||
int v = T[c];
|
||||
if (v < 0) {
|
||||
if (c == '=' || c == '\r' || c == '\n') continue;
|
||||
return {};
|
||||
}
|
||||
val = (val << 6) | v;
|
||||
bits += 6;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out.push_back(static_cast<char>((val >> bits) & 0xff));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void set_str_header(const Symbols& s, void* http_msg_obj,
|
||||
const char* name, const char* value) {
|
||||
auto n = abi::make_string_view(name);
|
||||
auto v = abi::make_string_view(value);
|
||||
s.HTTPMessage_setHeader(http_msg_obj, &n, &v);
|
||||
}
|
||||
|
||||
void set_url_param(const Symbols& s, void* url_req_obj,
|
||||
const char* name, const char* value) {
|
||||
auto n = abi::make_string_view(name);
|
||||
auto v = abi::make_string_view(value);
|
||||
s.URLRequest_setRequestParameter(url_req_obj, &n, &v);
|
||||
}
|
||||
|
||||
void construct_http_message(const Symbols& s,
|
||||
HTTPMessageHolder& msg,
|
||||
const char* url,
|
||||
const char* method) {
|
||||
// HTTPMessage(url, method) takes std::string by value. Use real
|
||||
// std::string temporaries so libc++ constructs/destructs valid objects
|
||||
// instead of treating an abi::std_string view as an owned string.
|
||||
if (s.HTTPMessage_ctor_c1 != nullptr) {
|
||||
s.HTTPMessage_ctor_c1(msg.sp.obj, std::string(url), std::string(method));
|
||||
} else {
|
||||
s.HTTPMessage_ctor(msg.sp.obj, std::string(url), std::string(method));
|
||||
}
|
||||
}
|
||||
|
||||
struct UrlRequestResult {
|
||||
std::string body;
|
||||
int error_code = 0;
|
||||
std::string error_what;
|
||||
bool ok = false;
|
||||
};
|
||||
|
||||
// Run an HTTPMessage through URLRequest with the given RequestContext
|
||||
// and return the response body. Optional `params` populates GET-style
|
||||
// query parameters via URLRequest::setRequestParameter.
|
||||
UrlRequestResult run_request(
|
||||
const Symbols& s,
|
||||
abi::shared_ptr req_ctx,
|
||||
HTTPMessageHolder& msg,
|
||||
const std::vector<std::pair<std::string, std::string>>& params) {
|
||||
UrlRequestResult r;
|
||||
|
||||
// Process-lifetime storage: URLRequest/HTTPRequest can retain references
|
||||
// beyond run(), so do not back this with a stack buffer.
|
||||
constexpr std::size_t kUrlReqSize = 8192;
|
||||
void* url_req_raw = nullptr;
|
||||
if (::posix_memalign(&url_req_raw, 16, kUrlReqSize) != 0 || url_req_raw == nullptr) {
|
||||
std::fprintf(stderr, "tokens: run_request: posix_memalign failed\n");
|
||||
std::fflush(stderr);
|
||||
return r;
|
||||
}
|
||||
std::uint8_t* url_req = static_cast<std::uint8_t*>(url_req_raw);
|
||||
std::memset(url_req, 0, kUrlReqSize);
|
||||
s.URLRequest_ctor(url_req, &msg.sp, &req_ctx);
|
||||
|
||||
for (const auto& p : params) {
|
||||
set_url_param(s, url_req, p.first.c_str(), p.second.c_str());
|
||||
}
|
||||
|
||||
aarch64_sret::urlrequest_run(url_req, s.URLRequest_run);
|
||||
|
||||
abi::shared_ptr* err = s.URLRequest_error(url_req);
|
||||
if (err != nullptr && err->obj != nullptr) {
|
||||
r.error_code = s.SEC_errorCode(err->obj);
|
||||
const char* what = s.SEC_what(err->obj);
|
||||
if (what != nullptr) r.error_what = what;
|
||||
return r;
|
||||
}
|
||||
|
||||
abi::shared_ptr* url_resp = s.URLRequest_response(url_req);
|
||||
if (url_resp == nullptr || url_resp->obj == nullptr) return r;
|
||||
abi::shared_ptr* inner = s.URLResponse_underlyingResponse(url_resp->obj);
|
||||
if (inner == nullptr || inner->obj == nullptr) return r;
|
||||
|
||||
// Upstream pattern: at offset +48 from HTTPMessage there's a
|
||||
// pointer to a mediaplatform::Data whose bytes() yields the body.
|
||||
void* http_msg_obj = inner->obj;
|
||||
void** data_slot = reinterpret_cast<void**>(static_cast<std::uint8_t*>(http_msg_obj) + 48);
|
||||
void* data_obj = *data_slot;
|
||||
if (data_obj == nullptr) return r;
|
||||
const char* bytes = s.Data_bytes(data_obj);
|
||||
if (bytes == nullptr) return r;
|
||||
r.body = std::string(bytes);
|
||||
r.ok = true;
|
||||
return r;
|
||||
}
|
||||
|
||||
// POST JSON body via URLRequest (uses Apple's signing headers on req_ctx).
|
||||
std::string post_json(const Symbols& s,
|
||||
abi::shared_ptr req_ctx,
|
||||
HTTPMessageHolder& msg,
|
||||
const std::string& body_json) {
|
||||
// Upstream uses a malloc'd + snprintf buffer and frees immediately after
|
||||
// setBodyData. libc++ std::string::data() is null-terminated, so we pass
|
||||
// the std::string buffer directly instead of copying into std::vector<char>
|
||||
// (which is NOT null-terminated and may cause strlen-based corruption).
|
||||
if (!body_json.empty()) {
|
||||
s.HTTPMessage_setBodyData(msg.sp.obj, body_json.data(), body_json.size());
|
||||
}
|
||||
auto r = run_request(s, req_ctx, msg, {});
|
||||
if (!r.ok) return {};
|
||||
return r.body;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string harvest_storefront(const Symbols& s, abi::shared_ptr req_ctx) {
|
||||
abi::std_string out{};
|
||||
abi::shared_ptr null_url_bag{};
|
||||
aarch64_sret::request_context_store_front_identifier(
|
||||
&out, req_ctx.obj, &null_url_bag, s.RequestContext_storeFrontIdentifier);
|
||||
return read_std_string(out);
|
||||
}
|
||||
|
||||
std::string device_guid_string(const Symbols& s, abi::shared_ptr device_guid) {
|
||||
if (device_guid.obj == nullptr) return {};
|
||||
// DeviceGUID::guid() returns a (Data*, void*) pair into a 16-byte
|
||||
// hidden first arg. The first 8 bytes are the Data* whose bytes()
|
||||
// we want.
|
||||
void* ret[2] = {nullptr, nullptr};
|
||||
aarch64_sret::device_guid_guid(ret, device_guid.obj, s.DeviceGUID_guid);
|
||||
if (ret[0] == nullptr) return {};
|
||||
const char* bytes = s.Data_bytes(ret[0]);
|
||||
if (bytes == nullptr) return {};
|
||||
return std::string(bytes);
|
||||
}
|
||||
|
||||
std::string harvest_dev_token(const Symbols& s, abi::shared_ptr req_ctx) {
|
||||
// Matches upstream get_dev_token(): GET + query params; JSON field is "token".
|
||||
HTTPMessageHolder msg;
|
||||
msg.init(s.vtable_HTTPMessage);
|
||||
construct_http_message(s, msg,
|
||||
"https://sf-api-token-service.itunes.apple.com/apiToken",
|
||||
"GET");
|
||||
|
||||
std::vector<std::pair<std::string, std::string>> params;
|
||||
params.emplace_back("clientId", "musicAndroid");
|
||||
params.emplace_back("version", "1");
|
||||
|
||||
auto r = run_request(s, req_ctx, msg, params);
|
||||
if (!r.ok || r.body.empty()) return {};
|
||||
|
||||
nlohmann::json j;
|
||||
if (!parse_apple_response_json(r.body, &j)) return {};
|
||||
if (j.contains("token") && j["token"].is_string()) {
|
||||
return j["token"].get<std::string>();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string harvest_music_user_token(const Symbols& s,
|
||||
abi::shared_ptr req_ctx,
|
||||
const std::string& guid,
|
||||
const std::string& dev_token) {
|
||||
HTTPMessageHolder msg;
|
||||
msg.init(s.vtable_HTTPMessage);
|
||||
construct_http_message(s, msg,
|
||||
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/createMusicToken",
|
||||
"POST");
|
||||
|
||||
set_str_header(s, msg.sp.obj, "Content-Type", "application/json; charset=UTF-8");
|
||||
set_str_header(s, msg.sp.obj, "Expect", "");
|
||||
set_str_header(s, msg.sp.obj, "X-Apple-Requesting-Bundle-Id", "com.apple.android.music");
|
||||
set_str_header(s, msg.sp.obj, "X-Apple-Requesting-Bundle-Version",
|
||||
"Music/4.9 Android/10 model/Samsung S9 build/7663313 (dt:66)");
|
||||
|
||||
auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch()).count();
|
||||
std::string body;
|
||||
body.reserve(512);
|
||||
body += "{\"guid\":\""; body += guid;
|
||||
body += "\",\"assertion\":\""; body += dev_token;
|
||||
body += "\",\"tcc-acceptance-date\":\""; body += std::to_string(now_ms);
|
||||
body += "\"}";
|
||||
|
||||
std::string resp_body = post_json(s, req_ctx, msg, body);
|
||||
if (resp_body.empty()) return {};
|
||||
|
||||
nlohmann::json j;
|
||||
if (!parse_apple_response_json(resp_body, &j)) return {};
|
||||
if (j.contains("music_token") && j["music_token"].is_string()) {
|
||||
return j["music_token"].get<std::string>();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::optional<std::string> extract_dsid_from_jwt(const std::string& jwt) {
|
||||
auto dot1 = jwt.find('.');
|
||||
if (dot1 == std::string::npos) return std::nullopt;
|
||||
auto dot2 = jwt.find('.', dot1 + 1);
|
||||
if (dot2 == std::string::npos) return std::nullopt;
|
||||
std::string payload_b64 = jwt.substr(dot1 + 1, dot2 - dot1 - 1);
|
||||
std::string payload = base64url_decode(payload_b64);
|
||||
if (payload.empty()) return std::nullopt;
|
||||
try {
|
||||
auto j = nlohmann::json::parse(payload);
|
||||
if (j.contains("dsid")) {
|
||||
const auto& v = j["dsid"];
|
||||
if (v.is_string()) return v.get<std::string>();
|
||||
if (v.is_number_integer()) return std::to_string(v.get<long long>());
|
||||
if (v.is_number_unsigned()) return std::to_string(v.get<unsigned long long>());
|
||||
}
|
||||
// some Apple tokens spell the claim "iss" or "sub" - try those too
|
||||
for (const char* k : {"sub", "iss"}) {
|
||||
if (j.contains(k) && j[k].is_string()) {
|
||||
auto cand = j[k].get<std::string>();
|
||||
// dsid looks like "1234567890" - return only if numeric
|
||||
bool numeric = !cand.empty();
|
||||
for (char c : cand) numeric = numeric && (c >= '0' && c <= '9');
|
||||
if (numeric) return cand;
|
||||
}
|
||||
}
|
||||
} catch (...) {
|
||||
// fall through
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
bool harvest_all(const Symbols& s,
|
||||
abi::shared_ptr req_ctx,
|
||||
abi::shared_ptr device_guid,
|
||||
Tokens* out) {
|
||||
out->storefront = harvest_storefront(s, req_ctx);
|
||||
if (out->storefront.empty()) return false;
|
||||
|
||||
out->dev_token = harvest_dev_token(s, req_ctx);
|
||||
if (out->dev_token.empty()) return false;
|
||||
|
||||
std::string guid = device_guid_string(s, device_guid);
|
||||
if (guid.empty()) return false;
|
||||
|
||||
out->music_user_token = harvest_music_user_token(s, req_ctx, guid, out->dev_token);
|
||||
if (out->music_user_token.empty()) return false;
|
||||
|
||||
if (auto d = extract_dsid_from_jwt(out->dev_token)) {
|
||||
out->dsid = std::move(*d);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace wrapper::apple::tokens
|
||||
@@ -0,0 +1,53 @@
|
||||
// Token-harvest helpers.
|
||||
//
|
||||
// Post-login, Apple's `RequestContext` carries the iTunes-side auth
|
||||
// state (DSID + signed device blobs). We use that to:
|
||||
// - Read the storefront identifier directly from RequestContext.
|
||||
// - GET sf-api-token-service.itunes.apple.com/apiToken?clientId=musicAndroid
|
||||
// &version=1 for the developer token (JWT; JSON key `token`).
|
||||
// - POST to play.itunes.apple.com/.../createMusicToken for the
|
||||
// Music User Token (MusicKit-side token derived from this login).
|
||||
// - Decode the `dsid` claim out of the dev-token JWT.
|
||||
//
|
||||
// Everything goes through Apple's URLRequest so requests are
|
||||
// transparently signed with X-Dsid / X-Token / X-iTunes-Storefront
|
||||
// headers; we don't have to reimplement that. The URL signing
|
||||
// machinery lives in libstoreservicescore.so and consumes the same
|
||||
// RequestContext that AuthenticateFlow just populated.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include "apple/abi.hpp"
|
||||
|
||||
namespace wrapper::apple {
|
||||
|
||||
struct Symbols;
|
||||
struct Tokens;
|
||||
|
||||
namespace tokens {
|
||||
|
||||
// One-shot orchestrator used by the worker thread. Populates the
|
||||
// storefront / dev_token / music_user_token / dsid fields on `out`
|
||||
// (the caller is responsible for apple_id and logged_in_at). Returns
|
||||
// false on the first hard failure with stderr-logged context.
|
||||
bool harvest_all(const Symbols& s,
|
||||
abi::shared_ptr req_ctx,
|
||||
abi::shared_ptr device_guid,
|
||||
Tokens* out);
|
||||
|
||||
// Individual stages, exposed for testing and finer-grained reuse.
|
||||
std::string harvest_storefront(const Symbols& s, abi::shared_ptr req_ctx);
|
||||
std::string harvest_dev_token(const Symbols& s, abi::shared_ptr req_ctx);
|
||||
std::string harvest_music_user_token(const Symbols& s,
|
||||
abi::shared_ptr req_ctx,
|
||||
const std::string& guid,
|
||||
const std::string& dev_token);
|
||||
std::string device_guid_string(const Symbols& s, abi::shared_ptr device_guid);
|
||||
std::optional<std::string> extract_dsid_from_jwt(const std::string& jwt);
|
||||
|
||||
} // namespace tokens
|
||||
|
||||
} // namespace wrapper::apple
|
||||
@@ -0,0 +1,345 @@
|
||||
// wrapper-v2 daemon entry point.
|
||||
//
|
||||
// The daemon starts in LoggedOut state, expects credentials via HTTP,
|
||||
// and drives Apple's AuthenticateFlow under the hood:
|
||||
//
|
||||
// GET /health
|
||||
// GET /me
|
||||
// POST /login body: { "apple_id": "...", "password": "..." }
|
||||
// POST /login/2fa body: { "code": "123456" }
|
||||
// DELETE /login
|
||||
//
|
||||
// Persistence: mount WRAPPER_BASE_DIR so Apple keeps mpl_db across
|
||||
// restarts. After a prior POST /login (or first-time -L style login),
|
||||
// startup may restore tokens from that session without password
|
||||
// (WRAPPER_RESTORE_SESSION=1, default).
|
||||
//
|
||||
// Configuration is environment-only. Optional argv: --help. All knobs
|
||||
// use the WRAPPER_ prefix:
|
||||
//
|
||||
// WRAPPER_HOST Bind address (default 0.0.0.0)
|
||||
// WRAPPER_PORT Bind port (default 80)
|
||||
// WRAPPER_MODE supervisor (default) or worker
|
||||
// WRAPPER_WORKER_PORT Private supervisor->worker port (default 18080)
|
||||
// WRAPPER_BASE_DIR Apple-lib working dir (default
|
||||
// /data/data/com.apple.android.music/files)
|
||||
// WRAPPER_DEVICE_INFO 9-tuple device identifier
|
||||
// WRAPPER_APPLE_INIT "0" to skip Apple lib init at startup
|
||||
// (useful for /health-only smoke tests).
|
||||
// WRAPPER_RESTORE_SESSION "0" skips on-disk session restore after init.
|
||||
// WRAPPER_APPLE_ID Optional label for GET /me apple_id after restore
|
||||
// (not sent to Apple).
|
||||
// WRAPPER_USERNAME With WRAPPER_PASSWORD, run password sign-in at
|
||||
// startup if not already authenticated (same as
|
||||
// POST /login username field — Apple ID email).
|
||||
// WRAPPER_PASSWORD App-specific password for WRAPPER_USERNAME auto-login.
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include <signal.h>
|
||||
#include <ucontext.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <httplib.h>
|
||||
|
||||
#include "apple/auth.hpp"
|
||||
#include "apple/loader.hpp"
|
||||
#include "apple/runtime.hpp"
|
||||
#include "server.hpp"
|
||||
#include "supervisor.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char* kDefaultHost = "0.0.0.0";
|
||||
constexpr int kDefaultPort = 80;
|
||||
constexpr int kDefaultWorkerPort = 18080;
|
||||
constexpr const char* kVersion = "0.0.1";
|
||||
|
||||
std::atomic<httplib::Server*> g_server{nullptr};
|
||||
|
||||
enum class ProgramMode {
|
||||
Supervisor,
|
||||
Worker,
|
||||
};
|
||||
|
||||
// Minimal async-signal-safe crash line (no /proc parsing — avoids huge logs).
|
||||
void on_crash(int sig, siginfo_t* info, void* ctx) {
|
||||
auto* uc = static_cast<ucontext_t*>(ctx);
|
||||
void* fault_addr = info ? info->si_addr : nullptr;
|
||||
void* rip = nullptr;
|
||||
#ifdef __x86_64__
|
||||
if (uc) rip = reinterpret_cast<void*>(uc->uc_mcontext.gregs[REG_RIP]);
|
||||
#elif defined(__aarch64__)
|
||||
if (uc) rip = reinterpret_cast<void*>(uc->uc_mcontext.pc);
|
||||
#endif
|
||||
char buf[128];
|
||||
int n = snprintf(buf, sizeof(buf),
|
||||
"wrapper-v2: fatal signal %d fault_addr=%p rip=%p\n",
|
||||
sig, fault_addr, rip);
|
||||
(void)write(STDERR_FILENO, buf, n > 0 ? n : 0);
|
||||
_exit(128 + sig);
|
||||
}
|
||||
|
||||
void on_signal(int sig) {
|
||||
auto* s = g_server.load();
|
||||
if (s != nullptr) {
|
||||
std::fprintf(stderr, "wrapper-v2: caught signal %d, stopping server\n", sig);
|
||||
s->stop();
|
||||
}
|
||||
}
|
||||
|
||||
std::string env_or(const char* name, std::string fallback) {
|
||||
const char* v = std::getenv(name);
|
||||
if (v == nullptr || *v == '\0') return fallback;
|
||||
return std::string(v);
|
||||
}
|
||||
|
||||
bool env_bool(const char* name, bool fallback) {
|
||||
const char* v = std::getenv(name);
|
||||
if (v == nullptr || *v == '\0') return fallback;
|
||||
if (std::strcmp(v, "0") == 0 || std::strcmp(v, "false") == 0
|
||||
|| std::strcmp(v, "no") == 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int env_int(const char* name, int fallback) {
|
||||
int v = std::atoi(env_or(name, std::to_string(fallback)).c_str());
|
||||
return v > 0 ? v : fallback;
|
||||
}
|
||||
|
||||
bool consume_argv(int argc, char** argv, ProgramMode* mode) {
|
||||
std::string mode_env = env_or("WRAPPER_MODE", "supervisor");
|
||||
if (mode_env == "worker") {
|
||||
*mode = ProgramMode::Worker;
|
||||
} else if (mode_env == "supervisor") {
|
||||
*mode = ProgramMode::Supervisor;
|
||||
} else {
|
||||
std::fprintf(stderr,
|
||||
"wrapper-v2: invalid WRAPPER_MODE='%s' "
|
||||
"(expected 'supervisor' or 'worker')\n",
|
||||
mode_env.c_str());
|
||||
std::exit(2);
|
||||
}
|
||||
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
std::string_view x = argv[i];
|
||||
if (x == "--help" || x == "-h") {
|
||||
std::printf(
|
||||
"wrapper-v2 daemon (%s)\n"
|
||||
"Usage: %s\n"
|
||||
"\n"
|
||||
"Default mode is a public supervisor HTTP server. Set\n"
|
||||
"WRAPPER_MODE=worker to start the private Apple runtime server\n"
|
||||
"used by the supervisor.\n"
|
||||
"\n"
|
||||
"Supervisor bind address and port are set with WRAPPER_HOST /\n"
|
||||
"WRAPPER_PORT (defaults %s / %d). Worker port is set with\n"
|
||||
"WRAPPER_WORKER_PORT (default %d).\n"
|
||||
"\n"
|
||||
"Environment:\n"
|
||||
" WRAPPER_HOST bind address\n"
|
||||
" WRAPPER_PORT bind port\n"
|
||||
" WRAPPER_MODE supervisor or worker\n"
|
||||
" WRAPPER_WORKER_PORT private worker bind port\n"
|
||||
" WRAPPER_BASE_DIR Apple-lib working dir\n"
|
||||
" WRAPPER_DEVICE_INFO 9-tuple device identifier\n"
|
||||
" WRAPPER_APPLE_INIT set to 0 to skip Apple lib init\n"
|
||||
" WRAPPER_RESTORE_SESSION set to 0 to skip session restore\n"
|
||||
" WRAPPER_APPLE_ID optional /me label after restore\n"
|
||||
" WRAPPER_USERNAME Apple ID for env auto-login (+WRAPPER_PASSWORD)\n"
|
||||
" WRAPPER_PASSWORD app password for env auto-login\n",
|
||||
kVersion, argv[0], kDefaultHost, kDefaultPort, kDefaultWorkerPort);
|
||||
return false;
|
||||
}
|
||||
std::fprintf(stderr,
|
||||
"wrapper-v2: unexpected argument '%s' "
|
||||
"(use environment variables; try '%s --help')\n",
|
||||
argv[i], argv[0]);
|
||||
std::exit(2);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void maybe_auto_login_from_env(wrapper::apple::Account& account,
|
||||
const wrapper::apple::Loader& loader,
|
||||
const wrapper::apple::Runtime& runtime,
|
||||
bool apple_init_enabled) {
|
||||
if (!apple_init_enabled || !runtime.initialized()) {
|
||||
return;
|
||||
}
|
||||
std::string user = env_or("WRAPPER_USERNAME", "");
|
||||
const char* pw = std::getenv("WRAPPER_PASSWORD");
|
||||
if (user.empty() || pw == nullptr || *pw == '\0') {
|
||||
if ((pw != nullptr && *pw != '\0') && user.empty()) {
|
||||
std::fprintf(stderr,
|
||||
"wrapper-v2: WRAPPER_PASSWORD is set but WRAPPER_USERNAME "
|
||||
"is missing; skipping env auto-login\n");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (account.state() == wrapper::apple::LoginState::Authenticated) {
|
||||
return;
|
||||
}
|
||||
std::string password(pw);
|
||||
if (!account.start_login(loader, runtime, std::move(user), std::move(password))) {
|
||||
std::fprintf(stderr,
|
||||
"wrapper-v2: env auto-login could not start (state=%s)\n",
|
||||
wrapper::apple::to_string(account.state()));
|
||||
return;
|
||||
}
|
||||
auto st = account.wait_for_settled_state(std::chrono::milliseconds(30000));
|
||||
if (st == wrapper::apple::LoginState::Authenticated) {
|
||||
std::fprintf(stderr, "wrapper-v2: env auto-login succeeded\n");
|
||||
} else if (st == wrapper::apple::LoginState::Awaiting2FA) {
|
||||
std::fprintf(stderr,
|
||||
"wrapper-v2: env auto-login needs 2FA; POST /login/2fa with "
|
||||
"{\"code\":\"...\"}\n");
|
||||
} else if (st == wrapper::apple::LoginState::Failed) {
|
||||
auto snap = account.public_snapshot();
|
||||
std::fprintf(stderr, "wrapper-v2: env auto-login failed: %s (code %d)\n",
|
||||
snap.last_error.c_str(), snap.last_error_code);
|
||||
} else {
|
||||
std::fprintf(stderr,
|
||||
"wrapper-v2: env auto-login still in progress or timed out "
|
||||
"(state=%s)\n",
|
||||
wrapper::apple::to_string(st));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
// Force unbuffered stderr so any prints made before a SIGSEGV are visible.
|
||||
// Bionic typically does this already, but be defensive — we have lost
|
||||
// diagnostics to flushing before, especially on aarch64 where DT_INIT
|
||||
// crashes during dlopen kill the process before fclose() runs.
|
||||
std::setvbuf(stderr, nullptr, _IONBF, 0);
|
||||
|
||||
std::fprintf(stderr,
|
||||
"wrapper-v2: daemon starting (argv0=%s, pid=%ld)\n",
|
||||
argc > 0 ? argv[0] : "?", static_cast<long>(getpid()));
|
||||
|
||||
ProgramMode mode = ProgramMode::Supervisor;
|
||||
if (!consume_argv(argc, argv, &mode)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::string listen_host = env_or("WRAPPER_HOST", kDefaultHost);
|
||||
int listen_port = env_int("WRAPPER_PORT", kDefaultPort);
|
||||
const int worker_port = env_int("WRAPPER_WORKER_PORT", kDefaultWorkerPort);
|
||||
|
||||
std::signal(SIGINT, on_signal);
|
||||
std::signal(SIGTERM, on_signal);
|
||||
std::signal(SIGPIPE, SIG_IGN);
|
||||
|
||||
// Install crash handler for a single stderr line with fault_addr + PC/RIP.
|
||||
{
|
||||
struct sigaction sa{};
|
||||
sa.sa_sigaction = on_crash;
|
||||
sa.sa_flags = SA_SIGINFO | SA_RESETHAND;
|
||||
sigaction(SIGSEGV, &sa, nullptr);
|
||||
sigaction(SIGABRT, &sa, nullptr);
|
||||
}
|
||||
|
||||
if (mode == ProgramMode::Supervisor) {
|
||||
wrapper::Supervisor supervisor(argc > 0 ? argv[0] : "/system/bin/main",
|
||||
kVersion, worker_port);
|
||||
|
||||
httplib::Server svr;
|
||||
g_server.store(&svr);
|
||||
supervisor.mount(svr);
|
||||
|
||||
std::fprintf(stderr,
|
||||
"wrapper-v2: %s supervisor listening on %s:%d "
|
||||
"(worker 127.0.0.1:%d)\n",
|
||||
kVersion, listen_host.c_str(), listen_port, worker_port);
|
||||
|
||||
if (!svr.listen(listen_host, listen_port)) {
|
||||
std::fprintf(stderr, "wrapper-v2: supervisor bind failed on %s:%d\n",
|
||||
listen_host.c_str(), listen_port);
|
||||
return 1;
|
||||
}
|
||||
supervisor.stop_worker();
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::fprintf(stderr, "wrapper-v2: worker mode starting\n");
|
||||
|
||||
wrapper::ServerInfo info;
|
||||
info.version = kVersion;
|
||||
info.apple_init_enabled = env_bool("WRAPPER_APPLE_INIT", true);
|
||||
|
||||
auto& account = wrapper::apple::Account::instance();
|
||||
wrapper::apple::Loader loader;
|
||||
auto& runtime = wrapper::apple::Runtime::instance();
|
||||
|
||||
std::string libs_dir = env_or("WRAPPER_LIBS_DIR", "/system/lib64");
|
||||
|
||||
if (info.apple_init_enabled) {
|
||||
std::fprintf(stderr, "wrapper-v2: opening Apple libs from %s\n",
|
||||
libs_dir.c_str());
|
||||
if (loader.open(libs_dir)) {
|
||||
wrapper::apple::RuntimeConfig rcfg;
|
||||
rcfg.base_dir = env_or("WRAPPER_BASE_DIR", rcfg.base_dir);
|
||||
rcfg.device_info = env_or("WRAPPER_DEVICE_INFO", rcfg.device_info);
|
||||
if (runtime.initialize(loader, rcfg)) {
|
||||
if (env_bool("WRAPPER_RESTORE_SESSION", true)) {
|
||||
const bool restored =
|
||||
account.try_restore_cached_session(loader, runtime);
|
||||
if (restored) {
|
||||
std::fprintf(stderr,
|
||||
"wrapper-v2: session restored from Apple cache "
|
||||
"(GET /me without POST /login)\n");
|
||||
}
|
||||
} else {
|
||||
std::fprintf(stderr,
|
||||
"wrapper-v2: WRAPPER_RESTORE_SESSION=0, skipping "
|
||||
"cached session probe\n");
|
||||
}
|
||||
} else {
|
||||
std::fprintf(stderr,
|
||||
"wrapper-v2: Apple runtime init failed after dlopen "
|
||||
"succeeded; the loaded libs may be from an unexpected "
|
||||
"Apple Music native library version. Continuing in "
|
||||
"stub mode.\n");
|
||||
}
|
||||
} else {
|
||||
std::fprintf(stderr,
|
||||
"wrapper-v2: Apple lib load failed (libs_dir=%s, error=%s). "
|
||||
"Continuing in stub mode; HTTP API works but Apple-backed "
|
||||
"endpoints will return 503.\n",
|
||||
libs_dir.c_str(), loader.last_error().c_str());
|
||||
}
|
||||
} else {
|
||||
std::fprintf(stderr,
|
||||
"wrapper-v2: WRAPPER_APPLE_INIT=0, skipping Apple lib init "
|
||||
"(stub mode: /health only; POST /login returns 503)\n");
|
||||
}
|
||||
|
||||
maybe_auto_login_from_env(account, loader, runtime, info.apple_init_enabled);
|
||||
|
||||
httplib::Server svr;
|
||||
g_server.store(&svr);
|
||||
|
||||
wrapper::Server server(svr, runtime, loader, account, info);
|
||||
server.mount();
|
||||
|
||||
std::fprintf(stderr, "wrapper-v2: %s worker listening on %s:%d\n", kVersion,
|
||||
listen_host.c_str(), listen_port);
|
||||
|
||||
if (!svr.listen(listen_host, listen_port)) {
|
||||
std::fprintf(stderr, "wrapper-v2: bind failed on %s:%d\n",
|
||||
listen_host.c_str(), listen_port);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
#include "server.hpp"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <exception>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "apple/decrypt.hpp"
|
||||
#include "apple/playback.hpp"
|
||||
|
||||
namespace wrapper {
|
||||
|
||||
namespace {
|
||||
|
||||
using nlohmann::json;
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
// How long POST /login is willing to block waiting for AuthenticateFlow
|
||||
// to settle to a terminal state (Authenticated/Failed) or to Awaiting2FA.
|
||||
// Apple's flow normally completes well within a couple of seconds when
|
||||
// no 2FA is needed; the credentialHandler dispatch is what controls
|
||||
// when this returns.
|
||||
constexpr auto kLoginTimeout = 30s;
|
||||
|
||||
// How long POST /login/2fa is willing to block after submitting the
|
||||
// code. The flow has to make a network round-trip back to Apple.
|
||||
constexpr auto kLogin2faTimeout = 60s;
|
||||
constexpr auto kDecryptWatchdogTimeout = 45s;
|
||||
constexpr int kRestartExitCode = 75;
|
||||
|
||||
void respond_json(httplib::Response& res, int status, json body) {
|
||||
res.status = status;
|
||||
res.set_content(body.dump(), "application/json");
|
||||
}
|
||||
|
||||
void access_log(const char* method, const httplib::Request& req) {
|
||||
const std::string& peer = req.remote_addr;
|
||||
const char* p = peer.empty() ? "-" : peer.c_str();
|
||||
std::fprintf(stderr, "http: %s %s client=%s\n", method, req.path.c_str(), p);
|
||||
}
|
||||
|
||||
void schedule_process_restart(std::string reason) {
|
||||
std::thread([reason = std::move(reason)] {
|
||||
std::this_thread::sleep_for(750ms);
|
||||
std::fprintf(stderr, "wrapper-v2: hard restart requested: %s\n", reason.c_str());
|
||||
std::fflush(stderr);
|
||||
std::_Exit(kRestartExitCode);
|
||||
}).detach();
|
||||
}
|
||||
|
||||
std::shared_ptr<std::atomic_bool> start_decrypt_watchdog() {
|
||||
auto done = std::make_shared<std::atomic_bool>(false);
|
||||
std::thread([done] {
|
||||
std::this_thread::sleep_for(kDecryptWatchdogTimeout);
|
||||
if (done->load(std::memory_order_acquire)) return;
|
||||
std::fprintf(stderr, "wrapper-v2: hard restart requested: POST /decrypt watchdog timeout\n");
|
||||
std::fflush(stderr);
|
||||
std::_Exit(kRestartExitCode);
|
||||
}).detach();
|
||||
return done;
|
||||
}
|
||||
|
||||
std::string iso8601_utc(std::chrono::system_clock::time_point tp) {
|
||||
if (tp.time_since_epoch().count() == 0) return {};
|
||||
auto t = std::chrono::system_clock::to_time_t(tp);
|
||||
char buf[32];
|
||||
std::tm tm_utc{};
|
||||
#if defined(_WIN32)
|
||||
gmtime_s(&tm_utc, &t);
|
||||
#else
|
||||
gmtime_r(&t, &tm_utc);
|
||||
#endif
|
||||
std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm_utc);
|
||||
return buf;
|
||||
}
|
||||
|
||||
json snapshot_to_json(const apple::AccountSnapshot& snap) {
|
||||
json out;
|
||||
out["state"] = apple::to_string(snap.state);
|
||||
if (!snap.apple_id.empty()) {
|
||||
out["apple_id"] = snap.apple_id;
|
||||
out["username"] = snap.apple_id;
|
||||
}
|
||||
if (snap.state == apple::LoginState::Authenticated) {
|
||||
if (!snap.storefront.empty()) out["storefront"] = snap.storefront;
|
||||
if (!snap.dsid.empty()) out["dsid"] = snap.dsid;
|
||||
if (!snap.music_user_token.empty()) out["music_user_token"] = snap.music_user_token;
|
||||
if (!snap.dev_token.empty()) out["dev_token"] = snap.dev_token;
|
||||
out["logged_in_at"] = iso8601_utc(snap.logged_in_at);
|
||||
}
|
||||
if (snap.state == apple::LoginState::Failed) {
|
||||
if (!snap.last_error.empty()) out["error"] = snap.last_error;
|
||||
if (snap.last_error_code != 0) out["error_code"] = snap.last_error_code;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
json runtime_to_json(const apple::Loader& loader,
|
||||
const apple::Runtime& rt,
|
||||
const ServerInfo& info) {
|
||||
json runtime = {
|
||||
{"apple_init_enabled", info.apple_init_enabled},
|
||||
{"loader_ok", loader.ok()},
|
||||
{"initialized", rt.initialized()},
|
||||
{"playback_ready", rt.playback_ready()},
|
||||
};
|
||||
if (!loader.ok() && !loader.last_error().empty()) {
|
||||
runtime["loader_error"] = loader.last_error();
|
||||
}
|
||||
if (rt.initialized()) {
|
||||
runtime["base_dir"] = rt.base_dir();
|
||||
runtime["device_info"] = rt.device_info();
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
bool restore_session_enabled() {
|
||||
const char* v = std::getenv("WRAPPER_RESTORE_SESSION");
|
||||
if (v == nullptr || *v == '\0') return true;
|
||||
return std::strcmp(v, "0") != 0
|
||||
&& std::strcmp(v, "false") != 0
|
||||
&& std::strcmp(v, "no") != 0;
|
||||
}
|
||||
|
||||
bool authenticated_or_restored(apple::Account& account,
|
||||
const apple::Loader& loader,
|
||||
const apple::Runtime& rt) {
|
||||
if (account.state() == apple::LoginState::Authenticated) {
|
||||
return true;
|
||||
}
|
||||
if (!restore_session_enabled()) {
|
||||
return false;
|
||||
}
|
||||
return account.try_restore_cached_session(loader, rt)
|
||||
|| account.state() == apple::LoginState::Authenticated;
|
||||
}
|
||||
|
||||
int http_status_for(apple::LoginState s) {
|
||||
switch (s) {
|
||||
case apple::LoginState::Authenticated: return 200;
|
||||
case apple::LoginState::Awaiting2FA: return 202;
|
||||
case apple::LoginState::Failed: return 401;
|
||||
case apple::LoginState::InProgress: return 504;
|
||||
case apple::LoginState::LoggedOut: return 400;
|
||||
}
|
||||
return 500;
|
||||
}
|
||||
|
||||
std::uint32_t read_u32_be(const std::uint8_t* p) {
|
||||
return (static_cast<std::uint32_t>(p[0]) << 24)
|
||||
| (static_cast<std::uint32_t>(p[1]) << 16)
|
||||
| (static_cast<std::uint32_t>(p[2]) << 8)
|
||||
| static_cast<std::uint32_t>(p[3]);
|
||||
}
|
||||
|
||||
void append_u32_be(std::string* out, std::uint32_t v) {
|
||||
out->push_back(static_cast<char>((v >> 24) & 0xff));
|
||||
out->push_back(static_cast<char>((v >> 16) & 0xff));
|
||||
out->push_back(static_cast<char>((v >> 8) & 0xff));
|
||||
out->push_back(static_cast<char>(v & 0xff));
|
||||
}
|
||||
|
||||
struct SampleDecryptFrame {
|
||||
std::string adam_id;
|
||||
std::string uri;
|
||||
std::vector<std::vector<std::uint8_t>> samples;
|
||||
};
|
||||
|
||||
bool parse_sample_decrypt_frame(const std::string& body,
|
||||
SampleDecryptFrame* out,
|
||||
std::string* error) {
|
||||
constexpr std::uint32_t kMaxSamples = 100000;
|
||||
constexpr std::uint32_t kMaxFieldLen = 1024 * 1024;
|
||||
constexpr std::uint64_t kMaxBodyLen = 256ull * 1024ull * 1024ull;
|
||||
|
||||
const auto* data = reinterpret_cast<const std::uint8_t*>(body.data());
|
||||
const std::size_t size = body.size();
|
||||
if (size > kMaxBodyLen) {
|
||||
*error = "request body too large";
|
||||
return false;
|
||||
}
|
||||
if (size < 12) {
|
||||
*error = "frame too short";
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::uint32_t adam_len = read_u32_be(data);
|
||||
const std::uint32_t uri_len = read_u32_be(data + 4);
|
||||
const std::uint32_t sample_count = read_u32_be(data + 8);
|
||||
if (adam_len == 0 || uri_len == 0) {
|
||||
*error = "adam_id and uri must be non-empty";
|
||||
return false;
|
||||
}
|
||||
if (adam_len > kMaxFieldLen || uri_len > kMaxFieldLen) {
|
||||
*error = "adam_id or uri is too large";
|
||||
return false;
|
||||
}
|
||||
if (sample_count == 0 || sample_count > kMaxSamples) {
|
||||
*error = "sample_count must be between 1 and 100000";
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::uint64_t table_bytes = static_cast<std::uint64_t>(sample_count) * 4u;
|
||||
const std::uint64_t fixed = 12ull + table_bytes + adam_len + uri_len;
|
||||
if (fixed > size) {
|
||||
*error = "frame length table exceeds request body";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<std::uint32_t> lengths;
|
||||
lengths.reserve(sample_count);
|
||||
std::uint64_t sample_bytes = 0;
|
||||
const std::uint8_t* lenp = data + 12;
|
||||
for (std::uint32_t i = 0; i < sample_count; ++i) {
|
||||
const std::uint32_t n = read_u32_be(lenp + (static_cast<std::size_t>(i) * 4u));
|
||||
if (n == 0) {
|
||||
*error = "sample length must be non-zero";
|
||||
return false;
|
||||
}
|
||||
sample_bytes += n;
|
||||
if (sample_bytes > kMaxBodyLen) {
|
||||
*error = "sample payload too large";
|
||||
return false;
|
||||
}
|
||||
lengths.push_back(n);
|
||||
}
|
||||
if (fixed + sample_bytes != size) {
|
||||
*error = "frame size does not match declared lengths";
|
||||
return false;
|
||||
}
|
||||
|
||||
const char* p = body.data() + 12 + static_cast<std::size_t>(table_bytes);
|
||||
out->adam_id.assign(p, adam_len);
|
||||
p += adam_len;
|
||||
out->uri.assign(p, uri_len);
|
||||
p += uri_len;
|
||||
|
||||
out->samples.clear();
|
||||
out->samples.reserve(sample_count);
|
||||
for (std::uint32_t n : lengths) {
|
||||
const auto* b = reinterpret_cast<const std::uint8_t*>(p);
|
||||
out->samples.emplace_back(b, b + n);
|
||||
p += n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool build_sample_decrypt_frame(const std::vector<std::vector<std::uint8_t>>& samples,
|
||||
std::string* out) {
|
||||
if (samples.size() > 0xffffffffu) return false;
|
||||
std::uint64_t size = 4ull + (static_cast<std::uint64_t>(samples.size()) * 4ull);
|
||||
for (const auto& sample : samples) {
|
||||
if (sample.size() > 0xffffffffu) return false;
|
||||
size += sample.size();
|
||||
}
|
||||
if (size > static_cast<std::uint64_t>(out->max_size())) return false;
|
||||
|
||||
out->clear();
|
||||
out->reserve(static_cast<std::size_t>(size));
|
||||
append_u32_be(out, static_cast<std::uint32_t>(samples.size()));
|
||||
for (const auto& sample : samples) {
|
||||
append_u32_be(out, static_cast<std::uint32_t>(sample.size()));
|
||||
}
|
||||
for (const auto& sample : samples) {
|
||||
out->append(reinterpret_cast<const char*>(sample.data()), sample.size());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Server::Server(httplib::Server& svr,
|
||||
apple::Runtime& rt,
|
||||
apple::Loader& loader,
|
||||
apple::Account& account,
|
||||
ServerInfo info)
|
||||
: svr_(svr), rt_(rt), loader_(loader), account_(account), info_(std::move(info)) {}
|
||||
|
||||
void Server::mount() {
|
||||
// ---- GET /health ----
|
||||
// Liveness + runtime debug info. Always returns 200 if the
|
||||
// process is up; consumers should treat runtime.initialized==false
|
||||
// as a soft failure (auth/decrypt endpoints won't work) rather than a hard one.
|
||||
svr_.Get("/health", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
access_log("GET", req);
|
||||
json runtime = {
|
||||
{"apple_init_enabled", info_.apple_init_enabled},
|
||||
{"loader_ok", loader_.ok()},
|
||||
{"initialized", rt_.initialized()},
|
||||
{"playback_ready", rt_.playback_ready()},
|
||||
};
|
||||
if (!loader_.ok() && !loader_.last_error().empty()) {
|
||||
runtime["loader_error"] = loader_.last_error();
|
||||
}
|
||||
if (rt_.initialized()) {
|
||||
runtime["base_dir"] = rt_.base_dir();
|
||||
runtime["device_info"] = rt_.device_info();
|
||||
}
|
||||
respond_json(res, 200, json{
|
||||
{"status", "ok"},
|
||||
{"version", info_.version},
|
||||
{"runtime", std::move(runtime)},
|
||||
});
|
||||
});
|
||||
|
||||
// ---- GET /me ----
|
||||
// Combined daemon snapshot: version, runtime probe (same facts as
|
||||
// /health.runtime), and auth (Apple ID state + harvested tokens after
|
||||
// a successful POST /login). iTunes account-token / X-Token are NOT
|
||||
// exposed — only dev_token, music_user_token, storefront, dsid.
|
||||
svr_.Get("/me", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
access_log("GET", req);
|
||||
if (rt_.initialized() && restore_session_enabled()) {
|
||||
(void)authenticated_or_restored(account_, loader_, rt_);
|
||||
}
|
||||
json body = {
|
||||
{"version", info_.version},
|
||||
{"runtime", runtime_to_json(loader_, rt_, info_)},
|
||||
{"auth", snapshot_to_json(account_.public_snapshot())},
|
||||
};
|
||||
respond_json(res, 200, std::move(body));
|
||||
});
|
||||
|
||||
// ---- POST /login ----
|
||||
// Body: { "username": "...", "password": "..." }
|
||||
// or { "apple_id": "...", "password": "..." } (synonyms)
|
||||
// Returns:
|
||||
// 200 if AuthenticateFlow completed (state=authenticated, tokens present)
|
||||
// 202 if Apple asked for HSA2 (state=awaiting_2fa) - follow up with
|
||||
// POST /login/2fa
|
||||
// 401 if Apple rejected credentials (state=failed)
|
||||
// 409 if a login is already in progress
|
||||
// 503 if the runtime is not initialized
|
||||
// 504 if the flow has not produced any state inside kLoginTimeout
|
||||
svr_.Post("/login", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
access_log("POST", req);
|
||||
if (!rt_.initialized()) {
|
||||
respond_json(res, 503, json{
|
||||
{"error", "runtime_not_initialized"},
|
||||
{"detail", "Apple lib init has not completed; check /health"},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
json body;
|
||||
try {
|
||||
body = json::parse(req.body);
|
||||
} catch (const std::exception& e) {
|
||||
respond_json(res, 400, json{{"error", "invalid_json"}, {"detail", e.what()}});
|
||||
return;
|
||||
}
|
||||
if (!body.is_object() || !body.contains("password")
|
||||
|| !body["password"].is_string()) {
|
||||
respond_json(res, 400, json{
|
||||
{"error", "missing_field"},
|
||||
{"detail", "expected JSON object with string 'password' and "
|
||||
"'username' or 'apple_id'"},
|
||||
});
|
||||
return;
|
||||
}
|
||||
std::string login_name;
|
||||
const bool has_apple =
|
||||
body.contains("apple_id") && body["apple_id"].is_string();
|
||||
const bool has_user =
|
||||
body.contains("username") && body["username"].is_string();
|
||||
if (has_apple && has_user) {
|
||||
std::string a = body["apple_id"].get<std::string>();
|
||||
std::string u = body["username"].get<std::string>();
|
||||
if (a != u) {
|
||||
respond_json(res, 400, json{
|
||||
{"error", "conflicting_identifiers"},
|
||||
{"detail", "'username' and 'apple_id' must match if both are sent"},
|
||||
});
|
||||
return;
|
||||
}
|
||||
login_name = std::move(a);
|
||||
} else if (has_apple) {
|
||||
login_name = body["apple_id"].get<std::string>();
|
||||
} else if (has_user) {
|
||||
login_name = body["username"].get<std::string>();
|
||||
} else {
|
||||
respond_json(res, 400, json{
|
||||
{"error", "missing_field"},
|
||||
{"detail", "expected 'username' or 'apple_id' string"},
|
||||
});
|
||||
return;
|
||||
}
|
||||
std::string password = body["password"].get<std::string>();
|
||||
if (login_name.empty() || password.empty()) {
|
||||
respond_json(res, 400, json{
|
||||
{"error", "empty_field"},
|
||||
{"detail", "username/apple_id and password must be non-empty"},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!account_.start_login(loader_, rt_, std::move(login_name), std::move(password))) {
|
||||
respond_json(res, 409, json{
|
||||
{"error", "already_in_progress"},
|
||||
{"detail", "a login flow is already running; DELETE /login to abort"},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
auto state = account_.wait_for_settled_state(kLoginTimeout);
|
||||
respond_json(res, http_status_for(state), snapshot_to_json(account_.public_snapshot()));
|
||||
});
|
||||
|
||||
// ---- POST /login/2fa ----
|
||||
// Body: { "code": "123456" }
|
||||
// Returns 200 / 401 / 409 / 504 with the same shape as /login.
|
||||
svr_.Post("/login/2fa", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
access_log("POST", req);
|
||||
json body;
|
||||
try {
|
||||
body = json::parse(req.body);
|
||||
} catch (const std::exception& e) {
|
||||
respond_json(res, 400, json{{"error", "invalid_json"}, {"detail", e.what()}});
|
||||
return;
|
||||
}
|
||||
if (!body.is_object() || !body.contains("code") || !body["code"].is_string()) {
|
||||
respond_json(res, 400, json{
|
||||
{"error", "missing_field"},
|
||||
{"detail", "expected JSON object with string 'code'"},
|
||||
});
|
||||
return;
|
||||
}
|
||||
std::string code = body["code"].get<std::string>();
|
||||
if (code.empty()) {
|
||||
respond_json(res, 400, json{
|
||||
{"error", "empty_code"},
|
||||
{"detail", "code must be non-empty"},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!account_.submit_2fa(std::move(code))) {
|
||||
respond_json(res, 409, json{
|
||||
{"error", "not_awaiting_2fa"},
|
||||
{"detail", "no login is currently waiting for a 2FA code; "
|
||||
"start one with POST /login"},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
auto state = account_.wait_for_settled_state(kLogin2faTimeout);
|
||||
respond_json(res, http_status_for(state), snapshot_to_json(account_.public_snapshot()));
|
||||
});
|
||||
|
||||
// ---- GET /playback ----
|
||||
// Returns Apple's full MZ-protocol playback dispatch as native JSON
|
||||
// (the CFDictionary plist tree walked into nlohmann::json: dict ->
|
||||
// object, array -> array, string/number/bool -> matching JSON types,
|
||||
// CFData -> base64 string, CFDate -> ISO 8601). Driven by
|
||||
// storeservicescore::PurchaseRequest with urlBagKey="subDownload"
|
||||
// (matching upstream wrapper's get_m3u8_method_download). Unlike
|
||||
// upstream which extracts just the last asset's URL, we hand back
|
||||
// every flavor / key URI / metadata field Apple included.
|
||||
svr_.Get("/playback", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
access_log("GET", req);
|
||||
if (!rt_.initialized()) {
|
||||
respond_json(res, 503, json{
|
||||
{"error", "runtime_not_initialized"},
|
||||
{"detail", "Apple lib init has not completed; check /health"},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!authenticated_or_restored(account_, loader_, rt_)) {
|
||||
respond_json(res, 401, json{
|
||||
{"error", "not_authenticated"},
|
||||
{"detail", "POST /login or restore a session first"},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
std::string adam_id;
|
||||
if (req.has_param("adam_id")) {
|
||||
adam_id = req.get_param_value("adam_id");
|
||||
} else if (req.has_param("adamId")) {
|
||||
adam_id = req.get_param_value("adamId");
|
||||
} else {
|
||||
respond_json(res, 400, json{
|
||||
{"error", "missing_field"},
|
||||
{"detail", "expected query string '?adam_id=<numeric store id>'"},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (adam_id.empty()) {
|
||||
respond_json(res, 400, json{
|
||||
{"error", "empty_field"},
|
||||
{"detail", "adam_id must be non-empty"},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
apple::PlaybackResult pr;
|
||||
{
|
||||
// PurchaseRequest drives Apple's URLBag dispatcher; same global
|
||||
// state /decrypt touches, so share the playback mutex to avoid
|
||||
// overlap with a concurrent decrypt request.
|
||||
std::lock_guard<std::mutex> lock(rt_.playback_mutex());
|
||||
pr = apple::fetch_playback_json(loader_, rt_, std::move(adam_id));
|
||||
}
|
||||
|
||||
if (!pr.ok) {
|
||||
respond_json(res, 502, json{
|
||||
{"error", "playback_dispatch_failed"},
|
||||
{"detail", pr.error},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
respond_json(res, 200, std::move(pr.body));
|
||||
});
|
||||
|
||||
// ---- POST /decrypt ----
|
||||
// FairPlay sample decrypt. Binary request:
|
||||
// u32be adam_id_len, u32be uri_len, u32be sample_count,
|
||||
// u32be sample_len[sample_count], adam_id bytes, uri bytes,
|
||||
// concatenated ciphertext samples.
|
||||
// Binary response:
|
||||
// u32be sample_count, u32be sample_len[sample_count],
|
||||
// concatenated plaintext samples.
|
||||
// Requires authenticated + playback_ready.
|
||||
svr_.Post("/decrypt", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
access_log("POST", req);
|
||||
if (!rt_.initialized()) {
|
||||
respond_json(res, 503, json{
|
||||
{"error", "runtime_not_initialized"},
|
||||
{"detail", "Apple lib init has not completed; check /health"},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!rt_.playback_ready()) {
|
||||
respond_json(res, 503, json{
|
||||
{"error", "decrypt_unavailable"},
|
||||
{"detail", "FairPlay playback stack did not initialize; check /health.runtime"},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!authenticated_or_restored(account_, loader_, rt_)) {
|
||||
respond_json(res, 401, json{
|
||||
{"error", "not_authenticated"},
|
||||
{"detail", "POST /login or restore a session first"},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
SampleDecryptFrame frame;
|
||||
std::string parse_error;
|
||||
if (!parse_sample_decrypt_frame(req.body, &frame, &parse_error)) {
|
||||
respond_json(res, 400, json{{"error", "invalid_frame"}, {"detail", parse_error}});
|
||||
return;
|
||||
}
|
||||
|
||||
auto decrypt_done = start_decrypt_watchdog();
|
||||
apple::DecryptResult dr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(rt_.playback_mutex());
|
||||
dr = apple::decrypt_samples(loader_, rt_, std::move(frame.adam_id), std::move(frame.uri),
|
||||
std::move(frame.samples));
|
||||
}
|
||||
decrypt_done->store(true, std::memory_order_release);
|
||||
|
||||
if (!dr.ok) {
|
||||
schedule_process_restart("POST /decrypt FairPlay failure: " + dr.error);
|
||||
respond_json(res, 502, json{
|
||||
{"error", "decrypt_failed"},
|
||||
{"detail", dr.error},
|
||||
{"restarting", true},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
std::string response_body;
|
||||
if (!build_sample_decrypt_frame(dr.plaintexts, &response_body)) {
|
||||
respond_json(res, 500, json{
|
||||
{"error", "response_too_large"},
|
||||
{"detail", "decrypted sample frame is too large"},
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.status = 200;
|
||||
res.set_content(response_body, "application/octet-stream");
|
||||
});
|
||||
|
||||
// ---- DELETE /login ----
|
||||
// Clears in-memory tokens and (if a flow is running) signals the
|
||||
// worker thread to abort. Apple's kvs.sqlitedb cache is NOT
|
||||
// touched; the next POST /login will reuse it if still valid.
|
||||
svr_.Delete("/login", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
access_log("DELETE", req);
|
||||
auto prev = account_.state();
|
||||
account_.logout();
|
||||
respond_json(res, 200, json{
|
||||
{"state", apple::to_string(account_.state())},
|
||||
{"was", apple::to_string(prev)},
|
||||
});
|
||||
});
|
||||
|
||||
// ---- exception fallback ----
|
||||
svr_.set_exception_handler([](const httplib::Request& req, httplib::Response& res,
|
||||
std::exception_ptr ep) {
|
||||
std::string what = "unknown";
|
||||
try {
|
||||
if (ep) std::rethrow_exception(ep);
|
||||
} catch (const std::exception& e) {
|
||||
what = e.what();
|
||||
} catch (...) {
|
||||
}
|
||||
std::fprintf(stderr, "http: exception %s %s: %s\n",
|
||||
req.method.c_str(), req.path.c_str(), what.c_str());
|
||||
respond_json(res, 500, json{{"error", "internal"}, {"detail", what}});
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace wrapper
|
||||
@@ -0,0 +1,48 @@
|
||||
// HTTP server wiring.
|
||||
//
|
||||
// The Server class owns an httplib::Server and mounts the routes
|
||||
// wrapper-v2 exposes. References to the runtime modules are
|
||||
// captured by reference - the Server does not own them.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include <httplib.h>
|
||||
|
||||
#include "apple/auth.hpp"
|
||||
#include "apple/loader.hpp"
|
||||
#include "apple/runtime.hpp"
|
||||
|
||||
namespace wrapper {
|
||||
|
||||
struct ServerInfo {
|
||||
// Free-form version string surfaced via /health and /me.
|
||||
std::string version = "0.0.1";
|
||||
|
||||
// True iff Apple lib initialization is enabled (controlled by
|
||||
// WRAPPER_APPLE_INIT). Surfaced via /health so it is obvious when
|
||||
// the daemon is running in stub-only mode.
|
||||
bool apple_init_enabled = true;
|
||||
};
|
||||
|
||||
class Server {
|
||||
public:
|
||||
Server(httplib::Server& svr,
|
||||
apple::Runtime& rt,
|
||||
apple::Loader& loader,
|
||||
apple::Account& account,
|
||||
ServerInfo info);
|
||||
|
||||
// Mount all routes onto the underlying httplib::Server.
|
||||
void mount();
|
||||
|
||||
private:
|
||||
httplib::Server& svr_;
|
||||
apple::Runtime& rt_;
|
||||
apple::Loader& loader_;
|
||||
apple::Account& account_;
|
||||
ServerInfo info_;
|
||||
};
|
||||
|
||||
} // namespace wrapper
|
||||
@@ -0,0 +1,451 @@
|
||||
#include "supervisor.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <exception>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
|
||||
#include <signal.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
namespace wrapper {
|
||||
|
||||
namespace {
|
||||
|
||||
using nlohmann::json;
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
constexpr const char* kWorkerHost = "127.0.0.1";
|
||||
constexpr int kMaxRecoveryAttempts = 5;
|
||||
constexpr int kMaxConsecutiveWorkerStartFailures = kMaxRecoveryAttempts;
|
||||
constexpr int kSupervisorFatalExitCode = 76;
|
||||
constexpr auto kMaxRecoveryRetryDelay = 8s;
|
||||
|
||||
struct WorkerHttpResponse {
|
||||
bool transport_ok = false;
|
||||
int status = 502;
|
||||
std::string body;
|
||||
std::string content_type = "application/json";
|
||||
std::string error;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
class Supervisor::Impl {
|
||||
public:
|
||||
Impl(std::string argv0, std::string version, int worker_port)
|
||||
: argv0_(std::move(argv0)),
|
||||
version_(std::move(version)),
|
||||
worker_port_(worker_port) {}
|
||||
|
||||
~Impl() {
|
||||
stop_worker();
|
||||
}
|
||||
|
||||
void mount(httplib::Server& svr) {
|
||||
svr.Get("/health", [this](const httplib::Request&, httplib::Response& res) {
|
||||
WorkerHttpResponse wh = request("GET", "/health", "", "", 5s);
|
||||
json body = {
|
||||
{"status", "ok"},
|
||||
{"version", version_},
|
||||
{"mode", "supervisor"},
|
||||
{"worker", {
|
||||
{"reachable", wh.transport_ok},
|
||||
{"status", wh.transport_ok ? wh.status : 0},
|
||||
}},
|
||||
};
|
||||
if (!wh.transport_ok) body["worker"]["error"] = wh.error;
|
||||
res.status = 200;
|
||||
res.set_content(body.dump(), "application/json");
|
||||
});
|
||||
|
||||
svr.Get("/me", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
proxy_response(res, request("GET", req.target, "", "", 10s));
|
||||
});
|
||||
|
||||
svr.Post("/login", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
proxy_response(res, request_with_login_recovery(
|
||||
req.target, req.body,
|
||||
req.get_header_value("Content-Type")));
|
||||
});
|
||||
|
||||
svr.Post("/login/2fa", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
proxy_response(res, request("POST", req.target, req.body,
|
||||
req.get_header_value("Content-Type"), 65s));
|
||||
});
|
||||
|
||||
svr.Delete("/login", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
proxy_response(res, request("DELETE", req.target, "", "", 10s));
|
||||
});
|
||||
|
||||
svr.Get("/playback", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
proxy_response(res, request("GET", req.target, "", "", 45s));
|
||||
});
|
||||
|
||||
svr.Post("/decrypt", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
proxy_response(res, request_with_decrypt_recovery(
|
||||
req.target, req.body,
|
||||
req.get_header_value("Content-Type")));
|
||||
});
|
||||
|
||||
svr.set_exception_handler([](const httplib::Request& req, httplib::Response& res,
|
||||
std::exception_ptr ep) {
|
||||
std::string what = "unknown";
|
||||
try {
|
||||
if (ep) std::rethrow_exception(ep);
|
||||
} catch (const std::exception& e) {
|
||||
what = e.what();
|
||||
} catch (...) {
|
||||
}
|
||||
std::fprintf(stderr, "supervisor: exception %s %s: %s\n",
|
||||
req.method.c_str(), req.path.c_str(), what.c_str());
|
||||
json body = {{"error", "internal"}, {"detail", what}};
|
||||
res.status = 500;
|
||||
res.set_content(body.dump(), "application/json");
|
||||
});
|
||||
}
|
||||
|
||||
void stop_worker() {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
stop_locked();
|
||||
}
|
||||
|
||||
private:
|
||||
bool ensure_started() {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
reap_locked();
|
||||
if (pid_ > 0 && wait_ready_locked(1s)) {
|
||||
return true;
|
||||
}
|
||||
if (pid_ > 0) {
|
||||
stop_locked();
|
||||
}
|
||||
const bool ok = start_locked() && wait_ready_locked(45s);
|
||||
note_worker_start_result_locked(ok);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool restart() {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
stop_locked();
|
||||
const bool ok = start_locked() && wait_ready_locked(45s);
|
||||
note_worker_start_result_locked(ok);
|
||||
return ok;
|
||||
}
|
||||
|
||||
WorkerHttpResponse request(const std::string& method,
|
||||
const std::string& path,
|
||||
const std::string& body,
|
||||
const std::string& content_type,
|
||||
std::chrono::seconds read_timeout) {
|
||||
std::lock_guard<std::mutex> lock(request_mu_);
|
||||
return request_with_transport_recovery_locked(method, path, body, content_type,
|
||||
read_timeout);
|
||||
}
|
||||
|
||||
WorkerHttpResponse request_with_decrypt_recovery(const std::string& path,
|
||||
const std::string& body,
|
||||
const std::string& content_type) {
|
||||
std::lock_guard<std::mutex> lock(request_mu_);
|
||||
WorkerHttpResponse last;
|
||||
for (int attempt = 1; attempt <= kMaxRecoveryAttempts; ++attempt) {
|
||||
last = request_with_transport_recovery_locked("POST", path, body,
|
||||
content_type, 50s);
|
||||
if (last.transport_ok && !should_restart_after_decrypt(last)) {
|
||||
return last;
|
||||
}
|
||||
if (!last.transport_ok || attempt == kMaxRecoveryAttempts) {
|
||||
return last;
|
||||
}
|
||||
|
||||
std::fprintf(stderr,
|
||||
"supervisor: restarting worker after decrypt response "
|
||||
"status=%d (attempt %d/%d)\n",
|
||||
last.status, attempt, kMaxRecoveryAttempts);
|
||||
if (!restart_after_delay("decrypt response", attempt)) {
|
||||
WorkerHttpResponse out;
|
||||
out.error = "worker restart failed after decrypt response";
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
return last;
|
||||
}
|
||||
|
||||
WorkerHttpResponse request_with_login_recovery(const std::string& path,
|
||||
const std::string& body,
|
||||
const std::string& content_type) {
|
||||
std::lock_guard<std::mutex> lock(request_mu_);
|
||||
WorkerHttpResponse last;
|
||||
for (int attempt = 1; attempt <= kMaxRecoveryAttempts; ++attempt) {
|
||||
last = request_with_transport_recovery_locked("POST", path, body,
|
||||
content_type, 35s);
|
||||
if (last.transport_ok && !should_restart_after_login(last)) {
|
||||
return last;
|
||||
}
|
||||
if (!last.transport_ok || attempt == kMaxRecoveryAttempts) {
|
||||
return last;
|
||||
}
|
||||
|
||||
std::fprintf(stderr,
|
||||
"supervisor: restarting worker after login response "
|
||||
"status=%d (attempt %d/%d)\n",
|
||||
last.status, attempt, kMaxRecoveryAttempts);
|
||||
if (!restart_after_delay("login response", attempt)) {
|
||||
WorkerHttpResponse out;
|
||||
out.error = "worker restart failed after login response";
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
return last;
|
||||
}
|
||||
|
||||
WorkerHttpResponse request_with_transport_recovery_locked(
|
||||
const std::string& method,
|
||||
const std::string& path,
|
||||
const std::string& body,
|
||||
const std::string& content_type,
|
||||
std::chrono::seconds read_timeout) {
|
||||
WorkerHttpResponse out;
|
||||
for (int attempt = 1; attempt <= kMaxRecoveryAttempts; ++attempt) {
|
||||
if (!ensure_started()) {
|
||||
out.error = "worker did not become ready";
|
||||
} else {
|
||||
out = request_once(method, path, body, content_type, read_timeout);
|
||||
if (out.transport_ok) return out;
|
||||
}
|
||||
|
||||
if (attempt == kMaxRecoveryAttempts) {
|
||||
return out;
|
||||
}
|
||||
|
||||
std::fprintf(stderr,
|
||||
"supervisor: worker transport failed (%s), restarting "
|
||||
"(attempt %d/%d)\n",
|
||||
out.error.c_str(), attempt, kMaxRecoveryAttempts);
|
||||
const std::string previous_error = out.error;
|
||||
if (!restart_after_delay("transport error", attempt)) {
|
||||
WorkerHttpResponse restart_error;
|
||||
restart_error.error = "worker restart failed after transport error: "
|
||||
+ previous_error;
|
||||
return restart_error;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool restart_after_delay(const char* reason, int failed_attempt) {
|
||||
auto delay = std::chrono::seconds(1 << (failed_attempt - 1));
|
||||
if (delay > kMaxRecoveryRetryDelay) {
|
||||
delay = kMaxRecoveryRetryDelay;
|
||||
}
|
||||
std::fprintf(stderr, "supervisor: waiting %lds before retry after %s\n",
|
||||
static_cast<long>(delay.count()), reason);
|
||||
std::this_thread::sleep_for(delay);
|
||||
return restart();
|
||||
}
|
||||
|
||||
bool start_locked() {
|
||||
if (pid_ > 0) return true;
|
||||
|
||||
pid_t child = fork();
|
||||
if (child < 0) {
|
||||
std::fprintf(stderr, "supervisor: fork worker failed\n");
|
||||
return false;
|
||||
}
|
||||
if (child == 0) {
|
||||
setenv("WRAPPER_HOST", kWorkerHost, 1);
|
||||
setenv("WRAPPER_PORT", std::to_string(worker_port_).c_str(), 1);
|
||||
setenv("WRAPPER_MODE", "worker", 1);
|
||||
execl(argv0_.c_str(), argv0_.c_str(), static_cast<char*>(nullptr));
|
||||
std::fprintf(stderr, "supervisor: exec worker failed: %s\n", argv0_.c_str());
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
pid_ = child;
|
||||
std::fprintf(stderr, "supervisor: started worker pid=%ld port=%d\n",
|
||||
static_cast<long>(pid_), worker_port_);
|
||||
return true;
|
||||
}
|
||||
|
||||
void stop_locked() {
|
||||
if (pid_ <= 0) return;
|
||||
|
||||
const pid_t old = pid_;
|
||||
kill(old, SIGTERM);
|
||||
for (int i = 0; i < 30; ++i) {
|
||||
int status = 0;
|
||||
pid_t r = waitpid(old, &status, WNOHANG);
|
||||
if (r == old) {
|
||||
pid_ = -1;
|
||||
return;
|
||||
}
|
||||
usleep(100000);
|
||||
}
|
||||
|
||||
kill(old, SIGKILL);
|
||||
int status = 0;
|
||||
(void)waitpid(old, &status, 0);
|
||||
pid_ = -1;
|
||||
}
|
||||
|
||||
void reap_locked() {
|
||||
if (pid_ <= 0) return;
|
||||
int status = 0;
|
||||
pid_t r = waitpid(pid_, &status, WNOHANG);
|
||||
if (r == pid_) {
|
||||
std::fprintf(stderr, "supervisor: worker exited status=%d\n", status);
|
||||
pid_ = -1;
|
||||
}
|
||||
}
|
||||
|
||||
bool wait_ready_locked(std::chrono::seconds timeout) {
|
||||
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
reap_locked();
|
||||
if (pid_ <= 0) return false;
|
||||
WorkerHttpResponse r = request_once_unlocked("GET", "/health", "", "", 2s);
|
||||
if (r.transport_ok) return true;
|
||||
usleep(200000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void note_worker_start_result_locked(bool ok) {
|
||||
if (ok) {
|
||||
consecutive_worker_start_failures_ = 0;
|
||||
return;
|
||||
}
|
||||
++consecutive_worker_start_failures_;
|
||||
std::fprintf(stderr,
|
||||
"supervisor: worker start/restart failed (%d/%d)\n",
|
||||
consecutive_worker_start_failures_,
|
||||
kMaxConsecutiveWorkerStartFailures);
|
||||
if (consecutive_worker_start_failures_ >= kMaxConsecutiveWorkerStartFailures) {
|
||||
std::fprintf(stderr,
|
||||
"supervisor: worker failed too many times; exiting supervisor\n");
|
||||
std::fflush(stderr);
|
||||
std::_Exit(kSupervisorFatalExitCode);
|
||||
}
|
||||
}
|
||||
|
||||
WorkerHttpResponse request_once(const std::string& method,
|
||||
const std::string& path,
|
||||
const std::string& body,
|
||||
const std::string& content_type,
|
||||
std::chrono::seconds read_timeout) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
reap_locked();
|
||||
if (pid_ <= 0) {
|
||||
WorkerHttpResponse out;
|
||||
out.error = "worker is not running";
|
||||
return out;
|
||||
}
|
||||
return request_once_unlocked(method, path, body, content_type, read_timeout);
|
||||
}
|
||||
|
||||
WorkerHttpResponse request_once_unlocked(const std::string& method,
|
||||
const std::string& path,
|
||||
const std::string& body,
|
||||
const std::string& content_type,
|
||||
std::chrono::seconds read_timeout) {
|
||||
httplib::Client cli(kWorkerHost, worker_port_);
|
||||
cli.set_connection_timeout(2, 0);
|
||||
cli.set_read_timeout(static_cast<time_t>(read_timeout.count()), 0);
|
||||
cli.set_write_timeout(10, 0);
|
||||
|
||||
auto finish = [](httplib::Result res) {
|
||||
WorkerHttpResponse out;
|
||||
if (!res) {
|
||||
out.error = "httplib error " + std::to_string(static_cast<int>(res.error()));
|
||||
return out;
|
||||
}
|
||||
|
||||
out.transport_ok = true;
|
||||
out.status = res->status;
|
||||
out.body = res->body;
|
||||
auto ct = res->get_header_value("Content-Type");
|
||||
if (!ct.empty()) out.content_type = std::move(ct);
|
||||
return out;
|
||||
};
|
||||
|
||||
if (method == "GET") {
|
||||
return finish(cli.Get(path));
|
||||
}
|
||||
if (method == "DELETE") {
|
||||
return finish(cli.Delete(path));
|
||||
}
|
||||
if (method == "POST") {
|
||||
const std::string ct = content_type.empty() ? "application/octet-stream" : content_type;
|
||||
return finish(cli.Post(path, body, ct));
|
||||
}
|
||||
|
||||
WorkerHttpResponse out;
|
||||
out.error = "unsupported proxy method";
|
||||
return out;
|
||||
}
|
||||
|
||||
static bool should_restart_after_decrypt(const WorkerHttpResponse& r) {
|
||||
if (!r.transport_ok) return true;
|
||||
if (r.status != 502 && r.status != 500 && r.status != 504) return false;
|
||||
if (r.body.find("decrypt_failed") != std::string::npos) return true;
|
||||
if (r.body.find("FairPlay") != std::string::npos) return true;
|
||||
if (r.body.find("CKC") != std::string::npos) return true;
|
||||
if (r.body.find("KDCanProcess") != std::string::npos) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool should_restart_after_login(const WorkerHttpResponse& r) {
|
||||
if (!r.transport_ok) return true;
|
||||
if (r.status != 401) return false;
|
||||
return r.body.find("Apple returned response type 4") != std::string::npos;
|
||||
}
|
||||
|
||||
static void proxy_response(httplib::Response& res, const WorkerHttpResponse& wr) {
|
||||
if (!wr.transport_ok) {
|
||||
res.status = 503;
|
||||
json body = {
|
||||
{"error", "worker_unavailable"},
|
||||
{"detail", wr.error},
|
||||
};
|
||||
res.set_content(body.dump(), "application/json");
|
||||
return;
|
||||
}
|
||||
res.status = wr.status;
|
||||
res.set_content(wr.body, wr.content_type);
|
||||
}
|
||||
|
||||
std::string argv0_;
|
||||
std::string version_;
|
||||
int worker_port_ = 18080;
|
||||
pid_t pid_ = -1;
|
||||
int consecutive_worker_start_failures_ = 0;
|
||||
std::mutex mu_;
|
||||
std::mutex request_mu_;
|
||||
};
|
||||
|
||||
Supervisor::Supervisor(std::string argv0, std::string version, int worker_port)
|
||||
: impl_(new Impl(std::move(argv0), std::move(version), worker_port)) {}
|
||||
|
||||
Supervisor::~Supervisor() {
|
||||
delete impl_;
|
||||
}
|
||||
|
||||
void Supervisor::mount(httplib::Server& svr) {
|
||||
impl_->mount(svr);
|
||||
}
|
||||
|
||||
void Supervisor::stop_worker() {
|
||||
impl_->stop_worker();
|
||||
}
|
||||
|
||||
} // namespace wrapper
|
||||
@@ -0,0 +1,27 @@
|
||||
// Public HTTP supervisor for the private Apple-runtime worker process.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include <httplib.h>
|
||||
|
||||
namespace wrapper {
|
||||
|
||||
class Supervisor {
|
||||
public:
|
||||
Supervisor(std::string argv0, std::string version, int worker_port);
|
||||
~Supervisor();
|
||||
|
||||
Supervisor(const Supervisor&) = delete;
|
||||
Supervisor& operator=(const Supervisor&) = delete;
|
||||
|
||||
void mount(httplib::Server& svr);
|
||||
void stop_worker();
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
Impl* impl_;
|
||||
};
|
||||
|
||||
} // namespace wrapper
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* wrapper-v2 chroot launcher.
|
||||
*
|
||||
* Sets up a minimal chroot inside ./rootfs/ that exposes the Android dynamic
|
||||
* linker (linker64) and Apple Music's native libs to a daemon binary at
|
||||
* /system/bin/main, then execs the daemon.
|
||||
*
|
||||
* Layout (/system/bin/linker64, /system/lib64) is the same for x86_64 and
|
||||
* arm64-v8a rootfs trees; the host binary must match the image arch (see Dockerfile).
|
||||
*
|
||||
* This is the host-Linux-side launcher. It is intentionally tiny and has no
|
||||
* dependency on the daemon's HTTP code or on the Apple libs.
|
||||
*
|
||||
* Differences vs upstream wrapper.c:
|
||||
* - No gengetopt; the daemon reads configuration from the environment
|
||||
* (WRAPPER_*). The launcher forwards argv unchanged (use --help only).
|
||||
* - --base-dir handling is handled by the daemon itself; the launcher
|
||||
* mkdir -p's WRAPPER_BASE_DIR (or the same default path as the daemon)
|
||||
* plus mpl_db before exec.
|
||||
* - SIGTERM is forwarded to the daemon (upstream only handled SIGINT).
|
||||
*/
|
||||
|
||||
#define _GNU_SOURCE
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <sched.h>
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/mount.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
extern char** environ;
|
||||
|
||||
#define CAP_SYS_ADMIN_IDX 21
|
||||
#define CAP_SYS_ADMIN_BIT (1ULL << CAP_SYS_ADMIN_IDX)
|
||||
|
||||
#define ROOTFS "./rootfs"
|
||||
#define DAEMON_PATH "/system/bin/main"
|
||||
#define LINKER_PATH "/system/bin/linker64"
|
||||
|
||||
/* Same default as daemon RuntimeConfig (runtime.hpp). */
|
||||
static const char k_default_base_dir[] = "/data/data/com.apple.android.music/files";
|
||||
|
||||
static volatile pid_t g_child = -1;
|
||||
|
||||
static void on_signal(int sig) {
|
||||
if (g_child > 0) {
|
||||
kill(g_child, sig);
|
||||
}
|
||||
}
|
||||
|
||||
static int has_cap_sys_admin(void) {
|
||||
FILE* fp = fopen("/proc/self/status", "r");
|
||||
if (!fp) return 0;
|
||||
|
||||
char line[256];
|
||||
int found = 0;
|
||||
while (fgets(line, sizeof(line), fp)) {
|
||||
if (strncmp(line, "CapEff:", 7) == 0) {
|
||||
char* p = line + 7;
|
||||
while (*p == ' ' || *p == '\t') ++p;
|
||||
unsigned long long cap = strtoull(p, NULL, 16);
|
||||
found = (cap & CAP_SYS_ADMIN_BIT) ? 1 : 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose(fp);
|
||||
return found;
|
||||
}
|
||||
|
||||
static int ensure_dir(const char* path, mode_t mode) {
|
||||
if (mkdir(path, mode) == 0) return 0;
|
||||
if (errno == EEXIST) return 0;
|
||||
fprintf(stderr, "wrapper: mkdir %s: %s\n", path, strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int ensure_dir_p(const char* path, mode_t mode) {
|
||||
if (path == NULL || *path == '\0') return -1;
|
||||
char buf[1024];
|
||||
size_t n = strlen(path);
|
||||
if (n >= sizeof(buf)) {
|
||||
fprintf(stderr, "wrapper: path too long for mkdir -p\n");
|
||||
return -1;
|
||||
}
|
||||
memcpy(buf, path, n + 1);
|
||||
for (char* p = buf + 1; *p; ++p) {
|
||||
if (*p == '/') {
|
||||
*p = '\0';
|
||||
if (ensure_dir(buf, mode) != 0) return -1;
|
||||
*p = '/';
|
||||
}
|
||||
}
|
||||
return ensure_dir(buf, mode);
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[], char* envp[]) {
|
||||
(void)envp;
|
||||
struct sigaction sa = {0};
|
||||
sa.sa_handler = on_signal;
|
||||
sigaction(SIGINT, &sa, NULL);
|
||||
sigaction(SIGTERM, &sa, NULL);
|
||||
|
||||
if (ensure_dir(ROOTFS "/dev", 0755) != 0) return 1;
|
||||
|
||||
int fd = open(ROOTFS "/dev/urandom", O_CREAT | O_RDWR, 0666);
|
||||
if (fd < 0) {
|
||||
fprintf(stderr, "wrapper: open %s/dev/urandom: %s\n", ROOTFS, strerror(errno));
|
||||
return 1;
|
||||
}
|
||||
close(fd);
|
||||
|
||||
if (mount("/dev/urandom", ROOTFS "/dev/urandom", NULL, MS_BIND, NULL) != 0) {
|
||||
fprintf(stderr, "wrapper: bind-mount /dev/urandom: %s\n", strerror(errno));
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (chdir(ROOTFS) != 0) {
|
||||
fprintf(stderr, "wrapper: chdir " ROOTFS ": %s\n", strerror(errno));
|
||||
return 1;
|
||||
}
|
||||
if (chroot("./") != 0) {
|
||||
fprintf(stderr, "wrapper: chroot: %s (need CAP_SYS_CHROOT or root)\n", strerror(errno));
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (ensure_dir("/proc", 0755) != 0) return 1;
|
||||
|
||||
chmod(LINKER_PATH, 0755);
|
||||
chmod(DAEMON_PATH, 0755);
|
||||
|
||||
if (has_cap_sys_admin()) {
|
||||
if (unshare(CLONE_NEWPID) != 0) {
|
||||
fprintf(stderr, "wrapper: unshare(CLONE_NEWPID): %s\n", strerror(errno));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
g_child = fork();
|
||||
if (g_child < 0) {
|
||||
fprintf(stderr, "wrapper: fork: %s\n", strerror(errno));
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (g_child > 0) {
|
||||
int status = 0;
|
||||
if (waitpid(g_child, &status, 0) < 0) {
|
||||
fprintf(stderr, "wrapper: waitpid: %s\n", strerror(errno));
|
||||
return 1;
|
||||
}
|
||||
if (WIFEXITED(status)) return WEXITSTATUS(status);
|
||||
if (WIFSIGNALED(status)) return 128 + WTERMSIG(status);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (mount("proc", "/proc", "proc", 0, NULL) != 0) {
|
||||
fprintf(stderr, "wrapper: mount proc: %s\n", strerror(errno));
|
||||
return 1;
|
||||
}
|
||||
|
||||
const char* base_dir = getenv("WRAPPER_BASE_DIR");
|
||||
if (base_dir == NULL || base_dir[0] == '\0') {
|
||||
base_dir = k_default_base_dir;
|
||||
}
|
||||
if (ensure_dir_p(base_dir, 0777) != 0) return 1;
|
||||
char db_dir[1024];
|
||||
if (snprintf(db_dir, sizeof(db_dir), "%s/mpl_db", base_dir) >= (int)sizeof(db_dir)) {
|
||||
fprintf(stderr, "wrapper: mpl_db path too long\n");
|
||||
return 1;
|
||||
}
|
||||
if (ensure_dir_p(db_dir, 0777) != 0) return 1;
|
||||
|
||||
/* Bionic DNS resolver behavior; upstream wrapper sets this in init(). */
|
||||
setenv("ANDROID_DNS_MODE", "local", 1);
|
||||
|
||||
/* Bionic internals (timezone, SSL cert paths, etc.) expect these. */
|
||||
setenv("ANDROID_DATA", "/data", 0);
|
||||
setenv("ANDROID_ROOT", "/system", 0);
|
||||
|
||||
/* Point libcurl / OpenSSL at the CA bundle so SSL verification works. */
|
||||
setenv("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt", 0);
|
||||
setenv("CURL_CA_BUNDLE", "/etc/ssl/certs/ca-certificates.crt", 0);
|
||||
|
||||
/* Pass environ so setenv() changes are visible (execve's envp is static). */
|
||||
/* execve leaves argv[0] as the host path (e.g. /app/wrapper); set argv[0]
|
||||
* to the daemon so bionic diagnostics and any argv[0]-based logic match. */
|
||||
const int max_args = 256;
|
||||
if (argc >= max_args) {
|
||||
fprintf(stderr, "wrapper: too many arguments (%d max)\n", max_args - 1);
|
||||
return 1;
|
||||
}
|
||||
char* fixed_argv[max_args + 1];
|
||||
fixed_argv[0] = (char*)DAEMON_PATH;
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
fixed_argv[i] = argv[i];
|
||||
}
|
||||
/* argv list must be NULL-terminated (C null pointer, not C++ nullptr). */
|
||||
fixed_argv[argc > 0 ? argc : 1] = 0;
|
||||
|
||||
execve(DAEMON_PATH, fixed_argv, environ);
|
||||
fprintf(stderr, "wrapper: execve %s: %s\n", DAEMON_PATH, strerror(errno));
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
#!/bin/bash
|
||||
# extract-libs.sh - Extract Apple native libraries from an APKMirror .apkm bundle
|
||||
# or a standalone arch split .apk, and verify each .so against LIBS_VERSION.json.
|
||||
#
|
||||
# The bundle/APK file itself is not hashed; only extracted libraries are checked.
|
||||
#
|
||||
# Usage:
|
||||
# extract-libs.sh --bundle <path-to-.apkm|.apk> [--arch <x86_64|arm64-v8a>] [--out <dir>] [--ignore-hash]
|
||||
#
|
||||
# Options:
|
||||
# --arch <x86_64|arm64-v8a> Which arch's libs to extract (default x86_64)
|
||||
# --out <directory> Where to drop the .so files (default: <repo>/rootfs/system/lib64)
|
||||
# --ignore-hash Copy expected files without SHA-256 verification
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="${BASH_SOURCE[0]%/*}"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
LIBS_VERSION="$REPO_ROOT/LIBS_VERSION.json"
|
||||
|
||||
BUNDLE=""
|
||||
ARCH="x86_64"
|
||||
OUT=""
|
||||
SENTINEL="libandroidappmusic.so"
|
||||
IGNORE_HASH=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--bundle) BUNDLE="$2"; shift 2 ;;
|
||||
--arch) ARCH="$2"; shift 2 ;;
|
||||
--out) OUT="$2"; shift 2 ;;
|
||||
--ignore-hash) IGNORE_HASH=1; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,13p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$BUNDLE" ]]; then
|
||||
echo "extract-libs: missing --bundle <path-to-.apkm|.apk>" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -f "$BUNDLE" ]]; then
|
||||
echo "extract-libs: not a file: $BUNDLE" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ -z "$OUT" ]]; then
|
||||
OUT="$REPO_ROOT/rootfs/system/lib64"
|
||||
fi
|
||||
|
||||
case "$ARCH" in
|
||||
x86_64) PREFERRED_SPLIT="split_config.x86_64.apk" ;;
|
||||
arm64-v8a) PREFERRED_SPLIT="split_config.arm64_v8a.apk" ;;
|
||||
*) echo "extract-libs: unsupported arch '$ARCH'" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
for c in jq unzip install; do
|
||||
command -v "$c" >/dev/null || { echo "extract-libs: $c is required" >&2; exit 3; }
|
||||
done
|
||||
|
||||
sha256_file() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$1" | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$1" | awk '{print $1}'
|
||||
else
|
||||
echo "extract-libs: sha256sum or shasum is required" >&2
|
||||
return 3
|
||||
fi
|
||||
}
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# Return 0 when $1 lists lib/<abi>/$SENTINEL inside the zip at $2.
|
||||
apk_has_sentinel() {
|
||||
local lib_dir="$1"
|
||||
local apk="$2"
|
||||
unzip -Z1 "$apk" 2>/dev/null | tr -d '\r' | grep -qF "${lib_dir}/${SENTINEL}"
|
||||
}
|
||||
|
||||
# Print lib/<abi> when $SENTINEL is present (first match), else return 1.
|
||||
detect_lib_dir_in_apk() {
|
||||
local apk="$1"
|
||||
local so_path
|
||||
so_path="$(
|
||||
unzip -Z1 "$apk" 2>/dev/null | tr -d '\r' |
|
||||
grep -E "^lib/[^/]+/${SENTINEL}\$" |
|
||||
head -n1
|
||||
)"
|
||||
if [[ -z "$so_path" ]]; then
|
||||
return 1
|
||||
fi
|
||||
echo "${so_path%/$SENTINEL}"
|
||||
}
|
||||
|
||||
# Prefer lib dirs that match the requested --arch, then any lib/* with the sentinel.
|
||||
pick_lib_dir_for_arch() {
|
||||
local apk="$1"
|
||||
local -a candidates=()
|
||||
case "$ARCH" in
|
||||
x86_64) candidates=("lib/x86_64" "lib/x86") ;;
|
||||
arm64-v8a) candidates=("lib/arm64-v8a" "lib/arm64") ;;
|
||||
esac
|
||||
local dir
|
||||
for dir in "${candidates[@]}"; do
|
||||
if apk_has_sentinel "$dir" "$apk"; then
|
||||
echo "$dir"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
detect_lib_dir_in_apk "$apk"
|
||||
}
|
||||
|
||||
# Extract one zip member from $1 into a unique dir under $TMP; print the file path.
|
||||
extract_zip_member() {
|
||||
local zip="$1"
|
||||
local member="$2"
|
||||
local dest="$TMP/$(echo "$member" | tr '/: ' '___')"
|
||||
mkdir -p "$dest"
|
||||
unzip -qq "$zip" "$member" -d "$dest"
|
||||
local base
|
||||
base="$(basename "$member")"
|
||||
local found
|
||||
found="$(find "$dest" -name "$base" -type f | head -n1)"
|
||||
if [[ -z "$found" || ! -f "$found" ]]; then
|
||||
echo "extract-libs: failed to extract $member from $zip" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "$found"
|
||||
}
|
||||
|
||||
# Score a zip member name for arch relevance (higher = better).
|
||||
split_name_score() {
|
||||
local name="$1"
|
||||
local base="${name##*/}"
|
||||
local score=0
|
||||
case "$ARCH" in
|
||||
x86_64)
|
||||
[[ "$base" == "$PREFERRED_SPLIT" ]] && score=100
|
||||
[[ "$base" == *x86_64* || "$base" == *x86-64* ]] && score=$((score + 50))
|
||||
[[ "$base" == *x86* ]] && score=$((score + 10))
|
||||
;;
|
||||
arm64-v8a)
|
||||
[[ "$base" == "$PREFERRED_SPLIT" ]] && score=100
|
||||
[[ "$base" == *arm64_v8a* || "$base" == *arm64-v8a* || "$base" == *arm64* ]] && score=$((score + 50))
|
||||
;;
|
||||
esac
|
||||
[[ "$base" == base.apk ]] && score=$((score - 20))
|
||||
echo "$score"
|
||||
}
|
||||
|
||||
# From an .apkm, pick the inner .apk that actually ships native libs for --arch.
|
||||
resolve_apk_from_apkm() {
|
||||
local bundle="$1"
|
||||
local -a members=()
|
||||
local member_name
|
||||
while IFS= read -r member_name; do
|
||||
members+=("$member_name")
|
||||
done < <(unzip -Z1 "$bundle" 2>/dev/null | tr -d '\r' | grep -E '\.apk$' || true)
|
||||
if [[ ${#members[@]} -eq 0 ]]; then
|
||||
echo "extract-libs: bundle contains no .apk members: $bundle" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local best_member="" best_score=-1 best_apk="" best_lib=""
|
||||
local member score lib_dir apk_path
|
||||
for member in "${members[@]}"; do
|
||||
score="$(split_name_score "$member")"
|
||||
[[ "$score" -le 0 ]] && continue
|
||||
apk_path="$(extract_zip_member "$bundle" "$member")" || continue
|
||||
lib_dir="$(pick_lib_dir_for_arch "$apk_path" || true)"
|
||||
if [[ -z "$lib_dir" ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ "$score" -gt "$best_score" ]]; then
|
||||
best_score="$score"
|
||||
best_member="$member"
|
||||
best_apk="$apk_path"
|
||||
best_lib="$lib_dir"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "$best_apk" ]]; then
|
||||
# Last resort: any inner .apk that contains the sentinel (any lib/*).
|
||||
for member in "${members[@]}"; do
|
||||
apk_path="$(extract_zip_member "$bundle" "$member")" || continue
|
||||
lib_dir="$(detect_lib_dir_in_apk "$apk_path" || true)"
|
||||
if [[ -n "$lib_dir" ]]; then
|
||||
best_member="$member"
|
||||
best_apk="$apk_path"
|
||||
best_lib="$lib_dir"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -z "$best_apk" ]]; then
|
||||
echo "extract-libs: no .apk in bundle contains $SENTINEL under lib/<abi>/ (arch=$ARCH)" >&2
|
||||
echo "extract-libs: members seen:" >&2
|
||||
printf ' %s\n' "${members[@]}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "$best_member" != "$PREFERRED_SPLIT" ]]; then
|
||||
echo "extract-libs: using $best_member (not $PREFERRED_SPLIT; that split has no native libs)" >&2
|
||||
fi
|
||||
if [[ "$best_lib" != "lib/$ARCH" ]]; then
|
||||
echo "extract-libs: using lib dir $best_lib inside $(basename "$best_apk")" >&2
|
||||
fi
|
||||
APK="$best_apk"
|
||||
APK_LIB_DIR="$best_lib"
|
||||
}
|
||||
|
||||
case "$BUNDLE" in
|
||||
*.[aA][pP][kK][mM])
|
||||
resolve_apk_from_apkm "$BUNDLE"
|
||||
;;
|
||||
*.[aA][pP][kK])
|
||||
APK="$BUNDLE"
|
||||
APK_LIB_DIR="$(pick_lib_dir_for_arch "$APK" || true)"
|
||||
if [[ -z "$APK_LIB_DIR" ]]; then
|
||||
echo "extract-libs: $APK has no lib/<abi>/$SENTINEL (wrong split or --arch?)" >&2
|
||||
echo "extract-libs: if this is an .apkm, pass the .apkm file instead of a single split" >&2
|
||||
exit 6
|
||||
fi
|
||||
if [[ "$APK_LIB_DIR" != "lib/$ARCH" ]]; then
|
||||
echo "extract-libs: using lib dir $APK_LIB_DIR" >&2
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "extract-libs: expected .apkm or .apk extension: $BUNDLE" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
mkdir -p "$OUT"
|
||||
LIB_TMP="$TMP/libs"
|
||||
mkdir -p "$LIB_TMP"
|
||||
unzip -qq "$APK" "$APK_LIB_DIR/*" -d "$LIB_TMP"
|
||||
|
||||
# `jq.exe` on msys2/MSVC emits CRLF on Windows; strip CR defensively before
|
||||
# iterating, otherwise lib names get a stray \r appended and every lookup fails.
|
||||
EXPECTED_LIBS=()
|
||||
while IFS= read -r so; do
|
||||
EXPECTED_LIBS+=("$so")
|
||||
done < <(jq -r --arg arch "$ARCH" '.libs[$arch] | keys[]' "$LIBS_VERSION" | tr -d '\r')
|
||||
[[ ${#EXPECTED_LIBS[@]} -gt 0 ]] || {
|
||||
echo "extract-libs: no libs pin for arch '$ARCH' in LIBS_VERSION.json" >&2
|
||||
exit 4
|
||||
}
|
||||
|
||||
ok=0
|
||||
fail=0
|
||||
for so in "${EXPECTED_LIBS[@]}"; do
|
||||
src="$LIB_TMP/$APK_LIB_DIR/$so"
|
||||
if [[ ! -f "$src" ]]; then
|
||||
echo "extract-libs: missing in APK: $so" >&2
|
||||
fail=$((fail+1))
|
||||
continue
|
||||
fi
|
||||
if [[ "$IGNORE_HASH" -eq 0 ]]; then
|
||||
expect="$(jq -r --arg arch "$ARCH" --arg so "$so" '.libs[$arch][$so]' "$LIBS_VERSION" | tr -d '\r')"
|
||||
actual="$(sha256_file "$src")"
|
||||
if [[ "$expect" != "$actual" ]]; then
|
||||
echo "extract-libs: SHA-256 mismatch on $so" >&2
|
||||
echo " expected: $expect" >&2
|
||||
echo " actual: $actual" >&2
|
||||
fail=$((fail+1))
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
install -m 0644 "$src" "$OUT/$so"
|
||||
ok=$((ok+1))
|
||||
done
|
||||
|
||||
if [[ "$IGNORE_HASH" -eq 1 ]]; then
|
||||
echo "extract-libs: $ok copied, $fail failed (arch=$ARCH out=$OUT from $(basename "$APK") $APK_LIB_DIR hash=ignored)"
|
||||
else
|
||||
echo "extract-libs: $ok ok, $fail failed (arch=$ARCH out=$OUT from $(basename "$APK") $APK_LIB_DIR)"
|
||||
fi
|
||||
[[ $fail -eq 0 ]]
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/bin/bash
|
||||
# stage-system.sh - Verify and copy committed Android system binaries into
|
||||
# rootfs/system/. These binaries (linker64 + bionic + a few AOSP libs) are
|
||||
# committed under vendor/android-system/<arch>/ with SHA-256 pins in
|
||||
# LIBS_VERSION.json.
|
||||
#
|
||||
# Usage:
|
||||
# tools/stage-system.sh [--arch <x86_64|arm64-v8a>] [--rootfs <path>] [--ignore-hash]
|
||||
#
|
||||
# Defaults: --arch x86_64, --rootfs <repo>/rootfs
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="${BASH_SOURCE[0]%/*}"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
LIBS_VERSION="$REPO_ROOT/LIBS_VERSION.json"
|
||||
|
||||
ARCH="x86_64"
|
||||
ROOTFS="$REPO_ROOT/rootfs"
|
||||
IGNORE_HASH=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--arch) ARCH="$2"; shift 2 ;;
|
||||
--rootfs) ROOTFS="$2"; shift 2 ;;
|
||||
--ignore-hash) IGNORE_HASH=1; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,10p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
VENDOR="$REPO_ROOT/vendor/android-system/$ARCH"
|
||||
if [[ ! -d "$VENDOR" ]]; then
|
||||
echo "stage-system: vendor dir does not exist: $VENDOR" >&2
|
||||
echo " (current arch '$ARCH' may not have committed binaries yet)" >&2
|
||||
exit 4
|
||||
fi
|
||||
|
||||
for c in jq install; do
|
||||
command -v "$c" >/dev/null || { echo "stage-system: $c is required" >&2; exit 3; }
|
||||
done
|
||||
|
||||
sha256_file() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$1" | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$1" | awk '{print $1}'
|
||||
else
|
||||
echo "stage-system: sha256sum or shasum is required" >&2
|
||||
return 3
|
||||
fi
|
||||
}
|
||||
|
||||
# `jq.exe` on msys2/MSVC emits CRLF on Windows; strip CR defensively before
|
||||
# iterating, otherwise paths get a stray \r appended and every lookup fails.
|
||||
EXPECTED=()
|
||||
while IFS= read -r rel; do
|
||||
EXPECTED+=("$rel")
|
||||
done < <(jq -r --arg arch "$ARCH" '.android_system[$arch] | keys[]' "$LIBS_VERSION" | tr -d '\r')
|
||||
[[ ${#EXPECTED[@]} -gt 0 ]] || { echo "stage-system: no .android_system.$ARCH section in LIBS_VERSION.json" >&2; exit 4; }
|
||||
|
||||
ok=0
|
||||
fail=0
|
||||
for rel in "${EXPECTED[@]}"; do
|
||||
src="$VENDOR/$rel"
|
||||
if [[ ! -f "$src" ]]; then
|
||||
echo "stage-system: missing committed file: vendor/android-system/$ARCH/$rel" >&2
|
||||
fail=$((fail+1))
|
||||
continue
|
||||
fi
|
||||
if [[ "$IGNORE_HASH" -eq 0 ]]; then
|
||||
expect="$(jq -r --arg arch "$ARCH" --arg p "$rel" '.android_system[$arch][$p]' "$LIBS_VERSION" | tr -d '\r')"
|
||||
actual="$(sha256_file "$src")"
|
||||
if [[ "$expect" != "$actual" ]]; then
|
||||
echo "stage-system: SHA-256 mismatch on $rel" >&2
|
||||
echo " expected: $expect" >&2
|
||||
echo " actual: $actual" >&2
|
||||
fail=$((fail+1))
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
case "$rel" in
|
||||
bin/*) mode=0755 ;;
|
||||
lib64/*) mode=0644 ;;
|
||||
*) mode=0644 ;;
|
||||
esac
|
||||
dest="$ROOTFS/system/$rel"
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
install -m "$mode" "$src" "$dest"
|
||||
ok=$((ok+1))
|
||||
done
|
||||
|
||||
if [[ "$IGNORE_HASH" -eq 1 ]]; then
|
||||
echo "stage-system: $ok copied, $fail failed (arch=$ARCH rootfs=$ROOTFS hash=ignored)"
|
||||
else
|
||||
echo "stage-system: $ok ok, $fail failed (arch=$ARCH rootfs=$ROOTFS)"
|
||||
fi
|
||||
[[ $fail -eq 0 ]]
|
||||
@@ -0,0 +1,61 @@
|
||||
# vendor/android-system
|
||||
|
||||
Android system binaries needed at runtime by Apple's `.so` files inside the
|
||||
chroot. Committed here so the build is fully self-contained except for the
|
||||
non-redistributable Apple libraries.
|
||||
|
||||
## What's included
|
||||
|
||||
Per arch under `<arch>/`:
|
||||
|
||||
| File | Source | Why |
|
||||
| --------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `bin/linker64` | AOSP `/system/bin/` | Android dynamic loader; resolves all `.so` dependencies. |
|
||||
| `lib64/libc.so` | bionic | Mandatory C runtime. |
|
||||
| `lib64/libm.so` | bionic | Math. |
|
||||
| `lib64/libstdc++.so` | bionic | Tiny stub. `libCoreFP`, `libCoreLSKD`, `libcurl` link against it. |
|
||||
| `lib64/liblog.so` | AOSP | Android logging. |
|
||||
| `lib64/libz.so` | AOSP | zlib. |
|
||||
| `lib64/libandroid.so` | AOSP | NDK runtime API (loaded by 6 Apple libs). |
|
||||
| `lib64/libOpenSLES.so` | AOSP | Stub satisfying `DT_NEEDED` of `libandroidappmusic`. We never call into it. |
|
||||
| `usr/share/zoneinfo/tzdata` | AOSP | Bionic timezone database. Optional; only suppresses `__bionic_open_tzdata` warnings on every `localtime`/`gmtime` call. Extract from any factory image at the same path (`simg2img system.img system.raw && debugfs -R "dump usr/share/zoneinfo/tzdata /tmp/tzdata" system.raw`). |
|
||||
|
||||
`libdl.so` is intentionally absent. Android's `linker64` itself exports the
|
||||
`dl_*` symbols, so a separate `libdl.so` file is not required at runtime.
|
||||
This matches how AOSP system images are laid out.
|
||||
|
||||
## License
|
||||
|
||||
These binaries are Apache-2.0 (AOSP). Redistribution is permitted; see
|
||||
`LICENSE` at the repo root for the wrapper-v2 project license.
|
||||
|
||||
## Provenance
|
||||
|
||||
The **x86_64** binaries were taken verbatim from
|
||||
[`zhaarey/wrapper`](https://github.com/zhaarey/wrapper) `rootfs/system/`,
|
||||
which sourced them from an Android x86_64 system image (likely API 21-29
|
||||
era based on file sizes and exported symbols).
|
||||
|
||||
The **arm64-v8a** set was taken verbatim from
|
||||
[`WorldObservationLog/wrapper`](https://github.com/WorldObservationLog/wrapper)
|
||||
branch **`arm64`** `rootfs/system/` (same layout as x86: `bin/linker64`,
|
||||
`lib64/*.so`, etc.). The IANA timezone blob `usr/share/zoneinfo/tzdata` is
|
||||
the same bytes on both arches (single file path; identical SHA-256 pin).
|
||||
|
||||
SHA-256 digests for both arches are pinned in
|
||||
[`LIBS_VERSION.json`](../../LIBS_VERSION.json) under `.android_system.<arch>`
|
||||
and verified by `tools/stage-system.sh`.
|
||||
|
||||
**Future work:** when reproducible provenance becomes important, replace
|
||||
these blobs with a `tools/fetch-android-system.sh` step that pulls a
|
||||
specific Google emulator system image (e.g.
|
||||
`https://dl.google.com/android/repository/sys-img/android/x86_64-29_r07.zip`),
|
||||
runs `simg2img` + `debugfs` to extract the same eight files, and compares
|
||||
the result against the pins committed here. The pins should not need to
|
||||
change.
|
||||
|
||||
## Adding a new arch
|
||||
|
||||
For any new ABI, add `vendor/android-system/<arch>/` with the same relative
|
||||
paths as the existing arches, plus a matching `.android_system.<arch>` entry
|
||||
in `LIBS_VERSION.json`.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user