mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-13 20:25:13 +03:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 744300e36b | |||
| e86f990395 | |||
| abc2f8f2f2 | |||
| 2dabb1c6fe | |||
| 8b80b0c6c5 | |||
| eef659bac8 | |||
| 0f7c3795a7 | |||
| c84b1137c2 | |||
| ebdc82d68b | |||
| 85c1fdbfbb | |||
| 5990e5f722 | |||
| b8bd406d74 | |||
| 57ee6e1db8 | |||
| a20feb2aa7 | |||
| 60db7e0339 | |||
| 575d2ee154 | |||
| f5bb56cab7 | |||
| ecc7979d7e | |||
| d129551b55 | |||
| 08a5ac00d8 | |||
| 628c9786d5 | |||
| 7de12c3da7 | |||
| 39d724c488 | |||
| 79e00e5e19 | |||
| e90fd24af0 | |||
| d68edd5393 | |||
| 5acefd9a06 | |||
| 93b62cdde9 | |||
| fc61a51da2 | |||
| 81b44a808d | |||
| 24f3af1a5e | |||
| 4a469d74d3 | |||
| 6122835caa | |||
| be597f0de4 | |||
| b10ab5332d | |||
| 080413b183 | |||
| f6443081ae | |||
| 8dcf10c221 | |||
| 6f5efd1779 | |||
| 06e43fdbbe | |||
| 646125b93f | |||
| ea281766ba | |||
| a1b0ad35ee | |||
| 2f715b3d9d | |||
| 461fcedf30 |
@@ -1,10 +1,10 @@
|
||||
# Glomatico's Apple Music Downloader
|
||||
A Python CLI app for downloading Apple Music songs/music videos/albums/playlists/posts.
|
||||
A Python CLI app for downloading Apple Music songs/music videos/posts.
|
||||
|
||||
**Discord Server:** https://discord.gg/aBjMEZ9tnq
|
||||
|
||||
## Features
|
||||
* Download songs in AAC/Spatial AAC/Dolby Atmos/ALAC*
|
||||
* 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
|
||||
@@ -14,7 +14,7 @@ A Python CLI app for downloading Apple Music songs/music videos/albums/playlists
|
||||
|
||||
## Prerequisites
|
||||
* Python 3.8 or higher
|
||||
* The cookies file of your Apple Music account (requires an active subscription)
|
||||
* The cookies file of your Apple Music browser session (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
|
||||
@@ -53,46 +53,52 @@ gamdl [OPTIONS] URLS...
|
||||
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
|
||||
|
||||
## Configuration
|
||||
You can configure gamdl 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 |
|
||||
| --------------------------------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------- |
|
||||
| `--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` |
|
||||
| `--overwrite` / `overwrite` | Overwrite existing files. | `false` |
|
||||
| `--read-urls-as-txt`, `-r` / - | Interpret URLs as paths to text files containing URLs separated by newlines. | `false` |
|
||||
| `--synced-lyrics-only` / `synced_lyrics_only` | Download only the synced lyrics. | `false` |
|
||||
| `--no-synced-lyrics` / `no_synced_lyrics` | Don't download the synced lyrics. | `false` |
|
||||
| `--config-path` / - | Path to config file. | `<home>/.spotify-web-downloader/config.json` |
|
||||
| `--log-level` / `log_level` | Log level. | `INFO` |
|
||||
| `--print-exceptions` / `print_exceptions` | 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` |
|
||||
| `--temp-path` / `temp_path` | Path to temporary directory. | `./temp` |
|
||||
| `--wvd-path` / `wvd_path` | Path to .wvd file. | `null` |
|
||||
| `--nm3u8dlre-path` / `nm3u8dlre_path` | Path to N_m3u8DL-RE binary. | `N_m3u8dl-RE` |
|
||||
| `--mp4decrypt-path` / `mp4decrypt_path` | Path to mp4decrypt binary. | `mp4decrypt` |
|
||||
| `--ffmpeg-path` / `ffmpeg_path` | Path to FFmpeg binary. | `ffmpeg` |
|
||||
| `--mp4box-path` / `mp4box_path` | Path to MP4Box binary. | `MP4Box` |
|
||||
| `--download-mode` / `download_mode` | Download mode. | `ytdlp` |
|
||||
| `--remux-mode` / `remux_mode` | Remux mode. | `ffmpeg` |
|
||||
| `--cover-format` / `cover_format` | Cover format. | `jpg` |
|
||||
| `--template-folder-album` / `template_folder_album` | Template folder for tracks that are part of an album. | `{album_artist}/{album}` |
|
||||
| `--template-folder-compilation` / `template_folder_compilation` | Template folder for tracks that are part of a compilation album. | `Compilations/{album}` |
|
||||
| `--template-file-single-disc` / `template_file_single_disc` | Template file for the tracks that are part of a single-disc album. | `{track:02d} {title}` |
|
||||
| `--template-file-multi-disc` / `template_file_multi_disc` | Template file for the tracks that are part of a multi-disc album. | `{disc}-{track:02d} {title}` |
|
||||
| `--template-folder-no-album` / `template_folder_no_album` | Template folder for the tracks that are not part of an album. | `{artist}/Unknown Album` |
|
||||
| `--template-file-no-album` / `template_file_no_album` | Template file for the tracks that are not part of an album. | `{title}` |
|
||||
| `--template-date` / `template_date` | Date tag template. | `%Y-%m-%dT%H:%M:%SZ` |
|
||||
| `--exclude-tags` / `exclude_tags` | Comma-separated tags to exclude. | `null` |
|
||||
| `--cover-size` / `cover_size` | Cover size. | `1200` |
|
||||
| `--truncate` / `truncate` | Maximum length of the file/folder names. | `40` |
|
||||
| `--codec-song` / `codec_song` | Song codec. | `aac-legacy` |
|
||||
| `--synced-lyrics-format` / `synced_lyrics_format` | Synced lyrics format. | `lrc` |
|
||||
| `--codec-music-video` / `codec_music_video` | Music video codec. | `h264` |
|
||||
| `--quality-post` / `quality_post` | Post video quality. | `best` |
|
||||
| `--no-config-file`, `-n` / - | Do not use a config file. | `false` |
|
||||
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 |
|
||||
| --------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------- |
|
||||
| `--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` |
|
||||
| `--overwrite` / `overwrite` | Overwrite existing files. | `false` |
|
||||
| `--read-urls-as-txt`, `-r` / - | Interpret URLs as paths to text files containing URLs separated by newlines. | `false` |
|
||||
| `--synced-lyrics-only` / `synced_lyrics_only` | Download only the synced lyrics. | `false` |
|
||||
| `--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` |
|
||||
| `--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` |
|
||||
| `--temp-path` / `temp_path` | Path to temporary directory. | `./temp` |
|
||||
| `--wvd-path` / `wvd_path` | Path to .wvd file. | `null` |
|
||||
| `--nm3u8dlre-path` / `nm3u8dlre_path` | Path to N_m3u8DL-RE binary. | `N_m3u8DL-RE` |
|
||||
| `--mp4decrypt-path` / `mp4decrypt_path` | Path to mp4decrypt binary. | `mp4decrypt` |
|
||||
| `--ffmpeg-path` / `ffmpeg_path` | Path to FFmpeg binary. | `ffmpeg` |
|
||||
| `--mp4box-path` / `mp4box_path` | Path to MP4Box binary. | `MP4Box` |
|
||||
| `--download-mode` / `download_mode` | Download mode. | `ytdlp` |
|
||||
| `--remux-mode` / `remux_mode` | Remux mode. | `ffmpeg` |
|
||||
| `--cover-format` / `cover_format` | Cover format. | `jpg` |
|
||||
| `--template-folder-album` / `template_folder_album` | Template folder for tracks that are part of an album. | `{album_artist}/{album}` |
|
||||
| `--template-folder-compilation` / `template_folder_compilation` | Template folder for tracks that are part of a compilation album. | `Compilations/{album}` |
|
||||
| `--template-file-single-disc` / `template_file_single_disc` | Template file for the tracks that are part of a single-disc album. | `{track:02d} {title}` |
|
||||
| `--template-file-multi-disc` / `template_file_multi_disc` | Template file for the tracks that are part of a multi-disc album. | `{disc}-{track:02d} {title}` |
|
||||
| `--template-folder-no-album` / `template_folder_no_album` | Template folder for the tracks that are not part of an album. | `{artist}/Unknown Album` |
|
||||
| `--template-file-no-album` / `template_file_no_album` | Template file for the tracks that are not part of an album. | `{title}` |
|
||||
| `--template-date` / `template_date` | Date tag template. | `%Y-%m-%dT%H:%M:%SZ` |
|
||||
| `--exclude-tags` / `exclude_tags` | Comma-separated tags to exclude. | `null` |
|
||||
| `--cover-size` / `cover_size` | Cover size. | `1200` |
|
||||
| `--truncate` / `truncate` | Maximum length of the file/folder names. | `40` |
|
||||
| `--codec-song` / `codec_song` | Song codec. | `aac-legacy` |
|
||||
| `--synced-lyrics-format` / `synced_lyrics_format` | Synced lyrics format. | `lrc` |
|
||||
| `--codec-music-video` / `codec_music_video` | Music video codec. | `h264` |
|
||||
| `--quality-post` / `quality_post` | Post video quality. | `best` |
|
||||
| `--no-config-file`, `-n` / - | Do not use a config file. | `false` |
|
||||
|
||||
|
||||
### Tags variables
|
||||
@@ -150,6 +156,8 @@ The following download modes are available:
|
||||
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`
|
||||
@@ -160,9 +168,7 @@ The following codecs are available:
|
||||
* `ac3`
|
||||
* `alac`
|
||||
* `ask`
|
||||
* When using this option, gamdl will ask you which **non-legacy** codec to use that is available for the song.
|
||||
|
||||
**Support for non-legacy codecs are not guaranteed, as most of the songs cannot be downloaded when using non-legacy codecs.**
|
||||
* 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:
|
||||
@@ -185,10 +191,12 @@ The following synced lyrics formats are available:
|
||||
* `srt`
|
||||
* `ttml`
|
||||
* Native format for Apple Music synced lyrics.
|
||||
* Highly unsupported by media players.
|
||||
* Highly 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.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
__version__ = "2.2"
|
||||
__version__ = "2.2.6"
|
||||
|
||||
@@ -169,13 +169,12 @@ class AppleMusicApi:
|
||||
def get_playlist(
|
||||
self,
|
||||
playlist_id: str,
|
||||
is_library: bool = False,
|
||||
limit_tracks: int = 300,
|
||||
extend: str = "extendedAssetUrls",
|
||||
fetch_all: bool = True,
|
||||
) -> dict:
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/{'me' if is_library else 'catalog'}/{self.storefront}/playlists/{playlist_id}",
|
||||
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/playlists/{playlist_id}",
|
||||
params={
|
||||
"extend": extend,
|
||||
"limit[tracks]": limit_tracks,
|
||||
|
||||
+35
-21
@@ -492,8 +492,12 @@ def main(
|
||||
lyrics_synced_path = downloader_song.get_lyrics_synced_path(
|
||||
final_path
|
||||
)
|
||||
cover_path = downloader_song.get_cover_path(final_path)
|
||||
cover_url = downloader.get_cover_url(track)
|
||||
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
|
||||
cover_path = downloader_song.get_cover_path(
|
||||
final_path,
|
||||
cover_file_extesion,
|
||||
)
|
||||
if synced_lyrics_only:
|
||||
pass
|
||||
elif final_path.exists() and not overwrite:
|
||||
@@ -525,10 +529,12 @@ def main(
|
||||
encrypted_path = downloader_song.get_encrypted_path(track["id"])
|
||||
decrypted_path = downloader_song.get_decrypted_path(track["id"])
|
||||
remuxed_path = downloader_song.get_remuxed_path(track["id"])
|
||||
logger.debug(f"Downloading to {encrypted_path}")
|
||||
logger.debug(f'Downloading to "{encrypted_path}"')
|
||||
downloader.download(encrypted_path, stream_info.stream_url)
|
||||
if codec_song in LEGACY_CODECS:
|
||||
logger.debug(f"Remuxing/Decrypting to {remuxed_path}")
|
||||
logger.debug(
|
||||
f'Decrypting/Remuxing to "{decrypted_path}"/"{remuxed_path}"'
|
||||
)
|
||||
downloader_song_legacy.remux(
|
||||
encrypted_path,
|
||||
decrypted_path,
|
||||
@@ -536,11 +542,11 @@ def main(
|
||||
decryption_key,
|
||||
)
|
||||
else:
|
||||
logger.debug(f"Decrypting to {decrypted_path}")
|
||||
logger.debug(f'Decrypting to "{decrypted_path}"')
|
||||
downloader_song.decrypt(
|
||||
encrypted_path, decrypted_path, decryption_key
|
||||
)
|
||||
logger.debug(f"Remuxing to {final_path}")
|
||||
logger.debug(f'Remuxing to "{final_path}"')
|
||||
downloader_song.remux(
|
||||
decrypted_path,
|
||||
remuxed_path,
|
||||
@@ -548,7 +554,7 @@ def main(
|
||||
)
|
||||
logger.debug("Applying tags")
|
||||
downloader.apply_tags(remuxed_path, tags, cover_url)
|
||||
logger.debug(f"Moving to {final_path}")
|
||||
logger.debug(f'Moving to "{final_path}"')
|
||||
downloader.move_to_output_path(remuxed_path, final_path)
|
||||
if no_synced_lyrics or not lyrics.synced:
|
||||
pass
|
||||
@@ -586,13 +592,17 @@ def main(
|
||||
stream_url_master
|
||||
)
|
||||
tags = downloader_music_video.get_tags(
|
||||
music_video_id_alt,
|
||||
itunes_page,
|
||||
m3u8_master_data,
|
||||
track,
|
||||
)
|
||||
final_path = downloader.get_final_path(tags, ".m4v")
|
||||
cover_path = downloader_music_video.get_cover_path(final_path)
|
||||
cover_url = downloader.get_cover_url(track)
|
||||
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
|
||||
cover_path = downloader_music_video.get_cover_path(
|
||||
final_path,
|
||||
cover_file_extesion,
|
||||
)
|
||||
if final_path.exists() and not overwrite:
|
||||
logger.warning(
|
||||
f'({queue_progress}) Music video already exists at "{final_path}", skipping'
|
||||
@@ -628,27 +638,27 @@ def main(
|
||||
remuxed_path = downloader_music_video.get_remuxed_path(
|
||||
track["id"]
|
||||
)
|
||||
logger.debug(f"Downloading video to {encrypted_path_video}")
|
||||
logger.debug(f'Downloading video to "{encrypted_path_video}"')
|
||||
downloader.download(
|
||||
encrypted_path_video, stream_info_video.stream_url
|
||||
)
|
||||
logger.debug(f"Downloading audio to {encrypted_path_audio}")
|
||||
logger.debug(f'Downloading audio to "{encrypted_path_audio}"')
|
||||
downloader.download(
|
||||
encrypted_path_audio, stream_info_audio.stream_url
|
||||
)
|
||||
logger.debug(f"Decrypting video to {decrypted_path_video}")
|
||||
logger.debug(f'Decrypting video to "{decrypted_path_video}"')
|
||||
downloader_music_video.decrypt(
|
||||
encrypted_path_video,
|
||||
decryption_key_video,
|
||||
decrypted_path_video,
|
||||
)
|
||||
logger.debug(f"Decrypting audio to {decrypted_path_audio}")
|
||||
logger.debug(f'Decrypting audio to "{decrypted_path_audio}"')
|
||||
downloader_music_video.decrypt(
|
||||
encrypted_path_audio,
|
||||
decryption_key_audio,
|
||||
decrypted_path_audio,
|
||||
)
|
||||
logger.debug(f"Remuxing to {remuxed_path}")
|
||||
logger.debug(f'Remuxing to "{remuxed_path}"')
|
||||
downloader_music_video.remux(
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
@@ -658,7 +668,7 @@ def main(
|
||||
)
|
||||
logger.debug("Applying tags")
|
||||
downloader.apply_tags(remuxed_path, tags, cover_url)
|
||||
logger.debug(f"Moving to {final_path}")
|
||||
logger.debug(f'Moving to "{final_path}"')
|
||||
downloader.move_to_output_path(remuxed_path, final_path)
|
||||
if not save_cover:
|
||||
pass
|
||||
@@ -672,21 +682,25 @@ def main(
|
||||
elif track["type"] == "uploaded-videos":
|
||||
stream_url = downloader_post.get_stream_url(track)
|
||||
tags = downloader_post.get_tags(track)
|
||||
temp_path = downloader_post.get_temp_path(track["id"])
|
||||
post_temp_path = downloader_post.get_post_temp_path(track["id"])
|
||||
final_path = downloader.get_final_path(tags, ".m4v")
|
||||
cover_path = downloader_music_video.get_cover_path(final_path)
|
||||
cover_url = downloader.get_cover_url(track)
|
||||
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
|
||||
cover_path = downloader_music_video.get_cover_path(
|
||||
final_path,
|
||||
cover_file_extesion,
|
||||
)
|
||||
if final_path.exists() and not overwrite:
|
||||
logger.warning(
|
||||
f'({queue_progress}) Post video already exists at "{final_path}", skipping'
|
||||
)
|
||||
else:
|
||||
logger.debug(f"Downloading to {final_path}")
|
||||
downloader.download_ytdlp(temp_path, stream_url)
|
||||
logger.debug(f'Downloading to "{post_temp_path}"')
|
||||
downloader.download_ytdlp(post_temp_path, stream_url)
|
||||
logger.debug("Applying tags")
|
||||
downloader.apply_tags(temp_path, tags, cover_url)
|
||||
logger.debug(f"Moving to {final_path}")
|
||||
downloader.move_to_output_path(temp_path, final_path)
|
||||
downloader.apply_tags(post_temp_path, tags, cover_url)
|
||||
logger.debug(f'Moving to "{final_path}"')
|
||||
downloader.move_to_output_path(post_temp_path, final_path)
|
||||
if not save_cover:
|
||||
pass
|
||||
elif cover_path.exists() and not overwrite:
|
||||
|
||||
@@ -208,6 +208,12 @@ SYNCED_LYRICS_FILE_EXTENSION_MAP = {
|
||||
}
|
||||
|
||||
|
||||
IMAGE_FILE_EXTENSION_MAP = {
|
||||
"jpeg": ".jpg",
|
||||
"tiff": ".tif",
|
||||
}
|
||||
|
||||
|
||||
EXCLUDED_CONFIG_FILE_PARAMS = (
|
||||
"urls",
|
||||
"config_path",
|
||||
|
||||
+50
-19
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import functools
|
||||
import io
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -12,11 +13,12 @@ import requests
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
from mutagen.mp4 import MP4, MP4Cover
|
||||
from PIL import Image
|
||||
from pywidevine import PSSH, Cdm, Device
|
||||
from yt_dlp import YoutubeDL
|
||||
|
||||
from .apple_music_api import AppleMusicApi
|
||||
from .constants import MP4_TAGS_MAP
|
||||
from .constants import IMAGE_FILE_EXTENSION_MAP, MP4_TAGS_MAP
|
||||
from .enums import CoverFormat, DownloadMode, RemuxMode
|
||||
from .hardcoded_wvd import HARDCODED_WVD
|
||||
from .itunes_api import ItunesApi
|
||||
@@ -33,7 +35,7 @@ class Downloader:
|
||||
output_path: Path = Path("./Apple Music"),
|
||||
temp_path: Path = Path("./temp"),
|
||||
wvd_path: Path = None,
|
||||
nm3u8dlre_path: str = "N_m3u8dl-RE",
|
||||
nm3u8dlre_path: str = "N_m3u8DL-RE",
|
||||
mp4decrypt_path: str = "mp4decrypt",
|
||||
ffmpeg_path: str = "ffmpeg",
|
||||
mp4box_path: str = "MP4Box",
|
||||
@@ -252,21 +254,23 @@ class Downloader:
|
||||
return datetime_obj.strftime(self.template_date)
|
||||
|
||||
def get_decryption_key(self, pssh: str, track_id: str) -> str:
|
||||
pssh_obj = PSSH(pssh.split(",")[-1])
|
||||
cdm_session = self.cdm.open()
|
||||
challenge = base64.b64encode(
|
||||
self.cdm.get_license_challenge(cdm_session, pssh_obj)
|
||||
).decode()
|
||||
license = self.apple_music_api.get_widevine_license(
|
||||
track_id,
|
||||
pssh,
|
||||
challenge,
|
||||
)
|
||||
self.cdm.parse_license(cdm_session, license)
|
||||
decryption_key = next(
|
||||
i for i in self.cdm.get_keys(cdm_session) if i.type == "CONTENT"
|
||||
).key.hex()
|
||||
self.cdm.close(cdm_session)
|
||||
try:
|
||||
pssh_obj = PSSH(pssh.split(",")[-1])
|
||||
cdm_session = self.cdm.open()
|
||||
challenge = base64.b64encode(
|
||||
self.cdm.get_license_challenge(cdm_session, pssh_obj)
|
||||
).decode()
|
||||
license = self.apple_music_api.get_widevine_license(
|
||||
track_id,
|
||||
pssh,
|
||||
challenge,
|
||||
)
|
||||
self.cdm.parse_license(cdm_session, license)
|
||||
decryption_key = next(
|
||||
i for i in self.cdm.get_keys(cdm_session) if i.type == "CONTENT"
|
||||
).key.hex()
|
||||
finally:
|
||||
self.cdm.close(cdm_session)
|
||||
return decryption_key
|
||||
|
||||
def download(self, path: Path, stream_url: str):
|
||||
@@ -350,9 +354,31 @@ class Downloader:
|
||||
]
|
||||
return self.output_path.joinpath(*final_path_folder).joinpath(*final_path_file)
|
||||
|
||||
def get_cover_file_extension(self, cover_url: str) -> str:
|
||||
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}")
|
||||
|
||||
def get_cover_url(self, metadata: dict) -> str:
|
||||
if self.cover_format == CoverFormat.RAW:
|
||||
return self._get_raw_cover_url(metadata["attributes"]["artwork"]["url"])
|
||||
return self._get_cover_url(metadata["attributes"]["artwork"]["url"])
|
||||
|
||||
def _get_raw_cover_url(self, cover_url_template: str) -> str:
|
||||
return re.sub(
|
||||
r"image/thumb/",
|
||||
"",
|
||||
re.sub(
|
||||
r"is1-ssl",
|
||||
"a1",
|
||||
re.sub(
|
||||
r"/\{w\}x\{h\}([a-z]{2})\.jpg",
|
||||
"",
|
||||
cover_url_template,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def _get_cover_url(self, cover_url_template: str) -> str:
|
||||
return re.sub(
|
||||
r"\{w\}x\{h\}([a-z]{2})\.jpg",
|
||||
@@ -363,7 +389,9 @@ class Downloader:
|
||||
@staticmethod
|
||||
@functools.lru_cache()
|
||||
def get_url_response_bytes(url: str) -> bytes:
|
||||
return requests.get(url).content
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
def apply_tags(
|
||||
self,
|
||||
@@ -401,7 +429,10 @@ class Downloader:
|
||||
and tags.get(tag_name) is not None
|
||||
):
|
||||
mp4_tags[MP4_TAGS_MAP[tag_name]] = [tags[tag_name]]
|
||||
if "cover" not in self.exclude_tags_list:
|
||||
if (
|
||||
"cover" not in self.exclude_tags_list
|
||||
and self.cover_format != CoverFormat.RAW
|
||||
):
|
||||
mp4_tags["covr"] = [
|
||||
MP4Cover(
|
||||
self.get_url_response_bytes(cover_url),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
import m3u8
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
@@ -15,7 +15,7 @@ from .models import StreamInfo
|
||||
|
||||
|
||||
class DownloaderMusicVideo:
|
||||
MP4_FORMAT_CODECS = ["hvc1", "ec-3"]
|
||||
MP4_FORMAT_CODECS = ["hvc1", "audio-atmos", "audio-ec3"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -106,6 +106,7 @@ class DownloaderMusicVideo:
|
||||
value=playlist,
|
||||
)
|
||||
for playlist in playlists
|
||||
if playlist.get("uri")
|
||||
]
|
||||
selected = inquirer.select(
|
||||
message="Select which audio codec to download:",
|
||||
@@ -142,9 +143,7 @@ class DownloaderMusicVideo:
|
||||
else:
|
||||
playlist = self.get_playlist_audio_from_user(m3u8_master_data["media"])
|
||||
stream_info.stream_url = playlist["uri"]
|
||||
stream_info.codec = re.search(r"_([^_]+)\.m3u8", stream_info.stream_url).group(
|
||||
1
|
||||
)
|
||||
stream_info.codec = playlist["group_id"]
|
||||
m3u8_data = m3u8.load(stream_info.stream_url).data
|
||||
stream_info.pssh = self.get_pssh(m3u8_data)
|
||||
return stream_info
|
||||
@@ -154,41 +153,35 @@ class DownloaderMusicVideo:
|
||||
|
||||
def get_tags(
|
||||
self,
|
||||
id_alt: str,
|
||||
itunes_page: dict,
|
||||
m3u8_master_data: dict,
|
||||
metadata: dict,
|
||||
):
|
||||
metadata_itunes = self.downloader.itunes_api.get_resource(id_alt)
|
||||
tags = {
|
||||
"artist": metadata["attributes"]["artistName"],
|
||||
"artist_id": int(itunes_page["artistId"]),
|
||||
"copyright": itunes_page["copyright"],
|
||||
"date": next(
|
||||
(
|
||||
session_data
|
||||
for session_data in m3u8_master_data["session_data"]
|
||||
if session_data["data_id"] == "com.apple.hls.release-date"
|
||||
),
|
||||
None,
|
||||
)["value"],
|
||||
"genre": metadata["attributes"]["genreNames"][0],
|
||||
"artist": metadata_itunes[0]["artistName"],
|
||||
"artist_id": int(metadata_itunes[0]["artistId"]),
|
||||
"copyright": itunes_page.get("copyright"),
|
||||
"date": self.downloader.sanitize_date(metadata_itunes[0]["releaseDate"]),
|
||||
"genre": metadata_itunes[0]["primaryGenreName"],
|
||||
"genre_id": int(itunes_page["genres"][0]["genreId"]),
|
||||
"media_type": 6,
|
||||
"title": metadata["attributes"]["name"],
|
||||
"storefront": int(self.downloader.itunes_api.storefront_id.split("-")[0]),
|
||||
"title": metadata_itunes[0]["trackCensoredName"],
|
||||
"title_id": int(metadata["id"]),
|
||||
}
|
||||
if metadata["attributes"].get("contentRating") == "clean":
|
||||
tags["rating"] = 2
|
||||
elif metadata["attributes"].get("contentRating") == "explicit":
|
||||
if metadata_itunes[0]["trackExplicitness"] == "notExplicit":
|
||||
tags["rating"] = 0
|
||||
elif metadata_itunes[0]["trackExplicitness"] == "explicit":
|
||||
tags["rating"] = 1
|
||||
else:
|
||||
tags["rating"] = 0
|
||||
if itunes_page.get("collectionId"):
|
||||
metadata_itunes = self.downloader.itunes_api.get_resource(itunes_page["id"])
|
||||
tags["rating"] = 2
|
||||
if len(metadata_itunes) > 1:
|
||||
album = self.downloader.apple_music_api.get_album(
|
||||
itunes_page["collectionId"]
|
||||
)
|
||||
tags["album"] = album["attributes"]["name"]
|
||||
tags["album_artist"] = album["attributes"]["artistName"]
|
||||
tags["album"] = metadata_itunes[1]["collectionCensoredName"]
|
||||
tags["album_artist"] = metadata_itunes[1]["artistName"]
|
||||
tags["album_id"] = int(itunes_page["collectionId"])
|
||||
tags["disc"] = metadata_itunes[0]["discNumber"]
|
||||
tags["disc_total"] = metadata_itunes[0]["discCount"]
|
||||
@@ -307,5 +300,5 @@ class DownloaderMusicVideo:
|
||||
codec_audio,
|
||||
)
|
||||
|
||||
def get_cover_path(self, final_path: Path) -> Path:
|
||||
return final_path.with_suffix(f".{self.downloader.cover_format.value}")
|
||||
def get_cover_path(self, final_path: Path, file_extension: str) -> Path:
|
||||
return final_path.with_suffix(file_extension)
|
||||
|
||||
@@ -67,7 +67,8 @@ class DownloaderPost:
|
||||
"date": self.downloader.sanitize_date(attributes["uploadDate"]),
|
||||
"title": attributes["name"],
|
||||
"title_id": int(metadata["id"]),
|
||||
"storefront": int(self.downloader.itunes_api.storefront_id.split("-")[0]),
|
||||
}
|
||||
|
||||
def get_temp_path(self, track_id: str) -> Path:
|
||||
def get_post_temp_path(self, track_id: str) -> Path:
|
||||
return self.downloader.temp_path / f"{track_id}_temp.m4v"
|
||||
|
||||
@@ -9,9 +9,9 @@ from pathlib import Path
|
||||
from xml.dom import minidom
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import m3u8
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
import m3u8
|
||||
|
||||
from .constants import SONG_CODEC_REGEX_MAP, SYNCED_LYRICS_FILE_EXTENSION_MAP
|
||||
from .downloader import Downloader
|
||||
@@ -365,8 +365,8 @@ class DownloaderSong:
|
||||
SYNCED_LYRICS_FILE_EXTENSION_MAP[self.synced_lyrics_format]
|
||||
)
|
||||
|
||||
def get_cover_path(self, final_path: Path) -> Path:
|
||||
return final_path.parent / f"Cover.{self.downloader.cover_format.value}"
|
||||
def get_cover_path(self, final_path: Path, file_extension: str) -> Path:
|
||||
return final_path.parent / ("Cover" + file_extension)
|
||||
|
||||
def save_lyrics_synced(self, lyrics_synced_path: Path, lyrics_synced: str):
|
||||
lyrics_synced_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -28,24 +28,28 @@ class DownloaderSongLegacy(DownloaderSong):
|
||||
return stream_info
|
||||
|
||||
def get_decryption_key(self, pssh: str, track_id: str) -> str:
|
||||
widevine_pssh_data = WidevinePsshData()
|
||||
widevine_pssh_data.algorithm = 1
|
||||
widevine_pssh_data.key_ids.append(base64.b64decode(pssh.split(",")[1]))
|
||||
pssh_obj = PSSH(widevine_pssh_data.SerializeToString())
|
||||
cdm_session = self.downloader.cdm.open()
|
||||
challenge = base64.b64encode(
|
||||
self.downloader.cdm.get_license_challenge(cdm_session, pssh_obj)
|
||||
).decode()
|
||||
license = self.downloader.apple_music_api.get_widevine_license(
|
||||
track_id,
|
||||
pssh,
|
||||
challenge,
|
||||
)
|
||||
self.downloader.cdm.parse_license(cdm_session, license)
|
||||
decryption_key = next(
|
||||
i for i in self.downloader.cdm.get_keys(cdm_session) if i.type == "CONTENT"
|
||||
).key.hex()
|
||||
self.downloader.cdm.close(cdm_session)
|
||||
try:
|
||||
widevine_pssh_data = WidevinePsshData()
|
||||
widevine_pssh_data.algorithm = 1
|
||||
widevine_pssh_data.key_ids.append(base64.b64decode(pssh.split(",")[1]))
|
||||
pssh_obj = PSSH(widevine_pssh_data.SerializeToString())
|
||||
cdm_session = self.downloader.cdm.open()
|
||||
challenge = base64.b64encode(
|
||||
self.downloader.cdm.get_license_challenge(cdm_session, pssh_obj)
|
||||
).decode()
|
||||
license = self.downloader.apple_music_api.get_widevine_license(
|
||||
track_id,
|
||||
pssh,
|
||||
challenge,
|
||||
)
|
||||
self.downloader.cdm.parse_license(cdm_session, license)
|
||||
decryption_key = next(
|
||||
i
|
||||
for i in self.downloader.cdm.get_keys(cdm_session)
|
||||
if i.type == "CONTENT"
|
||||
).key.hex()
|
||||
finally:
|
||||
self.downloader.cdm.close(cdm_session)
|
||||
return decryption_key
|
||||
|
||||
def decrypt(
|
||||
@@ -54,7 +58,6 @@ class DownloaderSongLegacy(DownloaderSong):
|
||||
decrypted_path: Path,
|
||||
decryption_key: str,
|
||||
):
|
||||
self.fix_key_id(encrypted_path)
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.mp4decrypt_path_full,
|
||||
|
||||
@@ -46,3 +46,4 @@ class PostQuality(Enum):
|
||||
class CoverFormat(Enum):
|
||||
JPG = "jpg"
|
||||
PNG = "png"
|
||||
RAW = "raw"
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "gamdl"
|
||||
description = "A Python CLI app for downloading Apple Music songs/music videos/albums/playlists/posts."
|
||||
description = "A Python CLI app for downloading Apple Music songs/music videos/posts."
|
||||
requires-python = ">=3.8"
|
||||
authors = [{ name = "glomatico" }]
|
||||
dependencies = [
|
||||
@@ -8,6 +8,7 @@ dependencies = [
|
||||
"click",
|
||||
"inquirerpy",
|
||||
"m3u8",
|
||||
"pillow",
|
||||
"pywidevine",
|
||||
"pyyaml",
|
||||
"yt-dlp",
|
||||
|
||||
@@ -2,6 +2,7 @@ ciso8601
|
||||
click
|
||||
inquirerpy
|
||||
m3u8
|
||||
pillow
|
||||
pywidevine
|
||||
pyyaml
|
||||
yt-dlp
|
||||
|
||||
Reference in New Issue
Block a user