Compare commits

...

98 Commits

Author SHA1 Message Date
Rafael Moraes 1013bd20b9 add mutagen to depedencies 2024-11-23 17:05:39 -03:00
Rafael Moraes 2c1fa9d99b bump version 2024-11-23 17:05:28 -03:00
Rafael Moraes fc1c161e30 Update README.md 2024-11-02 03:54:37 -03:00
Rafael Moraes 2f87902163 Update README.md 2024-11-01 13:38:54 -03:00
Rafael Moraes 9f7bb0d404 Update README.md 2024-11-01 13:37:28 -03:00
Rafael Moraes c653db00cf bump version 2024-10-31 13:59:02 -03:00
Rafael Moraes cdd574a349 Update README.md 2024-10-31 13:57:27 -03:00
Rafael Moraes afbe65707a replace print_exceptions with no_exceptions 2024-10-31 13:47:35 -03:00
Rafael Moraes 3998b698e0 fix for songs that don't have genre 2024-10-31 13:45:08 -03:00
Rafael Moraes a67c81bd22 remove ciso8601 from requirements 2024-10-27 12:37:40 -03:00
Rafael Moraes 9b0a2acc6f bump version 2024-10-27 12:36:46 -03:00
Rafael Moraes 4d904e2e7c address old python compatibility when parsing date 2024-10-27 12:36:12 -03:00
Rafael Moraes 2d3b2b6b1f replace ciso8601 with python datetime 2024-10-27 12:32:38 -03:00
Rafael Moraes 1ee8e2aa13 Merge pull request #139 from glomatico/glomatico-patch-1
Create LICENSE
2024-09-22 00:00:37 -03:00
Rafael Moraes fd6d8a0689 Create LICENSE 2024-09-22 00:00:26 -03:00
Rafael Moraes 50904e9c08 bump version 2024-09-13 21:20:33 -03:00
Rafael Moraes 66556eac0a update update_playlist_file 2024-09-13 21:20:20 -03:00
Rafael Moraes d97445ec9e check for synced_lyrics_only before saving cover 2024-09-08 00:26:56 -03:00
Rafael Moraes d6f30aa0a2 bump version 2024-09-08 00:21:47 -03:00
Rafael Moraes 42a17ca90f optimize stream_url fetching for music videos 2024-09-08 00:21:35 -03:00
Rafael Moraes 3ee0d28727 add from __future__ import annotations 2024-09-08 00:09:30 -03:00
Rafael Moraes 7b8875250c refactor get_playlist_file_path 2024-09-08 00:08:19 -03:00
Rafael Moraes 16734b8b64 refactor update_playlist_file 2024-09-08 00:07:54 -03:00
Rafael Moraes 475bddb5f7 refactor get_final_path 2024-09-08 00:07:30 -03:00
Rafael Moraes 63ba4b0824 change default value for template_file_playlist 2024-09-08 00:07:07 -03:00
Rafael Moraes 9d67e8f0f0 bump version 2024-08-29 22:50:49 -03:00
Rafael Moraes fcbe596a80 refactor to use webplayback api to get the stream url instead of itunes page 2024-08-29 22:50:19 -03:00
Rafael Moraes acd5fefb76 bump version 2024-08-05 11:14:39 -03:00
Rafael Moraes ed584cc9b9 skip to next track when track is not downloadable 2024-08-04 20:04:52 -03:00
Rafael Moraes bac8eb9254 remove useless save cover lines in music video 2024-08-04 20:02:41 -03:00
Rafael Moraes 71ac17cce2 improve post video download logic 2024-08-03 17:53:25 -03:00
Rafael Moraes a35a3835aa create parent directory before saving cover image 2024-08-03 17:49:42 -03:00
Rafael Moraes 091ca3bf53 fix get_playlist_file_path 2024-08-03 14:15:14 -03:00
Rafael Moraes e4498e11c0 bump version 2024-08-03 14:13:55 -03:00
Rafael Moraes 5a8c5d2c25 fix get_final_path 2024-08-03 14:13:38 -03:00
Rafael Moraes c21d50479f adjust updating m3u8 log message 2024-08-03 02:21:34 -03:00
Rafael Moraes b6d1f36281 run update_playlist_file only when track is downloaded 2024-08-03 02:19:15 -03:00
Rafael Moraes c0d1ec2383 specify netscape format on cookies instructions 2024-08-03 02:02:43 -03:00
Rafael Moraes 8c1a3dbe7d set default truncate value to null for avoiding confusion 2024-08-03 02:00:23 -03:00
Rafael Moraes aa71239eba fix artist links 2024-08-03 01:55:48 -03:00
Rafael Moraes c890068eb7 create ILLEGAL_CHAR_REPLACEMENT var 2024-08-03 01:45:53 -03:00
Rafael Moraes e3f96d8684 add encoding="utf8" when saving playlist 2024-08-03 01:41:22 -03:00
Rafael Moraes 238a8377e0 bump version 2024-08-03 01:38:14 -03:00
Rafael Moraes 6c3dff566b adjust m3u8 playlist log message 2024-08-03 00:39:37 -03:00
Rafael Moraes 07c847a788 rename save_playlist_file to save_playlist 2024-08-03 00:37:45 -03:00
Rafael Moraes 0ca56d24d7 update default file for truncate on config section 2024-08-03 00:37:08 -03:00
Rafael Moraes 566a8aa498 add save_playlist and template_file_playlist to config section 2024-08-03 00:36:47 -03:00
Rafael Moraes a7af9e704f add playlist tags to tags variables 2024-08-03 00:31:55 -03:00
Rafael Moraes 540009fc1b improve update_playlist_file 2024-08-03 00:28:06 -03:00
Rafael Moraes 19fdd85c35 add save_playlist_file option 2024-08-03 00:11:11 -03:00
Rafael Moraes 0cd87254d3 rework DownloadQueue and add playlist tags support 2024-08-02 23:47:31 -03:00
Rafael Moraes 6593644c72 rename metadata attribute from models to track_metadata 2024-08-02 22:39:30 -03:00
Rafael Moraes 005af07fcc create VALID_URL_REGEX variable 2024-08-02 22:22:19 -03:00
Rafael Moraes adabfd95bc adjust default truncate value to 0 2024-08-02 22:21:12 -03:00
Rafael Moraes 564ece387c handle no search results 2024-07-31 07:11:58 -03:00
Rafael Moraes 328428a520 Merge pull request #131 from dracarys69/patch-2
Update apple_music_api.py
2024-07-30 21:03:01 -03:00
dracarys69 2c70a23e59 Update apple_music_api.py
added search
2024-07-27 15:10:24 +03:00
Rafael Moraes 52288bb7af Update encoding for reading URLs from file 2024-07-08 16:04:45 -03:00
Rafael Moraes 281a357863 Update README.md 2024-06-21 00:15:49 -03:00
Rafael Moraes 744300e36b Bump version 2024-06-03 01:53:40 -03:00
Rafael Moraes e86f990395 Add pillow library to dependencies 2024-06-03 01:52:54 -03:00
Rafael Moraes abc2f8f2f2 Refactor get_cover_file_extension method to use IMAGE_FILE_EXTENSION_MAP 2024-06-03 01:51:48 -03:00
Rafael Moraes 2dabb1c6fe Add IMAGE_FILE_EXTENSION_MAP 2024-06-03 01:51:21 -03:00
Rafael Moraes 8b80b0c6c5 Update raw cover format description in README.md 2024-06-03 00:50:20 -03:00
Rafael Moraes eef659bac8 Update raw cover format description in README.md 2024-06-03 00:49:16 -03:00
Rafael Moraes 0f7c3795a7 Refactor cover image handling for downloader module in apply_tags 2024-06-03 00:34:35 -03:00
Rafael Moraes c84b1137c2 chore: Update cover path generation in downloader modules 2024-06-03 00:21:01 -03:00
Rafael Moraes ebdc82d68b docs: Clarify description for raw cover format in README.md 2024-06-02 21:24:22 -03:00
Rafael Moraes 85c1fdbfbb chore: Refactor get_url_response_bytes method to handle HTTP errors 2024-06-02 21:20:38 -03:00
Rafael Moraes 5990e5f722 Bump version to 2.2.5 2024-06-02 21:15:19 -03:00
Rafael Moraes b8bd406d74 chore: Add support for fetching raw cover images 2024-06-02 21:15:03 -03:00
Rafael Moraes 57ee6e1db8 Update README.md 2024-05-25 01:49:38 -03:00
Rafael Moraes a20feb2aa7 Update README.md 2024-05-25 01:47:21 -03:00
Rafael Moraes 60db7e0339 Update README.md 2024-05-25 01:44:59 -03:00
glomatico 575d2ee154 Update README.md 2024-05-20 15:58:12 -03:00
glomatico f5bb56cab7 lol 2024-05-20 15:58:04 -03:00
glomatico ecc7979d7e adjust log message 2024-05-20 12:52:21 -03:00
glomatico d129551b55 adjust log message 2024-05-20 12:51:47 -03:00
glomatico 08a5ac00d8 adjust get_playlist 2024-05-20 12:50:15 -03:00
glomatico 628c9786d5 remove unused import 2024-05-20 04:36:53 -03:00
glomatico 7de12c3da7 add storefront tag for post videos 2024-05-20 04:21:17 -03:00
glomatico 39d724c488 Update cli.py 2024-05-20 04:17:25 -03:00
glomatico 79e00e5e19 adjust some log messages 2024-05-20 04:15:54 -03:00
glomatico e90fd24af0 adjust temp_path for posts 2024-05-20 04:13:32 -03:00
glomatico d68edd5393 Update README.md 2024-05-20 04:09:31 -03:00
glomatico 5acefd9a06 Update README.md 2024-05-20 04:08:22 -03:00
glomatico 93b62cdde9 update description 2024-05-20 04:08:19 -03:00
glomatico fc61a51da2 Update README.md 2024-05-20 04:06:37 -03:00
glomatico 81b44a808d Add missing storefront id tag for music videos 2024-05-20 04:02:07 -03:00
glomatico 24f3af1a5e Update README.md 2024-05-16 23:11:26 -03:00
glomatico 4a469d74d3 Update README.md 2024-05-10 19:33:11 -03:00
glomatico 6122835caa Update README.md 2024-05-10 19:24:01 -03:00
glomatico be597f0de4 bump version 2024-05-10 12:42:41 -03:00
glomatico b10ab5332d adjust default nm3u8dlre path 2024-05-10 12:42:22 -03:00
glomatico 080413b183 bump version 2024-05-09 20:17:15 -03:00
glomatico f6443081ae add try-finally block in get_decryption_key 2024-05-09 20:16:56 -03:00
glomatico 8dcf10c221 whoops 2024-05-05 13:33:35 -03:00
glomatico 6f5efd1779 Update README.md 2024-05-05 13:32:21 -03:00
15 changed files with 532 additions and 284 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Glomatico
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+88 -59
View File
@@ -1,10 +1,10 @@
# Glomatico's Apple Music Downloader
A Python CLI app for downloading Apple Music songs/music videos/albums/playlists/posts.
A Python CLI app for downloading Apple Music songs/music videos/posts.
**Discord Server:** https://discord.gg/aBjMEZ9tnq
## Features
* Download songs in AAC/Spatial AAC/Dolby Atmos/ALAC*
* Download songs in AAC 256kbps and other codecs
* Download music videos up to 4K
* Download synced lyrics in LRC, SRT or TTML
* Choose between FFmpeg and MP4Box for remuxing
@@ -14,8 +14,8 @@ A Python CLI app for downloading Apple Music songs/music videos/albums/playlists
## Prerequisites
* Python 3.8 or higher
* The cookies file of your Apple Music account (requires an active subscription)
* You can get your cookies by using one of the following extensions on your browser of choice at the Apple Music website with your account signed in:
* The cookies file of your Apple Music browser session in Netscape format (requires an active subscription)
* To export your cookies, use one of the following browser extensions while signed in to Apple Music:
* Firefox: https://addons.mozilla.org/addon/export-cookies-txt
* Chromium based browsers: https://chrome.google.com/webstore/detail/gdocmgbfkjnnpapoeobnolbbkoibbcif
* FFmpeg on your system PATH
@@ -23,22 +23,38 @@ A Python CLI app for downloading Apple Music songs/music videos/albums/playlists
* Up to date binaries can be obtained from the links below:
* Windows: https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases
* Linux: https://johnvansickle.com/ffmpeg/
* (Optional) mp4decrypt on your system PATH
* Required to download music videos and songs in non-legacy formats.
* Binaries can be obtained from here: https://www.bento4.com/downloads/.
### 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 when setting `mp4box` as remux mode, for downloading music videos and for downloading songs in non-legacy formats.
* [MP4Box](https://gpac.io/downloads/gpac-nightly-builds/)
* Required when setting `mp4box` as remux mode.
* [N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest)
* Required when setting `nm3u8dlre` as download mode.
## Installation
1. Install the package `gamdl` using pip
```bash
pip install gamdl
```
2. Place your cookies file in the directory from which you will be running gamdl and name it `cookies.txt`.
2. Set up the cookies file.
* You can either move to the current directory from which you will be running Gamdl as `cookies.txt` or specify its path using the command-line arguments/config file.
## Usage
```bash
gamdl [OPTIONS] URLS...
```
### Supported URL types
Gamdl supports the following types of URLs:
* Song
* Album
* Playlist
* Music video
* Artist
* Post video/extra video
### Examples
* Download a song
```bash
@@ -53,46 +69,58 @@ gamdl [OPTIONS] URLS...
gamdl "https://music.apple.com/us/artist/rick-astley/669771"
```
### Interactive prompt controls
* Arrow keys - Move selection
* Space - Toggle selection
* Ctrl + A - Select all
* Enter - Confirm selection
## Configuration
You can configure gamdl by using the command line arguments or the config file. The config file is created automatically when you run gamdl for the first time at `~/.gamdl/config.json` on Linux and `%USERPROFILE%\.gamdl\config.json` on Windows. Config file values can be overridden using command line arguments.
| Command line argument / Config file key | Description | Default value |
| --------------------------------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------- |
| `--disable-music-video-skip` / `disable_music_video_skip` | Don't skip downloading music videos in albums/playlists. | `false` |
| `--save-cover`, `-s` / `save_cover` | Save cover as a separate file. | `false` |
| `--overwrite` / `overwrite` | Overwrite existing files. | `false` |
| `--read-urls-as-txt`, `-r` / - | Interpret URLs as paths to text files containing URLs separated by newlines. | `false` |
| `--synced-lyrics-only` / `synced_lyrics_only` | Download only the synced lyrics. | `false` |
| `--no-synced-lyrics` / `no_synced_lyrics` | Don't download the synced lyrics. | `false` |
| `--config-path` / - | Path to config file. | `<home>/.spotify-web-downloader/config.json` |
| `--log-level` / `log_level` | Log level. | `INFO` |
| `--print-exceptions` / `print_exceptions` | Print exceptions. | `false` |
| `--cookies-path`, `-c` / `cookies_path` | Path to .txt cookies file. | `./cookies.txt` |
| `--language`, `-l` / `language` | Metadata language as an ISO-2A language code (don't always work for videos). | `en-US` |
| `--output-path`, `-o` / `output_path` | Path to output directory. | `./Apple Music` |
| `--temp-path` / `temp_path` | Path to temporary directory. | `./temp` |
| `--wvd-path` / `wvd_path` | Path to .wvd file. | `null` |
| `--nm3u8dlre-path` / `nm3u8dlre_path` | Path to N_m3u8DL-RE binary. | `N_m3u8dl-RE` |
| `--mp4decrypt-path` / `mp4decrypt_path` | Path to mp4decrypt binary. | `mp4decrypt` |
| `--ffmpeg-path` / `ffmpeg_path` | Path to FFmpeg binary. | `ffmpeg` |
| `--mp4box-path` / `mp4box_path` | Path to MP4Box binary. | `MP4Box` |
| `--download-mode` / `download_mode` | Download mode. | `ytdlp` |
| `--remux-mode` / `remux_mode` | Remux mode. | `ffmpeg` |
| `--cover-format` / `cover_format` | Cover format. | `jpg` |
| `--template-folder-album` / `template_folder_album` | Template folder for tracks that are part of an album. | `{album_artist}/{album}` |
| `--template-folder-compilation` / `template_folder_compilation` | Template folder for tracks that are part of a compilation album. | `Compilations/{album}` |
| `--template-file-single-disc` / `template_file_single_disc` | Template file for the tracks that are part of a single-disc album. | `{track:02d} {title}` |
| `--template-file-multi-disc` / `template_file_multi_disc` | Template file for the tracks that are part of a multi-disc album. | `{disc}-{track:02d} {title}` |
| `--template-folder-no-album` / `template_folder_no_album` | Template folder for the tracks that are not part of an album. | `{artist}/Unknown Album` |
| `--template-file-no-album` / `template_file_no_album` | Template file for the tracks that are not part of an album. | `{title}` |
| `--template-date` / `template_date` | Date tag template. | `%Y-%m-%dT%H:%M:%SZ` |
| `--exclude-tags` / `exclude_tags` | Comma-separated tags to exclude. | `null` |
| `--cover-size` / `cover_size` | Cover size. | `1200` |
| `--truncate` / `truncate` | Maximum length of the file/folder names. | `40` |
| `--codec-song` / `codec_song` | Song codec. | `aac-legacy` |
| `--synced-lyrics-format` / `synced_lyrics_format` | Synced lyrics format. | `lrc` |
| `--codec-music-video` / `codec_music_video` | Music video codec. | `h264` |
| `--quality-post` / `quality_post` | Post video quality. | `best` |
| `--no-config-file`, `-n` / - | Do not use a config file. | `false` |
Gamdl can be configured by using the command line arguments or the config file.
The config file is created automatically when you run Gamdl for the first time at `~/.gamdl/config.json` on Linux and `%USERPROFILE%\.gamdl\config.json` on Windows.
Config file values can be overridden using command line arguments.
| Command line argument / Config file key | Description | Default value |
| --------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------- |
| `--disable-music-video-skip` / `disable_music_video_skip` | Don't skip downloading music videos in albums/playlists. | `false` |
| `--save-cover`, `-s` / `save_cover` | Save cover as a separate file. | `false` |
| `--overwrite` / `overwrite` | Overwrite existing files. | `false` |
| `--read-urls-as-txt`, `-r` / - | Interpret URLs as paths to text files containing URLs separated by newlines. | `false` |
| `--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` |
### Tags variables
@@ -119,6 +147,10 @@ The following variables can be used in the template folders/files and/or in the
* `genre_id`
* `lyrics`
* `media_type`
* `playlist_artist`
* `playlist_id`
* `playlist_title`
* `playlist_track`
* `rating`
* `storefront`
* `title`
@@ -131,25 +163,22 @@ The following variables can be used in the template folders/files and/or in the
### Remux modes
The following remux modes are available:
* `ffmpeg`
* Can be used without mp4decrypt only for songs and when using legacy song codecs
* `mp4box`
* Requires mp4decrypt
* Doesn't convert closed captions in music videos that have them
* Can be obtained from here: https://gpac.wp.imt.fr/downloads
### Download modes
The following download modes are available:
* `ytdlp`
* `nm3u8dlre`
* Faster than `ytdlp`
* Requires FFmpeg
* Can be obtained from here: https://github.com/nilaoda/N_m3u8DL-RE/releases
### Song codecs
The following codecs are available:
* `aac-legacy`
* `aac-he-legacy`
The following codecs are also available, **but are not guaranteed to work**, as currently most (or all) of the songs fails to be downloaded when using them:
* `aac`
* `aac-he`
* `aac-binaural`
@@ -160,22 +189,20 @@ The following codecs are available:
* `ac3`
* `alac`
* `ask`
* When using this option, gamdl will ask you which **non-legacy** codec to use that is available for the song.
**Support for non-legacy codecs are not guaranteed, as most of the songs cannot be downloaded when using non-legacy codecs.**
* When using this option, Gamdl will ask you which codec from this list to use that is available for the song.
### Music videos codecs
The following codecs are available:
* `h264` (up to 1080p, with AAC 256kbps)
* `h265` (up to 2160p, with AAC 256kpbs)
* `ask`
* When using this option, gamdl will ask you which audio and video codec to use that is available for the music video.
* 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` (up to 1080p, with AAC 256kbps)
* `ask`
* When using this option, gamdl will ask you which video quality to use that is available for the video.
* 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.
@@ -185,10 +212,12 @@ The following synced lyrics formats are available:
* `srt`
* `ttml`
* Native format for Apple Music synced lyrics.
* Highly unsupported by media players.
* Highly unsupported by most media players.
### Cover formats
The following cover formats are available:
* `jpg`
* `png`
* `raw`
* This format gets the raw cover without any processing.
* Note that when using this format, the cover image will not be embedded within the files. To address this, you can enable `save_cover` option to save the cover as a separate file.
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.2.2"
__version__ = "2.3.8"
+22 -3
View File
@@ -78,7 +78,7 @@ class AppleMusicApi:
try:
response.raise_for_status()
response_dict = response.json()
assert response_dict.get("data")
assert response_dict.get("data") or response_dict.get("results") is not None
except (
requests.HTTPError,
requests.exceptions.JSONDecodeError,
@@ -169,13 +169,12 @@ class AppleMusicApi:
def get_playlist(
self,
playlist_id: str,
is_library: bool = False,
limit_tracks: int = 300,
extend: str = "extendedAssetUrls",
fetch_all: bool = True,
) -> dict:
response = self.session.get(
f"{self.AMP_API_URL}/v1/{'me' if is_library else 'catalog'}/{self.storefront}/playlists/{playlist_id}",
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/playlists/{playlist_id}",
params={
"extend": extend,
"limit[tracks]": limit_tracks,
@@ -191,6 +190,26 @@ class AppleMusicApi:
playlist["relationships"]["tracks"]["data"].extend(additional_data)
return playlist
def search(
self,
term: str,
types: str = "songs,albums,artists,playlists",
limit: int = 25,
offset: int = 0,
) -> dict:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/search",
params={
"term": term,
"types": types,
"limit": limit,
"offset": offset,
},
)
self._check_amp_api_response(response)
return response.json()["results"]
def _extend_api_data(
self,
api_response: dict,
+168 -106
View File
@@ -97,6 +97,11 @@ def load_config_file(
is_flag=True,
help="Interpret URLs as paths to text files containing URLs separated by newlines",
)
@click.option(
"--save-playlist",
is_flag=True,
help="Save a M3U8 playlist file when downloading a playlist.",
)
@click.option(
"--synced-lyrics-only",
is_flag=True,
@@ -120,9 +125,9 @@ def load_config_file(
help="Log level.",
)
@click.option(
"--print-exceptions",
"--no-exceptions",
is_flag=True,
help="Print exceptions.",
help="Don't print exceptions.",
)
# API specific options
@click.option(
@@ -237,6 +242,12 @@ def load_config_file(
default=downloader_sig.parameters["template_file_no_album"].default,
help="Template file for the tracks that are not part of an album.",
)
@click.option(
"--template-file-playlist",
type=str,
default=downloader_sig.parameters["template_file_playlist"].default,
help="Template file for the M3U8 playlist.",
)
@click.option(
"--template-date",
type=str,
@@ -302,11 +313,12 @@ def main(
save_cover: bool,
overwrite: bool,
read_urls_as_txt: bool,
save_playlist: bool,
synced_lyrics_only: bool,
no_synced_lyrics: bool,
config_path: Path,
log_level: str,
print_exceptions: bool,
no_exceptions: bool,
cookies_path: Path,
language: str,
output_path: Path,
@@ -325,6 +337,7 @@ def main(
template_file_multi_disc: str,
template_folder_no_album: str,
template_file_no_album: str,
template_file_playlist: str,
template_date: str,
exclude_tags: str,
cover_size: int,
@@ -341,7 +354,7 @@ def main(
)
logger = logging.getLogger(__name__)
logger.setLevel(log_level)
logger.debug("Starting downloader")
logger.info("Starting Gamdl")
if not cookies_path.exists():
logger.critical(X_NOT_FOUND_STRING.format("Cookies file", cookies_path))
return
@@ -372,6 +385,7 @@ def main(
template_file_multi_disc,
template_folder_no_album,
template_file_no_album,
template_file_playlist,
template_date,
exclude_tags,
cover_size,
@@ -443,7 +457,7 @@ def main(
_urls = []
for url in urls:
if Path(url).exists():
_urls.extend(Path(url).read_text().splitlines())
_urls.extend(Path(url).read_text(encoding="utf-8").splitlines())
urls = _urls
for url_index, url in enumerate(urls, start=1):
url_progress = f"URL {url_index}/{len(urls)}"
@@ -451,30 +465,37 @@ def main(
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
except Exception as e:
error_count += 1
logger.error(
f'({url_progress}) Failed to check "{url}"',
exc_info=print_exceptions,
exc_info=not no_exceptions,
)
continue
for queue_index, queue_item in enumerate(download_queue, start=1):
queue_progress = f"Track {queue_index}/{len(download_queue)} from URL {url_index}/{len(urls)}"
track = queue_item.metadata
for download_index, track_metadata in enumerate(
download_queue_tracks_metadata, start=1
):
queue_progress = f"Track {download_index}/{len(download_queue_tracks_metadata)} from URL {url_index}/{len(urls)}"
try:
remuxed_path = None
if download_queue.playlist_attributes:
playlist_track = download_index
else:
playlist_track = None
logger.info(
f'({queue_progress}) Downloading "{track["attributes"]["name"]}"'
f'({queue_progress}) Downloading "{track_metadata["attributes"]["name"]}"'
)
if not track["attributes"].get("playParams"):
if not track_metadata["attributes"].get("playParams"):
logger.warning(
f"({queue_progress}) Track is not streamable, skipping"
)
continue
if (
(synced_lyrics_only and track["type"] != "songs")
or (track["type"] == "music-videos" and skip_mv)
(synced_lyrics_only and track_metadata["type"] != "songs")
or (track_metadata["type"] == "music-videos" and skip_mv)
or (
track["type"] == "music-videos"
track_metadata["type"] == "music-videos"
and url_info.type == "album"
and not disable_music_video_skip
)
@@ -482,18 +503,31 @@ def main(
logger.warning(
f"({queue_progress}) Track is not downloadable with current configuration, skipping"
)
elif track["type"] == "songs":
continue
elif track_metadata["type"] == "songs":
logger.debug("Getting lyrics")
lyrics = downloader_song.get_lyrics(track)
lyrics = downloader_song.get_lyrics(track_metadata)
logger.debug("Getting webplayback")
webplayback = apple_music_api.get_webplayback(track["id"])
webplayback = apple_music_api.get_webplayback(track_metadata["id"])
tags = downloader_song.get_tags(webplayback, lyrics.unsynced)
if playlist_track:
tags = {
**tags,
**downloader.get_playlist_tags(
download_queue.playlist_attributes,
playlist_track,
),
}
final_path = downloader.get_final_path(tags, ".m4a")
lyrics_synced_path = downloader_song.get_lyrics_synced_path(
final_path
)
cover_path = downloader_song.get_cover_path(final_path)
cover_url = downloader.get_cover_url(track)
cover_url = downloader.get_cover_url(track_metadata)
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
cover_path = downloader_song.get_cover_path(
final_path,
cover_file_extesion,
)
if synced_lyrics_only:
pass
elif final_path.exists() and not overwrite:
@@ -508,10 +542,12 @@ def main(
)
logger.debug("Getting decryption key")
decryption_key = downloader_song_legacy.get_decryption_key(
stream_info.pssh, track["id"]
stream_info.pssh, track_metadata["id"]
)
else:
stream_info = downloader_song.get_stream_info(track)
stream_info = downloader_song.get_stream_info(
track_metadata
)
if not stream_info.stream_url or not stream_info.pssh:
logger.warning(
f"({queue_progress}) Song is not downloadable or is not"
@@ -520,15 +556,23 @@ def main(
continue
logger.debug("Getting decryption key")
decryption_key = downloader.get_decryption_key(
stream_info.pssh, track["id"]
stream_info.pssh, track_metadata["id"]
)
encrypted_path = downloader_song.get_encrypted_path(track["id"])
decrypted_path = downloader_song.get_decrypted_path(track["id"])
remuxed_path = downloader_song.get_remuxed_path(track["id"])
logger.debug(f"Downloading to {encrypted_path}")
encrypted_path = downloader_song.get_encrypted_path(
track_metadata["id"]
)
decrypted_path = downloader_song.get_decrypted_path(
track_metadata["id"]
)
remuxed_path = downloader_song.get_remuxed_path(
track_metadata["id"]
)
logger.debug(f'Downloading to "{encrypted_path}"')
downloader.download(encrypted_path, stream_info.stream_url)
if codec_song in LEGACY_CODECS:
logger.debug(f"Remuxing/Decrypting to {remuxed_path}")
logger.debug(
f'Decrypting/Remuxing to "{decrypted_path}"/"{remuxed_path}"'
)
downloader_song_legacy.remux(
encrypted_path,
decrypted_path,
@@ -536,20 +580,16 @@ def main(
decryption_key,
)
else:
logger.debug(f"Decrypting to {decrypted_path}")
logger.debug(f'Decrypting to "{decrypted_path}"')
downloader_song.decrypt(
encrypted_path, decrypted_path, decryption_key
)
logger.debug(f"Remuxing to {final_path}")
logger.debug(f'Remuxing to "{final_path}"')
downloader_song.remux(
decrypted_path,
remuxed_path,
stream_info.codec,
)
logger.debug("Applying tags")
downloader.apply_tags(remuxed_path, tags, cover_url)
logger.debug(f"Moving to {final_path}")
downloader.move_to_output_path(remuxed_path, final_path)
if no_synced_lyrics or not lyrics.synced:
pass
elif lyrics_synced_path.exists() and not overwrite:
@@ -561,38 +601,52 @@ def main(
downloader_song.save_lyrics_synced(
lyrics_synced_path, lyrics.synced
)
if synced_lyrics_only or not save_cover:
pass
elif cover_path.exists() and not overwrite:
logger.debug(
f'Cover already exists at "{cover_path}", skipping'
)
else:
logger.debug(f'Saving cover to "{cover_path}"')
downloader.save_cover(cover_path, cover_url)
elif track["type"] == "music-videos":
elif track_metadata["type"] == "music-videos":
music_video_id_alt = downloader_music_video.get_music_video_id_alt(
track
track_metadata
)
logger.debug("Getting iTunes page")
itunes_page = itunes_api.get_itunes_page(
"music-video", music_video_id_alt
)
stream_url_master = downloader_music_video.get_stream_url_master(
itunes_page
)
if music_video_id_alt == track_metadata["id"]:
stream_url = (
downloader_music_video.get_stream_url_from_itunes_page(
itunes_page
)
)
else:
logger.debug("Getting webplayback")
webplayback = apple_music_api.get_webplayback(
track_metadata["id"]
)
stream_url = (
downloader_music_video.get_stream_url_from_webplayback(
webplayback
)
)
logger.debug("Getting M3U8 data")
m3u8_master_data = downloader_music_video.get_m3u8_master_data(
stream_url_master
)
m3u8_data = downloader_music_video.get_m3u8_master_data(stream_url)
tags = downloader_music_video.get_tags(
music_video_id_alt,
itunes_page,
track,
track_metadata,
)
if playlist_track:
tags = {
**tags,
**downloader.get_playlist_tags(
download_queue.playlist_attributes,
playlist_track,
),
}
final_path = downloader.get_final_path(tags, ".m4v")
cover_path = downloader_music_video.get_cover_path(final_path)
cover_url = downloader.get_cover_url(track)
cover_url = downloader.get_cover_url(track_metadata)
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
cover_path = downloader_music_video.get_cover_path(
final_path,
cover_file_extesion,
)
if final_path.exists() and not overwrite:
logger.warning(
f'({queue_progress}) Music video already exists at "{final_path}", skipping'
@@ -600,55 +654,59 @@ def main(
else:
logger.debug("Getting stream info")
stream_info_video, stream_info_audio = (
downloader_music_video.get_stream_info_video(
m3u8_master_data
),
downloader_music_video.get_stream_info_audio(
m3u8_master_data
),
downloader_music_video.get_stream_info_video(m3u8_data),
downloader_music_video.get_stream_info_audio(m3u8_data),
)
decryption_key_video = downloader.get_decryption_key(
stream_info_video.pssh, track["id"]
stream_info_video.pssh, track_metadata["id"]
)
decryption_key_audio = downloader.get_decryption_key(
stream_info_audio.pssh, track["id"]
stream_info_audio.pssh, track_metadata["id"]
)
encrypted_path_video = (
downloader_music_video.get_encrypted_path_video(track["id"])
downloader_music_video.get_encrypted_path_video(
track_metadata["id"]
)
)
encrypted_path_audio = (
downloader_music_video.get_encrypted_path_audio(track["id"])
downloader_music_video.get_encrypted_path_audio(
track_metadata["id"]
)
)
decrypted_path_video = (
downloader_music_video.get_decrypted_path_video(track["id"])
downloader_music_video.get_decrypted_path_video(
track_metadata["id"]
)
)
decrypted_path_audio = (
downloader_music_video.get_decrypted_path_audio(track["id"])
downloader_music_video.get_decrypted_path_audio(
track_metadata["id"]
)
)
remuxed_path = downloader_music_video.get_remuxed_path(
track["id"]
track_metadata["id"]
)
logger.debug(f"Downloading video to {encrypted_path_video}")
logger.debug(f'Downloading video to "{encrypted_path_video}"')
downloader.download(
encrypted_path_video, stream_info_video.stream_url
)
logger.debug(f"Downloading audio to {encrypted_path_audio}")
logger.debug(f'Downloading audio to "{encrypted_path_audio}"')
downloader.download(
encrypted_path_audio, stream_info_audio.stream_url
)
logger.debug(f"Decrypting video to {decrypted_path_video}")
logger.debug(f'Decrypting video to "{decrypted_path_video}"')
downloader_music_video.decrypt(
encrypted_path_video,
decryption_key_video,
decrypted_path_video,
)
logger.debug(f"Decrypting audio to {decrypted_path_audio}")
logger.debug(f'Decrypting audio to "{decrypted_path_audio}"')
downloader_music_video.decrypt(
encrypted_path_audio,
decryption_key_audio,
decrypted_path_audio,
)
logger.debug(f"Remuxing to {remuxed_path}")
logger.debug(f'Remuxing to "{remuxed_path}"')
downloader_music_video.remux(
decrypted_path_video,
decrypted_path_audio,
@@ -656,51 +714,55 @@ def main(
stream_info_video.codec,
stream_info_audio.codec,
)
logger.debug("Applying tags")
downloader.apply_tags(remuxed_path, tags, cover_url)
logger.debug(f"Moving to {final_path}")
downloader.move_to_output_path(remuxed_path, final_path)
if not save_cover:
pass
elif cover_path.exists() and not overwrite:
logger.debug(
f'Cover already exists at "{cover_path}", skipping'
)
else:
logger.debug(f'Saving cover to "{cover_path}"')
downloader.save_cover(cover_path, cover_url)
elif track["type"] == "uploaded-videos":
stream_url = downloader_post.get_stream_url(track)
tags = downloader_post.get_tags(track)
temp_path = downloader_post.get_temp_path(track["id"])
elif track_metadata["type"] == "uploaded-videos":
stream_url = downloader_post.get_stream_url(track_metadata)
tags = downloader_post.get_tags(track_metadata)
final_path = downloader.get_final_path(tags, ".m4v")
cover_path = downloader_music_video.get_cover_path(final_path)
cover_url = downloader.get_cover_url(track)
cover_url = downloader.get_cover_url(track_metadata)
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
cover_path = downloader_music_video.get_cover_path(
final_path,
cover_file_extesion,
)
if final_path.exists() and not overwrite:
logger.warning(
f'({queue_progress}) Post video already exists at "{final_path}", skipping'
)
else:
logger.debug(f"Downloading to {final_path}")
downloader.download_ytdlp(temp_path, stream_url)
logger.debug("Applying tags")
downloader.apply_tags(temp_path, tags, cover_url)
logger.debug(f"Moving to {final_path}")
downloader.move_to_output_path(temp_path, final_path)
if not save_cover:
pass
elif cover_path.exists() and not overwrite:
logger.debug(
f'Cover already exists at "{cover_path}", skipping'
remuxed_path = downloader_post.get_post_temp_path(
track_metadata["id"]
)
else:
logger.debug(f'Saving cover to "{cover_path}"')
downloader.save_cover(cover_path, cover_url)
logger.debug(f'Downloading to "{remuxed_path}"')
downloader.download_ytdlp(remuxed_path, stream_url)
if synced_lyrics_only or not save_cover:
pass
elif cover_path.exists() and not overwrite:
logger.debug(f'Cover already exists at "{cover_path}", skipping')
else:
logger.debug(f'Saving cover to "{cover_path}"')
downloader.save_cover(cover_path, cover_url)
if remuxed_path:
logger.debug("Applying tags")
downloader.apply_tags(remuxed_path, tags, cover_url)
logger.debug(f'Moving to "{final_path}"')
downloader.move_to_output_path(remuxed_path, final_path)
if (
not synced_lyrics_only
and save_playlist
and download_queue.playlist_attributes
):
playlist_file_path = downloader.get_playlist_file_path(tags)
logger.debug(f'Updating M3U8 playlist from "{playlist_file_path}"')
downloader.update_playlist_file(
playlist_file_path,
final_path,
playlist_track,
)
except Exception as e:
error_count += 1
logger.error(
f'({queue_progress}) Failed to download "{track["attributes"]["name"]}"',
exc_info=print_exceptions,
f'({queue_progress}) Failed to download "{track_metadata["attributes"]["name"]}"',
exc_info=not no_exceptions,
)
finally:
if temp_path.exists():
+6
View File
@@ -208,6 +208,12 @@ SYNCED_LYRICS_FILE_EXTENSION_MAP = {
}
IMAGE_FILE_EXTENSION_MAP = {
"jpeg": ".jpg",
"tiff": ".tif",
}
EXCLUDED_CONFIG_FILE_PARAMS = (
"urls",
"config_path",
+175 -78
View File
@@ -1,30 +1,35 @@
from __future__ import annotations
import base64
import datetime
import functools
import io
import re
import shutil
import subprocess
import typing
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 PIL import Image
from pywidevine import PSSH, Cdm, Device
from yt_dlp import YoutubeDL
from .apple_music_api import AppleMusicApi
from .constants import MP4_TAGS_MAP
from .constants import IMAGE_FILE_EXTENSION_MAP, MP4_TAGS_MAP
from .enums import CoverFormat, DownloadMode, RemuxMode
from .hardcoded_wvd import HARDCODED_WVD
from .itunes_api import ItunesApi
from .models import DownloadQueueItem, UrlInfo
from .models import DownloadQueue, UrlInfo
class Downloader:
ILLEGAL_CHARACTERS_REGEX = r'[\\/:*?"<>|;]'
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]*)?"
def __init__(
self,
@@ -33,7 +38,7 @@ class Downloader:
output_path: Path = Path("./Apple Music"),
temp_path: Path = Path("./temp"),
wvd_path: Path = None,
nm3u8dlre_path: str = "N_m3u8dl-RE",
nm3u8dlre_path: str = "N_m3u8DL-RE",
mp4decrypt_path: str = "mp4decrypt",
ffmpeg_path: str = "ffmpeg",
mp4box_path: str = "MP4Box",
@@ -46,10 +51,11 @@ class Downloader:
template_file_multi_disc: str = "{disc}-{track:02d} {title}",
template_folder_no_album: str = "{artist}/Unknown Album",
template_file_no_album: str = "{title}",
template_file_playlist: str = "Playlists/{playlist_artist}/{playlist_title}",
template_date: str = "%Y-%m-%dT%H:%M:%SZ",
exclude_tags: str = None,
cover_size: int = 1200,
truncate: int = 40,
truncate: int = None,
silent: bool = False,
):
self.apple_music_api = apple_music_api
@@ -70,6 +76,7 @@ class Downloader:
self.template_file_multi_disc = template_file_multi_disc
self.template_folder_no_album = template_folder_no_album
self.template_file_no_album = template_file_no_album
self.template_file_playlist = template_file_playlist
self.template_date = template_date
self.exclude_tags = exclude_tags
self.cover_size = cover_size
@@ -94,7 +101,8 @@ class Downloader:
)
def _set_truncate(self):
self.truncate = None if self.truncate < 4 else self.truncate
if self.truncate is not None:
self.truncate = None if self.truncate < 4 else self.truncate
def _set_subprocess_additional_args(self):
if self.silent:
@@ -114,7 +122,7 @@ class Downloader:
def get_url_info(self, url: str) -> UrlInfo:
url_info = UrlInfo()
url_regex_result = re.search(
r"/([a-z]{2})/(artist|album|playlist|song|music-video|post)/([^/]*)(?:/([^/?]*))?(?:\?i=)?([0-9a-z]*)?",
self.VALID_URL_RE,
url,
)
url_info.storefront = url_regex_result.group(1)
@@ -128,40 +136,42 @@ class Downloader:
)
return url_info
def get_download_queue(self, url_info: UrlInfo) -> list[DownloadQueueItem]:
def get_download_queue(self, url_info: UrlInfo) -> DownloadQueue:
return self._get_download_queue(url_info.type, url_info.id)
def _get_download_queue(self, url_type: str, id: str) -> list[DownloadQueueItem]:
download_queue = []
def _get_download_queue(self, url_type: str, id: str) -> DownloadQueue:
download_queue = DownloadQueue()
if url_type == "artist":
artist = self.apple_music_api.get_artist(id)
download_queue.extend(self.get_download_queue_from_artist(artist))
download_queue.tracks_metadata = list(
self.get_download_queue_from_artist(artist)
)
elif url_type == "song":
download_queue.append(DownloadQueueItem(self.apple_music_api.get_song(id)))
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.extend(
DownloadQueueItem(track)
for track in album["relationships"]["tracks"]["data"]
)
download_queue.tracks_metadata = [
track for track in album["relationships"]["tracks"]["data"]
]
elif url_type == "playlist":
download_queue.extend(
DownloadQueueItem(track)
playlist = self.apple_music_api.get_playlist(id)
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.append(
DownloadQueueItem(self.apple_music_api.get_music_video(id))
)
download_queue.tracks_metadata = [self.apple_music_api.get_music_video(id)]
elif url_type == "post":
download_queue.append(DownloadQueueItem(self.apple_music_api.get_post(id)))
else:
raise Exception(f"Invalid url type: {url_type}")
download_queue.tracks_metadata = [self.apple_music_api.get_post(id)]
return download_queue
def get_download_queue_from_artist(self, artist: dict) -> list[DownloadQueueItem]:
def get_download_queue_from_artist(
self,
artist: dict,
) -> typing.Generator[dict, None, None]:
media_type = inquirer.select(
message=f'Select which type to download for artist "{artist["attributes"]["name"]}":',
choices=[
@@ -175,18 +185,18 @@ class Downloader:
invalid_message="The artist doesn't have any items of this type",
).execute()
if media_type == "albums":
return self.select_albums_from_artist(
yield from self.select_albums_from_artist(
artist["relationships"]["albums"]["data"]
)
elif media_type == "music-videos":
return self.select_music_videos_from_artist(
yield from self.select_music_videos_from_artist(
artist["relationships"]["music-videos"]["data"]
)
def select_albums_from_artist(
self,
albums: list[dict],
) -> list[DownloadQueueItem]:
) -> typing.Generator[dict, None, None]:
choices = [
Choice(
name=" | ".join(
@@ -206,20 +216,16 @@ class Downloader:
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
for track in self.apple_music_api.get_album(album["id"])["relationships"][
"tracks"
]["data"]:
yield track
def select_music_videos_from_artist(
self,
music_videos: list[dict],
) -> list[DownloadQueueItem]:
) -> typing.Generator[dict, None, None]:
choices = [
Choice(
name=" | ".join(
@@ -240,33 +246,91 @@ class Downloader:
choices=choices,
multiselect=True,
).execute()
return [DownloadQueueItem(music_video) for music_video in selected]
for music_video in selected:
yield music_video
def get_playlist_tags(
self,
playlist_attributes: dict,
playlist_track: int,
) -> dict:
tags = {
"playlist_artist": playlist_attributes["curatorName"],
"playlist_id": playlist_attributes["playParams"]["id"],
"playlist_title": playlist_attributes["name"],
"playlist_track": playlist_track,
}
return tags
def get_playlist_file_path(
self,
tags: dict,
):
template_file = self.template_file_playlist.split("/")
return Path(
self.output_path,
*[
self.get_sanitized_string(i.format(**tags), True)
for i in template_file[0:-1]
],
*[
self.get_sanitized_string(template_file[-1].format(**tags), False)
+ ".m3u8"
],
)
def update_playlist_file(
self,
playlist_file_path: Path,
final_path: Path,
playlist_track: int,
):
playlist_file_path.parent.mkdir(parents=True, exist_ok=True)
playlist_file_path_parent_parts_len = len(playlist_file_path.parent.parts)
output_path_parts_len = len(self.output_path.parts)
final_path_relative = Path(
("../" * (playlist_file_path_parent_parts_len - output_path_parts_len)),
*final_path.parts[output_path_parts_len:],
)
playlist_file_lines = (
playlist_file_path.open("r", encoding="utf8").readlines()
if playlist_file_path.exists()
else []
)
if len(playlist_file_lines) < playlist_track:
playlist_file_lines.extend(
"\n" for _ in range(playlist_track - len(playlist_file_lines))
)
playlist_file_lines[playlist_track - 1] = final_path_relative.as_posix() + "\n"
with playlist_file_path.open("w", encoding="utf8") as playlist_file:
playlist_file.writelines(playlist_file_lines)
@staticmethod
def millis_to_min_sec(millis):
def millis_to_min_sec(millis) -> str:
minutes, seconds = divmod(millis // 1000, 60)
return f"{minutes:02d}:{seconds:02d}"
def sanitize_date(self, date: str):
datetime_obj = ciso8601.parse_datetime(date)
return datetime_obj.strftime(self.template_date)
def sanitize_date(self, date: str) -> datetime.datetime:
return datetime.datetime.fromisoformat(date[:-1]).strftime(self.template_date)
def get_decryption_key(self, pssh: str, track_id: str) -> str:
pssh_obj = PSSH(pssh.split(",")[-1])
cdm_session = self.cdm.open()
challenge = base64.b64encode(
self.cdm.get_license_challenge(cdm_session, pssh_obj)
).decode()
license = self.apple_music_api.get_widevine_license(
track_id,
pssh,
challenge,
)
self.cdm.parse_license(cdm_session, license)
decryption_key = next(
i for i in self.cdm.get_keys(cdm_session) if i.type == "CONTENT"
).key.hex()
self.cdm.close(cdm_session)
try:
pssh_obj = PSSH(pssh.split(",")[-1])
cdm_session = self.cdm.open()
challenge = base64.b64encode(
self.cdm.get_license_challenge(cdm_session, pssh_obj)
).decode()
license = self.apple_music_api.get_widevine_license(
track_id,
pssh,
challenge,
)
self.cdm.parse_license(cdm_session, license)
decryption_key = next(
i for i in self.cdm.get_keys(cdm_session) if i.type == "CONTENT"
).key.hex()
finally:
self.cdm.close(cdm_session)
return decryption_key
def download(self, path: Path, stream_url: str):
@@ -313,11 +377,15 @@ class Downloader:
)
def get_sanitized_string(self, dirty_string: str, is_folder: bool) -> str:
dirty_string = re.sub(self.ILLEGAL_CHARACTERS_REGEX, "_", dirty_string)
dirty_string = re.sub(
self.ILLEGAL_CHARS_RE,
self.ILLEGAL_CHAR_REPLACEMENT,
dirty_string,
)
if is_folder:
dirty_string = dirty_string[: self.truncate]
if dirty_string.endswith("."):
dirty_string = dirty_string[:-1] + "_"
dirty_string = dirty_string[:-1] + self.ILLEGAL_CHAR_REPLACEMENT
else:
if self.truncate is not None:
dirty_string = dirty_string[: self.truncate - 4]
@@ -325,34 +393,57 @@ class Downloader:
def get_final_path(self, tags: dict, file_extension: str) -> Path:
if tags.get("album"):
final_path_folder = (
template_folder = (
self.template_folder_compilation.split("/")
if tags.get("compilation")
else self.template_folder_album.split("/")
)
final_path_file = (
template_file = (
self.template_file_multi_disc.split("/")
if tags["disc_total"] > 1
else self.template_file_single_disc.split("/")
)
else:
final_path_folder = self.template_folder_no_album.split("/")
final_path_file = self.template_file_no_album.split("/")
final_path_folder = [
self.get_sanitized_string(i.format(**tags), True) for i in final_path_folder
]
final_path_file = [
self.get_sanitized_string(i.format(**tags), True)
for i in final_path_file[:-1]
] + [
self.get_sanitized_string(final_path_file[-1].format(**tags), False)
+ file_extension
]
return self.output_path.joinpath(*final_path_folder).joinpath(*final_path_file)
template_folder = self.template_folder_no_album.split("/")
template_file = self.template_file_no_album.split("/")
template_final = template_folder + template_file
return Path(
self.output_path,
*[
self.get_sanitized_string(i.format(**tags), True)
for i in template_final[0:-1]
],
(
self.get_sanitized_string(template_final[-1].format(**tags), False)
+ file_extension
),
)
def get_cover_file_extension(self, cover_url: str) -> str:
image_obj = Image.open(io.BytesIO(self.get_url_response_bytes(cover_url)))
image_format = image_obj.format.lower()
return IMAGE_FILE_EXTENSION_MAP.get(image_format, f".{image_format}")
def get_cover_url(self, metadata: dict) -> str:
if self.cover_format == CoverFormat.RAW:
return self._get_raw_cover_url(metadata["attributes"]["artwork"]["url"])
return self._get_cover_url(metadata["attributes"]["artwork"]["url"])
def _get_raw_cover_url(self, cover_url_template: str) -> str:
return re.sub(
r"image/thumb/",
"",
re.sub(
r"is1-ssl",
"a1",
re.sub(
r"/\{w\}x\{h\}([a-z]{2})\.jpg",
"",
cover_url_template,
),
),
)
def _get_cover_url(self, cover_url_template: str) -> str:
return re.sub(
r"\{w\}x\{h\}([a-z]{2})\.jpg",
@@ -363,7 +454,9 @@ class Downloader:
@staticmethod
@functools.lru_cache()
def get_url_response_bytes(url: str) -> bytes:
return requests.get(url).content
response = requests.get(url)
response.raise_for_status()
return response.content
def apply_tags(
self,
@@ -401,7 +494,10 @@ class Downloader:
and tags.get(tag_name) is not None
):
mp4_tags[MP4_TAGS_MAP[tag_name]] = [tags[tag_name]]
if "cover" not in self.exclude_tags_list:
if (
"cover" not in self.exclude_tags_list
and self.cover_format != CoverFormat.RAW
):
mp4_tags["covr"] = [
MP4Cover(
self.get_url_response_bytes(cover_url),
@@ -427,6 +523,7 @@ class Downloader:
@functools.lru_cache()
def save_cover(self, cover_path: Path, cover_url: str):
cover_path.parent.mkdir(parents=True, exist_ok=True)
cover_path.write_bytes(self.get_url_response_bytes(cover_url))
def cleanup_temp_path(self):
+12 -9
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import re
import subprocess
import urllib.parse
from pathlib import Path
@@ -26,17 +25,20 @@ class DownloaderMusicVideo:
self.downloader = downloader
self.codec = codec
def get_stream_url_master(self, itunes_page: dict) -> str:
return itunes_page["offers"][0]["assets"][0]["hlsUrl"]
def get_stream_url_from_webplayback(self, webplayback: dict) -> str:
return webplayback["hls-playlist-url"]
def get_m3u8_master_data(self, stream_url_master: str) -> dict:
url_parts = urllib.parse.urlparse(stream_url_master)
def get_stream_url_from_itunes_page(self, itunes_page: dict) -> dict:
stream_url = itunes_page["offers"][0]["assets"][0]["hlsUrl"]
url_parts = urllib.parse.urlparse(stream_url)
query = urllib.parse.parse_qs(url_parts.query, keep_blank_values=True)
query.update({"aec": "HD", "dsid": "1"})
stream_url_master_new = url_parts._replace(
return url_parts._replace(
query=urllib.parse.urlencode(query, doseq=True)
).geturl()
return m3u8.load(stream_url_master_new).data
def get_m3u8_master_data(self, stream_url_master: str) -> dict:
return m3u8.load(stream_url_master).data
def get_playlist_video(
self,
@@ -167,6 +169,7 @@ class DownloaderMusicVideo:
"genre": metadata_itunes[0]["primaryGenreName"],
"genre_id": int(itunes_page["genres"][0]["genreId"]),
"media_type": 6,
"storefront": int(self.downloader.itunes_api.storefront_id.split("-")[0]),
"title": metadata_itunes[0]["trackCensoredName"],
"title_id": int(metadata["id"]),
}
@@ -300,5 +303,5 @@ class DownloaderMusicVideo:
codec_audio,
)
def get_cover_path(self, final_path: Path) -> Path:
return final_path.with_suffix(f".{self.downloader.cover_format.value}")
def get_cover_path(self, final_path: Path, file_extension: str) -> Path:
return final_path.with_suffix(file_extension)
+2 -1
View File
@@ -67,7 +67,8 @@ class DownloaderPost:
"date": self.downloader.sanitize_date(attributes["uploadDate"]),
"title": attributes["name"],
"title_id": int(metadata["id"]),
"storefront": int(self.downloader.itunes_api.storefront_id.split("-")[0]),
}
def get_temp_path(self, track_id: str) -> Path:
def get_post_temp_path(self, track_id: str) -> Path:
return self.downloader.temp_path / f"{track_id}_temp.m4v"
+4 -4
View File
@@ -9,9 +9,9 @@ from pathlib import Path
from xml.dom import minidom
from xml.etree import ElementTree
import m3u8
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
import m3u8
from .constants import SONG_CODEC_REGEX_MAP, SYNCED_LYRICS_FILE_EXTENSION_MAP
from .downloader import Downloader
@@ -248,7 +248,7 @@ class DownloaderSong:
"disc": tags_raw["discNumber"],
"disc_total": tags_raw["discCount"],
"gapless": tags_raw["gapless"],
"genre": tags_raw["genre"],
"genre": tags_raw.get("genre"),
"genre_id": tags_raw["genreId"],
"lyrics": lyrics_unsynced if lyrics_unsynced else None,
"media_type": 1,
@@ -365,8 +365,8 @@ class DownloaderSong:
SYNCED_LYRICS_FILE_EXTENSION_MAP[self.synced_lyrics_format]
)
def get_cover_path(self, final_path: Path) -> Path:
return final_path.parent / f"Cover.{self.downloader.cover_format.value}"
def get_cover_path(self, final_path: Path, file_extension: str) -> Path:
return final_path.parent / ("Cover" + file_extension)
def save_lyrics_synced(self, lyrics_synced_path: Path, lyrics_synced: str):
lyrics_synced_path.parent.mkdir(parents=True, exist_ok=True)
+22 -18
View File
@@ -28,24 +28,28 @@ class DownloaderSongLegacy(DownloaderSong):
return stream_info
def get_decryption_key(self, pssh: str, track_id: str) -> str:
widevine_pssh_data = WidevinePsshData()
widevine_pssh_data.algorithm = 1
widevine_pssh_data.key_ids.append(base64.b64decode(pssh.split(",")[1]))
pssh_obj = PSSH(widevine_pssh_data.SerializeToString())
cdm_session = self.downloader.cdm.open()
challenge = base64.b64encode(
self.downloader.cdm.get_license_challenge(cdm_session, pssh_obj)
).decode()
license = self.downloader.apple_music_api.get_widevine_license(
track_id,
pssh,
challenge,
)
self.downloader.cdm.parse_license(cdm_session, license)
decryption_key = next(
i for i in self.downloader.cdm.get_keys(cdm_session) if i.type == "CONTENT"
).key.hex()
self.downloader.cdm.close(cdm_session)
try:
widevine_pssh_data = WidevinePsshData()
widevine_pssh_data.algorithm = 1
widevine_pssh_data.key_ids.append(base64.b64decode(pssh.split(",")[1]))
pssh_obj = PSSH(widevine_pssh_data.SerializeToString())
cdm_session = self.downloader.cdm.open()
challenge = base64.b64encode(
self.downloader.cdm.get_license_challenge(cdm_session, pssh_obj)
).decode()
license = self.downloader.apple_music_api.get_widevine_license(
track_id,
pssh,
challenge,
)
self.downloader.cdm.parse_license(cdm_session, license)
decryption_key = next(
i
for i in self.downloader.cdm.get_keys(cdm_session)
if i.type == "CONTENT"
).key.hex()
finally:
self.downloader.cdm.close(cdm_session)
return decryption_key
def decrypt(
+1
View File
@@ -46,3 +46,4 @@ class PostQuality(Enum):
class CoverFormat(Enum):
JPG = "jpg"
PNG = "png"
RAW = "raw"
+5 -2
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
from dataclasses import dataclass
@@ -9,8 +11,9 @@ class UrlInfo:
@dataclass
class DownloadQueueItem:
metadata: dict = None
class DownloadQueue:
playlist_attributes: dict = None
tracks_metadata: list[dict] = None
@dataclass
+3 -2
View File
@@ -1,13 +1,14 @@
[project]
name = "gamdl"
description = "A Python CLI app for downloading Apple Music songs/music videos/albums/playlists/posts."
description = "A Python CLI app for downloading Apple Music songs/music videos/posts."
requires-python = ">=3.8"
authors = [{ name = "glomatico" }]
dependencies = [
"ciso8601",
"click",
"inquirerpy",
"m3u8",
"mutagen",
"pillow",
"pywidevine",
"pyyaml",
"yt-dlp",
+2 -1
View File
@@ -1,7 +1,8 @@
ciso8601
click
inquirerpy
m3u8
mutagen
pillow
pywidevine
pyyaml
yt-dlp