Merge rust wrapper support

This commit is contained in:
glomatico
2026-07-09 12:30:36 -03:00
24 changed files with 4204 additions and 3719 deletions
+32
View File
@@ -0,0 +1,32 @@
name: CI
on:
pull_request:
push:
branches:
- main
- rust-wrapper-support
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.10"
- uses: dtolnay/rust-toolchain@stable
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Rust tests
run: |
PYO3_PYTHON=python cargo test --manifest-path gamdl/downloader/ammuxer/Cargo.toml
- name: Build native extension
run: |
uv run maturin develop
+5 -4
View File
@@ -24,13 +24,14 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.x"
python-version: "3.10"
- uses: dtolnay/rust-toolchain@stable
- name: Build release distributions
run: |
# NOTE: put your own distribution build steps here.
python -m pip install build
python -m build
python -m pip install maturin
maturin build --release --sdist --out dist
- name: Upload distributions
uses: actions/upload-artifact@v4
+8
View File
@@ -1,8 +1,16 @@
/*
__pycache__
!gamdl
!gamdl/downloader
!gamdl/downloader/**
gamdl/downloader/ammuxer/target/
gamdl/downloader/**/__pycache__/
gamdl/ammuxer/
!.gitignore
!.github
!.github/**
!.python-version
!pyproject.toml
!README.md
!uv.lock
gamdl/*.so
+7 -2
View File
@@ -32,13 +32,16 @@ A command-line app for downloading Apple Music songs, music videos and post vide
#### 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`.
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`. Configure wrapper HTTP account/playback calls with `--wrapper-url` or `wrapper_url`, and configure WV2D batch TCP decrypt with `--wrapper-decrypt-host` / `--wrapper-decrypt-port`.
gamdl builds a private Rust extension from `gamdl/downloader/ammuxer` as `gamdl._ammuxer`. That native media engine handles wrapper TCP decrypt/reassembly plus MP4/M4A writing and muxing; Python remains responsible for the CLI, downloads, metadata tagging, and high-level orchestration.
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.
- Newer wrapper-v2 builds use HTTP JSON for account/playback and WV2D batch TCP port `10020` for decrypt.
- Song codecs other than `alac` do not require the wrapper.
- Cookies can be skipped when using the wrapper.
@@ -136,7 +139,9 @@ The file is created automatically on first run. Command-line arguments override
| `--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` |
| `--wrapper-url` | Wrapper HTTP control base URL | `http://127.0.0.1` |
| `--wrapper-decrypt-host` | Wrapper TCP decrypt host | `127.0.0.1` |
| `--wrapper-decrypt-port` | Wrapper TCP decrypt port | `10020` |
| `--language`, `-l` | Metadata language | `en-US` |
| **Interface Options** | | |
| `--cover-format` | Cover format | `jpg` |
+7 -117
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import inspect
import struct
from collections.abc import Awaitable, Callable
from typing import TypeVar
@@ -31,82 +30,23 @@ class WrapperApi:
def __init__(
self,
base_url: str,
decrypt_host: str,
decrypt_port: int,
client: httpx.AsyncClient,
me: dict,
):
self.base_url = base_url
self.decrypt_host = decrypt_host
self.decrypt_port = decrypt_port
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",
decrypt_host: str = "127.0.0.1",
decrypt_port: int = 10020,
get_credentials_func: CredentialsFunc | None = None,
get_2fa_code: TwoFactorCodeFunc | None = None,
) -> WrapperApi:
@@ -134,7 +74,7 @@ class WrapperApi:
"Provide get_credentials_func or log in via the wrapper.",
)
return cls(base_url, client, me)
return cls(base_url, decrypt_host, decrypt_port, client, me)
@staticmethod
async def login(
@@ -220,53 +160,3 @@ class WrapperApi:
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
+2
View File
@@ -87,6 +87,8 @@ async def main(config: CliConfig):
try:
wrapper_api = await WrapperApi.create(
base_url=config.wrapper_url,
decrypt_host=config.wrapper_decrypt_host,
decrypt_port=config.wrapper_decrypt_port,
get_credentials_func=InteractivePrompts.get_wrapper_credentials,
get_2fa_code=InteractivePrompts.get_wrapper_2fa_code,
)
+16
View File
@@ -154,6 +154,22 @@ class CliConfig:
default=wrapper_api_create_sig.parameters["base_url"].default,
),
]
wrapper_decrypt_host: Annotated[
str,
option(
"--wrapper-decrypt-host",
help="Wrapper TCP decrypt host",
default=wrapper_api_create_sig.parameters["decrypt_host"].default,
),
]
wrapper_decrypt_port: Annotated[
int,
option(
"--wrapper-decrypt-port",
help="Wrapper TCP decrypt port",
default=wrapper_api_create_sig.parameters["decrypt_port"].default,
),
]
# API specific options
cookies_path: Annotated[
str,
+1 -1
View File
@@ -1,4 +1,4 @@
from .amdecrypt import decrypt_file_hex, decrypt_wrapper, write_decrypted_media
from .ammuxer import decrypt_and_mux_hex, decrypt_and_mux_wrapper
from .base import AppleMusicBaseDownloader
from .downloader import AppleMusicDownloader
from .enums import *
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
"""Thin async shims for gamdl's native Rust media engine."""
from __future__ import annotations
import asyncio
from .. import _ammuxer
from ..api.wrapper import WrapperApi
async def decrypt_and_mux_hex(
decryption_key_audio: str,
input_audio_path: str,
output_path: str,
decryption_key_video: str | None = None,
input_video_path: str | None = None,
*,
use_cenc: bool = False,
use_single_content_key: bool = False,
m4v_brand: bool = False,
) -> None:
"""Decrypt local-key media and mux the final file in one Rust call."""
await asyncio.to_thread(
_ammuxer.decrypt_and_mux_hex_native,
decryption_key_audio,
input_audio_path,
output_path,
decryption_key_video,
input_video_path,
use_cenc,
use_single_content_key,
m4v_brand,
)
async def decrypt_and_mux_wrapper(
wrapper_api: WrapperApi,
track_id: str,
input_audio_path: str,
output_path: str,
fairplay_key_audio: str,
*,
input_video_path: str | None = None,
fairplay_key_video: str | None = None,
use_single_content_key: bool = False,
m4v_brand: bool = False,
) -> None:
"""Decrypt wrapper-v2 FairPlay media and mux the final file in one Rust call."""
await asyncio.to_thread(
_ammuxer.decrypt_and_mux_wrapper_native,
wrapper_api.decrypt_host,
wrapper_api.decrypt_port,
track_id,
input_audio_path,
output_path,
fairplay_key_audio,
input_video_path,
fairplay_key_video,
use_single_content_key,
m4v_brand,
)
+370
View File
@@ -0,0 +1,370 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "gamdl-ammuxer"
version = "0.1.0"
dependencies = [
"aes",
"cbc",
"cipher",
"ctr",
"pyo3",
"tempfile",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "pyo3"
version = "0.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7778bffd85cf38175ac1f545509665d0b9b92a198ca7941f131f85f7a4f9a872"
dependencies = [
"cfg-if",
"indoc",
"libc",
"memoffset",
"once_cell",
"portable-atomic",
"pyo3-build-config",
"pyo3-ffi",
"pyo3-macros",
"unindent",
]
[[package]]
name = "pyo3-build-config"
version = "0.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94f6cbe86ef3bf18998d9df6e0f3fc1050a8c5efa409bf712e661a4366e010fb"
dependencies = [
"once_cell",
"target-lexicon",
]
[[package]]
name = "pyo3-ffi"
version = "0.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f1b4c431c0bb1c8fb0a338709859eed0d030ff6daa34368d3b152a63dfdd8d"
dependencies = [
"libc",
"pyo3-build-config",
]
[[package]]
name = "pyo3-macros"
version = "0.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbc2201328f63c4710f68abdf653c89d8dbc2858b88c5d88b0ff38a75288a9da"
dependencies = [
"proc-macro2",
"pyo3-macros-backend",
"quote",
"syn",
]
[[package]]
name = "pyo3-macros-backend"
version = "0.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fca6726ad0f3da9c9de093d6f116a93c1a38e417ed73bf138472cf4064f72028"
dependencies = [
"heck",
"proc-macro2",
"pyo3-build-config",
"quote",
"syn",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unindent"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "gamdl-ammuxer"
version = "0.1.0"
edition = "2021"
description = "Native media decrypt and mux engine for gamdl"
license = "MIT"
repository = "https://github.com/glomatico/gamdl"
[lib]
name = "_ammuxer"
crate-type = ["cdylib"]
[dependencies]
aes = "0.8"
cbc = { version = "0.1", features = ["block-padding"] }
ctr = "0.9"
cipher = "0.4"
pyo3 = { version = "0.23.5", features = ["extension-module"] }
tempfile = "3"
+347
View File
@@ -0,0 +1,347 @@
use pyo3::exceptions::{PyIOError, PyRuntimeError, PyValueError};
use pyo3::prelude::*;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;
const DECRYPT_MAGIC: u32 = 0x57563244; // WV2D
const DECRYPT_VERSION: u16 = 1;
const DECRYPT_KIND_BATCH: u16 = 1;
const DECRYPT_KIND_OK: u16 = 2;
const DECRYPT_KIND_ERROR: u16 = 3;
const DECRYPT_KIND_CLOSE: u16 = 9;
type BatchItem = (Vec<u8>, Vec<u8>, Vec<u8>, Vec<(usize, usize)>);
fn value_error(message: impl Into<String>) -> PyErr {
PyValueError::new_err(message.into())
}
fn io_error(message: impl Into<String>) -> PyErr {
PyIOError::new_err(message.into())
}
fn validate_label(name: &str, value: &str) -> PyResult<Vec<u8>> {
let bytes = value.as_bytes();
if bytes.is_empty() {
return Err(value_error(format!("wrapper-v2: {name} must not be empty")));
}
if bytes.len() > u16::MAX as usize {
return Err(value_error(format!(
"wrapper-v2: {name} is too long for TCP decrypt protocol"
)));
}
Ok(bytes.to_vec())
}
fn reassemble_sample(
data: &[u8],
plain: &[u8],
tail: &[u8],
subsamples: &[(usize, usize)],
) -> PyResult<Vec<u8>> {
let mut full_dec = Vec::with_capacity(plain.len() + tail.len());
full_dec.extend_from_slice(plain);
full_dec.extend_from_slice(tail);
if subsamples.is_empty() {
if full_dec.len() != data.len() {
return Err(io_error(format!(
"decrypted sample length mismatch: expected {}, got {}",
data.len(),
full_dec.len()
)));
}
return Ok(full_dec);
}
let encrypted_total = subsamples
.iter()
.try_fold(0usize, |acc, (_, enc)| acc.checked_add(*enc))
.ok_or_else(|| value_error("subsample encrypted byte count overflow"))?;
if full_dec.len() != encrypted_total {
return Err(io_error(format!(
"decrypted subsample length mismatch: expected {}, got {}",
encrypted_total,
full_dec.len()
)));
}
let mut out = Vec::with_capacity(data.len());
let mut dec_off = 0usize;
let mut offset = 0usize;
for (clear_b, enc_b) in subsamples {
let clear_end = offset
.checked_add(*clear_b)
.ok_or_else(|| value_error("subsample clear byte offset overflow"))?;
if clear_end > data.len() {
return Err(value_error("subsample clear range exceeds sample size"));
}
if *clear_b > 0 {
out.extend_from_slice(&data[offset..clear_end]);
}
offset = clear_end;
let dec_end = dec_off
.checked_add(*enc_b)
.ok_or_else(|| value_error("subsample encrypted byte offset overflow"))?;
let enc_end = offset
.checked_add(*enc_b)
.ok_or_else(|| value_error("subsample encrypted range overflow"))?;
if dec_end > full_dec.len() {
return Err(value_error(
"subsample decrypt range exceeds plaintext size",
));
}
if enc_end > data.len() {
return Err(value_error("subsample encrypted range exceeds sample size"));
}
if *enc_b > 0 {
out.extend_from_slice(&full_dec[dec_off..dec_end]);
}
dec_off = dec_end;
offset = enc_end;
}
if offset < data.len() {
out.extend_from_slice(&data[offset..]);
}
if out.len() != data.len() {
return Err(io_error(format!(
"reassembled sample length mismatch: expected {}, got {}",
data.len(),
out.len()
)));
}
Ok(out)
}
fn build_decrypt_batch_payload(
adam_id: &[u8],
skd_uri: &[u8],
items: &[BatchItem],
) -> PyResult<Vec<u8>> {
if items.is_empty() {
return Err(value_error(
"wrapper-v2: ciphertext batch must not be empty",
));
}
if items.len() > u32::MAX as usize {
return Err(value_error("wrapper-v2: ciphertext batch is too large"));
}
let mut size = 8usize
.checked_add(
items
.len()
.checked_mul(4)
.ok_or_else(|| value_error("wrapper-v2: decrypt batch size overflow"))?,
)
.and_then(|n| n.checked_add(adam_id.len()))
.and_then(|n| n.checked_add(skd_uri.len()))
.ok_or_else(|| value_error("wrapper-v2: decrypt batch size overflow"))?;
for (idx, (_, aligned, _, _)) in items.iter().enumerate() {
if aligned.is_empty() {
return Err(value_error(format!(
"wrapper-v2: ciphertext sample {idx} must not be empty"
)));
}
if aligned.len() > u32::MAX as usize {
return Err(value_error(format!(
"wrapper-v2: ciphertext sample {idx} is too large"
)));
}
size = size
.checked_add(aligned.len())
.ok_or_else(|| value_error("wrapper-v2: decrypt batch size overflow"))?;
}
let mut out = Vec::with_capacity(size);
out.extend_from_slice(&(adam_id.len() as u16).to_be_bytes());
out.extend_from_slice(&(skd_uri.len() as u16).to_be_bytes());
out.extend_from_slice(&(items.len() as u32).to_be_bytes());
for (_, aligned, _, _) in items {
out.extend_from_slice(&(aligned.len() as u32).to_be_bytes());
}
out.extend_from_slice(adam_id);
out.extend_from_slice(skd_uri);
for (_, aligned, _, _) in items {
out.extend_from_slice(aligned);
}
Ok(out)
}
fn read_decrypt_samples_payload(data: &[u8]) -> PyResult<Vec<Vec<u8>>> {
if data.len() < 4 {
return Err(io_error("wrapper-v2: decrypt response too short"));
}
let sample_count = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
let table_end = 4usize
.checked_add(
sample_count
.checked_mul(4)
.ok_or_else(|| io_error("wrapper-v2: decrypt response overflow"))?,
)
.ok_or_else(|| io_error("wrapper-v2: decrypt response overflow"))?;
if data.len() < table_end {
return Err(io_error("wrapper-v2: truncated decrypt length table"));
}
let mut lengths = Vec::with_capacity(sample_count);
for i in 0..sample_count {
let off = 4 + i * 4;
lengths.push(
u32::from_be_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]) as usize,
);
}
let mut offset = table_end;
let mut out = Vec::with_capacity(sample_count);
for len in lengths {
let end = offset
.checked_add(len)
.ok_or_else(|| io_error("wrapper-v2: decrypt response overflow"))?;
if end > data.len() {
return Err(io_error("wrapper-v2: truncated plaintext sample"));
}
out.push(data[offset..end].to_vec());
offset = end;
}
if offset != data.len() {
return Err(io_error("wrapper-v2: trailing decrypt response bytes"));
}
Ok(out)
}
fn read_frame(stream: &mut TcpStream) -> PyResult<(u16, u32, Vec<u8>)> {
let mut h = [0u8; 16];
stream.read_exact(&mut h).map_err(|e| {
io_error(format!(
"wrapper-v2: TCP decrypt truncated frame header: {e}"
))
})?;
let magic = u32::from_be_bytes([h[0], h[1], h[2], h[3]]);
let version = u16::from_be_bytes([h[4], h[5]]);
if magic != DECRYPT_MAGIC {
return Err(io_error("wrapper-v2: bad decrypt response magic"));
}
if version != DECRYPT_VERSION {
return Err(io_error("wrapper-v2: bad decrypt response version"));
}
let kind = u16::from_be_bytes([h[6], h[7]]);
let request_id = u32::from_be_bytes([h[8], h[9], h[10], h[11]]);
let payload_len = u32::from_be_bytes([h[12], h[13], h[14], h[15]]) as usize;
let mut payload = vec![0u8; payload_len];
stream.read_exact(&mut payload).map_err(|e| {
io_error(format!(
"wrapper-v2: TCP decrypt truncated frame payload: {e}"
))
})?;
Ok((kind, request_id, payload))
}
fn write_frame(stream: &mut TcpStream, kind: u16, request_id: u32, payload: &[u8]) -> PyResult<()> {
if payload.len() > u32::MAX as usize {
return Err(value_error("wrapper-v2: decrypt frame is too large"));
}
stream
.write_all(&DECRYPT_MAGIC.to_be_bytes())
.and_then(|_| stream.write_all(&DECRYPT_VERSION.to_be_bytes()))
.and_then(|_| stream.write_all(&kind.to_be_bytes()))
.and_then(|_| stream.write_all(&request_id.to_be_bytes()))
.and_then(|_| stream.write_all(&(payload.len() as u32).to_be_bytes()))
.and_then(|_| stream.write_all(payload))
.map_err(|e| io_error(format!("wrapper-v2: TCP decrypt frame write failed: {e}")))
}
#[pyclass]
pub struct WrapperDecryptSession {
stream: Option<TcpStream>,
next_request_id: u32,
}
#[pymethods]
impl WrapperDecryptSession {
#[new]
fn new(host: String, port: u16) -> PyResult<Self> {
let stream = TcpStream::connect((host.as_str(), port))
.map_err(|e| io_error(format!("wrapper-v2: TCP decrypt connect failed: {e}")))?;
stream
.set_nodelay(true)
.map_err(|e| io_error(format!("wrapper-v2: TCP_NODELAY setup failed: {e}")))?;
stream
.set_read_timeout(Some(Duration::from_secs(600)))
.map_err(|e| {
io_error(format!(
"wrapper-v2: TCP decrypt read timeout setup failed: {e}"
))
})?;
stream
.set_write_timeout(Some(Duration::from_secs(600)))
.map_err(|e| {
io_error(format!(
"wrapper-v2: TCP decrypt write timeout setup failed: {e}"
))
})?;
Ok(Self {
stream: Some(stream),
next_request_id: 1,
})
}
fn decrypt_reassemble(
&mut self,
py: Python<'_>,
adam_id: String,
skd_uri: String,
items: Vec<BatchItem>,
) -> PyResult<Vec<Vec<u8>>> {
let adam_id = validate_label("adam_id", &adam_id)?;
let skd_uri = validate_label("skd_uri", &skd_uri)?;
let request_id = self.next_request_id;
self.next_request_id = self.next_request_id.wrapping_add(1).max(1);
let stream = self
.stream
.as_mut()
.ok_or_else(|| PyRuntimeError::new_err("wrapper-v2: decrypt session is closed"))?;
py.allow_threads(move || {
let payload = build_decrypt_batch_payload(&adam_id, &skd_uri, &items)?;
write_frame(stream, DECRYPT_KIND_BATCH, request_id, &payload)?;
let (kind, response_id, response_payload) = read_frame(stream)?;
if response_id != request_id {
return Err(io_error("wrapper-v2: mismatched decrypt response id"));
}
if kind == DECRYPT_KIND_ERROR {
return Err(io_error(format!(
"wrapper-v2: decrypt failed: {}",
String::from_utf8_lossy(&response_payload)
)));
}
if kind != DECRYPT_KIND_OK {
return Err(io_error("wrapper-v2: unexpected decrypt response kind"));
}
let plains = read_decrypt_samples_payload(&response_payload)?;
if plains.len() != items.len() {
return Err(io_error(format!(
"wrapper-v2: expected {} plaintexts, got {}",
items.len(),
plains.len()
)));
}
let mut out = Vec::with_capacity(items.len());
for ((data, _, tail, subsamples), plain) in items.into_iter().zip(plains) {
out.push(reassemble_sample(&data, &plain, &tail, &subsamples)?);
}
Ok(out)
})
}
fn close(&mut self) -> PyResult<()> {
if let Some(mut stream) = self.stream.take() {
let _ = write_frame(&mut stream, DECRYPT_KIND_CLOSE, 0, &[]);
}
Ok(())
}
}
impl Drop for WrapperDecryptSession {
fn drop(&mut self) {
let _ = self.close();
}
}
+12
View File
@@ -0,0 +1,12 @@
mod decrypt;
mod media;
mod mp4;
mod mux;
mod python;
use pyo3::prelude::*;
#[pymodule]
fn _ammuxer(module: &Bound<'_, PyModule>) -> PyResult<()> {
python::register(module)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+330
View File
@@ -0,0 +1,330 @@
use crate::mp4::{
build_decrypted_track_moov, build_muxed_moov, extract_mdat_payload, extract_mvhd_timescale,
extract_top_level_box, find_child_box, find_track_by_handler, ftyp_m4v, ftyp_mp4,
patch_first_chunk_offset, patch_trak_duration_to_movie_timescale, patch_trak_track_id,
write_m4a_file, write_mdat_from_sources, write_track_file, PayloadSource, SampleInfo,
TrackInfo,
};
use pyo3::exceptions::{PyIOError, PyValueError};
use pyo3::prelude::*;
use pyo3::types::{PyAny, PyBytes, PyList};
use std::fs::File;
use std::io::{self, Write};
fn py_io_error(err: io::Error) -> PyErr {
PyIOError::new_err(err.to_string())
}
fn py_value_error(message: impl Into<String>) -> PyErr {
PyValueError::new_err(message.into())
}
fn bytes_attr(obj: &Bound<'_, PyAny>, name: &str) -> PyResult<Vec<u8>> {
let value = obj.getattr(name)?;
if value.is_none() {
Ok(Vec::new())
} else {
Ok(value.extract::<Vec<u8>>()?)
}
}
fn option_string_attr(obj: &Bound<'_, PyAny>, name: &str) -> PyResult<Option<String>> {
let value = obj.getattr(name)?;
if value.is_none() {
Ok(None)
} else {
value.extract::<String>().map(Some)
}
}
fn handler_attr(obj: &Bound<'_, PyAny>) -> PyResult<[u8; 4]> {
let value = bytes_attr(obj, "handler_type")?;
if value.len() != 4 {
return Err(py_value_error("mux: handler_type must be four bytes"));
}
Ok([value[0], value[1], value[2], value[3]])
}
fn extract_samples(track_info: &Bound<'_, PyAny>) -> PyResult<Vec<SampleInfo>> {
let samples = track_info.getattr("samples")?;
let list = samples.downcast::<PyList>()?;
let mut out = Vec::with_capacity(list.len());
for item in list.iter() {
let size = item.getattr("size")?.extract::<u64>().or_else(|_| {
item.getattr("data")?
.extract::<Vec<u8>>()
.map(|d| d.len() as u64)
})?;
out.push(SampleInfo {
size,
duration: item.getattr("duration")?.extract::<u32>()?,
desc_index: item.getattr("desc_index")?.extract::<usize>()?,
composition_time_offset: item.getattr("composition_time_offset")?.extract::<i32>()?,
is_sync: item.getattr("is_sync")?.extract::<bool>()?,
});
}
Ok(out)
}
fn extract_track_info(track_info: &Bound<'_, PyAny>) -> PyResult<TrackInfo> {
Ok(TrackInfo {
samples: extract_samples(track_info)?,
moov_data: bytes_attr(track_info, "moov_data")?,
ftyp_data: bytes_attr(track_info, "ftyp_data")?,
handler_type: handler_attr(track_info)?,
})
}
fn payload_source_from_parts(
data: Vec<u8>,
data_path: Option<String>,
data_size: u64,
) -> PyResult<PayloadSource> {
if let Some(path) = data_path {
let size = if data_size > 0 {
data_size
} else {
std::fs::metadata(&path).map_err(py_io_error)?.len()
};
Ok(PayloadSource::File {
path,
offset: 0,
size,
})
} else {
Ok(PayloadSource::Memory(data))
}
}
fn extract_decrypted_track(
track: &Bound<'_, PyAny>,
) -> PyResult<(String, TrackInfo, PayloadSource)> {
let input_path = track.getattr("input_path")?.extract::<String>()?;
let track_info = extract_track_info(&track.getattr("track_info")?)?;
let data = bytes_attr(track, "data")?;
let data_path = option_string_attr(track, "data_path")?;
let data_size = track.getattr("data_size")?.extract::<u64>()?;
let source = payload_source_from_parts(data, data_path, data_size)?;
Ok((input_path, track_info, source))
}
#[pyfunction]
#[pyo3(signature = (output_path, song_info, decrypted_data, original_path=None, decrypted_data_path=None))]
pub fn write_decrypted_m4a_native(
py: Python<'_>,
output_path: String,
song_info: Bound<'_, PyAny>,
decrypted_data: Bound<'_, PyBytes>,
original_path: Option<String>,
decrypted_data_path: Option<String>,
) -> PyResult<()> {
let track = extract_track_info(&song_info)?;
let payload =
payload_source_from_parts(decrypted_data.as_bytes().to_vec(), decrypted_data_path, 0)?;
py.allow_threads(move || {
write_m4a_file(&output_path, &track, original_path.as_deref(), &payload)
.map_err(py_io_error)
})
}
#[pyfunction]
#[pyo3(signature = (output_path, track_info, decrypted_data, original_path=None, decrypted_data_path=None))]
pub fn write_decrypted_mp4_track_native(
py: Python<'_>,
output_path: String,
track_info: Bound<'_, PyAny>,
decrypted_data: Bound<'_, PyBytes>,
original_path: Option<String>,
decrypted_data_path: Option<String>,
) -> PyResult<()> {
let track = extract_track_info(&track_info)?;
let payload =
payload_source_from_parts(decrypted_data.as_bytes().to_vec(), decrypted_data_path, 0)?;
py.allow_threads(move || {
write_track_file(&output_path, &track, original_path.as_deref(), &payload)
.map_err(py_io_error)
})
}
fn build_track_moov_for_decrypted_track(
track: &Bound<'_, PyAny>,
) -> PyResult<(Vec<u8>, PayloadSource)> {
let (input_path, track_info, source) = extract_decrypted_track(track)?;
let moov = build_decrypted_track_moov(&track_info, Some(&input_path)).map_err(py_io_error)?;
Ok((moov, source))
}
#[pyfunction]
pub fn mux_decrypted_media_direct_native(
py: Python<'_>,
decrypted_media: Bound<'_, PyAny>,
output_path: String,
m4v_brand: bool,
) -> PyResult<()> {
let video_obj = decrypted_media.getattr("video")?;
if video_obj.is_none() {
return Err(py_value_error("direct AV mux requires a video track"));
}
let audio_obj = decrypted_media.getattr("audio")?;
let captions_obj = decrypted_media.getattr("captions")?;
let (video_moov, video_source) = build_track_moov_for_decrypted_track(&video_obj)?;
let (audio_moov, audio_source) = build_track_moov_for_decrypted_track(&audio_obj)?;
let captions = captions_obj.downcast::<PyList>()?;
let mut extra_tracks = Vec::new();
for caption in captions.iter() {
let (moov, source) = build_track_moov_for_decrypted_track(&caption)?;
extra_tracks.push((moov, source));
}
py.allow_threads(move || {
let mvhd = find_child_box(&video_moov, b"mvhd", 8).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"mux: missing required audio/video track metadata",
)
})?;
let video_trak = find_track_by_handler(&video_moov, b"vide").ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"mux: missing required audio/video track metadata",
)
})?;
let mut audio_trak = find_track_by_handler(&audio_moov, b"soun").ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"mux: missing required audio/video track metadata",
)
})?;
let 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);
let mut patched_extra_traks = Vec::new();
let mut extra_sources = Vec::new();
for (idx, (extra_moov, source)) in extra_tracks.into_iter().enumerate() {
if let Some(mut trak) = find_child_box(&extra_moov, b"trak", 8) {
trak = patch_trak_track_id(&trak, idx as u32 + 3);
trak = patch_trak_duration_to_movie_timescale(&trak, movie_timescale);
patched_extra_traks.push(trak);
extra_sources.push(source);
}
}
let ftyp = if m4v_brand { ftyp_m4v()? } else { ftyp_mp4()? };
let mut traks = Vec::new();
traks.push(video_trak);
traks.push(audio_trak);
traks.extend(patched_extra_traks);
let moov_probe = build_muxed_moov(&mvhd, &traks)?;
let mut mdat_offset = ftyp.len() as u64 + moov_probe.len() as u64 + 8;
let mut patched_traks = Vec::new();
patched_traks.push(patch_first_chunk_offset(&traks[0], mdat_offset)?);
mdat_offset += video_source.len();
patched_traks.push(patch_first_chunk_offset(&traks[1], mdat_offset)?);
mdat_offset += audio_source.len();
for (trak, source) in traks.iter().skip(2).zip(extra_sources.iter()) {
patched_traks.push(patch_first_chunk_offset(trak, mdat_offset)?);
mdat_offset += source.len();
}
let moov = build_muxed_moov(&mvhd, &patched_traks)?;
let mut sources = vec![video_source, audio_source];
sources.extend(extra_sources);
let mut file = File::create(&output_path)?;
file.write_all(&ftyp)?;
file.write_all(&moov)?;
write_mdat_from_sources(&mut file, &sources)
})
.map_err(py_io_error)
}
#[pyfunction]
#[pyo3(signature = (input_path_video, input_path_audio, output_path, input_path_extra_tracks=None, m4v_brand=false))]
pub fn mux_decrypted_mp4_tracks_native(
py: Python<'_>,
input_path_video: String,
input_path_audio: String,
output_path: String,
input_path_extra_tracks: Option<Vec<String>>,
m4v_brand: bool,
) -> PyResult<()> {
py.allow_threads(move || {
let video_data = std::fs::read(&input_path_video)?;
let audio_data = std::fs::read(&input_path_audio)?;
let video_moov = extract_top_level_box(&video_data, b"moov").ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"mux: missing moov box in decrypted track file",
)
})?;
let audio_moov = extract_top_level_box(&audio_data, b"moov").ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"mux: missing moov box in decrypted track file",
)
})?;
let video_payload = extract_mdat_payload(&video_data)?;
let audio_payload = extract_mdat_payload(&audio_data)?;
let mut extra_tracks = Vec::new();
for path in input_path_extra_tracks.unwrap_or_default() {
let data = std::fs::read(&path)?;
if let Some(moov) = extract_top_level_box(&data, b"moov") {
let payload = extract_mdat_payload(&data)?;
extra_tracks.push((moov, payload));
}
}
let mvhd = find_child_box(&video_moov, b"mvhd", 8).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"mux: missing required audio/video track metadata",
)
})?;
let video_trak = find_track_by_handler(&video_moov, b"vide").ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"mux: missing required audio/video track metadata",
)
})?;
let mut audio_trak = find_track_by_handler(&audio_moov, b"soun").ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"mux: missing required audio/video track metadata",
)
})?;
let 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);
let mut traks = vec![video_trak, audio_trak];
let mut payloads = vec![video_payload, audio_payload];
for (idx, (extra_moov, payload)) in extra_tracks.into_iter().enumerate() {
if let Some(mut trak) = find_child_box(&extra_moov, b"trak", 8) {
trak = patch_trak_track_id(&trak, idx as u32 + 3);
trak = patch_trak_duration_to_movie_timescale(&trak, movie_timescale);
traks.push(trak);
payloads.push(payload);
}
}
let ftyp = if m4v_brand { ftyp_m4v()? } else { ftyp_mp4()? };
let moov_probe = build_muxed_moov(&mvhd, &traks)?;
let mut mdat_offset = ftyp.len() as u64 + moov_probe.len() as u64 + 8;
let mut patched_traks = Vec::new();
for (trak, payload) in traks.iter().zip(payloads.iter()) {
patched_traks.push(patch_first_chunk_offset(trak, mdat_offset)?);
mdat_offset += payload.len() as u64;
}
let moov = build_muxed_moov(&mvhd, &patched_traks)?;
let sources: Vec<PayloadSource> = payloads.into_iter().map(PayloadSource::Memory).collect();
let mut file = File::create(&output_path)?;
file.write_all(&ftyp)?;
file.write_all(&moov)?;
write_mdat_from_sources(&mut file, &sources)
})
.map_err(py_io_error)
}
+24
View File
@@ -0,0 +1,24 @@
use crate::decrypt::WrapperDecryptSession;
use crate::media::{decrypt_and_mux_hex_native, decrypt_and_mux_wrapper_native};
use crate::mux::{
mux_decrypted_media_direct_native, mux_decrypted_mp4_tracks_native, write_decrypted_m4a_native,
write_decrypted_mp4_track_native,
};
use pyo3::prelude::*;
#[pyfunction]
fn native_available() -> bool {
true
}
pub fn register(module: &Bound<'_, PyModule>) -> PyResult<()> {
module.add_function(wrap_pyfunction!(native_available, module)?)?;
module.add_function(wrap_pyfunction!(decrypt_and_mux_hex_native, module)?)?;
module.add_function(wrap_pyfunction!(decrypt_and_mux_wrapper_native, module)?)?;
module.add_function(wrap_pyfunction!(write_decrypted_m4a_native, module)?)?;
module.add_function(wrap_pyfunction!(write_decrypted_mp4_track_native, module)?)?;
module.add_function(wrap_pyfunction!(mux_decrypted_media_direct_native, module)?)?;
module.add_function(wrap_pyfunction!(mux_decrypted_mp4_tracks_native, module)?)?;
module.add_class::<WrapperDecryptSession>()?;
Ok(())
}
+3 -6
View File
@@ -2,7 +2,7 @@ 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 .ammuxer import decrypt_and_mux_hex
from .base import AppleMusicBaseDownloader
from .enums import RemuxFormatMusicVideo, RemuxMode
from .types import DownloadItem
@@ -25,15 +25,12 @@ class AppleMusicMusicVideoDownloader:
decryption_key: DecryptionKeyAv,
is_m4v: bool = False,
):
decrypted_media = await decrypt_file_hex(
await decrypt_and_mux_hex(
decryption_key.audio_track.key,
encrypted_path_audio,
staged_path,
decryption_key.video_track.key,
encrypted_path_video,
)
await write_decrypted_media(
decrypted_media,
staged_path,
m4v_brand=is_m4v,
)
+20 -20
View File
@@ -4,7 +4,7 @@ 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 .ammuxer import decrypt_and_mux_hex, decrypt_and_mux_wrapper
from .base import AppleMusicBaseDownloader
from .types import DownloadItem
@@ -51,7 +51,7 @@ class AppleMusicSongDownloader:
return download_item
async def _decrypt_amdecrypt(
async def _decrypt_ammuxer(
self,
input_path: str,
output_path: str,
@@ -63,16 +63,16 @@ class AppleMusicSongDownloader:
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)
await decrypt_and_mux_wrapper(
wrapper_api,
media_id,
input_path,
output_path,
fairplay_key_audio=fairplay_key,
use_single_content_key=use_single_content_key,
)
async def _decrypt_amdecrypt_hex(
async def _decrypt_ammuxer_hex(
self,
input_path: str,
output_path: str,
@@ -81,13 +81,13 @@ class AppleMusicSongDownloader:
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)
await decrypt_and_mux_hex(
decryption_key,
input_path,
output_path,
use_cenc=use_cenc,
use_single_content_key=use_single_content_key,
)
async def stage(
self,
@@ -107,7 +107,7 @@ class AppleMusicSongDownloader:
)
if decryption_key:
await self._decrypt_amdecrypt_hex(
await self._decrypt_ammuxer_hex(
encrypted_path,
staged_path,
decryption_key.audio_track.key,
@@ -115,7 +115,7 @@ class AppleMusicSongDownloader:
use_single_content_key=use_single_content_key,
)
else:
await self._decrypt_amdecrypt(
await self._decrypt_ammuxer(
encrypted_path,
staged_path,
media_id,
+2 -1
View File
@@ -175,6 +175,7 @@ class AppleMusicMusicVideoInterface:
async def _get_stream_info(
self,
media_id: str,
m3u8_master_url: str | None,
codec: MusicVideoCodec,
) -> StreamInfoAv | None:
@@ -397,7 +398,7 @@ class AppleMusicMusicVideoInterface:
stream_info = None
for codec in self.codec_priority:
stream_info = await self._get_stream_info(m3u8_master_url, codec)
stream_info = await self._get_stream_info(media_id, m3u8_master_url, codec)
if stream_info:
break
+23 -21
View File
@@ -212,21 +212,16 @@ class AppleMusicSongInterface:
async def _get_m3u8_master_url_from_assets(
self,
song_metadata: dict,
media_id: str,
) -> str | None:
log = logger.bind(
action="get_m3u8_master_url_from_assets",
song_id=song_metadata["id"],
song_id=media_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"),
media_id,
"song",
)
asset = next(
@@ -248,32 +243,44 @@ class AppleMusicSongInterface:
return None
async def get_m3u8_master_url(
async def _get_m3u8_master_url(
self,
media_id: str,
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)
m3u8_master_url = self._get_m3u8_from_playback(playback)
if m3u8_master_url:
return m3u8_master_url
return await self._get_m3u8_master_url_from_assets(media_id)
async def get_stream_info(
self,
media_id: str,
is_library: bool,
m3u8_master_url: str | None = None,
webplayback: dict | None = None,
playback: dict | None = None,
) -> StreamInfoAv:
stream_info = None
if is_library:
stream_info = await self._get_library_stream_info(webplayback)
else:
m3u8_master_url = None
fetched_m3u8_master_url = False
for codec in self.codec_priority:
if codec.is_web:
stream_info = await self._get_web_stream_info(webplayback, codec)
else:
if not fetched_m3u8_master_url:
m3u8_master_url = await self._get_m3u8_master_url(
media_id,
playback,
)
fetched_m3u8_master_url = True
stream_info = await self._get_stream_info_nonweb(
m3u8_master_url,
codec,
@@ -702,16 +709,11 @@ class AppleMusicSongInterface:
)
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,
playback,
)
if media.stream_info.audio_track.drm_free:
+11
View File
@@ -1,3 +1,7 @@
[build-system]
requires = ["maturin>=1.8,<2"]
build-backend = "maturin"
[project]
name = "gamdl"
version = "3.8.1"
@@ -29,6 +33,13 @@ gamdl = "gamdl.cli.cli:main"
[dependency-groups]
dev = [
"maturin>=1.8",
"pytest>=9.0.3",
"pytest-asyncio>=1.3.0",
]
[tool.maturin]
manifest-path = "gamdl/downloader/ammuxer/Cargo.toml"
module-name = "gamdl._ammuxer"
python-source = "."
features = ["pyo3/extension-module"]
Generated
+27 -1
View File
@@ -224,7 +224,7 @@ wheels = [
[[package]]
name = "gamdl"
version = "3.8.1"
source = { virtual = "." }
source = { editable = "." }
dependencies = [
{ name = "async-lru" },
{ name = "click" },
@@ -243,6 +243,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "maturin" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
@@ -266,6 +267,7 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "maturin", specifier = ">=1.8" },
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
]
@@ -362,6 +364,30 @@ 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 = "maturin"
version = "1.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/b3/addd877f871fb1860d46d3a4f206ecb10b946c85846805e6367631926fd3/maturin-1.14.1.tar.gz", hash = "sha256:9d6577a62cd08e0ceba7a0db06fb098e0c9b1b3429bad747a4f3a18215a1b3df", size = 369637, upload-time = "2026-06-19T05:19:49.774Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/f0/97c5a5bd9c71653a066c0976a484eaaae50b9369557838a4176b7b0bdaa5/maturin-1.14.1-py3-none-linux_armv6l.whl", hash = "sha256:522292398945442cdafa9daeb2271b2340fbde57027b818f923f88eab04174f8", size = 10207496, upload-time = "2026-06-19T05:19:09.321Z" },
{ url = "https://files.pythonhosted.org/packages/fe/83/294bca639b0e052f1e2f65199b3db258780c7d4e31408b934c9c974a1379/maturin-1.14.1-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ffe5ad71f21d1e6603c4dd75f7fee34adf5ed5ebcebb692886549888ebb329ed", size = 19680113, upload-time = "2026-06-19T05:19:13.43Z" },
{ url = "https://files.pythonhosted.org/packages/43/b6/79c881410a3b1c187f7eb3d407aecae646c6a4433d630d72200359015e83/maturin-1.14.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f3306078070c1508fd715b9116070cbcaff5959024272a9f1e6f5cb29768b86c", size = 10169205, upload-time = "2026-06-19T05:19:16.615Z" },
{ url = "https://files.pythonhosted.org/packages/93/9d/44b6f26dcb7f7a04c5501ac2dbb6ca1490150682baa525ca5860504f9eab/maturin-1.14.1-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:cd457cd88961156e26379e1155bd287cc0ec1c8b2f1582b0660fb31b87c8842d", size = 10188098, upload-time = "2026-06-19T05:19:19.736Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bd/9c0d5d6983905ce2c9edaa073a7e89355a9cf7f396988e05d32f1c37785d/maturin-1.14.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:dfc54ae32e6fcb18302193ab9a30b0b25eefffba994ae13238974805533ef75e", size = 10627576, upload-time = "2026-06-19T05:19:22.713Z" },
{ url = "https://files.pythonhosted.org/packages/e5/33/b096412bd6a7cb399652b260666f901adf88a687181a6dbd6a3f89f0a94e/maturin-1.14.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:a131d912b5267e640bc96d70f4914e10590aed64082ec9abacba7cea52004224", size = 10085181, upload-time = "2026-06-19T05:19:25.69Z" },
{ url = "https://files.pythonhosted.org/packages/56/8d/08c3bf469c38a23c9e6c877e338193001eb604d010fedc08341974e38528/maturin-1.14.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:be18fc568fb76884c0205456336892a75105ec398e6b667cd777c6268bd06d69", size = 10026363, upload-time = "2026-06-19T05:19:28.904Z" },
{ url = "https://files.pythonhosted.org/packages/3a/a4/c4d1a92839f8745ab4aab988a7db884a79d6d710bd3b286fcf9316dece1a/maturin-1.14.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:994a0c8ba3ad8a92b3a9ee1b02645d200d610216b15cff5102b0fe65e8e08666", size = 13321347, upload-time = "2026-06-19T05:19:32.411Z" },
{ url = "https://files.pythonhosted.org/packages/b3/fa/170f04624d03fd07d2a8b1b67de83a127af93aef9eaa425839553347297b/maturin-1.14.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be80866363e605d137991b491a741a84cde9ae350183c4c85f49690ca9aaaa65", size = 10877609, upload-time = "2026-06-19T05:19:35.448Z" },
{ url = "https://files.pythonhosted.org/packages/61/ad/1ae2e1d0ded282bf2c55ac13f0811d87deb425e200ae64a15785675dede9/maturin-1.14.1-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:5282dffd4b539d2be245f4e5b1a5ab6bc1033b58f4a4872f5833f9d43c954aa4", size = 10417316, upload-time = "2026-06-19T05:19:38.28Z" },
{ url = "https://files.pythonhosted.org/packages/fb/27/bf677183920718da49cd7982d6a3ffc440aad8919329f571d189f81b7bdf/maturin-1.14.1-py3-none-win32.whl", hash = "sha256:1a04de0a20188f95c721b5702eed18140bdcccb28c386797093eca3f62f4d4e0", size = 8931293, upload-time = "2026-06-19T05:19:41.183Z" },
{ url = "https://files.pythonhosted.org/packages/63/4b/585adeb9167b08d3cdff0032a938b0e72655c92003df4f52c3f696a1bcc2/maturin-1.14.1-py3-none-win_amd64.whl", hash = "sha256:3c9f94640ecc4895e94abaf834a0684430032c865b2748a36c12461fd9252fdd", size = 10314067, upload-time = "2026-06-19T05:19:44.389Z" },
{ url = "https://files.pythonhosted.org/packages/51/d4/dac8c0720ae246be1700afb6fbdbbea20fe35b13f6570b2f70faa005df77/maturin-1.14.1-py3-none-win_arm64.whl", hash = "sha256:15cea8fcb3ba47dd636f50092bb34baea8b04ac777392f23e6bf8a9a61efb894", size = 9718943, upload-time = "2026-06-19T05:19:47.49Z" },
]
[[package]]
name = "mutagen"
version = "1.47.0"