Compare commits

...

56 Commits

Author SHA1 Message Date
Rafael Moraes 047e9dbed8 Pass language to AppleMusicApi.from_netscape_cookies 2025-07-02 10:49:28 -03:00
Rafael Moraes e0bba0857a Update supported URL types in README 2025-07-02 10:42:54 -03:00
Rafael Moraes 6736acc5b0 Set Downloader to quiet according to log_level 2025-07-02 10:41:41 -03:00
Rafael Moraes 47521f1a82 Restrict log-level option to specific choices 2025-07-02 10:40:54 -03:00
Rafael Moraes 4d33f3e101 Support 'albums' as url_type in downloader 2025-07-02 10:29:44 -03:00
Rafael Moraes c827e26e43 Bump version 2025-07-02 10:21:23 -03:00
Rafael Moraes 1042e47c0b Handle missing URL in get_music_video_id_alt 2025-07-02 10:21:12 -03:00
Rafael Moraes 7f56f85f35 Handle library-music-videos in media type check 2025-07-02 10:20:58 -03:00
Rafael Moraes 560585eaa8 Improve README formatting and update usage details 2025-07-02 10:06:32 -03:00
Rafael Moraes 0fc2f75e5b Fix NoneType error in stream_info check 2025-07-02 10:03:28 -03:00
Rafael Moraes 82143df91a Update stream info methods to return None on failure 2025-07-02 10:03:17 -03:00
Rafael Moraes e89d1cb19a Refactor cover image download method naming 2025-07-02 09:58:52 -03:00
Rafael Moraes 01dd232565 Handle 400 status code for covers in downloader requests 2025-07-02 09:58:14 -03:00
Rafael Moraes c9e75ae2a2 Fix handling of missing lyrics in tag and sync logic 2025-07-02 09:50:21 -03:00
Rafael Moraes 9c26646636 Update get_lyrics to return None if no lyrics 2025-07-02 09:50:07 -03:00
Rafael Moraes efc452ba47 Rename tracks_metadata to medias_metadata in DownloadQueue 2025-07-02 09:44:51 -03:00
Rafael Moraes 57e9a1ca98 Fix Apple Music lyrics fetch with correct media ID 2025-07-02 09:44:43 -03:00
Rafael Moraes ca939d5760 Add get_media_id method to Downloader class 2025-07-02 09:44:33 -03:00
Rafael Moraes 6786ae393d Refactor media_id extraction in main download loop 2025-07-02 09:44:23 -03:00
Rafael Moraes 5458d7a1d4 Add 'extend' parameter to API pagination methods 2025-07-02 09:44:08 -03:00
Rafael Moraes 49368e7bc9 Rename tracks_metadata to medias_metadata in downloader 2025-07-02 09:24:06 -03:00
Rafael Moraes 621383a0d8 Refactor track_metadata to media_metadata in main loop 2025-07-02 09:23:56 -03:00
Rafael Moraes e7a055b1b8 Add is_library field to UrlInfo dataclass 2025-07-02 09:16:17 -03:00
Rafael Moraes bc070e4279 Add support for Apple Music library URLs in downloader 2025-07-02 09:16:04 -03:00
Rafael Moraes 2b1d02257c Add methods to fetch library albums and playlists 2025-07-02 09:15:41 -03:00
Rafael Moraes 3256aef9f8 update recommended cookies txt extension for chrome 2025-06-08 14:12:08 -03:00
Rafael Moraes 501cd48474 bump required python version 2025-06-08 14:10:14 -03:00
Rafael Moraes 9f31b99642 bump required python version 2025-06-08 14:09:58 -03:00
Rafael Moraes e9525668d6 refactor logger declaration 2025-06-08 14:09:35 -03:00
Rafael Moraes 60a2ca76fb refactor prompt_path 2025-06-08 14:08:27 -03:00
Rafael Moraes b81f740e2b fix wvd_file prompt 2025-06-02 08:58:08 -03:00
Rafael Moraes f8fc4c66e6 refactor for using streaminfoav 2025-06-02 00:03:07 -03:00
Rafael Moraes 74d1772173 add get_final_file_extension, fix cdm_session closing 2025-06-02 00:02:51 -03:00
Rafael Moraes 63830f2444 refactor for using MediaFileFormat and StreamInfoAv 2025-06-02 00:02:34 -03:00
Rafael Moraes f0838de397 add MediaFileFormat 2025-06-02 00:01:32 -03:00
Rafael Moraes dfe4e29ab5 add StreamInfoAv 2025-06-02 00:01:28 -03:00
Rafael Moraes 0782daed51 add music videos remux formats doc 2025-05-31 17:47:42 -03:00
Rafael Moraes 27ad170adf add remux_format_music_video option 2025-05-31 17:42:59 -03:00
Rafael Moraes b9377dc8b0 fix cookies path prompt 2025-05-31 17:20:21 -03:00
Rafael Moraes 5e413deb6d refactor cli skip_mv 2025-05-31 17:19:55 -03:00
Rafael Moraes af26e939e8 refactor api constructor 2025-05-31 17:19:33 -03:00
Rafael Moraes 66a965ecf6 update setup-python action to version 5 2025-05-12 09:35:36 -03:00
Rafael Moraes 24de608bc8 bump version 2025-05-12 09:28:28 -03:00
Rafael Moraes d0e2e08748 use prompt_path function for wvd and cookies 2025-05-12 09:11:58 -03:00
Rafael Moraes 2223d36d5e added prompt_path function 2025-05-12 09:11:07 -03:00
Rafael Moraes 3077456ab7 update cover path retrieval to use downloader_song 2025-05-09 14:19:39 -03:00
Rafael Moraes bbd96cbe6b remove redundant cover path lines 2025-05-09 14:16:33 -03:00
Rafael Moraes ca16a208ba rename custom_formatter to custom_logger_formatter 2025-05-09 14:07:31 -03:00
Rafael Moraes c32c8622b7 add error handling for missing media-user-token in cookies 2025-05-09 14:04:24 -03:00
Rafael Moraes 132ae0ea56 improve storefront retrieval 2025-05-05 23:04:09 -03:00
Rafael Moraes 70238facac better handling for media that has no cover 2025-02-25 02:35:39 -03:00
Rafael Moraes 4fb1fb609b Update custom_formatter.py 2025-02-23 16:28:38 -03:00
Rafael Moraes f97b3dba14 bump version 2025-02-23 04:36:05 -03:00
Rafael Moraes 2da824ecbc add colorama to dependencies 2025-02-23 04:34:28 -03:00
Rafael Moraes 24810da4b6 replace inline response exception handling with utility function 2025-02-23 04:32:50 -03:00
Rafael Moraes f16a30549c refactor logging color handling to use colorama and add utility function for colored text 2025-02-23 04:30:11 -03:00
17 changed files with 700 additions and 415 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
- uses: actions/checkout@v3
- name: Set up Python 3.9
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: 3.9
cache: pip
+175 -149
View File
@@ -1,197 +1,223 @@
# Glomaticos Apple Music Downloader
A Python CLI app for downloading Apple Music songs, music videos and post videos.
**Join our Discord Server:** https://discord.gg/aBjMEZ9tnq
## Features
* **High-Quality Songs**: Download songs in AAC 256kbps and other codecs.
* **High-Quality Music Videos**: Download music videos in resolutions up to 4K.
* **Synced Lyrics**: Download synced lyrics in LRC, SRT, or TTML formats.
* **Artist Support**: Download all albums or music videos from an artist using their link.
* **Highly Customizable**: Extensive configuration options for advanced users.
- **High-Quality Songs**: Download songs in AAC 256kbps and other codecs.
- **High-Quality Music Videos**: Download music videos in resolutions up to 4K.
- **Synced Lyrics**: Download synced lyrics in LRC, SRT, or TTML formats.
- **Artist Support**: Download all albums or music videos from an artist using their link.
- **Highly Customizable**: Extensive configuration options for advanced users.
## Prerequisites
* **Python 3.9 or higher** installed on your system.
* The **cookies file** of your Apple Music browser session in Netscape format (requires an active subscription).
* **Firefox**: Use the [Export Cookies](https://addons.mozilla.org/addon/export-cookies-txt) extension.
* **Chromium-based Browsers**: Use the [Open Cookies.txt](https://chromewebstore.google.com/detail/open-cookiestxt/gdocmgbfkjnnpapoeobnolbbkoibbcif) extension.
* **FFmpeg** on your system PATH.
* **Windows**: Download from [AnimMouses FFmpeg Builds](https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases).
* **Linux**: Download from [John Van Sickles FFmpeg Builds](https://johnvansickle.com/ffmpeg/).
- **Python 3.10 or higher** installed on your system.
- The **cookies file** of your Apple Music browser session in Netscape format (requires an active subscription).
- **Firefox**: Use the [Export Cookies](https://addons.mozilla.org/addon/export-cookies-txt) extension.
- **Chromium-based Browsers**: Use the [Get cookies.txt LOCALLY](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc) extension.
- **FFmpeg** on your system PATH.
- **Windows**: Download from [AnimMouses FFmpeg Builds](https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases).
- **Linux**: Download from [John Van Sickles FFmpeg Builds](https://johnvansickle.com/ffmpeg/).
### Optional dependencies
The following tools are optional but required for specific features. Add them to your systems PATH or specify their paths using command-line arguments or the config file.
* [mp4decrypt](https://www.bento4.com/downloads/): Required for `mp4box` remux mode, music video downloads, and experimental song codecs.
* [MP4Box](https://gpac.io/downloads/gpac-nightly-builds/): Required for `mp4box` remux mode.
* [N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest): Required for `nm3u8dlre` download mode.
- [mp4decrypt](https://www.bento4.com/downloads/): Required for `mp4box` remux mode, music video downloads, and experimental song codecs.
- [MP4Box](https://gpac.io/downloads/gpac-nightly-builds/): Required for `mp4box` remux mode.
- [N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest): Required for `nm3u8dlre` download mode.
## Installation
1. Install the package `gamdl` using pip
```bash
pip install gamdl
```
```bash
pip install gamdl
```
2. Set up the cookies file.
* Move the cookies file to the directory where youll run Gamdl and rename it to `cookies.txt`.
* Alternatively, specify the path to the cookies file using command-line arguments or the config file.
- Move the cookies file to the directory where youll run Gamdl and rename it to `cookies.txt`.
- Alternatively, specify the path to the cookies file using command-line arguments or the config file.
## Usage
Run Gamdl with the following command:
```bash
gamdl [OPTIONS] URLS...
```
### Supported URL types
* Song
* Album
* Playlist
* Music video
* Artist
* Post video
- Song
- Public/Library Album
- Public/Library Playlist
- Music video
- Artist
- Post video
### Examples
* Download a Song:
```bash
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/1624945511"
```
* Download from an Artist:
```bash
gamdl "https://music.apple.com/us/artist/rick-astley/669771"
```
- Download a Song:
```bash
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/1624945511"
```
- Download from an Artist:
```bash
gamdl "https://music.apple.com/us/artist/rick-astley/669771"
```
### Interactive prompt controls
* **Arrow keys**: Move selection
* **Space**: Toggle selection
* **Ctrl + A**: Select all
* **Enter**: Confirm selection
- **Arrow keys**: Move selection
- **Space**: Toggle selection
- **Ctrl + A**: Select all
- **Enter**: Confirm selection
## Configuration
Gamdl can be configured by using the command-line arguments or the config file.
The config file is created automatically when you run Gamdl for the first time at `~/.gamdl/config.json` on Linux and `%USERPROFILE%\.gamdl\config.json` on Windows.
Config file values can be overridden using command-line arguments.
| Command-line argument / Config file key | Description | Default value |
Config file values can be overridden using command-line arguments.
| Command-line argument / Config file key | Description | Default value |
| --------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------- |
| `--disable-music-video-skip` / `disable_music_video_skip` | Don't skip downloading music videos in albums/playlists. | `false` |
| `--save-cover`, `-s` / `save_cover` | Save cover as a separate file. | `false` |
| `--overwrite` / `overwrite` | Overwrite existing files. | `false` |
| `--read-urls-as-txt`, `-r` / - | Interpret URLs as paths to text files containing URLs separated by newlines. | `false` |
| `--save-playlist` / `save_playlist` | Save a M3U8 playlist file when downloading a playlist. | `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` |
| `--no-exceptions` / `no_exceptions` | Don't print exceptions. | `false` |
| `--cookies-path`, `-c` / `cookies_path` | Path to .txt cookies file. | `./cookies.txt` |
| `--language`, `-l` / `language` | Metadata language as an ISO-2A language code (don't always work for videos). | `en-US` |
| `--output-path`, `-o` / `output_path` | Path to output directory. | `./Apple Music` |
| `--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-file-playlist` / `template_file_playlist` | Template file for the M3U8 playlist. | `Playlists/{playlist_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. | `null` |
| `--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` |
| `--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` |
| `--save-playlist` / `save_playlist` | Save a M3U8 playlist file when downloading a playlist. | `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` |
| `--no-exceptions` / `no_exceptions` | Don't print exceptions. | `false` |
| `--cookies-path`, `-c` / `cookies_path` | Path to .txt cookies file. | `./cookies.txt` |
| `--language`, `-l` / `language` | Metadata language as an ISO-2A language code (don't always work for videos). | `en-US` |
| `--output-path`, `-o` / `output_path` | Path to output directory. | `./Apple Music` |
| `--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-file-playlist` / `template_file_playlist` | Template file for the M3U8 playlist. | `Playlists/{playlist_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. | `null` |
| `--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` |
| `--remux-format-music-video` / `remux_format_music_video` | Music video remux format. | `m4v` |
| `--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:
* `album`
* `album_artist`
* `album_id`
* `album_sort`
* `artist`
* `artist_id`
* `artist_sort`
* `comment`
* `compilation`
* `composer`
* `composer_id`
* `composer_sort`
* `copyright`
* `cover`
* `date`
* `disc`
* `disc_total`
* `gapless`
* `genre`
* `genre_id`
* `lyrics`
* `media_type`
* `playlist_artist`
* `playlist_id`
* `playlist_title`
* `playlist_track`
* `rating`
* `storefront`
* `title`
* `title_id`
* `title_sort`
* `track`
* `track_total`
* `xid`
- `album`
- `album_artist`
- `album_id`
- `album_sort`
- `artist`
- `artist_id`
- `artist_sort`
- `comment`
- `compilation`
- `composer`
- `composer_id`
- `composer_sort`
- `copyright`
- `cover`
- `date`
- `disc`
- `disc_total`
- `gapless`
- `genre`
- `genre_id`
- `lyrics`
- `media_type`
- `playlist_artist`
- `playlist_id`
- `playlist_title`
- `playlist_track`
- `rating`
- `storefront`
- `title`
- `title_id`
- `title_sort`
- `track`
- `track_total`
- `xid`
### Remux Modes
* `ffmpeg`: Default remuxing mode.
* `mp4box`: Alternative remuxing mode (doesnt convert closed captions in music videos).
- `ffmpeg`: Default remuxing mode.
- `mp4box`: Alternative remuxing mode (doesnt convert closed captions in music videos).
### Download modes
* `ytdlp`: Default download mode.
* `nm3u8dlre`: Faster than `ytdlp`.
- `ytdlp`: Default download mode.
- `nm3u8dlre`: Faster than `ytdlp`.
### Song Codecs
* Supported Codecs:
* `aac-legacy`: AAC 256kbps 44.1kHz.
* `aac-he-legacy`: AAC-HE 64kbps 44.1kHz.
* Experimental Codecs (not guaranteed to work due to API limitations):
* `aac`: AAC 256kbps up to 48kHz.
* `aac-he`: AAC-HE 64kbps up to 48kHz.
* `aac-binaural`: AAC 256kbps binaural.
* `aac-downmix`: AAC 256kbps downmix.
* `aac-he-binaural`: AAC-HE 64kbps binaural.
* `aac-he-downmix`: AAC-HE 64kbps downmix.
* `atmos`: Dolby Atmos 768kbps.
* `ac3`: AC3 640kbps.
* `alac`: ALAC up to 24-bit/192 kHz.
* `ask`: Prompt to choose available audio codec.
- Supported Codecs:
- `aac-legacy`: AAC 256kbps 44.1kHz.
- `aac-he-legacy`: AAC-HE 64kbps 44.1kHz.
- Experimental Codecs (not guaranteed to work due to API limitations):
- `aac`: AAC 256kbps up to 48kHz.
- `aac-he`: AAC-HE 64kbps up to 48kHz.
- `aac-binaural`: AAC 256kbps binaural.
- `aac-downmix`: AAC 256kbps downmix.
- `aac-he-binaural`: AAC-HE 64kbps binaural.
- `aac-he-downmix`: AAC-HE 64kbps downmix.
- `atmos`: Dolby Atmos 768kbps.
- `ac3`: AC3 640kbps.
- `alac`: ALAC up to 24-bit/192 kHz.
- `ask`: Prompt to choose available audio codec.
### Music Videos Codecs
* `h264`: Up to 1080p with AAC 256kbps.
* `h265`: Up to 2160p with AAC 256kpbs.
* `ask`: Prompt to choose available video and audio codecs.
- `h264`: Up to 1080p with AAC 256kbps.
- `h265`: Up to 2160p with AAC 256kpbs.
- `ask`: Prompt to choose available video and audio codecs.
### Music Videos Remux Formats
- `m4v`: Default remux format.
- `mp4`
### Post videos/extra videos qualities
* `best`: Up to 1080p with AAC 256kbps.
* `ask`: Prompt to choose available video quality.
- `best`: Up to 1080p with AAC 256kbps.
- `ask`: Prompt to choose available video quality.
### Synced lyrics formats
* `lrc`: Lightweight and widely supported.
* `srt`: SubRip format (has more accurate timestamps).
* `ttml`: Native Apple Music format (unsupported by most media players).
- `lrc`: Lightweight and widely supported.
- `srt`: SubRip format (has more accurate timestamps).
- `ttml`: Native Apple Music format (unsupported by most media players).
### Cover formats
* `jpg`: Default format.
* `png`: Lossless format.
* `raw`: Raw cover without processing (requires `save_cover` to save separately).
- `jpg`: Default format.
- `png`: Lossless format.
- `raw`: Raw cover without processing (requires `save_cover` to save separately).
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.4"
__version__ = "2.5"
+120 -35
View File
@@ -6,12 +6,15 @@ import time
import typing
from http.cookiejar import MozillaCookieJar
from pathlib import Path
from urllib.parse import urlparse
import requests
from .utils import raise_response_exception
class AppleMusicApi:
APPLE_MUSIC_HOMEPAGE_URL = "https://beta.music.apple.com"
APPLE_MUSIC_HOMEPAGE_URL = "https://music.apple.com"
AMP_API_URL = "https://amp-api.music.apple.com"
WEBPLAYBACK_API_URL = (
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback"
@@ -21,42 +24,72 @@ class AppleMusicApi:
def __init__(
self,
cookies_path: Path | None = Path("./cookies.txt"),
storefront: None | str = None,
storefront: str,
media_user_token: str | None = None,
language: str = "en-US",
):
self.cookies_path = cookies_path
self.media_user_token = media_user_token
self.storefront = storefront
self.language = language
self._set_session()
@classmethod
def from_netscape_cookies(
cls,
cookies_path: Path = Path("./cookies.txt"),
language: str = "en-US",
) -> AppleMusicApi:
parse_cookie = lambda name: next(
(
cookie.value
for cookie in cookies
if cookie.name == name
and cookie.domain.endswith(
urlparse(cls.APPLE_MUSIC_HOMEPAGE_URL).netloc
)
),
None,
)
cookies = MozillaCookieJar(cookies_path)
cookies.load(ignore_discard=True, ignore_expires=True)
media_user_token = parse_cookie("media-user-token")
if not media_user_token:
raise ValueError(
'"media-user-token" cookie not found in cookies. '
"Make sure you have exported the cookies from Apple Music webpage and are logged in "
"with an active subscription."
)
storefront = parse_cookie("itua")
return cls(
storefront=storefront,
media_user_token=media_user_token,
language=language,
)
def _set_session(self):
self.session = requests.Session()
if self.cookies_path:
cookies = MozillaCookieJar(self.cookies_path)
cookies.load(ignore_discard=True, ignore_expires=True)
self.session.cookies.update(cookies)
self.storefront = self.session.cookies.get_dict()["itua"]
media_user_token = self.session.cookies.get_dict()["media-user-token"]
else:
media_user_token = ""
self.session.headers.update(
{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0",
"Accept": "application/json",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"content-type": "application/json",
"Media-User-Token": media_user_token,
"x-apple-renewal": "true",
"DNT": "1",
"Connection": "keep-alive",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-site",
"accept": "*/*",
"accept-language": "en-US",
"origin": self.APPLE_MUSIC_HOMEPAGE_URL,
"priority": "u=1, i",
"referer": self.APPLE_MUSIC_HOMEPAGE_URL,
"sec-ch-ua": '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
}
)
if self.media_user_token:
self.session.cookies.update(
{
"media-user-token": self.media_user_token,
}
)
home_page = self.session.get(self.APPLE_MUSIC_HOMEPAGE_URL).text
index_js_uri = re.search(
r"/(assets/index-legacy-[^/]+\.js)",
@@ -68,12 +101,8 @@ class AppleMusicApi:
token = re.search('(?=eyJh)(.*?)(?=")', index_js_page).group(1)
self.session.headers.update({"authorization": f"Bearer {token}"})
self.session.params = {"l": self.language}
@staticmethod
def _raise_response_exception(response: requests.Response):
raise Exception(
f"Request failed with status code {response.status_code}: {response.text}"
)
if not self.storefront:
self._fetch_storefront()
def _check_amp_api_response(self, response: requests.Response):
try:
@@ -85,7 +114,17 @@ class AppleMusicApi:
requests.exceptions.JSONDecodeError,
AssertionError,
):
self._raise_response_exception(response)
raise_response_exception(response)
def _fetch_storefront(self):
self.storefront = self.get_user_storefront()["id"]
def get_user_storefront(
self,
) -> dict:
response = self.session.get(f"{self.AMP_API_URL}/v1/me/storefront")
self._check_amp_api_response(response)
return response.json()["data"][0]
def get_artist(
self,
@@ -108,6 +147,7 @@ class AppleMusicApi:
for additional_data in self._extend_api_data(
artist["relationships"][_include],
limit,
"",
):
artist["relationships"][_include]["data"].extend(additional_data)
return artist
@@ -187,6 +227,7 @@ class AppleMusicApi:
for additional_data in self._extend_api_data(
playlist["relationships"]["tracks"],
limit_tracks,
extend,
):
playlist["relationships"]["tracks"]["data"].extend(additional_data)
return playlist
@@ -211,23 +252,67 @@ class AppleMusicApi:
self._check_amp_api_response(response)
return response.json()["results"]
def get_library_album(self, album_id: str, extend: str = "extendedAssetUrls"):
response = self.session.get(
f"{self.AMP_API_URL}/v1/me/library/albums/{album_id}",
params={
"extend": extend,
},
)
self._check_amp_api_response(response)
return response.json()["data"][0]
def get_library_playlist(
self,
playlist_id: str,
include: str = "tracks",
limit: int = 100,
extend: str = "extendedAssetUrls",
fetch_all: bool = True,
) -> dict:
response = self.session.get(
f"{self.AMP_API_URL}/v1/me/library/playlists/{playlist_id}",
params={
"include": include,
**{f"limit[{_include}]": limit for _include in include.split(",")},
"extend": extend,
},
)
self._check_amp_api_response(response)
playlist = response.json()["data"][0]
if fetch_all:
for additional_data in self._extend_api_data(
playlist["relationships"]["tracks"],
limit,
extend,
):
playlist["relationships"]["tracks"]["data"].extend(additional_data)
return playlist
def _extend_api_data(
self,
api_response: dict,
limit: int,
extend: str,
) -> 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)
playlist_next = self._get_next_uri_response(next_uri, limit, extend)
yield playlist_next["data"]
next_uri = playlist_next.get("next")
time.sleep(self.WAIT_TIME)
def _get_next_uri_response(self, next_uri: str, limit: int) -> dict:
def _get_next_uri_response(
self,
next_uri: str,
limit: int,
extend: str,
) -> dict:
response = self.session.get(
self.AMP_API_URL + next_uri,
params={
"limit": limit,
"extend": extend,
},
)
self._check_amp_api_response(response)
@@ -254,7 +339,7 @@ class AppleMusicApi:
requests.exceptions.JSONDecodeError,
AssertionError,
):
self._raise_response_exception(response)
raise_response_exception(response)
return webplayback[0]
def get_widevine_license(
@@ -284,5 +369,5 @@ class AppleMusicApi:
requests.exceptions.JSONDecodeError,
AssertionError,
):
self._raise_response_exception(response)
raise_response_exception(response)
return widevine_license
+142 -115
View File
@@ -7,26 +7,38 @@ from enum import Enum
from pathlib import Path
import click
from termcolor import colored
import colorama
from . import __version__
from .apple_music_api import AppleMusicApi
from .constants import *
from .custom_formatter import CustomFormatter
from .custom_logger_formatter import CustomLoggerFormatter
from .downloader import Downloader
from .downloader_music_video import DownloaderMusicVideo
from .downloader_post import DownloaderPost
from .downloader_song import DownloaderSong
from .downloader_song_legacy import DownloaderSongLegacy
from .enums import CoverFormat, DownloadMode, MusicVideoCodec, PostQuality, RemuxMode
from .enums import (
CoverFormat,
DownloadMode,
MusicVideoCodec,
PostQuality,
RemuxFormatMusicVideo,
RemuxMode,
)
from .itunes_api import ItunesApi
from .utils import color_text, prompt_path
apple_music_api_sig = inspect.signature(AppleMusicApi.__init__)
apple_music_api_from_netscape_cookies_sig = inspect.signature(
AppleMusicApi.from_netscape_cookies
)
downloader_sig = inspect.signature(Downloader.__init__)
downloader_song_sig = inspect.signature(DownloaderSong.__init__)
downloader_music_video_sig = inspect.signature(DownloaderMusicVideo.__init__)
downloader_post_sig = inspect.signature(DownloaderPost.__init__)
logger = logging.getLogger("gamdl")
def get_param_string(param: click.Parameter) -> str:
if isinstance(param.default, Enum):
@@ -122,7 +134,7 @@ def load_config_file(
)
@click.option(
"--log-level",
type=str,
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]),
default="INFO",
help="Log level.",
)
@@ -136,14 +148,16 @@ def load_config_file(
"--cookies-path",
"-c",
type=Path,
default=apple_music_api_sig.parameters["cookies_path"].default,
default=apple_music_api_from_netscape_cookies_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,
default=apple_music_api_from_netscape_cookies_sig.parameters["language"].default,
help="Metadata language as an ISO-2A language code (don't always work for videos).",
)
# Downloader specific options
@@ -294,6 +308,12 @@ def load_config_file(
default=downloader_music_video_sig.parameters["codec"].default,
help="Music video codec.",
)
@click.option(
"--remux-format-music-video",
type=RemuxFormatMusicVideo,
default=downloader_music_video_sig.parameters["remux_format"].default,
help="Music video remux format.",
)
# DownloaderPost specific options
@click.option(
"--quality-post",
@@ -347,26 +367,20 @@ def main(
codec_song: SongCodec,
synced_lyrics_format: SyncedLyricsFormat,
codec_music_video: MusicVideoCodec,
remux_format_music_video: RemuxFormatMusicVideo,
quality_post: PostQuality,
no_config_file: bool,
):
logger = logging.getLogger(__name__)
colorama.just_fix_windows_console()
logger.setLevel(log_level)
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(CustomFormatter())
stream_handler.setFormatter(CustomLoggerFormatter())
logger.addHandler(stream_handler)
logger.info("Starting Gamdl")
while not cookies_path.exists():
cookies_path_str = click.prompt(
X_NOT_FOUND_STRING.format("Cookies file", cookies_path.absolute())
+ ". Move it to that location or drag and drop it here. Then, press enter to continue",
default=str(cookies_path),
show_default=False,
)
cookies_path = Path(cookies_path_str.strip('"'))
apple_music_api = AppleMusicApi(
cookies_path = prompt_path(True, cookies_path, "Cookies file")
apple_music_api = AppleMusicApi.from_netscape_cookies(
cookies_path,
language=language,
language,
)
itunes_api = ItunesApi(
apple_music_api.storefront,
@@ -396,6 +410,7 @@ def main(
exclude_tags,
cover_size,
truncate,
log_level in ("WARNING", "ERROR"),
)
downloader_song = DownloaderSong(
downloader,
@@ -409,15 +424,16 @@ def main(
downloader_music_video = DownloaderMusicVideo(
downloader,
codec_music_video,
remux_format_music_video,
)
downloader_post = DownloaderPost(
downloader,
quality_post,
)
skip_mv = False
if not synced_lyrics_only:
if wvd_path and not wvd_path.exists():
logger.critical(X_NOT_FOUND_STRING.format(".wvd file", wvd_path))
return
if wvd_path:
wvd_path = prompt_path(True, wvd_path, ".wvd file")
logger.debug("Setting up CDM")
downloader.set_cdm()
if not downloader.ffmpeg_path_full and (
@@ -451,8 +467,6 @@ def main(
+ ", music videos will not be downloaded"
)
skip_mv = True
else:
skip_mv = False
if codec_song not in LEGACY_CODECS:
logger.warning(
"You have chosen an experimental codec. "
@@ -466,12 +480,12 @@ def main(
_urls.extend(Path(url).read_text(encoding="utf-8").splitlines())
urls = _urls
for url_index, url in enumerate(urls, start=1):
url_progress = colored(f"URL {url_index}/{len(urls)}", "grey")
url_progress = color_text(f"URL {url_index}/{len(urls)}", colorama.Style.DIM)
try:
logger.info(f'({url_progress}) Checking "{url}"')
url_info = downloader.get_url_info(url)
download_queue = downloader.get_download_queue(url_info)
download_queue_tracks_metadata = download_queue.tracks_metadata
download_queue_medias_metadata = download_queue.medias_metadata
except Exception as e:
error_count += 1
logger.error(
@@ -479,32 +493,33 @@ def main(
exc_info=not no_exceptions,
)
continue
for download_index, track_metadata in enumerate(
download_queue_tracks_metadata, start=1
for download_index, media_metadata in enumerate(
download_queue_medias_metadata, start=1
):
queue_progress = colored(
f"Track {download_index}/{len(download_queue_tracks_metadata)} from URL {url_index}/{len(urls)}",
"grey",
queue_progress = color_text(
f"Track {download_index}/{len(download_queue_medias_metadata)} from URL {url_index}/{len(urls)}",
colorama.Style.DIM,
)
try:
media_id = downloader.get_media_id(media_metadata)
remuxed_path = None
if download_queue.playlist_attributes:
playlist_track = download_index
else:
playlist_track = None
logger.info(
f'({queue_progress}) Downloading "{track_metadata["attributes"]["name"]}"'
f'({queue_progress}) Downloading "{media_metadata["attributes"]["name"]}"'
)
if not track_metadata["attributes"].get("playParams"):
if media_id is None:
logger.warning(
f"({queue_progress}) Track is not streamable, skipping"
f"({queue_progress}) Track is not streamable or downloadable, skipping"
)
continue
if (
(synced_lyrics_only and track_metadata["type"] != "songs")
or (track_metadata["type"] == "music-videos" and skip_mv)
(synced_lyrics_only and media_metadata["type"] != "songs")
or (media_metadata["type"] == "music-videos" and skip_mv)
or (
track_metadata["type"] == "music-videos"
media_metadata["type"] == "music-videos"
and url_info.type == "album"
and not disable_music_video_skip
)
@@ -513,12 +528,15 @@ def main(
f"({queue_progress}) Track is not downloadable with current configuration, skipping"
)
continue
elif track_metadata["type"] == "songs":
elif media_metadata["type"] in ("songs", "library-songs"):
logger.debug("Getting lyrics")
lyrics = downloader_song.get_lyrics(track_metadata)
lyrics = downloader_song.get_lyrics(media_metadata)
logger.debug("Getting webplayback")
webplayback = apple_music_api.get_webplayback(track_metadata["id"])
tags = downloader_song.get_tags(webplayback, lyrics.unsynced)
webplayback = apple_music_api.get_webplayback(media_id)
tags = downloader_song.get_tags(
webplayback,
lyrics.unsynced if lyrics else None,
)
if playlist_track:
tags = {
**tags,
@@ -531,12 +549,15 @@ def main(
lyrics_synced_path = downloader_song.get_lyrics_synced_path(
final_path
)
cover_url = downloader.get_cover_url(track_metadata)
cover_url = downloader.get_cover_url(media_metadata)
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
cover_path = downloader_song.get_cover_path(
final_path,
cover_file_extesion,
)
if cover_file_extesion:
cover_path = downloader_song.get_cover_path(
final_path,
cover_file_extesion,
)
else:
cover_path = None
if synced_lyrics_only:
pass
elif final_path.exists() and not overwrite:
@@ -551,15 +572,16 @@ def main(
)
logger.debug("Getting decryption key")
decryption_key = downloader_song_legacy.get_decryption_key(
stream_info.widevine_pssh, track_metadata["id"]
stream_info.audio_track.widevine_pssh,
media_id,
)
else:
stream_info = downloader_song.get_stream_info(
track_metadata
media_metadata
)
if (
not stream_info.stream_url
or not stream_info.widevine_pssh
stream_info is None
or not stream_info.audio_track.widevine_pssh
):
logger.warning(
f"({queue_progress}) Song is not downloadable or is not"
@@ -568,19 +590,20 @@ def main(
continue
logger.debug("Getting decryption key")
decryption_key = downloader.get_decryption_key(
stream_info.widevine_pssh, track_metadata["id"]
stream_info.audio_track.widevine_pssh,
media_id,
)
encrypted_path = downloader_song.get_encrypted_path(
track_metadata["id"]
)
decrypted_path = downloader_song.get_decrypted_path(
track_metadata["id"]
)
encrypted_path = downloader_song.get_encrypted_path(media_id)
decrypted_path = downloader_song.get_decrypted_path(media_id)
remuxed_path = downloader_song.get_remuxed_path(
track_metadata["id"]
media_id,
stream_info.file_format,
)
logger.debug(f'Downloading to "{encrypted_path}"')
downloader.download(encrypted_path, stream_info.stream_url)
downloader.download(
encrypted_path,
stream_info.audio_track.stream_url,
)
if codec_song in LEGACY_CODECS:
logger.debug(
f'Decrypting/Remuxing to "{decrypted_path}"/"{remuxed_path}"'
@@ -594,15 +617,16 @@ def main(
else:
logger.debug(f'Decrypting to "{decrypted_path}"')
downloader_song.decrypt(
encrypted_path, decrypted_path, decryption_key
encrypted_path,
decrypted_path,
decryption_key,
)
logger.debug(f'Remuxing to "{final_path}"')
downloader_song.remux(
decrypted_path,
remuxed_path,
stream_info.codec,
)
if no_synced_lyrics or not lyrics.synced:
if no_synced_lyrics or not lyrics or not lyrics.synced:
pass
elif lyrics_synced_path.exists() and not overwrite:
logger.debug(
@@ -613,15 +637,16 @@ def main(
downloader_song.save_lyrics_synced(
lyrics_synced_path, lyrics.synced
)
elif track_metadata["type"] == "music-videos":
music_video_id_alt = downloader_music_video.get_music_video_id_alt(
track_metadata
elif media_metadata["type"] in ("music-videos", "library-music-videos"):
music_video_id_alt = (
downloader_music_video.get_music_video_id_alt(media_metadata)
or media_id
)
logger.debug("Getting iTunes page")
itunes_page = itunes_api.get_itunes_page(
"music-video", music_video_id_alt
)
if music_video_id_alt == track_metadata["id"]:
if music_video_id_alt == media_id:
stream_url = (
downloader_music_video.get_stream_url_from_itunes_page(
itunes_page
@@ -629,20 +654,17 @@ def main(
)
else:
logger.debug("Getting webplayback")
webplayback = apple_music_api.get_webplayback(
track_metadata["id"]
)
webplayback = apple_music_api.get_webplayback(media_id)
stream_url = (
downloader_music_video.get_stream_url_from_webplayback(
webplayback
)
)
logger.debug("Getting M3U8 data")
m3u8_data = downloader_music_video.get_m3u8_master_data(stream_url)
logger.debug("Getting tags")
tags = downloader_music_video.get_tags(
music_video_id_alt,
itunes_page,
track_metadata,
media_metadata,
)
if playlist_track:
tags = {
@@ -652,59 +674,65 @@ def main(
playlist_track,
),
}
final_path = downloader.get_final_path(tags, ".m4v")
cover_url = downloader.get_cover_url(track_metadata)
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
cover_path = downloader_music_video.get_cover_path(
final_path,
cover_file_extesion,
logger.debug("Getting M3U8 data")
m3u8_data = downloader_music_video.get_m3u8_master_data(stream_url)
stream_info_av = downloader_music_video.get_stream_info(
m3u8_data,
)
final_file_extesion = downloader.get_final_file_extension(
stream_info_av.file_format,
)
final_path = downloader.get_final_path(
tags,
final_file_extesion,
)
cover_url = downloader.get_cover_url(media_metadata)
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
if cover_file_extesion:
cover_path = downloader_music_video.get_cover_path(
final_path,
cover_file_extesion,
)
else:
cover_path = None
if final_path.exists() and not overwrite:
logger.warning(
f'({queue_progress}) Music video already exists at "{final_path}", skipping'
)
else:
logger.debug("Getting stream info")
stream_info_video, stream_info_audio = (
downloader_music_video.get_stream_info_video(m3u8_data),
downloader_music_video.get_stream_info_audio(m3u8_data),
)
decryption_key_video = downloader.get_decryption_key(
stream_info_video.widevine_pssh, track_metadata["id"]
stream_info_av.video_track.widevine_pssh,
media_id,
)
decryption_key_audio = downloader.get_decryption_key(
stream_info_audio.widevine_pssh, track_metadata["id"]
stream_info_av.audio_track.widevine_pssh,
media_id,
)
encrypted_path_video = (
downloader_music_video.get_encrypted_path_video(
track_metadata["id"]
)
downloader_music_video.get_encrypted_path_video(media_id)
)
encrypted_path_audio = (
downloader_music_video.get_encrypted_path_audio(
track_metadata["id"]
)
downloader_music_video.get_encrypted_path_audio(media_id)
)
decrypted_path_video = (
downloader_music_video.get_decrypted_path_video(
track_metadata["id"]
)
downloader_music_video.get_decrypted_path_video(media_id)
)
decrypted_path_audio = (
downloader_music_video.get_decrypted_path_audio(
track_metadata["id"]
)
downloader_music_video.get_decrypted_path_audio(media_id)
)
remuxed_path = downloader_music_video.get_remuxed_path(
track_metadata["id"]
media_id,
final_file_extesion,
)
logger.debug(f'Downloading video to "{encrypted_path_video}"')
downloader.download(
encrypted_path_video, stream_info_video.stream_url
encrypted_path_video,
stream_info_av.video_track.stream_url,
)
logger.debug(f'Downloading audio to "{encrypted_path_audio}"')
downloader.download(
encrypted_path_audio, stream_info_audio.stream_url
encrypted_path_audio,
stream_info_av.audio_track.stream_url,
)
logger.debug(f'Decrypting video to "{decrypted_path_video}"')
downloader_music_video.decrypt(
@@ -723,30 +751,29 @@ def main(
decrypted_path_video,
decrypted_path_audio,
remuxed_path,
stream_info_video.codec,
stream_info_audio.codec,
)
elif track_metadata["type"] == "uploaded-videos":
stream_url = downloader_post.get_stream_url(track_metadata)
tags = downloader_post.get_tags(track_metadata)
elif media_metadata["type"] == "uploaded-videos":
stream_url = downloader_post.get_stream_url(media_metadata)
tags = downloader_post.get_tags(media_metadata)
final_path = downloader.get_final_path(tags, ".m4v")
cover_url = downloader.get_cover_url(track_metadata)
cover_url = downloader.get_cover_url(media_metadata)
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
cover_path = downloader_music_video.get_cover_path(
final_path,
cover_file_extesion,
)
if cover_file_extesion:
cover_path = downloader_music_video.get_cover_path(
final_path,
cover_file_extesion,
)
else:
cover_path = None
if final_path.exists() and not overwrite:
logger.warning(
f'({queue_progress}) Post video already exists at "{final_path}", skipping'
)
else:
remuxed_path = downloader_post.get_post_temp_path(
track_metadata["id"]
)
remuxed_path = downloader_post.get_post_temp_path(media_id)
logger.debug(f'Downloading to "{remuxed_path}"')
downloader.download_ytdlp(remuxed_path, stream_url)
if synced_lyrics_only or not save_cover:
if synced_lyrics_only or not save_cover or cover_path is None:
pass
elif cover_path.exists() and not overwrite:
logger.debug(f'Cover already exists at "{cover_path}", skipping')
@@ -773,7 +800,7 @@ def main(
except Exception as e:
error_count += 1
logger.error(
f'({queue_progress}) Failed to download "{track_metadata["attributes"]["name"]}"',
f'({queue_progress}) Failed to download "{media_metadata["attributes"]["name"]}"',
exc_info=not no_exceptions,
)
finally:
-20
View File
@@ -1,20 +0,0 @@
import logging
from termcolor import colored
class CustomFormatter(logging.Formatter):
basic_format = "[%(levelname)-8s %(asctime)s]"
formats = {
logging.DEBUG: colored(basic_format, "grey"),
logging.INFO: colored(basic_format, "green"),
logging.WARNING: colored(basic_format, "yellow"),
logging.ERROR: colored(basic_format, "red"),
logging.CRITICAL: colored(basic_format, "red"),
}
date_format = "%H:%M:%S"
def format(self, record: logging.LogRecord) -> str:
return logging.Formatter(
self.formats.get(record.levelno) + " %(message)s",
datefmt=self.date_format,
).format(record)
+24
View File
@@ -0,0 +1,24 @@
import logging
import colorama
from .utils import color_text
class CustomLoggerFormatter(logging.Formatter):
base_format = "[%(levelname)-8s %(asctime)s]"
format_colors = {
logging.DEBUG: colorama.Style.DIM,
logging.INFO: colorama.Fore.GREEN,
logging.WARNING: colorama.Fore.YELLOW,
logging.ERROR: colorama.Fore.RED,
logging.CRITICAL: colorama.Fore.RED,
}
date_format = "%H:%M:%S"
def format(self, record: logging.LogRecord) -> str:
return logging.Formatter(
color_text(self.base_format, self.format_colors.get(record.levelno))
+ " %(message)s",
datefmt=self.date_format,
).format(record)
+89 -43
View File
@@ -20,16 +20,20 @@ from yt_dlp import YoutubeDL
from .apple_music_api import AppleMusicApi
from .constants import IMAGE_FILE_EXTENSION_MAP, MP4_TAGS_MAP
from .enums import CoverFormat, DownloadMode, RemuxMode
from .enums import CoverFormat, DownloadMode, MediaFileFormat, RemuxMode
from .hardcoded_wvd import HARDCODED_WVD
from .itunes_api import ItunesApi
from .models import DownloadQueue, UrlInfo
from .utils import raise_response_exception
class Downloader:
ILLEGAL_CHARS_RE = r'[\\/:*?"<>|;]'
ILLEGAL_CHAR_REPLACEMENT = "_"
VALID_URL_RE = r"/([a-z]{2})/(artist|album|playlist|song|music-video|post)/([^/]*)(?:/([^/?]*))?(?:\?i=)?([0-9a-z]*)?"
VALID_URL_RE = (
r"(/(?P<storefront>[a-z]{2})/(?P<type>artist|album|playlist|song|music-video|post)/(?P<slug>[^/]*)(?:/(?P<id>[^/?]*))?(?:\?i=)?(?P<sub_id>[0-9a-z]*)?)|"
r"(/library/(?P<library_type>|playlist|albums)/(?P<library_id>[a-z]\.[0-9a-zA-Z]*))"
)
def __init__(
self,
@@ -125,47 +129,66 @@ class Downloader:
self.VALID_URL_RE,
url,
)
url_info.storefront = url_regex_result.group(1)
url_info.type = (
"song" if url_regex_result.group(5) else url_regex_result.group(2)
)
url_info.id = (
url_regex_result.group(5)
or url_regex_result.group(4)
or url_regex_result.group(3)
)
is_library = url_regex_result.group("library_type") is not None
if is_library:
url_info.type = url_regex_result.group("library_type")
url_info.id = url_regex_result.group("library_id")
else:
url_info.storefront = url_regex_result.group("storefront")
url_info.type = (
"song"
if url_regex_result.group("sub_id")
else url_regex_result.group("type")
)
url_info.id = (
url_regex_result.group("sub_id")
or url_regex_result.group("id")
or url_regex_result.group("sub_id")
)
url_info.is_library = is_library
return url_info
def get_download_queue(self, url_info: UrlInfo) -> DownloadQueue:
return self._get_download_queue(url_info.type, url_info.id)
return self._get_download_queue(url_info.type, url_info.id, url_info.is_library)
def _get_download_queue(self, url_type: str, id: str) -> DownloadQueue:
def _get_download_queue(
self,
url_type: str,
id: str,
is_library: bool,
) -> DownloadQueue:
download_queue = DownloadQueue()
if url_type == "artist":
artist = self.apple_music_api.get_artist(id)
download_queue.tracks_metadata = list(
download_queue.medias_metadata = list(
self.get_download_queue_from_artist(artist)
)
elif url_type == "song":
download_queue.tracks_metadata = [self.apple_music_api.get_song(id)]
elif url_type == "album":
album = self.apple_music_api.get_album(id)
download_queue.tracks_metadata = [
download_queue.medias_metadata = [self.apple_music_api.get_song(id)]
elif url_type in ("album", "albums"):
if is_library:
album = self.apple_music_api.get_library_album(id)
else:
album = self.apple_music_api.get_album(id)
download_queue.medias_metadata = [
track for track in album["relationships"]["tracks"]["data"]
]
elif url_type == "playlist":
playlist = self.apple_music_api.get_playlist(id)
if is_library:
playlist = self.apple_music_api.get_library_playlist(id)
download_queue.medias_metadata = [
track for track in playlist["relationships"]["tracks"]["data"]
]
else:
playlist = self.apple_music_api.get_playlist(id)
download_queue.medias_metadata = [
track for track in playlist["relationships"]["tracks"]["data"]
]
download_queue.playlist_attributes = playlist["attributes"]
download_queue.tracks_metadata = [
track
for track in self.apple_music_api.get_playlist(id)["relationships"][
"tracks"
]["data"]
]
elif url_type == "music-video":
download_queue.tracks_metadata = [self.apple_music_api.get_music_video(id)]
download_queue.medias_metadata = [self.apple_music_api.get_music_video(id)]
elif url_type == "post":
download_queue.tracks_metadata = [self.apple_music_api.get_post(id)]
download_queue.medias_metadata = [self.apple_music_api.get_post(id)]
return download_queue
def get_download_queue_from_artist(
@@ -249,6 +272,13 @@ class Downloader:
for music_video in selected:
yield music_video
def get_media_id(
self,
media_metadata: dict,
) -> str | None:
play_params = media_metadata["attributes"].get("playParams", {})
return play_params.get("catalogId") or play_params.get("id")
def get_playlist_tags(
self,
playlist_attributes: dict,
@@ -315,8 +345,8 @@ class Downloader:
def get_decryption_key(self, pssh: str, track_id: str) -> str:
try:
pssh_obj = PSSH(pssh.split(",")[-1])
cdm_session = self.cdm.open()
pssh_obj = PSSH(pssh.split(",")[-1])
challenge = base64.b64encode(
self.cdm.get_license_challenge(cdm_session, pssh_obj)
).decode()
@@ -391,6 +421,12 @@ class Downloader:
dirty_string = dirty_string[: self.truncate - 4]
return dirty_string.strip()
def get_final_file_extension(
self,
file_format: MediaFileFormat,
) -> str:
return "." + file_format.value
def get_final_path(self, tags: dict, file_extension: str) -> Path:
if tags.get("album"):
template_folder = (
@@ -419,8 +455,11 @@ class Downloader:
),
)
def get_cover_file_extension(self, cover_url: str) -> str:
image_obj = Image.open(io.BytesIO(self.get_url_response_bytes(cover_url)))
def get_cover_file_extension(self, cover_url: str) -> str | None:
cover_bytes = self.get_cover_url_response_bytes(cover_url)
if cover_bytes is None:
return None
image_obj = Image.open(io.BytesIO(self.get_cover_url_response_bytes(cover_url)))
image_format = image_obj.format.lower()
return IMAGE_FILE_EXTENSION_MAP.get(image_format, f".{image_format}")
@@ -453,9 +492,14 @@ class Downloader:
@staticmethod
@functools.lru_cache()
def get_url_response_bytes(url: str) -> bytes:
def get_cover_url_response_bytes(url: str) -> bytes | None:
response = requests.get(url)
response.raise_for_status()
if response.status_code == 200:
return response.content
elif response.status_code in (404, 400):
return None
else:
raise_response_exception(response)
return response.content
def apply_tags(
@@ -498,16 +542,18 @@ class Downloader:
"cover" not in self.exclude_tags_list
and self.cover_format != CoverFormat.RAW
):
mp4_tags["covr"] = [
MP4Cover(
self.get_url_response_bytes(cover_url),
imageformat=(
MP4Cover.FORMAT_JPEG
if self.cover_format == CoverFormat.JPG
else MP4Cover.FORMAT_PNG
),
)
]
cover_bytes = self.get_cover_url_response_bytes(cover_url)
if cover_bytes is not None:
mp4_tags["covr"] = [
MP4Cover(
self.get_cover_url_response_bytes(cover_url),
imageformat=(
MP4Cover.FORMAT_JPEG
if self.cover_format == CoverFormat.JPG
else MP4Cover.FORMAT_PNG
),
)
]
mp4 = MP4(path)
mp4.clear()
mp4.update(mp4_tags)
@@ -524,7 +570,7 @@ class Downloader:
@functools.lru_cache()
def save_cover(self, cover_path: Path, cover_url: str):
cover_path.parent.mkdir(parents=True, exist_ok=True)
cover_path.write_bytes(self.get_url_response_bytes(cover_url))
cover_path.write_bytes(self.get_cover_url_response_bytes(cover_url))
def cleanup_temp_path(self):
shutil.rmtree(self.temp_path)
+44 -18
View File
@@ -7,11 +7,12 @@ from pathlib import Path
import m3u8
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from urllib.parse import urlparse
from .constants import MUSIC_VIDEO_CODEC_MAP
from .downloader import Downloader
from .enums import MusicVideoCodec, RemuxMode
from .models import StreamInfo
from .enums import MediaFileFormat, MusicVideoCodec, RemuxFormatMusicVideo, RemuxMode
from .models import StreamInfo, StreamInfoAv
class DownloaderMusicVideo:
@@ -21,9 +22,11 @@ class DownloaderMusicVideo:
self,
downloader: Downloader,
codec: MusicVideoCodec = MusicVideoCodec.H264,
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
):
self.downloader = downloader
self.codec = codec
self.remux_format = remux_format
def get_stream_url_from_webplayback(self, webplayback: dict) -> str:
return webplayback["hls-playlist-url"]
@@ -151,8 +154,38 @@ class DownloaderMusicVideo:
stream_info.widevine_pssh = self.get_pssh(m3u8_data)
return stream_info
def get_music_video_id_alt(self, metadata: dict) -> str:
return metadata["attributes"]["url"].split("/")[-1].split("?")[0]
def get_stream_info(
self,
m3u8_master_data: dict,
) -> StreamInfoAv:
stream_info_video = self.get_stream_info_video(m3u8_master_data)
stream_info_audio = self.get_stream_info_audio(m3u8_master_data)
use_mp4 = (
any(
stream_info_video.codec.startswith(codec)
for codec in self.MP4_FORMAT_CODECS
)
or any(
stream_info_audio.codec.startswith(codec)
for codec in self.MP4_FORMAT_CODECS
)
or self.remux_format == RemuxFormatMusicVideo.MP4
)
if use_mp4:
file_format = MediaFileFormat.MP4
else:
file_format = MediaFileFormat.M4V
return StreamInfoAv(
video_track=stream_info_video,
audio_track=stream_info_audio,
file_format=file_format,
)
def get_music_video_id_alt(self, metadata: dict) -> str | None:
music_video_url = metadata["attributes"].get("url")
if music_video_url is None:
return None
return music_video_url.split("/")[-1].split("?")[0]
def get_tags(
self,
@@ -171,7 +204,7 @@ class DownloaderMusicVideo:
"media_type": 6,
"storefront": int(self.downloader.itunes_api.storefront_id.split("-")[0]),
"title": metadata_itunes[0]["trackCensoredName"],
"title_id": int(metadata["id"]),
"title_id": int(self.downloader.get_media_id(metadata)),
}
if metadata_itunes[0]["trackExplicitness"] == "notExplicit":
tags["rating"] = 0
@@ -205,8 +238,12 @@ class DownloaderMusicVideo:
def get_decrypted_path_audio(self, track_id: str) -> str:
return self.downloader.temp_path / f"decrypted_{track_id}.m4a"
def get_remuxed_path(self, track_id: str) -> str:
return self.downloader.temp_path / f"remuxed_{track_id}.m4v"
def get_remuxed_path(
self,
track_id: str,
file_extension: str,
) -> str:
return self.downloader.temp_path / (f"remuxed_{track_id}" + file_extension)
def decrypt(self, encrypted_path: Path, decryption_key: str, decrypted_path: Path):
subprocess.run(
@@ -250,12 +287,7 @@ 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,
@@ -268,8 +300,6 @@ class DownloaderMusicVideo:
decrypte_path_audio,
"-movflags",
"+faststart",
"-f",
"mp4" if use_mp4_flag else "ipod",
"-c",
"copy",
"-c:s",
@@ -285,8 +315,6 @@ 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(
@@ -299,8 +327,6 @@ class DownloaderMusicVideo:
decrypted_path_video,
decrypted_path_audio,
remuxed_path,
codec_video,
codec_audio,
)
def get_cover_path(self, final_path: Path, file_extension: str) -> Path:
+25 -22
View File
@@ -15,8 +15,8 @@ from InquirerPy.base.control import Choice
from .constants import SONG_CODEC_REGEX_MAP, SYNCED_LYRICS_FILE_EXTENSION_MAP
from .downloader import Downloader
from .enums import RemuxMode, SongCodec, SyncedLyricsFormat
from .models import Lyrics, StreamInfo
from .enums import MediaFileFormat, RemuxMode, SongCodec, SyncedLyricsFormat
from .models import Lyrics, StreamInfo, StreamInfoAv
class DownloaderSong:
@@ -128,26 +128,26 @@ class DownloaderSong:
"com.apple.streamingkeydelivery",
)
def get_stream_info(self, track_metadata: dict) -> StreamInfo:
def get_stream_info(self, track_metadata: dict) -> StreamInfoAv | None:
m3u8_url = track_metadata["attributes"]["extendedAssetUrls"].get("enhancedHls")
if not m3u8_url:
return StreamInfo()
return None
return self._get_stream_info(m3u8_url)
def _get_stream_info(self, m3u8_url: str) -> StreamInfo:
def _get_stream_info(self, m3u8_url: str) -> StreamInfoAv | None:
stream_info = StreamInfo()
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
return None
asset_infos = self.get_asset_infos(m3u8_data)
if self.codec == SongCodec.ASK:
playlist = self.get_playlist_from_user(m3u8_data)
else:
playlist = self.get_playlist_from_codec(m3u8_data)
if playlist is None:
return stream_info
return None
stream_info.stream_url = m3u8_obj.base_uri + playlist["uri"]
variant_id = playlist["stream_info"]["stable_variant_id"]
drm_ids = asset_infos[variant_id]["AUDIO-SESSION-KEY-IDS"]
@@ -160,7 +160,14 @@ class DownloaderSong:
stream_info.playready_pssh = playready_pssh
stream_info.fairplay_key = fairplay_key
stream_info.codec = playlist["stream_info"]["codecs"]
return stream_info
is_mp4 = any(
stream_info.codec.startswith(possible_codec)
for possible_codec in self.MP4_FORMAT_CODECS
)
return StreamInfoAv(
audio_track=stream_info,
file_format=MediaFileFormat.MP4 if is_mp4 else MediaFileFormat.M4A,
)
@staticmethod
def parse_datetime_obj_from_timestamp_ttml(
@@ -209,13 +216,13 @@ class DownloaderSong:
timestamp_srt_end = self.get_lyrics_synced_timestamp_srt(timestamp_ttml_end)
return f"{index}\n{timestamp_srt_start} --> {timestamp_srt_end}\n{text}\n"
def get_lyrics(self, track_metadata: dict) -> Lyrics:
def get_lyrics(self, track_metadata: dict) -> Lyrics | None:
lyrics = Lyrics()
if not track_metadata["attributes"]["hasLyrics"]:
return lyrics
return None
elif track_metadata.get("relationships") is None:
track_metadata = self.downloader.apple_music_api.get_song(
track_metadata["id"]
self.downloader.get_media_id(track_metadata)
)
if (
track_metadata["relationships"].get("lyrics")
@@ -301,8 +308,11 @@ class DownloaderSong:
def get_decrypted_path(self, track_id: str) -> Path:
return self.downloader.temp_path / f"{track_id}_decrypted.m4a"
def get_remuxed_path(self, track_id: str) -> Path:
return self.downloader.temp_path / f"{track_id}_remuxed.m4a"
def get_remuxed_path(self, track_id: str, file_format: MediaFileFormat) -> Path:
return (
self.downloader.temp_path
/ f"{track_id}_remuxed.{"m4a" if file_format == MediaFileFormat.M4A else "mp4"}"
)
def fix_key_id(self, encrypted_path: Path):
count = 0
@@ -339,11 +349,11 @@ class DownloaderSong:
**self.downloader.subprocess_additional_args,
)
def remux(self, decrypted_path: Path, remuxed_path: Path, codec: str):
def remux(self, decrypted_path: Path, remuxed_path: Path):
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, codec)
self.remux_ffmpeg(decrypted_path, remuxed_path)
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path):
subprocess.run(
@@ -366,12 +376,7 @@ class DownloaderSong:
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,
@@ -382,8 +387,6 @@ class DownloaderSong:
decrypted_path,
"-c",
"copy",
"-f",
"mp4" if use_mp4_format else "ipod",
"-movflags",
"+faststart",
remuxed_path,
+7 -4
View File
@@ -9,15 +9,15 @@ from pywidevine import PSSH
from pywidevine.license_protocol_pb2 import WidevinePsshData
from .downloader_song import DownloaderSong
from .enums import RemuxMode, SongCodec
from .models import StreamInfo
from .enums import MediaFileFormat, RemuxMode, SongCodec
from .models import StreamInfo, StreamInfoAv
class DownloaderSongLegacy(DownloaderSong):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def get_stream_info(self, webplayback: dict) -> StreamInfo:
def get_stream_info(self, webplayback: dict) -> StreamInfoAv:
flavor = "32:ctrp64" if self.codec == SongCodec.AAC_HE_LEGACY else "28:ctrp256"
stream_info = StreamInfo()
stream_info.stream_url = next(
@@ -25,7 +25,10 @@ class DownloaderSongLegacy(DownloaderSong):
)["URL"]
m3u8_obj = m3u8.load(stream_info.stream_url)
stream_info.widevine_pssh = m3u8_obj.keys[0].uri
return stream_info
return StreamInfoAv(
audio_track=stream_info,
file_format=MediaFileFormat.M4A,
)
def get_decryption_key(self, pssh: str, track_id: str) -> str:
try:
+11
View File
@@ -38,6 +38,17 @@ class MusicVideoCodec(Enum):
ASK = "ask"
class RemuxFormatMusicVideo(Enum):
M4V = "m4v"
MP4 = "mp4"
class MediaFileFormat(Enum):
M4A = "m4a"
MP4 = "mp4"
M4V = "m4v"
class PostQuality(Enum):
BEST = "best"
ASK = "ask"
+3 -3
View File
@@ -4,8 +4,8 @@ import functools
import requests
from .apple_music_api import AppleMusicApi
from .constants import STOREFRONT_IDS
from .utils import raise_response_exception
class ItunesApi:
@@ -58,7 +58,7 @@ class ItunesApi:
requests.exceptions.JSONDecodeError,
AssertionError,
):
AppleMusicApi._raise_response_exception(response)
raise_response_exception(response)
return resource
def get_itunes_page(
@@ -81,5 +81,5 @@ class ItunesApi:
requests.exceptions.JSONDecodeError,
AssertionError,
):
AppleMusicApi._raise_response_exception(response)
raise_response_exception(response)
return itunes_page
+11 -1
View File
@@ -2,18 +2,21 @@ from __future__ import annotations
from dataclasses import dataclass
from .enums import MediaFileFormat
@dataclass
class UrlInfo:
storefront: str = None
type: str = None
id: str = None
is_library: bool = None
@dataclass
class DownloadQueue:
playlist_attributes: dict = None
tracks_metadata: list[dict] = None
medias_metadata: list[dict] = None
@dataclass
@@ -29,3 +32,10 @@ class StreamInfo:
playready_pssh: str = None
fairplay_key: str = None
codec: str = None
@dataclass
class StreamInfoAv:
video_track: StreamInfo = None
audio_track: StreamInfo = None
file_format: MediaFileFormat = None
+44
View File
@@ -0,0 +1,44 @@
from pathlib import Path
import click
import colorama
import requests
from .constants import X_NOT_FOUND_STRING
def color_text(text: str, color) -> str:
return color + text + colorama.Style.RESET_ALL
def raise_response_exception(response: requests.Response):
raise Exception(
f"Request failed with status code {response.status_code}: {response.text}"
)
def prompt_path(is_file: bool, initial_path: Path, description: str) -> Path:
path_validator = click.Path(
exists=True,
file_okay=is_file,
dir_okay=not is_file,
path_type=Path,
)
while True:
try:
path_obj = path_validator.convert(initial_path, None, None)
break
except click.BadParameter as e:
path_str = click.prompt(
(
f"{X_NOT_FOUND_STRING.format(description, initial_path.absolute())} or "
"the specified path is not valid. "
"Move it to that location, type the path or drag and drop it here. "
"Then, press enter to continue"
),
default=str(initial_path),
show_default=False,
)
path_str = path_str.strip('"')
initial_path = Path(path_str)
return path_obj
+2 -3
View File
@@ -1,17 +1,16 @@
[project]
name = "gamdl"
description = "A Python CLI app for downloading Apple Music songs, music videos and post videos."
requires-python = ">=3.9"
requires-python = ">=3.10"
authors = [{ name = "glomatico" }]
dependencies = [
"click",
"colorama",
"inquirerpy",
"m3u8",
"mutagen",
"pillow",
"pywidevine",
"pyyaml",
"termcolor",
"yt-dlp",
]
readme = "README.md"
+1
View File
@@ -1,4 +1,5 @@
click
colorama
inquirerpy
m3u8
mutagen