mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-13 12:15:18 +03:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||
|
||||
@@ -121,6 +122,7 @@ The file is created automatically on first run. Command-line arguments override
|
||||
| `--no-config-file`, `-n` | Don't use a config file | `false` |
|
||||
| **Apple Music Options** | | |
|
||||
| `--cookies-path`, `-c` | Cookies file path | `./cookies.txt` |
|
||||
| `--wrapper-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` |
|
||||
@@ -134,6 +136,9 @@ The file is created automatically on first run. Command-line arguments override
|
||||
| `--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` |
|
||||
@@ -220,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
|
||||
@@ -249,6 +254,22 @@ 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:
|
||||
@@ -338,4 +359,5 @@ 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.3"
|
||||
__version__ = "2.8"
|
||||
|
||||
@@ -23,10 +23,12 @@ class AppleMusicApi:
|
||||
self,
|
||||
storefront: str = "us",
|
||||
media_user_token: str | None = None,
|
||||
token: str | None = None,
|
||||
language: str = "en-US",
|
||||
) -> None:
|
||||
self.storefront = storefront
|
||||
self.media_user_token = media_user_token
|
||||
self.token = token
|
||||
self.language = language
|
||||
|
||||
@classmethod
|
||||
@@ -60,6 +62,23 @@ class AppleMusicApi:
|
||||
language=language,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_wrapper(
|
||||
cls,
|
||||
wrapper_account_url: str = "http://127.0.0.1:30020/",
|
||||
language: str = "en-US",
|
||||
) -> "AppleMusicApi":
|
||||
wrapper_account_response = httpx.get(wrapper_account_url)
|
||||
raise_for_status(wrapper_account_response)
|
||||
wrapper_account_info = safe_json(wrapper_account_response)
|
||||
|
||||
return cls(
|
||||
storefront=None,
|
||||
media_user_token=wrapper_account_info["music_token"],
|
||||
token=wrapper_account_info["dev_token"],
|
||||
language=language,
|
||||
)
|
||||
|
||||
async def setup(self) -> None:
|
||||
await self._setup_client()
|
||||
await self._setup_token()
|
||||
@@ -85,11 +104,11 @@ class AppleMusicApi:
|
||||
"l": self.language,
|
||||
},
|
||||
follow_redirects=True,
|
||||
transport=httpx.AsyncHTTPTransport(retries=3),
|
||||
timeout=30.0,
|
||||
transport=httpx.AsyncHTTPTransport(retries=10),
|
||||
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,7 +131,11 @@ 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_token(self) -> None:
|
||||
self.token = self.token or await self._get_token()
|
||||
self.client.headers.update({"authorization": f"Bearer {self.token}"})
|
||||
|
||||
async def _setup_account_info(self) -> None:
|
||||
if not self.media_user_token:
|
||||
|
||||
@@ -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",
|
||||
|
||||
+53
-20
@@ -17,9 +17,7 @@ from ..downloader import (
|
||||
CoverFormat,
|
||||
DownloadItem,
|
||||
DownloadMode,
|
||||
GamdlFormatNotAvailableError,
|
||||
GamdlNotStreamableError,
|
||||
GamdlSyncedLyricsOnlyError,
|
||||
GamdlError,
|
||||
RemuxFormatMusicVideo,
|
||||
RemuxMode,
|
||||
)
|
||||
@@ -36,11 +34,12 @@ from ..interface import (
|
||||
)
|
||||
from .config_file import ConfigFile
|
||||
from .constants import X_NOT_IN_PATH
|
||||
from .utils import Csv, CustomLoggerFormatter, PathPrompt
|
||||
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.from_netscape_cookies)
|
||||
api_from_wrapper_sig = inspect.signature(AppleMusicApi.from_wrapper)
|
||||
base_downloader_sig = inspect.signature(AppleMusicBaseDownloader.__init__)
|
||||
music_video_downloader_sig = inspect.signature(AppleMusicMusicVideoDownloader.__init__)
|
||||
song_downloader_sig = inspect.signature(AppleMusicSongDownloader.__init__)
|
||||
@@ -126,15 +125,21 @@ def make_sync(func):
|
||||
@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",
|
||||
type=str,
|
||||
default=api_sig.parameters["language"].default,
|
||||
default=api_from_cookies_sig.parameters["language"].default,
|
||||
help="Metadata language",
|
||||
)
|
||||
# Base Downloader specific options
|
||||
@@ -200,6 +205,24 @@ def make_sync(func):
|
||||
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,
|
||||
@@ -352,6 +375,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,
|
||||
@@ -363,6 +387,9 @@ 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,
|
||||
@@ -403,10 +430,17 @@ async def main(
|
||||
|
||||
logger.info(f"Starting Gamdl {__version__}")
|
||||
|
||||
apple_music_api = AppleMusicApi.from_netscape_cookies(
|
||||
cookies_path=cookies_path,
|
||||
language=language,
|
||||
)
|
||||
if use_wrapper:
|
||||
apple_music_api = AppleMusicApi.from_wrapper(
|
||||
wrapper_account_url=wrapper_account_url,
|
||||
language=language,
|
||||
)
|
||||
else:
|
||||
cookies_path = prompt_path(cookies_path)
|
||||
apple_music_api = AppleMusicApi.from_netscape_cookies(
|
||||
cookies_path=cookies_path,
|
||||
language=language,
|
||||
)
|
||||
await apple_music_api.setup()
|
||||
|
||||
itunes_api = ItunesApi(
|
||||
@@ -446,6 +480,9 @@ 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,
|
||||
@@ -530,9 +567,10 @@ async def main(
|
||||
)
|
||||
downloader.skip_music_videos = True
|
||||
|
||||
if not song_codec.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."
|
||||
)
|
||||
|
||||
@@ -601,12 +639,7 @@ async def main(
|
||||
|
||||
try:
|
||||
await downloader.download(download_item)
|
||||
except (
|
||||
FileExistsError,
|
||||
GamdlNotStreamableError,
|
||||
GamdlFormatNotAvailableError,
|
||||
GamdlSyncedLyricsOnlyError,
|
||||
) as e:
|
||||
except GamdlError as e:
|
||||
logger.warning(
|
||||
download_queue_progress + f' Skipping "{media_title}": {e}'
|
||||
)
|
||||
|
||||
+31
-40
@@ -38,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,
|
||||
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 = {
|
||||
@@ -103,3 +63,34 @@ class CustomLoggerFormatter(logging.Formatter):
|
||||
+ " %(message)s",
|
||||
datefmt=self.date_format,
|
||||
).format(record)
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
path_type = "directory" if is_dir else "file"
|
||||
|
||||
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('"')
|
||||
|
||||
return result_path
|
||||
|
||||
@@ -22,10 +22,11 @@ from .downloader_song import AppleMusicSongDownloader
|
||||
from .downloader_uploaded_video import AppleMusicUploadedVideoDownloader
|
||||
from .enums import DownloadMode, RemuxMode
|
||||
from .exceptions import (
|
||||
GamdlExecutableNotFoundError,
|
||||
GamdlFormatNotAvailableError,
|
||||
GamdlNotStreamableError,
|
||||
GamdlSyncedLyricsOnlyError,
|
||||
ExecutableNotFound,
|
||||
FormatNotAvailable,
|
||||
NotStreamable,
|
||||
SyncedLyricsOnly,
|
||||
MediaFileExists,
|
||||
)
|
||||
from .types import DownloadItem, UrlInfo
|
||||
|
||||
@@ -103,14 +104,11 @@ class AppleMusicDownloader:
|
||||
self,
|
||||
collection_metadata: dict,
|
||||
) -> list[DownloadItem]:
|
||||
collection_metadata["relationships"]["tracks"]["data"].extend(
|
||||
[
|
||||
extended_data
|
||||
async for extended_data in self.interface.apple_music_api.extend_api_data(
|
||||
collection_metadata["relationships"]["tracks"],
|
||||
)
|
||||
]
|
||||
)
|
||||
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(
|
||||
@@ -123,7 +121,7 @@ class AppleMusicDownloader:
|
||||
),
|
||||
)
|
||||
)
|
||||
for media_metadata in collection_metadata["relationships"]["tracks"]["data"]
|
||||
for media_metadata in tracks_metadata
|
||||
]
|
||||
|
||||
download_items = await safe_gather(*tasks)
|
||||
@@ -187,6 +185,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)",
|
||||
@@ -235,6 +234,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)",
|
||||
@@ -368,6 +368,9 @@ class AppleMusicDownloader:
|
||||
download_item: DownloadItem,
|
||||
) -> DownloadItem:
|
||||
try:
|
||||
if download_item.error:
|
||||
raise download_item.error
|
||||
|
||||
if download_item.flat_filter_result:
|
||||
download_item = await self.get_single_download_item_no_filter(
|
||||
download_item.media_metadata,
|
||||
@@ -387,14 +390,11 @@ class AppleMusicDownloader:
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
if download_item.error:
|
||||
raise download_item.error
|
||||
|
||||
if (
|
||||
self.song_downloader.synced_lyrics_only
|
||||
and download_item.media_metadata["type"] not in SONG_MEDIA_TYPE
|
||||
):
|
||||
raise GamdlSyncedLyricsOnlyError()
|
||||
raise SyncedLyricsOnly()
|
||||
|
||||
if self.song_downloader.synced_lyrics_only:
|
||||
return
|
||||
@@ -402,15 +402,13 @@ class AppleMusicDownloader:
|
||||
if not self.base_downloader.is_media_streamable(
|
||||
download_item.media_metadata,
|
||||
):
|
||||
raise GamdlNotStreamableError()
|
||||
raise NotStreamable(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 download_item.media_metadata["type"] in {
|
||||
*SONG_MEDIA_TYPE,
|
||||
@@ -420,31 +418,41 @@ class AppleMusicDownloader:
|
||||
self.base_downloader.remux_mode == RemuxMode.FFMPEG
|
||||
and not self.base_downloader.full_ffmpeg_path
|
||||
):
|
||||
raise GamdlExecutableNotFoundError("ffmpeg")
|
||||
raise ExecutableNotFound("ffmpeg")
|
||||
|
||||
if (
|
||||
self.base_downloader.remux_mode == RemuxMode.MP4BOX
|
||||
and not self.base_downloader.full_mp4box_path
|
||||
):
|
||||
raise GamdlExecutableNotFoundError("MP4Box")
|
||||
raise ExecutableNotFound("MP4Box")
|
||||
|
||||
if (
|
||||
download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE
|
||||
or self.base_downloader.remux_mode == RemuxMode.MP4BOX
|
||||
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 GamdlExecutableNotFoundError("mp4decrypt")
|
||||
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 GamdlExecutableNotFoundError("N_m3u8DL-RE")
|
||||
raise ExecutableNotFound("N_m3u8DL-RE")
|
||||
|
||||
if (
|
||||
not download_item.stream_info
|
||||
or not download_item.stream_info.audio_track.widevine_pssh
|
||||
):
|
||||
raise GamdlFormatNotAvailableError()
|
||||
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)
|
||||
|
||||
@@ -37,6 +37,9 @@ 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,
|
||||
@@ -63,6 +66,9 @@ 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
|
||||
@@ -88,6 +94,7 @@ class AppleMusicBaseDownloader:
|
||||
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):
|
||||
if self.wvd_path:
|
||||
|
||||
@@ -105,9 +105,9 @@ class AppleMusicSongDownloader(AppleMusicBaseDownloader):
|
||||
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
|
||||
and self.codec != SongCodec.ALAC
|
||||
):
|
||||
download_item.decryption_key = await self.interface.get_decryption_key(
|
||||
download_item.stream_info,
|
||||
@@ -119,12 +119,16 @@ class AppleMusicSongDownloader(AppleMusicBaseDownloader):
|
||||
download_item.cover_url_template = self.get_cover_url_template(song_metadata)
|
||||
|
||||
download_item.random_uuid = self.get_random_uuid()
|
||||
download_item.staged_path = self.get_temp_path(
|
||||
song_id,
|
||||
download_item.random_uuid,
|
||||
"staged",
|
||||
"." + download_item.stream_info.file_format.value,
|
||||
)
|
||||
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,
|
||||
)
|
||||
@@ -223,6 +227,23 @@ class AppleMusicSongDownloader(AppleMusicBaseDownloader):
|
||||
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(
|
||||
self,
|
||||
encrypted_path: str,
|
||||
@@ -230,6 +251,8 @@ class AppleMusicSongDownloader(AppleMusicBaseDownloader):
|
||||
staged_path: str,
|
||||
decryption_key: DecryptionKeyAv,
|
||||
codec: SongCodec,
|
||||
media_id: str,
|
||||
fairplay_key: str,
|
||||
):
|
||||
if codec.is_legacy() and self.remux_mode == RemuxMode.FFMPEG:
|
||||
await self.remux_ffmpeg(
|
||||
@@ -237,7 +260,7 @@ class AppleMusicSongDownloader(AppleMusicBaseDownloader):
|
||||
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,
|
||||
@@ -254,6 +277,13 @@ class AppleMusicSongDownloader(AppleMusicBaseDownloader):
|
||||
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))
|
||||
@@ -303,6 +333,8 @@ class AppleMusicSongDownloader(AppleMusicBaseDownloader):
|
||||
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.apply_tags(
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
class GamdlNotStreamableError(Exception):
|
||||
def __init__(self):
|
||||
super().__init__("Media is not streamable")
|
||||
class GamdlError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class GamdlFormatNotAvailableError(Exception):
|
||||
def __init__(self):
|
||||
super().__init__("Media is not available in the requested format")
|
||||
class MediaFileExists(GamdlError):
|
||||
def __init__(self, media_path: str):
|
||||
super().__init__(f"Media file already exists at path: {media_path}")
|
||||
|
||||
|
||||
class GamdlExecutableNotFoundError(Exception):
|
||||
class NotStreamable(GamdlError):
|
||||
def __init__(self, media_id: str):
|
||||
super().__init__(f"Media ID is not streamable: {media_id}")
|
||||
|
||||
|
||||
class FormatNotAvailable(GamdlError):
|
||||
def __init__(self, media_id: str):
|
||||
super().__init__(f"Requested format is not available for media ID: {media_id}")
|
||||
|
||||
|
||||
class ExecutableNotFound(GamdlError):
|
||||
def __init__(self, executable: str):
|
||||
super().__init__(f"{executable} was not found in system PATH")
|
||||
super().__init__(f"Executable not found: {executable}")
|
||||
|
||||
|
||||
class GamdlSyncedLyricsOnlyError(Exception):
|
||||
class SyncedLyricsOnly(GamdlError):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
"Cannot download media because downloader is configured to download "
|
||||
"synced lyrics only"
|
||||
)
|
||||
super().__init__("Only downloading synced lyrics is supported")
|
||||
|
||||
@@ -264,16 +264,34 @@ class AppleMusicMusicVideoInterface(AppleMusicInterface):
|
||||
|
||||
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,
|
||||
@@ -301,7 +319,9 @@ class AppleMusicMusicVideoInterface(AppleMusicInterface):
|
||||
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)
|
||||
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,7 +344,9 @@ class AppleMusicMusicVideoInterface(AppleMusicInterface):
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
@@ -107,9 +107,11 @@ class AppleMusicSongInterface(AppleMusicInterface):
|
||||
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
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ async def async_subprocess(*args: str, silent: bool = False) -> None:
|
||||
async def safe_gather(
|
||||
*tasks: typing.Awaitable[typing.Any],
|
||||
limit: int = 3,
|
||||
retries: int = 3,
|
||||
retries: int = 10,
|
||||
) -> list[typing.Any]:
|
||||
semaphore = asyncio.Semaphore(limit)
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "gamdl"
|
||||
version = "2.7.3"
|
||||
version = "2.8"
|
||||
description = "A command-line app for downloading Apple Music songs, music videos and post videos."
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
|
||||
Reference in New Issue
Block a user