Compare commits

...

89 Commits

Author SHA1 Message Date
alacleaker 6d7cb3ada4 update description 2024-04-23 00:32:22 -03:00
alacleaker b87d406ffa update requirements 2024-04-23 00:31:40 -03:00
alacleaker f6efdb3332 Update README.md 2024-04-23 00:19:59 -03:00
alacleaker 29a006c304 update --read-urls-as-txt help 2024-04-23 00:19:16 -03:00
alacleaker a49a9c90cc read all url arguments instead of just the first when -r 2024-04-23 00:16:27 -03:00
alacleaker 43fd1dd2e3 Update README.md 2024-04-23 00:01:27 -03:00
alacleaker 2ed6ac05ba Update README.md 2024-04-23 00:00:25 -03:00
alacleaker 0f0e17f4cd adjust inquirer message 2024-04-22 23:56:36 -03:00
alacleaker 8c4d2713f7 switch from fstring to string 2024-04-22 23:37:42 -03:00
alacleaker 1baca4151b switch to InquirerPy in get_stream_url_from_user 2024-04-22 23:36:50 -03:00
alacleaker 6f08a4b2f9 adjust select_albums_from_artist 2024-04-22 23:12:56 -03:00
alacleaker 38f708e2e9 add validation to get_download_queue_from_artist 2024-04-22 23:11:25 -03:00
alacleaker f27adf98df add column indication when listing artist mvs/albums 2024-04-22 23:01:52 -03:00
alacleaker 9f0b25e1d1 add rating info when listing artist mvs 2024-04-22 22:57:27 -03:00
alacleaker 3590d99063 add rating info when listing artist albums 2024-04-22 22:55:18 -03:00
alacleaker b200dade5a change checking url log from debug to info 2024-04-22 21:12:23 -03:00
alacleaker 118d23e9db remove _best prefix from music_video_codec enum 2024-04-22 20:41:39 -03:00
alacleaker 7bc8c6668f bump version 2024-04-22 20:37:42 -03:00
alacleaker 0e9fb3702d adjust getting streaminfo log 2024-04-22 20:26:21 -03:00
alacleaker e706f0fa82 switch from tabulate to InquirerPy for selecting codecs 2024-04-22 20:24:37 -03:00
alacleaker 3014fb112d add support for artist urls 2024-04-22 20:05:15 -03:00
alacleaker d7f17b8b6f add debug message for url checking 2024-04-22 20:04:50 -03:00
alacleaker 947e2df81a rename some variables 2024-04-22 17:02:48 -03:00
alacleaker c8fe96b31d add get_artist 2024-04-22 13:01:38 -03:00
alacleaker 83a3efc1fa add _extend_api_data and _get_next_uri_response methods 2024-04-22 12:54:28 -03:00
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
14 changed files with 369 additions and 185 deletions
+1
View File
@@ -0,0 +1 @@
ko_fi: glomatico
+65 -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*
@@ -8,6 +10,7 @@ A Python script to download Apple Music songs/music videos/albums/playlists/post
* 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
## Prerequisites
* Python 3.8 or higher
@@ -29,57 +32,68 @@ 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"
```
* Choose which albums or music videos to download from an artist
```bash
gamdl "https://music.apple.com/us/artist/rick-astley/669771"
```
## 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 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` |
### Tags variables
The following variables can be used in the template folders/files and/or in the `exclude_tags` list:
@@ -142,23 +156,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` (up to 1080p, with AAC 256kbps)
* `h265` (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.1"
__version__ = "2.2"
+45 -16
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import functools
import re
import time
import typing
from http.cookiejar import MozillaCookieJar
from pathlib import Path
@@ -85,6 +86,31 @@ class AppleMusicApi:
):
self._raise_response_exception(response)
def get_artist(
self,
artist_id: str,
include: str = "albums,music-videos",
limit: int = 100,
fetch_all: bool = True,
) -> dict:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/artists/{artist_id}",
params={
"include": include,
**{f"limit[{_include}]": limit for _include in include.split(",")},
},
)
self._check_amp_api_response(response)
artist = response.json()["data"][0]
if fetch_all:
for _include in include.split(","):
for additional_data in self._extend_api_data(
artist["relationships"][_include],
limit,
):
artist["relationships"][_include]["data"].extend(additional_data)
return artist
def get_song(
self,
song_id: str,
@@ -146,7 +172,7 @@ class AppleMusicApi:
is_library: bool = False,
limit_tracks: int = 300,
extend: str = "extendedAssetUrls",
full_playlist: bool = True,
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}",
@@ -157,28 +183,31 @@ class AppleMusicApi:
)
self._check_amp_api_response(response)
playlist = response.json()["data"][0]
if full_playlist:
playlist = self._extend_playlists_tracks(playlist, limit_tracks)
if fetch_all:
for additional_data in self._extend_api_data(
playlist["relationships"]["tracks"],
limit_tracks,
):
playlist["relationships"]["tracks"]["data"].extend(additional_data)
return playlist
def _extend_playlists_tracks(
def _extend_api_data(
self,
playlist: dict,
limit_tracks: int,
) -> dict:
playlist_next_uri = playlist["relationships"]["tracks"].get("next")
while playlist_next_uri:
playlist_next = self._get_playlist_next(playlist_next_uri, limit_tracks)
playlist["relationships"]["tracks"]["data"].extend(playlist_next["data"])
playlist_next_uri = playlist_next.get("next")
api_response: dict,
limit: int,
) -> typing.Generator[list[dict], None, None]:
next_uri = api_response.get("next")
while next_uri:
playlist_next = self._get_next_uri_response(next_uri, limit)
yield playlist_next["data"]
next_uri = playlist_next.get("next")
time.sleep(self.WAIT_TIME)
return playlist
def _get_playlist_next(self, playlist_next_uri: str, limit_tracks: int) -> dict:
def _get_next_uri_response(self, next_uri: str, limit: int) -> dict:
response = self.session.get(
self.AMP_API_URL + playlist_next_uri,
self.AMP_API_URL + next_uri,
params={
"limit[tracks]": limit_tracks,
"limit": limit,
},
)
self._check_amp_api_response(response)
+34 -20
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)
@@ -95,7 +95,7 @@ def load_config_file(
"--read-urls-as-txt",
"-r",
is_flag=True,
help="Interpret URLs as paths to text files containing URLs.",
help="Interpret URLs as paths to text files containing URLs separated by newlines",
)
@click.option(
"--synced-lyrics-only",
@@ -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,12 +433,22 @@ 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()]
_urls = []
for url in urls:
if Path(url).exists():
_urls.extend(Path(url).read_text().splitlines())
urls = _urls
for url_index, url in enumerate(urls, start=1):
url_progress = f"URL {url_index}/{len(urls)}"
try:
logger.info(f'({url_progress}) Checking "{url}"')
url_info = downloader.get_url_info(url)
download_queue = downloader.get_download_queue(url_info)
except Exception as e:
@@ -477,11 +501,8 @@ def main(
f'({queue_progress}) Song already exists at "{final_path}", skipping'
)
else:
if codec_song in (
SongCodec.AAC_LEGACY,
SongCodec.AAC_HE_LEGACY,
):
logger.debug("Getting stream info")
logger.debug("Getting stream info")
if codec_song in LEGACY_CODECS:
stream_info = downloader_song_legacy.get_stream_info(
webplayback
)
@@ -491,14 +512,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 +527,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,
+8 -4
View File
@@ -191,13 +191,14 @@ 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 = {
MusicVideoCodec.H264_BEST: "avc1",
MusicVideoCodec.H265_BEST: "hvc1",
MusicVideoCodec.H264: "avc1",
MusicVideoCodec.H265: "hvc1",
}
SYNCED_LYRICS_FILE_EXTENSION_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,
]
+109 -13
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import base64
import functools
import re
@@ -7,6 +9,8 @@ from pathlib import Path
import ciso8601
import requests
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from mutagen.mp4 import MP4, MP4Cover
from pywidevine import PSSH, Cdm, Device
from yt_dlp import YoutubeDL
@@ -46,7 +50,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 +74,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 +96,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))
@@ -100,7 +114,7 @@ class Downloader:
def get_url_info(self, url: str) -> UrlInfo:
url_info = UrlInfo()
url_regex_result = re.search(
r"/([a-z]{2})/(album|playlist|song|music-video|post)/([^/]*)(?:/([^/?]*))?(?:\?i=)?([0-9a-z]*)?",
r"/([a-z]{2})/(artist|album|playlist|song|music-video|post)/([^/]*)(?:/([^/?]*))?(?:\?i=)?([0-9a-z]*)?",
url,
)
url_info.storefront = url_regex_result.group(1)
@@ -119,7 +133,10 @@ class Downloader:
def _get_download_queue(self, url_type: str, id: str) -> list[DownloadQueueItem]:
download_queue = []
if url_type == "song":
if url_type == "artist":
artist = self.apple_music_api.get_artist(id)
download_queue.extend(self.get_download_queue_from_artist(artist))
elif url_type == "song":
download_queue.append(DownloadQueueItem(self.apple_music_api.get_song(id)))
elif url_type == "album":
album = self.apple_music_api.get_album(id)
@@ -144,6 +161,92 @@ class Downloader:
raise Exception(f"Invalid url type: {url_type}")
return download_queue
def get_download_queue_from_artist(self, artist: dict) -> list[DownloadQueueItem]:
media_type = inquirer.select(
message=f'Select which type to download for artist "{artist["attributes"]["name"]}":',
choices=[
Choice(name="Albums", value="albums"),
Choice(
name="Music Videos",
value="music-videos",
),
],
validate=lambda result: artist["relationships"].get(result, {}).get("data"),
invalid_message="The artist doesn't have any items of this type",
).execute()
if media_type == "albums":
return self.select_albums_from_artist(
artist["relationships"]["albums"]["data"]
)
elif media_type == "music-videos":
return self.select_music_videos_from_artist(
artist["relationships"]["music-videos"]["data"]
)
def select_albums_from_artist(
self,
albums: list[dict],
) -> list[DownloadQueueItem]:
choices = [
Choice(
name=" | ".join(
[
f'{album["attributes"]["trackCount"]:03d}',
f'{album["attributes"]["releaseDate"]:<10}',
f'{album["attributes"].get("contentRating", "None").title():<8}',
f'{album["attributes"]["name"]}',
]
),
value=album,
)
for album in albums
]
selected = inquirer.select(
message="Select which albums to download: (Track Count | Release Date | Rating | Title)",
choices=choices,
multiselect=True,
).execute()
download_queue = []
for album in selected:
download_queue.extend(
DownloadQueueItem(track)
for track in self.apple_music_api.get_album(album["id"])[
"relationships"
]["tracks"]["data"]
)
return download_queue
def select_music_videos_from_artist(
self,
music_videos: list[dict],
) -> list[DownloadQueueItem]:
choices = [
Choice(
name=" | ".join(
[
self.millis_to_min_sec(
music_video["attributes"]["durationInMillis"]
),
f'{music_video["attributes"].get("contentRating", "None").title():<8}',
music_video["attributes"]["name"],
],
),
value=music_video,
)
for music_video in music_videos
]
selected = inquirer.select(
message="Select which music videos to download: (Duration | Rating | Title)",
choices=choices,
multiselect=True,
).execute()
return [DownloadQueueItem(music_video) for music_video in selected]
@staticmethod
def millis_to_min_sec(millis):
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)
@@ -181,19 +284,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 +309,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:
+39 -40
View File
@@ -1,11 +1,12 @@
from __future__ import annotations
import re
import subprocess
import urllib.parse
from pathlib import Path
import click
import m3u8
from tabulate import tabulate
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from .constants import MUSIC_VIDEO_CODEC_MAP
from .downloader import Downloader
@@ -19,7 +20,7 @@ class DownloaderMusicVideo:
def __init__(
self,
downloader: Downloader,
codec: MusicVideoCodec = MusicVideoCodec.H264_BEST,
codec: MusicVideoCodec = MusicVideoCodec.H264,
):
self.downloader = downloader
self.codec = codec
@@ -52,7 +53,7 @@ class DownloaderMusicVideo:
playlist
for playlist in playlists
if playlist["stream_info"]["codecs"].startswith(
MUSIC_VIDEO_CODEC_MAP[MusicVideoCodec.H264_BEST]
MUSIC_VIDEO_CODEC_MAP[MusicVideoCodec.H264]
)
]
playlists_filtered.sort(key=lambda x: x["stream_info"]["bandwidth"])
@@ -62,24 +63,24 @@ class DownloaderMusicVideo:
self,
playlists: list[dict],
) -> dict:
table = [
[
i,
playlist["stream_info"]["codecs"],
playlist["stream_info"]["resolution"],
playlist["stream_info"]["bandwidth"],
]
for i, playlist in enumerate(playlists, 1)
]
print(tabulate(table))
try:
choice = (
click.prompt("Choose a video codec", type=click.IntRange(1, len(table)))
- 1
choices = [
Choice(
name=" | ".join(
[
playlist["stream_info"]["codecs"][:4],
playlist["stream_info"]["resolution"],
str(playlist["stream_info"]["bandwidth"]),
]
),
value=playlist,
)
except click.exceptions.Abort:
raise KeyboardInterrupt()
return playlists[choice]
for playlist in playlists
]
selected = inquirer.select(
message="Select which video codec to download: (Codec | Resolution | Bitrate)",
choices=choices,
).execute()
return selected
def get_playlist_audio(
self,
@@ -99,24 +100,18 @@ class DownloaderMusicVideo:
self,
playlists: list[dict],
) -> dict:
table = [
[
i,
playlist["group_id"],
]
for i, playlist in enumerate(playlists, 1)
]
print(tabulate(table))
try:
choice = (
click.prompt(
"Choose an audio codec", type=click.IntRange(1, len(table))
)
- 1
choices = [
Choice(
name=playlist["group_id"],
value=playlist,
)
except click.exceptions.Abort:
raise KeyboardInterrupt()
return playlists[choice]
for playlist in playlists
]
selected = inquirer.select(
message="Select which audio codec to download:",
choices=choices,
).execute()
return selected
def get_pssh(self, m3u8_data: dict):
return next(
@@ -227,6 +222,7 @@ class DownloaderMusicVideo:
decrypted_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_mp4box(
@@ -234,7 +230,7 @@ class DownloaderMusicVideo:
decrypted_path_audio: Path,
decrypted_path_video: Path,
fixed_path: Path,
) -> None:
):
subprocess.run(
[
self.downloader.mp4box_path_full,
@@ -245,10 +241,12 @@ class DownloaderMusicVideo:
decrypted_path_video,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
fixed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_ffmpeg(
@@ -283,6 +281,7 @@ class DownloaderMusicVideo:
fixed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux(
+15 -13
View File
@@ -1,9 +1,11 @@
from __future__ import annotations
from pathlib import Path
import click
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from .downloader import Downloader
from tabulate import tabulate
from .enums import PostQuality
@@ -38,18 +40,18 @@ class DownloaderPost:
def get_stream_url_from_user(self, metadata: dict) -> str:
qualities = list(metadata["attributes"]["assetTokens"].keys())
table = [
[index, quality]
for index, quality in enumerate(
qualities,
start=1,
choices = [
Choice(
name=quality,
value=quality,
)
for quality in qualities
]
print(tabulate(table))
choice = (
click.prompt("Choose a quality", type=click.IntRange(1, len(table))) - 1
)
return metadata["attributes"]["assetTokens"][qualities[choice]]
selected = inquirer.select(
message="Select which quality to download:",
choices=choices,
).execute()
return metadata["attributes"]["assetTokens"][selected]
def get_stream_url(self, metadata: dict) -> str:
if self.quality == PostQuality.BEST:
@@ -62,7 +64,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"]),
}
+38 -23
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import base64
import datetime
import json
@@ -7,9 +9,9 @@ from pathlib import Path
from xml.dom import minidom
from xml.etree import ElementTree
import click
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
import m3u8
from tabulate import tabulate
from .constants import SONG_CODEC_REGEX_MAP, SYNCED_LYRICS_FILE_EXTENSION_MAP
from .downloader import Downloader
@@ -41,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:
@@ -70,18 +72,18 @@ class DownloaderSong:
def get_playlist_from_user(self, m3u8_data: dict) -> dict | None:
m3u8_master_playlists = [playlist for playlist in m3u8_data["playlists"]]
table = [
[i, playlist["stream_info"]["audio"]]
for i, playlist in enumerate(m3u8_master_playlists, 1)
]
print(tabulate(table))
try:
choice = (
click.prompt("Choose a codec", type=click.IntRange(1, len(table))) - 1
choices = [
Choice(
name=playlist["stream_info"]["audio"],
value=playlist,
)
except click.exceptions.Abort:
raise KeyboardInterrupt()
return m3u8_master_playlists[choice]
for playlist in m3u8_master_playlists
]
selected = inquirer.select(
message="Select which codec to download:",
choices=choices,
).execute()
return selected
def get_pssh(
self,
@@ -104,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:
@@ -112,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)
@@ -172,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("", "")
@@ -197,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(
@@ -294,6 +304,7 @@ class DownloaderSong:
decrypted_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux(self, decrypted_path: Path, remuxed_path: Path, codec: str):
@@ -311,10 +322,12 @@ class DownloaderSong:
decrypted_path,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
remuxed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_ffmpeg(
@@ -324,7 +337,8 @@ class DownloaderSong:
codec: str,
):
use_mp4_format = any(
codec.startswith(possible_codec) for possible_codec in self.MP4_FORMAT_CODECS
codec.startswith(possible_codec)
for possible_codec in self.MP4_FORMAT_CODECS
)
subprocess.run(
[
@@ -343,6 +357,7 @@ class DownloaderSong:
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(
+4 -3
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"
@@ -32,8 +33,8 @@ class SyncedLyricsFormat(Enum):
class MusicVideoCodec(Enum):
H264_BEST = "h264-best"
H265_BEST = "h265-best"
H264 = "h264"
H265 = "h265"
ASK = "ask"
+2 -2
View File
@@ -1,13 +1,13 @@
[project]
name = "gamdl"
description = "Download Apple Music songs/music videos/albums/playlists"
description = "A Python CLI app for downloading Apple Music songs/music videos/albums/playlists/posts."
requires-python = ">=3.8"
authors = [{ name = "glomatico" }]
dependencies = [
"ciso8601",
"click",
"inquirerpy",
"m3u8",
"tabulate",
"pywidevine",
"pyyaml",
"yt-dlp",
+1 -1
View File
@@ -1,7 +1,7 @@
ciso8601
click
inquirerpy
m3u8
tabulate
pywidevine
pyyaml
yt-dlp