mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-13 12:15:18 +03:00
Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d88dbe5bb6 | |||
| 8398d9c65f | |||
| c6bce4b2c1 | |||
| f54ab12408 | |||
| 817479d807 | |||
| d072f322db | |||
| a62ac76639 | |||
| 31b143d870 | |||
| 387861bb2f | |||
| 24fb9bddb9 | |||
| 30ca108b80 | |||
| 1d00e74ec6 | |||
| bb511de552 | |||
| 15c1bc64dd | |||
| 4f910c8e8a | |||
| ff3dcda54c | |||
| 7ac3322839 | |||
| 740cad2ee0 | |||
| 5a41dfbdaa | |||
| 141d9cd654 | |||
| 50f82b5de2 | |||
| eb9caff85c | |||
| 73e0b4b48d | |||
| 8f82697c14 | |||
| 4650391be3 | |||
| 0519adf693 | |||
| 4fc91bac9f | |||
| cb367049f1 | |||
| 34357ad31e | |||
| a7140cb860 | |||
| aa14693924 | |||
| 76a7c792cd | |||
| c75249bc2d | |||
| 001a502a5c | |||
| 1eba432153 | |||
| 622661a679 | |||
| 8200ee0dd1 | |||
| a8bf884d8f | |||
| 6d8ecf65b6 | |||
| 03fb4a255e | |||
| f8ec2367af | |||
| b5432d1344 | |||
| bd59bb7c98 | |||
| 92b8220c71 | |||
| 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 | |||
| a009071a8d | |||
| 64b1974232 | |||
| 37ede6572e | |||
| 2e57216c3c | |||
| 5d242c89cd | |||
| e5675f8874 | |||
| 716112c294 | |||
| 63ad0f2e07 | |||
| 939520b3f8 | |||
| df23276d3c | |||
| a9227493ea | |||
| 9375c2fccd | |||
| c83e47df0c |
@@ -23,31 +23,42 @@ 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`.
|
||||
|
||||
N_m3u8DL-RE also needs FFmpeg. If the FFmpeg executable is not available in your system PATH, set its location with `--ffmpeg-path` or `ffmpeg_path`.
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
@@ -61,9 +72,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
|
||||
|
||||
@@ -73,10 +83,10 @@ gamdl [OPTIONS] URLS...
|
||||
|
||||
### Supported URL Types
|
||||
|
||||
- Songs
|
||||
- Albums (Public/Library)
|
||||
- Playlists (Public/Library)
|
||||
- Music Videos
|
||||
- Songs (Catalog/Library)
|
||||
- Albums (Catalog/Library)
|
||||
- Playlists (Catalog/Library)
|
||||
- Music Videos (Catalog/Library)
|
||||
- Artists
|
||||
- Post Videos
|
||||
- Apple Music Classical
|
||||
@@ -123,66 +133,60 @@ 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` |
|
||||
| `--ffmpeg-path` | FFmpeg executable path | `ffmpeg` |
|
||||
| `--download-mode` | Download mode | `ytdlp` |
|
||||
| **Template Options** | | |
|
||||
| `--album-folder-template` | Album folder template | `{album_artist}/{album}` |
|
||||
| `--compilation-folder-template` | Compilation folder template | `Compilations/{album}` |
|
||||
| `--no-album-folder-template` | No album folder template | `{artist}/Unknown Album` |
|
||||
| `--playlist-folder-template` | Playlist folder template | `Playlists/{playlist_artist}` |
|
||||
| `--single-disc-file-template` | Single disc file template | `{track:02d} {title}` |
|
||||
| `--multi-disc-file-template` | Multi disc file template | `{disc}-{track:02d} {title}` |
|
||||
| `--no-album-file-template` | No album file template | `{title}` |
|
||||
| `--playlist-file-template` | Playlist file template | `{playlist_title}` |
|
||||
| `--date-tag-template` | Date tag template | `%Y-%m-%dT%H:%M:%SZ` |
|
||||
| `--exclude-tags` | Comma-separated tags to exclude | - |
|
||||
| `--truncate` | Max filename length | - |
|
||||
| **File Output Options** | | |
|
||||
| `--overwrite` | Overwrite existing files | `false` |
|
||||
| `--save-cover`, `-s` | Save cover as separate file | `false` |
|
||||
| `--save-playlist` | Save M3U8 playlist file | `false` |
|
||||
|
||||
### Template Variables
|
||||
|
||||
@@ -213,13 +217,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 +232,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 +247,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 +286,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 +365,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.2"
|
||||
__version__ = "3.7.3"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .apple_music import AppleMusicApi
|
||||
from .exceptions import *
|
||||
from .itunes import ItunesApi
|
||||
from .wrapper import WrapperApi
|
||||
|
||||
+165
-22
@@ -15,15 +15,22 @@ from .constants import (
|
||||
APPLE_MUSIC_HOMEPAGE_URL,
|
||||
APPLE_MUSIC_LIBRARY_ALBUM_API_URI,
|
||||
APPLE_MUSIC_LIBRARY_PLAYLIST_API_URI,
|
||||
APPLE_MUSIC_LIBRARY_PLAYLISTS_API_URI,
|
||||
APPLE_MUSIC_LICENSE_API_URL,
|
||||
APPLE_MUSIC_LIBRARY_MUSIC_VIDEO_API_URI,
|
||||
APPLE_MUSIC_MUSIC_VIDEO_API_URI,
|
||||
APPLE_MUSIC_LIBRARY_ALBUMS_API_URI,
|
||||
APPLE_MUSIC_PLAYLIST_API_URI,
|
||||
APPLE_MUSIC_SEARCH_API_URI,
|
||||
APPLE_MUSIC_LIBRARY_MUSIC_VIDEOS_API_URI,
|
||||
APPLE_MUSIC_LIBRARY_SONG_API_URI,
|
||||
APPLE_MUSIC_LIBRARY_SONGS_API_URI,
|
||||
APPLE_MUSIC_SONG_API_URI,
|
||||
APPLE_MUSIC_UPLOADED_VIDEO_API_URL,
|
||||
APPLE_MUSIC_WEBPLAYBACK_API_URL,
|
||||
)
|
||||
from .exceptions import GamdlApiResponseError
|
||||
from .wrapper import WrapperApi
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -242,25 +249,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,
|
||||
)
|
||||
@@ -428,9 +432,54 @@ class AppleMusicApi:
|
||||
|
||||
return artist
|
||||
|
||||
async def get_library_song(
|
||||
self,
|
||||
song_id: str,
|
||||
include: str = "catalog",
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_library_song", song_id=song_id)
|
||||
|
||||
song = await self._amp_request(
|
||||
APPLE_MUSIC_LIBRARY_SONG_API_URI.format(
|
||||
song_id=song_id,
|
||||
),
|
||||
{
|
||||
"include": include,
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", song=song)
|
||||
|
||||
return song
|
||||
|
||||
async def get_library_music_video(
|
||||
self,
|
||||
music_video_id: str,
|
||||
include: str = "catalog",
|
||||
) -> dict:
|
||||
log = logger.bind(
|
||||
action="get_library_music_video", music_video_id=music_video_id
|
||||
)
|
||||
|
||||
music_video = await self._amp_request(
|
||||
APPLE_MUSIC_LIBRARY_MUSIC_VIDEO_API_URI.format(
|
||||
music_video_id=music_video_id,
|
||||
),
|
||||
{
|
||||
"include": include,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", music_video=music_video)
|
||||
|
||||
return music_video
|
||||
|
||||
async def get_library_album(
|
||||
self,
|
||||
album_id: str,
|
||||
include: str = "catalog",
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_library_album", album_id=album_id)
|
||||
@@ -440,6 +489,7 @@ class AppleMusicApi:
|
||||
album_id=album_id,
|
||||
),
|
||||
{
|
||||
"include": include,
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
@@ -451,7 +501,7 @@ class AppleMusicApi:
|
||||
async def get_library_playlist(
|
||||
self,
|
||||
playlist_id: str,
|
||||
include: str = "tracks",
|
||||
include: str = "catalog,tracks",
|
||||
limit: int = 100,
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict:
|
||||
@@ -472,6 +522,92 @@ class AppleMusicApi:
|
||||
|
||||
return playlist
|
||||
|
||||
async def get_library_songs(
|
||||
self,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
include: str = "catalog",
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_library_songs")
|
||||
|
||||
library_songs = await self._amp_request(
|
||||
APPLE_MUSIC_LIBRARY_SONGS_API_URI,
|
||||
{
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"include": include,
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", library_songs=library_songs)
|
||||
|
||||
return library_songs
|
||||
|
||||
async def get_library_music_videos(
|
||||
self,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
include: str = "catalog",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_library_music_videos")
|
||||
|
||||
library_music_videos = await self._amp_request(
|
||||
APPLE_MUSIC_LIBRARY_MUSIC_VIDEOS_API_URI,
|
||||
{
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"include": include,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", library_music_videos=library_music_videos)
|
||||
|
||||
return library_music_videos
|
||||
|
||||
async def get_library_albums(
|
||||
self,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
include: str = "catalog",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_library_albums")
|
||||
|
||||
library_albums = await self._amp_request(
|
||||
APPLE_MUSIC_LIBRARY_ALBUMS_API_URI,
|
||||
{
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"include": include,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", library_albums=library_albums)
|
||||
|
||||
return library_albums
|
||||
|
||||
async def get_library_playlists(
|
||||
self,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
include: str = "catalog",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_library_playlists")
|
||||
|
||||
library_playlists = await self._amp_request(
|
||||
APPLE_MUSIC_LIBRARY_PLAYLISTS_API_URI,
|
||||
{
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"include": include,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", library_playlists=library_playlists)
|
||||
|
||||
return library_playlists
|
||||
|
||||
async def get_search_results(
|
||||
self,
|
||||
term: str,
|
||||
@@ -518,13 +654,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"]},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -535,17 +669,26 @@ class AppleMusicApi:
|
||||
async def get_webplayback(
|
||||
self,
|
||||
track_id: str,
|
||||
is_library: bool = False,
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_webplayback", track_id=track_id)
|
||||
|
||||
response = None
|
||||
|
||||
if is_library:
|
||||
request_body = {
|
||||
"universalLibraryId": track_id,
|
||||
}
|
||||
else:
|
||||
request_body = {
|
||||
"salableAdamId": track_id,
|
||||
}
|
||||
request_body["language"] = self.language
|
||||
|
||||
try:
|
||||
response = await self.client.post(
|
||||
APPLE_MUSIC_WEBPLAYBACK_API_URL,
|
||||
json={
|
||||
"salableAdamId": track_id,
|
||||
"language": self.language,
|
||||
},
|
||||
json=request_body,
|
||||
)
|
||||
response.raise_for_status()
|
||||
webplayback = response.json()
|
||||
|
||||
@@ -14,9 +14,17 @@ APPLE_MUSIC_UPLOADED_VIDEO_API_URL = (
|
||||
APPLE_MUSIC_ALBUM_API_URI = "/v1/catalog/{storefront}/albums/{album_id}"
|
||||
APPLE_MUSIC_PLAYLIST_API_URI = "/v1/catalog/{storefront}/playlists/{playlist_id}"
|
||||
APPLE_MUSIC_ARTIST_API_URI = "/v1/catalog/{storefront}/artists/{artist_id}"
|
||||
APPLE_MUSIC_LIBRARY_SONG_API_URI = "/v1/me/library/songs/{song_id}"
|
||||
APPLE_MUSIC_LIBRARY_MUSIC_VIDEO_API_URI = (
|
||||
"/v1/me/library/music-videos/{music_video_id}"
|
||||
)
|
||||
APPLE_MUSIC_LIBRARY_ALBUM_API_URI = "/v1/me/library/albums/{album_id}"
|
||||
APPLE_MUSIC_LIBRARY_PLAYLIST_API_URI = "/v1/me/library/playlists/{playlist_id}"
|
||||
APPLE_MUSIC_SEARCH_API_URI = "/v1/catalog/{storefront}/search"
|
||||
APPLE_MUSIC_LIBRARY_SONGS_API_URI = "/v1/me/library/songs"
|
||||
APPLE_MUSIC_LIBRARY_MUSIC_VIDEOS_API_URI = "/v1/me/library/music-videos"
|
||||
APPLE_MUSIC_LIBRARY_ALBUMS_API_URI = "/v1/me/library/albums"
|
||||
APPLE_MUSIC_LIBRARY_PLAYLISTS_API_URI = "/v1/me/library/playlists"
|
||||
|
||||
APPLE_MUSIC_WEBPLAYBACK_API_URL = (
|
||||
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback"
|
||||
|
||||
+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
|
||||
+22
-31
@@ -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,7 @@ 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 +196,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
-55
@@ -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,14 +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(
|
||||
@@ -336,22 +321,6 @@ class CliConfig:
|
||||
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 +422,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
@@ -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,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
@@ -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,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 *
|
||||
|
||||
+2062
-437
File diff suppressed because it is too large
Load Diff
+103
-34
@@ -1,11 +1,16 @@
|
||||
import asyncio
|
||||
import multiprocessing
|
||||
import queue
|
||||
import re
|
||||
import shutil
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from mutagen.mp4 import MP4, MP4Cover
|
||||
from yt_dlp import YoutubeDL
|
||||
from yt_dlp.downloader.hls import HlsFD
|
||||
from yt_dlp.downloader.http import HttpFD
|
||||
|
||||
from ..interface.enums import CoverFormat
|
||||
from ..interface.interface import AppleMusicInterface
|
||||
@@ -17,6 +22,51 @@ from .enums import DownloadMode
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _download_ytdlp_process(
|
||||
stream_url: str,
|
||||
download_path: str,
|
||||
silent: bool,
|
||||
result_queue,
|
||||
) -> None:
|
||||
try:
|
||||
Path(download_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with YoutubeDL(
|
||||
{
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"overwrites": True,
|
||||
"noprogress": silent,
|
||||
"allow_unplayable_formats": True,
|
||||
"concurrent_fragment_downloads": 8,
|
||||
}
|
||||
) as ydl:
|
||||
if stream_url.split("?")[0].endswith(".m3u8"):
|
||||
hls_downloader = HlsFD(ydl, ydl.params)
|
||||
success, _ = hls_downloader.download(
|
||||
download_path,
|
||||
{
|
||||
"url": stream_url,
|
||||
"ext": "mp4",
|
||||
"protocol": "m3u8",
|
||||
},
|
||||
)
|
||||
if not success:
|
||||
raise RuntimeError("yt-dlp HLS download failed")
|
||||
else:
|
||||
http_downloader = HttpFD(ydl, ydl.params)
|
||||
success, _ = http_downloader.download(
|
||||
download_path,
|
||||
{
|
||||
"url": stream_url,
|
||||
},
|
||||
)
|
||||
if not success:
|
||||
raise RuntimeError("yt-dlp HTTP download failed")
|
||||
except Exception as e:
|
||||
result_queue.put(("error", repr(e), traceback.format_exc()))
|
||||
|
||||
|
||||
class AppleMusicBaseDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -24,10 +74,7 @@ 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 +93,7 @@ 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 +114,12 @@ 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(
|
||||
@@ -200,40 +240,70 @@ class AppleMusicBaseDownloader:
|
||||
|
||||
return final_path
|
||||
|
||||
async def download_stream(self, stream_url: str, download_path: str):
|
||||
async def download_stream(
|
||||
self,
|
||||
stream_url: str,
|
||||
download_path: str,
|
||||
):
|
||||
log = logger.bind(
|
||||
action="download_stream", stream_url=stream_url, download_path=download_path
|
||||
)
|
||||
|
||||
if self.download_mode == DownloadMode.YTDLP:
|
||||
await self._download_ytdlp_async(stream_url, download_path)
|
||||
stream_url_stripped = stream_url.split("?")[0]
|
||||
|
||||
if self.download_mode == DownloadMode.NM3U8DLRE:
|
||||
if (
|
||||
self.download_mode == DownloadMode.YTDLP
|
||||
or not stream_url_stripped.endswith(".m3u8")
|
||||
):
|
||||
await self._download_ytdlp_async(
|
||||
stream_url,
|
||||
download_path,
|
||||
)
|
||||
|
||||
elif self.download_mode == DownloadMode.NM3U8DLRE:
|
||||
await self._download_nm3u8dlre(stream_url, download_path)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
async def _download_ytdlp_async(self, stream_url: str, download_path: str) -> None:
|
||||
await asyncio.to_thread(
|
||||
self._download_ytdlp_sync,
|
||||
stream_url,
|
||||
download_path,
|
||||
async def _download_ytdlp_async(
|
||||
self,
|
||||
stream_url: str,
|
||||
download_path: str,
|
||||
) -> None:
|
||||
ctx = multiprocessing.get_context()
|
||||
result_queue = ctx.Queue()
|
||||
process = ctx.Process(
|
||||
target=_download_ytdlp_process,
|
||||
args=(stream_url, download_path, self.silent, result_queue),
|
||||
)
|
||||
process.start()
|
||||
|
||||
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)
|
||||
@@ -296,7 +366,6 @@ class AppleMusicBaseDownloader:
|
||||
skip_tagging: bool,
|
||||
):
|
||||
mp4 = MP4(media_path)
|
||||
mp4.clear()
|
||||
|
||||
if not skip_tagging:
|
||||
if cover_bytes is not None:
|
||||
|
||||
@@ -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
@@ -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 = (
|
||||
|
||||
+200
-181
@@ -1,181 +1,200 @@
|
||||
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:
|
||||
if download_item.media.stream_info.audio_track.drm_free:
|
||||
await self.base.download_stream(
|
||||
download_item.media.stream_info.audio_track.stream_url,
|
||||
download_item.staged_path,
|
||||
)
|
||||
else:
|
||||
encrypted_path = self.base.get_temp_path(
|
||||
download_item.media.media_metadata["id"],
|
||||
download_item.uuid_,
|
||||
"encrypted",
|
||||
".m4a",
|
||||
)
|
||||
await self.base.download_stream(
|
||||
download_item.media.stream_info.audio_track.stream_url,
|
||||
encrypted_path,
|
||||
)
|
||||
|
||||
await self.stage(
|
||||
encrypted_path,
|
||||
download_item.staged_path,
|
||||
download_item.media.media_id,
|
||||
download_item.media.decryption_key,
|
||||
download_item.media.stream_info.audio_track.fairplay_key,
|
||||
download_item.media.stream_info.audio_track.use_cenc,
|
||||
download_item.media.stream_info.audio_track.use_single_content_key,
|
||||
)
|
||||
|
||||
cover_bytes = (
|
||||
await self.base.interface.base.get_cover_bytes(
|
||||
download_item.media.cover.url
|
||||
)
|
||||
if self.base.interface.base.cover_format != CoverFormat.RAW
|
||||
else None
|
||||
)
|
||||
await self.base.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media.tags,
|
||||
cover_bytes,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,7 +46,7 @@ class AppleMusicUploadedVideoDownloader:
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
await self.base._download_ytdlp_async(
|
||||
await self.base.download_stream(
|
||||
download_item.media.stream_info.video_track.stream_url,
|
||||
download_item.staged_path,
|
||||
)
|
||||
|
||||
+96
-21
@@ -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:
|
||||
@@ -57,11 +56,6 @@ class AppleMusicBaseInterface:
|
||||
) -> bool:
|
||||
return bool(media_metadata["attributes"].get("playParams"))
|
||||
|
||||
@staticmethod
|
||||
def parse_catalog_media_id(media_metadata: dict) -> str:
|
||||
play_params = media_metadata["attributes"].get("playParams", {})
|
||||
return play_params.get("catalogId", media_metadata["id"])
|
||||
|
||||
@staticmethod
|
||||
def parse_media_id_from_url(media_metadata: dict) -> str | None:
|
||||
media_url = media_metadata["attributes"].get("url")
|
||||
@@ -119,20 +113,32 @@ class AppleMusicBaseInterface:
|
||||
template_cover_url,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_catalog_metadata_from_library(library_metadata: dict) -> dict | None:
|
||||
data = library_metadata.get("relationships", {}).get("catalog", {}).get("data")
|
||||
if not data:
|
||||
return None
|
||||
|
||||
return data[0]
|
||||
|
||||
@classmethod
|
||||
async def create(
|
||||
cls,
|
||||
apple_music_api: AppleMusicApi,
|
||||
cover_format: CoverFormat = CoverFormat.JPG,
|
||||
cover_size: int = 1200,
|
||||
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 +147,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 +228,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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -257,9 +266,7 @@ class AppleMusicBaseInterface:
|
||||
self,
|
||||
metadata: dict,
|
||||
) -> str:
|
||||
log = logger.bind(
|
||||
action="get_cover", media_id=self.parse_catalog_media_id(metadata)
|
||||
)
|
||||
log = logger.bind(action="get_cover", media_id=metadata["id"])
|
||||
|
||||
template_url = self._get_cover_template_url(metadata)
|
||||
|
||||
@@ -327,3 +334,71 @@ 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"]) if asset_data.get("artistId") else None
|
||||
),
|
||||
artist_sort=asset_data["sort-artist"],
|
||||
comment=asset_data.get("comments"),
|
||||
compilation=asset_data.get("compilation"),
|
||||
composer=asset_data.get("composerName"),
|
||||
composer_id=(
|
||||
int(asset_data.get("composerId"))
|
||||
if asset_data.get("composerId")
|
||||
else None
|
||||
),
|
||||
composer_sort=asset_data.get("sort-composer"),
|
||||
copyright=asset_data.get("copyright"),
|
||||
date=(
|
||||
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"]) if asset_data.get("genreId") else None
|
||||
),
|
||||
lyrics=lyrics if lyrics else None,
|
||||
media_type=(
|
||||
MediaType.SONG
|
||||
if asset_data["kind"] == "song"
|
||||
else MediaType.MUSIC_VIDEO
|
||||
),
|
||||
rating=MediaRating(asset_data["explicit"]),
|
||||
storefront=(int(asset_data["s"]) if asset_data.get("s") else None),
|
||||
title=asset_data["itemName"],
|
||||
title_id=int(asset_data["itemId"]),
|
||||
title_sort=asset_data["sort-name"],
|
||||
track=asset_data.get("trackNumber"),
|
||||
track_total=asset_data.get("trackCount"),
|
||||
xid=asset_data.get("xid"),
|
||||
)
|
||||
|
||||
log.debug("success", tags=tags)
|
||||
|
||||
return tags
|
||||
|
||||
@@ -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"
|
||||
@@ -73,8 +71,8 @@ VALID_URL_PATTERN = re.compile(
|
||||
r"(?:\?i=(?P<sub_id>[0-9]+))?"
|
||||
r"|"
|
||||
r"(?:/(?P<library_storefront>[a-z]{2}))?"
|
||||
r"/library/(?P<library_type>playlist|albums)"
|
||||
r"/(?P<library_id>p\.[a-zA-Z0-9]+|l\.[a-zA-Z0-9]+)"
|
||||
r"/library/(?P<library_type>playlist|albums|songs|music-videos)"
|
||||
r"/(?P<library_id>[pli]\.[a-zA-Z0-9]+)"
|
||||
r")"
|
||||
)
|
||||
|
||||
@@ -96,3 +94,10 @@ ARTIST_AUTO_SELECT_STR_MAP = {
|
||||
"top-songs": "Top Songs",
|
||||
"music-videos": "Music Videos",
|
||||
}
|
||||
|
||||
MEDIA_CODEC_FLAVOR_MAP = {
|
||||
"aac-web": "28:ctrp256",
|
||||
"aac-he-web": "32:ctrp64",
|
||||
"aac-fps-web": "30:cbcp256",
|
||||
"aac-he-fps-web": "34:cbcp64",
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
MEDIA_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 MEDIA_CODEC_FLAVOR_MAP.get(self.value)
|
||||
|
||||
@property
|
||||
def is_cenc(self) -> bool:
|
||||
return self.flavor is not None and "ctrp" in self.flavor
|
||||
|
||||
|
||||
class MusicVideoCodec(Enum):
|
||||
@@ -68,8 +80,9 @@ class MusicVideoCodec(Enum):
|
||||
H265 = "h265"
|
||||
ASK = "ask"
|
||||
|
||||
def fourcc(self) -> str:
|
||||
return FOURCC_MAP[self.value]
|
||||
@property
|
||||
def fourcc(self) -> str | None:
|
||||
return FOURCC_MAP.get(self.value)
|
||||
|
||||
|
||||
class MusicVideoResolution(Enum):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -101,19 +101,16 @@ class AppleMusicInterface:
|
||||
total: int | None = None,
|
||||
media_metadata: dict | None = None,
|
||||
playlist_metadata: dict | None = None,
|
||||
is_library: bool = False,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
media = AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
is_library=is_library,
|
||||
index=index,
|
||||
total=total,
|
||||
media_metadata=media_metadata,
|
||||
playlist_metadata=playlist_metadata,
|
||||
)
|
||||
|
||||
if index is not None:
|
||||
media.index = index
|
||||
if total is not None:
|
||||
media.total = total
|
||||
|
||||
media.media_metadata = media_metadata
|
||||
media.playlist_metadata = playlist_metadata
|
||||
|
||||
try:
|
||||
async for media in self.song.get_media(media):
|
||||
yield media
|
||||
@@ -133,16 +130,17 @@ class AppleMusicInterface:
|
||||
total: int | None = None,
|
||||
media_metadata: dict | None = None,
|
||||
playlist_metadata: dict | None = None,
|
||||
is_library: bool = False,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
media = AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
is_library=is_library,
|
||||
index=index,
|
||||
total=total,
|
||||
media_metadata=media_metadata,
|
||||
playlist_metadata=playlist_metadata,
|
||||
)
|
||||
|
||||
if index is not None:
|
||||
media.index = index
|
||||
if total is not None:
|
||||
media.total = total
|
||||
|
||||
media.media_metadata = media_metadata
|
||||
media.playlist_metadata = playlist_metadata
|
||||
|
||||
@@ -187,12 +185,14 @@ class AppleMusicInterface:
|
||||
|
||||
try:
|
||||
base_media.media_metadata = (
|
||||
await self.base.apple_music_api.get_library_album(
|
||||
media_id,
|
||||
)
|
||||
if is_library
|
||||
else await self.base.apple_music_api.get_album(
|
||||
media_id,
|
||||
await (
|
||||
self.base.apple_music_api.get_library_album(
|
||||
media_id,
|
||||
)
|
||||
if is_library
|
||||
else self.base.apple_music_api.get_album(
|
||||
media_id,
|
||||
)
|
||||
)
|
||||
)["data"][0]
|
||||
|
||||
@@ -214,6 +214,7 @@ class AppleMusicInterface:
|
||||
index=index,
|
||||
total=base_media.media_metadata["attributes"]["trackCount"],
|
||||
media_metadata=track,
|
||||
is_library=is_library,
|
||||
)
|
||||
if track["type"] in {"songs", "library-songs"}
|
||||
else self._get_music_video_media(
|
||||
@@ -221,6 +222,7 @@ class AppleMusicInterface:
|
||||
index=index,
|
||||
total=base_media.media_metadata["attributes"]["trackCount"],
|
||||
media_metadata=track,
|
||||
is_library=is_library,
|
||||
)
|
||||
)
|
||||
for index, track in enumerate(tracks)
|
||||
@@ -246,12 +248,14 @@ class AppleMusicInterface:
|
||||
|
||||
try:
|
||||
base_media.media_metadata = (
|
||||
await self.base.apple_music_api.get_library_playlist(
|
||||
media_id,
|
||||
)
|
||||
if is_library
|
||||
else await self.base.apple_music_api.get_playlist(
|
||||
media_id,
|
||||
await (
|
||||
self.base.apple_music_api.get_library_playlist(
|
||||
media_id,
|
||||
)
|
||||
if is_library
|
||||
else self.base.apple_music_api.get_playlist(
|
||||
media_id,
|
||||
)
|
||||
)
|
||||
)["data"][0]
|
||||
|
||||
@@ -281,17 +285,17 @@ class AppleMusicInterface:
|
||||
self._get_song_media(
|
||||
media_id=track["id"],
|
||||
index=index,
|
||||
total=base_media.media_metadata["attributes"]["trackCount"],
|
||||
media_metadata=track,
|
||||
playlist_metadata=base_media.media_metadata,
|
||||
is_library=is_library,
|
||||
)
|
||||
if track["type"] in {"songs", "library-songs"}
|
||||
else self._get_music_video_media(
|
||||
media_id=track["id"],
|
||||
index=index,
|
||||
total=base_media.media_metadata["attributes"]["trackCount"],
|
||||
media_metadata=track,
|
||||
playlist_metadata=base_media.media_metadata,
|
||||
is_library=is_library,
|
||||
)
|
||||
)
|
||||
for index, track in enumerate(tracks)
|
||||
@@ -427,32 +431,38 @@ class AppleMusicInterface:
|
||||
url_info.type,
|
||||
)
|
||||
|
||||
if url_info.type == "song" or url_info.sub_id:
|
||||
if (
|
||||
url_info.type == "song"
|
||||
or url_info.library_type == "songs"
|
||||
or url_info.sub_id
|
||||
):
|
||||
async for media in self._get_song_media(
|
||||
media_id=url_info.sub_id or url_info.id,
|
||||
media_id=url_info.sub_id or url_info.id or url_info.library_id,
|
||||
index=0,
|
||||
total=1,
|
||||
is_library=bool(url_info.library_type),
|
||||
):
|
||||
yield media
|
||||
|
||||
elif url_info.type == "music-video":
|
||||
elif url_info.type == "music-video" or url_info.library_type == "music-videos":
|
||||
async for media in self._get_music_video_media(
|
||||
media_id=url_info.id,
|
||||
media_id=url_info.id or url_info.library_id,
|
||||
index=0,
|
||||
total=1,
|
||||
is_library=bool(url_info.library_type),
|
||||
):
|
||||
yield media
|
||||
|
||||
elif url_info.type == "album" or url_info.library_type == "albums":
|
||||
async for media in self._get_album_media(
|
||||
media_id=url_info.library_id or url_info.id,
|
||||
media_id=url_info.id or url_info.library_id,
|
||||
is_library=bool(url_info.library_type),
|
||||
):
|
||||
yield media
|
||||
|
||||
elif url_info.type == "playlist" or url_info.library_type == "playlist":
|
||||
async for media in self._get_playlist_media(
|
||||
media_id=url_info.library_id or url_info.id,
|
||||
media_id=url_info.id or url_info.library_id,
|
||||
is_library=bool(url_info.library_type),
|
||||
):
|
||||
yield media
|
||||
|
||||
+118
-43
@@ -57,13 +57,20 @@ class AppleMusicMusicVideoInterface:
|
||||
return itunes_page["storePlatformData"]["product-dv"]["results"][url_media_id]
|
||||
|
||||
def _get_m3u8_master_url_from_webplayback(self, webplayback: dict) -> str:
|
||||
log = logger.bind(action="get_m3u8_master_url_from_webplayback")
|
||||
|
||||
m3u8_master_url = webplayback["hls-playlist-url"]
|
||||
|
||||
log.debug("success", m3u8_master_url=m3u8_master_url)
|
||||
|
||||
return m3u8_master_url
|
||||
|
||||
def _get_m3u8_master_url_from_itunes_page_metadata(
|
||||
self,
|
||||
itunes_page_metadata: dict,
|
||||
) -> str | None:
|
||||
log = logger.bind(action="get_m3u8_master_url_from_itunes_page_metadata")
|
||||
|
||||
stream_url = itunes_page_metadata["offers"][0]["assets"][0].get("hlsUrl")
|
||||
if not stream_url:
|
||||
return None
|
||||
@@ -76,6 +83,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(
|
||||
@@ -85,7 +102,7 @@ class AppleMusicMusicVideoInterface:
|
||||
) -> MediaTags:
|
||||
log = logger.bind(
|
||||
action="get_music_video_tags",
|
||||
media_id=self.base.parse_catalog_media_id(metadata),
|
||||
media_id=metadata["id"],
|
||||
)
|
||||
|
||||
url_media_id = self.base.parse_media_id_from_url(metadata)
|
||||
@@ -110,7 +127,8 @@ class AppleMusicMusicVideoInterface:
|
||||
genre_id=int(itunes_page_metadata["genres"][0]["genreId"]),
|
||||
media_type=MediaType.MUSIC_VIDEO,
|
||||
storefront=self.base.itunes_api.storefront_id,
|
||||
title=lookup_metadata[0]["trackCensoredName"],
|
||||
title=lookup_metadata[0]["trackName"],
|
||||
title_sort=lookup_metadata[0]["trackCensoredName"],
|
||||
title_id=int(metadata["id"]),
|
||||
rating=rating,
|
||||
)
|
||||
@@ -122,7 +140,8 @@ class AppleMusicMusicVideoInterface:
|
||||
if not album:
|
||||
return tags
|
||||
|
||||
tags.album = lookup_metadata[1]["collectionCensoredName"]
|
||||
tags.album = lookup_metadata[1]["collectionName"]
|
||||
tags.album_sort = lookup_metadata[1]["collectionCensoredName"]
|
||||
tags.album_artist = lookup_metadata[1]["artistName"]
|
||||
tags.album_id = int(itunes_page_metadata["collectionId"])
|
||||
tags.disc = lookup_metadata[0]["discNumber"]
|
||||
@@ -135,39 +154,51 @@ class AppleMusicMusicVideoInterface:
|
||||
|
||||
return tags
|
||||
|
||||
async def get_stream_info(
|
||||
async def get_m3u8_master_url(
|
||||
self,
|
||||
metadata: dict,
|
||||
itunes_page_metadata: dict,
|
||||
) -> StreamInfoAv | None:
|
||||
log = logger.bind(
|
||||
action="get_music_video_stream_info",
|
||||
media_id=self.base.parse_catalog_media_id(metadata),
|
||||
)
|
||||
|
||||
) -> str | None:
|
||||
url_media_id = self.base.parse_media_id_from_url(metadata)
|
||||
m3u8_master_url = None
|
||||
|
||||
if url_media_id == metadata["id"]:
|
||||
m3u8_master_url = self._get_m3u8_master_url_from_itunes_page_metadata(
|
||||
return self._get_m3u8_master_url_from_itunes_page_metadata(
|
||||
itunes_page_metadata,
|
||||
)
|
||||
|
||||
webplayback_response = await self.base.apple_music_api.get_webplayback(
|
||||
metadata["id"]
|
||||
)
|
||||
return self._get_m3u8_master_url_from_webplayback(
|
||||
webplayback_response["songList"][0],
|
||||
)
|
||||
|
||||
async def _get_stream_info(
|
||||
self,
|
||||
m3u8_master_url: str | None,
|
||||
codec: MusicVideoCodec,
|
||||
) -> StreamInfoAv | None:
|
||||
log = logger.bind(
|
||||
action="get_music_video_stream_info",
|
||||
m3u8_master_url=m3u8_master_url,
|
||||
codec=codec.value,
|
||||
)
|
||||
|
||||
if not m3u8_master_url:
|
||||
webplayback_response = await self.base.apple_music_api.get_webplayback(
|
||||
metadata["id"]
|
||||
)
|
||||
m3u8_master_url = self._get_m3u8_master_url_from_webplayback(
|
||||
webplayback_response["songList"][0],
|
||||
)
|
||||
log.debug("no_m3u8_master_url")
|
||||
return None
|
||||
|
||||
playlist_master_m3u8_obj = m3u8.loads(
|
||||
(await self.base.get_response(m3u8_master_url)).text
|
||||
)
|
||||
playlist_master_m3u8_obj.base_uri = m3u8_master_url.rpartition("/")[0]
|
||||
stream_info_video = await self._get_stream_info_video(playlist_master_m3u8_obj)
|
||||
stream_info_video = await self._get_stream_info_video(
|
||||
playlist_master_m3u8_obj,
|
||||
codec,
|
||||
)
|
||||
stream_info_audio = await self._get_stream_info_audio(
|
||||
playlist_master_m3u8_obj.data,
|
||||
codec,
|
||||
)
|
||||
if not stream_info_video or not stream_info_audio:
|
||||
return None
|
||||
@@ -195,20 +226,20 @@ class AppleMusicMusicVideoInterface:
|
||||
def _get_video_playlist_from_resolution(
|
||||
self,
|
||||
video_playlists: list[m3u8.Playlist],
|
||||
codec: MusicVideoCodec,
|
||||
) -> m3u8.Playlist | None:
|
||||
playlist_results = []
|
||||
for codec_index, codec in enumerate(self.codec_priority):
|
||||
for playlist in video_playlists:
|
||||
if playlist.stream_info.codecs.startswith(codec.fourcc()):
|
||||
playlist_results.append((codec_index, playlist))
|
||||
playlist_results = [
|
||||
playlist
|
||||
for playlist in video_playlists
|
||||
if playlist.stream_info.codecs.startswith(codec.fourcc)
|
||||
]
|
||||
|
||||
if not playlist_results:
|
||||
return None
|
||||
|
||||
def sort_key(
|
||||
item: tuple[int, m3u8.Playlist],
|
||||
) -> tuple[bool, int, int, int, int]:
|
||||
codec_index, playlist = item
|
||||
playlist: m3u8.Playlist,
|
||||
) -> tuple[bool, int, int, int]:
|
||||
playlist_resolution = playlist.stream_info.resolution[-1]
|
||||
bandwidth = playlist.stream_info.bandwidth
|
||||
exceeds_resolution = playlist_resolution > int(self.resolution)
|
||||
@@ -217,13 +248,12 @@ class AppleMusicMusicVideoInterface:
|
||||
return (
|
||||
exceeds_resolution,
|
||||
resolution_difference,
|
||||
codec_index,
|
||||
-playlist_resolution,
|
||||
-bandwidth,
|
||||
)
|
||||
|
||||
playlist_results.sort(key=sort_key)
|
||||
return playlist_results[0][1]
|
||||
return playlist_results[0]
|
||||
|
||||
def _get_best_stereo_audio_playlist(
|
||||
self,
|
||||
@@ -302,12 +332,14 @@ class AppleMusicMusicVideoInterface:
|
||||
async def _get_stream_info_video(
|
||||
self,
|
||||
playlist_master_m3u8_obj: m3u8.M3U8,
|
||||
codec: MusicVideoCodec,
|
||||
) -> StreamInfo | None:
|
||||
stream_info = StreamInfo()
|
||||
|
||||
if MusicVideoCodec.ASK not in self.codec_priority:
|
||||
if codec != MusicVideoCodec.ASK:
|
||||
playlist = self._get_video_playlist_from_resolution(
|
||||
playlist_master_m3u8_obj.playlists,
|
||||
codec,
|
||||
)
|
||||
else:
|
||||
playlist = await self._get_video_playlist_from_user(
|
||||
@@ -333,10 +365,11 @@ class AppleMusicMusicVideoInterface:
|
||||
async def _get_stream_info_audio(
|
||||
self,
|
||||
playlist_master_data: dict,
|
||||
codec: MusicVideoCodec,
|
||||
) -> StreamInfo | None:
|
||||
stream_info = StreamInfo()
|
||||
|
||||
if MusicVideoCodec.ASK not in self.codec_priority:
|
||||
if codec != MusicVideoCodec.ASK:
|
||||
playlist = self._get_best_stereo_audio_playlist(playlist_master_data)
|
||||
else:
|
||||
playlist = await self._get_audio_playlist_from_user(playlist_master_data)
|
||||
@@ -356,6 +389,27 @@ class AppleMusicMusicVideoInterface:
|
||||
|
||||
return stream_info
|
||||
|
||||
async def get_stream_info(
|
||||
self,
|
||||
media_id: str,
|
||||
m3u8_master_url: str | None,
|
||||
) -> StreamInfoAv:
|
||||
stream_info = None
|
||||
|
||||
for codec in self.codec_priority:
|
||||
stream_info = await self._get_stream_info(m3u8_master_url, codec)
|
||||
|
||||
if stream_info:
|
||||
break
|
||||
|
||||
if not stream_info:
|
||||
raise GamdlInterfaceFormatNotAvailableError(
|
||||
media_id=media_id,
|
||||
codec=[codec.value for codec in self.codec_priority],
|
||||
)
|
||||
|
||||
return stream_info
|
||||
|
||||
async def get_decryption_key(
|
||||
self,
|
||||
stream_info: StreamInfoAv,
|
||||
@@ -382,10 +436,24 @@ class AppleMusicMusicVideoInterface:
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
if not media.media_metadata:
|
||||
media.media_metadata = (
|
||||
await self.base.apple_music_api.get_music_video(media.media_id)
|
||||
await (
|
||||
self.base.apple_music_api.get_library_music_video(media.media_id)
|
||||
if media.is_library
|
||||
else self.base.apple_music_api.get_music_video(media.media_id)
|
||||
)
|
||||
)["data"][0]
|
||||
|
||||
media.media_id = self.base.parse_catalog_media_id(media.media_metadata)
|
||||
if media.media_metadata["attributes"].get("playParams", {}).get("isLibrary"):
|
||||
catalog_metadata = self.base.get_catalog_metadata_from_library(
|
||||
media.media_metadata
|
||||
)
|
||||
if catalog_metadata:
|
||||
media.media_id = catalog_metadata["id"]
|
||||
media.is_library = False
|
||||
media.media_metadata = catalog_metadata
|
||||
|
||||
if media.is_library:
|
||||
raise GamdlInterfaceMediaNotStreamableError(media.media_id)
|
||||
|
||||
yield media
|
||||
|
||||
@@ -401,20 +469,27 @@ 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,
|
||||
)
|
||||
|
||||
media.stream_info = await self.get_stream_info(
|
||||
if self.base.wrapper_api:
|
||||
playback = await self.base.wrapper_api.get_playback(media.media_id)
|
||||
media.tags = await self.base.get_tags_from_asset_info(
|
||||
playback["songList"][0]["assets"][0]["metadata"],
|
||||
)
|
||||
else:
|
||||
playback = None
|
||||
media.tags = await self.get_tags(
|
||||
media.media_metadata,
|
||||
itunes_page_metadata,
|
||||
)
|
||||
|
||||
m3u8_master_url = await self.get_m3u8_master_url(
|
||||
media.media_metadata,
|
||||
itunes_page_metadata,
|
||||
)
|
||||
if not media.stream_info:
|
||||
raise GamdlInterfaceFormatNotAvailableError(
|
||||
media.media_id,
|
||||
self.codec_priority,
|
||||
)
|
||||
media.stream_info = await self.get_stream_info(
|
||||
media.media_id,
|
||||
m3u8_master_url,
|
||||
)
|
||||
|
||||
if (
|
||||
not media.stream_info.video_track.widevine_pssh
|
||||
|
||||
+602
-543
File diff suppressed because it is too large
Load Diff
@@ -122,7 +122,9 @@ class StreamInfo:
|
||||
codec: str = None
|
||||
width: int = None
|
||||
height: int = None
|
||||
legacy: bool = None
|
||||
drm_free: bool = False
|
||||
use_cenc: bool = False
|
||||
use_single_content_key: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -155,6 +157,7 @@ class Cover:
|
||||
@dataclass
|
||||
class AppleMusicMedia:
|
||||
media_id: str
|
||||
is_library: bool = False
|
||||
index: int = 0
|
||||
total: int = 0
|
||||
partial: bool = True
|
||||
|
||||
@@ -79,6 +79,7 @@ class AppleMusicUploadedVideoInterface:
|
||||
file_format=MediaFileFormat.M4V,
|
||||
video_track=StreamInfo(
|
||||
stream_url=stream_url,
|
||||
drm_free=True,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -113,7 +114,7 @@ class AppleMusicUploadedVideoInterface:
|
||||
await self.base.apple_music_api.get_uploaded_video(media.media_id)
|
||||
)["data"][0]
|
||||
|
||||
media.media_id = self.base.parse_catalog_media_id(media.media_metadata)
|
||||
media.media_id = media["id"]
|
||||
|
||||
yield media
|
||||
|
||||
|
||||
+14
-5
@@ -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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "gamdl"
|
||||
version = "3.2"
|
||||
version = "3.7.3"
|
||||
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