mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-13 20:25:13 +03:00
Compare commits
285 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da6c84f3c0 | |||
| 636a227ba8 | |||
| 71643e04a3 | |||
| cd995ffcbd | |||
| eab33bc02c | |||
| ac0d9374fb | |||
| 7a72ecd301 | |||
| 2de68d5985 | |||
| 2e920b7306 | |||
| 8120e9e855 | |||
| 047e9dbed8 | |||
| e0bba0857a | |||
| 6736acc5b0 | |||
| 47521f1a82 | |||
| 4d33f3e101 | |||
| c827e26e43 | |||
| 1042e47c0b | |||
| 7f56f85f35 | |||
| 560585eaa8 | |||
| 0fc2f75e5b | |||
| 82143df91a | |||
| e89d1cb19a | |||
| 01dd232565 | |||
| c9e75ae2a2 | |||
| 9c26646636 | |||
| efc452ba47 | |||
| 57e9a1ca98 | |||
| ca939d5760 | |||
| 6786ae393d | |||
| 5458d7a1d4 | |||
| 49368e7bc9 | |||
| 621383a0d8 | |||
| e7a055b1b8 | |||
| bc070e4279 | |||
| 2b1d02257c | |||
| 3256aef9f8 | |||
| 501cd48474 | |||
| 9f31b99642 | |||
| e9525668d6 | |||
| 60a2ca76fb | |||
| b81f740e2b | |||
| f8fc4c66e6 | |||
| 74d1772173 | |||
| 63830f2444 | |||
| f0838de397 | |||
| dfe4e29ab5 | |||
| 0782daed51 | |||
| 27ad170adf | |||
| b9377dc8b0 | |||
| 5e413deb6d | |||
| af26e939e8 | |||
| 66a965ecf6 | |||
| 24de608bc8 | |||
| d0e2e08748 | |||
| 2223d36d5e | |||
| 3077456ab7 | |||
| bbd96cbe6b | |||
| ca16a208ba | |||
| c32c8622b7 | |||
| 132ae0ea56 | |||
| 70238facac | |||
| 4fb1fb609b | |||
| f97b3dba14 | |||
| 2da824ecbc | |||
| 24810da4b6 | |||
| f16a30549c | |||
| 2001b19d8f | |||
| 14814dd2da | |||
| 6fad41467f | |||
| 0868f1c28c | |||
| a964011507 | |||
| 3a943d0154 | |||
| 84bf0a3c2b | |||
| 93dda6889c | |||
| d62a1377f8 | |||
| 3a2d521352 | |||
| c8f45110bd | |||
| 36925025b7 | |||
| d8937d9805 | |||
| 513db83645 | |||
| 11f9b5a75c | |||
| 1dd01368c3 | |||
| 4fc8887101 | |||
| 9169665579 | |||
| d053db96e8 | |||
| 1013bd20b9 | |||
| 2c1fa9d99b | |||
| fc1c161e30 | |||
| 2f87902163 | |||
| 9f7bb0d404 | |||
| c653db00cf | |||
| cdd574a349 | |||
| afbe65707a | |||
| 3998b698e0 | |||
| a67c81bd22 | |||
| 9b0a2acc6f | |||
| 4d904e2e7c | |||
| 2d3b2b6b1f | |||
| 1ee8e2aa13 | |||
| fd6d8a0689 | |||
| 50904e9c08 | |||
| 66556eac0a | |||
| d97445ec9e | |||
| d6f30aa0a2 | |||
| 42a17ca90f | |||
| 3ee0d28727 | |||
| 7b8875250c | |||
| 16734b8b64 | |||
| 475bddb5f7 | |||
| 63ba4b0824 | |||
| 9d67e8f0f0 | |||
| fcbe596a80 | |||
| acd5fefb76 | |||
| ed584cc9b9 | |||
| bac8eb9254 | |||
| 71ac17cce2 | |||
| a35a3835aa | |||
| 091ca3bf53 | |||
| e4498e11c0 | |||
| 5a8c5d2c25 | |||
| c21d50479f | |||
| b6d1f36281 | |||
| c0d1ec2383 | |||
| 8c1a3dbe7d | |||
| aa71239eba | |||
| c890068eb7 | |||
| e3f96d8684 | |||
| 238a8377e0 | |||
| 6c3dff566b | |||
| 07c847a788 | |||
| 0ca56d24d7 | |||
| 566a8aa498 | |||
| a7af9e704f | |||
| 540009fc1b | |||
| 19fdd85c35 | |||
| 0cd87254d3 | |||
| 6593644c72 | |||
| 005af07fcc | |||
| adabfd95bc | |||
| 564ece387c | |||
| 328428a520 | |||
| 2c70a23e59 | |||
| 52288bb7af | |||
| 281a357863 | |||
| 744300e36b | |||
| e86f990395 | |||
| abc2f8f2f2 | |||
| 2dabb1c6fe | |||
| 8b80b0c6c5 | |||
| eef659bac8 | |||
| 0f7c3795a7 | |||
| c84b1137c2 | |||
| ebdc82d68b | |||
| 85c1fdbfbb | |||
| 5990e5f722 | |||
| b8bd406d74 | |||
| 57ee6e1db8 | |||
| a20feb2aa7 | |||
| 60db7e0339 | |||
| 575d2ee154 | |||
| f5bb56cab7 | |||
| ecc7979d7e | |||
| d129551b55 | |||
| 08a5ac00d8 | |||
| 628c9786d5 | |||
| 7de12c3da7 | |||
| 39d724c488 | |||
| 79e00e5e19 | |||
| e90fd24af0 | |||
| d68edd5393 | |||
| 5acefd9a06 | |||
| 93b62cdde9 | |||
| fc61a51da2 | |||
| 81b44a808d | |||
| 24f3af1a5e | |||
| 4a469d74d3 | |||
| 6122835caa | |||
| be597f0de4 | |||
| b10ab5332d | |||
| 080413b183 | |||
| f6443081ae | |||
| 8dcf10c221 | |||
| 6f5efd1779 | |||
| 06e43fdbbe | |||
| 646125b93f | |||
| ea281766ba | |||
| a1b0ad35ee | |||
| 2f715b3d9d | |||
| 461fcedf30 | |||
| 6d7cb3ada4 | |||
| b87d406ffa | |||
| f6efdb3332 | |||
| 29a006c304 | |||
| a49a9c90cc | |||
| 43fd1dd2e3 | |||
| 2ed6ac05ba | |||
| 0f0e17f4cd | |||
| 8c4d2713f7 | |||
| 1baca4151b | |||
| 6f08a4b2f9 | |||
| 38f708e2e9 | |||
| f27adf98df | |||
| 9f0b25e1d1 | |||
| 3590d99063 | |||
| b200dade5a | |||
| 118d23e9db | |||
| 7bc8c6668f | |||
| 0e9fb3702d | |||
| e706f0fa82 | |||
| 3014fb112d | |||
| d7f17b8b6f | |||
| 947e2df81a | |||
| c8fe96b31d | |||
| 83a3efc1fa | |||
| 345afbf174 | |||
| c35051a7ec | |||
| b286ee84e2 | |||
| 9094f2c7b4 | |||
| 5feb5b274a | |||
| 1375af929c | |||
| d280f1fad2 | |||
| 3a04d7927e | |||
| 942a812308 | |||
| 66e01293e6 | |||
| c0561da592 | |||
| 56d238fb1b | |||
| d3a53bf93b | |||
| bb7a3ff77e | |||
| bf6293a0a0 | |||
| c421b3e855 | |||
| 7e495300f9 | |||
| ccef00e39f | |||
| 94cdba313c | |||
| d7b19e8c67 | |||
| a6809df2ef | |||
| 40f3616bc3 | |||
| 104100e091 | |||
| ce4a7d7880 | |||
| ec09bacd39 | |||
| e0d3f46159 | |||
| 0bfb4d80b8 | |||
| 54d6d93967 | |||
| c13160b999 | |||
| ea4d574810 | |||
| 9da35c3f57 | |||
| ebb7ec1da7 | |||
| d155a42e3a | |||
| 8f18562e1c | |||
| 25ed506b82 | |||
| ba76241032 | |||
| c6f7e99135 | |||
| dbaa1faa6b | |||
| a61a9c4975 | |||
| 8ffe5c86ca | |||
| 88bdf64825 | |||
| 3849df9adb | |||
| a989ff6c34 | |||
| f3d583aab2 | |||
| 7ac3d3e400 | |||
| 085e8f1b5d | |||
| 4df36e60d9 | |||
| f6d726e466 | |||
| 61b1bf1e55 | |||
| 3ae6709ccb | |||
| 1f00e4fb9f | |||
| 714d47bb13 | |||
| 46e3a92d4f | |||
| 42b536d271 | |||
| dac8d5eed9 | |||
| 2956f20dfa | |||
| 8f76743a3b | |||
| 3096bbc79d | |||
| ed49d7bd5f | |||
| 0ea72d0b78 | |||
| ae490320ad | |||
| e40668e6ec | |||
| 62c695b5ff | |||
| 5d9c8c1f0b | |||
| 54d640230a | |||
| 3d272a6891 | |||
| e99ed0eb5a | |||
| 86b5029773 | |||
| 3df0a91d3f | |||
| d356596cf4 | |||
| cbd2df79b7 |
@@ -0,0 +1 @@
|
||||
ko_fi: glomatico
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.9
|
||||
cache: pip
|
||||
|
||||
@@ -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.
|
||||
@@ -1,177 +1,231 @@
|
||||
# Glomatico's Apple Music Downloader
|
||||
A Python script to download Apple Music songs/music videos/albums/playlists/post videos.
|
||||
# Glomatico’s Apple Music Downloader
|
||||
|
||||
A command-line app for downloading Apple Music songs, music videos and post videos.
|
||||
|
||||
**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
|
||||
|
||||
- **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.10 or higher** installed on your system.
|
||||
- The **cookies file** of your Apple Music browser session in Netscape format (requires an active subscription).
|
||||
- **Firefox**: Use the [Export Cookies](https://addons.mozilla.org/addon/export-cookies-txt) extension.
|
||||
- **Chromium-based Browsers**: Use the [Get cookies.txt LOCALLY](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc) extension.
|
||||
- **FFmpeg** on your system PATH.
|
||||
- **Windows**: Download from [AnimMouse’s FFmpeg Builds](https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases).
|
||||
- **Linux**: Download from [John Van Sickle’s FFmpeg Builds](https://johnvansickle.com/ffmpeg/).
|
||||
|
||||
### Optional dependencies
|
||||
|
||||
The following tools are optional but required for specific features. Add them to your system’s 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 in the same directory you will run the script from and name it as `cookies.txt`
|
||||
|
||||
```bash
|
||||
pip install gamdl
|
||||
```
|
||||
|
||||
2. Set up the cookies file.
|
||||
- Move the cookies file to the directory where you’ll 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
|
||||
* Download a song
|
||||
```bash
|
||||
gamdl "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1626265761?i=1626265765"
|
||||
```
|
||||
* Download an album
|
||||
```bash
|
||||
gamdl "https://music.apple.com/us/album/whenever-you-need-somebody-2022-remaster/1626265761"
|
||||
```
|
||||
|
||||
Run Gamdl with the following command:
|
||||
|
||||
```bash
|
||||
gamdl [OPTIONS] URLS...
|
||||
```
|
||||
|
||||
### Supported URL types
|
||||
|
||||
- Song
|
||||
- Public/Library Album
|
||||
- Public/Library Playlist
|
||||
- Music video
|
||||
- Artist
|
||||
- Post video
|
||||
|
||||
### Examples
|
||||
|
||||
- Download a Song:
|
||||
|
||||
```bash
|
||||
gamdl "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
|
||||
```
|
||||
|
||||
- Download an Album:
|
||||
|
||||
```bash
|
||||
gamdl "https://music.apple.com/us/album/whenever-you-need-somebody-2022-remaster/1624945511"
|
||||
```
|
||||
|
||||
- Download from an Artist:
|
||||
|
||||
```bash
|
||||
gamdl "https://music.apple.com/us/artist/rick-astley/669771"
|
||||
```
|
||||
|
||||
### 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. | `false` |
|
||||
| `--synced-lyrics-only` / `synced_lyrics_only` | Download only the synced lyrics. | `false` |
|
||||
| `--no-synced-lyrics` / `no_synced_lyrics` | Don't download the synced lyrics. | `false` |
|
||||
| `--config-path` / - | Path to config file. | `<home>/.spotify-web-downloader/config.json` |
|
||||
| `--log-level` / `log_level` | Log level. | `INFO` |
|
||||
| `--print-exceptions` / `print_exceptions` | Print exceptions. | `false` |
|
||||
| `--cookies-path`, `-c` / `cookies_path` | Path to .txt cookies file. | `./cookies.txt` |
|
||||
| `--output-path`, `-o` / `output_path` | Path to output directory. | `./Apple Music` |
|
||||
| `--temp-path` / `temp_path` | Path to temporary directory. | `./temp` |
|
||||
| `--wvd-path` / `wvd_path` | Path to .wvd file. | `null` |
|
||||
| `--nm3u8dlre-path` / `nm3u8dlre_path` | Path to N_m3u8DL-RE binary. | `N_m3u8dl-RE` |
|
||||
| `--mp4decrypt-path` / `mp4decrypt_path` | Path to mp4decrypt binary. | `mp4decrypt` |
|
||||
| `--ffmpeg-path` / `ffmpeg_path` | Path to FFmpeg binary. | `ffmpeg` |
|
||||
| `--mp4box-path` / `mp4box_path` | Path to MP4Box binary. | `MP4Box` |
|
||||
| `--download-mode` / `download_mode` | Download mode. | `ytdlp` |
|
||||
| `--remux-mode` / `remux_mode` | Remux mode. | `ffmpeg` |
|
||||
| `--cover-format` / `cover_format` | Cover format. | `jpg` |
|
||||
| `--template-folder-album` / `template_folder_album` | Template folder for tracks that are part of an album. | `{album_artist}/{album}` |
|
||||
| `--template-folder-compilation` / `template_folder_compilation` | Template folder for tracks that are part of a compilation album. | `Compilations/{album}` |
|
||||
| `--template-file-single-disc` / `template_file_single_disc` | Template file for the tracks that are part of a single-disc album. | `{track:02d} {title}` |
|
||||
| `--template-file-multi-disc` / `template_file_multi_disc` | Template file for the tracks that are part of a multi-disc album. | `{disc}-{track:02d} {title}` |
|
||||
| `--template-folder-no-album` / `template_folder_no_album` | Template folder for the tracks that are not part of an album. | `{artist}/Unknown Album` |
|
||||
| `--template-file-no-album` / `template_file_no_album` | Template file for the tracks that are not part of an album. | `{title}` |
|
||||
| `--template-date` / `template_date` | Date tag template. | `%Y-%m-%dT%H:%M:%SZ` |
|
||||
| `--exclude-tags` / `exclude_tags` | Comma-separated tags to exclude. | `null` |
|
||||
| `--cover-size` / `cover_size` | Cover size. | `1200` |
|
||||
| `--truncate` / `truncate` | Maximum length of the file/folder names. | `40` |
|
||||
| `--codec-song` / `codec_song` | Song codec. | `aac-legacy` |
|
||||
| `--synced-lyrics-format` / `synced_lyrics_format` | Synced lyrics format. | `lrc` |
|
||||
| `--codec-music-video` / `codec_music_video` | Music video codec. | `h264-best` |
|
||||
| `--quality-post` / `quality_post` | Post video quality. | `best` |
|
||||
| `--no-config-file`, `-n` / - | Do not use a config file. | `false` |
|
||||
|
||||
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` |
|
||||
| `--remux-format-music-video` / `remux_format_music_video` | Music video remux format. | `m4v` |
|
||||
| `--quality-post` / `quality_post` | Post video quality. | `best` |
|
||||
| `--no-config-file`, `-n` / - | Do not use a config file. | `false` |
|
||||
|
||||
### Tags variables
|
||||
The following variables can be used in the template folders/files and/or in the `exclude_tags` list:
|
||||
* `album`
|
||||
* `album_artist`
|
||||
* `album_id`
|
||||
* `album_sort`
|
||||
* `artist`
|
||||
* `artist_id`
|
||||
* `artist_sort`
|
||||
* `comment`
|
||||
* `compilation`
|
||||
* `composer`
|
||||
* `composer_id`
|
||||
* `composer_sort`
|
||||
* `copyright`
|
||||
* `cover`
|
||||
* `date`
|
||||
* `disc`
|
||||
* `disc_total`
|
||||
* `gapless`
|
||||
* `genre`
|
||||
* `genre_id`
|
||||
* `lyrics`
|
||||
* `media_type`
|
||||
* `rating`
|
||||
* `storefront`
|
||||
* `title`
|
||||
* `title_id`
|
||||
* `title_sort`
|
||||
* `track`
|
||||
* `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
|
||||
The following variables can be used in the template folders/files and/or in the `exclude_tags` list:
|
||||
|
||||
- `album`
|
||||
- `album_artist`
|
||||
- `album_id`
|
||||
- `album_sort`
|
||||
- `artist`
|
||||
- `artist_id`
|
||||
- `artist_sort`
|
||||
- `comment`
|
||||
- `compilation`
|
||||
- `composer`
|
||||
- `composer_id`
|
||||
- `composer_sort`
|
||||
- `copyright`
|
||||
- `cover`
|
||||
- `date`
|
||||
- `disc`
|
||||
- `disc_total`
|
||||
- `gapless`
|
||||
- `genre`
|
||||
- `genre_id`
|
||||
- `lyrics`
|
||||
- `media_type`
|
||||
- `playlist_artist`
|
||||
- `playlist_id`
|
||||
- `playlist_title`
|
||||
- `playlist_track`
|
||||
- `rating`
|
||||
- `storefront`
|
||||
- `title`
|
||||
- `title_id`
|
||||
- `title_sort`
|
||||
- `track`
|
||||
- `track_total`
|
||||
- `xid`
|
||||
|
||||
### Remux Modes
|
||||
|
||||
- `ffmpeg`: Default remuxing mode.
|
||||
- `mp4box`: Alternative remuxing mode (doesn’t 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
|
||||
The following codecs are available:
|
||||
* `aac-legacy`
|
||||
* `aac-he-legacy`
|
||||
* `aac`
|
||||
* `aac-he`
|
||||
* `aac-binaural`
|
||||
* `aac-downmix`
|
||||
* `aac-he-binaural`
|
||||
* `aac-he-downmix`
|
||||
* `alac`
|
||||
* `atmos`
|
||||
### Song Codecs
|
||||
|
||||
**Support for non-legacy codecs are not guaranteed, as most of the songs cannot be decrypted when using non-legacy 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### Music Videos Remux Formats
|
||||
|
||||
- `m4v`: Default remux format.
|
||||
- `mp4`
|
||||
|
||||
### Music videos codecs
|
||||
The following codecs are available:
|
||||
* `h264-best` (with AAC 256kbps, up to 1080p)
|
||||
* `h265-best` (With AAC 256kpbs, up to 2160p)
|
||||
* `ask`
|
||||
* When using this option, the script will ask you which audio and video codec to use.
|
||||
|
||||
### Post videos/extra videos qualities
|
||||
The following qualities are available:
|
||||
* `best` (with AAC 256kbps, up to 1080p)
|
||||
* `ask`
|
||||
* When using this option, the script will ask you which video quality to use.
|
||||
|
||||
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.
|
||||
|
||||
### Cover formats
|
||||
The following cover formats are available:
|
||||
* `jpg`
|
||||
* `png`
|
||||
|
||||
- `lrc`: Lightweight and widely supported.
|
||||
- `srt`: SubRip format (has more accurate timestamps).
|
||||
- `ttml`: Native Apple Music format (unsupported by most media players).
|
||||
|
||||
### Cover formats
|
||||
|
||||
- `jpg`: Default format.
|
||||
- `png`: Lossless format.
|
||||
- `raw`: Raw cover without processing (requires `save_cover` to save separately).
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
__version__ = "2.0"
|
||||
__version__ = "2.5.2"
|
||||
|
||||
+183
-49
@@ -3,14 +3,18 @@ from __future__ import annotations
|
||||
import functools
|
||||
import re
|
||||
import time
|
||||
import typing
|
||||
from http.cookiejar import MozillaCookieJar
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from .utils import raise_response_exception
|
||||
|
||||
|
||||
class AppleMusicApi:
|
||||
APPLE_MUSIC_HOMEPAGE_URL = "https://beta.music.apple.com"
|
||||
APPLE_MUSIC_HOMEPAGE_URL = "https://music.apple.com"
|
||||
AMP_API_URL = "https://amp-api.music.apple.com"
|
||||
WEBPLAYBACK_API_URL = (
|
||||
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback"
|
||||
@@ -20,39 +24,63 @@ class AppleMusicApi:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cookies_path: Path | None = Path("./cookies.txt"),
|
||||
storefront: None | str = None,
|
||||
storefront: str,
|
||||
media_user_token: str | None = None,
|
||||
language: str = "en-US",
|
||||
):
|
||||
self.cookies_path = cookies_path
|
||||
self.media_user_token = media_user_token
|
||||
self.storefront = storefront
|
||||
self.language = language
|
||||
self._set_session()
|
||||
|
||||
@classmethod
|
||||
def from_netscape_cookies(
|
||||
cls,
|
||||
cookies_path: Path = Path("./cookies.txt"),
|
||||
language: str = "en-US",
|
||||
) -> AppleMusicApi:
|
||||
parse_cookie = lambda name: next(
|
||||
(
|
||||
cookie.value
|
||||
for cookie in cookies
|
||||
if cookie.name == name
|
||||
and cookie.domain.endswith(
|
||||
urlparse(cls.APPLE_MUSIC_HOMEPAGE_URL).netloc
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
cookies = MozillaCookieJar(cookies_path)
|
||||
cookies.load(ignore_discard=True, ignore_expires=True)
|
||||
media_user_token = parse_cookie("media-user-token")
|
||||
if not media_user_token:
|
||||
raise ValueError(
|
||||
'"media-user-token" cookie not found in cookies. '
|
||||
"Make sure you have exported the cookies from Apple Music webpage and are logged in "
|
||||
"with an active subscription."
|
||||
)
|
||||
return cls(
|
||||
storefront=None,
|
||||
media_user_token=media_user_token,
|
||||
language=language,
|
||||
)
|
||||
|
||||
def _set_session(self):
|
||||
self.session = requests.Session()
|
||||
if self.cookies_path:
|
||||
cookies = MozillaCookieJar(self.cookies_path)
|
||||
cookies.load(ignore_discard=True, ignore_expires=True)
|
||||
self.session.cookies.update(cookies)
|
||||
self.storefront = self.session.cookies.get_dict()["itua"]
|
||||
self.session.headers.update(
|
||||
{
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0",
|
||||
"Accept": "application/json",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"content-type": "application/json",
|
||||
"Media-User-Token": self.session.cookies.get_dict().get(
|
||||
"media-user-token", ""
|
||||
),
|
||||
"x-apple-renewal": "true",
|
||||
"DNT": "1",
|
||||
"Connection": "keep-alive",
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-site",
|
||||
"accept": "*/*",
|
||||
"accept-language": "en-US",
|
||||
"origin": self.APPLE_MUSIC_HOMEPAGE_URL,
|
||||
"priority": "u=1, i",
|
||||
"referer": self.APPLE_MUSIC_HOMEPAGE_URL,
|
||||
"sec-ch-ua": '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"Windows"',
|
||||
"sec-fetch-dest": "empty",
|
||||
"sec-fetch-mode": "cors",
|
||||
"sec-fetch-site": "same-site",
|
||||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
|
||||
}
|
||||
)
|
||||
home_page = self.session.get(self.APPLE_MUSIC_HOMEPAGE_URL).text
|
||||
@@ -66,24 +94,63 @@ class AppleMusicApi:
|
||||
token = re.search('(?=eyJh)(.*?)(?=")', index_js_page).group(1)
|
||||
self.session.headers.update({"authorization": f"Bearer {token}"})
|
||||
self.session.params = {"l": self.language}
|
||||
if self.media_user_token:
|
||||
self.session.cookies.update(
|
||||
{
|
||||
"media-user-token": self.media_user_token,
|
||||
}
|
||||
)
|
||||
self._set_account_info()
|
||||
|
||||
@staticmethod
|
||||
def _raise_response_exception(response: requests.Response):
|
||||
raise Exception(
|
||||
f"Request failed with status code {response.status_code}: {response.text}"
|
||||
)
|
||||
def _set_account_info(self):
|
||||
self.account_info = self.get_account_info()
|
||||
self.storefront = self.account_info["meta"]["subscription"]["storefront"]
|
||||
|
||||
def _check_amp_api_response(self, response: requests.Response):
|
||||
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,
|
||||
AssertionError,
|
||||
):
|
||||
self._raise_response_exception(response)
|
||||
raise_response_exception(response)
|
||||
|
||||
def get_account_info(self, meta: str = "subscription") -> dict:
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/me/account",
|
||||
params={"meta": meta},
|
||||
)
|
||||
self._check_amp_api_response(response)
|
||||
return response.json()
|
||||
|
||||
def get_artist(
|
||||
self,
|
||||
artist_id: str,
|
||||
include: str = "albums,music-videos",
|
||||
limit: int = 100,
|
||||
fetch_all: bool = True,
|
||||
) -> dict:
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/artists/{artist_id}",
|
||||
params={
|
||||
"include": include,
|
||||
**{f"limit[{_include}]": limit for _include in include.split(",")},
|
||||
},
|
||||
)
|
||||
self._check_amp_api_response(response)
|
||||
artist = response.json()["data"][0]
|
||||
if fetch_all:
|
||||
for _include in include.split(","):
|
||||
for additional_data in self._extend_api_data(
|
||||
artist["relationships"][_include],
|
||||
limit,
|
||||
"",
|
||||
):
|
||||
artist["relationships"][_include]["data"].extend(additional_data)
|
||||
return artist
|
||||
|
||||
def get_song(
|
||||
self,
|
||||
@@ -143,13 +210,12 @@ class AppleMusicApi:
|
||||
def get_playlist(
|
||||
self,
|
||||
playlist_id: str,
|
||||
is_library: bool = False,
|
||||
limit_tracks: int = 300,
|
||||
extend: str = "extendedAssetUrls",
|
||||
full_playlist: bool = True,
|
||||
fetch_all: bool = True,
|
||||
) -> dict:
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/{'me' if is_library else 'catalog'}/{self.storefront}/playlists/{playlist_id}",
|
||||
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/playlists/{playlist_id}",
|
||||
params={
|
||||
"extend": extend,
|
||||
"limit[tracks]": limit_tracks,
|
||||
@@ -157,28 +223,96 @@ class AppleMusicApi:
|
||||
)
|
||||
self._check_amp_api_response(response)
|
||||
playlist = response.json()["data"][0]
|
||||
if full_playlist:
|
||||
playlist = self._extend_playlists_tracks(playlist, limit_tracks)
|
||||
if fetch_all:
|
||||
for additional_data in self._extend_api_data(
|
||||
playlist["relationships"]["tracks"],
|
||||
limit_tracks,
|
||||
extend,
|
||||
):
|
||||
playlist["relationships"]["tracks"]["data"].extend(additional_data)
|
||||
return playlist
|
||||
|
||||
def _extend_playlists_tracks(
|
||||
def search(
|
||||
self,
|
||||
playlist: dict,
|
||||
limit_tracks: int,
|
||||
term: str,
|
||||
types: str = "songs,albums,artists,playlists",
|
||||
limit: int = 25,
|
||||
offset: int = 0,
|
||||
) -> dict:
|
||||
playlist_next_uri = playlist["relationships"]["tracks"].get("next")
|
||||
while playlist_next_uri:
|
||||
playlist_next = self._get_playlist_next(playlist_next_uri, limit_tracks)
|
||||
playlist["relationships"]["tracks"]["data"].extend(playlist_next["data"])
|
||||
playlist_next_uri = playlist_next.get("next")
|
||||
time.sleep(self.WAIT_TIME)
|
||||
|
||||
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 get_library_album(self, album_id: str, extend: str = "extendedAssetUrls"):
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/me/library/albums/{album_id}",
|
||||
params={
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
self._check_amp_api_response(response)
|
||||
return response.json()["data"][0]
|
||||
|
||||
def get_library_playlist(
|
||||
self,
|
||||
playlist_id: str,
|
||||
include: str = "tracks",
|
||||
limit: int = 100,
|
||||
extend: str = "extendedAssetUrls",
|
||||
fetch_all: bool = True,
|
||||
) -> dict:
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/me/library/playlists/{playlist_id}",
|
||||
params={
|
||||
"include": include,
|
||||
**{f"limit[{_include}]": limit for _include in include.split(",")},
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
self._check_amp_api_response(response)
|
||||
playlist = response.json()["data"][0]
|
||||
if fetch_all:
|
||||
for additional_data in self._extend_api_data(
|
||||
playlist["relationships"]["tracks"],
|
||||
limit,
|
||||
extend,
|
||||
):
|
||||
playlist["relationships"]["tracks"]["data"].extend(additional_data)
|
||||
return playlist
|
||||
|
||||
def _get_playlist_next(self, playlist_next_uri: str, limit_tracks: int) -> dict:
|
||||
def _extend_api_data(
|
||||
self,
|
||||
api_response: dict,
|
||||
limit: int,
|
||||
extend: str,
|
||||
) -> typing.Generator[list[dict], None, None]:
|
||||
next_uri = api_response.get("next")
|
||||
while next_uri:
|
||||
playlist_next = self._get_next_uri_response(next_uri, limit, extend)
|
||||
yield playlist_next["data"]
|
||||
next_uri = playlist_next.get("next")
|
||||
time.sleep(self.WAIT_TIME)
|
||||
|
||||
def _get_next_uri_response(
|
||||
self,
|
||||
next_uri: str,
|
||||
limit: int,
|
||||
extend: str,
|
||||
) -> dict:
|
||||
response = self.session.get(
|
||||
self.AMP_API_URL + playlist_next_uri,
|
||||
self.AMP_API_URL + next_uri,
|
||||
params={
|
||||
"limit[tracks]": limit_tracks,
|
||||
"limit": limit,
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
self._check_amp_api_response(response)
|
||||
@@ -205,7 +339,7 @@ class AppleMusicApi:
|
||||
requests.exceptions.JSONDecodeError,
|
||||
AssertionError,
|
||||
):
|
||||
self._raise_response_exception(response)
|
||||
raise_response_exception(response)
|
||||
return webplayback[0]
|
||||
|
||||
def get_widevine_license(
|
||||
@@ -235,5 +369,5 @@ class AppleMusicApi:
|
||||
requests.exceptions.JSONDecodeError,
|
||||
AssertionError,
|
||||
):
|
||||
self._raise_response_exception(response)
|
||||
raise_response_exception(response)
|
||||
return widevine_license
|
||||
|
||||
+288
-156
@@ -7,24 +7,38 @@ from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import colorama
|
||||
|
||||
from . import __version__
|
||||
from .apple_music_api import AppleMusicApi
|
||||
from .constants import *
|
||||
from .custom_logger_formatter import CustomLoggerFormatter
|
||||
from .downloader import Downloader
|
||||
from .downloader_music_video import DownloaderMusicVideo
|
||||
from .downloader_post import DownloaderPost
|
||||
from .downloader_song import DownloaderSong
|
||||
from .downloader_song_legacy import DownloaderSongLegacy
|
||||
from .enums import CoverFormat, DownloadMode, MusicVideoCodec, PostQuality, RemuxMode
|
||||
from .enums import (
|
||||
CoverFormat,
|
||||
DownloadMode,
|
||||
MusicVideoCodec,
|
||||
PostQuality,
|
||||
RemuxFormatMusicVideo,
|
||||
RemuxMode,
|
||||
)
|
||||
from .itunes_api import ItunesApi
|
||||
from .utils import color_text, prompt_path
|
||||
|
||||
apple_music_api_sig = inspect.signature(AppleMusicApi.__init__)
|
||||
apple_music_api_from_netscape_cookies_sig = inspect.signature(
|
||||
AppleMusicApi.from_netscape_cookies
|
||||
)
|
||||
downloader_sig = inspect.signature(Downloader.__init__)
|
||||
downloader_song_sig = inspect.signature(DownloaderSong.__init__)
|
||||
downloader_music_video_sig = inspect.signature(DownloaderMusicVideo.__init__)
|
||||
downloader_post_sig = inspect.signature(DownloaderPost.__init__)
|
||||
|
||||
logger = logging.getLogger("gamdl")
|
||||
|
||||
|
||||
def get_param_string(param: click.Parameter) -> str:
|
||||
if isinstance(param.default, Enum):
|
||||
@@ -35,7 +49,7 @@ def get_param_string(param: click.Parameter) -> str:
|
||||
return param.default
|
||||
|
||||
|
||||
def write_default_config_file(ctx: click.Context) -> None:
|
||||
def write_default_config_file(ctx: click.Context):
|
||||
ctx.params["config_path"].parent.mkdir(parents=True, exist_ok=True)
|
||||
config_file = {
|
||||
param.name: get_param_string(param)
|
||||
@@ -95,7 +109,12 @@ def load_config_file(
|
||||
"--read-urls-as-txt",
|
||||
"-r",
|
||||
is_flag=True,
|
||||
help="Interpret URLs as paths to text files containing URLs.",
|
||||
help="Interpret URLs as paths to text files containing URLs separated by newlines",
|
||||
)
|
||||
@click.option(
|
||||
"--save-playlist",
|
||||
is_flag=True,
|
||||
help="Save a M3U8 playlist file when downloading a playlist.",
|
||||
)
|
||||
@click.option(
|
||||
"--synced-lyrics-only",
|
||||
@@ -115,23 +134,32 @@ def load_config_file(
|
||||
)
|
||||
@click.option(
|
||||
"--log-level",
|
||||
type=str,
|
||||
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]),
|
||||
default="INFO",
|
||||
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(
|
||||
"--cookies-path",
|
||||
"-c",
|
||||
type=Path,
|
||||
default=apple_music_api_sig.parameters["cookies_path"].default,
|
||||
default=apple_music_api_from_netscape_cookies_sig.parameters[
|
||||
"cookies_path"
|
||||
].default,
|
||||
help="Path to .txt cookies file.",
|
||||
)
|
||||
@click.option(
|
||||
"--language",
|
||||
"-l",
|
||||
type=str,
|
||||
default=apple_music_api_from_netscape_cookies_sig.parameters["language"].default,
|
||||
help="Metadata language as an ISO-2A language code (don't always work for videos).",
|
||||
)
|
||||
# Downloader specific options
|
||||
@click.option(
|
||||
"--output-path",
|
||||
@@ -230,6 +258,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,
|
||||
@@ -274,6 +308,12 @@ def load_config_file(
|
||||
default=downloader_music_video_sig.parameters["codec"].default,
|
||||
help="Music video codec.",
|
||||
)
|
||||
@click.option(
|
||||
"--remux-format-music-video",
|
||||
type=RemuxFormatMusicVideo,
|
||||
default=downloader_music_video_sig.parameters["remux_format"].default,
|
||||
help="Music video remux format.",
|
||||
)
|
||||
# DownloaderPost specific options
|
||||
@click.option(
|
||||
"--quality-post",
|
||||
@@ -295,12 +335,14 @@ 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,
|
||||
temp_path: Path,
|
||||
wvd_path: Path,
|
||||
@@ -317,6 +359,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,
|
||||
@@ -324,17 +367,32 @@ def main(
|
||||
codec_song: SongCodec,
|
||||
synced_lyrics_format: SyncedLyricsFormat,
|
||||
codec_music_video: MusicVideoCodec,
|
||||
remux_format_music_video: RemuxFormatMusicVideo,
|
||||
quality_post: PostQuality,
|
||||
no_config_file: bool,
|
||||
):
|
||||
logging.basicConfig(
|
||||
format="[%(levelname)-8s %(asctime)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
colorama.just_fix_windows_console()
|
||||
logger.setLevel(log_level)
|
||||
logger.debug("Starting downloader")
|
||||
apple_music_api = AppleMusicApi(cookies_path)
|
||||
stream_handler = logging.StreamHandler()
|
||||
stream_handler.setFormatter(CustomLoggerFormatter())
|
||||
logger.addHandler(stream_handler)
|
||||
logger.info("Starting Gamdl")
|
||||
cookies_path = prompt_path(True, cookies_path, "Cookies file")
|
||||
apple_music_api = AppleMusicApi.from_netscape_cookies(
|
||||
cookies_path,
|
||||
language,
|
||||
)
|
||||
if not apple_music_api.account_info["meta"]["subscription"]["active"]:
|
||||
logger.critical(
|
||||
"No active Apple Music subscription found, you won't be able to download"
|
||||
" anything"
|
||||
)
|
||||
return
|
||||
if apple_music_api.account_info["data"][0]["attributes"].get("restrictions"):
|
||||
logger.warning(
|
||||
"Your account has content restrictions enabled, some content may not be"
|
||||
" downloadable"
|
||||
)
|
||||
itunes_api = ItunesApi(
|
||||
apple_music_api.storefront,
|
||||
apple_music_api.language,
|
||||
@@ -358,10 +416,12 @@ def main(
|
||||
template_file_multi_disc,
|
||||
template_folder_no_album,
|
||||
template_file_no_album,
|
||||
template_file_playlist,
|
||||
template_date,
|
||||
exclude_tags,
|
||||
cover_size,
|
||||
truncate,
|
||||
log_level in ("WARNING", "ERROR"),
|
||||
)
|
||||
downloader_song = DownloaderSong(
|
||||
downloader,
|
||||
@@ -375,15 +435,16 @@ def main(
|
||||
downloader_music_video = DownloaderMusicVideo(
|
||||
downloader,
|
||||
codec_music_video,
|
||||
remux_format_music_video,
|
||||
)
|
||||
downloader_post = DownloaderPost(
|
||||
downloader,
|
||||
quality_post,
|
||||
)
|
||||
skip_mv = False
|
||||
if not synced_lyrics_only:
|
||||
if wvd_path and not wvd_path.exists():
|
||||
logger.critical(X_NOT_FOUND_STRING.format(".wvd file", wvd_path))
|
||||
return
|
||||
if wvd_path:
|
||||
wvd_path = prompt_path(True, wvd_path, ".wvd file")
|
||||
logger.debug("Setting up CDM")
|
||||
downloader.set_cdm()
|
||||
if not downloader.ffmpeg_path_full and (
|
||||
@@ -412,45 +473,64 @@ 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"
|
||||
)
|
||||
skip_mv = True
|
||||
else:
|
||||
skip_mv = False
|
||||
if codec_song not in 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 = [url.strip() for url in Path(urls[0]).read_text().splitlines()]
|
||||
_urls = []
|
||||
for url in urls:
|
||||
if Path(url).exists():
|
||||
_urls.extend(Path(url).read_text(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 = color_text(f"URL {url_index}/{len(urls)}", colorama.Style.DIM)
|
||||
try:
|
||||
logger.info(f'({url_progress}) Checking "{url}"')
|
||||
url_info = downloader.get_url_info(url)
|
||||
download_queue = downloader.get_download_queue(url_info)
|
||||
download_queue_medias_metadata = download_queue.medias_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, media_metadata in enumerate(
|
||||
download_queue_medias_metadata, start=1
|
||||
):
|
||||
queue_progress = color_text(
|
||||
f"Track {download_index}/{len(download_queue_medias_metadata)} from URL {url_index}/{len(urls)}",
|
||||
colorama.Style.DIM,
|
||||
)
|
||||
try:
|
||||
media_id = downloader.get_media_id(media_metadata)
|
||||
remuxed_path = None
|
||||
if download_queue.playlist_attributes:
|
||||
playlist_track = download_index
|
||||
else:
|
||||
playlist_track = None
|
||||
logger.info(
|
||||
f'({queue_progress}) Downloading "{track["attributes"]["name"]}"'
|
||||
f'({queue_progress}) Downloading "{media_metadata["attributes"]["name"]}"'
|
||||
)
|
||||
if not track["attributes"].get("playParams"):
|
||||
if media_id is None:
|
||||
logger.warning(
|
||||
f"({queue_progress}) Track is not streamable, skipping"
|
||||
f"({queue_progress}) Track is not streamable or downloadable, skipping"
|
||||
)
|
||||
continue
|
||||
if (
|
||||
(synced_lyrics_only and track["type"] != "songs")
|
||||
or (track["type"] == "music-videos" and skip_mv)
|
||||
(synced_lyrics_only and media_metadata["type"] != "songs")
|
||||
or (media_metadata["type"] == "music-videos" and skip_mv)
|
||||
or (
|
||||
track["type"] == "music-videos"
|
||||
media_metadata["type"] == "music-videos"
|
||||
and url_info.type == "album"
|
||||
and not disable_music_video_skip
|
||||
)
|
||||
@@ -458,18 +538,37 @@ def main(
|
||||
logger.warning(
|
||||
f"({queue_progress}) Track is not downloadable with current configuration, skipping"
|
||||
)
|
||||
elif track["type"] == "songs":
|
||||
continue
|
||||
elif media_metadata["type"] in ("songs", "library-songs"):
|
||||
logger.debug("Getting lyrics")
|
||||
lyrics = downloader_song.get_lyrics(track)
|
||||
lyrics = downloader_song.get_lyrics(media_metadata)
|
||||
logger.debug("Getting webplayback")
|
||||
webplayback = apple_music_api.get_webplayback(track["id"])
|
||||
tags = downloader_song.get_tags(webplayback, lyrics.unsynced)
|
||||
webplayback = apple_music_api.get_webplayback(media_id)
|
||||
tags = downloader_song.get_tags(
|
||||
webplayback,
|
||||
lyrics.unsynced if lyrics else None,
|
||||
)
|
||||
if playlist_track:
|
||||
tags = {
|
||||
**tags,
|
||||
**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(media_metadata)
|
||||
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
|
||||
if cover_file_extesion:
|
||||
cover_path = downloader_song.get_cover_path(
|
||||
final_path,
|
||||
cover_file_extesion,
|
||||
)
|
||||
else:
|
||||
cover_path = None
|
||||
if synced_lyrics_only:
|
||||
pass
|
||||
elif final_path.exists() and not overwrite:
|
||||
@@ -477,44 +576,49 @@ def main(
|
||||
f'({queue_progress}) Song already exists at "{final_path}", skipping'
|
||||
)
|
||||
else:
|
||||
if codec_song in (
|
||||
SongCodec.AAC_LEGACY,
|
||||
SongCodec.AAC_HE_LEGACY,
|
||||
):
|
||||
logger.debug("Getting stream info")
|
||||
logger.debug("Getting stream info")
|
||||
if codec_song in LEGACY_CODECS:
|
||||
stream_info = downloader_song_legacy.get_stream_info(
|
||||
webplayback
|
||||
)
|
||||
logger.debug("Getting decryption key")
|
||||
decryption_key = downloader_song_legacy.get_decryption_key(
|
||||
stream_info.pssh, track["id"]
|
||||
stream_info.audio_track.widevine_pssh,
|
||||
media_id,
|
||||
)
|
||||
else:
|
||||
stream_info = downloader_song.get_stream_info(track)
|
||||
if not stream_info.pssh:
|
||||
stream_info = downloader_song.get_stream_info(
|
||||
media_metadata
|
||||
)
|
||||
if (
|
||||
stream_info is None
|
||||
or not stream_info.audio_track.widevine_pssh
|
||||
):
|
||||
logger.warning(
|
||||
f"({queue_progress}) Song does not contain Widevine DRM, skipping"
|
||||
)
|
||||
continue
|
||||
elif not stream_info.stream_url:
|
||||
logger.warning(
|
||||
f"({queue_progress}) Song is not available with the selected codec, skipping"
|
||||
f"({queue_progress}) Song is not downloadable or is not"
|
||||
" available in the chosen codec, skipping"
|
||||
)
|
||||
continue
|
||||
logger.debug("Getting decryption key")
|
||||
decryption_key = downloader.get_decryption_key(
|
||||
stream_info.pssh, track["id"]
|
||||
stream_info.audio_track.widevine_pssh,
|
||||
media_id,
|
||||
)
|
||||
encrypted_path = downloader_song.get_encrypted_path(media_id)
|
||||
decrypted_path = downloader_song.get_decrypted_path(media_id)
|
||||
remuxed_path = downloader_song.get_remuxed_path(
|
||||
media_id,
|
||||
stream_info.file_format,
|
||||
)
|
||||
logger.debug(f'Downloading to "{encrypted_path}"')
|
||||
downloader.download(
|
||||
encrypted_path,
|
||||
stream_info.audio_track.stream_url,
|
||||
)
|
||||
if codec_song in LEGACY_CODECS:
|
||||
logger.debug(
|
||||
f'Decrypting/Remuxing to "{decrypted_path}"/"{remuxed_path}"'
|
||||
)
|
||||
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}")
|
||||
downloader.download(encrypted_path, stream_info.stream_url)
|
||||
if codec_song in (
|
||||
SongCodec.AAC_LEGACY,
|
||||
SongCodec.AAC_HE_LEGACY,
|
||||
):
|
||||
logger.debug(f"Remuxing/Decrypting to {remuxed_path}")
|
||||
downloader_song_legacy.remux(
|
||||
encrypted_path,
|
||||
decrypted_path,
|
||||
@@ -522,17 +626,18 @@ 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
|
||||
encrypted_path,
|
||||
decrypted_path,
|
||||
decryption_key,
|
||||
)
|
||||
logger.debug(f"Remuxing to {final_path}")
|
||||
downloader_song.remux(decrypted_path, 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 no_synced_lyrics or not lyrics.synced:
|
||||
logger.debug(f'Remuxing to "{final_path}"')
|
||||
downloader_song.remux(
|
||||
decrypted_path,
|
||||
remuxed_path,
|
||||
)
|
||||
if no_synced_lyrics or not lyrics or not lyrics.synced:
|
||||
pass
|
||||
elif lyrics_synced_path.exists() and not overwrite:
|
||||
logger.debug(
|
||||
@@ -543,144 +648,171 @@ 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":
|
||||
music_video_id_alt = downloader_music_video.get_music_video_id_alt(
|
||||
track
|
||||
elif media_metadata["type"] in ("music-videos", "library-music-videos"):
|
||||
music_video_id_alt = (
|
||||
downloader_music_video.get_music_video_id_alt(media_metadata)
|
||||
or media_id
|
||||
)
|
||||
logger.debug("Getting iTunes page")
|
||||
itunes_page = itunes_api.get_itunes_page(
|
||||
"music-video", music_video_id_alt
|
||||
)
|
||||
stream_url_master = downloader_music_video.get_stream_url_master(
|
||||
itunes_page
|
||||
)
|
||||
logger.debug("Getting M3U8 data")
|
||||
m3u8_master_data = downloader_music_video.get_m3u8_master_data(
|
||||
stream_url_master
|
||||
)
|
||||
if music_video_id_alt == media_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(media_id)
|
||||
stream_url = (
|
||||
downloader_music_video.get_stream_url_from_webplayback(
|
||||
webplayback
|
||||
)
|
||||
)
|
||||
logger.debug("Getting tags")
|
||||
tags = downloader_music_video.get_tags(
|
||||
music_video_id_alt,
|
||||
itunes_page,
|
||||
m3u8_master_data,
|
||||
track,
|
||||
media_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)
|
||||
if playlist_track:
|
||||
tags = {
|
||||
**tags,
|
||||
**downloader.get_playlist_tags(
|
||||
download_queue.playlist_attributes,
|
||||
playlist_track,
|
||||
),
|
||||
}
|
||||
logger.debug("Getting M3U8 data")
|
||||
m3u8_data = downloader_music_video.get_m3u8_master_data(stream_url)
|
||||
stream_info_av = downloader_music_video.get_stream_info(
|
||||
m3u8_data,
|
||||
)
|
||||
final_file_extesion = downloader.get_final_file_extension(
|
||||
stream_info_av.file_format,
|
||||
)
|
||||
final_path = downloader.get_final_path(
|
||||
tags,
|
||||
final_file_extesion,
|
||||
)
|
||||
cover_url = downloader.get_cover_url(media_metadata)
|
||||
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
|
||||
if cover_file_extesion:
|
||||
cover_path = downloader_music_video.get_cover_path(
|
||||
final_path,
|
||||
cover_file_extesion,
|
||||
)
|
||||
else:
|
||||
cover_path = None
|
||||
if final_path.exists() and not overwrite:
|
||||
logger.warning(
|
||||
f'({queue_progress}) Music video already exists at "{final_path}", skipping'
|
||||
)
|
||||
else:
|
||||
logger.debug("Getting stream info")
|
||||
stream_info_video, stream_info_audio = (
|
||||
downloader_music_video.get_stream_info_video(
|
||||
m3u8_master_data
|
||||
),
|
||||
downloader_music_video.get_stream_info_audio(
|
||||
m3u8_master_data
|
||||
),
|
||||
)
|
||||
decryption_key_video = downloader.get_decryption_key(
|
||||
stream_info_video.pssh, track["id"]
|
||||
stream_info_av.video_track.widevine_pssh,
|
||||
media_id,
|
||||
)
|
||||
decryption_key_audio = downloader.get_decryption_key(
|
||||
stream_info_audio.pssh, track["id"]
|
||||
stream_info_av.audio_track.widevine_pssh,
|
||||
media_id,
|
||||
)
|
||||
encrypted_path_video = (
|
||||
downloader_music_video.get_encrypted_path_video(track["id"])
|
||||
downloader_music_video.get_encrypted_path_video(media_id)
|
||||
)
|
||||
encrypted_path_audio = (
|
||||
downloader_music_video.get_encrypted_path_audio(track["id"])
|
||||
downloader_music_video.get_encrypted_path_audio(media_id)
|
||||
)
|
||||
decrypted_path_video = (
|
||||
downloader_music_video.get_decrypted_path_video(track["id"])
|
||||
downloader_music_video.get_decrypted_path_video(media_id)
|
||||
)
|
||||
decrypted_path_audio = (
|
||||
downloader_music_video.get_decrypted_path_audio(track["id"])
|
||||
downloader_music_video.get_decrypted_path_audio(media_id)
|
||||
)
|
||||
remuxed_path = downloader_music_video.get_remuxed_path(
|
||||
track["id"]
|
||||
media_id,
|
||||
final_file_extesion,
|
||||
)
|
||||
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
|
||||
encrypted_path_video,
|
||||
stream_info_av.video_track.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
|
||||
encrypted_path_audio,
|
||||
stream_info_av.audio_track.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,
|
||||
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 save_cover:
|
||||
pass
|
||||
elif cover_path.exists() and not overwrite:
|
||||
logger.debug(
|
||||
f'Cover already exists at "{cover_path}", skipping'
|
||||
elif media_metadata["type"] == "uploaded-videos":
|
||||
stream_url = downloader_post.get_stream_url(media_metadata)
|
||||
tags = downloader_post.get_tags(media_metadata)
|
||||
final_path = downloader.get_final_path(tags, ".m4v")
|
||||
cover_url = downloader.get_cover_url(media_metadata)
|
||||
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
|
||||
if cover_file_extesion:
|
||||
cover_path = downloader_music_video.get_cover_path(
|
||||
final_path,
|
||||
cover_file_extesion,
|
||||
)
|
||||
else:
|
||||
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"])
|
||||
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_path = None
|
||||
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'
|
||||
)
|
||||
else:
|
||||
logger.debug(f'Saving cover to "{cover_path}"')
|
||||
downloader.save_cover(cover_path, cover_url)
|
||||
remuxed_path = downloader_post.get_post_temp_path(media_id)
|
||||
logger.debug(f'Downloading to "{remuxed_path}"')
|
||||
downloader.download_ytdlp(remuxed_path, stream_url)
|
||||
if synced_lyrics_only or not save_cover or cover_path is None:
|
||||
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 "{media_metadata["attributes"]["name"]}"',
|
||||
exc_info=not no_exceptions,
|
||||
)
|
||||
finally:
|
||||
if temp_path.exists():
|
||||
|
||||
+14
-4
@@ -191,13 +191,14 @@ SONG_CODEC_REGEX_MAP = {
|
||||
SongCodec.AAC_DOWNMIX: r"audio-stereo-\d+-downmix",
|
||||
SongCodec.AAC_HE_BINAURAL: r"audio-HE-stereo-\d+-binaural",
|
||||
SongCodec.AAC_HE_DOWNMIX: r"audio-HE-stereo-\d+-downmix",
|
||||
SongCodec.ALAC: r"audio-alac-.*",
|
||||
SongCodec.ATMOS: r"audio-atmos-.*",
|
||||
SongCodec.AC3: r"audio-ac3-.*",
|
||||
SongCodec.ALAC: r"audio-alac-.*",
|
||||
}
|
||||
|
||||
MUSIC_VIDEO_CODEC_MAP = {
|
||||
MusicVideoCodec.H264_BEST: "avc1",
|
||||
MusicVideoCodec.H265_BEST: "hvc1",
|
||||
MusicVideoCodec.H264: "avc1",
|
||||
MusicVideoCodec.H265: "hvc1",
|
||||
}
|
||||
|
||||
SYNCED_LYRICS_FILE_EXTENSION_MAP = {
|
||||
@@ -207,6 +208,12 @@ SYNCED_LYRICS_FILE_EXTENSION_MAP = {
|
||||
}
|
||||
|
||||
|
||||
IMAGE_FILE_EXTENSION_MAP = {
|
||||
"jpeg": ".jpg",
|
||||
"tiff": ".tif",
|
||||
}
|
||||
|
||||
|
||||
EXCLUDED_CONFIG_FILE_PARAMS = (
|
||||
"urls",
|
||||
"config_path",
|
||||
@@ -218,4 +225,7 @@ EXCLUDED_CONFIG_FILE_PARAMS = (
|
||||
|
||||
X_NOT_FOUND_STRING = '{} not found at "{}"'
|
||||
|
||||
AMP_API_HOSTNAME = "https://amp-api.music.apple.com"
|
||||
LEGACY_CODECS = [
|
||||
SongCodec.AAC_LEGACY,
|
||||
SongCodec.AAC_HE_LEGACY,
|
||||
]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import logging
|
||||
|
||||
import colorama
|
||||
|
||||
from .utils import color_text
|
||||
|
||||
|
||||
class CustomLoggerFormatter(logging.Formatter):
|
||||
base_format = "[%(levelname)-8s %(asctime)s]"
|
||||
format_colors = {
|
||||
logging.DEBUG: colorama.Style.DIM,
|
||||
logging.INFO: colorama.Fore.GREEN,
|
||||
logging.WARNING: colorama.Fore.YELLOW,
|
||||
logging.ERROR: colorama.Fore.RED,
|
||||
logging.CRITICAL: colorama.Fore.RED,
|
||||
}
|
||||
date_format = "%H:%M:%S"
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
return logging.Formatter(
|
||||
color_text(self.base_format, self.format_colors.get(record.levelno))
|
||||
+ " %(message)s",
|
||||
datefmt=self.date_format,
|
||||
).format(record)
|
||||
+340
-101
@@ -1,26 +1,39 @@
|
||||
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 .enums import CoverFormat, DownloadMode, RemuxMode
|
||||
from .constants import IMAGE_FILE_EXTENSION_MAP, MP4_TAGS_MAP
|
||||
from .enums import CoverFormat, DownloadMode, MediaFileFormat, RemuxMode
|
||||
from .hardcoded_wvd import HARDCODED_WVD
|
||||
from .itunes_api import ItunesApi
|
||||
from .models import DownloadQueueItem, UrlInfo
|
||||
from .models import DownloadQueue, UrlInfo
|
||||
from .utils import raise_response_exception
|
||||
|
||||
|
||||
class Downloader:
|
||||
ILLEGAL_CHARACTERS_REGEX = r'[\\/:*?"<>|;]'
|
||||
ILLEGAL_CHARS_RE = r'[\\/:*?"<>|;]'
|
||||
ILLEGAL_CHAR_REPLACEMENT = "_"
|
||||
VALID_URL_RE = (
|
||||
r"(/(?P<storefront>[a-z]{2})/(?P<type>artist|album|playlist|song|music-video|post)/(?P<slug>[^/]*)(?:/(?P<id>[^/?]*))?(?:\?i=)?(?P<sub_id>[0-9a-z]*)?)|"
|
||||
r"(/library/(?P<library_type>|playlist|albums)/(?P<library_id>[a-z]\.[0-9a-zA-Z]*))"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -29,7 +42,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",
|
||||
@@ -42,11 +55,12 @@ 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,
|
||||
no_progress: bool = False,
|
||||
truncate: int = None,
|
||||
silent: bool = False,
|
||||
):
|
||||
self.apple_music_api = apple_music_api
|
||||
self.itunes_api = itunes_api
|
||||
@@ -66,14 +80,16 @@ 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
|
||||
self.truncate = truncate
|
||||
self.no_progress = no_progress
|
||||
self.silent = silent
|
||||
self._set_binaries_path_full()
|
||||
self._set_exclude_tags_list()
|
||||
self._set_truncate()
|
||||
self._set_subprocess_additional_args()
|
||||
|
||||
def _set_binaries_path_full(self):
|
||||
self.nm3u8dlre_path_full = shutil.which(self.nm3u8dlre_path)
|
||||
@@ -89,7 +105,17 @@ 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:
|
||||
self.subprocess_additional_args = {
|
||||
"stdout": subprocess.DEVNULL,
|
||||
"stderr": subprocess.DEVNULL,
|
||||
}
|
||||
else:
|
||||
self.subprocess_additional_args = {}
|
||||
|
||||
def set_cdm(self):
|
||||
if self.wvd_path:
|
||||
@@ -100,70 +126,241 @@ class Downloader:
|
||||
def get_url_info(self, url: str) -> UrlInfo:
|
||||
url_info = UrlInfo()
|
||||
url_regex_result = re.search(
|
||||
r"/([a-z]{2})/(album|playlist|song|music-video|post)/([^/]*)(?:/([^/?]*))?(?:\?i=)?([0-9a-z]*)?",
|
||||
self.VALID_URL_RE,
|
||||
url,
|
||||
)
|
||||
url_info.storefront = url_regex_result.group(1)
|
||||
url_info.type = (
|
||||
"song" if url_regex_result.group(5) else url_regex_result.group(2)
|
||||
)
|
||||
url_info.id = (
|
||||
url_regex_result.group(5)
|
||||
or url_regex_result.group(4)
|
||||
or url_regex_result.group(3)
|
||||
)
|
||||
is_library = url_regex_result.group("library_type") is not None
|
||||
if is_library:
|
||||
url_info.type = url_regex_result.group("library_type")
|
||||
url_info.id = url_regex_result.group("library_id")
|
||||
else:
|
||||
url_info.storefront = url_regex_result.group("storefront")
|
||||
url_info.type = (
|
||||
"song"
|
||||
if url_regex_result.group("sub_id")
|
||||
else url_regex_result.group("type")
|
||||
)
|
||||
url_info.id = (
|
||||
url_regex_result.group("sub_id")
|
||||
or url_regex_result.group("id")
|
||||
or url_regex_result.group("sub_id")
|
||||
)
|
||||
url_info.is_library = is_library
|
||||
return url_info
|
||||
|
||||
def get_download_queue(self, url_info: UrlInfo) -> list[DownloadQueueItem]:
|
||||
return self._get_download_queue(url_info.type, url_info.id)
|
||||
def get_download_queue(self, url_info: UrlInfo) -> DownloadQueue:
|
||||
return self._get_download_queue(url_info.type, url_info.id, url_info.is_library)
|
||||
|
||||
def _get_download_queue(self, url_type: str, id: str) -> list[DownloadQueueItem]:
|
||||
download_queue = []
|
||||
if url_type == "song":
|
||||
download_queue.append(DownloadQueueItem(self.apple_music_api.get_song(id)))
|
||||
elif url_type == "album":
|
||||
album = self.apple_music_api.get_album(id)
|
||||
download_queue.extend(
|
||||
DownloadQueueItem(track)
|
||||
for track in album["relationships"]["tracks"]["data"]
|
||||
def _get_download_queue(
|
||||
self,
|
||||
url_type: str,
|
||||
id: str,
|
||||
is_library: bool,
|
||||
) -> DownloadQueue:
|
||||
download_queue = DownloadQueue()
|
||||
if url_type == "artist":
|
||||
artist = self.apple_music_api.get_artist(id)
|
||||
download_queue.medias_metadata = list(
|
||||
self.get_download_queue_from_artist(artist)
|
||||
)
|
||||
elif url_type == "song":
|
||||
download_queue.medias_metadata = [self.apple_music_api.get_song(id)]
|
||||
elif url_type in ("album", "albums"):
|
||||
if is_library:
|
||||
album = self.apple_music_api.get_library_album(id)
|
||||
else:
|
||||
album = self.apple_music_api.get_album(id)
|
||||
download_queue.medias_metadata = [
|
||||
track for track in album["relationships"]["tracks"]["data"]
|
||||
]
|
||||
elif url_type == "playlist":
|
||||
download_queue.extend(
|
||||
DownloadQueueItem(track)
|
||||
for track in self.apple_music_api.get_playlist(id)["relationships"][
|
||||
"tracks"
|
||||
]["data"]
|
||||
)
|
||||
if is_library:
|
||||
playlist = self.apple_music_api.get_library_playlist(id)
|
||||
download_queue.medias_metadata = [
|
||||
track for track in playlist["relationships"]["tracks"]["data"]
|
||||
]
|
||||
else:
|
||||
playlist = self.apple_music_api.get_playlist(id)
|
||||
download_queue.medias_metadata = [
|
||||
track for track in playlist["relationships"]["tracks"]["data"]
|
||||
]
|
||||
download_queue.playlist_attributes = playlist["attributes"]
|
||||
elif url_type == "music-video":
|
||||
download_queue.append(
|
||||
DownloadQueueItem(self.apple_music_api.get_music_video(id))
|
||||
)
|
||||
download_queue.medias_metadata = [self.apple_music_api.get_music_video(id)]
|
||||
elif url_type == "post":
|
||||
download_queue.append(DownloadQueueItem(self.apple_music_api.get_post(id)))
|
||||
else:
|
||||
raise Exception(f"Invalid url type: {url_type}")
|
||||
download_queue.medias_metadata = [self.apple_music_api.get_post(id)]
|
||||
return download_queue
|
||||
|
||||
def sanitize_date(self, date: str):
|
||||
datetime_obj = ciso8601.parse_datetime(date)
|
||||
return datetime_obj.strftime(self.template_date)
|
||||
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=[
|
||||
Choice(name="Albums", value="albums"),
|
||||
Choice(
|
||||
name="Music Videos",
|
||||
value="music-videos",
|
||||
),
|
||||
],
|
||||
validate=lambda result: artist["relationships"].get(result, {}).get("data"),
|
||||
invalid_message="The artist doesn't have any items of this type",
|
||||
).execute()
|
||||
if media_type == "albums":
|
||||
yield from self.select_albums_from_artist(
|
||||
artist["relationships"]["albums"]["data"]
|
||||
)
|
||||
elif media_type == "music-videos":
|
||||
yield from self.select_music_videos_from_artist(
|
||||
artist["relationships"]["music-videos"]["data"]
|
||||
)
|
||||
|
||||
def select_albums_from_artist(
|
||||
self,
|
||||
albums: list[dict],
|
||||
) -> typing.Generator[dict, None, None]:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
f'{album["attributes"]["trackCount"]:03d}',
|
||||
f'{album["attributes"]["releaseDate"]:<10}',
|
||||
f'{album["attributes"].get("contentRating", "None").title():<8}',
|
||||
f'{album["attributes"]["name"]}',
|
||||
]
|
||||
),
|
||||
value=album,
|
||||
)
|
||||
for album in albums
|
||||
]
|
||||
selected = inquirer.select(
|
||||
message="Select which albums to download: (Track Count | Release Date | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute()
|
||||
for album in selected:
|
||||
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],
|
||||
) -> typing.Generator[dict, None, None]:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
self.millis_to_min_sec(
|
||||
music_video["attributes"]["durationInMillis"]
|
||||
),
|
||||
f'{music_video["attributes"].get("contentRating", "None").title():<8}',
|
||||
music_video["attributes"]["name"],
|
||||
],
|
||||
),
|
||||
value=music_video,
|
||||
)
|
||||
for music_video in music_videos
|
||||
]
|
||||
selected = inquirer.select(
|
||||
message="Select which music videos to download: (Duration | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute()
|
||||
for music_video in selected:
|
||||
yield music_video
|
||||
|
||||
def get_media_id(
|
||||
self,
|
||||
media_metadata: dict,
|
||||
) -> str | None:
|
||||
play_params = media_metadata["attributes"].get("playParams", {})
|
||||
return play_params.get("catalogId") or play_params.get("id")
|
||||
|
||||
def get_playlist_tags(
|
||||
self,
|
||||
playlist_attributes: dict,
|
||||
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) -> str:
|
||||
minutes, seconds = divmod(millis // 1000, 60)
|
||||
return f"{minutes:02d}:{seconds:02d}"
|
||||
|
||||
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:
|
||||
cdm_session = self.cdm.open()
|
||||
pssh_obj = PSSH(pssh.split(",")[-1])
|
||||
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):
|
||||
@@ -181,19 +378,12 @@ class Downloader:
|
||||
"allow_unplayable_formats": True,
|
||||
"fixup": "never",
|
||||
"allowed_extractors": ["generic"],
|
||||
"noprogress": self.no_progress,
|
||||
"noprogress": self.silent,
|
||||
}
|
||||
) as ydl:
|
||||
ydl.download(stream_url)
|
||||
|
||||
def download_nm3u8dlre(self, path: Path, stream_url: str):
|
||||
if self.no_progress:
|
||||
subprocess_additional_args = {
|
||||
"stdout": subprocess.DEVNULL,
|
||||
"stderr": subprocess.DEVNULL,
|
||||
}
|
||||
else:
|
||||
subprocess_additional_args = {}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(
|
||||
[
|
||||
@@ -213,50 +403,86 @@ class Downloader:
|
||||
path.parent,
|
||||
],
|
||||
check=True,
|
||||
**subprocess_additional_args,
|
||||
**self.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def get_sanitized_string(self, dirty_string: str, is_folder: bool) -> str:
|
||||
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]
|
||||
return dirty_string.strip()
|
||||
|
||||
def get_final_file_extension(
|
||||
self,
|
||||
file_format: MediaFileFormat,
|
||||
) -> str:
|
||||
return "." + file_format.value
|
||||
|
||||
def get_final_path(self, tags: dict, file_extension: str) -> Path:
|
||||
if tags.get("album"):
|
||||
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 | None:
|
||||
cover_bytes = self.get_cover_url_response_bytes(cover_url)
|
||||
if cover_bytes is None:
|
||||
return None
|
||||
image_obj = Image.open(io.BytesIO(self.get_cover_url_response_bytes(cover_url)))
|
||||
image_format = image_obj.format.lower()
|
||||
return IMAGE_FILE_EXTENSION_MAP.get(image_format, f".{image_format}")
|
||||
|
||||
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",
|
||||
@@ -266,8 +492,15 @@ class Downloader:
|
||||
|
||||
@staticmethod
|
||||
@functools.lru_cache()
|
||||
def get_url_response_bytes(url: str) -> bytes:
|
||||
return requests.get(url).content
|
||||
def get_cover_url_response_bytes(url: str) -> bytes | None:
|
||||
response = requests.get(url)
|
||||
if response.status_code == 200:
|
||||
return response.content
|
||||
elif response.status_code in (404, 400):
|
||||
return None
|
||||
else:
|
||||
raise_response_exception(response)
|
||||
return response.content
|
||||
|
||||
def apply_tags(
|
||||
self,
|
||||
@@ -305,17 +538,22 @@ 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:
|
||||
mp4_tags["covr"] = [
|
||||
MP4Cover(
|
||||
self.get_url_response_bytes(cover_url),
|
||||
imageformat=(
|
||||
MP4Cover.FORMAT_JPEG
|
||||
if self.cover_format == CoverFormat.JPG
|
||||
else MP4Cover.FORMAT_PNG
|
||||
),
|
||||
)
|
||||
]
|
||||
if (
|
||||
"cover" not in self.exclude_tags_list
|
||||
and self.cover_format != CoverFormat.RAW
|
||||
):
|
||||
cover_bytes = self.get_cover_url_response_bytes(cover_url)
|
||||
if cover_bytes is not None:
|
||||
mp4_tags["covr"] = [
|
||||
MP4Cover(
|
||||
self.get_cover_url_response_bytes(cover_url),
|
||||
imageformat=(
|
||||
MP4Cover.FORMAT_JPEG
|
||||
if self.cover_format == CoverFormat.JPG
|
||||
else MP4Cover.FORMAT_PNG
|
||||
),
|
||||
)
|
||||
]
|
||||
mp4 = MP4(path)
|
||||
mp4.clear()
|
||||
mp4.update(mp4_tags)
|
||||
@@ -331,7 +569,8 @@ class Downloader:
|
||||
|
||||
@functools.lru_cache()
|
||||
def save_cover(self, cover_path: Path, cover_url: str):
|
||||
cover_path.write_bytes(self.get_url_response_bytes(cover_url))
|
||||
cover_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cover_path.write_bytes(self.get_cover_url_response_bytes(cover_url))
|
||||
|
||||
def cleanup_temp_path(self):
|
||||
shutil.rmtree(self.temp_path)
|
||||
|
||||
+132
-102
@@ -1,42 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import m3u8
|
||||
from tabulate import tabulate
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
|
||||
from .constants import MUSIC_VIDEO_CODEC_MAP
|
||||
from .downloader import Downloader
|
||||
from .enums import MusicVideoCodec, RemuxMode
|
||||
from .models import StreamInfo
|
||||
from .enums import MediaFileFormat, MusicVideoCodec, RemuxFormatMusicVideo, RemuxMode
|
||||
from .models import StreamInfo, StreamInfoAv
|
||||
|
||||
|
||||
class DownloaderMusicVideo:
|
||||
MP4_FORMAT_CODECS = ["hvc1", "audio-atmos", "audio-ec3"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
downloader: Downloader,
|
||||
codec: MusicVideoCodec = MusicVideoCodec.H264_BEST,
|
||||
codec: MusicVideoCodec = MusicVideoCodec.H264,
|
||||
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
|
||||
):
|
||||
self.downloader = downloader
|
||||
self.codec = codec
|
||||
self.remux_format = remux_format
|
||||
|
||||
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_stream_url_video(
|
||||
def get_m3u8_master_data(self, stream_url_master: str) -> dict:
|
||||
return m3u8.load(stream_url_master).data
|
||||
|
||||
def get_playlist_video(
|
||||
self,
|
||||
playlists: list[dict],
|
||||
):
|
||||
) -> dict:
|
||||
playlists_filtered = [
|
||||
playlist
|
||||
for playlist in playlists
|
||||
@@ -49,39 +58,39 @@ class DownloaderMusicVideo:
|
||||
playlist
|
||||
for playlist in playlists
|
||||
if playlist["stream_info"]["codecs"].startswith(
|
||||
MUSIC_VIDEO_CODEC_MAP[MusicVideoCodec.H264_BEST]
|
||||
MUSIC_VIDEO_CODEC_MAP[MusicVideoCodec.H264]
|
||||
)
|
||||
]
|
||||
playlists_filtered.sort(key=lambda x: x["stream_info"]["bandwidth"])
|
||||
return playlists_filtered[-1]["uri"]
|
||||
return playlists_filtered[-1]
|
||||
|
||||
def get_stream_url_video_from_user(
|
||||
def get_playlist_video_from_user(
|
||||
self,
|
||||
playlists: list[dict],
|
||||
):
|
||||
table = [
|
||||
[
|
||||
i,
|
||||
playlist["stream_info"]["codecs"],
|
||||
playlist["stream_info"]["resolution"],
|
||||
playlist["stream_info"]["bandwidth"],
|
||||
]
|
||||
for i, playlist in enumerate(playlists, 1)
|
||||
]
|
||||
print(tabulate(table))
|
||||
try:
|
||||
choice = (
|
||||
click.prompt("Choose a video codec", type=click.IntRange(1, len(table)))
|
||||
- 1
|
||||
) -> dict:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
playlist["stream_info"]["codecs"][:4],
|
||||
playlist["stream_info"]["resolution"],
|
||||
str(playlist["stream_info"]["bandwidth"]),
|
||||
]
|
||||
),
|
||||
value=playlist,
|
||||
)
|
||||
except click.exceptions.Abort:
|
||||
raise KeyboardInterrupt()
|
||||
return playlists[choice]["uri"]
|
||||
for playlist in playlists
|
||||
]
|
||||
selected = inquirer.select(
|
||||
message="Select which video codec to download: (Codec | Resolution | Bitrate)",
|
||||
choices=choices,
|
||||
).execute()
|
||||
return selected
|
||||
|
||||
def get_stream_url_audio(
|
||||
def get_playlist_audio(
|
||||
self,
|
||||
playlists: list[dict],
|
||||
) -> str:
|
||||
) -> dict:
|
||||
stream_url = next(
|
||||
(
|
||||
playlist
|
||||
@@ -89,31 +98,26 @@ class DownloaderMusicVideo:
|
||||
if playlist["group_id"] == "audio-stereo-256"
|
||||
),
|
||||
None,
|
||||
)["uri"]
|
||||
)
|
||||
return stream_url
|
||||
|
||||
def get_stream_url_audio_from_user(
|
||||
def get_playlist_audio_from_user(
|
||||
self,
|
||||
playlists: list[dict],
|
||||
):
|
||||
table = [
|
||||
[
|
||||
i,
|
||||
playlist["group_id"],
|
||||
]
|
||||
for i, playlist in enumerate(playlists, 1)
|
||||
]
|
||||
print(tabulate(table))
|
||||
try:
|
||||
choice = (
|
||||
click.prompt(
|
||||
"Choose an audio codec", type=click.IntRange(1, len(table))
|
||||
)
|
||||
- 1
|
||||
) -> dict:
|
||||
choices = [
|
||||
Choice(
|
||||
name=playlist["group_id"],
|
||||
value=playlist,
|
||||
)
|
||||
except click.exceptions.Abort:
|
||||
raise KeyboardInterrupt()
|
||||
return playlists[choice]["uri"]
|
||||
for playlist in playlists
|
||||
if playlist.get("uri")
|
||||
]
|
||||
selected = inquirer.select(
|
||||
message="Select which audio codec to download:",
|
||||
choices=choices,
|
||||
).execute()
|
||||
return selected
|
||||
|
||||
def get_pssh(self, m3u8_data: dict):
|
||||
return next(
|
||||
@@ -128,71 +132,91 @@ class DownloaderMusicVideo:
|
||||
def get_stream_info_video(self, m3u8_master_data: dict) -> StreamInfo:
|
||||
stream_info = StreamInfo()
|
||||
if self.codec != MusicVideoCodec.ASK:
|
||||
stream_info.stream_url = self.get_stream_url_video(
|
||||
m3u8_master_data["playlists"]
|
||||
)
|
||||
playlist = self.get_playlist_video(m3u8_master_data["playlists"])
|
||||
else:
|
||||
stream_info.stream_url = self.get_stream_url_video_from_user(
|
||||
m3u8_master_data["playlists"]
|
||||
)
|
||||
playlist = self.get_playlist_video_from_user(m3u8_master_data["playlists"])
|
||||
stream_info.stream_url = playlist["uri"]
|
||||
stream_info.codec = playlist["stream_info"]["codecs"]
|
||||
m3u8_data = m3u8.load(stream_info.stream_url).data
|
||||
stream_info.pssh = self.get_pssh(m3u8_data)
|
||||
stream_info.widevine_pssh = self.get_pssh(m3u8_data)
|
||||
return stream_info
|
||||
|
||||
def get_stream_info_audio(self, m3u8_master_data: dict) -> StreamInfo:
|
||||
stream_info = StreamInfo()
|
||||
if self.codec != MusicVideoCodec.ASK:
|
||||
stream_info.stream_url = self.get_stream_url_audio(
|
||||
m3u8_master_data["media"]
|
||||
)
|
||||
playlist = self.get_playlist_audio(m3u8_master_data["media"])
|
||||
else:
|
||||
stream_info.stream_url = self.get_stream_url_audio_from_user(
|
||||
m3u8_master_data["media"]
|
||||
)
|
||||
playlist = self.get_playlist_audio_from_user(m3u8_master_data["media"])
|
||||
stream_info.stream_url = playlist["uri"]
|
||||
stream_info.codec = 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:
|
||||
return metadata["attributes"]["url"].split("/")[-1].split("?")[0]
|
||||
def get_stream_info(
|
||||
self,
|
||||
m3u8_master_data: dict,
|
||||
) -> StreamInfoAv:
|
||||
stream_info_video = self.get_stream_info_video(m3u8_master_data)
|
||||
stream_info_audio = self.get_stream_info_audio(m3u8_master_data)
|
||||
use_mp4 = (
|
||||
any(
|
||||
stream_info_video.codec.startswith(codec)
|
||||
for codec in self.MP4_FORMAT_CODECS
|
||||
)
|
||||
or any(
|
||||
stream_info_audio.codec.startswith(codec)
|
||||
for codec in self.MP4_FORMAT_CODECS
|
||||
)
|
||||
or self.remux_format == RemuxFormatMusicVideo.MP4
|
||||
)
|
||||
if use_mp4:
|
||||
file_format = MediaFileFormat.MP4
|
||||
else:
|
||||
file_format = MediaFileFormat.M4V
|
||||
return StreamInfoAv(
|
||||
video_track=stream_info_video,
|
||||
audio_track=stream_info_audio,
|
||||
file_format=file_format,
|
||||
)
|
||||
|
||||
def get_music_video_id_alt(self, metadata: dict) -> str | None:
|
||||
music_video_url = metadata["attributes"].get("url")
|
||||
if music_video_url is None:
|
||||
return None
|
||||
return music_video_url.split("/")[-1].split("?")[0]
|
||||
|
||||
def get_tags(
|
||||
self,
|
||||
id_alt: str,
|
||||
itunes_page: dict,
|
||||
m3u8_master_data: dict,
|
||||
metadata: dict,
|
||||
):
|
||||
metadata_itunes = self.downloader.itunes_api.get_resource(id_alt)
|
||||
tags = {
|
||||
"artist": metadata["attributes"]["artistName"],
|
||||
"artist_id": int(itunes_page["artistId"]),
|
||||
"copyright": itunes_page["copyright"],
|
||||
"date": next(
|
||||
(
|
||||
session_data
|
||||
for session_data in m3u8_master_data["session_data"]
|
||||
if session_data["data_id"] == "com.apple.hls.release-date"
|
||||
),
|
||||
None,
|
||||
)["value"],
|
||||
"genre": metadata["attributes"]["genreNames"][0],
|
||||
"artist": metadata_itunes[0]["artistName"],
|
||||
"artist_id": int(metadata_itunes[0]["artistId"]),
|
||||
"copyright": itunes_page.get("copyright"),
|
||||
"date": self.downloader.sanitize_date(metadata_itunes[0]["releaseDate"]),
|
||||
"genre": metadata_itunes[0]["primaryGenreName"],
|
||||
"genre_id": int(itunes_page["genres"][0]["genreId"]),
|
||||
"media_type": 6,
|
||||
"title": metadata["attributes"]["name"],
|
||||
"title_id": int(metadata["id"]),
|
||||
"storefront": int(self.downloader.itunes_api.storefront_id.split("-")[0]),
|
||||
"title": metadata_itunes[0]["trackCensoredName"],
|
||||
"title_id": int(self.downloader.get_media_id(metadata)),
|
||||
}
|
||||
if metadata["attributes"].get("contentRating") == "clean":
|
||||
tags["rating"] = 2
|
||||
elif metadata["attributes"].get("contentRating") == "explicit":
|
||||
if metadata_itunes[0]["trackExplicitness"] == "notExplicit":
|
||||
tags["rating"] = 0
|
||||
elif metadata_itunes[0]["trackExplicitness"] == "explicit":
|
||||
tags["rating"] = 1
|
||||
else:
|
||||
tags["rating"] = 0
|
||||
if itunes_page.get("collectionId"):
|
||||
metadata_itunes = self.downloader.itunes_api.get_resource(itunes_page["id"])
|
||||
tags["rating"] = 2
|
||||
if len(metadata_itunes) > 1:
|
||||
album = self.downloader.apple_music_api.get_album(
|
||||
itunes_page["collectionId"]
|
||||
)
|
||||
tags["album"] = album["attributes"]["name"]
|
||||
tags["album_artist"] = album["attributes"]["artistName"]
|
||||
tags["album"] = metadata_itunes[1]["collectionCensoredName"]
|
||||
tags["album_artist"] = metadata_itunes[1]["artistName"]
|
||||
tags["album_id"] = int(itunes_page["collectionId"])
|
||||
tags["disc"] = metadata_itunes[0]["discNumber"]
|
||||
tags["disc_total"] = metadata_itunes[0]["discCount"]
|
||||
@@ -213,8 +237,12 @@ class DownloaderMusicVideo:
|
||||
def get_decrypted_path_audio(self, track_id: str) -> str:
|
||||
return self.downloader.temp_path / f"decrypted_{track_id}.m4a"
|
||||
|
||||
def get_remuxed_path(self, track_id: str) -> str:
|
||||
return self.downloader.temp_path / f"remuxed_{track_id}.m4v"
|
||||
def get_remuxed_path(
|
||||
self,
|
||||
track_id: str,
|
||||
file_extension: str,
|
||||
) -> str:
|
||||
return self.downloader.temp_path / (f"remuxed_{track_id}" + file_extension)
|
||||
|
||||
def decrypt(self, encrypted_path: Path, decryption_key: str, decrypted_path: Path):
|
||||
subprocess.run(
|
||||
@@ -226,6 +254,7 @@ class DownloaderMusicVideo:
|
||||
decrypted_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def remux_mp4box(
|
||||
@@ -233,7 +262,7 @@ class DownloaderMusicVideo:
|
||||
decrypted_path_audio: Path,
|
||||
decrypted_path_video: Path,
|
||||
fixed_path: Path,
|
||||
) -> None:
|
||||
):
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.mp4box_path_full,
|
||||
@@ -244,10 +273,12 @@ class DownloaderMusicVideo:
|
||||
decrypted_path_video,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-keep-utc",
|
||||
"-new",
|
||||
fixed_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def remux_ffmpeg(
|
||||
@@ -268,8 +299,6 @@ class DownloaderMusicVideo:
|
||||
decrypte_path_audio,
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-f",
|
||||
"mp4",
|
||||
"-c",
|
||||
"copy",
|
||||
"-c:s",
|
||||
@@ -277,6 +306,7 @@ class DownloaderMusicVideo:
|
||||
fixed_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def remux(
|
||||
@@ -298,5 +328,5 @@ class DownloaderMusicVideo:
|
||||
remuxed_path,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
+17
-14
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
|
||||
from .downloader import Downloader
|
||||
from tabulate import tabulate
|
||||
from .enums import PostQuality
|
||||
|
||||
|
||||
@@ -38,18 +40,18 @@ class DownloaderPost:
|
||||
|
||||
def get_stream_url_from_user(self, metadata: dict) -> str:
|
||||
qualities = list(metadata["attributes"]["assetTokens"].keys())
|
||||
table = [
|
||||
[index, quality]
|
||||
for index, quality in enumerate(
|
||||
qualities,
|
||||
start=1,
|
||||
choices = [
|
||||
Choice(
|
||||
name=quality,
|
||||
value=quality,
|
||||
)
|
||||
for quality in qualities
|
||||
]
|
||||
print(tabulate(table))
|
||||
choice = (
|
||||
click.prompt("Choose a quality", type=click.IntRange(1, len(table))) - 1
|
||||
)
|
||||
return metadata["attributes"]["assetTokens"][qualities[choice]]
|
||||
selected = inquirer.select(
|
||||
message="Select which quality to download:",
|
||||
choices=choices,
|
||||
).execute()
|
||||
return metadata["attributes"]["assetTokens"][selected]
|
||||
|
||||
def get_stream_url(self, metadata: dict) -> str:
|
||||
if self.quality == PostQuality.BEST:
|
||||
@@ -62,10 +64,11 @@ class DownloaderPost:
|
||||
attributes = metadata["attributes"]
|
||||
return {
|
||||
"artist": attributes["artistName"],
|
||||
"date": attributes["uploadDate"],
|
||||
"date": self.downloader.sanitize_date(attributes["uploadDate"]),
|
||||
"title": attributes["name"],
|
||||
"title_id": int(metadata["id"]),
|
||||
"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"
|
||||
|
||||
+107
-47
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
@@ -7,18 +9,19 @@ from pathlib import Path
|
||||
from xml.dom import minidom
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import click
|
||||
import m3u8
|
||||
from tabulate import tabulate
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
|
||||
from .constants import SONG_CODEC_REGEX_MAP, SYNCED_LYRICS_FILE_EXTENSION_MAP
|
||||
from .downloader import Downloader
|
||||
from .enums import RemuxMode, SongCodec, SyncedLyricsFormat
|
||||
from .models import Lyrics, StreamInfo
|
||||
from .enums import MediaFileFormat, RemuxMode, SongCodec, SyncedLyricsFormat
|
||||
from .models import Lyrics, StreamInfo, StreamInfoAv
|
||||
|
||||
|
||||
class DownloaderSong:
|
||||
DEFAULT_DECRYPTION_KEY = "32b8ade1769e26b1ffb8986352793fc6"
|
||||
MP4_FORMAT_CODECS = ["ec-3"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -40,7 +43,7 @@ class DownloaderSong:
|
||||
None,
|
||||
)
|
||||
if not drm_info_raw:
|
||||
raise Exception("DRM info not found")
|
||||
return None
|
||||
return json.loads(base64.b64decode(drm_info_raw["value"]).decode("utf-8"))
|
||||
|
||||
def get_asset_infos(self, m3u8_data: dict) -> dict:
|
||||
@@ -69,61 +72,102 @@ class DownloaderSong:
|
||||
|
||||
def get_playlist_from_user(self, m3u8_data: dict) -> dict | None:
|
||||
m3u8_master_playlists = [playlist for playlist in m3u8_data["playlists"]]
|
||||
table = [
|
||||
[i, playlist["stream_info"]["audio"]]
|
||||
for i, playlist in enumerate(m3u8_master_playlists, 1)
|
||||
]
|
||||
print(tabulate(table))
|
||||
try:
|
||||
choice = (
|
||||
click.prompt("Choose a codec", type=click.IntRange(1, len(table))) - 1
|
||||
choices = [
|
||||
Choice(
|
||||
name=playlist["stream_info"]["audio"],
|
||||
value=playlist,
|
||||
)
|
||||
except click.exceptions.Abort:
|
||||
raise KeyboardInterrupt()
|
||||
return m3u8_master_playlists[choice]
|
||||
for playlist in m3u8_master_playlists
|
||||
]
|
||||
selected = inquirer.select(
|
||||
message="Select which codec to download:",
|
||||
choices=choices,
|
||||
).execute()
|
||||
return selected
|
||||
|
||||
def get_pssh(
|
||||
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_stream_info(self, track_metadata: dict) -> StreamInfo:
|
||||
m3u8_url = track_metadata["attributes"]["extendedAssetUrls"]["enhancedHls"]
|
||||
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) -> StreamInfoAv | None:
|
||||
m3u8_url = track_metadata["attributes"]["extendedAssetUrls"].get("enhancedHls")
|
||||
if not m3u8_url:
|
||||
return None
|
||||
return self._get_stream_info(m3u8_url)
|
||||
|
||||
def _get_stream_info(self, m3u8_url: str) -> StreamInfo:
|
||||
def _get_stream_info(self, m3u8_url: str) -> StreamInfoAv | None:
|
||||
stream_info = StreamInfo()
|
||||
m3u8_obj = m3u8.load(m3u8_url)
|
||||
m3u8_data = m3u8_obj.data
|
||||
drm_infos = self.get_drm_infos(m3u8_data)
|
||||
if not drm_infos:
|
||||
return None
|
||||
asset_infos = self.get_asset_infos(m3u8_data)
|
||||
if self.codec == SongCodec.ASK:
|
||||
playlist = self.get_playlist_from_user(m3u8_data)
|
||||
else:
|
||||
playlist = self.get_playlist_from_codec(m3u8_data)
|
||||
if playlist is None:
|
||||
return stream_info
|
||||
return None
|
||||
stream_info.stream_url = m3u8_obj.base_uri + playlist["uri"]
|
||||
variant_id = playlist["stream_info"]["stable_variant_id"]
|
||||
drm_ids = asset_infos[variant_id]["AUDIO-SESSION-KEY-IDS"]
|
||||
pssh = self.get_pssh(drm_infos, drm_ids)
|
||||
stream_info.pssh = pssh
|
||||
return stream_info
|
||||
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"]
|
||||
is_mp4 = any(
|
||||
stream_info.codec.startswith(possible_codec)
|
||||
for possible_codec in self.MP4_FORMAT_CODECS
|
||||
)
|
||||
return StreamInfoAv(
|
||||
audio_track=stream_info,
|
||||
file_format=MediaFileFormat.MP4 if is_mp4 else MediaFileFormat.M4A,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse_datetime_obj_from_timestamp_ttml(
|
||||
@@ -139,7 +183,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)
|
||||
@@ -169,21 +216,25 @@ class DownloaderSong:
|
||||
timestamp_srt_end = self.get_lyrics_synced_timestamp_srt(timestamp_ttml_end)
|
||||
return f"{index}\n{timestamp_srt_start} --> {timestamp_srt_end}\n{text}\n"
|
||||
|
||||
def get_lyrics(self, track_metadata: dict) -> Lyrics:
|
||||
def get_lyrics(self, track_metadata: dict) -> Lyrics | None:
|
||||
lyrics = Lyrics()
|
||||
if not track_metadata["attributes"]["hasLyrics"]:
|
||||
return Lyrics()
|
||||
return None
|
||||
elif track_metadata.get("relationships") is None:
|
||||
track_metadata = self.downloader.apple_music_api.get_song(
|
||||
track_metadata["id"]
|
||||
self.downloader.get_media_id(track_metadata)
|
||||
)
|
||||
if track_metadata["relationships"]["lyrics"]["data"]:
|
||||
return self._get_lyrics(
|
||||
if (
|
||||
track_metadata["relationships"].get("lyrics")
|
||||
and track_metadata["relationships"]["lyrics"].get("data")
|
||||
and track_metadata["relationships"]["lyrics"]["data"][0].get("attributes")
|
||||
):
|
||||
lyrics = self._get_lyrics(
|
||||
track_metadata["relationships"]["lyrics"]["data"][0]["attributes"][
|
||||
"ttml"
|
||||
]
|
||||
)
|
||||
else:
|
||||
return Lyrics()
|
||||
return lyrics
|
||||
|
||||
def _get_lyrics(self, lyrics_ttml: str) -> Lyrics:
|
||||
lyrics = Lyrics("", "")
|
||||
@@ -195,9 +246,9 @@ class DownloaderSong:
|
||||
lyrics.unsynced += p.text + "\n"
|
||||
if p.attrib.get("begin"):
|
||||
if self.synced_lyrics_format == SyncedLyricsFormat.LRC:
|
||||
lyrics.synced += f"{self.get_lyrics_synced_line_lrc(p.attrib.get('begin'), p.text)}\n"
|
||||
lyrics.synced += f"{self.get_lyrics_synced_line_lrc(p.attrib.get('begin'), p.text)}"
|
||||
elif self.synced_lyrics_format == SyncedLyricsFormat.SRT:
|
||||
lyrics.synced += f"{self.get_lyrics_synced_line_srt(index, p.attrib.get('begin'), p.attrib.get('end'), p.text)}\n"
|
||||
lyrics.synced += f"{self.get_lyrics_synced_line_srt(index, p.attrib.get('begin'), p.attrib.get('end'), p.text)}"
|
||||
elif self.synced_lyrics_format == SyncedLyricsFormat.TTML:
|
||||
if not lyrics.synced:
|
||||
lyrics.synced = minidom.parseString(
|
||||
@@ -236,7 +287,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,
|
||||
@@ -257,8 +308,9 @@ class DownloaderSong:
|
||||
def get_decrypted_path(self, track_id: str) -> Path:
|
||||
return self.downloader.temp_path / f"{track_id}_decrypted.m4a"
|
||||
|
||||
def get_remuxed_path(self, track_id: str) -> Path:
|
||||
return self.downloader.temp_path / f"{track_id}_remuxed.m4a"
|
||||
def get_remuxed_path(self, track_id: str, file_format: MediaFileFormat) -> Path:
|
||||
file_suffix = "m4a" if file_format == MediaFileFormat.M4A else "mp4"
|
||||
return self.downloader.temp_path / f"{track_id}_remuxed.{file_suffix}"
|
||||
|
||||
def fix_key_id(self, encrypted_path: Path):
|
||||
count = 0
|
||||
@@ -292,15 +344,16 @@ class DownloaderSong:
|
||||
decrypted_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def remux(self, decrypted_path: Path, remuxed_path: Path) -> None:
|
||||
def remux(self, decrypted_path: Path, remuxed_path: Path):
|
||||
if self.downloader.remux_mode == RemuxMode.MP4BOX:
|
||||
self.remux_mp4box(decrypted_path, remuxed_path)
|
||||
elif self.downloader.remux_mode == RemuxMode.FFMPEG:
|
||||
self.remux_ffmpeg(decrypted_path, remuxed_path)
|
||||
|
||||
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path) -> None:
|
||||
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path):
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.mp4box_path_full,
|
||||
@@ -309,13 +362,19 @@ class DownloaderSong:
|
||||
decrypted_path,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-keep-utc",
|
||||
"-new",
|
||||
remuxed_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def remux_ffmpeg(self, decrypted_path: Path, remuxed_path: Path) -> None:
|
||||
def remux_ffmpeg(
|
||||
self,
|
||||
decrypted_path: Path,
|
||||
remuxed_path: Path,
|
||||
):
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.ffmpeg_path_full,
|
||||
@@ -331,6 +390,7 @@ class DownloaderSong:
|
||||
remuxed_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def get_lyrics_synced_path(self, final_path: Path) -> Path:
|
||||
@@ -338,8 +398,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)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -7,43 +9,50 @@ from pywidevine import PSSH
|
||||
from pywidevine.license_protocol_pb2 import WidevinePsshData
|
||||
|
||||
from .downloader_song import DownloaderSong
|
||||
from .enums import RemuxMode, SongCodec
|
||||
from .models import StreamInfo
|
||||
from .enums import MediaFileFormat, RemuxMode, SongCodec
|
||||
from .models import StreamInfo, StreamInfoAv
|
||||
|
||||
|
||||
class DownloaderSongLegacy(DownloaderSong):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_stream_info(self, webplayback: dict) -> StreamInfo:
|
||||
def get_stream_info(self, webplayback: dict) -> StreamInfoAv:
|
||||
flavor = "32:ctrp64" if self.codec == SongCodec.AAC_HE_LEGACY else "28:ctrp256"
|
||||
stream_info = StreamInfo()
|
||||
stream_info.stream_url = next(
|
||||
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
|
||||
return stream_info
|
||||
stream_info.widevine_pssh = m3u8_obj.keys[0].uri
|
||||
return StreamInfoAv(
|
||||
audio_track=stream_info,
|
||||
file_format=MediaFileFormat.M4A,
|
||||
)
|
||||
|
||||
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(
|
||||
@@ -52,7 +61,6 @@ class DownloaderSongLegacy(DownloaderSong):
|
||||
decrypted_path: Path,
|
||||
decryption_key: str,
|
||||
):
|
||||
self.fix_key_id(encrypted_path)
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.mp4decrypt_path_full,
|
||||
@@ -62,9 +70,10 @@ class DownloaderSongLegacy(DownloaderSong):
|
||||
decrypted_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path) -> None:
|
||||
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path):
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.mp4box_path_full,
|
||||
@@ -73,10 +82,12 @@ class DownloaderSongLegacy(DownloaderSong):
|
||||
decrypted_path,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-keep-utc",
|
||||
"-new",
|
||||
remuxed_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def remux_ffmpeg(
|
||||
@@ -102,6 +113,7 @@ class DownloaderSongLegacy(DownloaderSong):
|
||||
remuxed_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def remux(
|
||||
|
||||
+16
-3
@@ -20,8 +20,9 @@ class SongCodec(Enum):
|
||||
AAC_DOWNMIX = "aac-downmix"
|
||||
AAC_HE_BINAURAL = "aac-he-binaural"
|
||||
AAC_HE_DOWNMIX = "aac-he-downmix"
|
||||
ALAC = "alac"
|
||||
ATMOS = "atmos"
|
||||
AC3 = "ac3"
|
||||
ALAC = "alac"
|
||||
ASK = "ask"
|
||||
|
||||
|
||||
@@ -32,11 +33,22 @@ class SyncedLyricsFormat(Enum):
|
||||
|
||||
|
||||
class MusicVideoCodec(Enum):
|
||||
H264_BEST = "h264-best"
|
||||
H265_BEST = "h265-best"
|
||||
H264 = "h264"
|
||||
H265 = "h265"
|
||||
ASK = "ask"
|
||||
|
||||
|
||||
class RemuxFormatMusicVideo(Enum):
|
||||
M4V = "m4v"
|
||||
MP4 = "mp4"
|
||||
|
||||
|
||||
class MediaFileFormat(Enum):
|
||||
M4A = "m4a"
|
||||
MP4 = "mp4"
|
||||
M4V = "m4v"
|
||||
|
||||
|
||||
class PostQuality(Enum):
|
||||
BEST = "best"
|
||||
ASK = "ask"
|
||||
@@ -45,3 +57,4 @@ class PostQuality(Enum):
|
||||
class CoverFormat(Enum):
|
||||
JPG = "jpg"
|
||||
PNG = "png"
|
||||
RAW = "raw"
|
||||
|
||||
+3
-3
@@ -4,8 +4,8 @@ import functools
|
||||
|
||||
import requests
|
||||
|
||||
from .apple_music_api import AppleMusicApi
|
||||
from .constants import STOREFRONT_IDS
|
||||
from .utils import raise_response_exception
|
||||
|
||||
|
||||
class ItunesApi:
|
||||
@@ -58,7 +58,7 @@ class ItunesApi:
|
||||
requests.exceptions.JSONDecodeError,
|
||||
AssertionError,
|
||||
):
|
||||
AppleMusicApi._raise_response_exception(response)
|
||||
raise_response_exception(response)
|
||||
return resource
|
||||
|
||||
def get_itunes_page(
|
||||
@@ -81,5 +81,5 @@ class ItunesApi:
|
||||
requests.exceptions.JSONDecodeError,
|
||||
AssertionError,
|
||||
):
|
||||
AppleMusicApi._raise_response_exception(response)
|
||||
raise_response_exception(response)
|
||||
return itunes_page
|
||||
|
||||
+19
-3
@@ -1,16 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .enums import MediaFileFormat
|
||||
|
||||
|
||||
@dataclass
|
||||
class UrlInfo:
|
||||
storefront: str = None
|
||||
type: str = None
|
||||
id: str = None
|
||||
is_library: bool = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadQueueItem:
|
||||
metadata: dict = None
|
||||
class DownloadQueue:
|
||||
playlist_attributes: dict = None
|
||||
medias_metadata: list[dict] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -22,4 +28,14 @@ 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
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamInfoAv:
|
||||
video_track: StreamInfo = None
|
||||
audio_track: StreamInfo = None
|
||||
file_format: MediaFileFormat = None
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import colorama
|
||||
import requests
|
||||
|
||||
from .constants import X_NOT_FOUND_STRING
|
||||
|
||||
|
||||
def color_text(text: str, color) -> str:
|
||||
return color + text + colorama.Style.RESET_ALL
|
||||
|
||||
|
||||
def raise_response_exception(response: requests.Response):
|
||||
raise Exception(
|
||||
f"Request failed with status code {response.status_code}: {response.text}"
|
||||
)
|
||||
|
||||
|
||||
def prompt_path(is_file: bool, initial_path: Path, description: str) -> Path:
|
||||
path_validator = click.Path(
|
||||
exists=True,
|
||||
file_okay=is_file,
|
||||
dir_okay=not is_file,
|
||||
path_type=Path,
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
path_obj = path_validator.convert(initial_path, None, None)
|
||||
break
|
||||
except click.BadParameter as e:
|
||||
path_str = click.prompt(
|
||||
(
|
||||
f"{X_NOT_FOUND_STRING.format(description, initial_path.absolute())} or "
|
||||
"the specified path is not valid. "
|
||||
"Move it to that location, type the path or drag and drop it here. "
|
||||
"Then, press enter to continue"
|
||||
),
|
||||
default=str(initial_path),
|
||||
show_default=False,
|
||||
)
|
||||
path_str = path_str.strip('"')
|
||||
initial_path = Path(path_str)
|
||||
return path_obj
|
||||
+6
-5
@@ -1,15 +1,16 @@
|
||||
[project]
|
||||
name = "gamdl"
|
||||
description = "Download Apple Music songs/music videos/albums/playlists"
|
||||
requires-python = ">=3.8"
|
||||
description = "A command-line app for downloading Apple Music songs, music videos and post videos."
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "glomatico" }]
|
||||
dependencies = [
|
||||
"ciso8601",
|
||||
"click",
|
||||
"colorama",
|
||||
"inquirerpy",
|
||||
"m3u8",
|
||||
"tabulate",
|
||||
"mutagen",
|
||||
"pillow",
|
||||
"pywidevine",
|
||||
"pyyaml",
|
||||
"yt-dlp",
|
||||
]
|
||||
readme = "README.md"
|
||||
|
||||
+5
-2
@@ -1,7 +1,10 @@
|
||||
ciso8601
|
||||
click
|
||||
colorama
|
||||
inquirerpy
|
||||
m3u8
|
||||
tabulate
|
||||
mutagen
|
||||
pillow
|
||||
pywidevine
|
||||
pyyaml
|
||||
termcolor
|
||||
yt-dlp
|
||||
|
||||
Reference in New Issue
Block a user