Compare commits

..

51 Commits

Author SHA1 Message Date
Rafael Moraes ccd51d4dc1 Clarify README note about wrapper login 2026-05-20 17:59:54 -03:00
Rafael Moraes 35b3013b87 Refactor wrapper related methods to WrapperApi 2026-05-20 17:52:06 -03:00
Rafael Moraes 8aeda0abff Note that wrapper can skip cookies 2026-05-20 17:38:02 -03:00
Rafael Moraes 30aeee90b8 Add use_cenc and use_single_content_key to StreamInfo 2026-05-20 16:57:50 -03:00
Rafael Moraes 67bdfe8584 Add song codec flavor mappings and properties 2026-05-20 16:57:34 -03:00
Rafael Moraes 97086adfbe Add CENC and single content key support 2026-05-20 16:57:25 -03:00
Rafael Moraes da7346f704 Add use_single_content_key and use_cenc options 2026-05-20 16:57:15 -03:00
Rafael Moraes 3dd829b38c Bump version 2026-05-20 15:03:22 -03:00
Rafael Moraes c503d482a7 Bump version 2026-05-20 15:03:03 -03:00
Rafael Moraes 46df1672d9 README: add subscription note & update cookie text 2026-05-20 15:02:09 -03:00
Rafael Moraes d61e315362 Remove redundant Optional Dependencies note 2026-05-20 14:58:52 -03:00
Rafael Moraes b787e64820 Clarify README: optional deps, wrapper & codecs 2026-05-20 14:57:36 -03:00
Rafael Moraes 31d6ba7c93 Clarify wrapper CLI option help 2026-05-20 14:57:26 -03:00
Rafael Moraes 4841b953a7 Run yt-dlp in separate process 2026-05-20 14:46:35 -03:00
Rafael Moraes ada986573d Use codec.is_web property in codec check 2026-05-20 14:31:48 -03:00
Rafael Moraes 8ea1373c83 Use use_prefetch_key flag; update song staging 2026-05-20 14:31:37 -03:00
Rafael Moraes b7fdf7356f Support web AAC codecs and web stream handling 2026-05-20 14:31:22 -03:00
Rafael Moraes fba6a72747 Return stream_info in AppleMusicSongInterface 2026-05-20 08:27:10 -03:00
Rafael Moraes 48df71271b Add native music video muxing 2026-05-20 07:57:52 -03:00
Rafael Moraes cbd161038e Add logging to get_wrapper_playback 2026-05-20 06:44:37 -03:00
Rafael Moraes 66c3a0fcf1 Add media_id and raise on missing stream formats 2026-05-20 06:44:19 -03:00
Rafael Moraes b0b13e8367 Decrypt prefetch/default-key samples locally with DEFAULT_SONG_DECRYPTION_KEY 2026-05-18 22:05:20 -03:00
Rafael Moraes 7dab944908 Use /decrypt endpoint and cleanup formatting 2026-05-18 19:38:32 -03:00
Rafael Moraes ffeb3bcfec Adjust default wrapper decrypt endpoint 2026-05-18 18:30:41 -03:00
Rafael Moraes 6aae17c138 Use /sample/decrypt endpoint for decryption 2026-05-18 14:52:33 -03:00
Rafael Moraes 4cdad09372 Refactor amdecrypt for wrapper-v2 /decrypt/samples 2026-05-18 14:52:21 -03:00
Rafael Moraes 86bbb94274 Refactor music video to use tags from asset_info when using wrapper 2026-05-18 14:05:33 -03:00
Rafael Moraes e44b037414 Update to wrapper-v2 endpoints 2026-05-18 13:42:30 -03:00
Rafael Moraes 2205b76c07 Use wrapper-v2 HTTP decrypt for FairPlay CBCS.
Point amdecrypt at POST /decrypt with batched samples, robust moof/trun/senc parsing and CBCS subsample handling, and CLI/base defaults for the daemon URL. Update download/song and Apple Music paths to use the new flow; includes formatting and related API touch-ups.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 11:24:20 -03:00
Rafael Moraes 82e3cf20a0 Bump version to 3.5.2 2026-05-13 20:26:05 -03:00
Rafael Moraes bc4cdd181c Open file with UTF-8 encoding in add_file 2026-05-13 20:24:53 -03:00
Rafael Moraes dec4a22208 Bind logger and log m3u8 master URL extraction 2026-05-13 20:24:45 -03:00
Rafael Moraes b48dbeff8e Forward next_params (except limit) for pagination 2026-05-13 07:25:34 -03:00
Rafael Moraes 34a397eb18 Bump gamdl version to 3.5.1 2026-05-07 18:09:20 -03:00
Rafael Moraes 2c3abfd352 Bump version to 3.5.1 2026-05-07 18:08:26 -03:00
Rafael Moraes 1fc708177c Normalize Apple Music m3u8 master URL 2026-05-07 18:01:27 -03:00
Rafael Moraes f670fe8e95 Bump version to 3.5 2026-04-27 09:19:46 -03:00
Rafael Moraes 8f184fcb66 Remove '-28' from X-Apple-Store-Front header 2026-04-27 09:17:36 -03:00
Rafael Moraes 3765ef0df4 Set storefront_id None for non-US iTunes API 2026-04-27 08:56:43 -03:00
Rafael Moraes 4e28b7e9a3 Enable redirects and use correct storefront header 2026-04-27 08:54:22 -03:00
Rafael Moraes a009071a8d Bump version to 3.4 2026-04-27 06:35:39 -03:00
Rafael Moraes 64b1974232 Include filter result in exclusion error message 2026-04-27 06:35:00 -03:00
Rafael Moraes 37ede6572e Add overwrite flag to Database 2026-04-27 06:34:51 -03:00
Rafael Moraes 2e57216c3c Strip size suffix from Apple Music cover URLs 2026-04-27 06:25:55 -03:00
Rafael Moraes 5d242c89cd Remove 'level' and 'event' from event_dict 2026-04-26 11:41:47 -03:00
Rafael Moraes e5675f8874 Use CustomOutputWriter for structlog output 2026-04-26 00:38:08 -03:00
Rafael Moraes 716112c294 Use default_factory for DownloadItem uuid 2026-04-25 14:52:19 -03:00
Rafael Moraes 63ad0f2e07 Respect skip_cleanup when removing temp files 2026-04-25 14:28:09 -03:00
Rafael Moraes 939520b3f8 Stringify subprocess args in error message 2026-04-25 14:02:52 -03:00
Rafael Moraes df23276d3c Improve subprocess error message 2026-04-25 13:56:55 -03:00
Rafael Moraes a9227493ea Include subprocess output in async errors 2026-04-25 13:03:48 -03:00
28 changed files with 3325 additions and 1543 deletions
+90 -103
View File
@@ -23,31 +23,40 @@ A command-line app for downloading Apple Music songs, music videos and post vide
### Required
- **Python 3.10 or higher**
- **Apple Music Cookies** - Export your browser cookies in Netscape format while logged in with an active subscription at the Apple Music website:
- **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)
### Dependencies
### Optional Dependencies
Add these tools to your system PATH or specify their paths via command-line arguments or the config file. The tools needed depend on which audio quality, video format, and download mode you want. Use the table below to find the required tools for your use case:
#### Wrapper
| Use Case | Configuration | Required Tools |
|---|---|---|
| **Songs in Legacy Codecs** | `song_codec_priority: aac-legacy\|aac-he-legacy` | None |
| **Songs in Non Legacy Codecs** | `song_codec_priority: aac\|aac-he\|aac-binaural\|aac-downmix\|aac-he-binaural\|aac-he-downmix\|atmos\|ac3`<br/>`use_wrapper: true` | Wrapper |
| **Music Videos** | `music_video_remux_mode: ffmpeg` | FFmpeg<br/>mp4decrypt |
| | `music_video_remux_mode: mp4box` | MP4Box<br/>mp4decrypt |
| **Faster Downloads** | `download_mode: nm3u8dlre` | N_m3u8DL-RE |
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`.
#### Tool Reference
The wrapper is recommended when using these non-web song codecs:
| Tool | Download | Purpose |
|---|---|---|
| **FFmpeg** | [Windows](https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases) / [Linux](https://johnvansickle.com/ffmpeg/) | Required for music video remuxing with FFmpeg mode |
| **MP4Box** | [Download](https://gpac.io/downloads/gpac-nightly-builds/) | Alternative for music video remuxing |
| **mp4decrypt** | [Download](https://www.bento4.com/downloads/) | Decrypts MP4 files when used with MP4Box |
| **N_m3u8DL-RE** | [Download](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest) | Faster download alternative |
| **Wrapper** | [Download](https://github.com/WorldObservationLog/wrapper) | For downloading songs in ALAC and other experimental codecs |
- `aac`
- `aac-he`
- `aac-binaural`
- `aac-downmix`
- `aac-he-binaural`
- `aac-he-downmix`
- `atmos`
- `ac3`
- `alac`
**Note:**
- When using the Wrapper, you'll be asked to insert your credentials to login if you haven't already.
- Web song codecs such as `aac-web` and `aac-he-web` 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`.
## 📦 Installation
@@ -61,9 +70,8 @@ Add these tools to your system PATH or specify their paths via command-line argu
- 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 tools** (only if you need the functionality)
See the [Dependencies](#dependencies) section to determine which tools you need based on your use case, then follow the [Tool Reference](#tool-reference) for download and installation instructions.
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
@@ -123,66 +131,59 @@ The file is created automatically on first run. Command-line arguments override
### 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-account-url` | Wrapper account URL | `http://127.0.0.1:30020` |
| `--language`, `-l` | Metadata language | `en-US` |
| **Output Options** | | |
| `--cover-format` | Cover format | `jpg` |
| `--cover-size` | Cover size in pixels | `1200` |
| `--wvd-path` | .wvd file path | - |
| `--wrapper-m3u8-ip` | Wrapper m3u8 IP address and port | - |
| **Song Options** | | |
| `--synced-lyrics-format` | Synced lyrics format | `lrc` |
| `--song-codec-priority` | Comma-separated codec priority | `aac-legacy` |
| `--use-album-date` | Use album release date for songs | `false` |
| `--no-synced-lyrics` | Don't download synced lyrics | `false` |
| `--synced-lyrics-only` | Download only synced lyrics | `false` |
| **Music Video Options** | | |
| `--music-video-resolution` | Max music video resolution | `1080p` |
| `--music-video-codec-priority` | Comma-separated codec priority | `h264,h265` |
| `--music-video-remux-mode` | Remux mode | `ffmpeg` |
| `--music-video-remux-format` | Music video remux format | `m4v` |
| **Post Video Options** | | |
| `--uploaded-video-quality` | Post video quality | `best` |
| **Download & Path Options** | | |
| `--output-path`, `-o` | Output directory path | `./Apple Music` |
| `--temp-path` | Temporary directory path | `.` |
| `--nm3u8dlre-path` | N_m3u8DL-RE executable path | `N_m3u8DL-RE` |
| `--mp4decrypt-path` | mp4decrypt executable path | `mp4decrypt` |
| `--ffmpeg-path` | FFmpeg executable path | `ffmpeg` |
| `--mp4box-path` | MP4Box executable path | `MP4Box` |
| `--use-wrapper` | Use wrapper for decrypting songs | `false` |
| `--wrapper-decrypt-ip` | Wrapper decryption server IP | `127.0.0.1:10020` |
| `--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}/{playlist_title}` |
| `--single-disc-file-template` | Single disc file template | `{track:02d} {title}` |
| `--multi-disc-file-template` | Multi disc file template | `{disc}-{track:02d} {title}` |
| `--no-album-file-template` | No album file template | `{title}` |
| `--playlist-file-template` | Playlist file template | `Playlists/{playlist_artist}/{playlist_title}` |
| `--date-tag-template` | Date tag template | `%Y-%m-%dT%H:%M:%SZ` |
| `--exclude-tags` | Comma-separated tags to exclude | - |
| `--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` |
| 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` |
| `--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
@@ -213,13 +214,9 @@ The file is created automatically on first run. Command-line arguments override
- `ytdlp`, `nm3u8dlre`
> [!NOTE]
>
> - **yt-dlp is only used as a file download library**. Media is still fetched directly from Apple Music's servers, and yt-dlp is only responsible for handling the file download process.
### Remux Mode
- `ffmpeg`
- `mp4box` - Preserve the original closed caption track in music videos and some other minor metadata
### Cover Format
- `jpg`
@@ -232,12 +229,12 @@ Use ISO 639-1 language codes (e.g., `en-US`, `es-ES`, `ja-JP`, `pt-BR`). Don't a
### Song Codecs
**Stable:**
**Web:**
- `aac-legacy` - AAC 256kbps 44.1kHz
- `aac-he-legacy` - AAC-HE 64kbps 44.1kHz
- `aac-web` - AAC 256kbps 44.1kHz
- `aac-he-web` - AAC-HE 64kbps 44.1kHz
**Experimental** (may not work due to API limitations):
**Non-web** (wrapper recommended; may not work without wrapper due to API limitations):
- `aac` - AAC 256kbps up to 48kHz
- `aac-he` - AAC-HE 64kbps up to 48kHz
@@ -247,8 +244,8 @@ Use ISO 639-1 language codes (e.g., `en-US`, `es-ES`, `ja-JP`, `pt-BR`). Don't a
- `aac-he-downmix` - AAC-HE 64kbps downmix
- `atmos` - Dolby Atmos 768kbps
- `ac3` - AC3 640kbps
- `alac` - ALAC up to 24-bit/192kHz (unsupported)
- `ask` - Interactive experimental codec selection
- `alac` - ALAC up to 24-bit/192kHz
- `ask` - Interactive codec selection
### Synced Lyrics Format
@@ -286,16 +283,6 @@ Use ISO 639-1 language codes (e.g., `en-US`, `es-ES`, `ja-JP`, `pt-BR`). Don't a
- `top-songs`
- `music-videos`
## ⚙️ Wrapper
Use the [wrapper](https://github.com/WorldObservationLog/wrapper) to download songs in ALAC and other experimental codecs without API limitations. Cookies are not required when using the wrapper.
### Setup Instructions
1. **Start the wrapper server** - Run the wrapper server
2. **Enable wrapper in Gamdl** - Use `--use-wrapper` flag or set `use_wrapper = true` in config
3. **Run Gamdl** - Download as usual with the wrapper enabled
## 🐍 Embedding
Use Gamdl as a library in your Python projects:
@@ -375,7 +362,7 @@ async def main():
# 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)
+1 -1
View File
@@ -1 +1 @@
__version__ = "3.3"
__version__ = "3.6"
+1
View File
@@ -1,3 +1,4 @@
from .apple_music import AppleMusicApi
from .exceptions import *
from .itunes import ItunesApi
from .wrapper import WrapperApi
+13 -17
View File
@@ -24,6 +24,7 @@ from .constants import (
APPLE_MUSIC_WEBPLAYBACK_API_URL,
)
from .exceptions import GamdlApiResponseError
from .wrapper import WrapperApi
logger = structlog.get_logger(__name__)
@@ -242,25 +243,22 @@ class AppleMusicApi:
@classmethod
async def create_from_wrapper(
cls,
wrapper_account_url: str = "http://127.0.0.1:30020/",
wrapper_api: WrapperApi,
*args,
**kwargs,
) -> "AppleMusicApi":
response = None
async with httpx.AsyncClient() as client:
try:
response = await client.get(wrapper_account_url)
response.raise_for_status()
wrapper_account_info = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching wrapper account info",
status_code=response.status_code if response is not None else None,
)
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=wrapper_account_info["music_token"],
token=wrapper_account_info["dev_token"],
media_user_token=media_user_token,
token=token,
*args,
**kwargs,
)
@@ -518,13 +516,11 @@ class AppleMusicApi:
else:
limit = None
offset = int(next_params["offset"][0])
extended_data = await self._amp_request(
urlparse(next_uri).path,
{
"offset": offset,
**({"limit": limit} if limit else {}),
**{k: v for k, v in next_params.items() if k not in ["limit"]},
},
)
+2 -1
View File
@@ -77,6 +77,7 @@ class ItunesApi:
client = httpx.AsyncClient(
timeout=60.0,
follow_redirects=True,
)
return cls(
@@ -133,7 +134,7 @@ class ItunesApi:
response = await self.client.get(
ITUNES_PAGE_API_URL.format(media_type=media_type, media_id=media_id),
headers={
"X-Apple-Store-Front": f"{self.storefront_id}-1,32 t:music31",
"X-Apple-Store-Front": f"{self.storefront_id},32 t:music31",
},
)
response.raise_for_status()
+272
View File
@@ -0,0 +1,272 @@
from __future__ import annotations
import inspect
import struct
from collections.abc import Awaitable, Callable
from typing import TypeVar
import httpx
import structlog
from .exceptions import GamdlApiResponseError
logger = structlog.get_logger(__name__)
T = TypeVar("T")
CredentialsFunc = (
Callable[[], tuple[str, str]] | Callable[[], Awaitable[tuple[str, str]]]
)
TwoFactorCodeFunc = Callable[[], str] | Callable[[], Awaitable[str]]
async def _invoke(func: Callable[[], T | Awaitable[T]]) -> T:
result = func()
if inspect.isawaitable(result):
return await result
return result
class WrapperApi:
def __init__(
self,
base_url: str,
client: httpx.AsyncClient,
me: dict,
):
self.base_url = base_url
self.client = client
self.me = me
@staticmethod
def build_decrypt_sample_frame(
adam_id: str,
skd_uri: str,
ciphertexts: list[bytes],
) -> bytes:
"""Build wrapper-v2 /decrypt binary request frame."""
adam_id_bytes = adam_id.encode("utf-8")
skd_uri_bytes = skd_uri.encode("utf-8")
if not adam_id_bytes:
raise ValueError("wrapper-v2: adam_id must not be empty")
if not skd_uri_bytes:
raise ValueError("wrapper-v2: skd_uri must not be empty")
if not ciphertexts:
raise ValueError("wrapper-v2: ciphertext batch must not be empty")
frame = bytearray()
frame += struct.pack(
">III",
len(adam_id_bytes),
len(skd_uri_bytes),
len(ciphertexts),
)
for ciphertext in ciphertexts:
frame += struct.pack(">I", len(ciphertext))
frame += adam_id_bytes
frame += skd_uri_bytes
for ciphertext in ciphertexts:
frame += ciphertext
return bytes(frame)
@staticmethod
def parse_decrypt_sample_frame(data: bytes, expected_count: int) -> list[bytes]:
"""Parse wrapper-v2 /decrypt binary response frame."""
if len(data) < 4:
raise IOError("wrapper-v2: POST /decrypt returned a truncated response")
(sample_count,) = struct.unpack_from(">I", data, 0)
if sample_count != expected_count:
raise IOError(
f"wrapper-v2: expected {expected_count} samples in response, "
f"got {sample_count}"
)
table_end = 4 + sample_count * 4
if len(data) < table_end:
raise IOError("wrapper-v2: POST /decrypt returned a truncated length table")
lengths = [
struct.unpack_from(">I", data, 4 + i * 4)[0] for i in range(sample_count)
]
offset = table_end
out: list[bytes] = []
for i, length in enumerate(lengths):
end = offset + length
if end > len(data):
raise IOError(
f"wrapper-v2: POST /decrypt returned truncated sample {i}"
)
out.append(data[offset:end])
offset = end
if offset != len(data):
raise IOError("wrapper-v2: POST /decrypt returned trailing bytes")
return out
@classmethod
async def create(
cls,
base_url: str = "http://127.0.0.1",
get_credentials_func: CredentialsFunc | None = None,
get_2fa_code: TwoFactorCodeFunc | None = None,
) -> WrapperApi:
client = httpx.AsyncClient(
timeout=httpx.Timeout(600.0, connect=30.0),
)
base_url = base_url.rstrip("/")
me = await cls.get_me(client, base_url)
if get_credentials_func is not None and me["auth"]["state"] == "logged_out":
username, password = await _invoke(get_credentials_func)
await cls.login(
client,
base_url,
username,
password,
get_2fa_code,
)
me = await cls.get_me(client, base_url)
if me.get("auth", {}).get("state") == "logged_out":
raise GamdlApiResponseError(
"Wrapper is not authenticated. "
"Provide get_credentials_func or log in via the wrapper.",
)
return cls(base_url, client, me)
@staticmethod
async def login(
client: httpx.AsyncClient,
base_url: str,
username: str,
password: str,
get_2fa_code: TwoFactorCodeFunc | None = None,
) -> None:
base_url = base_url.rstrip("/")
response = await client.post(
f"{base_url}/login",
json={"username": username, "password": password},
)
if response.status_code == 200:
return
if response.status_code == 202:
if get_2fa_code is None:
raise GamdlApiResponseError(
"Wrapper login requires 2FA; provide get_2fa_code",
status_code=202,
)
code = await _invoke(get_2fa_code)
tfa_response = await client.post(
f"{base_url}/login/2fa",
json={"code": code},
)
if tfa_response.is_error:
raise GamdlApiResponseError(
"Wrapper 2FA login failed",
content=tfa_response.text,
status_code=tfa_response.status_code,
)
return
raise GamdlApiResponseError(
"Wrapper login failed",
content=response.text,
status_code=response.status_code,
)
@staticmethod
async def get_me(client: httpx.AsyncClient, base_url: str) -> dict:
log = logger.bind(action="wrapper_get_me")
response = None
try:
response = await client.get(f"{base_url}/me")
response.raise_for_status()
account_info = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching wrapper account info",
content=getattr(response, "text", None),
status_code=getattr(response, "status_code", None),
)
log.debug("success", account_info=account_info)
return account_info
async def get_playback(self, media_id: str) -> dict:
log = logger.bind(action="wrapper_get_playback", media_id=media_id)
response = None
try:
response = await self.client.get(
f"{self.base_url}/playback",
params={"adam_id": media_id},
)
response.raise_for_status()
playback = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching wrapper playback",
content=getattr(response, "text", None),
status_code=getattr(response, "status_code", None),
)
log.debug("success", playback=playback)
return playback
async def decrypt(
self,
adam_id: str,
skd_uri: str,
ciphertexts: list[bytes],
) -> list[bytes]:
"""Decrypt one POST /decrypt batch; plaintexts match ciphertext order."""
log = logger.bind(
action="wrapper_decrypt",
adam_id=adam_id,
sample_count=len(ciphertexts),
)
frame = self.build_decrypt_sample_frame(adam_id, skd_uri, ciphertexts)
response = await self.client.post(
f"{self.base_url}/decrypt",
content=frame,
headers={
"content-type": "application/octet-stream",
"accept": "application/octet-stream",
},
)
if response.status_code == 401:
raise IOError(
"wrapper-v2: POST /decrypt returned 401 — log in with POST /login "
"or restore a session on the daemon first"
)
if response.status_code == 503:
raise IOError(
"wrapper-v2: decrypt unavailable (503) — check daemon logs /health "
"for playback_ready and Apple lib init"
)
if response.status_code != 200:
detail = ""
try:
j = response.json()
detail = (j.get("detail") or j.get("error") or str(j)) or ""
except Exception:
detail = (response.text or "")[:500]
raise IOError(
f"wrapper-v2: POST /decrypt failed HTTP {response.status_code}: {detail}"
)
plaintexts = self.parse_decrypt_sample_frame(
response.content,
len(ciphertexts),
)
log.debug("success")
return plaintexts
+22 -32
View File
@@ -1,5 +1,4 @@
import asyncio
import logging
from functools import wraps
from pathlib import Path
@@ -11,6 +10,7 @@ from httpx import ConnectError
from .. import __version__
from ..api import AppleMusicApi
from ..api.wrapper import WrapperApi
from ..downloader import (
AppleMusicBaseDownloader,
AppleMusicDownloader,
@@ -38,7 +38,7 @@ from .cli_config import CliConfig
from .config_file import ConfigFile
from .database import Database
from .interactive_prompts import InteractivePrompts
from .utils import custom_structlog_formatter, prompt_path
from .utils import CustomOutputWriter, custom_structlog_formatter, prompt_path
logger = structlog.get_logger(__name__)
@@ -60,18 +60,10 @@ def make_sync(func):
async def main(config: CliConfig):
colorama.just_fix_windows_console()
root_logger = logging.getLogger(__name__.split(".")[0])
root_logger.setLevel(config.log_level)
root_logger.propagate = False
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(logging.Formatter("%(message)s"))
root_logger.addHandler(stream_handler)
log_output = CustomOutputWriter()
if config.log_file:
file_handler = logging.FileHandler(config.log_file, encoding="utf-8")
file_handler.setFormatter(logging.Formatter("%(message)s"))
root_logger.addHandler(file_handler)
log_output.add_file(config.log_file)
structlog.configure(
processors=[
@@ -79,22 +71,29 @@ async def main(config: CliConfig):
structlog.processors.ExceptionPrettyPrinter(),
custom_structlog_formatter,
],
logger_factory=structlog.stdlib.LoggerFactory(),
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_account_url=config.wrapper_account_url,
wrapper_api=wrapper_api,
language=config.language,
)
except ConnectError:
logger.critical(
"Could not connect to the wrapper account API. "
"Make sure the wrapper is running and the URL is correct."
)
except Exception as e:
logger.exception(f"Error: {e}")
return
else:
cookies_path = prompt_path(config.cookies_path)
@@ -102,6 +101,7 @@ async def main(config: CliConfig):
cookies_path=cookies_path,
language=config.language,
)
wrapper_api = None
if not apple_music_api.active_subscription:
logger.critical(
@@ -117,7 +117,7 @@ async def main(config: CliConfig):
)
if (
any(not codec.is_legacy() for codec in config.song_codec_piority)
any(not codec.is_web for codec in config.song_codec_piority)
and not config.use_wrapper
):
logger.warning(
@@ -127,23 +127,18 @@ async def main(config: CliConfig):
)
if config.database_path:
database = Database(config.database_path)
database = Database(config.database_path, config.overwrite)
flat_filter = database.flat_filter
else:
database = None
flat_filter = None
interactive_prompts = InteractivePrompts(
artist_auto_select=config.artist_auto_select,
)
base_interface = await AppleMusicBaseInterface.create(
apple_music_api=apple_music_api,
cover_format=config.cover_format,
cover_size=config.cover_size,
use_wrapper=config.use_wrapper,
wrapper_m3u8_ip=config.wrapper_m3u8_ip,
wvd_path=config.wvd_path,
wrapper_api=wrapper_api,
)
song_interface = AppleMusicSongInterface(
@@ -181,10 +176,6 @@ async def main(config: CliConfig):
output_path=config.output_path,
temp_path=config.temp_path,
nm3u8dlre_path=config.nm3u8dlre_path,
mp4decrypt_path=config.mp4decrypt_path,
ffmpeg_path=config.ffmpeg_path,
mp4box_path=config.mp4box_path,
wrapper_decrypt_ip=config.wrapper_decrypt_ip,
download_mode=config.download_mode,
album_folder_template=config.album_folder_template,
compilation_folder_template=config.compilation_folder_template,
@@ -204,7 +195,6 @@ async def main(config: CliConfig):
)
music_video_downloader = AppleMusicMusicVideoDownloader(
base=base_downloader,
remux_mode=config.music_video_remux_mode,
remux_format=config.music_video_remux_format,
)
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(
+15 -63
View File
@@ -6,7 +6,7 @@ from typing import Annotated
import click
from dataclass_click import argument, option
from ..api import AppleMusicApi
from ..api import AppleMusicApi, WrapperApi
from ..downloader import (
AppleMusicBaseDownloader,
AppleMusicDownloader,
@@ -32,7 +32,7 @@ from ..interface import (
from .utils import Csv
api_from_cookies_sig = inspect.signature(AppleMusicApi.create_from_netscape_cookies)
api_from_wrapper_sig = inspect.signature(AppleMusicApi.create_from_wrapper)
wrapper_api_create_sig = inspect.signature(WrapperApi.create)
api_create_sig = inspect.signature(AppleMusicApi.create)
base_interface_create_sig = inspect.signature(AppleMusicBaseInterface.create)
@@ -145,6 +145,15 @@ class CliConfig:
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,
@@ -161,14 +170,6 @@ class CliConfig:
),
),
]
wrapper_account_url: Annotated[
str,
option(
"--wrapper-account-url",
help="Wrapper account URL",
default=api_from_wrapper_sig.parameters["wrapper_account_url"].default,
),
]
language: Annotated[
str,
option(
@@ -203,9 +204,9 @@ class CliConfig:
help=".wvd file path",
default=base_interface_create_sig.parameters["wvd_path"].default,
type=click.Path(
file_okay=False,
dir_okay=True,
writable=True,
file_okay=True,
dir_okay=False,
writable=False,
resolve_path=True,
),
),
@@ -214,18 +215,10 @@ class CliConfig:
bool,
option(
"--use-wrapper",
help="Use wrapper for decrypting songs",
help="Use wrapper for account, playback, and decryption requests",
is_flag=True,
),
]
wrapper_m3u8_ip: Annotated[
str,
option(
"--wrapper-m3u8-ip",
help="Wrapper m3u8 IP address and port",
default=base_interface_create_sig.parameters["wrapper_m3u8_ip"].default,
),
]
# Song Interface Options
synced_lyrics_format: Annotated[
SyncedLyricsFormat,
@@ -320,38 +313,6 @@ class CliConfig:
default=base_downloader_sig.parameters["nm3u8dlre_path"].default,
),
]
mp4decrypt_path: Annotated[
str,
option(
"--mp4decrypt-path",
help="mp4decrypt executable path",
default=base_downloader_sig.parameters["mp4decrypt_path"].default,
),
]
ffmpeg_path: Annotated[
str,
option(
"--ffmpeg-path",
help="FFmpeg executable path",
default=base_downloader_sig.parameters["ffmpeg_path"].default,
),
]
mp4box_path: Annotated[
str,
option(
"--mp4box-path",
help="MP4Box executable path",
default=base_downloader_sig.parameters["mp4box_path"].default,
),
]
wrapper_decrypt_ip: Annotated[
str,
option(
"--wrapper-decrypt-ip",
help="IP address and port for wrapper decryption",
default=base_downloader_sig.parameters["wrapper_decrypt_ip"].default,
),
]
download_mode: Annotated[
DownloadMode,
option(
@@ -453,15 +414,6 @@ class CliConfig:
),
]
# DownloaderMusicVideo specific options
music_video_remux_mode: Annotated[
RemuxMode,
option(
"--music-video-remux-mode",
help="Remux mode",
default=music_video_downloader_sig.parameters["remux_mode"].default,
type=RemuxMode,
),
]
music_video_remux_format: Annotated[
RemuxFormatMusicVideo,
option(
+12 -2
View File
@@ -3,7 +3,13 @@ from pathlib import Path
class Database:
def __init__(self, path: Path):
def __init__(
self,
path: Path,
overwrite: bool,
):
self.overwrite = overwrite
self.connection = sqlite3.connect(path)
self.cursor = self.connection.cursor()
self._create_tables()
@@ -45,4 +51,8 @@ class Database:
if not result:
return None
return result if Path(result).exists() else None
return (
"Registered in database"
if Path(result).exists() and not self.overwrite
else None
)
+16
View File
@@ -16,6 +16,22 @@ class InteractivePrompts:
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],
+25 -2
View File
@@ -1,3 +1,5 @@
import atexit
import sys
from datetime import datetime
from enum import Enum
from pathlib import Path
@@ -39,12 +41,33 @@ class Csv(click.ParamType):
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.get("level", "INFO").upper()
level = event_dict.pop("level", "INFO").upper()
timestamp = datetime.now().strftime("%H:%M:%S")
level_colors = {
@@ -63,7 +86,7 @@ def custom_structlog_formatter(
prefix += click.style(f" [{action}]", dim=True)
if level in {"INFO", "WARNING", "ERROR", "CRITICAL"}:
message = event_dict.get("event", "")
message = event_dict.pop("event", "")
return f"{prefix} {message}"
else:
return f"{prefix} {event_dict}"
+1 -1
View File
@@ -1,4 +1,4 @@
from .amdecrypt import decrypt_file, decrypt_file_hex
from .amdecrypt import decrypt_file_hex, decrypt_wrapper, write_decrypted_media
from .base import AppleMusicBaseDownloader
from .downloader import AppleMusicDownloader
from .enums import *
File diff suppressed because it is too large Load Diff
+59 -34
View File
@@ -1,6 +1,9 @@
import asyncio
import multiprocessing
import queue
import re
import shutil
import traceback
from pathlib import Path
import structlog
@@ -17,6 +20,30 @@ 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:
with YoutubeDL(
{
"quiet": True,
"no_warnings": True,
"outtmpl": download_path,
"allow_unplayable_formats": True,
"overwrites": True,
"fixup": "never",
"noprogress": silent,
"allowed_extractors": ["generic"],
}
) as ydl:
ydl.download(stream_url)
except Exception as e:
result_queue.put(("error", repr(e), traceback.format_exc()))
class AppleMusicBaseDownloader:
def __init__(
self,
@@ -24,10 +51,6 @@ class AppleMusicBaseDownloader:
output_path: str = "./Apple Music",
temp_path: str = ".",
nm3u8dlre_path: str = "N_m3u8DL-RE",
mp4decrypt_path: str = "mp4decrypt",
ffmpeg_path: str = "ffmpeg",
mp4box_path: str = "MP4Box",
wrapper_decrypt_ip: str = "127.0.0.1:10020",
download_mode: DownloadMode = DownloadMode.YTDLP,
album_folder_template: str = "{album_artist}/{album}",
compilation_folder_template: str = "Compilations/{album}",
@@ -46,10 +69,6 @@ class AppleMusicBaseDownloader:
self.output_path = output_path
self.temp_path = temp_path
self.nm3u8dlre_path = nm3u8dlre_path
self.mp4decrypt_path = mp4decrypt_path
self.ffmpeg_path = ffmpeg_path
self.mp4box_path = mp4box_path
self.wrapper_decrypt_ip = wrapper_decrypt_ip
self.download_mode = download_mode
self.album_folder_template = album_folder_template
self.compilation_folder_template = compilation_folder_template
@@ -70,16 +89,10 @@ class AppleMusicBaseDownloader:
log = logger.bind(action="initialize_binary_paths")
self.full_nm3u8dlre_path = shutil.which(self.nm3u8dlre_path)
self.full_mp4decrypt_path = shutil.which(self.mp4decrypt_path)
self.full_ffmpeg_path = shutil.which(self.ffmpeg_path)
self.full_mp4box_path = shutil.which(self.mp4box_path)
log = log.debug(
"success",
full_nm3u8dlre_path=self.full_nm3u8dlre_path,
full_mp4decrypt_path=self.full_mp4decrypt_path,
full_ffmpeg_path=self.full_ffmpeg_path,
full_mp4box_path=self.full_mp4box_path,
)
def get_temp_path(
@@ -214,26 +227,40 @@ class AppleMusicBaseDownloader:
log.debug("success")
async def _download_ytdlp_async(self, stream_url: str, download_path: str) -> None:
await asyncio.to_thread(
self._download_ytdlp_sync,
stream_url,
download_path,
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()
def _download_ytdlp_sync(self, stream_url: str, download_path: str) -> None:
with YoutubeDL(
{
"quiet": True,
"no_warnings": True,
"outtmpl": download_path,
"allow_unplayable_formats": True,
"overwrites": True,
"fixup": "never",
"noprogress": self.silent,
"allowed_extractors": ["generic"],
}
) as ydl:
ydl.download(stream_url)
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)
@@ -246,8 +273,6 @@ class AppleMusicBaseDownloader:
"--no-log",
"--log-level",
"off",
"--ffmpeg-binary-path",
self.full_ffmpeg_path,
"--save-name",
download_path_obj.stem,
"--save-dir",
+4 -18
View File
@@ -6,7 +6,7 @@ import structlog
from ..interface.types import AppleMusicMedia
from .constants import TEMP_PATH_TEMPLATE
from .enums import DownloadMode, RemuxMode
from .enums import DownloadMode
from .exceptions import (
GamdlDownloaderDependencyNotFoundError,
GamdlDownloaderMediaFileExistsError,
@@ -88,7 +88,8 @@ class AppleMusicDownloader:
await self._download(item)
await self._final_processing(item)
finally:
self._cleanup_temp(item.uuid_)
if not self.skip_cleanup:
self._cleanup_temp(item.uuid_)
def _update_playlist_file(
self,
@@ -214,21 +215,6 @@ class AppleMusicDownloader:
"music-videos",
"library-music-videos",
}:
if not self.base.full_mp4decrypt_path:
raise GamdlDownloaderDependencyNotFoundError("mp4decrypt")
if (
self.music_video.remux_mode == RemuxMode.FFMPEG
and not self.base.full_ffmpeg_path
):
raise GamdlDownloaderDependencyNotFoundError("FFmpeg")
if (
self.music_video.remux_mode == RemuxMode.MP4BOX
and not self.base.full_mp4box_path
):
raise GamdlDownloaderDependencyNotFoundError("MP4Box")
await self.music_video.download(item)
elif item.media.media_metadata["type"] in {"uploaded-videos"}:
@@ -263,6 +249,6 @@ class AppleMusicDownloader:
log = logger.bind(action="cleanup_temp", folder_tag=folder_tag)
temp_path = Path(self.base.temp_path) / TEMP_PATH_TEMPLATE.format(folder_tag)
if temp_path.exists() and temp_path.is_dir() and not self.skip_cleanup:
if temp_path.exists() and temp_path.is_dir():
shutil.rmtree(temp_path, ignore_errors=True)
log.debug("success")
+12 -102
View File
@@ -2,7 +2,7 @@ from pathlib import Path
from ..interface.enums import CoverFormat
from ..interface.types import AppleMusicMedia, DecryptionKeyAv
from ..utils import async_subprocess
from .amdecrypt import decrypt_file_hex, write_decrypted_media
from .base import AppleMusicBaseDownloader
from .enums import RemuxFormatMusicVideo, RemuxMode
from .types import DownloadItem
@@ -12,106 +12,30 @@ class AppleMusicMusicVideoDownloader:
def __init__(
self,
base: AppleMusicBaseDownloader,
remux_mode: RemuxMode = RemuxMode.FFMPEG,
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
):
self.base = base
self.remux_mode = remux_mode
self.remux_format = remux_format
async def _remux_mp4box(
self,
input_path_video: str,
input_path_audio: str,
output_path: str,
):
await async_subprocess(
self.base.full_mp4box_path,
"-quiet",
"-add",
input_path_audio,
"-add",
input_path_video,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
output_path,
silent=self.base.silent,
)
async def _remux_ffmpeg(
self,
input_path_video: str,
input_path_audio: str,
output_path: str,
):
await async_subprocess(
self.base.full_ffmpeg_path,
"-loglevel",
"error",
"-y",
"-i",
input_path_video,
"-i",
input_path_audio,
"-c",
"copy",
"-c:s",
"mov_text",
"-movflags",
"+faststart",
output_path,
silent=self.base.silent,
)
async def _decrypt_mp4decrypt(
self,
input_path: str,
output_path: str,
decryption_key: str,
):
await async_subprocess(
self.base.full_mp4decrypt_path,
"--key",
f"1:{decryption_key}",
input_path,
output_path,
silent=self.base.silent,
)
async def stage(
self,
encrypted_path_video: str,
encrypted_path_audio: str,
decrypted_path_video: str,
decrypted_path_audio: str,
staged_path: str,
decryption_key: DecryptionKeyAv,
is_m4v: bool = False,
):
await self._decrypt_mp4decrypt(
encrypted_path_video,
decrypted_path_video,
decryption_key.video_track.key,
)
await self._decrypt_mp4decrypt(
encrypted_path_audio,
decrypted_path_audio,
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,
)
if self.remux_mode == RemuxMode.MP4BOX:
await self._remux_mp4box(
decrypted_path_video,
decrypted_path_audio,
staged_path,
)
else:
await self._remux_ffmpeg(
decrypted_path_video,
decrypted_path_audio,
staged_path,
)
def get_cover_path(
self,
@@ -177,26 +101,12 @@ class AppleMusicMusicVideoDownloader:
encrypted_path_audio,
)
decrypted_path_video = self.base.get_temp_path(
download_item.media.media_metadata["id"],
download_item.uuid_,
"decrypted_video",
".mp4",
)
decrypted_path_audio = self.base.get_temp_path(
download_item.media.media_metadata["id"],
download_item.uuid_,
"decrypted_audio",
".m4a",
)
await self.stage(
encrypted_path_video,
encrypted_path_audio,
decrypted_path_video,
decrypted_path_audio,
download_item.staged_path,
download_item.media.decryption_key,
download_item.staged_path.endswith(".m4v"),
)
cover_bytes = (
+194 -181
View File
@@ -1,181 +1,194 @@
from pathlib import Path
import structlog
from ..interface.enums import CoverFormat
from ..interface.types import AppleMusicMedia, DecryptionKeyAv
from .amdecrypt import decrypt_file, decrypt_file_hex
from .base import AppleMusicBaseDownloader
from .types import DownloadItem
logger = structlog.get_logger(__name__)
class AppleMusicSongDownloader:
def __init__(
self,
base: AppleMusicBaseDownloader,
):
self.base = base
async def get_download_item(self, media: AppleMusicMedia) -> DownloadItem:
download_item = DownloadItem(media)
if media.stream_info:
download_item.staged_path = self.base.get_temp_path(
media.media_metadata["id"],
download_item.uuid_,
"staged",
"." + media.stream_info.file_format.value,
)
download_item.final_path = self.base.get_final_path(
media.tags,
".m4a",
media.playlist_tags,
)
if media.playlist_tags:
download_item.playlist_file_path = self.base.get_playlist_file_path(
media.playlist_tags,
)
download_item.synced_lyrics_path = self.get_synced_lyrics_path(
download_item.final_path
)
download_item.cover_path = self.get_cover_path(
download_item.final_path,
media.cover.file_extension,
)
return download_item
async def _decrypt_amdecrypt(
self,
input_path: str,
output_path: str,
media_id: str,
fairplay_key: str,
) -> None:
await decrypt_file(
self.base.wrapper_decrypt_ip,
media_id,
fairplay_key,
input_path,
output_path,
)
async def _decrypt_amdecrypt_hex(
self,
input_path: str,
output_path: str,
decryption_key: str,
legacy: bool = False,
) -> None:
await decrypt_file_hex(
input_path,
output_path,
decryption_key,
legacy=legacy,
)
async def stage(
self,
encrypted_path: str,
staged_path: str,
decryption_key: DecryptionKeyAv,
legacy: bool,
media_id: str,
fairplay_key: str,
):
log = logger.bind(
action="stage_song",
media_id=media_id,
encrypted_path=encrypted_path,
staged_path=staged_path,
)
if self.base.interface.base.use_wrapper and not legacy:
await self._decrypt_amdecrypt(
encrypted_path,
staged_path,
media_id,
fairplay_key,
)
else:
await self._decrypt_amdecrypt_hex(
encrypted_path,
staged_path,
decryption_key.audio_track.key,
legacy,
)
log.debug("success")
def get_synced_lyrics_path(self, final_path: str) -> str:
log = logger.bind(action="get_synced_lyrics_path", final_path=final_path)
synced_lyrics_path = str(
Path(final_path).with_suffix(
"." + self.base.interface.song.synced_lyrics_format.value
)
)
log.debug("success", synced_lyrics_path=synced_lyrics_path)
return synced_lyrics_path
def get_cover_path(
self,
final_path: str,
file_extension: str,
) -> str:
log = logger.bind(
action="get_song_cover_path",
final_path=final_path,
file_extension=file_extension,
)
cover_path = str(Path(final_path).parent / ("Cover" + file_extension))
log.debug("success", cover_path=cover_path)
return cover_path
async def download(
self,
download_item: DownloadItem,
) -> None:
encrypted_path = self.base.get_temp_path(
download_item.media.media_metadata["id"],
download_item.uuid_,
"encrypted",
".m4a",
)
await self.base.download_stream(
download_item.media.stream_info.audio_track.stream_url,
encrypted_path,
)
await self.stage(
encrypted_path,
download_item.staged_path,
download_item.media.decryption_key,
download_item.media.stream_info.audio_track.legacy,
download_item.media.media_metadata["id"],
download_item.media.stream_info.audio_track.fairplay_key,
)
cover_bytes = (
await self.base.interface.base.get_cover_bytes(
download_item.media.cover.url
)
if self.base.interface.base.cover_format != CoverFormat.RAW
else None
)
await self.base.apply_tags(
download_item.staged_path,
download_item.media.tags,
cover_bytes,
)
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:
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,
)
+2 -2
View File
@@ -1,5 +1,5 @@
import uuid
from dataclasses import dataclass
from dataclasses import dataclass, field
from ..interface.types import AppleMusicMedia
@@ -7,7 +7,7 @@ from ..interface.types import AppleMusicMedia
@dataclass
class DownloadItem:
media: AppleMusicMedia
uuid_: str = uuid.uuid4().hex[:8]
uuid_: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
staged_path: str = None
final_path: str = None
playlist_file_path: str = None
+83 -13
View File
@@ -15,9 +15,10 @@ 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, PlaylistTags
from .types import Cover, DecryptionKey, MediaRating, MediaTags, MediaType, PlaylistTags
logger = structlog.get_logger(__name__)
@@ -27,19 +28,17 @@ class AppleMusicBaseInterface:
self,
apple_music_api: AppleMusicApi,
itunes_api: ItunesApi,
wrapper_api: WrapperApi | None,
cover_format: CoverFormat,
cover_size: int,
use_wrapper: bool,
wrapper_m3u8_ip: str,
cdm: Cdm,
) -> None:
self.apple_music_api = apple_music_api
self.itunes_api = itunes_api
self.cover_format = cover_format
self.cover_size = cover_size
self.use_wrapper = use_wrapper
self.wrapper_m3u8_ip = wrapper_m3u8_ip
self.cdm = cdm
self.wrapper_api = wrapper_api
@staticmethod
def create_cdm(wvd_path: str | None = None) -> Cdm:
@@ -125,14 +124,18 @@ class AppleMusicBaseInterface:
apple_music_api: AppleMusicApi,
cover_format: CoverFormat = CoverFormat.JPG,
cover_size: int = 1200,
use_wrapper: bool = False,
wrapper_m3u8_ip: str = "127.0.0.1:20020",
wvd_path: str | None = None,
itunes_api: ItunesApi | None = None,
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)
@@ -141,9 +144,8 @@ class AppleMusicBaseInterface:
itunes_api=itunes_api,
cover_format=cover_format,
cover_size=cover_size,
use_wrapper=use_wrapper,
wrapper_m3u8_ip=wrapper_m3u8_ip,
cdm=cdm,
wrapper_api=wrapper_api,
)
return base
@@ -223,12 +225,16 @@ class AppleMusicBaseInterface:
def _get_raw_cover_url(self, cover_url_template: str) -> str:
return re.sub(
r"image/thumb/",
r"/\{w\}x\{h\}bb\.jpg",
"",
re.sub(
r"is1-ssl",
"a1",
cover_url_template,
r"image/thumb/",
"",
re.sub(
r"is1-ssl",
"a1",
cover_url_template,
),
),
)
@@ -327,3 +333,67 @@ class AppleMusicBaseInterface:
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"]
)
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"]),
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=(
await self.get_media_date(asset_data["playlistId"])
if use_album_date
else (
self.parse_date(asset_data["releaseDate"])
if asset_data.get("releaseDate")
else None
)
),
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"]),
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=asset_data["s"],
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
+7 -2
View File
@@ -11,8 +11,6 @@ MEDIA_RATING_STR_MAP = {
2: "Clean",
}
LEGACY_SONG_CODECS = {"aac-legacy", "aac-he-legacy"}
DRM_DEFAULT_KEY_MAPPING = {
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed": (
"data:text/plain;base64,AAAAOHBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAABgSEAAAAAA"
@@ -96,3 +94,10 @@ ARTIST_AUTO_SELECT_STR_MAP = {
"top-songs": "Top Songs",
"music-videos": "Music Videos",
}
SONG_CODEC_FLAVOR_MAP = {
"aac-web": "28:ctrp256",
"aac-he-web": "32:ctrp64",
"aac-fps-web": "30:cbcp256",
"aac-he-fps-web": "34:cbcp64",
}
+17 -5
View File
@@ -4,9 +4,9 @@ from .constants import (
ARTIST_AUTO_SELECT_KEY_MAP,
ARTIST_AUTO_SELECT_STR_MAP,
FOURCC_MAP,
LEGACY_SONG_CODECS,
MEDIA_RATING_STR_MAP,
MEDIA_TYPE_STR_MAP,
SONG_CODEC_FLAVOR_MAP,
)
@@ -46,8 +46,11 @@ class MediaFileFormat(Enum):
class SongCodec(Enum):
AAC_LEGACY = "aac-legacy"
AAC_HE_LEGACY = "aac-he-legacy"
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"
@@ -59,8 +62,17 @@ class SongCodec(Enum):
ALAC = "alac"
ASK = "ask"
def is_legacy(self) -> bool:
return self.value in LEGACY_SONG_CODECS
@property
def is_web(self) -> bool:
return self.value.endswith("-web")
@property
def flavor(self) -> str | None:
return SONG_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):
+3 -1
View File
@@ -46,6 +46,8 @@ class GamdlInterfaceArtistMediaTypeError(GamdlInterfaceError):
class GamdlInterfaceFlatFilterExcludedError(GamdlInterfaceError):
def __init__(self, media_id: str, result: Any):
super().__init__(f"Media excluded by flat filter: {media_id}")
super().__init__(
f"Media excluded by flat filter (media ID: {media_id}): {result}"
)
self.result = result
+23 -4
View File
@@ -64,6 +64,8 @@ class AppleMusicMusicVideoInterface:
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
@@ -76,6 +78,16 @@ class AppleMusicMusicVideoInterface:
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(
@@ -401,10 +413,17 @@ class AppleMusicMusicVideoInterface:
media.cover = await self.base.get_cover(media.media_metadata)
itunes_page_metadata = await self.get_itunes_page_metadata(media.media_metadata)
media.tags = await self.get_tags(
media.media_metadata,
itunes_page_metadata,
)
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:
media.tags = await self.get_tags(
media.media_metadata,
itunes_page_metadata,
)
media.stream_info = await self.get_stream_info(
media.media_metadata,
+571 -543
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -122,7 +122,8 @@ class StreamInfo:
codec: str = None
width: int = None
height: int = None
legacy: bool = None
use_cenc: bool = False
use_single_content_key: bool = True
@dataclass
+14 -5
View File
@@ -1,14 +1,13 @@
import asyncio
import string
import subprocess
import typing
async def async_subprocess(*args: str, silent: bool = False) -> None:
if silent:
additional_args = {
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
"stdout": asyncio.subprocess.PIPE,
"stderr": asyncio.subprocess.PIPE,
}
else:
additional_args = {}
@@ -17,10 +16,20 @@ async def async_subprocess(*args: str, silent: bool = False) -> None:
*args,
**additional_args,
)
await proc.communicate()
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise Exception(f'"{args[0]}" exited with code {proc.returncode}')
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(
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "gamdl"
version = "3.3"
version = "3.6"
description = "A command-line app for downloading Apple Music songs, music videos and post videos."
readme = "README.md"
license = "MIT"
Generated
+1 -1
View File
@@ -223,7 +223,7 @@ wheels = [
[[package]]
name = "gamdl"
version = "3.3"
version = "3.6"
source = { virtual = "." }
dependencies = [
{ name = "async-lru" },