Compare commits

...

71 Commits

Author SHA1 Message Date
alacleaker 345afbf174 adjust lyrics checking 2024-04-16 21:19:45 -03:00
alacleaker c35051a7ec bump version 2024-04-16 21:13:48 -03:00
alacleaker b286ee84e2 add from __future__ import annotations 2024-04-16 21:12:29 -03:00
alacleaker 9094f2c7b4 remove -> None 2024-04-16 21:09:04 -03:00
alacleaker 5feb5b274a remove "-isma" 2024-04-16 21:07:08 -03:00
alacleaker 1375af929c add keep utc to remux mp4box 2024-04-16 00:37:54 -03:00
alacleaker d280f1fad2 Merge branch 'main' of https://github.com/glomatico/gamdl 2024-04-15 10:43:45 -03:00
alacleaker 3a04d7927e adjust get_lyrics 2024-04-15 10:43:37 -03:00
Rafael Moraes 942a812308 Update README.md 2024-04-14 13:42:48 -03:00
Rafael Moraes 66e01293e6 Update README.md 2024-04-14 13:26:03 -03:00
alacleaker c0561da592 Update README.md 2024-04-13 02:58:04 -03:00
alacleaker 56d238fb1b update description 2024-04-13 02:58:01 -03:00
alacleaker d3a53bf93b Update README.md 2024-04-12 18:15:56 -03:00
alacleaker bb7a3ff77e Update README.md 2024-04-12 18:14:55 -03:00
alacleaker bf6293a0a0 Update README.md 2024-04-12 18:12:05 -03:00
alacleaker c421b3e855 fix incorrect spacing on synced lyrics 2024-04-12 18:05:04 -03:00
alacleaker 7e495300f9 bump version 2024-04-12 08:30:09 -03:00
alacleaker ccef00e39f add additional lyrics presence check 2024-04-12 07:14:53 -03:00
alacleaker 94cdba313c add missing subprocess_additional_args 2024-04-11 07:08:47 -03:00
alacleaker d7b19e8c67 add cookies file not found error 2024-04-11 07:07:31 -03:00
alacleaker a6809df2ef update description 2024-04-11 07:01:37 -03:00
alacleaker 40f3616bc3 Update README.md 2024-04-11 06:59:14 -03:00
alacleaker 104100e091 Update README.md 2024-04-11 06:58:36 -03:00
alacleaker ce4a7d7880 Update README.md 2024-04-11 06:52:15 -03:00
alacleaker ec09bacd39 Update README.md 2024-04-11 06:49:32 -03:00
alacleaker e0d3f46159 Update README.md 2024-04-10 19:10:27 -03:00
alacleaker 0bfb4d80b8 Update README.md 2024-04-10 19:06:03 -03:00
alacleaker 54d6d93967 Update README.md 2024-04-10 19:03:15 -03:00
alacleaker c13160b999 Update README.md 2024-04-10 19:02:34 -03:00
alacleaker ea4d574810 Update README.md 2024-04-10 18:57:20 -03:00
alacleaker 9da35c3f57 update --language help 2024-04-10 18:57:17 -03:00
alacleaker ebb7ec1da7 Update README.md 2024-04-10 18:55:27 -03:00
alacleaker d155a42e3a update --language help 2024-04-10 18:54:20 -03:00
alacleaker 8f18562e1c bump version 2024-04-10 17:07:50 -03:00
alacleaker 25ed506b82 add language option 2024-04-10 17:07:17 -03:00
alacleaker ba76241032 add silent and subprocess_additional_args 2024-04-10 16:51:15 -03:00
alacleaker c6f7e99135 add ac3 2024-04-09 14:43:35 -03:00
alacleaker dbaa1faa6b Merge branch 'main' of https://github.com/glomatico/gamdl 2024-04-09 14:40:32 -03:00
alacleaker a61a9c4975 catch abort exception when getting codec from user 2024-04-09 14:40:30 -03:00
Rafael Moraes 8ffe5c86ca Update FUNDING.yml 2024-04-09 13:20:03 -03:00
alacleaker 88bdf64825 Merge branch 'main' of https://github.com/glomatico/gamdl 2024-04-09 13:16:54 -03:00
alacleaker 3849df9adb add missing sanitize_date 2024-04-09 13:16:46 -03:00
Rafael Moraes a989ff6c34 Merge pull request #96 from glomatico/glomatico-patch-1
Create FUNDING.yml
2024-04-09 13:12:05 -03:00
Rafael Moraes f3d583aab2 Create FUNDING.yml 2024-04-09 13:11:53 -03:00
alacleaker 7ac3d3e400 bump version 2024-04-09 12:47:25 -03:00
alacleaker 085e8f1b5d check if m3u8 url exists 2024-04-09 12:47:11 -03:00
alacleaker 4df36e60d9 add from __future__ import annotations 2024-04-09 12:27:40 -03:00
alacleaker f6d726e466 bump version 2024-04-09 07:16:16 -03:00
alacleaker 61b1bf1e55 fix song not downloadable detection 2024-04-09 07:16:07 -03:00
alacleaker 3ae6709ccb bump version 2024-04-09 06:54:07 -03:00
alacleaker 1f00e4fb9f adjust non-legacy message 2024-04-09 06:53:53 -03:00
alacleaker 714d47bb13 Update README.md 2024-04-09 06:52:01 -03:00
alacleaker 46e3a92d4f adjust non-legacy warn 2024-04-09 06:51:56 -03:00
alacleaker 42b536d271 warn about non-legacy 2024-04-09 06:49:43 -03:00
alacleaker dac8d5eed9 add LEGACY_CODECS 2024-04-09 06:49:32 -03:00
alacleaker 2956f20dfa Update README.md 2024-04-08 21:18:53 -03:00
alacleaker 8f76743a3b Update README.md 2024-04-08 21:13:17 -03:00
alacleaker 3096bbc79d adjust song is not available message 2024-04-08 21:06:57 -03:00
alacleaker ed49d7bd5f adjust song is not available message 2024-04-08 21:06:47 -03:00
alacleaker 0ea72d0b78 adjust song is not available message 2024-04-08 21:06:37 -03:00
alacleaker ae490320ad bump version 2024-04-08 21:04:57 -03:00
alacleaker e40668e6ec adjust song is not available message 2024-04-08 21:04:22 -03:00
alacleaker 62c695b5ff bump version 2024-04-08 19:50:50 -03:00
alacleaker 5d9c8c1f0b add from __future__ import annotations 2024-04-08 19:50:39 -03:00
alacleaker 54d640230a bump version 2024-04-08 18:25:10 -03:00
alacleaker 3d272a6891 use m4v with ffmpeg when compatible codecs 2024-04-08 17:29:45 -03:00
alacleaker e99ed0eb5a fix wrong format and rename to use_mp4_format 2024-04-08 17:00:20 -03:00
alacleaker 86b5029773 add stream info codec to remux 2024-04-08 16:59:44 -03:00
alacleaker 3df0a91d3f adjust mp4 flag codecs 2024-04-08 16:54:17 -03:00
alacleaker d356596cf4 add codec to StreamInfo 2024-04-08 16:54:03 -03:00
alacleaker cbd2df79b7 add -f mp4 for atmos 2024-04-08 16:46:36 -03:00
13 changed files with 220 additions and 127 deletions
+1
View File
@@ -0,0 +1 @@
ko_fi: glomatico
+60 -48
View File
@@ -1,5 +1,7 @@
# Glomatico's Apple Music Downloader
A Python script to download Apple Music songs/music videos/albums/playlists/post videos.
A Python CLI app for downloading Apple Music songs/music videos/albums/playlists/posts.
**Discord Server:** https://discord.gg/aBjMEZ9tnq
## Features
* Download songs in AAC/Spatial AAC/Dolby Atmos/ALAC*
@@ -29,57 +31,64 @@ A Python script to download Apple Music songs/music videos/albums/playlists/post
```bash
pip install gamdl
```
2. Place your cookies in the same directory you will run the script from and name it as `cookies.txt`
2. Place your cookies file in the directory from which you will be running gamdl and name it `cookies.txt`.
## Usage
```bash
gamdl [OPTIONS] URLS...
```
### Examples
* Download a song
```bash
gamdl "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1626265761?i=1626265765"
gamdl "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
```
* Download an album
```bash
gamdl "https://music.apple.com/us/album/whenever-you-need-somebody-2022-remaster/1626265761"
gamdl "https://music.apple.com/us/album/whenever-you-need-somebody-2022-remaster/1624945511"
```
## 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. | `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` |
| `--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-best` |
| `--quality-post` / `quality_post` | Post video quality. | `best` |
| `--no-config-file`, `-n` / - | Do not use a config file. | `false` |
| 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. | `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-best` |
| `--quality-post` / `quality_post` | Post video quality. | `best` |
| `--no-config-file`, `-n` / - | Do not use a config file. | `false` |
### Tags variables
The following variables can be used in the template folders/files and/or in the `exclude_tags` list:
@@ -142,23 +151,26 @@ The following codecs are available:
* `aac-downmix`
* `aac-he-binaural`
* `aac-he-downmix`
* `alac`
* `atmos`
* `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 decrypted when using non-legacy codecs.**
**Support for non-legacy codecs are not guaranteed, as most of the songs cannot be downloaded when using non-legacy codecs.**
### Music videos codecs
The following codecs are available:
* `h264-best` (with AAC 256kbps, up to 1080p)
* `h265-best` (With AAC 256kpbs, up to 2160p)
* `h264-best` (up to 1080p, with AAC 256kbps)
* `h265-best` (up to 2160p, with AAC 256kpbs)
* `ask`
* When using this option, the script will ask you which audio and video codec to use.
* When using this option, gamdl will ask you which audio and video codec to use that is available for the music video.
### Post videos/extra videos qualities
The following qualities are available:
* `best` (with AAC 256kbps, up to 1080p)
* `best` (up to 1080p, with AAC 256kbps)
* `ask`
* When using this option, the script will ask you which video quality to use.
* 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.
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.0"
__version__ = "2.1.8"
+33 -18
View File
@@ -35,7 +35,7 @@ def get_param_string(param: click.Parameter) -> str:
return param.default
def write_default_config_file(ctx: click.Context) -> None:
def write_default_config_file(ctx: click.Context):
ctx.params["config_path"].parent.mkdir(parents=True, exist_ok=True)
config_file = {
param.name: get_param_string(param)
@@ -132,6 +132,13 @@ def load_config_file(
default=apple_music_api_sig.parameters["cookies_path"].default,
help="Path to .txt cookies file.",
)
@click.option(
"--language",
"-l",
type=str,
default=apple_music_api_sig.parameters["language"].default,
help="Metadata language as an ISO-2A language code (don't always work for videos).",
)
# Downloader specific options
@click.option(
"--output-path",
@@ -301,6 +308,7 @@ def main(
log_level: str,
print_exceptions: bool,
cookies_path: Path,
language: str,
output_path: Path,
temp_path: Path,
wvd_path: Path,
@@ -334,7 +342,13 @@ def main(
logger = logging.getLogger(__name__)
logger.setLevel(log_level)
logger.debug("Starting downloader")
apple_music_api = AppleMusicApi(cookies_path)
if not cookies_path.exists():
logger.critical(X_NOT_FOUND_STRING.format("Cookies file", cookies_path))
return
apple_music_api = AppleMusicApi(
cookies_path,
language=language,
)
itunes_api = ItunesApi(
apple_music_api.storefront,
apple_music_api.language,
@@ -419,6 +433,11 @@ def main(
skip_mv = True
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."
)
error_count = 0
if read_urls_as_txt:
urls = [url.strip() for url in Path(urls[0]).read_text().splitlines()]
@@ -477,10 +496,7 @@ def main(
f'({queue_progress}) Song already exists at "{final_path}", skipping'
)
else:
if codec_song in (
SongCodec.AAC_LEGACY,
SongCodec.AAC_HE_LEGACY,
):
if codec_song in LEGACY_CODECS:
logger.debug("Getting stream info")
stream_info = downloader_song_legacy.get_stream_info(
webplayback
@@ -491,14 +507,10 @@ def main(
)
else:
stream_info = downloader_song.get_stream_info(track)
if not stream_info.pssh:
if not stream_info.stream_url or not stream_info.pssh:
logger.warning(
f"({queue_progress}) Song does not contain Widevine DRM, skipping"
)
continue
elif not stream_info.stream_url:
logger.warning(
f"({queue_progress}) Song is not available with the selected codec, skipping"
f"({queue_progress}) Song is not downloadable or is not"
" available in the chosen codec, skipping"
)
continue
logger.debug("Getting decryption key")
@@ -510,10 +522,7 @@ def main(
remuxed_path = downloader_song.get_remuxed_path(track["id"])
logger.debug(f"Downloading to {encrypted_path}")
downloader.download(encrypted_path, stream_info.stream_url)
if codec_song in (
SongCodec.AAC_LEGACY,
SongCodec.AAC_HE_LEGACY,
):
if codec_song in LEGACY_CODECS:
logger.debug(f"Remuxing/Decrypting to {remuxed_path}")
downloader_song_legacy.remux(
encrypted_path,
@@ -527,7 +536,11 @@ def main(
encrypted_path, decrypted_path, decryption_key
)
logger.debug(f"Remuxing to {final_path}")
downloader_song.remux(decrypted_path, remuxed_path)
downloader_song.remux(
decrypted_path,
remuxed_path,
stream_info.codec,
)
logger.debug("Applying tags")
downloader.apply_tags(remuxed_path, tags, cover_url)
logger.debug(f"Moving to {final_path}")
@@ -635,6 +648,8 @@ def main(
decrypted_path_video,
decrypted_path_audio,
remuxed_path,
stream_info_video.codec,
stream_info_audio.codec,
)
logger.debug("Applying tags")
downloader.apply_tags(remuxed_path, tags, cover_url)
+6 -2
View File
@@ -191,8 +191,9 @@ SONG_CODEC_REGEX_MAP = {
SongCodec.AAC_DOWNMIX: r"audio-stereo-\d+-downmix",
SongCodec.AAC_HE_BINAURAL: r"audio-HE-stereo-\d+-binaural",
SongCodec.AAC_HE_DOWNMIX: r"audio-HE-stereo-\d+-downmix",
SongCodec.ALAC: r"audio-alac-.*",
SongCodec.ATMOS: r"audio-atmos-.*",
SongCodec.AC3: r"audio-ac3-.*",
SongCodec.ALAC: r"audio-alac-.*",
}
MUSIC_VIDEO_CODEC_MAP = {
@@ -218,4 +219,7 @@ EXCLUDED_CONFIG_FILE_PARAMS = (
X_NOT_FOUND_STRING = '{} not found at "{}"'
AMP_API_HOSTNAME = "https://amp-api.music.apple.com"
LEGACY_CODECS = [
SongCodec.AAC_LEGACY,
SongCodec.AAC_HE_LEGACY,
]
+16 -11
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import base64
import functools
import re
@@ -46,7 +48,7 @@ class Downloader:
exclude_tags: str = None,
cover_size: int = 1200,
truncate: int = 40,
no_progress: bool = False,
silent: bool = False,
):
self.apple_music_api = apple_music_api
self.itunes_api = itunes_api
@@ -70,10 +72,11 @@ class Downloader:
self.exclude_tags = exclude_tags
self.cover_size = cover_size
self.truncate = truncate
self.no_progress = no_progress
self.silent = silent
self._set_binaries_path_full()
self._set_exclude_tags_list()
self._set_truncate()
self._set_subprocess_additional_args()
def _set_binaries_path_full(self):
self.nm3u8dlre_path_full = shutil.which(self.nm3u8dlre_path)
@@ -91,6 +94,15 @@ class Downloader:
def _set_truncate(self):
self.truncate = None if self.truncate < 4 else self.truncate
def _set_subprocess_additional_args(self):
if self.silent:
self.subprocess_additional_args = {
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
}
else:
self.subprocess_additional_args = {}
def set_cdm(self):
if self.wvd_path:
self.cdm = Cdm.from_device(Device.load(self.wvd_path))
@@ -181,19 +193,12 @@ class Downloader:
"allow_unplayable_formats": True,
"fixup": "never",
"allowed_extractors": ["generic"],
"noprogress": self.no_progress,
"noprogress": self.silent,
}
) as ydl:
ydl.download(stream_url)
def download_nm3u8dlre(self, path: Path, stream_url: str):
if self.no_progress:
subprocess_additional_args = {
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
}
else:
subprocess_additional_args = {}
path.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
[
@@ -213,7 +218,7 @@ class Downloader:
path.parent,
],
check=True,
**subprocess_additional_args,
**self.subprocess_additional_args,
)
def get_sanitized_string(self, dirty_string: str, is_folder: bool) -> str:
+42 -26
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
import re
import subprocess
import urllib.parse
from pathlib import Path
@@ -13,6 +16,8 @@ from .models import StreamInfo
class DownloaderMusicVideo:
MP4_FORMAT_CODECS = ["hvc1", "ec-3"]
def __init__(
self,
downloader: Downloader,
@@ -33,10 +38,10 @@ class DownloaderMusicVideo:
).geturl()
return m3u8.load(stream_url_master_new).data
def get_stream_url_video(
def get_playlist_video(
self,
playlists: list[dict],
):
) -> dict:
playlists_filtered = [
playlist
for playlist in playlists
@@ -53,12 +58,12 @@ class DownloaderMusicVideo:
)
]
playlists_filtered.sort(key=lambda x: x["stream_info"]["bandwidth"])
return playlists_filtered[-1]["uri"]
return playlists_filtered[-1]
def get_stream_url_video_from_user(
def get_playlist_video_from_user(
self,
playlists: list[dict],
):
) -> dict:
table = [
[
i,
@@ -76,12 +81,12 @@ class DownloaderMusicVideo:
)
except click.exceptions.Abort:
raise KeyboardInterrupt()
return playlists[choice]["uri"]
return playlists[choice]
def get_stream_url_audio(
def get_playlist_audio(
self,
playlists: list[dict],
) -> str:
) -> dict:
stream_url = next(
(
playlist
@@ -89,13 +94,13 @@ class DownloaderMusicVideo:
if playlist["group_id"] == "audio-stereo-256"
),
None,
)["uri"]
)
return stream_url
def get_stream_url_audio_from_user(
def get_playlist_audio_from_user(
self,
playlists: list[dict],
):
) -> dict:
table = [
[
i,
@@ -113,7 +118,7 @@ class DownloaderMusicVideo:
)
except click.exceptions.Abort:
raise KeyboardInterrupt()
return playlists[choice]["uri"]
return playlists[choice]
def get_pssh(self, m3u8_data: dict):
return next(
@@ -128,13 +133,11 @@ class DownloaderMusicVideo:
def get_stream_info_video(self, m3u8_master_data: dict) -> StreamInfo:
stream_info = StreamInfo()
if self.codec != MusicVideoCodec.ASK:
stream_info.stream_url = self.get_stream_url_video(
m3u8_master_data["playlists"]
)
playlist = self.get_playlist_video(m3u8_master_data["playlists"])
else:
stream_info.stream_url = self.get_stream_url_video_from_user(
m3u8_master_data["playlists"]
)
playlist = self.get_playlist_video_from_user(m3u8_master_data["playlists"])
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)
return stream_info
@@ -142,13 +145,13 @@ class DownloaderMusicVideo:
def get_stream_info_audio(self, m3u8_master_data: dict) -> StreamInfo:
stream_info = StreamInfo()
if self.codec != MusicVideoCodec.ASK:
stream_info.stream_url = self.get_stream_url_audio(
m3u8_master_data["media"]
)
playlist = self.get_playlist_audio(m3u8_master_data["media"])
else:
stream_info.stream_url = self.get_stream_url_audio_from_user(
m3u8_master_data["media"]
)
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
)
m3u8_data = m3u8.load(stream_info.stream_url).data
stream_info.pssh = self.get_pssh(m3u8_data)
return stream_info
@@ -226,6 +229,7 @@ class DownloaderMusicVideo:
decrypted_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_mp4box(
@@ -233,7 +237,7 @@ class DownloaderMusicVideo:
decrypted_path_audio: Path,
decrypted_path_video: Path,
fixed_path: Path,
) -> None:
):
subprocess.run(
[
self.downloader.mp4box_path_full,
@@ -244,10 +248,12 @@ class DownloaderMusicVideo:
decrypted_path_video,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
fixed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_ffmpeg(
@@ -255,7 +261,12 @@ class DownloaderMusicVideo:
decrypted_path_video: Path,
decrypte_path_audio: Path,
fixed_path: Path,
codec_video: str,
codec_audio: str,
):
use_mp4_flag = any(
codec_video.startswith(codec) for codec in self.MP4_FORMAT_CODECS
) or any(codec_audio.startswith(codec) for codec in self.MP4_FORMAT_CODECS)
subprocess.run(
[
self.downloader.ffmpeg_path_full,
@@ -269,7 +280,7 @@ class DownloaderMusicVideo:
"-movflags",
"+faststart",
"-f",
"mp4",
"mp4" if use_mp4_flag else "ipod",
"-c",
"copy",
"-c:s",
@@ -277,6 +288,7 @@ class DownloaderMusicVideo:
fixed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux(
@@ -284,6 +296,8 @@ class DownloaderMusicVideo:
decrypted_path_video: Path,
decrypted_path_audio: Path,
remuxed_path: Path,
codec_video: str,
codec_audio: str,
):
if self.downloader.remux_mode == RemuxMode.MP4BOX:
self.remux_mp4box(
@@ -296,6 +310,8 @@ class DownloaderMusicVideo:
decrypted_path_video,
decrypted_path_audio,
remuxed_path,
codec_video,
codec_audio,
)
def get_cover_path(self, final_path: Path) -> Path:
+10 -5
View File
@@ -1,9 +1,11 @@
from __future__ import annotations
from pathlib import Path
import click
from tabulate import tabulate
from .downloader import Downloader
from tabulate import tabulate
from .enums import PostQuality
@@ -46,9 +48,12 @@ class DownloaderPost:
)
]
print(tabulate(table))
choice = (
click.prompt("Choose a quality", type=click.IntRange(1, len(table))) - 1
)
try:
choice = (
click.prompt("Choose a quality", type=click.IntRange(1, len(table))) - 1
)
except click.exceptions.Abort:
raise KeyboardInterrupt()
return metadata["attributes"]["assetTokens"][qualities[choice]]
def get_stream_url(self, metadata: dict) -> str:
@@ -62,7 +67,7 @@ class DownloaderPost:
attributes = metadata["attributes"]
return {
"artist": attributes["artistName"],
"date": attributes["uploadDate"],
"date": self.downloader.sanitize_date(attributes["uploadDate"]),
"title": attributes["name"],
"title_id": int(metadata["id"]),
}
+40 -13
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import base64
import datetime
import json
@@ -19,6 +21,7 @@ from .models import Lyrics, StreamInfo
class DownloaderSong:
DEFAULT_DECRYPTION_KEY = "32b8ade1769e26b1ffb8986352793fc6"
MP4_FORMAT_CODECS = ["ec-3"]
def __init__(
self,
@@ -40,7 +43,7 @@ class DownloaderSong:
None,
)
if not drm_info_raw:
raise Exception("DRM info not found")
return None
return json.loads(base64.b64decode(drm_info_raw["value"]).decode("utf-8"))
def get_asset_infos(self, m3u8_data: dict) -> dict:
@@ -103,7 +106,9 @@ class DownloaderSong:
return drm_info["urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"]["URI"]
def get_stream_info(self, track_metadata: dict) -> StreamInfo:
m3u8_url = track_metadata["attributes"]["extendedAssetUrls"]["enhancedHls"]
m3u8_url = track_metadata["attributes"]["extendedAssetUrls"].get("enhancedHls")
if not m3u8_url:
return StreamInfo()
return self._get_stream_info(m3u8_url)
def _get_stream_info(self, m3u8_url: str) -> StreamInfo:
@@ -111,6 +116,8 @@ class DownloaderSong:
m3u8_obj = m3u8.load(m3u8_url)
m3u8_data = m3u8_obj.data
drm_infos = self.get_drm_infos(m3u8_data)
if not drm_infos:
return stream_info
asset_infos = self.get_asset_infos(m3u8_data)
if self.codec == SongCodec.ASK:
playlist = self.get_playlist_from_user(m3u8_data)
@@ -123,6 +130,7 @@ class DownloaderSong:
drm_ids = asset_infos[variant_id]["AUDIO-SESSION-KEY-IDS"]
pssh = self.get_pssh(drm_infos, drm_ids)
stream_info.pssh = pssh
stream_info.codec = playlist["stream_info"]["codecs"]
return stream_info
@staticmethod
@@ -170,20 +178,24 @@ class DownloaderSong:
return f"{index}\n{timestamp_srt_start} --> {timestamp_srt_end}\n{text}\n"
def get_lyrics(self, track_metadata: dict) -> Lyrics:
lyrics = Lyrics()
if not track_metadata["attributes"]["hasLyrics"]:
return Lyrics()
return lyrics
elif track_metadata.get("relationships") is None:
track_metadata = self.downloader.apple_music_api.get_song(
track_metadata["id"]
)
if track_metadata["relationships"]["lyrics"]["data"]:
return self._get_lyrics(
if (
track_metadata["relationships"].get("lyrics")
and track_metadata["relationships"]["lyrics"].get("data")
and track_metadata["relationships"]["lyrics"]["data"][0].get("attributes")
):
lyrics = self._get_lyrics(
track_metadata["relationships"]["lyrics"]["data"][0]["attributes"][
"ttml"
]
)
else:
return Lyrics()
return lyrics
def _get_lyrics(self, lyrics_ttml: str) -> Lyrics:
lyrics = Lyrics("", "")
@@ -195,9 +207,9 @@ class DownloaderSong:
lyrics.unsynced += p.text + "\n"
if p.attrib.get("begin"):
if self.synced_lyrics_format == SyncedLyricsFormat.LRC:
lyrics.synced += f"{self.get_lyrics_synced_line_lrc(p.attrib.get('begin'), p.text)}\n"
lyrics.synced += f"{self.get_lyrics_synced_line_lrc(p.attrib.get('begin'), p.text)}"
elif self.synced_lyrics_format == SyncedLyricsFormat.SRT:
lyrics.synced += f"{self.get_lyrics_synced_line_srt(index, p.attrib.get('begin'), p.attrib.get('end'), p.text)}\n"
lyrics.synced += f"{self.get_lyrics_synced_line_srt(index, p.attrib.get('begin'), p.attrib.get('end'), p.text)}"
elif self.synced_lyrics_format == SyncedLyricsFormat.TTML:
if not lyrics.synced:
lyrics.synced = minidom.parseString(
@@ -292,15 +304,16 @@ class DownloaderSong:
decrypted_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux(self, decrypted_path: Path, remuxed_path: Path) -> None:
def remux(self, decrypted_path: Path, remuxed_path: Path, codec: str):
if self.downloader.remux_mode == RemuxMode.MP4BOX:
self.remux_mp4box(decrypted_path, remuxed_path)
elif self.downloader.remux_mode == RemuxMode.FFMPEG:
self.remux_ffmpeg(decrypted_path, remuxed_path)
self.remux_ffmpeg(decrypted_path, remuxed_path, codec)
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path) -> None:
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path):
subprocess.run(
[
self.downloader.mp4box_path_full,
@@ -309,13 +322,24 @@ class DownloaderSong:
decrypted_path,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
remuxed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_ffmpeg(self, decrypted_path: Path, remuxed_path: Path) -> None:
def remux_ffmpeg(
self,
decrypted_path: Path,
remuxed_path: Path,
codec: str,
):
use_mp4_format = any(
codec.startswith(possible_codec)
for possible_codec in self.MP4_FORMAT_CODECS
)
subprocess.run(
[
self.downloader.ffmpeg_path_full,
@@ -326,11 +350,14 @@ class DownloaderSong:
decrypted_path,
"-c",
"copy",
"-f",
"mp4" if use_mp4_format else "ipod",
"-movflags",
"+faststart",
remuxed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def get_lyrics_synced_path(self, final_path: Path) -> Path:
+7 -1
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import base64
import subprocess
from pathlib import Path
@@ -62,9 +64,10 @@ class DownloaderSongLegacy(DownloaderSong):
decrypted_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path) -> None:
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path):
subprocess.run(
[
self.downloader.mp4box_path_full,
@@ -73,10 +76,12 @@ class DownloaderSongLegacy(DownloaderSong):
decrypted_path,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
remuxed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_ffmpeg(
@@ -102,6 +107,7 @@ class DownloaderSongLegacy(DownloaderSong):
remuxed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux(
+2 -1
View File
@@ -20,8 +20,9 @@ class SongCodec(Enum):
AAC_DOWNMIX = "aac-downmix"
AAC_HE_BINAURAL = "aac-he-binaural"
AAC_HE_DOWNMIX = "aac-he-downmix"
ALAC = "alac"
ATMOS = "atmos"
AC3 = "ac3"
ALAC = "alac"
ASK = "ask"
+1
View File
@@ -23,3 +23,4 @@ class Lyrics:
class StreamInfo:
stream_url: str = None
pssh: str = None
codec: str = None
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "gamdl"
description = "Download Apple Music songs/music videos/albums/playlists"
description = "A Python CLI app to download Apple Music songs/music videos/albums/playlists/posts."
requires-python = ">=3.8"
authors = [{ name = "glomatico" }]
dependencies = [