mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-13 20:25:13 +03:00
Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ade78ad7b3 | |||
| 054f636434 | |||
| bf9c74d9d8 | |||
| 3c48618e84 | |||
| c940ee2f47 | |||
| 7f56dfd0c8 | |||
| 7c3112421d | |||
| 55ce7555a9 | |||
| 9c4adbb2c1 | |||
| 1591f0daf2 | |||
| 25d028bea4 | |||
| ebc28a019e | |||
| 690df6e9d7 | |||
| 8039c7c86f | |||
| f67ba37d19 | |||
| 59f247a90f | |||
| 181bdb198d | |||
| 1945342adc | |||
| f19ef4d6dd | |||
| 1ceb7fcf46 | |||
| 23ed14ca04 | |||
| 3e3939d0ee | |||
| 780261a9c8 | |||
| 80cb80e9a2 | |||
| f3b7adaad3 | |||
| fe6a6e308d | |||
| b08bf98759 | |||
| 37c857b503 | |||
| 4693ba69c9 | |||
| 9212319d3b | |||
| e54f318c36 | |||
| b1e40299ca | |||
| ba86825068 | |||
| b5f08753b8 | |||
| d4bf75c0d1 | |||
| e998ce1a2e | |||
| 5285ca0cfa | |||
| f3927b8e6d | |||
| 40b7ce05d3 | |||
| 8cd01e7964 | |||
| f769c6b686 | |||
| ea7356e7c4 | |||
| f3d8242110 | |||
| faf3bb3a20 | |||
| 24c3ce8a02 | |||
| 65eb8c0fb6 | |||
| f90be057d6 | |||
| 76cc80cba8 | |||
| 7a7c1adb22 | |||
| 200e392fad | |||
| 1083957303 | |||
| ae6bed11af | |||
| 7da83866cf | |||
| 273b171398 | |||
| 2913d96b70 | |||
| a332516056 | |||
| c636e4be33 | |||
| 1841a988e2 | |||
| 8cdaa127d7 | |||
| c31a6eee8e | |||
| 00d301c23d | |||
| f05aa579d3 | |||
| 7e642ab2f3 | |||
| c34f49faae | |||
| 78c3da5b8c | |||
| 00410aeb77 | |||
| 4211ab6f8c | |||
| 599c9140db | |||
| 73ab79beea | |||
| 2dfed33fe2 | |||
| 4eb764af17 | |||
| 6cdccf1f4f | |||
| a999271715 | |||
| 633674f45e | |||
| ceeef6b352 | |||
| 8aa172185a | |||
| bdbaf7ca05 | |||
| a9e1e02ebb | |||
| 85619a3672 | |||
| 15c1cc45f5 | |||
| b86e938185 | |||
| be4596798a | |||
| da8e49bd68 | |||
| 03c3b0e788 | |||
| 3aca011b7d | |||
| dfa38c6736 | |||
| 48a8c940e1 | |||
| e80c776835 | |||
| 36e85098e5 | |||
| 7610768723 | |||
| 9afe027f5d | |||
| 4c5c43844a | |||
| 025c89d85a | |||
| f8d1036c37 | |||
| 0d8e6c4626 | |||
| 5aff11bcae | |||
| b5ce18ef26 | |||
| 70346171b1 | |||
| 4a63070489 | |||
| cb60eee694 | |||
| 955f649779 | |||
| c833f24fe2 | |||
| bc76032532 | |||
| 42f782faa5 | |||
| 862a150c44 | |||
| 4cfb626d00 | |||
| fdab6481ea | |||
| 9eff34390b | |||
| f2c1961697 | |||
| fff227522f | |||
| b7c813571e | |||
| 2c91982ae0 | |||
| 04f847a9bf |
@@ -37,6 +37,7 @@ Add these tools to your system PATH for additional features:
|
||||
- **[mp4decrypt](https://www.bento4.com/downloads/)** - Required for `mp4box` remux mode, music videos, and experimental codecs
|
||||
- **[MP4Box](https://gpac.io/downloads/gpac-nightly-builds/)** - Required for `mp4box` remux mode
|
||||
- **[N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest)** - Required for `nm3u8dlre` download mode, which is faster than the default downloader
|
||||
- **[Wrapper & amdecrypt](#️-wrapper--amdecrypt)** - For downloading songs in ALAC and other experimental codecs without API limitations
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
@@ -110,57 +111,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` |
|
||||
| `--no-config-file`, `-n` | Don't use a config file | `false` |
|
||||
| **Apple Music Options** | | |
|
||||
| `--cookies-path`, `-c` | Cookies file path | `./cookies.txt` |
|
||||
| `--language`, `-l` | Metadata language | `en-US` |
|
||||
| **Output Options** | | |
|
||||
| `--output-path`, `-o` | Output directory path | `./Apple Music` |
|
||||
| `--temp-path` | Temporary directory path | `.` |
|
||||
| `--overwrite` | Overwrite existing files | `false` |
|
||||
| `--save-cover`, `-s` | Save cover as separate file | `false` |
|
||||
| `--save-playlist` | Save M3U8 playlist file | `false` |
|
||||
| **Download Options** | | |
|
||||
| `--download-mode` | Download mode | `ytdlp` |
|
||||
| `--remux-mode` | Remux mode | `ffmpeg` |
|
||||
| `--cover-format` | Cover format | `jpg` |
|
||||
| `--cover-size` | Cover size in pixels | `1200` |
|
||||
| `--truncate` | Max filename length | - |
|
||||
| **Binary Paths** | | |
|
||||
| `--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` |
|
||||
| `--wvd-path` | .wvd file executable path | - |
|
||||
| **Template Options** | | |
|
||||
| `--album-folder-template` | Album folder template | `{album_artist}/{album}` |
|
||||
| `--compilation-folder-template` | Compilation folder template | `Compilations/{album}` |
|
||||
| `--single-disc-folder-template` | Single disc template | `{track:02d} {title}` |
|
||||
| `--multi-disc-folder-template` | Multi disc template | `{disc}-{track:02d} {title}` |
|
||||
| `--no-album-folder-template` | No album folder template | `{artist}/Unknown Album` |
|
||||
| `--no-album-file-template` | No album file template | `{title}` |
|
||||
| `--playlist-file-template` | Playlist 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 | - |
|
||||
| **Song Options** | | |
|
||||
| `--codec-song` | Song codec | `aac-legacy` |
|
||||
| `--synced-lyrics-format` | Synced lyrics format | `lrc` |
|
||||
| `--no-synced-lyrics` | Don't download synced lyrics | `false` |
|
||||
| `--synced-lyrics-only` | Download only synced lyrics | `false` |
|
||||
| **Music Video Options** | | |
|
||||
| `--music-video-codec-priority` | Comma-separated codec priority | `h264,h265` |
|
||||
| `--music-video-remux-format` | Music video remux format | `m4v` |
|
||||
| `--music-video-resolution` | Max music video resolution (see below) | `1080p` |
|
||||
| **Post Video Options** | | |
|
||||
| `--uploaded-video-quality` | Post video quality | `best` |
|
||||
| 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` |
|
||||
| `--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** | | |
|
||||
| `--output-path`, `-o` | Output directory path | `./Apple Music` |
|
||||
| `--temp-path` | Temporary directory path | `.` |
|
||||
| `--wvd-path` | .wvd file path | - |
|
||||
| `--overwrite` | Overwrite existing files | `false` |
|
||||
| `--save-cover`, `-s` | Save cover as separate file | `false` |
|
||||
| `--save-playlist` | Save M3U8 playlist file | `false` |
|
||||
| **Download Options** | | |
|
||||
| `--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` |
|
||||
| `--amdecrypt-path` | amdecrypt executable path | `amdecrypt` |
|
||||
| `--use-wrapper` | Use wrapper and amdecrypt | `false` |
|
||||
| `--wrapper-decrypt-ip` | Wrapper decryption server IP | `127.0.0.1:10020` |
|
||||
| `--download-mode` | Download mode | `ytdlp` |
|
||||
| `--remux-mode` | Remux mode | `ffmpeg` |
|
||||
| `--cover-format` | Cover format | `jpg` |
|
||||
| **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` |
|
||||
| `--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 | - |
|
||||
| `--cover-size` | Cover size in pixels | `1200` |
|
||||
| `--truncate` | Max filename length | - |
|
||||
| **Song Options** | | |
|
||||
| `--song-codec` | Song codec | `aac-legacy` |
|
||||
| `--synced-lyrics-format` | Synced lyrics format | `lrc` |
|
||||
| `--no-synced-lyrics` | Don't download synced lyrics | `false` |
|
||||
| `--synced-lyrics-only` | Download only synced lyrics | `false` |
|
||||
| **Music Video Options** | | |
|
||||
| `--music-video-codec-priority` | Comma-separated codec priority | `h264,h265` |
|
||||
| `--music-video-remux-format` | Music video remux format | `m4v` |
|
||||
| `--music-video-resolution` | Max music video resolution | `1080p` |
|
||||
| **Post Video Options** | | |
|
||||
| `--uploaded-video-quality` | Post video quality | `best` |
|
||||
|
||||
### Template Variables
|
||||
|
||||
@@ -221,7 +225,7 @@ 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
|
||||
- `alac` - ALAC up to 24-bit/192kHz (unsupported)
|
||||
- `ask` - Interactive experimental codec selection
|
||||
|
||||
### Synced Lyrics Format
|
||||
@@ -250,13 +254,30 @@ Use ISO 639-1 language codes (e.g., `en-US`, `es-ES`, `ja-JP`, `pt-BR`). Don't a
|
||||
- `best` - Up to 1080p with AAC 256kbps
|
||||
- `ask` - Interactive quality selection
|
||||
|
||||
## ⚙️ Wrapper & amdecrypt
|
||||
|
||||
Use the [wrapper](https://github.com/WorldObservationLog/wrapper) and [amdecrypt](https://github.com/glomatico/amdecrypt) to download songs in ALAC and other experimental codecs without API limitations. Cookies are not required when using the wrapper.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **[wrapper](https://github.com/WorldObservationLog/wrapper)** - Refer to the repository for installation
|
||||
- **[amdecrypt](https://github.com/glomatico/amdecrypt)** - Refer to the repository for installation
|
||||
- **[mp4decrypt](https://www.bento4.com/downloads/)** - Required by amdecrypt to decrypt protected files
|
||||
|
||||
### 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:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from gamdl.api import AppleMusicApi
|
||||
|
||||
from gamdl.api import AppleMusicApi, ItunesApi
|
||||
from gamdl.downloader import (
|
||||
AppleMusicBaseDownloader,
|
||||
AppleMusicDownloader,
|
||||
@@ -264,39 +285,59 @@ from gamdl.downloader import (
|
||||
AppleMusicSongDownloader,
|
||||
AppleMusicUploadedVideoDownloader,
|
||||
)
|
||||
|
||||
from gamdl.interface import (
|
||||
AppleMusicInterface,
|
||||
AppleMusicMusicVideoInterface,
|
||||
AppleMusicSongInterface,
|
||||
AppleMusicUploadedVideoInterface,
|
||||
)
|
||||
|
||||
async def main():
|
||||
# Initialize API
|
||||
api = AppleMusicApi.from_netscape_cookies(cookies_path="cookies.txt")
|
||||
await api.setup()
|
||||
# Create AppleMusicApi instance (from cookies or wrapper)
|
||||
apple_music_api = await AppleMusicApi.create_from_netscape_cookies(
|
||||
cookies_path="cookies.txt",
|
||||
)
|
||||
itunes_api = ItunesApi(
|
||||
apple_music_api.storefront,
|
||||
apple_music_api.language,
|
||||
)
|
||||
|
||||
# Initialize downloaders
|
||||
base_downloader = AppleMusicBaseDownloader(apple_music_api=api)
|
||||
base_downloader.setup()
|
||||
# Check subscription
|
||||
assert apple_music_api.active_subscription
|
||||
|
||||
song_downloader = AppleMusicSongDownloader(base_downloader)
|
||||
song_downloader.setup()
|
||||
# Set up interfaces
|
||||
interface = AppleMusicInterface(apple_music_api, itunes_api)
|
||||
song_interface = AppleMusicSongInterface(interface)
|
||||
music_video_interface = AppleMusicMusicVideoInterface(interface)
|
||||
uploaded_video_interface = AppleMusicUploadedVideoInterface(interface)
|
||||
|
||||
music_video_downloader = AppleMusicMusicVideoDownloader(base_downloader)
|
||||
music_video_downloader.setup()
|
||||
# Set up base downloader and specialized downloaders
|
||||
base_downloader = AppleMusicBaseDownloader()
|
||||
song_downloader = AppleMusicSongDownloader(
|
||||
base_downloader=base_downloader,
|
||||
interface=song_interface,
|
||||
)
|
||||
music_video_downloader = AppleMusicMusicVideoDownloader(
|
||||
base_downloader=base_downloader,
|
||||
interface=music_video_interface,
|
||||
)
|
||||
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(
|
||||
base_downloader=base_downloader,
|
||||
interface=uploaded_video_interface,
|
||||
)
|
||||
|
||||
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(base_downloader)
|
||||
uploaded_video_downloader.setup()
|
||||
|
||||
# Create main downloader
|
||||
# Main downloader
|
||||
downloader = AppleMusicDownloader(
|
||||
base_downloader,
|
||||
song_downloader,
|
||||
music_video_downloader,
|
||||
uploaded_video_downloader,
|
||||
interface=interface,
|
||||
base_downloader=base_downloader,
|
||||
song_downloader=song_downloader,
|
||||
music_video_downloader=music_video_downloader,
|
||||
uploaded_video_downloader=uploaded_video_downloader,
|
||||
)
|
||||
|
||||
# Download a song
|
||||
url_info = downloader.get_url_info(
|
||||
"https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
|
||||
)
|
||||
|
||||
url = "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
|
||||
url_info = downloader.get_url_info(url)
|
||||
if url_info:
|
||||
download_queue = await downloader.get_download_queue(url_info)
|
||||
if download_queue:
|
||||
@@ -314,4 +355,4 @@ MIT License - see [LICENSE](LICENSE) file for details
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Feel free to open issues or submit pull requests, but you may discuss major changes first on our Discord server.
|
||||
Currently, I'm not interested in reviewing pull requests that change or add features. Only critical bug fixes will be considered. However, feel free to open issues for bugs or feature requests.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
__version__ = "2.7"
|
||||
__version__ = "2.8.2"
|
||||
|
||||
@@ -6,7 +6,7 @@ from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from ..utils import raise_for_status, safe_json
|
||||
from ..utils import get_response, raise_for_status, safe_json
|
||||
from .constants import (
|
||||
AMP_API_URL,
|
||||
APPLE_MUSIC_COOKIE_DOMAIN,
|
||||
@@ -22,18 +22,21 @@ class AppleMusicApi:
|
||||
def __init__(
|
||||
self,
|
||||
storefront: str = "us",
|
||||
media_user_token: str | None = None,
|
||||
language: str = "en-US",
|
||||
media_user_token: str | None = None,
|
||||
developer_token: str | None = None,
|
||||
) -> None:
|
||||
self.storefront = storefront
|
||||
self.media_user_token = media_user_token
|
||||
self.language = language
|
||||
self.media_user_token = media_user_token
|
||||
self.token = developer_token
|
||||
|
||||
@classmethod
|
||||
def from_netscape_cookies(
|
||||
async def create_from_netscape_cookies(
|
||||
cls,
|
||||
cookies_path: str = "./cookies.txt",
|
||||
language: str = "en-US",
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> "AppleMusicApi":
|
||||
cookies = MozillaCookieJar(cookies_path)
|
||||
cookies.load(ignore_discard=True, ignore_expires=True)
|
||||
@@ -54,18 +57,55 @@ class AppleMusicApi:
|
||||
"and are logged in with an active subscription."
|
||||
)
|
||||
|
||||
return cls(
|
||||
return await cls.create(
|
||||
storefront=None,
|
||||
media_user_token=media_user_token,
|
||||
language=language,
|
||||
developer_token=None,
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def setup(self) -> None:
|
||||
await self._setup_client()
|
||||
await self._setup_token()
|
||||
await self._setup_account_info()
|
||||
@classmethod
|
||||
async def create_from_wrapper(
|
||||
cls,
|
||||
wrapper_account_url: str = "http://127.0.0.1:30020/",
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> "AppleMusicApi":
|
||||
wrapper_account_response = await get_response(wrapper_account_url)
|
||||
wrapper_account_info = safe_json(wrapper_account_response)
|
||||
|
||||
async def _setup_client(self) -> None:
|
||||
return await cls.create(
|
||||
storefront=None,
|
||||
media_user_token=wrapper_account_info["music_token"],
|
||||
developer_token=wrapper_account_info["dev_token"],
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def create(
|
||||
cls,
|
||||
storefront: str | None = "us",
|
||||
language: str = "en-US",
|
||||
media_user_token: str | None = None,
|
||||
developer_token: str | None = None,
|
||||
) -> "AppleMusicApi":
|
||||
api = cls(
|
||||
storefront=storefront,
|
||||
language=language,
|
||||
media_user_token=media_user_token,
|
||||
developer_token=developer_token,
|
||||
)
|
||||
await api.initialize()
|
||||
return api
|
||||
|
||||
async def initialize(self) -> None:
|
||||
await self._initialize_client()
|
||||
await self._initialize_token()
|
||||
await self._initialize_account_info()
|
||||
|
||||
async def _initialize_client(self) -> None:
|
||||
self.client = httpx.AsyncClient(
|
||||
headers={
|
||||
"accept": "*/*",
|
||||
@@ -85,11 +125,10 @@ class AppleMusicApi:
|
||||
"l": self.language,
|
||||
},
|
||||
follow_redirects=True,
|
||||
transport=httpx.AsyncHTTPTransport(retries=3),
|
||||
timeout=30.0,
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
async def _setup_token(self) -> None:
|
||||
async def _get_token(self) -> str:
|
||||
response = await self.client.get(APPLE_MUSIC_HOMEPAGE_URL)
|
||||
raise_for_status(response)
|
||||
home_page = response.text
|
||||
@@ -112,9 +151,13 @@ class AppleMusicApi:
|
||||
token = token_match.group(1)
|
||||
|
||||
logger.debug(f"Token: {token}")
|
||||
self.client.headers.update({"authorization": f"Bearer {token}"})
|
||||
return token
|
||||
|
||||
async def _setup_account_info(self) -> None:
|
||||
async def _initialize_token(self) -> None:
|
||||
self.token = self.token or await self._get_token()
|
||||
self.client.headers.update({"authorization": f"Bearer {self.token}"})
|
||||
|
||||
async def _initialize_account_info(self) -> None:
|
||||
if not self.media_user_token:
|
||||
return
|
||||
|
||||
@@ -127,6 +170,22 @@ class AppleMusicApi:
|
||||
self.account_info = await self.get_account_info()
|
||||
self.storefront = self.account_info["meta"]["subscription"]["storefront"]
|
||||
|
||||
@property
|
||||
def active_subscription(self) -> bool:
|
||||
return (
|
||||
getattr(self, "account_info", {})
|
||||
.get("meta", {})
|
||||
.get("subscription", {})
|
||||
.get("active", False)
|
||||
)
|
||||
|
||||
@property
|
||||
def account_restrictions(self) -> dict | None:
|
||||
data = getattr(self, "account_info", {}).get("data", [])
|
||||
if not data:
|
||||
return None
|
||||
return data[0].get("attributes", {}).get("restrictions")
|
||||
|
||||
async def get_account_info(self, meta: str | None = "subscription") -> dict:
|
||||
response = await self.client.get(
|
||||
f"{AMP_API_URL}/v1/me/account",
|
||||
|
||||
@@ -39,6 +39,7 @@ STOREFRONT_IDS = {
|
||||
"CA": "143455-6,32",
|
||||
"CG": "143582-2,32",
|
||||
"CH": "143459-57,32",
|
||||
"CM": "143574-2,32",
|
||||
"CL": "143483-28,32",
|
||||
"CN": "143465-19,32",
|
||||
"CO": "143501-28,32",
|
||||
|
||||
@@ -16,18 +16,19 @@ class ItunesApi:
|
||||
) -> None:
|
||||
self.storefront = storefront
|
||||
self.language = language
|
||||
self.initialize()
|
||||
|
||||
def setup(self) -> None:
|
||||
self._setup_storefront_id()
|
||||
self._setup_session()
|
||||
def initialize(self) -> None:
|
||||
self._initialize_storefront_id()
|
||||
self._initialize_client()
|
||||
|
||||
def _setup_storefront_id(self) -> None:
|
||||
def _initialize_storefront_id(self) -> None:
|
||||
try:
|
||||
self.storefront_id = STOREFRONT_IDS[self.storefront.upper()]
|
||||
except KeyError:
|
||||
raise Exception(f"No storefront id for {self.storefront}")
|
||||
|
||||
def _setup_session(self) -> None:
|
||||
def _initialize_client(self) -> None:
|
||||
self.client = httpx.AsyncClient(
|
||||
params={
|
||||
"country": self.storefront,
|
||||
@@ -36,6 +37,7 @@ class ItunesApi:
|
||||
headers={
|
||||
"X-Apple-Store-Front": f"{self.storefront_id} t:music31",
|
||||
},
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
async def get_lookup_result(
|
||||
|
||||
+151
-77
@@ -1,11 +1,14 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import colorama
|
||||
|
||||
from .. import __version__
|
||||
from ..api import AppleMusicApi
|
||||
from ..api import AppleMusicApi, ItunesApi
|
||||
from ..downloader import (
|
||||
AppleMusicBaseDownloader,
|
||||
AppleMusicDownloader,
|
||||
@@ -15,25 +18,30 @@ from ..downloader import (
|
||||
CoverFormat,
|
||||
DownloadItem,
|
||||
DownloadMode,
|
||||
MediaDownloadConfigurationError,
|
||||
MediaFormatNotAvailableError,
|
||||
MediaNotStreamableError,
|
||||
GamdlError,
|
||||
RemuxFormatMusicVideo,
|
||||
RemuxMode,
|
||||
)
|
||||
from ..interface import (
|
||||
AppleMusicInterface,
|
||||
AppleMusicMusicVideoInterface,
|
||||
AppleMusicSongInterface,
|
||||
AppleMusicUploadedVideoInterface,
|
||||
MusicVideoCodec,
|
||||
MusicVideoResolution,
|
||||
SongCodec,
|
||||
SyncedLyricsFormat,
|
||||
UploadedVideoQuality,
|
||||
)
|
||||
from .config_file import ConfigFile
|
||||
from .constants import X_NOT_IN_PATH
|
||||
from .utils import Csv, CustomLoggerFormatter, PathPrompt, load_config_file, make_sync
|
||||
from .utils import Csv, CustomLoggerFormatter, prompt_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
api_sig = inspect.signature(AppleMusicApi.from_netscape_cookies)
|
||||
api_from_cookies_sig = inspect.signature(AppleMusicApi.create_from_netscape_cookies)
|
||||
api_from_wrapper_sig = inspect.signature(AppleMusicApi.create_from_wrapper)
|
||||
api_sig = inspect.signature(AppleMusicApi.__init__)
|
||||
base_downloader_sig = inspect.signature(AppleMusicBaseDownloader.__init__)
|
||||
music_video_downloader_sig = inspect.signature(AppleMusicMusicVideoDownloader.__init__)
|
||||
song_downloader_sig = inspect.signature(AppleMusicSongDownloader.__init__)
|
||||
@@ -42,6 +50,40 @@ uploaded_video_downloader_sig = inspect.signature(
|
||||
)
|
||||
|
||||
|
||||
def load_config_file(
|
||||
ctx: click.Context,
|
||||
param: click.Parameter,
|
||||
no_config_file: bool,
|
||||
) -> click.Context:
|
||||
if no_config_file:
|
||||
return ctx
|
||||
|
||||
config_file = ConfigFile(ctx.params["config_path"])
|
||||
config_file.cleanup_unknown_params(ctx.command.params)
|
||||
config_file.add_params_default_to_config(
|
||||
ctx.command.params,
|
||||
)
|
||||
parsed_params = config_file.parse_params_from_config(
|
||||
[
|
||||
param
|
||||
for param in ctx.command.params
|
||||
if ctx.get_parameter_source(param.name)
|
||||
!= click.core.ParameterSource.COMMANDLINE
|
||||
]
|
||||
)
|
||||
ctx.params.update(parsed_params)
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
def make_sync(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return asyncio.run(func(*args, **kwargs))
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.help_option("-h", "--help")
|
||||
@click.version_option(__version__, "-v", "--version")
|
||||
@@ -85,10 +127,16 @@ uploaded_video_downloader_sig = inspect.signature(
|
||||
@click.option(
|
||||
"--cookies-path",
|
||||
"-c",
|
||||
type=PathPrompt(is_file=True),
|
||||
default=api_sig.parameters["cookies_path"].default,
|
||||
type=click.Path(file_okay=True, dir_okay=False, readable=True, resolve_path=True),
|
||||
default=api_from_cookies_sig.parameters["cookies_path"].default,
|
||||
help="Cookies file path",
|
||||
)
|
||||
@click.option(
|
||||
"--wrapper-account-url",
|
||||
type=str,
|
||||
default=api_from_wrapper_sig.parameters["wrapper_account_url"].default,
|
||||
help="Wrapper account URL",
|
||||
)
|
||||
@click.option(
|
||||
"--language",
|
||||
"-l",
|
||||
@@ -114,7 +162,7 @@ uploaded_video_downloader_sig = inspect.signature(
|
||||
"--wvd-path",
|
||||
type=click.Path(file_okay=False, dir_okay=True, writable=True, resolve_path=True),
|
||||
default=base_downloader_sig.parameters["wvd_path"].default,
|
||||
help=".wvd file executable path",
|
||||
help=".wvd file path",
|
||||
)
|
||||
@click.option(
|
||||
"--overwrite",
|
||||
@@ -159,6 +207,24 @@ uploaded_video_downloader_sig = inspect.signature(
|
||||
default=base_downloader_sig.parameters["mp4box_path"].default,
|
||||
help="MP4Box executable path",
|
||||
)
|
||||
@click.option(
|
||||
"--amdecrypt-path",
|
||||
type=str,
|
||||
default=base_downloader_sig.parameters["amdecrypt_path"].default,
|
||||
help="amdecrypt executable path",
|
||||
)
|
||||
@click.option(
|
||||
"--use-wrapper",
|
||||
is_flag=True,
|
||||
help="Use wrapper and amdecrypt for decrypting songs",
|
||||
default=False,
|
||||
)
|
||||
@click.option(
|
||||
"--wrapper-decrypt-ip",
|
||||
type=str,
|
||||
default=base_downloader_sig.parameters["wrapper_decrypt_ip"].default,
|
||||
help="IP address and port for wrapper decryption",
|
||||
)
|
||||
@click.option(
|
||||
"--download-mode",
|
||||
type=DownloadMode,
|
||||
@@ -189,24 +255,24 @@ uploaded_video_downloader_sig = inspect.signature(
|
||||
default=base_downloader_sig.parameters["compilation_folder_template"].default,
|
||||
help="Compilation folder template",
|
||||
)
|
||||
@click.option(
|
||||
"--single-disc-folder-template",
|
||||
type=str,
|
||||
default=base_downloader_sig.parameters["single_disc_folder_template"].default,
|
||||
help="Single disc template",
|
||||
)
|
||||
@click.option(
|
||||
"--multi-disc-folder-template",
|
||||
type=str,
|
||||
default=base_downloader_sig.parameters["multi_disc_folder_template"].default,
|
||||
help="Multi disc template",
|
||||
)
|
||||
@click.option(
|
||||
"--no-album-folder-template",
|
||||
type=str,
|
||||
default=base_downloader_sig.parameters["no_album_folder_template"].default,
|
||||
help="No album folder template",
|
||||
)
|
||||
@click.option(
|
||||
"--single-disc-file-template",
|
||||
type=str,
|
||||
default=base_downloader_sig.parameters["single_disc_file_template"].default,
|
||||
help="Single disc file template",
|
||||
)
|
||||
@click.option(
|
||||
"--multi-disc-file-template",
|
||||
type=str,
|
||||
default=base_downloader_sig.parameters["multi_disc_file_template"].default,
|
||||
help="Multi disc file template",
|
||||
)
|
||||
@click.option(
|
||||
"--no-album-file-template",
|
||||
type=str,
|
||||
@@ -217,7 +283,7 @@ uploaded_video_downloader_sig = inspect.signature(
|
||||
"--playlist-file-template",
|
||||
type=str,
|
||||
default=base_downloader_sig.parameters["playlist_file_template"].default,
|
||||
help="Playlist template",
|
||||
help="Playlist file template",
|
||||
)
|
||||
@click.option(
|
||||
"--date-tag-template",
|
||||
@@ -245,7 +311,7 @@ uploaded_video_downloader_sig = inspect.signature(
|
||||
)
|
||||
# DownloaderSong specific options
|
||||
@click.option(
|
||||
"--codec-song",
|
||||
"--song-codec",
|
||||
type=SongCodec,
|
||||
default=song_downloader_sig.parameters["codec"].default,
|
||||
help="Song codec",
|
||||
@@ -311,6 +377,7 @@ async def main(
|
||||
log_file: str,
|
||||
no_exceptions: bool,
|
||||
cookies_path: str,
|
||||
wrapper_account_url: str,
|
||||
language: str,
|
||||
output_path: str,
|
||||
temp_path: str,
|
||||
@@ -322,21 +389,24 @@ async def main(
|
||||
mp4decrypt_path: str,
|
||||
ffmpeg_path: str,
|
||||
mp4box_path: str,
|
||||
amdecrypt_path: str,
|
||||
use_wrapper: bool,
|
||||
wrapper_decrypt_ip: str,
|
||||
download_mode: DownloadMode,
|
||||
remux_mode: RemuxMode,
|
||||
cover_format: CoverFormat,
|
||||
album_folder_template: str,
|
||||
compilation_folder_template: str,
|
||||
single_disc_folder_template: str,
|
||||
multi_disc_folder_template: str,
|
||||
no_album_folder_template: str,
|
||||
single_disc_file_template: str,
|
||||
multi_disc_file_template: str,
|
||||
no_album_file_template: str,
|
||||
playlist_file_template: str,
|
||||
date_tag_template: str,
|
||||
exclude_tags: list[str],
|
||||
cover_size: int,
|
||||
truncate: int,
|
||||
codec_song: SongCodec,
|
||||
song_codec: SongCodec,
|
||||
synced_lyrics_format: SyncedLyricsFormat,
|
||||
no_synced_lyrics: bool,
|
||||
synced_lyrics_only: bool,
|
||||
@@ -347,6 +417,8 @@ async def main(
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
colorama.just_fix_windows_console()
|
||||
|
||||
root_logger = logging.getLogger(__name__.split(".")[0])
|
||||
root_logger.setLevel(log_level)
|
||||
root_logger.propagate = False
|
||||
@@ -362,26 +434,44 @@ async def main(
|
||||
|
||||
logger.info(f"Starting Gamdl {__version__}")
|
||||
|
||||
api = AppleMusicApi.from_netscape_cookies(
|
||||
cookies_path=cookies_path,
|
||||
language=language,
|
||||
)
|
||||
await api.setup()
|
||||
if use_wrapper:
|
||||
apple_music_api = await AppleMusicApi.create_from_wrapper(
|
||||
wrapper_account_url=wrapper_account_url,
|
||||
language=language,
|
||||
)
|
||||
else:
|
||||
cookies_path = prompt_path(cookies_path)
|
||||
apple_music_api = await AppleMusicApi.create_from_netscape_cookies(
|
||||
cookies_path=cookies_path,
|
||||
language=language,
|
||||
)
|
||||
|
||||
if not api.account_info["meta"]["subscription"]["active"]:
|
||||
itunes_api = ItunesApi(
|
||||
apple_music_api.storefront,
|
||||
apple_music_api.language,
|
||||
)
|
||||
|
||||
if not apple_music_api.active_subscription:
|
||||
logger.critical(
|
||||
"No active Apple Music subscription found, you won't be able to download"
|
||||
" anything"
|
||||
)
|
||||
return
|
||||
if api.account_info["data"][0]["attributes"].get("restrictions"):
|
||||
if apple_music_api.account_restrictions:
|
||||
logger.warning(
|
||||
"Your account has content restrictions enabled, some content may not be"
|
||||
" downloadable"
|
||||
)
|
||||
|
||||
interface = AppleMusicInterface(
|
||||
apple_music_api,
|
||||
itunes_api,
|
||||
)
|
||||
song_interface = AppleMusicSongInterface(interface)
|
||||
music_video_interface = AppleMusicMusicVideoInterface(interface)
|
||||
uploaded_video_interface = AppleMusicUploadedVideoInterface(interface)
|
||||
|
||||
base_downloader = AppleMusicBaseDownloader(
|
||||
apple_music_api=api,
|
||||
output_path=output_path,
|
||||
temp_path=temp_path,
|
||||
wvd_path=wvd_path,
|
||||
@@ -392,14 +482,17 @@ async def main(
|
||||
mp4decrypt_path=mp4decrypt_path,
|
||||
ffmpeg_path=ffmpeg_path,
|
||||
mp4box_path=mp4box_path,
|
||||
amdecrypt_path=amdecrypt_path,
|
||||
use_wrapper=use_wrapper,
|
||||
wrapper_decrypt_ip=wrapper_decrypt_ip,
|
||||
download_mode=download_mode,
|
||||
remux_mode=remux_mode,
|
||||
cover_format=cover_format,
|
||||
album_folder_template=album_folder_template,
|
||||
compilation_folder_template=compilation_folder_template,
|
||||
single_disc_folder_template=single_disc_folder_template,
|
||||
multi_disc_folder_template=multi_disc_folder_template,
|
||||
no_album_folder_template=no_album_folder_template,
|
||||
single_disc_file_template=single_disc_file_template,
|
||||
multi_disc_file_template=multi_disc_file_template,
|
||||
no_album_file_template=no_album_file_template,
|
||||
playlist_file_template=playlist_file_template,
|
||||
date_tag_template=date_tag_template,
|
||||
@@ -407,36 +500,32 @@ async def main(
|
||||
cover_size=cover_size,
|
||||
truncate=truncate,
|
||||
)
|
||||
base_downloader.setup()
|
||||
|
||||
song_downloader = AppleMusicSongDownloader(
|
||||
base_downloader,
|
||||
codec=codec_song,
|
||||
base_downloader=base_downloader,
|
||||
interface=song_interface,
|
||||
codec=song_codec,
|
||||
synced_lyrics_format=synced_lyrics_format,
|
||||
no_synced_lyrics=no_synced_lyrics,
|
||||
synced_lyrics_only=synced_lyrics_only,
|
||||
)
|
||||
song_downloader.setup()
|
||||
|
||||
music_video_downloader = AppleMusicMusicVideoDownloader(
|
||||
base_downloader,
|
||||
base_downloader=base_downloader,
|
||||
interface=music_video_interface,
|
||||
codec_priority=music_video_codec_priority,
|
||||
remux_format=music_video_remux_format,
|
||||
resolution=music_video_resolution,
|
||||
)
|
||||
music_video_downloader.setup()
|
||||
|
||||
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(
|
||||
base_downloader,
|
||||
base_downloader=base_downloader,
|
||||
interface=uploaded_video_interface,
|
||||
quality=uploaded_video_quality,
|
||||
)
|
||||
uploaded_video_downloader.setup()
|
||||
|
||||
downloader = AppleMusicDownloader(
|
||||
base_downloader,
|
||||
song_downloader,
|
||||
music_video_downloader,
|
||||
uploaded_video_downloader,
|
||||
interface=interface,
|
||||
base_downloader=base_downloader,
|
||||
song_downloader=song_downloader,
|
||||
music_video_downloader=music_video_downloader,
|
||||
uploaded_video_downloader=uploaded_video_downloader,
|
||||
)
|
||||
|
||||
if not synced_lyrics_only:
|
||||
@@ -450,17 +539,9 @@ async def main(
|
||||
logger.critical(X_NOT_IN_PATH.format("MP4Box", mp4box_path))
|
||||
return
|
||||
|
||||
if (
|
||||
not base_downloader.full_mp4decrypt_path
|
||||
and codec_song
|
||||
not in (
|
||||
SongCodec.AAC_LEGACY,
|
||||
SongCodec.AAC_HE_LEGACY,
|
||||
)
|
||||
or (
|
||||
remux_mode == RemuxMode.MP4BOX
|
||||
and not base_downloader.full_mp4decrypt_path
|
||||
)
|
||||
if not base_downloader.full_mp4decrypt_path and (
|
||||
song_codec not in (SongCodec.AAC_LEGACY, SongCodec.AAC_HE_LEGACY)
|
||||
or remux_mode == RemuxMode.MP4BOX
|
||||
):
|
||||
logger.critical(X_NOT_IN_PATH.format("mp4decrypt", mp4decrypt_path))
|
||||
return
|
||||
@@ -472,16 +553,14 @@ async def main(
|
||||
logger.critical(X_NOT_IN_PATH.format("N_m3u8DL-RE", nm3u8dlre_path))
|
||||
return
|
||||
|
||||
if not base_downloader.full_mp4decrypt_path:
|
||||
logger.warning(
|
||||
X_NOT_IN_PATH.format("mp4decrypt", mp4decrypt_path)
|
||||
+ ", music videos will not be downloaded"
|
||||
)
|
||||
downloader.skip_music_videos = True
|
||||
if use_wrapper and not base_downloader.full_amdecrypt_path:
|
||||
logger.critical(X_NOT_IN_PATH.format("amdecrypt", amdecrypt_path))
|
||||
return
|
||||
|
||||
if not codec_song.is_legacy():
|
||||
if not song_codec.is_legacy() and not use_wrapper:
|
||||
logger.warning(
|
||||
"You have chosen an experimental song codec. "
|
||||
"You have chosen an experimental song codec"
|
||||
" without enabling wrapper."
|
||||
"They're not guaranteed to work due to API limitations."
|
||||
)
|
||||
|
||||
@@ -550,12 +629,7 @@ async def main(
|
||||
|
||||
try:
|
||||
await downloader.download(download_item)
|
||||
except (
|
||||
FileExistsError,
|
||||
MediaNotStreamableError,
|
||||
MediaFormatNotAvailableError,
|
||||
MediaDownloadConfigurationError,
|
||||
) as e:
|
||||
except GamdlError as e:
|
||||
logger.warning(
|
||||
download_queue_progress + f' Skipping "{media_title}": {e}'
|
||||
)
|
||||
|
||||
+30
-19
@@ -1,11 +1,12 @@
|
||||
import configparser
|
||||
import typing
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from click.types import BoolParamType, FuncParamType
|
||||
|
||||
from .constants import EXCLUDED_CONFIG_FILE_PARAMS
|
||||
from .utils import Csv
|
||||
|
||||
|
||||
class ConfigFile:
|
||||
@@ -35,34 +36,29 @@ class ConfigFile:
|
||||
self.config.write(config_file)
|
||||
|
||||
def _serialize_param_default(self, param: click.Parameter) -> str:
|
||||
if not isinstance(param.default, (list, tuple)):
|
||||
param_default = [param.default]
|
||||
else:
|
||||
param_default = param.default
|
||||
|
||||
if not param_default:
|
||||
return ""
|
||||
|
||||
first = param_default[0]
|
||||
|
||||
if isinstance(first, Enum):
|
||||
return ",".join(str(item.value) for item in param_default)
|
||||
if isinstance(first, bool):
|
||||
return ",".join(str(item).lower() for item in param_default)
|
||||
if first is None:
|
||||
if param.default is None:
|
||||
return "null"
|
||||
|
||||
return ",".join(str(item) for item in param_default)
|
||||
if isinstance(param.type, Csv):
|
||||
return ",".join(item.value for item in param.default)
|
||||
|
||||
if isinstance(param.type, BoolParamType):
|
||||
return str(param.default).lower()
|
||||
|
||||
if isinstance(param.type, FuncParamType):
|
||||
return param.default.value
|
||||
|
||||
return str(param.default)
|
||||
|
||||
def _add_param_default_to_config(
|
||||
self,
|
||||
param: click.Parameter,
|
||||
) -> bool:
|
||||
if self.config[self.section_name].get(param.name):
|
||||
if self.config.has_option(self.section_name, param.name):
|
||||
return False
|
||||
|
||||
value = self._serialize_param_default(param)
|
||||
self.config[self.section_name][param.name] = value
|
||||
self.config.set(self.section_name, param.name, value)
|
||||
|
||||
return True
|
||||
|
||||
@@ -92,6 +88,21 @@ class ConfigFile:
|
||||
if has_changes:
|
||||
self._write_config_file()
|
||||
|
||||
def cleanup_unknown_params(
|
||||
self,
|
||||
params: list[click.Parameter],
|
||||
) -> None:
|
||||
param_names = {param.name for param in params}
|
||||
has_changes = False
|
||||
|
||||
for key in list(self.config[self.section_name].keys()):
|
||||
if key not in param_names:
|
||||
self.config.remove_option(self.section_name, key)
|
||||
has_changes = True
|
||||
|
||||
if has_changes:
|
||||
self._write_config_file()
|
||||
|
||||
def parse_params_from_config(
|
||||
self,
|
||||
params: list[click.Parameter],
|
||||
|
||||
+30
-76
@@ -1,29 +1,25 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import typing
|
||||
from functools import wraps
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from .config_file import ConfigFile
|
||||
|
||||
|
||||
class Csv(click.ParamType):
|
||||
name = "csv"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
subtype: typing.Any,
|
||||
subtype: Enum,
|
||||
) -> None:
|
||||
self.subtype = subtype
|
||||
|
||||
def convert(
|
||||
self,
|
||||
value: str | typing.Any,
|
||||
value: str,
|
||||
param: click.Parameter,
|
||||
ctx: click.Context,
|
||||
) -> list[typing.Any]:
|
||||
) -> list[Enum]:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
|
||||
@@ -42,46 +38,6 @@ class Csv(click.ParamType):
|
||||
return result
|
||||
|
||||
|
||||
class PathPrompt(click.ParamType):
|
||||
name = "path"
|
||||
|
||||
def __init__(self, is_file: bool = False) -> None:
|
||||
self.is_file = is_file
|
||||
|
||||
def convert(
|
||||
self,
|
||||
value: str | typing.Any,
|
||||
param: click.Parameter,
|
||||
ctx: click.Context,
|
||||
) -> str:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
|
||||
path_validator = click.Path(
|
||||
exists=True,
|
||||
file_okay=self.is_file,
|
||||
dir_okay=not self.is_file,
|
||||
)
|
||||
path_type = "file" if self.is_file else "directory"
|
||||
while True:
|
||||
try:
|
||||
result = path_validator.convert(value, None, None)
|
||||
break
|
||||
except click.BadParameter as e:
|
||||
value = click.prompt(
|
||||
(
|
||||
f'{path_type.capitalize()} "{Path(value).absolute()}" does not exist. '
|
||||
f"Create the {path_type} at the specified path, "
|
||||
f"type a new path or drag and drop the {path_type} here. "
|
||||
"Then, press enter to continue"
|
||||
),
|
||||
default=value,
|
||||
show_default=False,
|
||||
)
|
||||
value = value.strip('"')
|
||||
return result
|
||||
|
||||
|
||||
class CustomLoggerFormatter(logging.Formatter):
|
||||
base_format = "[%(levelname)-8s %(asctime)s]"
|
||||
format_colors = {
|
||||
@@ -109,34 +65,32 @@ class CustomLoggerFormatter(logging.Formatter):
|
||||
).format(record)
|
||||
|
||||
|
||||
def load_config_file(
|
||||
ctx: click.Context,
|
||||
param: click.Parameter,
|
||||
no_config_file: bool,
|
||||
) -> click.Context:
|
||||
if no_config_file:
|
||||
return ctx
|
||||
|
||||
config_file = ConfigFile(ctx.params["config_path"])
|
||||
config_file.add_params_default_to_config(
|
||||
ctx.command.params,
|
||||
def prompt_path(
|
||||
input_path: str,
|
||||
is_dir: bool = False,
|
||||
) -> str:
|
||||
path_validator = click.Path(
|
||||
exists=True,
|
||||
file_okay=not is_dir,
|
||||
dir_okay=is_dir,
|
||||
)
|
||||
parsed_params = config_file.parse_params_from_config(
|
||||
[
|
||||
param
|
||||
for param in ctx.command.params
|
||||
if ctx.get_parameter_source(param.name)
|
||||
!= click.core.ParameterSource.COMMANDLINE
|
||||
]
|
||||
)
|
||||
ctx.params.update(parsed_params)
|
||||
path_type = "directory" if is_dir else "file"
|
||||
|
||||
return ctx
|
||||
while True:
|
||||
try:
|
||||
result_path = path_validator.convert(input_path, None, None)
|
||||
break
|
||||
except click.BadParameter as e:
|
||||
input_path = click.prompt(
|
||||
(
|
||||
f'{path_type.capitalize()} "{Path(input_path).absolute()}" does not exist. '
|
||||
f"Create the {path_type} at the specified path, "
|
||||
f"type a new path or drag and drop the {path_type} here. "
|
||||
"Then, press enter to continue"
|
||||
),
|
||||
default=input_path,
|
||||
show_default=False,
|
||||
)
|
||||
input_path = input_path.strip('"')
|
||||
|
||||
|
||||
def make_sync(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return asyncio.run(func(*args, **kwargs))
|
||||
|
||||
return wrapper
|
||||
return result_path
|
||||
|
||||
+157
-82
@@ -1,9 +1,11 @@
|
||||
import asyncio
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
|
||||
from ..interface import AppleMusicInterface
|
||||
from ..utils import safe_gather
|
||||
from .constants import (
|
||||
ALBUM_MEDIA_TYPE,
|
||||
@@ -18,10 +20,13 @@ from .downloader_base import AppleMusicBaseDownloader
|
||||
from .downloader_music_video import AppleMusicMusicVideoDownloader
|
||||
from .downloader_song import AppleMusicSongDownloader
|
||||
from .downloader_uploaded_video import AppleMusicUploadedVideoDownloader
|
||||
from .enums import DownloadMode, RemuxMode
|
||||
from .exceptions import (
|
||||
MediaFormatNotAvailableError,
|
||||
MediaNotStreamableError,
|
||||
MediaDownloadConfigurationError,
|
||||
ExecutableNotFound,
|
||||
FormatNotAvailable,
|
||||
MediaFileExists,
|
||||
NotStreamable,
|
||||
SyncedLyricsOnly,
|
||||
)
|
||||
from .types import DownloadItem, UrlInfo
|
||||
|
||||
@@ -29,25 +34,62 @@ from .types import DownloadItem, UrlInfo
|
||||
class AppleMusicDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
interface: AppleMusicInterface,
|
||||
base_downloader: AppleMusicBaseDownloader,
|
||||
song_downloader: AppleMusicSongDownloader,
|
||||
music_video_downloader: AppleMusicMusicVideoDownloader,
|
||||
uploaded_video_downloader: AppleMusicUploadedVideoDownloader,
|
||||
skip_music_videos: bool = False,
|
||||
skip_processing: bool = False,
|
||||
flat_filter: typing.Callable = None,
|
||||
):
|
||||
self.interface = interface
|
||||
self.base_downloader = base_downloader
|
||||
self.song_downloader = song_downloader
|
||||
self.music_video_downloader = music_video_downloader
|
||||
self.uploaded_video_downloader = uploaded_video_downloader
|
||||
self.skip_music_videos = skip_music_videos
|
||||
self.skip_processing = skip_processing
|
||||
self.flat_filter = flat_filter
|
||||
|
||||
async def get_single_download_item(
|
||||
self,
|
||||
media_metadata: dict,
|
||||
playlist_metadata: dict = None,
|
||||
) -> DownloadItem:
|
||||
if self.flat_filter:
|
||||
flat_filter_result = self.flat_filter(media_metadata)
|
||||
if asyncio.iscoroutine(flat_filter_result):
|
||||
flat_filter_result = await flat_filter_result
|
||||
|
||||
if flat_filter_result:
|
||||
return DownloadItem(
|
||||
media_metadata=media_metadata,
|
||||
playlist_metadata=playlist_metadata,
|
||||
flat_filter_result=flat_filter_result,
|
||||
)
|
||||
|
||||
return await self.get_single_download_item_no_filter(
|
||||
media_metadata,
|
||||
playlist_metadata,
|
||||
)
|
||||
|
||||
async def get_single_download_item_no_filter(
|
||||
self,
|
||||
media_metadata: dict,
|
||||
playlist_metadata: dict = None,
|
||||
) -> DownloadItem:
|
||||
download_item = None
|
||||
|
||||
if not self.base_downloader.is_media_streamable(
|
||||
media_metadata,
|
||||
):
|
||||
return DownloadItem(
|
||||
media_metadata=media_metadata,
|
||||
playlist_metadata=playlist_metadata,
|
||||
error=NotStreamable(media_metadata["id"]),
|
||||
)
|
||||
|
||||
if media_metadata["type"] in SONG_MEDIA_TYPE:
|
||||
download_item = await self.song_downloader.get_download_item(
|
||||
media_metadata,
|
||||
@@ -70,28 +112,23 @@ class AppleMusicDownloader:
|
||||
async def get_collection_download_items(
|
||||
self,
|
||||
collection_metadata: dict,
|
||||
) -> list[DownloadItem | Exception]:
|
||||
collection_metadata["relationships"]["tracks"]["data"].extend(
|
||||
[
|
||||
extended_data
|
||||
async for extended_data in self.base_downloader.apple_music_api.extend_api_data(
|
||||
collection_metadata["relationships"]["tracks"],
|
||||
)
|
||||
]
|
||||
)
|
||||
) -> list[DownloadItem]:
|
||||
tracks_metadata = collection_metadata["relationships"]["tracks"]["data"]
|
||||
async for extended_data in self.interface.apple_music_api.extend_api_data(
|
||||
collection_metadata["relationships"]["tracks"],
|
||||
):
|
||||
tracks_metadata.extend(extended_data["data"])
|
||||
|
||||
tasks = [
|
||||
asyncio.create_task(
|
||||
self.get_single_download_item(
|
||||
media_metadata,
|
||||
(
|
||||
collection_metadata
|
||||
if collection_metadata["type"] in PLAYLIST_MEDIA_TYPE
|
||||
else None
|
||||
),
|
||||
)
|
||||
self.get_single_download_item(
|
||||
media_metadata,
|
||||
(
|
||||
collection_metadata
|
||||
if collection_metadata["type"] in PLAYLIST_MEDIA_TYPE
|
||||
else None
|
||||
),
|
||||
)
|
||||
for media_metadata in collection_metadata["relationships"]["tracks"]["data"]
|
||||
for media_metadata in tracks_metadata
|
||||
]
|
||||
|
||||
download_items = await safe_gather(*tasks)
|
||||
@@ -100,12 +137,12 @@ class AppleMusicDownloader:
|
||||
async def get_artist_download_items(
|
||||
self,
|
||||
artist_metadata: dict,
|
||||
) -> list[DownloadItem | Exception]:
|
||||
) -> list[DownloadItem]:
|
||||
for relationship in artist_metadata["relationships"].keys():
|
||||
artist_metadata["relationships"][relationship]["data"].extend(
|
||||
[
|
||||
extended_data
|
||||
async for extended_data in self.base_downloader.apple_music_api.extend_api_data(
|
||||
async for extended_data in self.interface.apple_music_api.extend_api_data(
|
||||
artist_metadata["relationships"][relationship],
|
||||
)
|
||||
]
|
||||
@@ -141,7 +178,7 @@ class AppleMusicDownloader:
|
||||
async def get_artist_albums_download_items(
|
||||
self,
|
||||
albums_metadata: list[dict],
|
||||
) -> list[DownloadItem | Exception]:
|
||||
) -> list[DownloadItem]:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
@@ -155,6 +192,7 @@ class AppleMusicDownloader:
|
||||
value=album,
|
||||
)
|
||||
for album in albums_metadata
|
||||
if album.get("attributes")
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which albums to download: (Track Count | Release Date | Rating | Title)",
|
||||
@@ -165,17 +203,13 @@ class AppleMusicDownloader:
|
||||
download_items = []
|
||||
|
||||
album_tasks = [
|
||||
asyncio.create_task(
|
||||
self.base_downloader.apple_music_api.get_album(album_metadata["id"])
|
||||
)
|
||||
self.interface.apple_music_api.get_album(album_metadata["id"])
|
||||
for album_metadata in selected
|
||||
]
|
||||
album_responses = await safe_gather(*album_tasks)
|
||||
|
||||
track_tasks = [
|
||||
asyncio.create_task(
|
||||
self.get_collection_download_items(album_response["data"][0])
|
||||
)
|
||||
self.get_collection_download_items(album_response["data"][0])
|
||||
for album_response in album_responses
|
||||
]
|
||||
track_results = await safe_gather(*track_tasks)
|
||||
@@ -188,7 +222,7 @@ class AppleMusicDownloader:
|
||||
async def get_artist_music_videos_download_items(
|
||||
self,
|
||||
music_videos_metadata: list[dict],
|
||||
) -> list[DownloadItem | Exception]:
|
||||
) -> list[DownloadItem]:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
@@ -203,6 +237,7 @@ class AppleMusicDownloader:
|
||||
value=music_video,
|
||||
)
|
||||
for music_video in music_videos_metadata
|
||||
if music_video.get("attributes")
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which music videos to download: (Duration | Rating | Title)",
|
||||
@@ -211,10 +246,8 @@ class AppleMusicDownloader:
|
||||
).execute_async()
|
||||
|
||||
music_video_tasks = [
|
||||
asyncio.create_task(
|
||||
self.get_single_download_item(
|
||||
music_video_metadata,
|
||||
)
|
||||
self.get_single_download_item(
|
||||
music_video_metadata,
|
||||
)
|
||||
for music_video_metadata in selected
|
||||
]
|
||||
@@ -238,9 +271,9 @@ class AppleMusicDownloader:
|
||||
async def get_download_queue(
|
||||
self,
|
||||
url_info: UrlInfo,
|
||||
) -> list[DownloadItem | Exception] | None:
|
||||
) -> list[DownloadItem] | None:
|
||||
return await self._get_download_queue(
|
||||
"song" if url_info.sub_id else url_info.type,
|
||||
"song" if url_info.sub_id else url_info.type or url_info.library_type,
|
||||
url_info.sub_id or url_info.id or url_info.library_id,
|
||||
url_info.library_id is not None,
|
||||
)
|
||||
@@ -250,11 +283,11 @@ class AppleMusicDownloader:
|
||||
url_type: str,
|
||||
id: str,
|
||||
is_library: bool,
|
||||
) -> list[DownloadItem | Exception] | None:
|
||||
) -> list[DownloadItem] | None:
|
||||
download_items = []
|
||||
|
||||
if url_type in ARTIST_MEDIA_TYPE:
|
||||
artist_response = await self.base_downloader.apple_music_api.get_artist(
|
||||
artist_response = await self.interface.apple_music_api.get_artist(
|
||||
id,
|
||||
)
|
||||
|
||||
@@ -266,7 +299,7 @@ class AppleMusicDownloader:
|
||||
)
|
||||
|
||||
if url_type in SONG_MEDIA_TYPE:
|
||||
song_respose = await self.base_downloader.apple_music_api.get_song(id)
|
||||
song_respose = await self.interface.apple_music_api.get_song(id)
|
||||
|
||||
if song_respose is None:
|
||||
return None
|
||||
@@ -277,13 +310,11 @@ class AppleMusicDownloader:
|
||||
|
||||
if url_type in ALBUM_MEDIA_TYPE:
|
||||
if is_library:
|
||||
album_response = (
|
||||
await self.base_downloader.apple_music_api.get_library_album(id)
|
||||
)
|
||||
else:
|
||||
album_response = await self.base_downloader.apple_music_api.get_album(
|
||||
album_response = await self.interface.apple_music_api.get_library_album(
|
||||
id
|
||||
)
|
||||
else:
|
||||
album_response = await self.interface.apple_music_api.get_album(id)
|
||||
|
||||
if album_response is None:
|
||||
return None
|
||||
@@ -295,11 +326,11 @@ class AppleMusicDownloader:
|
||||
if url_type in PLAYLIST_MEDIA_TYPE:
|
||||
if is_library:
|
||||
playlist_response = (
|
||||
await self.base_downloader.apple_music_api.get_library_playlist(id)
|
||||
await self.interface.apple_music_api.get_library_playlist(id)
|
||||
)
|
||||
else:
|
||||
playlist_response = (
|
||||
await self.base_downloader.apple_music_api.get_playlist(id)
|
||||
playlist_response = await self.interface.apple_music_api.get_playlist(
|
||||
id
|
||||
)
|
||||
|
||||
if playlist_response is None:
|
||||
@@ -310,8 +341,8 @@ class AppleMusicDownloader:
|
||||
)
|
||||
|
||||
if url_type in MUSIC_VIDEO_MEDIA_TYPE:
|
||||
music_video_response = (
|
||||
await self.base_downloader.apple_music_api.get_music_video(id)
|
||||
music_video_response = await self.interface.apple_music_api.get_music_video(
|
||||
id
|
||||
)
|
||||
|
||||
if music_video_response is None:
|
||||
@@ -322,9 +353,7 @@ class AppleMusicDownloader:
|
||||
)
|
||||
|
||||
if url_type in UPLOADED_VIDEO_MEDIA_TYPE:
|
||||
uploaded_video = (
|
||||
await self.base_downloader.apple_music_api.get_uploaded_video(id)
|
||||
)
|
||||
uploaded_video = await self.interface.apple_music_api.get_uploaded_video(id)
|
||||
|
||||
if uploaded_video is None:
|
||||
return None
|
||||
@@ -335,16 +364,27 @@ class AppleMusicDownloader:
|
||||
|
||||
return download_items
|
||||
|
||||
async def download(self, download_item: DownloadItem | Exception) -> None:
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> DownloadItem:
|
||||
try:
|
||||
if isinstance(download_item, Exception):
|
||||
raise download_item
|
||||
if download_item.flat_filter_result:
|
||||
download_item = await self.get_single_download_item_no_filter(
|
||||
download_item.media_metadata,
|
||||
download_item.playlist_metadata,
|
||||
)
|
||||
|
||||
if download_item.error:
|
||||
raise download_item.error
|
||||
|
||||
await self._initial_processing(download_item)
|
||||
await self._download(download_item)
|
||||
await self._final_processing(download_item)
|
||||
|
||||
return download_item
|
||||
finally:
|
||||
if isinstance(download_item, DownloadItem):
|
||||
if isinstance(download_item, DownloadItem) and not self.skip_processing:
|
||||
self.base_downloader.cleanup_temp(download_item.random_uuid)
|
||||
|
||||
async def _download(
|
||||
@@ -354,40 +394,69 @@ class AppleMusicDownloader:
|
||||
if (
|
||||
self.song_downloader.synced_lyrics_only
|
||||
and download_item.media_metadata["type"] not in SONG_MEDIA_TYPE
|
||||
) or (
|
||||
self.skip_music_videos
|
||||
and download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE
|
||||
):
|
||||
raise MediaDownloadConfigurationError(download_item.media_metadata["id"])
|
||||
raise SyncedLyricsOnly()
|
||||
|
||||
if self.song_downloader.synced_lyrics_only:
|
||||
return
|
||||
|
||||
if download_item.media_metadata["type"] in {
|
||||
*SONG_MEDIA_TYPE,
|
||||
*MUSIC_VIDEO_MEDIA_TYPE,
|
||||
} and (
|
||||
not download_item.stream_info
|
||||
or not download_item.stream_info.audio_track.widevine_pssh
|
||||
):
|
||||
raise MediaFormatNotAvailableError(
|
||||
download_item.media_metadata["id"],
|
||||
)
|
||||
|
||||
if (
|
||||
Path(download_item.final_path).exists()
|
||||
and not self.base_downloader.overwrite
|
||||
):
|
||||
raise FileExistsError(
|
||||
f'Media file already exists at "{download_item.final_path}"'
|
||||
)
|
||||
raise MediaFileExists(download_item.final_path)
|
||||
|
||||
if not self.base_downloader.is_media_streamable(
|
||||
download_item.media_metadata,
|
||||
):
|
||||
raise MediaNotStreamableError(
|
||||
download_item.media_metadata["id"],
|
||||
)
|
||||
if download_item.media_metadata["type"] in {
|
||||
*SONG_MEDIA_TYPE,
|
||||
*MUSIC_VIDEO_MEDIA_TYPE,
|
||||
}:
|
||||
if (
|
||||
self.base_downloader.remux_mode == RemuxMode.FFMPEG
|
||||
and not self.base_downloader.full_ffmpeg_path
|
||||
):
|
||||
raise ExecutableNotFound("ffmpeg")
|
||||
|
||||
if (
|
||||
self.base_downloader.remux_mode == RemuxMode.MP4BOX
|
||||
and not self.base_downloader.full_mp4box_path
|
||||
):
|
||||
raise ExecutableNotFound("MP4Box")
|
||||
|
||||
if (
|
||||
self.song_downloader.use_wrapper
|
||||
or (
|
||||
download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE
|
||||
or self.base_downloader.remux_mode == RemuxMode.MP4BOX
|
||||
)
|
||||
) and not self.base_downloader.full_mp4decrypt_path:
|
||||
raise ExecutableNotFound("mp4decrypt")
|
||||
|
||||
if (
|
||||
self.song_downloader.use_wrapper
|
||||
and not self.base_downloader.full_amdecrypt_path
|
||||
):
|
||||
raise ExecutableNotFound("amdecrypt")
|
||||
|
||||
if (
|
||||
self.base_downloader.download_mode == DownloadMode.NM3U8DLRE
|
||||
and not self.base_downloader.full_nm3u8dlre_path
|
||||
):
|
||||
raise ExecutableNotFound("N_m3u8DL-RE")
|
||||
|
||||
if (
|
||||
not download_item.stream_info
|
||||
or not download_item.stream_info.audio_track
|
||||
or not download_item.stream_info.audio_track.stream_url
|
||||
or (
|
||||
(
|
||||
not download_item.decryption_key
|
||||
or not download_item.decryption_key.audio_track
|
||||
or not download_item.decryption_key.audio_track.key
|
||||
)
|
||||
and not self.base_downloader.use_wrapper
|
||||
)
|
||||
):
|
||||
raise FormatNotAvailable(download_item.media_metadata["id"])
|
||||
|
||||
if download_item.media_metadata["type"] in SONG_MEDIA_TYPE:
|
||||
await self.song_downloader.download(download_item)
|
||||
@@ -402,6 +471,9 @@ class AppleMusicDownloader:
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
if self.skip_processing:
|
||||
return
|
||||
|
||||
if download_item.cover_path and self.base_downloader.save_cover:
|
||||
cover_url = self.base_downloader.get_cover_url(
|
||||
download_item.cover_url_template,
|
||||
@@ -441,6 +513,9 @@ class AppleMusicDownloader:
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
if self.skip_processing:
|
||||
return
|
||||
|
||||
if download_item.staged_path and Path(download_item.staged_path).exists():
|
||||
self.base_downloader.move_to_final_path(
|
||||
download_item.staged_path,
|
||||
|
||||
@@ -12,11 +12,8 @@ from PIL import Image
|
||||
from pywidevine import Cdm, Device
|
||||
from yt_dlp import YoutubeDL
|
||||
|
||||
from ..api.apple_music_api import AppleMusicApi
|
||||
from ..api.itunes_api import ItunesApi
|
||||
from ..interface.interface import AppleMusicInterface
|
||||
from ..interface.types import MediaTags, PlaylistTags
|
||||
from ..utils import async_subprocess, raise_for_status
|
||||
from ..utils import async_subprocess, get_response
|
||||
from .constants import (
|
||||
ILLEGAL_CHAR_REPLACEMENT,
|
||||
ILLEGAL_CHARS_RE,
|
||||
@@ -30,7 +27,6 @@ from .hardcoded_wvd import HARDCODED_WVD
|
||||
class AppleMusicBaseDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
apple_music_api: AppleMusicApi,
|
||||
output_path: str = "./Apple Music",
|
||||
temp_path: str = ".",
|
||||
wvd_path: str = None,
|
||||
@@ -41,14 +37,17 @@ class AppleMusicBaseDownloader:
|
||||
mp4decrypt_path: str = "mp4decrypt",
|
||||
ffmpeg_path: str = "ffmpeg",
|
||||
mp4box_path: str = "MP4Box",
|
||||
amdecrypt_path: str = "amdecrypt",
|
||||
use_wrapper: bool = False,
|
||||
wrapper_decrypt_ip: str = "127.0.0.1:10020",
|
||||
download_mode: DownloadMode = DownloadMode.YTDLP,
|
||||
remux_mode: RemuxMode = RemuxMode.FFMPEG,
|
||||
cover_format: CoverFormat = CoverFormat.JPG,
|
||||
album_folder_template: str = "{album_artist}/{album}",
|
||||
compilation_folder_template: str = "Compilations/{album}",
|
||||
single_disc_folder_template: str = "{track:02d} {title}",
|
||||
multi_disc_folder_template: str = "{disc}-{track:02d} {title}",
|
||||
no_album_folder_template: str = "{artist}/Unknown Album",
|
||||
single_disc_file_template: str = "{track:02d} {title}",
|
||||
multi_disc_file_template: str = "{disc}-{track:02d} {title}",
|
||||
no_album_file_template: str = "{title}",
|
||||
playlist_file_template: str = "Playlists/{playlist_artist}/{playlist_title}",
|
||||
date_tag_template: str = "%Y-%m-%dT%H:%M:%SZ",
|
||||
@@ -56,9 +55,7 @@ class AppleMusicBaseDownloader:
|
||||
cover_size: int = 1200,
|
||||
truncate: int = None,
|
||||
silent: bool = False,
|
||||
skip_processing: bool = False,
|
||||
):
|
||||
self.apple_music_api = apple_music_api
|
||||
self.output_path = output_path
|
||||
self.temp_path = temp_path
|
||||
self.wvd_path = wvd_path
|
||||
@@ -69,14 +66,17 @@ class AppleMusicBaseDownloader:
|
||||
self.mp4decrypt_path = mp4decrypt_path
|
||||
self.ffmpeg_path = ffmpeg_path
|
||||
self.mp4box_path = mp4box_path
|
||||
self.amdecrypt_path = amdecrypt_path
|
||||
self.use_wrapper = use_wrapper
|
||||
self.wrapper_decrypt_ip = wrapper_decrypt_ip
|
||||
self.download_mode = download_mode
|
||||
self.remux_mode = remux_mode
|
||||
self.cover_format = cover_format
|
||||
self.album_folder_template = album_folder_template
|
||||
self.compilation_folder_template = compilation_folder_template
|
||||
self.single_disc_folder_template = single_disc_folder_template
|
||||
self.multi_disc_folder_template = multi_disc_folder_template
|
||||
self.no_album_folder_template = no_album_folder_template
|
||||
self.single_disc_file_template = single_disc_file_template
|
||||
self.multi_disc_file_template = multi_disc_file_template
|
||||
self.no_album_file_template = no_album_file_template
|
||||
self.playlist_file_template = playlist_file_template
|
||||
self.date_tag_template = date_tag_template
|
||||
@@ -84,34 +84,26 @@ class AppleMusicBaseDownloader:
|
||||
self.cover_size = cover_size
|
||||
self.truncate = truncate
|
||||
self.silent = silent
|
||||
self.skip_processing = skip_processing
|
||||
self.initialize()
|
||||
|
||||
def setup(self):
|
||||
self._setup_binary_paths()
|
||||
self._setup_cdm()
|
||||
self._setup_interface()
|
||||
def initialize(self):
|
||||
self._initialize_binary_paths()
|
||||
self._initialize_cdm()
|
||||
|
||||
def _setup_binary_paths(self):
|
||||
def _initialize_binary_paths(self):
|
||||
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)
|
||||
self.full_amdecrypt_path = shutil.which(self.amdecrypt_path)
|
||||
|
||||
def _setup_cdm(self):
|
||||
def _initialize_cdm(self):
|
||||
if self.wvd_path:
|
||||
self.cdm = Cdm.from_device(Device.load(self.wvd_path))
|
||||
else:
|
||||
self.cdm = Cdm.from_device(Device.loads(HARDCODED_WVD))
|
||||
self.cdm.MAX_NUM_OF_SESSIONS = float("inf")
|
||||
|
||||
def _setup_interface(self):
|
||||
self.itunes_api = ItunesApi(
|
||||
self.apple_music_api.storefront,
|
||||
self.apple_music_api.language,
|
||||
)
|
||||
self.itunes_api.setup()
|
||||
self.interface = AppleMusicInterface(self.apple_music_api, self.itunes_api)
|
||||
|
||||
def get_random_uuid(self) -> str:
|
||||
return uuid.uuid4().hex[:8]
|
||||
|
||||
@@ -171,13 +163,10 @@ class AppleMusicBaseDownloader:
|
||||
|
||||
@alru_cache()
|
||||
async def get_cover_bytes(self, cover_url: str) -> bytes | None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(cover_url)
|
||||
raise_for_status(response, {200, 404})
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.content
|
||||
return None
|
||||
response = await get_response(cover_url, {200, 404})
|
||||
if response.status_code == 200:
|
||||
return response.content
|
||||
return None
|
||||
|
||||
def get_sanitized_string(self, dirty_string: str, is_folder: bool) -> str:
|
||||
dirty_string = re.sub(
|
||||
@@ -207,9 +196,9 @@ class AppleMusicBaseDownloader:
|
||||
else self.album_folder_template.split("/")
|
||||
)
|
||||
template_file = (
|
||||
self.multi_disc_folder_template.split("/")
|
||||
self.multi_disc_file_template.split("/")
|
||||
if tags.disc_total > 1
|
||||
else self.single_disc_folder_template.split("/")
|
||||
else self.single_disc_file_template.split("/")
|
||||
)
|
||||
else:
|
||||
template_folder = self.no_album_folder_template.split("/")
|
||||
@@ -343,15 +332,43 @@ class AppleMusicBaseDownloader:
|
||||
}
|
||||
)
|
||||
mp4_tags = filtered_tags.as_mp4_tags(self.date_tag_template)
|
||||
|
||||
cover_url = self.get_cover_url(cover_url_template)
|
||||
cover_bytes = await self.get_cover_bytes(cover_url)
|
||||
|
||||
skip_tagging = "all" in exclude_tags
|
||||
|
||||
await asyncio.to_thread(
|
||||
self.apply_mp4_tags,
|
||||
media_path,
|
||||
mp4_tags,
|
||||
cover_bytes,
|
||||
skip_tagging,
|
||||
)
|
||||
|
||||
def apply_mp4_tags(
|
||||
self,
|
||||
media_path: Path,
|
||||
tags: dict,
|
||||
cover_bytes: bytes | None,
|
||||
skip_tagging: bool,
|
||||
):
|
||||
mp4 = MP4(media_path)
|
||||
mp4.clear()
|
||||
|
||||
if not skip_tagging:
|
||||
if "cover" not in exclude_tags and self.cover_format != CoverFormat.RAW:
|
||||
await self._apply_cover(mp4, cover_url_template)
|
||||
mp4.update(mp4_tags)
|
||||
if cover_bytes is not None:
|
||||
mp4["covr"] = [
|
||||
MP4Cover(
|
||||
data=cover_bytes,
|
||||
imageformat=(
|
||||
MP4Cover.FORMAT_JPEG
|
||||
if self.cover_format == CoverFormat.JPG
|
||||
else MP4Cover.FORMAT_PNG
|
||||
),
|
||||
)
|
||||
]
|
||||
mp4.update(tags)
|
||||
|
||||
mp4.save()
|
||||
|
||||
|
||||
@@ -9,10 +9,11 @@ from .enums import RemuxFormatMusicVideo, RemuxMode
|
||||
from .types import DownloadItem
|
||||
|
||||
|
||||
class AppleMusicMusicVideoDownloader:
|
||||
class AppleMusicMusicVideoDownloader(AppleMusicBaseDownloader):
|
||||
def __init__(
|
||||
self,
|
||||
downloader: AppleMusicBaseDownloader,
|
||||
base_downloader: AppleMusicBaseDownloader,
|
||||
interface: AppleMusicMusicVideoInterface,
|
||||
codec_priority: list[MusicVideoCodec] = [
|
||||
MusicVideoCodec.H264,
|
||||
MusicVideoCodec.H265,
|
||||
@@ -20,19 +21,12 @@ class AppleMusicMusicVideoDownloader:
|
||||
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
|
||||
resolution: MusicVideoResolution = MusicVideoResolution.R1080P,
|
||||
):
|
||||
self.downloader = downloader
|
||||
self.__dict__.update(base_downloader.__dict__)
|
||||
self.interface = interface
|
||||
self.codec_priority = codec_priority
|
||||
self.remux_format = remux_format
|
||||
self.resolution = resolution
|
||||
|
||||
def setup(self):
|
||||
self._setup_interface()
|
||||
|
||||
def _setup_interface(self):
|
||||
self.music_video_interface = AppleMusicMusicVideoInterface(
|
||||
self.downloader.interface,
|
||||
)
|
||||
|
||||
async def remux_mp4box(
|
||||
self,
|
||||
input_path_video: str,
|
||||
@@ -40,7 +34,7 @@ class AppleMusicMusicVideoDownloader:
|
||||
output_path: str,
|
||||
):
|
||||
await async_subprocess(
|
||||
self.downloader.full_mp4box_path,
|
||||
self.full_mp4box_path,
|
||||
"-quiet",
|
||||
"-add",
|
||||
input_path_audio,
|
||||
@@ -51,7 +45,7 @@ class AppleMusicMusicVideoDownloader:
|
||||
"-keep-utc",
|
||||
"-new",
|
||||
output_path,
|
||||
silent=self.downloader.silent,
|
||||
silent=self.silent,
|
||||
)
|
||||
|
||||
async def remux_ffmpeg(
|
||||
@@ -70,7 +64,7 @@ class AppleMusicMusicVideoDownloader:
|
||||
key = []
|
||||
|
||||
await async_subprocess(
|
||||
self.downloader.full_ffmpeg_path,
|
||||
self.full_ffmpeg_path,
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
@@ -86,7 +80,7 @@ class AppleMusicMusicVideoDownloader:
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
output_path,
|
||||
silent=self.downloader.silent,
|
||||
silent=self.silent,
|
||||
)
|
||||
|
||||
async def decrypt_mp4decrypt(
|
||||
@@ -96,12 +90,12 @@ class AppleMusicMusicVideoDownloader:
|
||||
decryption_key: str,
|
||||
):
|
||||
await async_subprocess(
|
||||
self.downloader.full_mp4decrypt_path,
|
||||
self.full_mp4decrypt_path,
|
||||
"--key",
|
||||
f"1:{decryption_key}",
|
||||
input_path,
|
||||
output_path,
|
||||
silent=self.downloader.silent,
|
||||
silent=self.silent,
|
||||
)
|
||||
|
||||
async def stage(
|
||||
@@ -124,7 +118,7 @@ class AppleMusicMusicVideoDownloader:
|
||||
decryption_key.audio_track.key,
|
||||
)
|
||||
|
||||
if self.downloader.remux_mode == RemuxMode.MP4BOX:
|
||||
if self.remux_mode == RemuxMode.MP4BOX:
|
||||
await self.remux_mp4box(
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
@@ -148,50 +142,64 @@ class AppleMusicMusicVideoDownloader:
|
||||
self,
|
||||
music_video_metadata: dict,
|
||||
playlist_metadata: dict = None,
|
||||
) -> DownloadItem:
|
||||
try:
|
||||
return await self._get_download_item(
|
||||
music_video_metadata,
|
||||
playlist_metadata,
|
||||
)
|
||||
except Exception as e:
|
||||
return DownloadItem(
|
||||
media_metadata=music_video_metadata,
|
||||
playlist_metadata=playlist_metadata,
|
||||
error=e,
|
||||
)
|
||||
|
||||
async def _get_download_item(
|
||||
self,
|
||||
music_video_metadata: dict,
|
||||
playlist_metadata: dict = None,
|
||||
) -> DownloadItem:
|
||||
download_item = DownloadItem()
|
||||
|
||||
download_item.media_metadata = music_video_metadata
|
||||
download_item.playlist_metadata = playlist_metadata
|
||||
|
||||
music_video_id = self.downloader.interface.get_media_id_of_library_media(
|
||||
music_video_id = self.interface.get_media_id_of_library_media(
|
||||
music_video_metadata,
|
||||
)
|
||||
|
||||
itunes_page_metadata = (
|
||||
await self.music_video_interface.get_itunes_page_metadata(
|
||||
music_video_metadata,
|
||||
)
|
||||
itunes_page_metadata = await self.interface.get_itunes_page_metadata(
|
||||
music_video_metadata,
|
||||
)
|
||||
download_item.media_tags = await self.music_video_interface.get_tags(
|
||||
download_item.media_tags = await self.interface.get_tags(
|
||||
music_video_metadata,
|
||||
itunes_page_metadata,
|
||||
)
|
||||
|
||||
if playlist_metadata:
|
||||
download_item.playlist_tags = self.downloader.get_playlist_tags(
|
||||
download_item.playlist_tags = self.get_playlist_tags(
|
||||
playlist_metadata,
|
||||
music_video_metadata,
|
||||
)
|
||||
download_item.playlist_file_path = self.downloader.get_playlist_file_path(
|
||||
download_item.playlist_file_path = self.get_playlist_file_path(
|
||||
download_item.playlist_tags,
|
||||
)
|
||||
|
||||
stream_info = await self.music_video_interface.get_stream_info(
|
||||
download_item.stream_info = await self.interface.get_stream_info(
|
||||
music_video_metadata,
|
||||
itunes_page_metadata,
|
||||
self.codec_priority,
|
||||
self.resolution,
|
||||
)
|
||||
download_item.stream_info = stream_info
|
||||
|
||||
decryption_key = await self.music_video_interface.get_decryption_key(
|
||||
stream_info,
|
||||
self.downloader.cdm,
|
||||
download_item.decryption_key = await self.interface.get_decryption_key(
|
||||
download_item.stream_info,
|
||||
self.cdm,
|
||||
)
|
||||
download_item.decryption_key = decryption_key
|
||||
|
||||
download_item.random_uuid = self.downloader.get_random_uuid()
|
||||
download_item.staged_path = self.downloader.get_temp_path(
|
||||
download_item.random_uuid = self.get_random_uuid()
|
||||
download_item.staged_path = self.get_temp_path(
|
||||
music_video_id,
|
||||
download_item.random_uuid,
|
||||
"staged",
|
||||
@@ -204,16 +212,16 @@ class AppleMusicMusicVideoDownloader:
|
||||
)
|
||||
),
|
||||
)
|
||||
download_item.final_path = self.downloader.get_final_path(
|
||||
download_item.final_path = self.get_final_path(
|
||||
download_item.media_tags,
|
||||
Path(download_item.staged_path).suffix,
|
||||
playlist_metadata,
|
||||
)
|
||||
|
||||
download_item.cover_url_template = self.downloader.get_cover_url_template(
|
||||
download_item.cover_url_template = self.get_cover_url_template(
|
||||
music_video_metadata,
|
||||
)
|
||||
cover_file_extension = await self.downloader.get_cover_file_extension(
|
||||
cover_file_extension = await self.get_cover_file_extension(
|
||||
download_item.cover_url_template,
|
||||
)
|
||||
if cover_file_extension:
|
||||
@@ -228,35 +236,35 @@ class AppleMusicMusicVideoDownloader:
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
encrypted_path_video = self.downloader.get_temp_path(
|
||||
encrypted_path_video = self.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"encrypted_video",
|
||||
".mp4",
|
||||
)
|
||||
encrypted_path_audio = self.downloader.get_temp_path(
|
||||
encrypted_path_audio = self.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"encrypted_audio",
|
||||
".m4a",
|
||||
)
|
||||
|
||||
await self.downloader.download_stream(
|
||||
await self.download_stream(
|
||||
download_item.stream_info.video_track.stream_url,
|
||||
encrypted_path_video,
|
||||
)
|
||||
await self.downloader.download_stream(
|
||||
await self.download_stream(
|
||||
download_item.stream_info.audio_track.stream_url,
|
||||
encrypted_path_audio,
|
||||
)
|
||||
|
||||
decrypted_path_video = self.downloader.get_temp_path(
|
||||
decrypted_path_video = self.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"decrypted_video",
|
||||
".mp4",
|
||||
)
|
||||
decrypted_path_audio = self.downloader.get_temp_path(
|
||||
decrypted_path_audio = self.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"decrypted_audio",
|
||||
@@ -272,7 +280,7 @@ class AppleMusicMusicVideoDownloader:
|
||||
download_item.decryption_key,
|
||||
)
|
||||
|
||||
await self.downloader.apply_tags(
|
||||
await self.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media_tags,
|
||||
download_item.cover_url_template,
|
||||
|
||||
@@ -10,59 +10,73 @@ from .enums import RemuxMode
|
||||
from .types import DownloadItem
|
||||
|
||||
|
||||
class AppleMusicSongDownloader:
|
||||
class AppleMusicSongDownloader(AppleMusicBaseDownloader):
|
||||
def __init__(
|
||||
self,
|
||||
downloader: AppleMusicBaseDownloader,
|
||||
base_downloader: AppleMusicBaseDownloader,
|
||||
interface: AppleMusicSongInterface,
|
||||
codec: SongCodec = SongCodec.AAC_LEGACY,
|
||||
synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC,
|
||||
no_synced_lyrics: bool = False,
|
||||
synced_lyrics_only: bool = False,
|
||||
):
|
||||
self.downloader = downloader
|
||||
self.__dict__.update(base_downloader.__dict__)
|
||||
self.interface = interface
|
||||
self.codec = codec
|
||||
self.synced_lyrics_format = synced_lyrics_format
|
||||
self.no_synced_lyrics = no_synced_lyrics
|
||||
self.synced_lyrics_only = synced_lyrics_only
|
||||
|
||||
def setup(self):
|
||||
self._setup_interface()
|
||||
|
||||
def _setup_interface(self):
|
||||
self.song_interface = AppleMusicSongInterface(self.downloader.interface)
|
||||
|
||||
async def get_download_item(
|
||||
self,
|
||||
song_metadata: dict,
|
||||
playlist_metadata: dict = None,
|
||||
) -> DownloadItem:
|
||||
try:
|
||||
return await self._get_download_item(
|
||||
song_metadata,
|
||||
playlist_metadata,
|
||||
)
|
||||
except Exception as e:
|
||||
return DownloadItem(
|
||||
media_metadata=song_metadata,
|
||||
playlist_metadata=playlist_metadata,
|
||||
error=e,
|
||||
)
|
||||
|
||||
async def _get_download_item(
|
||||
self,
|
||||
song_metadata: dict,
|
||||
playlist_metadata: dict = None,
|
||||
) -> DownloadItem:
|
||||
download_item = DownloadItem()
|
||||
|
||||
download_item.media_metadata = song_metadata
|
||||
download_item.playlist_metadata = playlist_metadata
|
||||
|
||||
song_id = self.downloader.interface.get_media_id_of_library_media(song_metadata)
|
||||
song_id = self.interface.get_media_id_of_library_media(song_metadata)
|
||||
|
||||
download_item.lyrics = await self.song_interface.get_lyrics(
|
||||
download_item.lyrics = await self.interface.get_lyrics(
|
||||
song_metadata,
|
||||
self.synced_lyrics_format,
|
||||
)
|
||||
|
||||
webplayback = await self.downloader.apple_music_api.get_webplayback(song_id)
|
||||
download_item.media_tags = self.song_interface.get_tags(
|
||||
webplayback = await self.interface.apple_music_api.get_webplayback(song_id)
|
||||
download_item.media_tags = self.interface.get_tags(
|
||||
webplayback,
|
||||
download_item.lyrics.unsynced if download_item.lyrics else None,
|
||||
)
|
||||
|
||||
if playlist_metadata:
|
||||
download_item.playlist_tags = self.downloader.get_playlist_tags(
|
||||
download_item.playlist_tags = self.get_playlist_tags(
|
||||
playlist_metadata,
|
||||
song_metadata,
|
||||
)
|
||||
download_item.playlist_file_path = self.downloader.get_playlist_file_path(
|
||||
download_item.playlist_file_path = self.get_playlist_file_path(
|
||||
download_item.playlist_tags,
|
||||
)
|
||||
|
||||
download_item.final_path = self.downloader.get_final_path(
|
||||
download_item.final_path = self.get_final_path(
|
||||
download_item.media_tags,
|
||||
".m4a",
|
||||
download_item.playlist_tags,
|
||||
@@ -75,48 +89,47 @@ class AppleMusicSongDownloader:
|
||||
return download_item
|
||||
|
||||
if self.codec.is_legacy():
|
||||
download_item.stream_info = (
|
||||
await self.song_interface.get_stream_info_legacy(
|
||||
webplayback,
|
||||
self.codec,
|
||||
)
|
||||
download_item.stream_info = await self.interface.get_stream_info_legacy(
|
||||
webplayback,
|
||||
self.codec,
|
||||
)
|
||||
download_item.decryption_key = (
|
||||
await self.song_interface.get_decryption_key_legacy(
|
||||
await self.interface.get_decryption_key_legacy(
|
||||
download_item.stream_info,
|
||||
self.downloader.cdm,
|
||||
self.cdm,
|
||||
)
|
||||
)
|
||||
else:
|
||||
download_item.stream_info = await self.song_interface.get_stream_info(
|
||||
download_item.stream_info = await self.interface.get_stream_info(
|
||||
song_metadata,
|
||||
self.codec,
|
||||
)
|
||||
if (
|
||||
download_item.stream_info
|
||||
not self.use_wrapper
|
||||
and download_item.stream_info
|
||||
and download_item.stream_info.audio_track.widevine_pssh
|
||||
):
|
||||
download_item.decryption_key = (
|
||||
await self.song_interface.get_decryption_key(
|
||||
download_item.stream_info,
|
||||
self.downloader.cdm,
|
||||
)
|
||||
download_item.decryption_key = await self.interface.get_decryption_key(
|
||||
download_item.stream_info,
|
||||
self.cdm,
|
||||
)
|
||||
else:
|
||||
download_item.decryption_key = None
|
||||
|
||||
download_item.cover_url_template = self.downloader.get_cover_url_template(
|
||||
song_metadata
|
||||
)
|
||||
download_item.cover_url_template = self.get_cover_url_template(song_metadata)
|
||||
|
||||
download_item.random_uuid = self.downloader.get_random_uuid()
|
||||
download_item.staged_path = self.downloader.get_temp_path(
|
||||
song_id,
|
||||
download_item.random_uuid,
|
||||
"staged",
|
||||
"." + download_item.stream_info.file_format.value,
|
||||
)
|
||||
cover_file_extension = await self.downloader.get_cover_file_extension(
|
||||
download_item.random_uuid = self.get_random_uuid()
|
||||
if download_item.stream_info and download_item.stream_info.file_format:
|
||||
download_item.staged_path = self.get_temp_path(
|
||||
song_id,
|
||||
download_item.random_uuid,
|
||||
"staged",
|
||||
"." + download_item.stream_info.file_format.value,
|
||||
)
|
||||
else:
|
||||
download_item.staged_path = None
|
||||
|
||||
cover_file_extension = await self.get_cover_file_extension(
|
||||
download_item.cover_url_template,
|
||||
)
|
||||
if cover_file_extension:
|
||||
@@ -143,7 +156,7 @@ class AppleMusicSongDownloader:
|
||||
|
||||
async def remux_mp4box(self, input_path: str, output_path: str):
|
||||
await async_subprocess(
|
||||
self.downloader.full_mp4box_path,
|
||||
self.full_mp4box_path,
|
||||
"-quiet",
|
||||
"-add",
|
||||
input_path,
|
||||
@@ -152,7 +165,7 @@ class AppleMusicSongDownloader:
|
||||
"-keep-utc",
|
||||
"-new",
|
||||
output_path,
|
||||
silent=self.downloader.silent,
|
||||
silent=self.silent,
|
||||
)
|
||||
|
||||
async def remux_ffmpeg(
|
||||
@@ -170,7 +183,7 @@ class AppleMusicSongDownloader:
|
||||
key = []
|
||||
|
||||
await async_subprocess(
|
||||
self.downloader.full_ffmpeg_path,
|
||||
self.full_ffmpeg_path,
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
@@ -182,7 +195,7 @@ class AppleMusicSongDownloader:
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
output_path,
|
||||
silent=self.downloader.silent,
|
||||
silent=self.silent,
|
||||
)
|
||||
|
||||
async def decrypt_mp4decrypt(
|
||||
@@ -207,11 +220,28 @@ class AppleMusicSongDownloader:
|
||||
]
|
||||
|
||||
await async_subprocess(
|
||||
self.downloader.full_mp4decrypt_path,
|
||||
self.full_mp4decrypt_path,
|
||||
*keys,
|
||||
input_path,
|
||||
output_path,
|
||||
silent=self.downloader.silent,
|
||||
silent=self.silent,
|
||||
)
|
||||
|
||||
async def decrypt_amdecrypt(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
media_id: str,
|
||||
fairplay_key: str,
|
||||
) -> None:
|
||||
await async_subprocess(
|
||||
self.amdecrypt_path,
|
||||
self.wrapper_decrypt_ip,
|
||||
self.full_mp4decrypt_path,
|
||||
media_id,
|
||||
fairplay_key,
|
||||
input_path,
|
||||
output_path,
|
||||
)
|
||||
|
||||
async def stage(
|
||||
@@ -221,21 +251,23 @@ class AppleMusicSongDownloader:
|
||||
staged_path: str,
|
||||
decryption_key: DecryptionKeyAv,
|
||||
codec: SongCodec,
|
||||
media_id: str,
|
||||
fairplay_key: str,
|
||||
):
|
||||
if codec.is_legacy() and self.downloader.remux_mode == RemuxMode.FFMPEG:
|
||||
if codec.is_legacy() and self.remux_mode == RemuxMode.FFMPEG:
|
||||
await self.remux_ffmpeg(
|
||||
encrypted_path,
|
||||
staged_path,
|
||||
decryption_key.audio_track.key,
|
||||
)
|
||||
else:
|
||||
elif codec.is_legacy() or not self.use_wrapper:
|
||||
await self.decrypt_mp4decrypt(
|
||||
encrypted_path,
|
||||
decrypted_path,
|
||||
decryption_key.audio_track.key,
|
||||
codec.is_legacy(),
|
||||
)
|
||||
if self.downloader.remux_mode == RemuxMode.FFMPEG:
|
||||
if self.remux_mode == RemuxMode.FFMPEG:
|
||||
await self.remux_ffmpeg(
|
||||
decrypted_path,
|
||||
staged_path,
|
||||
@@ -245,6 +277,13 @@ class AppleMusicSongDownloader:
|
||||
decrypted_path,
|
||||
staged_path,
|
||||
)
|
||||
else:
|
||||
await self.decrypt_amdecrypt(
|
||||
encrypted_path,
|
||||
staged_path,
|
||||
media_id,
|
||||
fairplay_key,
|
||||
)
|
||||
|
||||
def get_lyrics_synced_path(self, final_path: str) -> str:
|
||||
return str(Path(final_path).with_suffix("." + self.synced_lyrics_format.value))
|
||||
@@ -271,18 +310,18 @@ class AppleMusicSongDownloader:
|
||||
if self.synced_lyrics_only:
|
||||
return
|
||||
|
||||
encrypted_path = self.downloader.get_temp_path(
|
||||
encrypted_path = self.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"encrypted",
|
||||
".m4a",
|
||||
)
|
||||
await self.downloader.download_stream(
|
||||
await self.download_stream(
|
||||
download_item.stream_info.audio_track.stream_url,
|
||||
encrypted_path,
|
||||
)
|
||||
|
||||
decrypted_path = self.downloader.get_temp_path(
|
||||
decrypted_path = self.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"decrypted",
|
||||
@@ -294,9 +333,11 @@ class AppleMusicSongDownloader:
|
||||
download_item.staged_path,
|
||||
download_item.decryption_key,
|
||||
self.codec,
|
||||
download_item.media_metadata["id"],
|
||||
download_item.stream_info.audio_track.fairplay_key,
|
||||
)
|
||||
|
||||
await self.downloader.apply_tags(
|
||||
await self.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media_tags,
|
||||
download_item.cover_url_template,
|
||||
|
||||
@@ -6,60 +6,68 @@ from .downloader_base import AppleMusicBaseDownloader
|
||||
from .types import DownloadItem
|
||||
|
||||
|
||||
class AppleMusicUploadedVideoDownloader:
|
||||
class AppleMusicUploadedVideoDownloader(AppleMusicBaseDownloader):
|
||||
def __init__(
|
||||
self,
|
||||
downloader: AppleMusicBaseDownloader,
|
||||
base_downloader: AppleMusicBaseDownloader,
|
||||
interface: AppleMusicUploadedVideoInterface,
|
||||
quality: UploadedVideoQuality = UploadedVideoQuality.BEST,
|
||||
):
|
||||
self.downloader = downloader
|
||||
self.__dict__.update(base_downloader.__dict__)
|
||||
self.interface = interface
|
||||
self.quality = quality
|
||||
|
||||
def setup(self):
|
||||
self._setup_interface()
|
||||
|
||||
def _setup_interface(self):
|
||||
self.uploaded_video_interface = AppleMusicUploadedVideoInterface(
|
||||
self.downloader.interface,
|
||||
)
|
||||
|
||||
def get_cover_path(self, final_path: str, file_extension: str) -> str:
|
||||
return str(Path(final_path).with_suffix(file_extension))
|
||||
|
||||
async def get_download_item(
|
||||
self,
|
||||
uploaded_video_metadata: dict,
|
||||
) -> DownloadItem:
|
||||
try:
|
||||
return await self._get_download_item(
|
||||
uploaded_video_metadata,
|
||||
)
|
||||
except Exception as e:
|
||||
return DownloadItem(
|
||||
media_metadata=uploaded_video_metadata,
|
||||
error=e,
|
||||
)
|
||||
|
||||
async def _get_download_item(
|
||||
self,
|
||||
uploaded_video_metadata: dict,
|
||||
) -> DownloadItem:
|
||||
download_item = DownloadItem()
|
||||
|
||||
download_item.media_metadata = uploaded_video_metadata
|
||||
|
||||
download_item.media_tags = self.uploaded_video_interface.get_tags(
|
||||
download_item.media_tags = self.interface.get_tags(
|
||||
uploaded_video_metadata,
|
||||
)
|
||||
|
||||
download_item.stream_info = await self.uploaded_video_interface.get_stream_info(
|
||||
download_item.stream_info = await self.interface.get_stream_info(
|
||||
uploaded_video_metadata,
|
||||
self.quality,
|
||||
)
|
||||
|
||||
download_item.random_uuid = self.downloader.get_random_uuid()
|
||||
download_item.staged_path = self.downloader.get_temp_path(
|
||||
download_item.random_uuid = self.get_random_uuid()
|
||||
download_item.staged_path = self.get_temp_path(
|
||||
uploaded_video_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"staged",
|
||||
"." + download_item.stream_info.file_format.value,
|
||||
)
|
||||
download_item.final_path = self.downloader.get_final_path(
|
||||
download_item.final_path = self.get_final_path(
|
||||
download_item.media_tags,
|
||||
Path(download_item.staged_path).suffix,
|
||||
None,
|
||||
)
|
||||
|
||||
download_item.cover_url_template = self.downloader.get_cover_url_template(
|
||||
download_item.cover_url_template = self.get_cover_url_template(
|
||||
uploaded_video_metadata,
|
||||
)
|
||||
cover_file_extension = await self.downloader.get_cover_file_extension(
|
||||
cover_file_extension = await self.get_cover_file_extension(
|
||||
download_item.cover_url_template,
|
||||
)
|
||||
if cover_file_extension:
|
||||
@@ -74,11 +82,11 @@ class AppleMusicUploadedVideoDownloader:
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
await self.downloader.download_ytdlp(
|
||||
await self.download_ytdlp(
|
||||
download_item.stream_info.video_track.stream_url,
|
||||
download_item.staged_path,
|
||||
)
|
||||
await self.downloader.apply_tags(
|
||||
await self.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media_tags,
|
||||
download_item.cover_url_template,
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
class MediaNotStreamableError(Exception):
|
||||
class GamdlError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MediaFileExists(GamdlError):
|
||||
def __init__(self, media_path: str):
|
||||
super().__init__(f"Media file already exists at path: {media_path}")
|
||||
|
||||
|
||||
class NotStreamable(GamdlError):
|
||||
def __init__(self, media_id: str):
|
||||
super().__init__(
|
||||
f'Media with ID "{media_id}" is not streamable'.format(media_id=media_id)
|
||||
)
|
||||
super().__init__(f"Media ID is not streamable: {media_id}")
|
||||
|
||||
|
||||
class MediaFormatNotAvailableError(Exception):
|
||||
class FormatNotAvailable(GamdlError):
|
||||
def __init__(self, media_id: str):
|
||||
super().__init__(
|
||||
f'Media with ID "{media_id}" is not available in the requested format'
|
||||
)
|
||||
super().__init__(f"Requested format is not available for media ID: {media_id}")
|
||||
|
||||
|
||||
class MediaDownloadConfigurationError(Exception):
|
||||
def __init__(self, media_id: str):
|
||||
super().__init__(
|
||||
f'Media with ID "{media_id}" is not downloadable with the current configuration'
|
||||
)
|
||||
class ExecutableNotFound(GamdlError):
|
||||
def __init__(self, executable: str):
|
||||
super().__init__(f"Executable not found: {executable}")
|
||||
|
||||
|
||||
class SyncedLyricsOnly(GamdlError):
|
||||
def __init__(self):
|
||||
super().__init__("Only downloading synced lyrics is supported")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from ..interface.types import (
|
||||
DecryptionKeyAv,
|
||||
@@ -12,6 +13,7 @@ from ..interface.types import (
|
||||
@dataclass
|
||||
class DownloadItem:
|
||||
media_metadata: dict = None
|
||||
playlist_metadata: dict = None
|
||||
random_uuid: str = None
|
||||
lyrics: Lyrics = None
|
||||
media_tags: MediaTags = None
|
||||
@@ -24,6 +26,8 @@ class DownloadItem:
|
||||
playlist_file_path: str = None
|
||||
synced_lyrics_path: str = None
|
||||
cover_path: str = None
|
||||
flat_filter_result: Any = None
|
||||
error: Exception = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime
|
||||
import logging
|
||||
@@ -41,7 +42,9 @@ class AppleMusicInterface:
|
||||
pssh_obj = PSSH(track_uri.split(",")[-1])
|
||||
|
||||
challenge = base64.b64encode(
|
||||
cdm.get_license_challenge(cdm_session, pssh_obj)
|
||||
await asyncio.to_thread(
|
||||
cdm.get_license_challenge, cdm_session, pssh_obj
|
||||
)
|
||||
).decode()
|
||||
license = await self.apple_music_api.get_license_exchange(
|
||||
track_id,
|
||||
@@ -49,7 +52,7 @@ class AppleMusicInterface:
|
||||
challenge,
|
||||
)
|
||||
|
||||
cdm.parse_license(cdm_session, license["license"])
|
||||
await asyncio.to_thread(cdm.parse_license, cdm_session, license["license"])
|
||||
decryption_key_info = next(
|
||||
i for i in cdm.get_keys(cdm_session) if i.type == "CONTENT"
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
from pywidevine import Cdm
|
||||
|
||||
from ..utils import get_response_text
|
||||
from ..utils import get_response
|
||||
from .constants import MP4_FORMAT_CODECS
|
||||
from .enums import MediaRating, MediaType, MusicVideoCodec, MusicVideoResolution
|
||||
from .interface import AppleMusicInterface
|
||||
@@ -16,19 +16,16 @@ from .types import DecryptionKeyAv, MediaFileFormat, MediaTags, StreamInfo, Stre
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppleMusicMusicVideoInterface:
|
||||
def __init__(
|
||||
self,
|
||||
interface: AppleMusicInterface,
|
||||
):
|
||||
self.interface = interface
|
||||
class AppleMusicMusicVideoInterface(AppleMusicInterface):
|
||||
def __init__(self, interface: AppleMusicInterface):
|
||||
self.__dict__.update(interface.__dict__)
|
||||
|
||||
async def get_itunes_page_metadata(
|
||||
self,
|
||||
music_video_metadata: dict,
|
||||
) -> dict:
|
||||
alt_id = self.get_alt_id(music_video_metadata)
|
||||
itunes_page = await self.interface.itunes_api.get_itunes_page(
|
||||
itunes_page = await self.itunes_api.get_itunes_page(
|
||||
"music-video",
|
||||
alt_id,
|
||||
)
|
||||
@@ -69,7 +66,7 @@ class AppleMusicMusicVideoInterface:
|
||||
self,
|
||||
collection_id: int,
|
||||
) -> dict | None:
|
||||
album_response = await self.interface.apple_music_api.get_album(collection_id)
|
||||
album_response = await self.apple_music_api.get_album(collection_id)
|
||||
if not album_response:
|
||||
return None
|
||||
return album_response["data"][0]
|
||||
@@ -80,9 +77,7 @@ class AppleMusicMusicVideoInterface:
|
||||
itunes_page_metadata: dict,
|
||||
) -> MediaTags:
|
||||
alt_id = self.get_alt_id(metadata)
|
||||
lookup_metadata = (await self.interface.itunes_api.get_lookup_result(alt_id))[
|
||||
"results"
|
||||
]
|
||||
lookup_metadata = (await self.itunes_api.get_lookup_result(alt_id))["results"]
|
||||
|
||||
explicitness = lookup_metadata[0]["trackExplicitness"]
|
||||
if explicitness == "notExplicit":
|
||||
@@ -96,11 +91,11 @@ class AppleMusicMusicVideoInterface:
|
||||
artist=lookup_metadata[0]["artistName"],
|
||||
artist_id=int(lookup_metadata[0]["artistId"]),
|
||||
copyright=itunes_page_metadata.get("copyright"),
|
||||
date=self.interface.parse_date(lookup_metadata[0]["releaseDate"]),
|
||||
date=self.parse_date(lookup_metadata[0]["releaseDate"]),
|
||||
genre=lookup_metadata[0]["primaryGenreName"],
|
||||
genre_id=int(itunes_page_metadata["genres"][0]["genreId"]),
|
||||
media_type=MediaType.MUSIC_VIDEO,
|
||||
storefront=int(self.interface.itunes_api.storefront_id.split("-")[0]),
|
||||
storefront=int(self.itunes_api.storefront_id.split("-")[0]),
|
||||
title=lookup_metadata[0]["trackCensoredName"],
|
||||
title_id=int(metadata["id"]),
|
||||
rating=rating,
|
||||
@@ -137,14 +132,16 @@ class AppleMusicMusicVideoInterface:
|
||||
itunes_page_metadata,
|
||||
)
|
||||
else:
|
||||
webplayback_response = await self.interface.apple_music_api.get_webplayback(
|
||||
webplayback_response = await self.apple_music_api.get_webplayback(
|
||||
metadata["id"]
|
||||
)
|
||||
m3u8_master_url = self.get_m3u8_master_url_from_webplayback(
|
||||
webplayback_response["songList"][0],
|
||||
)
|
||||
|
||||
playlist_master_m3u8_obj = m3u8.loads(await get_response_text(m3u8_master_url))
|
||||
playlist_master_m3u8_obj = m3u8.loads(
|
||||
(await 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,
|
||||
@@ -180,31 +177,37 @@ class AppleMusicMusicVideoInterface:
|
||||
def get_video_playlist_from_resolution(
|
||||
self,
|
||||
video_playlists: list[m3u8.Playlist],
|
||||
codec: MusicVideoCodec,
|
||||
codec_priority: list[MusicVideoCodec],
|
||||
resolution: MusicVideoResolution,
|
||||
) -> m3u8.Playlist | None:
|
||||
playlists_filtered = [
|
||||
playlist
|
||||
for playlist in video_playlists
|
||||
if playlist.stream_info.codecs.startswith(codec.fourcc())
|
||||
]
|
||||
if not playlists_filtered:
|
||||
playlist_results = []
|
||||
for codec_index, codec in enumerate(codec_priority):
|
||||
for playlist in video_playlists:
|
||||
if playlist.stream_info.codecs.startswith(codec.fourcc()):
|
||||
playlist_results.append((codec_index, playlist))
|
||||
|
||||
if not playlist_results:
|
||||
return None
|
||||
|
||||
def sort_key(playlist: m3u8.Playlist) -> tuple[int, int, int, int]:
|
||||
def sort_key(
|
||||
item: tuple[int, m3u8.Playlist],
|
||||
) -> tuple[bool, int, int, int, int]:
|
||||
codec_index, playlist = item
|
||||
playlist_resolution = playlist.stream_info.resolution[-1]
|
||||
resolution_difference = abs(playlist_resolution - int(resolution))
|
||||
bandwidth = playlist.stream_info.bandwidth
|
||||
exceeds_resolution = playlist_resolution > int(resolution)
|
||||
resolution_difference = abs(playlist_resolution - int(resolution))
|
||||
|
||||
return (
|
||||
exceeds_resolution,
|
||||
resolution_difference,
|
||||
codec_index,
|
||||
-playlist_resolution,
|
||||
-bandwidth,
|
||||
)
|
||||
|
||||
playlists_filtered.sort(key=sort_key)
|
||||
|
||||
return playlists_filtered[0]
|
||||
playlist_results.sort(key=sort_key)
|
||||
return playlist_results[0][1]
|
||||
|
||||
def get_best_stereo_audio_playlist(
|
||||
self,
|
||||
@@ -263,16 +266,34 @@ class AppleMusicMusicVideoInterface:
|
||||
|
||||
return selected
|
||||
|
||||
def get_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
def _get_key_by_format(
|
||||
self,
|
||||
m3u8_obj: m3u8.M3U8,
|
||||
key_format: str,
|
||||
) -> str:
|
||||
return next(
|
||||
(
|
||||
key
|
||||
for key in m3u8_obj.keys
|
||||
if key.keyformat == "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
|
||||
),
|
||||
(key for key in m3u8_obj.keys if key.keyformat == key_format),
|
||||
None,
|
||||
).uri
|
||||
|
||||
def get_widevine_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
return self._get_key_by_format(
|
||||
m3u8_obj,
|
||||
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
|
||||
)
|
||||
|
||||
def get_playready_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
return self._get_key_by_format(
|
||||
m3u8_obj,
|
||||
"com.microsoft.playready",
|
||||
)
|
||||
|
||||
def get_fairplay_key(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
return self._get_key_by_format(
|
||||
m3u8_obj,
|
||||
"com.apple.streamingkeydelivery",
|
||||
)
|
||||
|
||||
async def get_stream_info_video(
|
||||
self,
|
||||
playlist_master_m3u8_obj: m3u8.M3U8,
|
||||
@@ -282,14 +303,11 @@ class AppleMusicMusicVideoInterface:
|
||||
stream_info = StreamInfo()
|
||||
|
||||
if MusicVideoCodec.ASK not in codec_priority:
|
||||
for codec in codec_priority:
|
||||
playlist = self.get_video_playlist_from_resolution(
|
||||
playlist_master_m3u8_obj.playlists,
|
||||
codec,
|
||||
resolution,
|
||||
)
|
||||
if playlist:
|
||||
break
|
||||
playlist = self.get_video_playlist_from_resolution(
|
||||
playlist_master_m3u8_obj.playlists,
|
||||
codec_priority,
|
||||
resolution,
|
||||
)
|
||||
else:
|
||||
playlist = await self.get_video_playlist_from_user(
|
||||
playlist_master_m3u8_obj.playlists
|
||||
@@ -300,9 +318,14 @@ class AppleMusicMusicVideoInterface:
|
||||
|
||||
stream_info.stream_url = playlist.uri
|
||||
stream_info.codec = playlist.stream_info.codecs
|
||||
stream_info.width, stream_info.height = playlist.stream_info.resolution
|
||||
|
||||
playlist_m3u8_obj = m3u8.loads(await get_response_text(stream_info.stream_url))
|
||||
stream_info.widevine_pssh = self.get_pssh(playlist_m3u8_obj)
|
||||
playlist_m3u8_obj = m3u8.loads(
|
||||
(await get_response(stream_info.stream_url)).text
|
||||
)
|
||||
stream_info.widevine_pssh = self.get_widevine_pssh(playlist_m3u8_obj)
|
||||
stream_info.fairplay_key = self.get_fairplay_key(playlist_m3u8_obj)
|
||||
stream_info.playready_pssh = self.get_playready_pssh(playlist_m3u8_obj)
|
||||
|
||||
return stream_info
|
||||
|
||||
@@ -324,8 +347,12 @@ class AppleMusicMusicVideoInterface:
|
||||
stream_info.stream_url = playlist["uri"]
|
||||
stream_info.codec = playlist["group_id"]
|
||||
|
||||
playlist_m3u8_obj = m3u8.loads(await get_response_text(stream_info.stream_url))
|
||||
stream_info.widevine_pssh = self.get_pssh(playlist_m3u8_obj)
|
||||
playlist_m3u8_obj = m3u8.loads(
|
||||
(await get_response(stream_info.stream_url)).text
|
||||
)
|
||||
stream_info.widevine_pssh = self.get_widevine_pssh(playlist_m3u8_obj)
|
||||
stream_info.fairplay_key = self.get_fairplay_key(playlist_m3u8_obj)
|
||||
stream_info.playready_pssh = self.get_playready_pssh(playlist_m3u8_obj)
|
||||
|
||||
return stream_info
|
||||
|
||||
@@ -334,12 +361,14 @@ class AppleMusicMusicVideoInterface:
|
||||
stream_info: StreamInfoAv,
|
||||
cdm: Cdm,
|
||||
) -> DecryptionKeyAv:
|
||||
decryption_key_video = await self.interface.get_decryption_key(
|
||||
decryption_key_video = await AppleMusicInterface.get_decryption_key(
|
||||
self,
|
||||
stream_info.video_track.widevine_pssh,
|
||||
stream_info.media_id,
|
||||
cdm,
|
||||
)
|
||||
decryption_key_audio = await self.interface.get_decryption_key(
|
||||
decryption_key_audio = await AppleMusicInterface.get_decryption_key(
|
||||
self,
|
||||
stream_info.audio_track.widevine_pssh,
|
||||
stream_info.media_id,
|
||||
cdm,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
@@ -12,7 +13,7 @@ from InquirerPy.base.control import Choice
|
||||
from pywidevine import PSSH, Cdm
|
||||
from pywidevine.license_protocol_pb2 import WidevinePsshData
|
||||
|
||||
from ..utils import get_response_text
|
||||
from ..utils import get_response
|
||||
from .constants import DRM_DEFAULT_KEY_MAPPING, MP4_FORMAT_CODECS, SONG_CODEC_REGEX_MAP
|
||||
from .enums import MediaRating, MediaType, SongCodec, SyncedLyricsFormat
|
||||
from .interface import AppleMusicInterface
|
||||
@@ -29,12 +30,9 @@ from .types import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppleMusicSongInterface:
|
||||
def __init__(
|
||||
self,
|
||||
interface: AppleMusicInterface,
|
||||
) -> None:
|
||||
self.interface = interface
|
||||
class AppleMusicSongInterface(AppleMusicInterface):
|
||||
def __init__(self, interface: AppleMusicInterface):
|
||||
self.__dict__.update(interface.__dict__)
|
||||
|
||||
async def get_lyrics(
|
||||
self,
|
||||
@@ -49,8 +47,8 @@ class AppleMusicSongInterface:
|
||||
or "lyrics" not in song_metadata["relationships"]
|
||||
):
|
||||
song_metadata = (
|
||||
await self.interface.apple_music_api.get_song(
|
||||
self.interface.get_media_id_of_library_media(song_metadata)
|
||||
await self.apple_music_api.get_song(
|
||||
self.get_media_id_of_library_media(song_metadata)
|
||||
)
|
||||
)["data"][0]
|
||||
|
||||
@@ -109,9 +107,11 @@ class AppleMusicSongInterface:
|
||||
index += 1
|
||||
|
||||
return Lyrics(
|
||||
synced="\n".join(synced_lyrics + ["\n"]),
|
||||
unsynced="\n\n".join(
|
||||
["\n".join(lyric_group) for lyric_group in unsynced_lyrics]
|
||||
synced="\n".join(synced_lyrics + ["\n"]) if synced_lyrics else None,
|
||||
unsynced=(
|
||||
"\n\n".join(["\n".join(lyric_group) for lyric_group in unsynced_lyrics])
|
||||
if unsynced_lyrics
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
@@ -194,7 +194,7 @@ class AppleMusicSongInterface:
|
||||
composer_sort=webplayback_metadata.get("sort-composer"),
|
||||
copyright=webplayback_metadata.get("copyright"),
|
||||
date=(
|
||||
self.interface.parse_date(webplayback_metadata["releaseDate"])
|
||||
self.parse_date(webplayback_metadata["releaseDate"])
|
||||
if webplayback_metadata.get("releaseDate")
|
||||
else None
|
||||
),
|
||||
@@ -229,7 +229,7 @@ class AppleMusicSongInterface:
|
||||
if not m3u8_master_url:
|
||||
return None
|
||||
|
||||
m3u8_master_obj = m3u8.loads(await get_response_text(m3u8_master_url))
|
||||
m3u8_master_obj = m3u8.loads((await get_response(m3u8_master_url)).text)
|
||||
m3u8_master_data = m3u8_master_obj.data
|
||||
|
||||
if codec == SongCodec.ASK:
|
||||
@@ -273,7 +273,7 @@ class AppleMusicSongInterface:
|
||||
"com.apple.streamingkeydelivery",
|
||||
)
|
||||
else:
|
||||
m3u8_obj = m3u8.loads(await get_response_text(stream_info.stream_url))
|
||||
m3u8_obj = m3u8.loads((await get_response(stream_info.stream_url)).text)
|
||||
|
||||
stream_info.widevine_pssh = self._get_drm_uri_from_m3u8_keys(
|
||||
m3u8_obj,
|
||||
@@ -384,7 +384,7 @@ class AppleMusicSongInterface:
|
||||
i for i in webplayback["songList"][0]["assets"] if i["flavor"] == flavor
|
||||
)["URL"]
|
||||
|
||||
m3u8_obj = m3u8.loads(await get_response_text(stream_info.stream_url))
|
||||
m3u8_obj = m3u8.loads((await get_response(stream_info.stream_url)).text)
|
||||
stream_info.widevine_pssh = m3u8_obj.keys[0].uri
|
||||
|
||||
stream_info_av = StreamInfoAv(
|
||||
@@ -414,17 +414,19 @@ class AppleMusicSongInterface:
|
||||
pssh_obj = PSSH(widevine_pssh_data.SerializeToString())
|
||||
|
||||
challenge = base64.b64encode(
|
||||
cdm.get_license_challenge(cdm_session, pssh_obj)
|
||||
).decode()
|
||||
license_response = (
|
||||
await self.interface.apple_music_api.get_license_exchange(
|
||||
stream_info.media_id,
|
||||
stream_info.audio_track.widevine_pssh,
|
||||
challenge,
|
||||
await asyncio.to_thread(
|
||||
cdm.get_license_challenge, cdm_session, pssh_obj
|
||||
)
|
||||
).decode()
|
||||
license_response = await self.apple_music_api.get_license_exchange(
|
||||
stream_info.media_id,
|
||||
stream_info.audio_track.widevine_pssh,
|
||||
challenge,
|
||||
)
|
||||
|
||||
cdm.parse_license(cdm_session, license_response["license"])
|
||||
await asyncio.to_thread(
|
||||
cdm.parse_license, cdm_session, license_response["license"]
|
||||
)
|
||||
|
||||
decryption_key = next(
|
||||
i for i in cdm.get_keys(cdm_session) if i.type == "CONTENT"
|
||||
@@ -448,7 +450,8 @@ class AppleMusicSongInterface:
|
||||
cdm: Cdm,
|
||||
) -> DecryptionKeyAv:
|
||||
return DecryptionKeyAv(
|
||||
audio_track=await self.interface.get_decryption_key(
|
||||
audio_track=await AppleMusicInterface.get_decryption_key(
|
||||
self,
|
||||
stream_info.audio_track.widevine_pssh,
|
||||
stream_info.media_id,
|
||||
cdm,
|
||||
|
||||
@@ -7,14 +7,14 @@ from ..interface.enums import UploadedVideoQuality
|
||||
from ..interface.types import MediaTags
|
||||
from .constants import UPLOADED_VIDEO_QUALITY_RANK
|
||||
from .interface import AppleMusicInterface
|
||||
from .types import StreamInfo, StreamInfoAv, MediaFileFormat
|
||||
from .types import MediaFileFormat, StreamInfo, StreamInfoAv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppleMusicUploadedVideoInterface:
|
||||
class AppleMusicUploadedVideoInterface(AppleMusicInterface):
|
||||
def __init__(self, interface: AppleMusicInterface):
|
||||
self.interface = interface
|
||||
self.__dict__.update(interface.__dict__)
|
||||
|
||||
def get_stream_url_best(self, metadata: dict) -> str:
|
||||
best_quality = next(
|
||||
@@ -76,10 +76,10 @@ class AppleMusicUploadedVideoInterface:
|
||||
|
||||
tags = MediaTags(
|
||||
artist=attributes.get("artistName"),
|
||||
date=self.interface.parse_date(upload_date) if upload_date else None,
|
||||
date=self.parse_date(upload_date) if upload_date else None,
|
||||
title=attributes.get("name"),
|
||||
title_id=int(metadata["id"]),
|
||||
storefront=int(self.interface.itunes_api.storefront_id.split("-")[0]),
|
||||
storefront=int(self.itunes_api.storefront_id.split("-")[0]),
|
||||
)
|
||||
logger.debug(f"Tags: {tags}")
|
||||
|
||||
|
||||
+41
-38
@@ -44,22 +44,18 @@ class MediaTags:
|
||||
|
||||
def as_mp4_tags(self, date_format: str = None) -> dict:
|
||||
disc_mp4 = [
|
||||
[
|
||||
self.disc if self.disc is not None else 0,
|
||||
self.disc_total if self.disc_total is not None else 0,
|
||||
]
|
||||
self.disc if self.disc is not None else 0,
|
||||
self.disc_total if self.disc_total is not None else 0,
|
||||
]
|
||||
if disc_mp4[0][0] == 0 and disc_mp4[0][1] == 0:
|
||||
disc_mp4 = [None]
|
||||
if disc_mp4[0] == 0 and disc_mp4[1] == 0:
|
||||
disc_mp4 = None
|
||||
|
||||
track_mp4 = [
|
||||
[
|
||||
self.track if self.track is not None else 0,
|
||||
self.track_total if self.track_total is not None else 0,
|
||||
]
|
||||
self.track if self.track is not None else 0,
|
||||
self.track_total if self.track_total is not None else 0,
|
||||
]
|
||||
if track_mp4[0][0] == 0 and track_mp4[0][1] == 0:
|
||||
track_mp4 = [None]
|
||||
if track_mp4[0] == 0 and track_mp4[1] == 0:
|
||||
track_mp4 = None
|
||||
|
||||
if isinstance(self.date, datetime.date):
|
||||
if date_format is None:
|
||||
@@ -72,35 +68,40 @@ class MediaTags:
|
||||
date_mp4 = None
|
||||
|
||||
mp4_tags = {
|
||||
"\xa9alb": [self.album],
|
||||
"aART": [self.album_artist],
|
||||
"plID": [self.album_id],
|
||||
"soal": [self.album_sort],
|
||||
"\xa9ART": [self.artist],
|
||||
"atID": [self.artist_id],
|
||||
"soar": [self.artist_sort],
|
||||
"\xa9cmt": [self.comment],
|
||||
"cpil": [bool(self.compilation) if self.compilation is not None else None],
|
||||
"\xa9wrt": [self.composer],
|
||||
"cmID": [self.composer_id],
|
||||
"soco": [self.composer_sort],
|
||||
"cprt": [self.copyright],
|
||||
"\xa9day": [date_mp4],
|
||||
"\xa9alb": self.album,
|
||||
"aART": self.album_artist,
|
||||
"plID": self.album_id,
|
||||
"soal": self.album_sort,
|
||||
"\xa9ART": self.artist,
|
||||
"atID": self.artist_id,
|
||||
"soar": self.artist_sort,
|
||||
"\xa9cmt": self.comment,
|
||||
"cpil": bool(self.compilation) if self.compilation is not None else None,
|
||||
"\xa9wrt": self.composer,
|
||||
"cmID": self.composer_id,
|
||||
"soco": self.composer_sort,
|
||||
"cprt": self.copyright,
|
||||
"\xa9day": date_mp4,
|
||||
"disk": disc_mp4,
|
||||
"pgap": [bool(self.gapless) if self.gapless is not None else None],
|
||||
"\xa9gen": [self.genre],
|
||||
"\xa9lyr": [self.lyrics],
|
||||
"geID": [self.genre_id],
|
||||
"stik": [int(self.media_type) if self.media_type is not None else None],
|
||||
"rtng": [int(self.rating) if self.rating is not None else None],
|
||||
"sfID": [self.storefront],
|
||||
"\xa9nam": [self.title],
|
||||
"cnID": [self.title_id],
|
||||
"sonm": [self.title_sort],
|
||||
"pgap": bool(self.gapless) if self.gapless is not None else None,
|
||||
"\xa9gen": self.genre,
|
||||
"\xa9lyr": self.lyrics,
|
||||
"geID": self.genre_id,
|
||||
"stik": int(self.media_type) if self.media_type is not None else None,
|
||||
"rtng": int(self.rating) if self.rating is not None else None,
|
||||
"sfID": self.storefront,
|
||||
"\xa9nam": self.title,
|
||||
"cnID": self.title_id,
|
||||
"sonm": self.title_sort,
|
||||
"trkn": track_mp4,
|
||||
"xid ": [self.xid],
|
||||
"xid ": self.xid,
|
||||
}
|
||||
|
||||
return {
|
||||
k: ([v] if not isinstance(v, bool) else v)
|
||||
for k, v in mp4_tags.items()
|
||||
if v is not None
|
||||
}
|
||||
return {k: v for k, v in mp4_tags.items() if v[0] is not None}
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -118,6 +119,8 @@ class StreamInfo:
|
||||
playready_pssh: str = None
|
||||
fairplay_key: str = None
|
||||
codec: str = None
|
||||
width: int = None
|
||||
height: int = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
+25
-15
@@ -20,11 +20,14 @@ def safe_json(httpx_response: httpx.Response) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
async def get_response_text(url: str) -> str:
|
||||
async with httpx.AsyncClient() as client:
|
||||
async def get_response(
|
||||
url: str,
|
||||
valid_responses: set[int] = {200},
|
||||
) -> httpx.Response:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.get(url)
|
||||
raise_for_status(response)
|
||||
return response.text
|
||||
raise_for_status(response, valid_responses)
|
||||
return response
|
||||
|
||||
|
||||
async def async_subprocess(*args: str, silent: bool = False) -> None:
|
||||
@@ -48,24 +51,31 @@ async def async_subprocess(*args: str, silent: bool = False) -> None:
|
||||
|
||||
async def safe_gather(
|
||||
*tasks: typing.Awaitable[typing.Any],
|
||||
limit: int = 5,
|
||||
retries: int = 3,
|
||||
limit: int = 10,
|
||||
) -> list[typing.Any]:
|
||||
semaphore = asyncio.Semaphore(limit)
|
||||
|
||||
async def bounded_task(task: typing.Awaitable[typing.Any]) -> typing.Any:
|
||||
async with semaphore:
|
||||
last_exception = None
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
return await task
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
if attempt < retries:
|
||||
await asyncio.sleep(2**attempt)
|
||||
return last_exception
|
||||
return await task
|
||||
|
||||
return await asyncio.gather(
|
||||
*(bounded_task(task) for task in tasks),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
|
||||
async def sequential_gather(
|
||||
*tasks: typing.Awaitable[typing.Any],
|
||||
interval: float = 0.5,
|
||||
) -> list[typing.Any]:
|
||||
results = []
|
||||
for i, task in enumerate(tasks):
|
||||
try:
|
||||
result = await task
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
results.append(e)
|
||||
if interval > 0 and i < len(tasks) - 1:
|
||||
await asyncio.sleep(interval)
|
||||
return results
|
||||
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "gamdl"
|
||||
version = "2.7"
|
||||
version = "2.8.2"
|
||||
description = "A command-line app for downloading Apple Music songs, music videos and post videos."
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
@@ -8,6 +8,7 @@ requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"async-lru>=2.0.5",
|
||||
"click>=8.3.0",
|
||||
"colorama>=0.4.6",
|
||||
"httpx>=0.28.1",
|
||||
"inquirerpy>=0.3.4",
|
||||
"m3u8>=6.0.0",
|
||||
@@ -17,5 +18,8 @@ dependencies = [
|
||||
"yt-dlp>=2025.10.22",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/glomatico/gamdl"
|
||||
|
||||
[project.scripts]
|
||||
gamdl = "gamdl.cli.cli:main"
|
||||
|
||||
@@ -202,11 +202,12 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "gamdl"
|
||||
version = "2.7"
|
||||
version = "2.8.2"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "async-lru" },
|
||||
{ name = "click" },
|
||||
{ name = "colorama" },
|
||||
{ name = "httpx" },
|
||||
{ name = "inquirerpy" },
|
||||
{ name = "m3u8" },
|
||||
@@ -220,6 +221,7 @@ dependencies = [
|
||||
requires-dist = [
|
||||
{ name = "async-lru", specifier = ">=2.0.5" },
|
||||
{ name = "click", specifier = ">=8.3.0" },
|
||||
{ name = "colorama", specifier = ">=0.4.6" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "inquirerpy", specifier = ">=0.3.4" },
|
||||
{ name = "m3u8", specifier = ">=6.0.0" },
|
||||
|
||||
Reference in New Issue
Block a user