Compare commits

...

68 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
Rafael Moraes 1013bd20b9 add mutagen to depedencies 2024-11-23 17:05:39 -03:00
Rafael Moraes 2c1fa9d99b bump version 2024-11-23 17:05:28 -03:00
Rafael Moraes fc1c161e30 Update README.md 2024-11-02 03:54:37 -03:00
Rafael Moraes 2f87902163 Update README.md 2024-11-01 13:38:54 -03:00
Rafael Moraes 9f7bb0d404 Update README.md 2024-11-01 13:37:28 -03:00
Rafael Moraes c653db00cf bump version 2024-10-31 13:59:02 -03:00
Rafael Moraes cdd574a349 Update README.md 2024-10-31 13:57:27 -03:00
Rafael Moraes afbe65707a replace print_exceptions with no_exceptions 2024-10-31 13:47:35 -03:00
Rafael Moraes 3998b698e0 fix for songs that don't have genre 2024-10-31 13:45:08 -03:00
Rafael Moraes a67c81bd22 remove ciso8601 from requirements 2024-10-27 12:37:40 -03:00
Rafael Moraes 9b0a2acc6f bump version 2024-10-27 12:36:46 -03:00
Rafael Moraes 4d904e2e7c address old python compatibility when parsing date 2024-10-27 12:36:12 -03:00
Rafael Moraes 2d3b2b6b1f replace ciso8601 with python datetime 2024-10-27 12:32:38 -03:00
Rafael Moraes 1ee8e2aa13 Merge pull request #139 from glomatico/glomatico-patch-1
Create LICENSE
2024-09-22 00:00:37 -03:00
Rafael Moraes fd6d8a0689 Create LICENSE 2024-09-22 00:00:26 -03:00
Rafael Moraes 50904e9c08 bump version 2024-09-13 21:20:33 -03:00
Rafael Moraes 66556eac0a update update_playlist_file 2024-09-13 21:20:20 -03:00
Rafael Moraes d97445ec9e check for synced_lyrics_only before saving cover 2024-09-08 00:26:56 -03:00
Rafael Moraes d6f30aa0a2 bump version 2024-09-08 00:21:47 -03:00
Rafael Moraes 42a17ca90f optimize stream_url fetching for music videos 2024-09-08 00:21:35 -03:00
Rafael Moraes 3ee0d28727 add from __future__ import annotations 2024-09-08 00:09:30 -03:00
Rafael Moraes 7b8875250c refactor get_playlist_file_path 2024-09-08 00:08:19 -03:00
Rafael Moraes 16734b8b64 refactor update_playlist_file 2024-09-08 00:07:54 -03:00
Rafael Moraes 475bddb5f7 refactor get_final_path 2024-09-08 00:07:30 -03:00
Rafael Moraes 63ba4b0824 change default value for template_file_playlist 2024-09-08 00:07:07 -03:00
Rafael Moraes 9d67e8f0f0 bump version 2024-08-29 22:50:49 -03:00
Rafael Moraes fcbe596a80 refactor to use webplayback api to get the stream url instead of itunes page 2024-08-29 22:50:19 -03:00
Rafael Moraes acd5fefb76 bump version 2024-08-05 11:14:39 -03:00
Rafael Moraes ed584cc9b9 skip to next track when track is not downloadable 2024-08-04 20:04:52 -03:00
Rafael Moraes bac8eb9254 remove useless save cover lines in music video 2024-08-04 20:02:41 -03:00
Rafael Moraes 71ac17cce2 improve post video download logic 2024-08-03 17:53:25 -03:00
Rafael Moraes a35a3835aa create parent directory before saving cover image 2024-08-03 17:49:42 -03:00
Rafael Moraes 091ca3bf53 fix get_playlist_file_path 2024-08-03 14:15:14 -03:00
Rafael Moraes e4498e11c0 bump version 2024-08-03 14:13:55 -03:00
Rafael Moraes 5a8c5d2c25 fix get_final_path 2024-08-03 14:13:38 -03:00
15 changed files with 413 additions and 246 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Glomatico
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+77 -92
View File
@@ -1,71 +1,80 @@
# 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)
* You can get your cookies by using one of the following extensions on your browser of choice at the Apple Music website with your account signed in:
* 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/
* (Optional) mp4decrypt on your system PATH
* Required to download music videos and songs in non-legacy formats.
* Binaries can be obtained from here: https://www.bento4.com/downloads/.
* **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 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
```bash
pip install gamdl
```
2. Place your cookies file in the directory from which you will be running gamdl and name it `cookies.txt`.
2. Set up the cookies 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
* Song
* Album
* Playlist
* Music video
* Artist
* 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.
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` |
@@ -76,7 +85,7 @@ Config file values can be overridden using command line arguments.
| `--no-synced-lyrics` / `no_synced_lyrics` | Don't download the synced lyrics. | `false` |
| `--config-path` / - | Path to config file. | `<home>/.gamdl/config.json` |
| `--log-level` / `log_level` | Log level. | `INFO` |
| `--print-exceptions` / `print_exceptions` | Print exceptions. | `false` |
| `--no-exceptions` / `no_exceptions` | Don't print exceptions. | `false` |
| `--cookies-path`, `-c` / `cookies_path` | Path to .txt cookies file. | `./cookies.txt` |
| `--language`, `-l` / `language` | Metadata language as an ISO-2A language code (don't always work for videos). | `en-US` |
| `--output-path`, `-o` / `output_path` | Path to output directory. | `./Apple Music` |
@@ -144,69 +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`
* Can be used without mp4decrypt only for songs and when using legacy song codecs
* `mp4box`
* Requires mp4decrypt
* Doesn't convert closed captions in music videos that have them
* Can be obtained from here: https://gpac.wp.imt.fr/downloads
### 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`
* Requires FFmpeg
* Can be obtained from here: https://github.com/nilaoda/N_m3u8DL-RE/releases
* `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"
__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
+90 -70
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__)
@@ -125,9 +128,9 @@ def load_config_file(
help="Log level.",
)
@click.option(
"--print-exceptions",
"--no-exceptions",
is_flag=True,
help="Print exceptions.",
help="Don't print exceptions.",
)
# API specific options
@click.option(
@@ -318,7 +321,7 @@ def main(
no_synced_lyrics: bool,
config_path: Path,
log_level: str,
print_exceptions: bool,
no_exceptions: bool,
cookies_path: Path,
language: str,
output_path: Path,
@@ -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)
logger.debug("Starting downloader")
if not cookies_path.exists():
logger.critical(X_NOT_FOUND_STRING.format("Cookies file", cookies_path))
return
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(CustomLoggerFormatter())
logger.addHandler(stream_handler)
logger.info("Starting Gamdl")
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)
@@ -470,13 +470,16 @@ def main(
error_count += 1
logger.error(
f'({url_progress}) Failed to check "{url}"',
exc_info=print_exceptions,
exc_info=not no_exceptions,
)
continue
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:
@@ -503,6 +506,7 @@ def main(
logger.warning(
f"({queue_progress}) Track is not downloadable with current configuration, skipping"
)
continue
elif track_metadata["type"] == "songs":
logger.debug("Getting lyrics")
lyrics = downloader_song.get_lyrics(track_metadata)
@@ -523,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:
@@ -541,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"
@@ -555,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"]
@@ -608,13 +618,24 @@ def main(
itunes_page = itunes_api.get_itunes_page(
"music-video", music_video_id_alt
)
stream_url_master = downloader_music_video.get_stream_url_master(
itunes_page
)
if music_video_id_alt == track_metadata["id"]:
stream_url = (
downloader_music_video.get_stream_url_from_itunes_page(
itunes_page
)
)
else:
logger.debug("Getting webplayback")
webplayback = apple_music_api.get_webplayback(
track_metadata["id"]
)
stream_url = (
downloader_music_video.get_stream_url_from_webplayback(
webplayback
)
)
logger.debug("Getting M3U8 data")
m3u8_master_data = downloader_music_video.get_m3u8_master_data(
stream_url_master
)
m3u8_data = downloader_music_video.get_m3u8_master_data(stream_url)
tags = downloader_music_video.get_tags(
music_video_id_alt,
itunes_page,
@@ -631,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'
@@ -642,18 +666,14 @@ def main(
else:
logger.debug("Getting stream info")
stream_info_video, stream_info_audio = (
downloader_music_video.get_stream_info_video(
m3u8_master_data
),
downloader_music_video.get_stream_info_audio(
m3u8_master_data
),
downloader_music_video.get_stream_info_video(m3u8_data),
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(
@@ -706,36 +726,30 @@ def main(
stream_info_video.codec,
stream_info_audio.codec,
)
if not save_cover:
pass
elif cover_path.exists() and not overwrite:
logger.debug(
f'Cover already exists at "{cover_path}", skipping'
)
else:
logger.debug(f'Saving cover to "{cover_path}"')
downloader.save_cover(cover_path, cover_url)
elif track_metadata["type"] == "uploaded-videos":
stream_url = downloader_post.get_stream_url(track_metadata)
tags = downloader_post.get_tags(track_metadata)
remuxed_path = downloader_post.get_post_temp_path(
track_metadata["id"]
)
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'
)
else:
remuxed_path = downloader_post.get_post_temp_path(
track_metadata["id"]
)
logger.debug(f'Downloading to "{remuxed_path}"')
downloader.download_ytdlp(remuxed_path, stream_url)
if 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')
@@ -747,17 +761,23 @@ def main(
downloader.apply_tags(remuxed_path, tags, cover_url)
logger.debug(f'Moving to "{final_path}"')
downloader.move_to_output_path(remuxed_path, final_path)
if save_playlist and download_queue.playlist_attributes:
playlist_file_path = downloader.get_playlist_file_path(tags)
logger.debug(
f'Updating M3U8 playlist from "{playlist_file_path}"'
)
downloader.update_playlist_file(playlist_file_path, final_path)
if (
not synced_lyrics_only
and save_playlist
and download_queue.playlist_attributes
):
playlist_file_path = downloader.get_playlist_file_path(tags)
logger.debug(f'Updating M3U8 playlist from "{playlist_file_path}"')
downloader.update_playlist_file(
playlist_file_path,
final_path,
playlist_track,
)
except Exception as e:
error_count += 1
logger.error(
f'({queue_progress}) Failed to download "{track_metadata["attributes"]["name"]}"',
exc_info=print_exceptions,
exc_info=not no_exceptions,
)
finally:
if temp_path.exists():
+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)
+70 -42
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import base64
import datetime
import functools
import io
import re
@@ -9,7 +10,6 @@ import subprocess
import typing
from pathlib import Path
import ciso8601
import requests
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
@@ -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:
@@ -51,7 +52,7 @@ class Downloader:
template_file_multi_disc: str = "{disc}-{track:02d} {title}",
template_folder_no_album: str = "{artist}/Unknown Album",
template_file_no_album: str = "{title}",
template_file_playlist: str = "Playlists/{playlist_title}",
template_file_playlist: str = "Playlists/{playlist_artist}/{playlist_title}",
template_date: str = "%Y-%m-%dT%H:%M:%SZ",
exclude_tags: str = None,
cover_size: int = 1200,
@@ -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,
@@ -266,39 +267,52 @@ class Downloader:
self,
tags: dict,
):
template_folder = self.template_file_playlist.split("/")[0:-1]
template_file = self.template_file_playlist.split("/")[-1]
return self.output_path.joinpath(
template_file = self.template_file_playlist.split("/")
return Path(
self.output_path,
*[
self.get_sanitized_string(i.format(**tags), True)
for i in template_folder
]
).joinpath(
*[self.get_sanitized_string(template_file.format(**tags), False) + ".m3u8"]
for i in template_file[0:-1]
],
*[
self.get_sanitized_string(template_file[-1].format(**tags), False)
+ ".m3u8"
],
)
def update_playlist_file(
self,
playlist_file_path: Path,
final_path: Path,
playlist_track: int,
):
playlist_file_path.parent.mkdir(parents=True, exist_ok=True)
with playlist_file_path.open("a", encoding="utf8") as playlist_file:
playlist_file.write(
final_path.relative_to(
playlist_file_path.parent, walk_up=True
).as_posix()
+ "\n"
playlist_file_path_parent_parts_len = len(playlist_file_path.parent.parts)
output_path_parts_len = len(self.output_path.parts)
final_path_relative = Path(
("../" * (playlist_file_path_parent_parts_len - output_path_parts_len)),
*final_path.parts[output_path_parts_len:],
)
playlist_file_lines = (
playlist_file_path.open("r", encoding="utf8").readlines()
if playlist_file_path.exists()
else []
)
if len(playlist_file_lines) < playlist_track:
playlist_file_lines.extend(
"\n" for _ in range(playlist_track - len(playlist_file_lines))
)
playlist_file_lines[playlist_track - 1] = final_path_relative.as_posix() + "\n"
with playlist_file_path.open("w", encoding="utf8") as playlist_file:
playlist_file.writelines(playlist_file_lines)
@staticmethod
def millis_to_min_sec(millis):
def millis_to_min_sec(millis) -> str:
minutes, seconds = divmod(millis // 1000, 60)
return f"{minutes:02d}:{seconds:02d}"
def sanitize_date(self, date: str):
datetime_obj = ciso8601.parse_datetime(date)
return datetime_obj.strftime(self.template_date)
def sanitize_date(self, date: str) -> datetime.datetime:
return datetime.datetime.fromisoformat(date[:-1]).strftime(self.template_date)
def get_decryption_key(self, pssh: str, track_id: str) -> str:
try:
@@ -384,26 +398,32 @@ class Downloader:
self.template_folder_compilation.split("/")
if tags.get("compilation")
else self.template_folder_album.split("/")
)[0:-1]
)
template_file = (
self.template_file_multi_disc.split("/")
if tags["disc_total"] > 1
else self.template_file_single_disc.split("/")
)[-1]
)
else:
template_folder = self.template_folder_no_album.split("/")[0:-1]
template_file = self.template_file_no_album.split("/")[-1]
return self.output_path.joinpath(
template_folder = self.template_folder_no_album.split("/")
template_file = self.template_file_no_album.split("/")
template_final = template_folder + template_file
return Path(
self.output_path,
*[
self.get_sanitized_string(i.format(**tags), True)
for i in template_folder
]
).joinpath(
self.get_sanitized_string(template_file.format(**tags), False)
+ file_extension
for i in template_final[0:-1]
],
(
self.get_sanitized_string(template_final[-1].format(**tags), False)
+ file_extension
),
)
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}")
@@ -439,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(
@@ -482,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)
@@ -507,6 +534,7 @@ class Downloader:
@functools.lru_cache()
def save_cover(self, cover_path: Path, cover_url: str):
cover_path.parent.mkdir(parents=True, exist_ok=True)
cover_path.write_bytes(self.get_url_response_bytes(cover_url))
def cleanup_temp_path(self):
+11 -8
View File
@@ -25,17 +25,20 @@ class DownloaderMusicVideo:
self.downloader = downloader
self.codec = codec
def get_stream_url_master(self, itunes_page: dict) -> str:
return itunes_page["offers"][0]["assets"][0]["hlsUrl"]
def get_stream_url_from_webplayback(self, webplayback: dict) -> str:
return webplayback["hls-playlist-url"]
def get_m3u8_master_data(self, stream_url_master: str) -> dict:
url_parts = urllib.parse.urlparse(stream_url_master)
def get_stream_url_from_itunes_page(self, itunes_page: dict) -> dict:
stream_url = itunes_page["offers"][0]["assets"][0]["hlsUrl"]
url_parts = urllib.parse.urlparse(stream_url)
query = urllib.parse.parse_qs(url_parts.query, keep_blank_values=True)
query.update({"aec": "HD", "dsid": "1"})
stream_url_master_new = url_parts._replace(
return url_parts._replace(
query=urllib.parse.urlencode(query, doseq=True)
).geturl()
return m3u8.load(stream_url_master_new).data
def get_m3u8_master_data(self, stream_url_master: str) -> dict:
return m3u8.load(stream_url_master).data
def get_playlist_video(
self,
@@ -133,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:
@@ -145,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:
+42 -10
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)
@@ -248,7 +280,7 @@ class DownloaderSong:
"disc": tags_raw["discNumber"],
"disc_total": tags_raw["discCount"],
"gapless": tags_raw["gapless"],
"genre": tags_raw["genre"],
"genre": tags_raw.get("genre"),
"genre_id": tags_raw["genreId"],
"lyrics": lyrics_unsynced if lyrics_unsynced else None,
"media_type": 1,
+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
+5 -1
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
from dataclasses import dataclass
@@ -23,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
+4 -4
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 = [
"ciso8601",
"click",
"colorama",
"inquirerpy",
"m3u8",
"mutagen",
"pillow",
"pywidevine",
"pyyaml",
"yt-dlp",
]
readme = "README.md"
+3 -1
View File
@@ -1,8 +1,10 @@
ciso8601
click
colorama
inquirerpy
m3u8
mutagen
pillow
pywidevine
pyyaml
termcolor
yt-dlp