Compare commits

...

33 Commits

Author SHA1 Message Date
Rafael Moraes 24de608bc8 bump version 2025-05-12 09:28:28 -03:00
Rafael Moraes d0e2e08748 use prompt_path function for wvd and cookies 2025-05-12 09:11:58 -03:00
Rafael Moraes 2223d36d5e added prompt_path function 2025-05-12 09:11:07 -03:00
Rafael Moraes 3077456ab7 update cover path retrieval to use downloader_song 2025-05-09 14:19:39 -03:00
Rafael Moraes bbd96cbe6b remove redundant cover path lines 2025-05-09 14:16:33 -03:00
Rafael Moraes ca16a208ba rename custom_formatter to custom_logger_formatter 2025-05-09 14:07:31 -03:00
Rafael Moraes c32c8622b7 add error handling for missing media-user-token in cookies 2025-05-09 14:04:24 -03:00
Rafael Moraes 132ae0ea56 improve storefront retrieval 2025-05-05 23:04:09 -03:00
Rafael Moraes 70238facac better handling for media that has no cover 2025-02-25 02:35:39 -03:00
Rafael Moraes 4fb1fb609b Update custom_formatter.py 2025-02-23 16:28:38 -03:00
Rafael Moraes f97b3dba14 bump version 2025-02-23 04:36:05 -03:00
Rafael Moraes 2da824ecbc add colorama to dependencies 2025-02-23 04:34:28 -03:00
Rafael Moraes 24810da4b6 replace inline response exception handling with utility function 2025-02-23 04:32:50 -03:00
Rafael Moraes f16a30549c refactor logging color handling to use colorama and add utility function for colored text 2025-02-23 04:30:11 -03:00
Rafael Moraes 2001b19d8f bump version 2025-02-23 03:50:23 -03:00
Rafael Moraes 14814dd2da small refactor on cookies user input 2025-02-23 03:16:03 -03:00
Rafael Moraes 6fad41467f add termcolor to dependencies 2025-02-23 03:14:31 -03:00
Rafael Moraes 0868f1c28c set timezone to utc on parse_datetime_obj_from_timestamp_ttml 2025-02-23 03:12:29 -03:00
Rafael Moraes a964011507 implement custom logging formatter with colored output 2025-02-23 03:11:23 -03:00
Rafael Moraes 3a943d0154 refactor media user token retrieval from cookies 2025-02-23 02:13:09 -03:00
Rafael Moraes 84bf0a3c2b prompt user for cookies file path if not found 2025-02-23 02:12:15 -03:00
Rafael Moraes 93dda6889c update Python version requirement to 3.9 2025-02-16 10:43:05 -03:00
Rafael Moraes d62a1377f8 Update README.md 2025-01-29 16:35:36 -03:00
Rafael Moraes 3a2d521352 bump version to 2.3.9 2025-01-29 16:31:23 -03:00
Rafael Moraes c8f45110bd replace deprecated logger.warn with logger.warning 2025-01-29 16:30:08 -03:00
Rafael Moraes 36925025b7 fix: improve formatting in README.md for download modes section 2025-01-29 16:28:50 -03:00
Rafael Moraes d8937d9805 bump required Python version to 3.9 in pyproject.toml 2025-01-29 16:19:25 -03:00
Rafael Moraes 513db83645 update project description for clarity and completeness 2025-01-29 16:17:53 -03:00
Rafael Moraes 11f9b5a75c Update README.md for improved clarity and formatting 2025-01-29 16:15:43 -03:00
Rafael Moraes 1dd01368c3 Update downloader_song.py 2025-01-27 22:47:39 -03:00
Rafael Moraes 4fc8887101 refactor DRM handling to use widevine_pssh and add support for playready and fairplay keys 2025-01-27 22:47:24 -03:00
Rafael Moraes 9169665579 Merge pull request #159 from shafreeck/main
Fix handling of 'Favorite Songs' playlist
2025-01-27 22:39:56 -03:00
Shafreeck d053db96e8 handle missing curatorName in 'Favorite Songs' playlist
'Favorite Songs' is an automatically generated playlist by
Apple Music that lacks the curatorName attribute. Set the
default curator to 'Apple Music' to maintain consistency
with other system playlists.
2025-01-06 07:19:56 +08:00
14 changed files with 278 additions and 170 deletions
+64 -90
View File
@@ -1,37 +1,29 @@
# Glomatico's Apple Music Downloader
A Python CLI app for downloading Apple Music songs/music videos/posts.
# Glomaticos Apple Music Downloader
A Python CLI app for downloading Apple Music songs, music videos and post videos.
**Discord Server:** https://discord.gg/aBjMEZ9tnq
**Join our Discord Server:** https://discord.gg/aBjMEZ9tnq
## Features
* Download songs in AAC 256kbps and other codecs
* Download music videos up to 4K
* Download synced lyrics in LRC, SRT or TTML
* Choose between FFmpeg and MP4Box for remuxing
* Choose between yt-dlp and N_m3u8DL-RE for downloading
* Highly customizable
* Use artist links to download all of their albums or music videos
* **High-Quality Songs**: Download songs in AAC 256kbps and other codecs.
* **High-Quality Music Videos**: Download music videos in resolutions up to 4K.
* **Synced Lyrics**: Download synced lyrics in LRC, SRT, or TTML formats.
* **Artist Support**: Download all albums or music videos from an artist using their link.
* **Highly Customizable**: Extensive configuration options for advanced users.
## Prerequisites
* Python 3.8 or higher
* The cookies file of your Apple Music browser session in Netscape format (requires an active subscription)
* To export your cookies, use one of the following browser extensions while signed in to Apple Music:
* Firefox: https://addons.mozilla.org/addon/export-cookies-txt
* Chromium based browsers: https://chrome.google.com/webstore/detail/gdocmgbfkjnnpapoeobnolbbkoibbcif
* FFmpeg on your system PATH
* Older versions of FFmpeg may not work.
* Up to date binaries can be obtained from the links below:
* Windows: https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases
* Linux: https://johnvansickle.com/ffmpeg/
* **Python 3.9 or higher** installed on your system.
* The **cookies file** of your Apple Music browser session in Netscape format (requires an active subscription).
* **Firefox**: Use the [Export Cookies](https://addons.mozilla.org/addon/export-cookies-txt) extension.
* **Chromium-based Browsers**: Use the [Open Cookies.txt](https://chromewebstore.google.com/detail/open-cookiestxt/gdocmgbfkjnnpapoeobnolbbkoibbcif) extension.
* **FFmpeg** on your system PATH.
* **Windows**: Download from [AnimMouses FFmpeg Builds](https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases).
* **Linux**: Download from [John Van Sickles FFmpeg Builds](https://johnvansickle.com/ffmpeg/).
### Optional dependencies
The following tools are optional but required for specific features. Add them to your systems PATH or specify their paths using command-line arguments or the config file.
* [mp4decrypt](https://www.bento4.com/downloads/)
* Required when setting `mp4box` as remux mode, for downloading music videos and for downloading songs in non-legacy formats.
* [MP4Box](https://gpac.io/downloads/gpac-nightly-builds/)
* Required when setting `mp4box` as remux mode.
* [N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest)
* Required when setting `nm3u8dlre` as download mode.
* [mp4decrypt](https://www.bento4.com/downloads/): Required for `mp4box` remux mode, music video downloads, and experimental song 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.
## Installation
1. Install the package `gamdl` using pip
@@ -39,49 +31,50 @@ The following tools are optional but required for specific features. Add them to
pip install gamdl
```
2. Set up the cookies file.
* You can either move to the current directory from which you will be running Gamdl as `cookies.txt` or specify its path using the command-line arguments/config file.
* Move the cookies file to the directory where youll run Gamdl and rename it to `cookies.txt`.
* Alternatively, specify the path to the cookies file using command-line arguments or the config file.
## Usage
Run Gamdl with the following command:
```bash
gamdl [OPTIONS] URLS...
```
### Supported URL types
Gamdl supports the following types of URLs:
* Song
* Album
* Playlist
* Music video
* Artist
* Post video/extra video
* Post video
### Examples
* Download a song
* Download a Song:
```bash
gamdl "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
```
* Download an album
* Download an Album:
```bash
gamdl "https://music.apple.com/us/album/whenever-you-need-somebody-2022-remaster/1624945511"
```
* Choose which albums or music videos to download from an artist
* Download from an Artist:
```bash
gamdl "https://music.apple.com/us/artist/rick-astley/669771"
```
### Interactive prompt controls
* Arrow keys - Move selection
* Space - Toggle selection
* Ctrl + A - Select all
* Enter - Confirm selection
* **Arrow keys**: Move selection
* **Space**: Toggle selection
* **Ctrl + A**: Select all
* **Enter**: Confirm selection
## Configuration
Gamdl can be configured by using the command line arguments or the config file.
Gamdl can be configured by using the command-line arguments or the config file.
The config file is created automatically when you run Gamdl for the first time at `~/.gamdl/config.json` on Linux and `%USERPROFILE%\.gamdl\config.json` on Windows.
Config file values can be overridden using command line arguments.
| Command line argument / Config file key | Description | Default value |
Config file values can be overridden using command-line arguments.
| Command-line argument / Config file key | Description | Default value |
| --------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------- |
| `--disable-music-video-skip` / `disable_music_video_skip` | Don't skip downloading music videos in albums/playlists. | `false` |
| `--save-cover`, `-s` / `save_cover` | Save cover as a separate file. | `false` |
@@ -160,64 +153,45 @@ The following variables can be used in the template folders/files and/or in the
* `track_total`
* `xid`
### Remux modes
The following remux modes are available:
* `ffmpeg`
* `mp4box`
* Doesn't convert closed captions in music videos that have them
### Remux Modes
* `ffmpeg`: Default remuxing mode.
* `mp4box`: Alternative remuxing mode (doesnt convert closed captions in music videos).
### Download modes
The following download modes are available:
* `ytdlp`
* `nm3u8dlre`
* Faster than `ytdlp`
* `ytdlp`: Default download mode.
* `nm3u8dlre`: Faster than `ytdlp`.
### Song Codecs
* Supported Codecs:
* `aac-legacy`: AAC 256kbps 44.1kHz.
* `aac-he-legacy`: AAC-HE 64kbps 44.1kHz.
* Experimental Codecs (not guaranteed to work due to API limitations):
* `aac`: AAC 256kbps up to 48kHz.
* `aac-he`: AAC-HE 64kbps up to 48kHz.
* `aac-binaural`: AAC 256kbps binaural.
* `aac-downmix`: AAC 256kbps downmix.
* `aac-he-binaural`: AAC-HE 64kbps binaural.
* `aac-he-downmix`: AAC-HE 64kbps downmix.
* `atmos`: Dolby Atmos 768kbps.
* `ac3`: AC3 640kbps.
* `alac`: ALAC up to 24-bit/192 kHz.
* `ask`: Prompt to choose available audio codec.
### Song codecs
The following codecs are available:
* `aac-legacy`
* `aac-he-legacy`
The following codecs are also available, **but are not guaranteed to work**, as currently most (or all) of the songs fails to be downloaded when using them:
* `aac`
* `aac-he`
* `aac-binaural`
* `aac-downmix`
* `aac-he-binaural`
* `aac-he-downmix`
* `atmos`
* `ac3`
* `alac`
* `ask`
* When using this option, Gamdl will ask you which codec from this list to use that is available for the song.
### Music videos codecs
The following codecs are available:
* `h264` (up to 1080p, with AAC 256kbps)
* `h265` (up to 2160p, with AAC 256kpbs)
* `ask`
* When using this option, Gamdl will ask you which audio and video codec to use that is available for the music video.
### Music Videos Codecs
* `h264`: Up to 1080p with AAC 256kbps.
* `h265`: Up to 2160p with AAC 256kpbs.
* `ask`: Prompt to choose available video and audio codecs.
### Post videos/extra videos qualities
The following qualities are available:
* `best` (up to 1080p, with AAC 256kbps)
* `ask`
* When using this option, Gamdl will ask you which video quality to use that is available for the video.
Post videos doesn't require remuxing and are limited to `ytdlp` download mode.
* `best`: Up to 1080p with AAC 256kbps.
* `ask`: Prompt to choose available video quality.
### Synced lyrics formats
The following synced lyrics formats are available:
* `lrc`
* `srt`
* `ttml`
* Native format for Apple Music synced lyrics.
* Highly unsupported by most media players.
* `lrc`: Lightweight and widely supported.
* `srt`: SubRip format (has more accurate timestamps).
* `ttml`: Native Apple Music format (unsupported by most media players).
### Cover formats
The following cover formats are available:
* `jpg`
* `png`
* `raw`
* This format gets the raw cover without any processing.
* Note that when using this format, the cover image will not be embedded within the files. To address this, you can enable `save_cover` option to save the cover as a separate file.
* `jpg`: Default format.
* `png`: Lossless format.
* `raw`: Raw cover without processing (requires `save_cover` to save separately).
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.3.8"
__version__ = "2.4.2"
+32 -13
View File
@@ -9,6 +9,8 @@ from pathlib import Path
import requests
from .utils import raise_response_exception
class AppleMusicApi:
APPLE_MUSIC_HOMEPAGE_URL = "https://beta.music.apple.com"
@@ -36,7 +38,15 @@ class AppleMusicApi:
cookies = MozillaCookieJar(self.cookies_path)
cookies.load(ignore_discard=True, ignore_expires=True)
self.session.cookies.update(cookies)
self.storefront = self.session.cookies.get_dict()["itua"]
media_user_token = self.session.cookies.get_dict().get("media-user-token")
if not media_user_token:
raise ValueError(
"media-user-token not found in cookies. "
"Make sure you're logged in to Apple Music, have an active subscription, and "
"exported the cookies from the Apple Music homepage."
)
else:
media_user_token = ""
self.session.headers.update(
{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0",
@@ -44,9 +54,7 @@ class AppleMusicApi:
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"content-type": "application/json",
"Media-User-Token": self.session.cookies.get_dict().get(
"media-user-token", ""
),
"Media-User-Token": media_user_token,
"x-apple-renewal": "true",
"DNT": "1",
"Connection": "keep-alive",
@@ -67,12 +75,7 @@ class AppleMusicApi:
token = re.search('(?=eyJh)(.*?)(?=")', index_js_page).group(1)
self.session.headers.update({"authorization": f"Bearer {token}"})
self.session.params = {"l": self.language}
@staticmethod
def _raise_response_exception(response: requests.Response):
raise Exception(
f"Request failed with status code {response.status_code}: {response.text}"
)
self._set_storefront()
def _check_amp_api_response(self, response: requests.Response):
try:
@@ -84,7 +87,23 @@ class AppleMusicApi:
requests.exceptions.JSONDecodeError,
AssertionError,
):
self._raise_response_exception(response)
raise_response_exception(response)
def _set_storefront(self):
if self.cookies_path:
self.storefront = (
self.session.cookies.get_dict().get("itua")
or self.get_user_storefront()["id"]
)
else:
self.storefront = self.storefront or "us"
def get_user_storefront(
self,
) -> dict:
response = self.session.get(f"{self.AMP_API_URL}/v1/me/storefront")
self._check_amp_api_response(response)
return response.json()["data"][0]
def get_artist(
self,
@@ -253,7 +272,7 @@ class AppleMusicApi:
requests.exceptions.JSONDecodeError,
AssertionError,
):
self._raise_response_exception(response)
raise_response_exception(response)
return webplayback[0]
def get_widevine_license(
@@ -283,5 +302,5 @@ class AppleMusicApi:
requests.exceptions.JSONDecodeError,
AssertionError,
):
self._raise_response_exception(response)
raise_response_exception(response)
return widevine_license
+49 -34
View File
@@ -7,10 +7,12 @@ from enum import Enum
from pathlib import Path
import click
import colorama
from . import __version__
from .apple_music_api import AppleMusicApi
from .constants import *
from .custom_logger_formatter import CustomLoggerFormatter
from .downloader import Downloader
from .downloader_music_video import DownloaderMusicVideo
from .downloader_post import DownloaderPost
@@ -18,6 +20,7 @@ from .downloader_song import DownloaderSong
from .downloader_song_legacy import DownloaderSongLegacy
from .enums import CoverFormat, DownloadMode, MusicVideoCodec, PostQuality, RemuxMode
from .itunes_api import ItunesApi
from .utils import color_text, prompt_path
apple_music_api_sig = inspect.signature(AppleMusicApi.__init__)
downloader_sig = inspect.signature(Downloader.__init__)
@@ -348,16 +351,14 @@ def main(
quality_post: PostQuality,
no_config_file: bool,
):
logging.basicConfig(
format="[%(levelname)-8s %(asctime)s] %(message)s",
datefmt="%H:%M:%S",
)
colorama.just_fix_windows_console()
logger = logging.getLogger(__name__)
logger.setLevel(log_level)
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(CustomLoggerFormatter())
logger.addHandler(stream_handler)
logger.info("Starting Gamdl")
if not cookies_path.exists():
logger.critical(X_NOT_FOUND_STRING.format("Cookies file", cookies_path))
return
prompt_path("Cookies file", cookies_path)
apple_music_api = AppleMusicApi(
cookies_path,
language=language,
@@ -409,9 +410,8 @@ def main(
quality_post,
)
if not synced_lyrics_only:
if wvd_path and not wvd_path.exists():
logger.critical(X_NOT_FOUND_STRING.format(".wvd file", wvd_path))
return
if wvd_path:
prompt_path(".wvd file", wvd_path)
logger.debug("Setting up CDM")
downloader.set_cdm()
if not downloader.ffmpeg_path_full and (
@@ -440,7 +440,7 @@ def main(
logger.critical(X_NOT_FOUND_STRING.format("N_m3u8DL-RE", nm3u8dlre_path))
return
if not downloader.mp4decrypt_path_full:
logger.warn(
logger.warning(
X_NOT_FOUND_STRING.format("mp4decrypt", mp4decrypt_path)
+ ", music videos will not be downloaded"
)
@@ -448,9 +448,9 @@ def main(
else:
skip_mv = False
if codec_song not in LEGACY_CODECS:
logger.warn(
"You have chosen a non-legacy codec. Support for non-legacy codecs are not guaranteed, "
"as most of the songs cannot be downloaded when using non-legacy codecs."
logger.warning(
"You have chosen an experimental codec. "
"They're not guaranteed to work due to API limitations."
)
error_count = 0
if read_urls_as_txt:
@@ -460,7 +460,7 @@ def main(
_urls.extend(Path(url).read_text(encoding="utf-8").splitlines())
urls = _urls
for url_index, url in enumerate(urls, start=1):
url_progress = f"URL {url_index}/{len(urls)}"
url_progress = color_text(f"URL {url_index}/{len(urls)}", colorama.Style.DIM)
try:
logger.info(f'({url_progress}) Checking "{url}"')
url_info = downloader.get_url_info(url)
@@ -476,7 +476,10 @@ def main(
for download_index, track_metadata in enumerate(
download_queue_tracks_metadata, start=1
):
queue_progress = f"Track {download_index}/{len(download_queue_tracks_metadata)} from URL {url_index}/{len(urls)}"
queue_progress = color_text(
f"Track {download_index}/{len(download_queue_tracks_metadata)} from URL {url_index}/{len(urls)}",
colorama.Style.DIM,
)
try:
remuxed_path = None
if download_queue.playlist_attributes:
@@ -524,10 +527,13 @@ def main(
)
cover_url = downloader.get_cover_url(track_metadata)
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
cover_path = downloader_song.get_cover_path(
final_path,
cover_file_extesion,
)
if cover_file_extesion:
cover_path = downloader_song.get_cover_path(
final_path,
cover_file_extesion,
)
else:
cover_path = None
if synced_lyrics_only:
pass
elif final_path.exists() and not overwrite:
@@ -542,13 +548,16 @@ def main(
)
logger.debug("Getting decryption key")
decryption_key = downloader_song_legacy.get_decryption_key(
stream_info.pssh, track_metadata["id"]
stream_info.widevine_pssh, track_metadata["id"]
)
else:
stream_info = downloader_song.get_stream_info(
track_metadata
)
if not stream_info.stream_url or not stream_info.pssh:
if (
not stream_info.stream_url
or not stream_info.widevine_pssh
):
logger.warning(
f"({queue_progress}) Song is not downloadable or is not"
" available in the chosen codec, skipping"
@@ -556,7 +565,7 @@ def main(
continue
logger.debug("Getting decryption key")
decryption_key = downloader.get_decryption_key(
stream_info.pssh, track_metadata["id"]
stream_info.widevine_pssh, track_metadata["id"]
)
encrypted_path = downloader_song.get_encrypted_path(
track_metadata["id"]
@@ -643,10 +652,13 @@ def main(
final_path = downloader.get_final_path(tags, ".m4v")
cover_url = downloader.get_cover_url(track_metadata)
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
cover_path = downloader_music_video.get_cover_path(
final_path,
cover_file_extesion,
)
if cover_file_extesion:
cover_path = downloader_music_video.get_cover_path(
final_path,
cover_file_extesion,
)
else:
cover_path = None
if final_path.exists() and not overwrite:
logger.warning(
f'({queue_progress}) Music video already exists at "{final_path}", skipping'
@@ -658,10 +670,10 @@ def main(
downloader_music_video.get_stream_info_audio(m3u8_data),
)
decryption_key_video = downloader.get_decryption_key(
stream_info_video.pssh, track_metadata["id"]
stream_info_video.widevine_pssh, track_metadata["id"]
)
decryption_key_audio = downloader.get_decryption_key(
stream_info_audio.pssh, track_metadata["id"]
stream_info_audio.widevine_pssh, track_metadata["id"]
)
encrypted_path_video = (
downloader_music_video.get_encrypted_path_video(
@@ -720,10 +732,13 @@ def main(
final_path = downloader.get_final_path(tags, ".m4v")
cover_url = downloader.get_cover_url(track_metadata)
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
cover_path = downloader_music_video.get_cover_path(
final_path,
cover_file_extesion,
)
if cover_file_extesion:
cover_path = downloader_music_video.get_cover_path(
final_path,
cover_file_extesion,
)
else:
cover_path = None
if final_path.exists() and not overwrite:
logger.warning(
f'({queue_progress}) Post video already exists at "{final_path}", skipping'
@@ -734,7 +749,7 @@ def main(
)
logger.debug(f'Downloading to "{remuxed_path}"')
downloader.download_ytdlp(remuxed_path, stream_url)
if synced_lyrics_only or not save_cover:
if synced_lyrics_only or not save_cover or cover_path is None:
pass
elif cover_path.exists() and not overwrite:
logger.debug(f'Cover already exists at "{cover_path}", skipping')
+24
View File
@@ -0,0 +1,24 @@
import logging
import colorama
from .utils import color_text
class CustomLoggerFormatter(logging.Formatter):
base_format = "[%(levelname)-8s %(asctime)s]"
format_colors = {
logging.DEBUG: colorama.Style.DIM,
logging.INFO: colorama.Fore.GREEN,
logging.WARNING: colorama.Fore.YELLOW,
logging.ERROR: colorama.Fore.RED,
logging.CRITICAL: colorama.Fore.RED,
}
date_format = "%H:%M:%S"
def format(self, record: logging.LogRecord) -> str:
return logging.Formatter(
color_text(self.base_format, self.format_colors.get(record.levelno))
+ " %(message)s",
datefmt=self.date_format,
).format(record)
+24 -13
View File
@@ -24,6 +24,7 @@ from .enums import CoverFormat, DownloadMode, RemuxMode
from .hardcoded_wvd import HARDCODED_WVD
from .itunes_api import ItunesApi
from .models import DownloadQueue, UrlInfo
from .utils import raise_response_exception
class Downloader:
@@ -255,7 +256,7 @@ class Downloader:
playlist_track: int,
) -> dict:
tags = {
"playlist_artist": playlist_attributes["curatorName"],
"playlist_artist": playlist_attributes.get("curatorName", "Apple Music"),
"playlist_id": playlist_attributes["playParams"]["id"],
"playlist_title": playlist_attributes["name"],
"playlist_track": playlist_track,
@@ -419,7 +420,10 @@ class Downloader:
),
)
def get_cover_file_extension(self, cover_url: str) -> str:
def get_cover_file_extension(self, cover_url: str) -> str | None:
cover_bytes = self.get_url_response_bytes(cover_url)
if cover_bytes is None:
return None
image_obj = Image.open(io.BytesIO(self.get_url_response_bytes(cover_url)))
image_format = image_obj.format.lower()
return IMAGE_FILE_EXTENSION_MAP.get(image_format, f".{image_format}")
@@ -455,7 +459,12 @@ class Downloader:
@functools.lru_cache()
def get_url_response_bytes(url: str) -> bytes:
response = requests.get(url)
response.raise_for_status()
if response.status_code == 200:
return response.content
elif response.status_code == 404:
return None
else:
raise_response_exception(response)
return response.content
def apply_tags(
@@ -498,16 +507,18 @@ class Downloader:
"cover" not in self.exclude_tags_list
and self.cover_format != CoverFormat.RAW
):
mp4_tags["covr"] = [
MP4Cover(
self.get_url_response_bytes(cover_url),
imageformat=(
MP4Cover.FORMAT_JPEG
if self.cover_format == CoverFormat.JPG
else MP4Cover.FORMAT_PNG
),
)
]
cover_bytes = self.get_url_response_bytes(cover_url)
if cover_bytes is not None:
mp4_tags["covr"] = [
MP4Cover(
self.get_url_response_bytes(cover_url),
imageformat=(
MP4Cover.FORMAT_JPEG
if self.cover_format == CoverFormat.JPG
else MP4Cover.FORMAT_PNG
),
)
]
mp4 = MP4(path)
mp4.clear()
mp4.update(mp4_tags)
+2 -2
View File
@@ -136,7 +136,7 @@ class DownloaderMusicVideo:
stream_info.stream_url = playlist["uri"]
stream_info.codec = playlist["stream_info"]["codecs"]
m3u8_data = m3u8.load(stream_info.stream_url).data
stream_info.pssh = self.get_pssh(m3u8_data)
stream_info.widevine_pssh = self.get_pssh(m3u8_data)
return stream_info
def get_stream_info_audio(self, m3u8_master_data: dict) -> StreamInfo:
@@ -148,7 +148,7 @@ class DownloaderMusicVideo:
stream_info.stream_url = playlist["uri"]
stream_info.codec = playlist["group_id"]
m3u8_data = m3u8.load(stream_info.stream_url).data
stream_info.pssh = self.get_pssh(m3u8_data)
stream_info.widevine_pssh = self.get_pssh(m3u8_data)
return stream_info
def get_music_video_id_alt(self, metadata: dict) -> str:
+41 -9
View File
@@ -85,25 +85,48 @@ class DownloaderSong:
).execute()
return selected
def get_pssh(
def _get_drm_data(
self,
drm_infos: dict,
drm_ids: list,
drm_key: str,
) -> str | None:
drm_info = next(
(
drm_infos[drm_id]
for drm_id in drm_ids
if drm_infos[drm_id].get(
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
)
and drm_id != "1"
if drm_infos[drm_id].get(drm_key) and drm_id != "1"
),
None,
)
if not drm_info:
return None
return drm_info["urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"]["URI"]
return drm_info[drm_key]["URI"]
def get_widevine_pssh(
self,
drm_infos: dict,
drm_ids: list,
) -> str | None:
return self._get_drm_data(
drm_infos,
drm_ids,
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
)
def get_playready_pssh(self, drm_infos: dict, drm_ids: list) -> str | None:
return self._get_drm_data(
drm_infos,
drm_ids,
"com.microsoft.playready",
)
def get_fairplay_key(self, drm_infos: dict, drm_ids: list) -> str | None:
return self._get_drm_data(
drm_infos,
drm_ids,
"com.apple.streamingkeydelivery",
)
def get_stream_info(self, track_metadata: dict) -> StreamInfo:
m3u8_url = track_metadata["attributes"]["extendedAssetUrls"].get("enhancedHls")
@@ -128,8 +151,14 @@ class DownloaderSong:
stream_info.stream_url = m3u8_obj.base_uri + playlist["uri"]
variant_id = playlist["stream_info"]["stable_variant_id"]
drm_ids = asset_infos[variant_id]["AUDIO-SESSION-KEY-IDS"]
pssh = self.get_pssh(drm_infos, drm_ids)
stream_info.pssh = pssh
widevine_pssh, playready_pssh, fairplay_key = (
self.get_widevine_pssh(drm_infos, drm_ids),
self.get_playready_pssh(drm_infos, drm_ids),
self.get_fairplay_key(drm_infos, drm_ids),
)
stream_info.widevine_pssh = widevine_pssh
stream_info.playready_pssh = playready_pssh
stream_info.fairplay_key = fairplay_key
stream_info.codec = playlist["stream_info"]["codecs"]
return stream_info
@@ -147,7 +176,10 @@ class DownloaderSong:
secs = float(f"{mins_secs_ms[-2]}.{mins_secs_ms[-1]}")
if len(mins_secs_ms) > 2:
mins = int(mins_secs_ms[-3])
return datetime.datetime.fromtimestamp((mins * 60) + secs + (ms / 1000))
return datetime.datetime.fromtimestamp(
(mins * 60) + secs + (ms / 1000),
tz=datetime.timezone.utc,
)
def get_lyrics_synced_timestamp_lrc(self, timestamp_ttml: str) -> str:
datetime_obj = self.parse_datetime_obj_from_timestamp_ttml(timestamp_ttml)
+1 -1
View File
@@ -24,7 +24,7 @@ class DownloaderSongLegacy(DownloaderSong):
i for i in webplayback["assets"] if i["flavor"] == flavor
)["URL"]
m3u8_obj = m3u8.load(stream_info.stream_url)
stream_info.pssh = m3u8_obj.keys[0].uri
stream_info.widevine_pssh = m3u8_obj.keys[0].uri
return stream_info
def get_decryption_key(self, pssh: str, track_id: str) -> str:
+3 -3
View File
@@ -4,8 +4,8 @@ import functools
import requests
from .apple_music_api import AppleMusicApi
from .constants import STOREFRONT_IDS
from .utils import raise_response_exception
class ItunesApi:
@@ -58,7 +58,7 @@ class ItunesApi:
requests.exceptions.JSONDecodeError,
AssertionError,
):
AppleMusicApi._raise_response_exception(response)
raise_response_exception(response)
return resource
def get_itunes_page(
@@ -81,5 +81,5 @@ class ItunesApi:
requests.exceptions.JSONDecodeError,
AssertionError,
):
AppleMusicApi._raise_response_exception(response)
raise_response_exception(response)
return itunes_page
+3 -1
View File
@@ -25,5 +25,7 @@ class Lyrics:
@dataclass
class StreamInfo:
stream_url: str = None
pssh: str = None
widevine_pssh: str = None
playready_pssh: str = None
fairplay_key: str = None
codec: str = None
+29
View File
@@ -0,0 +1,29 @@
from pathlib import Path
import click
import colorama
import requests
from .constants import X_NOT_FOUND_STRING
def color_text(text: str, color) -> str:
return color + text + colorama.Style.RESET_ALL
def raise_response_exception(response: requests.Response):
raise Exception(
f"Request failed with status code {response.status_code}: {response.text}"
)
def prompt_path(path_description: str, path_obj: Path) -> Path:
while not path_obj.exists():
path_obj_str = click.prompt(
X_NOT_FOUND_STRING.format(path_description, path_obj.absolute())
+ ". Move it to that location or drag and drop it here. Then, press enter to continue",
default=str(path_obj),
show_default=False,
)
path_obj = Path(path_obj_str.strip('"'))
return path_obj
+3 -3
View File
@@ -1,16 +1,16 @@
[project]
name = "gamdl"
description = "A Python CLI app for downloading Apple Music songs/music videos/posts."
requires-python = ">=3.8"
description = "A Python CLI app for downloading Apple Music songs, music videos and post videos."
requires-python = ">=3.9"
authors = [{ name = "glomatico" }]
dependencies = [
"click",
"colorama",
"inquirerpy",
"m3u8",
"mutagen",
"pillow",
"pywidevine",
"pyyaml",
"yt-dlp",
]
readme = "README.md"
+2
View File
@@ -1,8 +1,10 @@
click
colorama
inquirerpy
m3u8
mutagen
pillow
pywidevine
pyyaml
termcolor
yt-dlp