mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-13 12:15:18 +03:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ccd51d4dc1 | |||
| 35b3013b87 | |||
| 8aeda0abff | |||
| 30aeee90b8 | |||
| 67bdfe8584 | |||
| 97086adfbe | |||
| da7346f704 | |||
| 3dd829b38c | |||
| c503d482a7 | |||
| 46df1672d9 | |||
| d61e315362 | |||
| b787e64820 | |||
| 31d6ba7c93 | |||
| 4841b953a7 | |||
| ada986573d | |||
| 8ea1373c83 | |||
| b7fdf7356f | |||
| fba6a72747 | |||
| 48df71271b | |||
| cbd161038e | |||
| 66c3a0fcf1 | |||
| b0b13e8367 | |||
| 7dab944908 | |||
| ffeb3bcfec | |||
| 6aae17c138 | |||
| 4cdad09372 | |||
| 86bbb94274 | |||
| e44b037414 | |||
| 2205b76c07 | |||
| 82e3cf20a0 | |||
| bc4cdd181c | |||
| dec4a22208 | |||
| b48dbeff8e | |||
| 34a397eb18 | |||
| 2c3abfd352 | |||
| 1fc708177c | |||
| f670fe8e95 | |||
| 8f184fcb66 | |||
| 3765ef0df4 | |||
| 4e28b7e9a3 |
@@ -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
@@ -1 +1 @@
|
||||
__version__ = "3.4"
|
||||
__version__ = "3.6"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .apple_music import AppleMusicApi
|
||||
from .exceptions import *
|
||||
from .itunes import ItunesApi
|
||||
from .wrapper import WrapperApi
|
||||
|
||||
+13
-17
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
+16
-18
@@ -10,6 +10,7 @@ from httpx import ConnectError
|
||||
|
||||
from .. import __version__
|
||||
from ..api import AppleMusicApi
|
||||
from ..api.wrapper import WrapperApi
|
||||
from ..downloader import (
|
||||
AppleMusicBaseDownloader,
|
||||
AppleMusicDownloader,
|
||||
@@ -76,17 +77,23 @@ async def main(config: CliConfig):
|
||||
|
||||
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)
|
||||
@@ -94,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(
|
||||
@@ -109,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(
|
||||
@@ -125,17 +133,12 @@ async def main(config: CliConfig):
|
||||
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(
|
||||
@@ -173,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,
|
||||
@@ -196,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
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ class CustomOutputWriter:
|
||||
self.streams = streams
|
||||
|
||||
def add_file(self, path: str):
|
||||
file_stream = open(path, "a")
|
||||
file_stream = open(path, "a", encoding="utf-8")
|
||||
atexit.register(file_stream.close)
|
||||
self.streams.append(file_stream)
|
||||
|
||||
|
||||
@@ -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 *
|
||||
|
||||
+1862
-408
File diff suppressed because it is too large
Load Diff
+59
-34
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
@@ -215,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"}:
|
||||
|
||||
+12
-102
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
+75
-9
@@ -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
|
||||
|
||||
@@ -331,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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "gamdl"
|
||||
version = "3.4"
|
||||
version = "3.6"
|
||||
description = "A command-line app for downloading Apple Music songs, music videos and post videos."
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
Reference in New Issue
Block a user