Compare commits

...

117 Commits

Author SHA1 Message Date
Rafael Moraes 2001b19d8f bump version 2025-02-23 03:50:23 -03:00
Rafael Moraes 14814dd2da small refactor on cookies user input 2025-02-23 03:16:03 -03:00
Rafael Moraes 6fad41467f add termcolor to dependencies 2025-02-23 03:14:31 -03:00
Rafael Moraes 0868f1c28c set timezone to utc on parse_datetime_obj_from_timestamp_ttml 2025-02-23 03:12:29 -03:00
Rafael Moraes a964011507 implement custom logging formatter with colored output 2025-02-23 03:11:23 -03:00
Rafael Moraes 3a943d0154 refactor media user token retrieval from cookies 2025-02-23 02:13:09 -03:00
Rafael Moraes 84bf0a3c2b prompt user for cookies file path if not found 2025-02-23 02:12:15 -03:00
Rafael Moraes 93dda6889c update Python version requirement to 3.9 2025-02-16 10:43:05 -03:00
Rafael Moraes d62a1377f8 Update README.md 2025-01-29 16:35:36 -03:00
Rafael Moraes 3a2d521352 bump version to 2.3.9 2025-01-29 16:31:23 -03:00
Rafael Moraes c8f45110bd replace deprecated logger.warn with logger.warning 2025-01-29 16:30:08 -03:00
Rafael Moraes 36925025b7 fix: improve formatting in README.md for download modes section 2025-01-29 16:28:50 -03:00
Rafael Moraes d8937d9805 bump required Python version to 3.9 in pyproject.toml 2025-01-29 16:19:25 -03:00
Rafael Moraes 513db83645 update project description for clarity and completeness 2025-01-29 16:17:53 -03:00
Rafael Moraes 11f9b5a75c Update README.md for improved clarity and formatting 2025-01-29 16:15:43 -03:00
Rafael Moraes 1dd01368c3 Update downloader_song.py 2025-01-27 22:47:39 -03:00
Rafael Moraes 4fc8887101 refactor DRM handling to use widevine_pssh and add support for playready and fairplay keys 2025-01-27 22:47:24 -03:00
Rafael Moraes 9169665579 Merge pull request #159 from shafreeck/main
Fix handling of 'Favorite Songs' playlist
2025-01-27 22:39:56 -03:00
Shafreeck d053db96e8 handle missing curatorName in 'Favorite Songs' playlist
'Favorite Songs' is an automatically generated playlist by
Apple Music that lacks the curatorName attribute. Set the
default curator to 'Apple Music' to maintain consistency
with other system playlists.
2025-01-06 07:19:56 +08:00
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
16 changed files with 666 additions and 375 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.
+123 -120
View File
@@ -1,98 +1,119 @@
# Glomatico's Apple Music Downloader
A Python CLI app for downloading Apple Music songs/music videos/albums/playlists/posts.
# Glomaticos Apple Music Downloader
A Python CLI app for downloading Apple Music songs, music videos and post videos.
**Discord Server:** https://discord.gg/aBjMEZ9tnq
**Join our Discord Server:** https://discord.gg/aBjMEZ9tnq
## Features
* Download songs in AAC/Spatial AAC/Dolby Atmos/ALAC*
* Download music videos up to 4K
* Download synced lyrics in LRC, SRT or TTML
* Choose between FFmpeg and MP4Box for remuxing
* Choose between yt-dlp and N_m3u8DL-RE for downloading
* Highly customizable
* Use artist links to download all of their albums or music videos
* **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.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:
* Firefox: https://addons.mozilla.org/addon/export-cookies-txt
* Chromium based browsers: https://chrome.google.com/webstore/detail/gdocmgbfkjnnpapoeobnolbbkoibbcif
* FFmpeg on your system PATH
* Older versions of FFmpeg may not work.
* 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/.
* **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/).
### 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.
## 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.
* 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
### Examples
* Download a song
* Download a Song:
```bash
gamdl "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
```
* Download an album
* Download an Album:
```bash
gamdl "https://music.apple.com/us/album/whenever-you-need-somebody-2022-remaster/1624945511"
```
* Choose which albums or music videos to download from an artist
* 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
## 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 +140,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`
@@ -128,67 +153,45 @@ The following variables can be used in the template folders/files and/or in the
* `track_total`
* `xid`
### 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
### Remux Modes
* `ffmpeg`: Default remuxing mode.
* `mp4box`: Alternative remuxing mode (doesnt convert closed captions in music videos).
### 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
* `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.
### Song codecs
The following codecs are available:
* `aac-legacy`
* `aac-he-legacy`
* `aac`
* `aac-he`
* `aac-binaural`
* `aac-downmix`
* `aac-he-binaural`
* `aac-he-downmix`
* `atmos`
* `ac3`
* `alac`
* `ask`
* When using this option, gamdl will ask you which **non-legacy** codec to use that is available for the song.
**Support for non-legacy codecs are not guaranteed, as most of the songs cannot be downloaded when using non-legacy codecs.**
### 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.
### 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.
### 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.
Post videos doesn't require remuxing and are limited to `ytdlp` download mode.
* `best`: Up to 1080p with AAC 256kbps.
* `ask`: Prompt to choose available video quality.
### Synced lyrics formats
The following synced lyrics formats are available:
* `lrc`
* `srt`
* `ttml`
* Native format for Apple Music synced lyrics.
* Highly unsupported by 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
The following cover formats are available:
* `jpg`
* `png`
* `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.2.2"
__version__ = "2.4"
+26 -6
View File
@@ -37,6 +37,9 @@ class AppleMusicApi:
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",
@@ -44,9 +47,7 @@ class AppleMusicApi:
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"content-type": "application/json",
"Media-User-Token": self.session.cookies.get_dict().get(
"media-user-token", ""
),
"Media-User-Token": media_user_token,
"x-apple-renewal": "true",
"DNT": "1",
"Connection": "keep-alive",
@@ -78,7 +79,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 +170,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 +191,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,
+193 -119
View File
@@ -7,10 +7,12 @@ from enum import Enum
from pathlib import Path
import click
from termcolor import colored
from . import __version__
from .apple_music_api import AppleMusicApi
from .constants import *
from .custom_formatter import CustomFormatter
from .downloader import Downloader
from .downloader_music_video import DownloaderMusicVideo
from .downloader_post import DownloaderPost
@@ -97,6 +99,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 +127,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 +244,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 +315,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 +339,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,
@@ -335,16 +350,20 @@ def main(
quality_post: PostQuality,
no_config_file: bool,
):
logging.basicConfig(
format="[%(levelname)-8s %(asctime)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger(__name__)
logger.setLevel(log_level)
logger.debug("Starting downloader")
if not cookies_path.exists():
logger.critical(X_NOT_FOUND_STRING.format("Cookies file", cookies_path))
return
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(CustomFormatter())
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,
language=language,
@@ -372,6 +391,7 @@ def main(
template_file_multi_disc,
template_folder_no_album,
template_file_no_album,
template_file_playlist,
template_date,
exclude_tags,
cover_size,
@@ -426,7 +446,7 @@ def main(
logger.critical(X_NOT_FOUND_STRING.format("N_m3u8DL-RE", nm3u8dlre_path))
return
if not downloader.mp4decrypt_path_full:
logger.warn(
logger.warning(
X_NOT_FOUND_STRING.format("mp4decrypt", mp4decrypt_path)
+ ", music videos will not be downloaded"
)
@@ -434,47 +454,57 @@ def main(
else:
skip_mv = False
if codec_song not in LEGACY_CODECS:
logger.warn(
"You have chosen a non-legacy codec. Support for non-legacy codecs are not guaranteed, "
"as most of the songs cannot be downloaded when using non-legacy codecs."
logger.warning(
"You have chosen an experimental codec. "
"They're not guaranteed to work due to API limitations."
)
error_count = 0
if read_urls_as_txt:
_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)}"
url_progress = colored(f"URL {url_index}/{len(urls)}", "grey")
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
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 = colored(
f"Track {download_index}/{len(download_queue_tracks_metadata)} from URL {url_index}/{len(urls)}",
"grey",
)
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 +512,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,11 +551,16 @@ def main(
)
logger.debug("Getting decryption key")
decryption_key = downloader_song_legacy.get_decryption_key(
stream_info.pssh, track["id"]
stream_info.widevine_pssh, track_metadata["id"]
)
else:
stream_info = downloader_song.get_stream_info(track)
if not stream_info.stream_url or not stream_info.pssh:
stream_info = downloader_song.get_stream_info(
track_metadata
)
if (
not stream_info.stream_url
or not stream_info.widevine_pssh
):
logger.warning(
f"({queue_progress}) Song is not downloadable or is not"
" available in the chosen codec, skipping"
@@ -520,15 +568,23 @@ def main(
continue
logger.debug("Getting decryption key")
decryption_key = downloader.get_decryption_key(
stream_info.pssh, track["id"]
stream_info.widevine_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 +592,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 +613,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 +666,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.widevine_pssh, track_metadata["id"]
)
decryption_key_audio = downloader.get_decryption_key(
stream_info_audio.pssh, track["id"]
stream_info_audio.widevine_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 +726,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",
+20
View File
@@ -0,0 +1,20 @@
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)
+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.get("curatorName", "Apple Music"),
"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):
+14 -11
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,
@@ -134,7 +136,7 @@ class DownloaderMusicVideo:
stream_info.stream_url = playlist["uri"]
stream_info.codec = playlist["stream_info"]["codecs"]
m3u8_data = m3u8.load(stream_info.stream_url).data
stream_info.pssh = self.get_pssh(m3u8_data)
stream_info.widevine_pssh = self.get_pssh(m3u8_data)
return stream_info
def get_stream_info_audio(self, m3u8_master_data: dict) -> StreamInfo:
@@ -146,7 +148,7 @@ class DownloaderMusicVideo:
stream_info.stream_url = playlist["uri"]
stream_info.codec = playlist["group_id"]
m3u8_data = m3u8.load(stream_info.stream_url).data
stream_info.pssh = self.get_pssh(m3u8_data)
stream_info.widevine_pssh = self.get_pssh(m3u8_data)
return stream_info
def get_music_video_id_alt(self, metadata: dict) -> str:
@@ -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"
+45 -13
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
@@ -85,25 +85,48 @@ class DownloaderSong:
).execute()
return selected
def get_pssh(
def _get_drm_data(
self,
drm_infos: dict,
drm_ids: list,
drm_key: str,
) -> str | None:
drm_info = next(
(
drm_infos[drm_id]
for drm_id in drm_ids
if drm_infos[drm_id].get(
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
)
and drm_id != "1"
if drm_infos[drm_id].get(drm_key) and drm_id != "1"
),
None,
)
if not drm_info:
return None
return drm_info["urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"]["URI"]
return drm_info[drm_key]["URI"]
def get_widevine_pssh(
self,
drm_infos: dict,
drm_ids: list,
) -> str | None:
return self._get_drm_data(
drm_infos,
drm_ids,
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
)
def get_playready_pssh(self, drm_infos: dict, drm_ids: list) -> str | None:
return self._get_drm_data(
drm_infos,
drm_ids,
"com.microsoft.playready",
)
def get_fairplay_key(self, drm_infos: dict, drm_ids: list) -> str | None:
return self._get_drm_data(
drm_infos,
drm_ids,
"com.apple.streamingkeydelivery",
)
def get_stream_info(self, track_metadata: dict) -> StreamInfo:
m3u8_url = track_metadata["attributes"]["extendedAssetUrls"].get("enhancedHls")
@@ -128,8 +151,14 @@ class DownloaderSong:
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"]
pssh = self.get_pssh(drm_infos, drm_ids)
stream_info.pssh = pssh
widevine_pssh, playready_pssh, fairplay_key = (
self.get_widevine_pssh(drm_infos, drm_ids),
self.get_playready_pssh(drm_infos, drm_ids),
self.get_fairplay_key(drm_infos, drm_ids),
)
stream_info.widevine_pssh = widevine_pssh
stream_info.playready_pssh = playready_pssh
stream_info.fairplay_key = fairplay_key
stream_info.codec = playlist["stream_info"]["codecs"]
return stream_info
@@ -147,7 +176,10 @@ class DownloaderSong:
secs = float(f"{mins_secs_ms[-2]}.{mins_secs_ms[-1]}")
if len(mins_secs_ms) > 2:
mins = int(mins_secs_ms[-3])
return datetime.datetime.fromtimestamp((mins * 60) + secs + (ms / 1000))
return datetime.datetime.fromtimestamp(
(mins * 60) + secs + (ms / 1000),
tz=datetime.timezone.utc,
)
def get_lyrics_synced_timestamp_lrc(self, timestamp_ttml: str) -> str:
datetime_obj = self.parse_datetime_obj_from_timestamp_ttml(timestamp_ttml)
@@ -248,7 +280,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 +397,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)
+23 -19
View File
@@ -24,28 +24,32 @@ class DownloaderSongLegacy(DownloaderSong):
i for i in webplayback["assets"] if i["flavor"] == flavor
)["URL"]
m3u8_obj = m3u8.load(stream_info.stream_url)
stream_info.pssh = m3u8_obj.keys[0].uri
stream_info.widevine_pssh = m3u8_obj.keys[0].uri
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"
+8 -3
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
@@ -22,5 +25,7 @@ class Lyrics:
@dataclass
class StreamInfo:
stream_url: str = None
pssh: str = None
widevine_pssh: str = None
playready_pssh: str = None
fairplay_key: str = None
codec: str = None
+5 -3
View File
@@ -1,15 +1,17 @@
[project]
name = "gamdl"
description = "A Python CLI app for downloading Apple Music songs/music videos/albums/playlists/posts."
requires-python = ">=3.8"
description = "A Python CLI app for downloading Apple Music songs, music videos and post videos."
requires-python = ">=3.9"
authors = [{ name = "glomatico" }]
dependencies = [
"ciso8601",
"click",
"inquirerpy",
"m3u8",
"mutagen",
"pillow",
"pywidevine",
"pyyaml",
"termcolor",
"yt-dlp",
]
readme = "README.md"
+3 -1
View File
@@ -1,7 +1,9 @@
ciso8601
click
inquirerpy
m3u8
mutagen
pillow
pywidevine
pyyaml
termcolor
yt-dlp