Compare commits

...

36 Commits

Author SHA1 Message Date
Rafael Moraes 37c857b503 Bump version to 2.8 2025-11-28 19:18:32 -03:00
Rafael Moraes 4693ba69c9 Merge branch 'wrapper' 2025-11-28 19:16:44 -03:00
Rafael Moraes 9212319d3b Remove unused STOREFRONT_IDS import 2025-11-28 18:49:23 -03:00
Rafael Moraes e54f318c36 Add wrapper & amdecrypt instructions to README 2025-11-28 11:23:39 -03:00
Rafael Moraes b1e40299ca Refactor AppleMusicApi token setup logic 2025-11-28 00:15:17 -03:00
Rafael Moraes ba86825068 Merge pull request #252 from fredystar200/patch-1
Add constant for 'CM' in constants.py (Cameroon)
2025-11-27 21:09:11 -03:00
Rafael Moraes b5f08753b8 Rename use_wrapper_decrypt to use_wrapper 2025-11-27 18:16:32 -03:00
Rafael Moraes d4bf75c0d1 Rename enable_wrapper_decrypt to use_wrapper_decrypt 2025-11-27 16:09:11 -03:00
Rafael Moraes e998ce1a2e Add support for FairPlay and PlayReady PSSH extraction 2025-11-27 15:17:13 -03:00
Rafael Moraes 5285ca0cfa Update warning for experimental song codec usage 2025-11-27 15:03:57 -03:00
Rafael Moraes f3927b8e6d Add wrapper decryption options to CLI 2025-11-27 15:02:53 -03:00
Rafael Moraes 40b7ce05d3 Fix decryption key check in AppleMusicDownloader 2025-11-27 15:02:47 -03:00
Rafael Moraes 8cd01e7964 Refactor wrapper_decrypt_ip handling in downloaders 2025-11-27 15:02:40 -03:00
Rafael Moraes f769c6b686 Refactor wrapper decrypt flag handling in downloaders 2025-11-27 14:46:56 -03:00
Rafael Moraes ea7356e7c4 Add amdecrypt support for wrapper-based decryption 2025-11-27 14:44:29 -03:00
Rafael Moraes f3d8242110 Add from_wrapper constructor to AppleMusicApi 2025-11-27 14:34:35 -03:00
Rafael Moraes faf3bb3a20 Add optional token parameter to AppleMusicApi 2025-11-27 12:59:59 -03:00
Rafael Moraes 24c3ce8a02 Handle missing stream info in staged path assignment 2025-11-27 11:18:28 -03:00
Rafael Moraes 65eb8c0fb6 Simplify decryption key validation logic 2025-11-27 11:14:04 -03:00
Rafael Moraes f90be057d6 Add decryption key checks to AppleMusicDownloader 2025-11-27 11:10:57 -03:00
Rafael Moraes 76cc80cba8 Refactor error handling to use GamdlError 2025-11-27 00:55:32 -03:00
Rafael Moraes 7a7c1adb22 Check for widevine_pssh in audio track before download 2025-11-27 00:54:40 -03:00
Rafael Moraes 200e392fad Refactor exception classes and usage in downloader 2025-11-27 00:52:02 -03:00
Rafael Moraes 1083957303 Raise error if present in download_item 2025-11-21 20:27:59 -03:00
fredystar200 ae6bed11af Add constant for 'CM' in constants.py (Cameroon) 2025-11-19 10:08:07 +01:00
Rafael Moraes 7da83866cf Update contributing guidelines in README 2025-11-18 15:18:44 -03:00
Rafael Moraes 273b171398 Bump version to 2.7.5 2025-11-12 12:01:27 -03:00
Rafael Moraes 2913d96b70 Filter out items without attributes in selection lists 2025-11-12 11:56:26 -03:00
Rafael Moraes a332516056 Increase retry limit in safe_gather to 10 2025-11-12 11:51:54 -03:00
Rafael Moraes c636e4be33 Mark ALAC codec as unsupported in README 2025-11-11 22:06:09 -03:00
Rafael Moraes 1841a988e2 Handle empty lyrics in AppleMusicSongInterface 2025-11-11 22:04:55 -03:00
Rafael Moraes 8cdaa127d7 Bump version to 2.7.4 2025-11-11 22:02:39 -03:00
Rafael Moraes c31a6eee8e Increase Apple Music API client timeout to 60s 2025-11-11 22:02:14 -03:00
Rafael Moraes 00d301c23d Refactor track metadata extension logic 2025-11-11 22:02:02 -03:00
Rafael Moraes f05aa579d3 Increase HTTP transport retries to 10 2025-11-11 02:14:35 -03:00
Rafael Moraes 7e642ab2f3 Refactor path prompt logic in CLI utilities 2025-11-11 01:53:59 -03:00
14 changed files with 280 additions and 133 deletions
+24 -2
View File
@@ -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
View File
@@ -1 +1 @@
__version__ = "2.7.3"
__version__ = "2.8"
+27 -4
View File
@@ -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:
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+39 -31
View File
@@ -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)
+7
View File
@@ -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:
+41 -9
View File
@@ -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(
+19 -13
View File
@@ -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")
+30 -8
View File
@@ -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
+5 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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" }