mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-13 20:25:13 +03:00
Compare commits
383 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d150c35a8 | |||
| be8eeb80c9 | |||
| b17c31d416 | |||
| 42d10d555a | |||
| 38d131a699 | |||
| 322cb7714e | |||
| 6383dd78c4 | |||
| 04351c8e34 | |||
| 758f64ce38 | |||
| e797690a13 | |||
| 332dc9baad | |||
| 8be3d0babd | |||
| d1a32adcf8 | |||
| bb5652c2f9 | |||
| b6a756d661 | |||
| a4e4c9d0fd | |||
| 993872acde | |||
| 9de1ec033a | |||
| 3fb28d4e2d | |||
| 678e3cbad6 | |||
| 0384944589 | |||
| 3eb9dd3fbd | |||
| 1fbb3f1da6 | |||
| cd787e66cd | |||
| b4e41cbdd8 | |||
| 16d0c046ad | |||
| ec81808fd8 | |||
| 4113e8435c | |||
| 3d3251fef7 | |||
| b1dae8c21c | |||
| a4af50b4a0 | |||
| d88cf3438a | |||
| 138154974f | |||
| f6ede92322 | |||
| 65d8289d2e | |||
| bb6a922c0a | |||
| 534c6d6f7b | |||
| 3ca50af186 | |||
| 16d7d857d4 | |||
| 85004e6f5e | |||
| 98698e999c | |||
| 828c4e494a | |||
| e8310c6ea2 | |||
| 7a8311628d | |||
| b5406ca31d | |||
| e7c0e0e7a0 | |||
| 141a18e223 | |||
| 8df23c84cf | |||
| bd6310d39b | |||
| b7ea0aef19 | |||
| 569a35eaaf | |||
| 3bc01ad075 | |||
| 8369c41725 | |||
| 082f30ed4a | |||
| a2b284403f | |||
| ae32670c2e | |||
| cc3592951f | |||
| 8a4a30f047 | |||
| ce942d30f1 | |||
| 68fd1d5ae5 | |||
| d86f42ef22 | |||
| 7b71dc4e1c | |||
| 591dd6c71d | |||
| da1a896c7b | |||
| 65ca041fb6 | |||
| 4f5cf185aa | |||
| 9f16469a1b | |||
| 25d5f422fd | |||
| 74ff16b487 | |||
| 165e78c69b | |||
| 6fd01557af | |||
| 68a88e8aec | |||
| cf44b59757 | |||
| 438fa1087c | |||
| 8ba73ea952 | |||
| 45b49cd22e | |||
| 8decb3001e | |||
| fdfcb24efb | |||
| e47aa7dbea | |||
| c7caba519e | |||
| 66a0e2b5f7 | |||
| 7f5f2a7524 | |||
| 19589bf683 | |||
| b7a0545151 | |||
| f77ac9861f | |||
| c785acb69e | |||
| 1afdd4c4b5 | |||
| c265b4be50 | |||
| 0b43049dc8 | |||
| 4cf54b6221 | |||
| 33b2d08aa9 | |||
| fa80558050 | |||
| 9964bc5022 | |||
| 90b59152dc | |||
| 9a7ae643d8 | |||
| d5e0ef0823 | |||
| d2b2dff223 | |||
| 58093887b6 | |||
| 66564ef2ba | |||
| fbe64946e8 | |||
| 7792e581e7 | |||
| 349dbd0fc6 | |||
| 51d4addd7a | |||
| 38fede14fb | |||
| 6e31633d01 | |||
| 136b46309e | |||
| b916ac2715 | |||
| 5b970e4e5b | |||
| 9c517226b5 | |||
| bde5749084 | |||
| fec3682655 | |||
| 1248228394 | |||
| 9b556ff736 | |||
| 363da82556 | |||
| 2478135561 | |||
| ccee28f61e | |||
| 8f5683b870 | |||
| 174c351edf | |||
| 363013f4c7 | |||
| 5b484d6f1d | |||
| a4a5a916b2 | |||
| 026dc1a83b | |||
| 7fd61ad850 | |||
| fbf181c732 | |||
| 44e52697f6 | |||
| 2f1779690b | |||
| 115becc3d9 | |||
| 3342938a6a | |||
| 577f55a005 | |||
| 51bc3876ec | |||
| dc04bfc5b4 | |||
| ab2f1becc8 | |||
| c38a17b44c | |||
| a3444ef6ef | |||
| ed5491c87d | |||
| fc16df44ab | |||
| 282c6a407b | |||
| b32f921f6c | |||
| 3183e04c78 | |||
| f4469fb332 | |||
| 33d422e5d2 | |||
| b0e5bdad28 | |||
| e243b2b3b5 | |||
| fe72c2ca0f | |||
| fe1aa5e62d | |||
| 3c9d6da2d8 | |||
| 1e3449d850 | |||
| 3de0bff6ff | |||
| d907d2131f | |||
| ca9fec9efd | |||
| fc1f8fc639 | |||
| ea37530df1 | |||
| 5264c045f8 | |||
| 429eb5c1d2 | |||
| b325ebc04e | |||
| 2f64cf4fea | |||
| b9d049562c | |||
| 9a479c34dd | |||
| 8805b31c6e | |||
| 664072b5a0 | |||
| 121056d0f5 | |||
| d93b353a00 | |||
| f19b27416f | |||
| bb66b221d7 | |||
| 01c66279db | |||
| 0faaacbe91 | |||
| b29033f4cd | |||
| 77a849fed3 | |||
| fe6d1e5378 | |||
| 3e298425cc | |||
| 239bb1255b | |||
| 873cf48812 | |||
| 80f1c3a4a3 | |||
| b781ccacd5 | |||
| 807878b8ae | |||
| e901cfc6e5 | |||
| 77c20d76a5 | |||
| bef05689b4 | |||
| db22291167 | |||
| 08146f3a95 | |||
| 74a28933a2 | |||
| 27be0116a0 | |||
| aed9bc3bc8 | |||
| 5b3ef3a17e | |||
| c13ed8593f | |||
| 8b762c21ee | |||
| e5aa261eea | |||
| f6741a440d | |||
| b47b293ef7 | |||
| 82a102a893 | |||
| a46370c8fc | |||
| 68c51e0ad6 | |||
| c647872828 | |||
| b8ae10bc55 | |||
| 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 |
@@ -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,194 +1,270 @@
|
||||
# Glomatico's Apple Music Downloader
|
||||
A Python CLI app for downloading Apple Music songs/music videos/albums/playlists/posts.
|
||||
|
||||
**Discord Server:** https://discord.gg/aBjMEZ9tnq
|
||||
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
|
||||
* Use artist links to download all of their albums or music videos
|
||||
|
||||
- **High-Quality Songs**: Download songs in AAC 256kbps and other codecs.
|
||||
- **High-Quality Music Videos**: Download music videos in resolutions up to 4K.
|
||||
- **Synced Lyrics**: Download synced lyrics in LRC, SRT, or TTML formats.
|
||||
- **Artist Support**: Download all albums or music videos from an artist using their link.
|
||||
- **Highly Customizable**: Extensive configuration options for advanced users.
|
||||
|
||||
## Prerequisites
|
||||
* Python 3.8 or higher
|
||||
* The cookies file of your Apple Music account (requires an active subscription)
|
||||
* You can get your cookies by using one of the following extensions on your browser of choice at the Apple Music website with your account signed in:
|
||||
* Firefox: https://addons.mozilla.org/addon/export-cookies-txt
|
||||
* Chromium based browsers: https://chrome.google.com/webstore/detail/gdocmgbfkjnnpapoeobnolbbkoibbcif
|
||||
* FFmpeg on your system PATH
|
||||
* Older versions of FFmpeg may not work.
|
||||
* Up to date binaries can be obtained from the links below:
|
||||
* Windows: https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases
|
||||
* Linux: https://johnvansickle.com/ffmpeg/
|
||||
* (Optional) mp4decrypt on your system PATH
|
||||
* Required to download music videos and songs in non-legacy formats.
|
||||
* Binaries can be obtained from here: https://www.bento4.com/downloads/.
|
||||
|
||||
|
||||
- **Python 3.10 or higher** installed on your system.
|
||||
- The **cookies file** of your Apple Music browser session in Netscape format. Use one of the following extensions at the Apple Music homepage while logged in and with an active subscription to export the cookies:
|
||||
- **Firefox**: [Export Cookies](https://addons.mozilla.org/addon/export-cookies-txt).
|
||||
- **Chromium-based Browsers**: [Get cookies.txt LOCALLY](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc).
|
||||
- **FFmpeg** on your system PATH. Use one of the recommended builds:
|
||||
- **Windows**: [AnimMouse's FFmpeg Builds](https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases).
|
||||
- **Linux**: [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 file in the directory from which you will be running gamdl and name it `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
|
||||
|
||||
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"
|
||||
```
|
||||
* Choose which albums or music videos to download from an artist
|
||||
```bash
|
||||
gamdl "https://music.apple.com/us/artist/rick-astley/669771"
|
||||
```
|
||||
|
||||
- Download a Song:
|
||||
|
||||
```bash
|
||||
gamdl "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
|
||||
```
|
||||
|
||||
- Download an Album:
|
||||
|
||||
```bash
|
||||
gamdl "https://music.apple.com/us/album/whenever-you-need-somebody-2022-remaster/1624945511"
|
||||
```
|
||||
|
||||
- Download from an Artist:
|
||||
|
||||
```bash
|
||||
gamdl "https://music.apple.com/us/artist/rick-astley/669771"
|
||||
```
|
||||
|
||||
### Interactive prompt controls
|
||||
|
||||
- **Arrow keys**: Move selection
|
||||
- **Space**: Toggle selection
|
||||
- **Ctrl + A**: Select all
|
||||
- **Enter**: Confirm selection
|
||||
|
||||
## Configuration
|
||||
You can configure gamdl by using the command line arguments or the config file. The config file is created automatically when you run gamdl for the first time at `~/.gamdl/config.json` on Linux and `%USERPROFILE%\.gamdl\config.json` on Windows. Config file values can be overridden using command line arguments.
|
||||
| Command line argument / Config file key | Description | Default value |
|
||||
| --------------------------------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------- |
|
||||
| `--disable-music-video-skip` / `disable_music_video_skip` | Don't skip downloading music videos in albums/playlists. | `false` |
|
||||
| `--save-cover`, `-s` / `save_cover` | Save cover as a separate file. | `false` |
|
||||
| `--overwrite` / `overwrite` | Overwrite existing files. | `false` |
|
||||
| `--read-urls-as-txt`, `-r` / - | Interpret URLs as paths to text files containing URLs separated by newlines. | `false` |
|
||||
| `--synced-lyrics-only` / `synced_lyrics_only` | Download only the synced lyrics. | `false` |
|
||||
| `--no-synced-lyrics` / `no_synced_lyrics` | Don't download the synced lyrics. | `false` |
|
||||
| `--config-path` / - | Path to config file. | `<home>/.spotify-web-downloader/config.json` |
|
||||
| `--log-level` / `log_level` | Log level. | `INFO` |
|
||||
| `--print-exceptions` / `print_exceptions` | Print exceptions. | `false` |
|
||||
| `--cookies-path`, `-c` / `cookies_path` | Path to .txt cookies file. | `./cookies.txt` |
|
||||
| `--language`, `-l` / `language` | Metadata language as an ISO-2A language code (don't always work for videos). | `en-US` |
|
||||
| `--output-path`, `-o` / `output_path` | Path to output directory. | `./Apple Music` |
|
||||
| `--temp-path` / `temp_path` | Path to temporary directory. | `./temp` |
|
||||
| `--wvd-path` / `wvd_path` | Path to .wvd file. | `null` |
|
||||
| `--nm3u8dlre-path` / `nm3u8dlre_path` | Path to N_m3u8DL-RE binary. | `N_m3u8dl-RE` |
|
||||
| `--mp4decrypt-path` / `mp4decrypt_path` | Path to mp4decrypt binary. | `mp4decrypt` |
|
||||
| `--ffmpeg-path` / `ffmpeg_path` | Path to FFmpeg binary. | `ffmpeg` |
|
||||
| `--mp4box-path` / `mp4box_path` | Path to MP4Box binary. | `MP4Box` |
|
||||
| `--download-mode` / `download_mode` | Download mode. | `ytdlp` |
|
||||
| `--remux-mode` / `remux_mode` | Remux mode. | `ffmpeg` |
|
||||
| `--cover-format` / `cover_format` | Cover format. | `jpg` |
|
||||
| `--template-folder-album` / `template_folder_album` | Template folder for tracks that are part of an album. | `{album_artist}/{album}` |
|
||||
| `--template-folder-compilation` / `template_folder_compilation` | Template folder for tracks that are part of a compilation album. | `Compilations/{album}` |
|
||||
| `--template-file-single-disc` / `template_file_single_disc` | Template file for the tracks that are part of a single-disc album. | `{track:02d} {title}` |
|
||||
| `--template-file-multi-disc` / `template_file_multi_disc` | Template file for the tracks that are part of a multi-disc album. | `{disc}-{track:02d} {title}` |
|
||||
| `--template-folder-no-album` / `template_folder_no_album` | Template folder for the tracks that are not part of an album. | `{artist}/Unknown Album` |
|
||||
| `--template-file-no-album` / `template_file_no_album` | Template file for the tracks that are not part of an album. | `{title}` |
|
||||
| `--template-date` / `template_date` | Date tag template. | `%Y-%m-%dT%H:%M:%SZ` |
|
||||
| `--exclude-tags` / `exclude_tags` | Comma-separated tags to exclude. | `null` |
|
||||
| `--cover-size` / `cover_size` | Cover size. | `1200` |
|
||||
| `--truncate` / `truncate` | Maximum length of the file/folder names. | `40` |
|
||||
| `--codec-song` / `codec_song` | Song codec. | `aac-legacy` |
|
||||
| `--synced-lyrics-format` / `synced_lyrics_format` | Synced lyrics format. | `lrc` |
|
||||
| `--codec-music-video` / `codec_music_video` | Music video codec. | `h264` |
|
||||
| `--quality-post` / `quality_post` | Post video quality. | `best` |
|
||||
| `--no-config-file`, `-n` / - | Do not use a config file. | `false` |
|
||||
|
||||
Gamdl can be configured by using the command-line arguments or the config file.
|
||||
|
||||
The config file is created automatically when you run Gamdl for the first time at `~/.gamdl/config.ini` on Linux and `%USERPROFILE%\.gamdl\config.ini` 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` |
|
||||
| `--read-urls-as-txt`, `-r` / - | Interpret URLs as paths to text files containing URLs separated by newlines | `false` |
|
||||
| `--config-path` / - | Path to config file. | `<home>/.gamdl/config.ini` |
|
||||
| `--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` |
|
||||
| `--overwrite` / `overwrite` | Overwrite existing files. | `false` |
|
||||
| `--save-cover`, `-s` / `save_cover` | Save cover as a separate file. | `false` |
|
||||
| `--save-playlist` / `save_playlist` | Save a M3U8 playlist file when downloading a playlist. | `false` |
|
||||
| `--no-synced-lyrics` / `no_synced_lyrics` | Don't download the synced lyrics. | `false` |
|
||||
| `--synced-lyrics-only` / `synced_lyrics_only` | Download only the synced lyrics. | `false` |
|
||||
| `--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_artist}/{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` | Comma-separated music video codec priority. | `h264,h265` |
|
||||
| `--remux-format-music-video` / `remux_format_music_video` | Music video remux format. | `m4v` |
|
||||
| `--quality-post` / `quality_post` | Post video quality. | `best` |
|
||||
| `--resolution` / `resolution` | Target video resolution for music videos. | `1080p` |
|
||||
| `--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`: Supports strftime formats. For example, `{date:%Y}` will be replaced with the year of the release 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`
|
||||
- `all`: Skip tagging.
|
||||
|
||||
### 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`
|
||||
* `atmos`
|
||||
* `ac3`
|
||||
* `alac`
|
||||
* `ask`
|
||||
* When using this option, gamdl will ask you which **non-legacy** codec to use that is available for the song.
|
||||
### Song Codecs
|
||||
|
||||
**Support for non-legacy codecs are not guaranteed, as most of the songs cannot be downloaded 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 (no reports of successful downloads have been made).
|
||||
- `ask`: Prompt to choose available audio codec.
|
||||
|
||||
### Music Videos Codecs
|
||||
|
||||
- `h264`
|
||||
- `h265`
|
||||
- `ask`: Prompt to choose available video and audio codecs.
|
||||
|
||||
### Music Videos Remux Formats
|
||||
|
||||
- `m4v`: Default remux format.
|
||||
- `mp4`
|
||||
|
||||
### Music Videos Maximum Resolutions
|
||||
|
||||
- H.264 Resolutions:
|
||||
- `240p`
|
||||
- `360p`
|
||||
- `480p`
|
||||
- `540p`
|
||||
- `720p`
|
||||
- `1080p`
|
||||
- H.265-only Resolutions:
|
||||
- `1440p`
|
||||
- `2160p`
|
||||
|
||||
### Music videos codecs
|
||||
The following codecs are available:
|
||||
* `h264` (up to 1080p, with AAC 256kbps)
|
||||
* `h265` (up to 2160p, with AAC 256kpbs)
|
||||
* `ask`
|
||||
* When using this option, gamdl will ask you which audio and video codec to use that is available for the music video.
|
||||
|
||||
### Post videos/extra videos qualities
|
||||
The following qualities are available:
|
||||
* `best` (up to 1080p, with AAC 256kbps)
|
||||
* `ask`
|
||||
* When using this option, gamdl will ask you which video quality to use that is available for the video.
|
||||
|
||||
Post videos doesn't require remuxing and are limited to `ytdlp` download mode.
|
||||
- `best`: Up to 1080p with AAC 256kbps.
|
||||
- `ask`: Prompt to choose available video quality.
|
||||
|
||||
### Synced lyrics formats
|
||||
The following synced lyrics formats are available:
|
||||
* `lrc`
|
||||
* `srt`
|
||||
* `ttml`
|
||||
* Native format for Apple Music synced lyrics.
|
||||
* Highly unsupported by media players.
|
||||
|
||||
### 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).
|
||||
|
||||
## Embedding
|
||||
|
||||
Gamdl can be used as a library in Python scripts. Here's a basic example of downloading a song by its ID:
|
||||
|
||||
```python
|
||||
from gamdl import AppleMusicApi, ItunesApi, Downloader, DownloaderSong
|
||||
|
||||
|
||||
apple_music_api = AppleMusicApi.from_netscape_cookies(cookies_path="cookies.txt")
|
||||
itunes_api = ItunesApi(
|
||||
storefront=apple_music_api.storefront,
|
||||
language=apple_music_api.language,
|
||||
)
|
||||
|
||||
downloader = Downloader(
|
||||
apple_music_api=apple_music_api,
|
||||
itunes_api=itunes_api,
|
||||
)
|
||||
downloader.set_cdm()
|
||||
downloader_song = DownloaderSong(downloader=downloader)
|
||||
|
||||
downloader_song.download(media_id="1624945512")
|
||||
```
|
||||
|
||||
+8
-1
@@ -1 +1,8 @@
|
||||
__version__ = "2.2"
|
||||
from .apple_music_api import AppleMusicApi
|
||||
from .downloader import Downloader
|
||||
from .downloader_music_video import DownloaderMusicVideo
|
||||
from .downloader_post import DownloaderPost
|
||||
from .downloader_song import DownloaderSong
|
||||
from .itunes_api import ItunesApi
|
||||
|
||||
__version__ = "2.6.1"
|
||||
|
||||
+190
-43
@@ -6,12 +6,15 @@ import time
|
||||
import typing
|
||||
from http.cookiejar import MozillaCookieJar
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from .utils import raise_response_exception
|
||||
|
||||
|
||||
class AppleMusicApi:
|
||||
APPLE_MUSIC_HOMEPAGE_URL = "https://beta.music.apple.com"
|
||||
APPLE_MUSIC_HOMEPAGE_URL = "https://music.apple.com"
|
||||
AMP_API_URL = "https://amp-api.music.apple.com"
|
||||
WEBPLAYBACK_API_URL = (
|
||||
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback"
|
||||
@@ -21,41 +24,69 @@ 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
|
||||
index_js_uri = re.search(
|
||||
r"/(assets/index-legacy-[^/]+\.js)",
|
||||
@@ -65,26 +96,42 @@ class AppleMusicApi:
|
||||
f"{self.APPLE_MUSIC_HOMEPAGE_URL}/{index_js_uri}"
|
||||
).text
|
||||
token = re.search('(?=eyJh)(.*?)(?=")', index_js_page).group(1)
|
||||
|
||||
self.session.headers.update({"authorization": f"Bearer {token}"})
|
||||
self.session.params = {"l": self.language}
|
||||
|
||||
@staticmethod
|
||||
def _raise_response_exception(response: requests.Response):
|
||||
raise Exception(
|
||||
f"Request failed with status code {response.status_code}: {response.text}"
|
||||
)
|
||||
if self.media_user_token:
|
||||
self.session.cookies.update(
|
||||
{
|
||||
"media-user-token": self.media_user_token,
|
||||
}
|
||||
)
|
||||
self._set_account_info()
|
||||
|
||||
def _check_amp_api_response(self, response: requests.Response):
|
||||
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) -> None:
|
||||
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,
|
||||
@@ -92,7 +139,7 @@ class AppleMusicApi:
|
||||
include: str = "albums,music-videos",
|
||||
limit: int = 100,
|
||||
fetch_all: bool = True,
|
||||
) -> dict:
|
||||
) -> dict | None:
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/artists/{artist_id}",
|
||||
params={
|
||||
@@ -100,13 +147,17 @@ class AppleMusicApi:
|
||||
**{f"limit[{_include}]": limit for _include in include.split(",")},
|
||||
},
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
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
|
||||
@@ -116,7 +167,7 @@ class AppleMusicApi:
|
||||
song_id: str,
|
||||
extend: str = "extendedAssetUrls",
|
||||
include: str = "lyrics,albums",
|
||||
) -> dict:
|
||||
) -> dict | None:
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/songs/{song_id}",
|
||||
params={
|
||||
@@ -124,31 +175,40 @@ class AppleMusicApi:
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
self._check_amp_api_response(response)
|
||||
|
||||
return response.json()["data"][0]
|
||||
|
||||
def get_music_video(
|
||||
self,
|
||||
music_video_id: str,
|
||||
include: str = "albums",
|
||||
) -> dict:
|
||||
) -> dict | None:
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/music-videos/{music_video_id}",
|
||||
params={
|
||||
"include": include,
|
||||
},
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
self._check_amp_api_response(response)
|
||||
|
||||
return response.json()["data"][0]
|
||||
|
||||
def get_post(
|
||||
self,
|
||||
post_id: str,
|
||||
) -> dict:
|
||||
) -> dict | None:
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/uploaded-videos/{post_id}"
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
self._check_amp_api_response(response)
|
||||
|
||||
return response.json()["data"][0]
|
||||
|
||||
@functools.lru_cache()
|
||||
@@ -156,37 +216,112 @@ class AppleMusicApi:
|
||||
self,
|
||||
album_id: str,
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict:
|
||||
) -> dict | None:
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/albums/{album_id}",
|
||||
params={
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
self._check_amp_api_response(response)
|
||||
|
||||
return response.json()["data"][0]
|
||||
|
||||
def get_playlist(
|
||||
self,
|
||||
playlist_id: str,
|
||||
is_library: bool = False,
|
||||
limit_tracks: int = 300,
|
||||
extend: str = "extendedAssetUrls",
|
||||
fetch_all: bool = True,
|
||||
) -> dict:
|
||||
) -> dict | None:
|
||||
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,
|
||||
},
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
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_tracks,
|
||||
extend,
|
||||
):
|
||||
playlist["relationships"]["tracks"]["data"].extend(additional_data)
|
||||
return playlist
|
||||
|
||||
def search(
|
||||
self,
|
||||
term: str,
|
||||
types: str = "songs,albums,artists,playlists",
|
||||
limit: int = 25,
|
||||
offset: int = 0,
|
||||
) -> dict | None:
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/search",
|
||||
params={
|
||||
"term": term,
|
||||
"types": types,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
},
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
self._check_amp_api_response(response)
|
||||
|
||||
return response.json()["results"]
|
||||
|
||||
def get_library_album(
|
||||
self,
|
||||
album_id: str,
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict | None:
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/me/library/albums/{album_id}",
|
||||
params={
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
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 | None:
|
||||
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,
|
||||
},
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
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
|
||||
@@ -195,22 +330,30 @@ class AppleMusicApi:
|
||||
self,
|
||||
api_response: dict,
|
||||
limit: int,
|
||||
extend: str,
|
||||
) -> typing.Generator[list[dict], None, None]:
|
||||
next_uri = api_response.get("next")
|
||||
while next_uri:
|
||||
playlist_next = self._get_next_uri_response(next_uri, limit)
|
||||
playlist_next = self._get_next_uri_response(next_uri, limit, extend)
|
||||
yield playlist_next["data"]
|
||||
next_uri = playlist_next.get("next")
|
||||
time.sleep(self.WAIT_TIME)
|
||||
|
||||
def _get_next_uri_response(self, next_uri: str, limit: int) -> dict:
|
||||
def _get_next_uri_response(
|
||||
self,
|
||||
next_uri: str,
|
||||
limit: int,
|
||||
extend: str,
|
||||
) -> dict:
|
||||
response = self.session.get(
|
||||
self.AMP_API_URL + next_uri,
|
||||
params={
|
||||
"limit": limit,
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
self._check_amp_api_response(response)
|
||||
|
||||
return response.json()
|
||||
|
||||
def get_webplayback(
|
||||
@@ -224,6 +367,7 @@ class AppleMusicApi:
|
||||
"language": self.language,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
response_dict = response.json()
|
||||
@@ -234,7 +378,8 @@ class AppleMusicApi:
|
||||
requests.exceptions.JSONDecodeError,
|
||||
AssertionError,
|
||||
):
|
||||
self._raise_response_exception(response)
|
||||
raise_response_exception(response)
|
||||
|
||||
return webplayback[0]
|
||||
|
||||
def get_widevine_license(
|
||||
@@ -254,6 +399,7 @@ class AppleMusicApi:
|
||||
"user-initiated": True,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
response_dict = response.json()
|
||||
@@ -264,5 +410,6 @@ class AppleMusicApi:
|
||||
requests.exceptions.JSONDecodeError,
|
||||
AssertionError,
|
||||
):
|
||||
self._raise_response_exception(response)
|
||||
raise_response_exception(response)
|
||||
|
||||
return widevine_license
|
||||
|
||||
+258
-329
@@ -1,48 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
from enum import Enum
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import colorama
|
||||
|
||||
from . import __version__
|
||||
from .apple_music_api import AppleMusicApi
|
||||
from .config_file import ConfigFile
|
||||
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,
|
||||
MusicVideoResolution,
|
||||
PostQuality,
|
||||
RemuxFormatMusicVideo,
|
||||
RemuxMode,
|
||||
SongCodec,
|
||||
SyncedLyricsFormat,
|
||||
)
|
||||
from .exceptions import *
|
||||
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__)
|
||||
|
||||
|
||||
def get_param_string(param: click.Parameter) -> str:
|
||||
if isinstance(param.default, Enum):
|
||||
return param.default.value
|
||||
elif isinstance(param.default, Path):
|
||||
return str(param.default)
|
||||
else:
|
||||
return param.default
|
||||
logger = logging.getLogger("gamdl")
|
||||
|
||||
|
||||
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)
|
||||
for param in ctx.command.params
|
||||
if param.name not in EXCLUDED_CONFIG_FILE_PARAMS
|
||||
}
|
||||
ctx.params["config_path"].write_text(json.dumps(config_file, indent=4))
|
||||
class Csv(click.ParamType):
|
||||
name = "csv"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
subtype: typing.Any,
|
||||
) -> None:
|
||||
self.subtype = subtype
|
||||
|
||||
def convert(
|
||||
self,
|
||||
value: str | typing.Any,
|
||||
param: click.Parameter,
|
||||
ctx: click.Context,
|
||||
) -> list[typing.Any]:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
items = [v.strip() for v in value.split(",") if v.strip()]
|
||||
result = []
|
||||
for item in items:
|
||||
try:
|
||||
result.append(self.subtype(item))
|
||||
except ValueError as e:
|
||||
self.fail(
|
||||
f"'{item}' is not a valid value for {self.subtype.__name__}",
|
||||
param,
|
||||
ctx,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def load_config_file(
|
||||
@@ -52,16 +81,27 @@ def load_config_file(
|
||||
) -> click.Context:
|
||||
if no_config_file:
|
||||
return ctx
|
||||
if not ctx.params["config_path"].exists():
|
||||
write_default_config_file(ctx)
|
||||
config_file = dict(json.loads(ctx.params["config_path"].read_text()))
|
||||
for param in ctx.command.params:
|
||||
if (
|
||||
config_file.get(param.name) is not None
|
||||
and not ctx.get_parameter_source(param.name)
|
||||
== click.core.ParameterSource.COMMANDLINE
|
||||
):
|
||||
ctx.params[param.name] = param.type_cast_value(ctx, config_file[param.name])
|
||||
|
||||
filtered_params = [
|
||||
param
|
||||
for param in ctx.command.params
|
||||
if param.name not in EXCLUDED_CONFIG_FILE_PARAMS
|
||||
]
|
||||
|
||||
config_file = ConfigFile(ctx.params["config_path"])
|
||||
config_file.add_params_default_to_config(
|
||||
filtered_params,
|
||||
)
|
||||
parsed_params = config_file.parse_params_from_config(
|
||||
[
|
||||
param
|
||||
for param in filtered_params
|
||||
if ctx.get_parameter_source(param.name)
|
||||
!= click.core.ParameterSource.COMMANDLINE
|
||||
]
|
||||
)
|
||||
ctx.params.update(parsed_params)
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
@@ -80,63 +120,44 @@ def load_config_file(
|
||||
is_flag=True,
|
||||
help="Don't skip downloading music videos in albums/playlists.",
|
||||
)
|
||||
@click.option(
|
||||
"--save-cover",
|
||||
"-s",
|
||||
is_flag=True,
|
||||
help="Save cover as a separate file.",
|
||||
)
|
||||
@click.option(
|
||||
"--overwrite",
|
||||
is_flag=True,
|
||||
help="Overwrite existing files.",
|
||||
)
|
||||
@click.option(
|
||||
"--read-urls-as-txt",
|
||||
"-r",
|
||||
is_flag=True,
|
||||
help="Interpret URLs as paths to text files containing URLs separated by newlines",
|
||||
)
|
||||
@click.option(
|
||||
"--synced-lyrics-only",
|
||||
is_flag=True,
|
||||
help="Download only the synced lyrics.",
|
||||
)
|
||||
@click.option(
|
||||
"--no-synced-lyrics",
|
||||
is_flag=True,
|
||||
help="Don't download the synced lyrics.",
|
||||
)
|
||||
@click.option(
|
||||
"--config-path",
|
||||
type=Path,
|
||||
default=Path.home() / ".gamdl" / "config.json",
|
||||
default=Path.home() / ".gamdl" / "config.ini",
|
||||
help="Path to 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_sig.parameters["language"].default,
|
||||
default=apple_music_api_from_netscape_cookies_sig.parameters["language"].default,
|
||||
help="Metadata language as an ISO-2A language code (don't always work for videos).",
|
||||
)
|
||||
# Downloader specific options
|
||||
@@ -159,6 +180,37 @@ def load_config_file(
|
||||
default=downloader_sig.parameters["wvd_path"].default,
|
||||
help="Path to .wvd file.",
|
||||
)
|
||||
@click.option(
|
||||
"--overwrite",
|
||||
is_flag=True,
|
||||
help="Overwrite existing files.",
|
||||
default=downloader_sig.parameters["overwrite"].default,
|
||||
)
|
||||
@click.option(
|
||||
"--save-cover",
|
||||
"-s",
|
||||
is_flag=True,
|
||||
help="Save cover as a separate file.",
|
||||
default=downloader_sig.parameters["save_cover"].default,
|
||||
)
|
||||
@click.option(
|
||||
"--save-playlist",
|
||||
is_flag=True,
|
||||
help="Save a M3U8 playlist file when downloading a playlist.",
|
||||
default=downloader_sig.parameters["save_playlist"].default,
|
||||
)
|
||||
@click.option(
|
||||
"--no-synced-lyrics",
|
||||
is_flag=True,
|
||||
help="Don't download the synced lyrics.",
|
||||
default=downloader_sig.parameters["no_synced_lyrics"].default,
|
||||
)
|
||||
@click.option(
|
||||
"--synced-lyrics-only",
|
||||
is_flag=True,
|
||||
help="Download only the synced lyrics.",
|
||||
default=downloader_sig.parameters["synced_lyrics_only"].default,
|
||||
)
|
||||
@click.option(
|
||||
"--nm3u8dlre-path",
|
||||
type=str,
|
||||
@@ -237,6 +289,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,
|
||||
@@ -245,7 +303,7 @@ def load_config_file(
|
||||
)
|
||||
@click.option(
|
||||
"--exclude-tags",
|
||||
type=str,
|
||||
type=Csv(str),
|
||||
default=downloader_sig.parameters["exclude_tags"].default,
|
||||
help="Comma-separated tags to exclude.",
|
||||
)
|
||||
@@ -277,9 +335,21 @@ def load_config_file(
|
||||
# DownloaderMusicVideo specific options
|
||||
@click.option(
|
||||
"--codec-music-video",
|
||||
type=MusicVideoCodec,
|
||||
type=Csv(MusicVideoCodec),
|
||||
default=downloader_music_video_sig.parameters["codec"].default,
|
||||
help="Music video codec.",
|
||||
help="Comma-separated music video codec priority.",
|
||||
)
|
||||
@click.option(
|
||||
"--remux-format-music-video",
|
||||
type=RemuxFormatMusicVideo,
|
||||
default=downloader_music_video_sig.parameters["remux_format"].default,
|
||||
help="Music video remux format.",
|
||||
)
|
||||
@click.option(
|
||||
"--resolution",
|
||||
type=MusicVideoResolution,
|
||||
default=downloader_music_video_sig.parameters["resolution"].default,
|
||||
help="Target video resolution for music videos.",
|
||||
)
|
||||
# DownloaderPost specific options
|
||||
@click.option(
|
||||
@@ -299,19 +369,20 @@ def load_config_file(
|
||||
def main(
|
||||
urls: list[str],
|
||||
disable_music_video_skip: bool,
|
||||
save_cover: bool,
|
||||
overwrite: bool,
|
||||
read_urls_as_txt: 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,
|
||||
overwrite: bool,
|
||||
save_cover: bool,
|
||||
save_playlist: bool,
|
||||
no_synced_lyrics: bool,
|
||||
synced_lyrics_only: bool,
|
||||
nm3u8dlre_path: str,
|
||||
mp4decrypt_path: str,
|
||||
ffmpeg_path: str,
|
||||
@@ -325,40 +396,63 @@ 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,
|
||||
exclude_tags: list[str],
|
||||
cover_size: int,
|
||||
truncate: int,
|
||||
codec_song: SongCodec,
|
||||
synced_lyrics_format: SyncedLyricsFormat,
|
||||
codec_music_video: MusicVideoCodec,
|
||||
codec_music_video: list[MusicVideoCodec],
|
||||
remux_format_music_video: RemuxFormatMusicVideo,
|
||||
resolution: MusicVideoResolution,
|
||||
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")
|
||||
if not cookies_path.exists():
|
||||
logger.critical(X_NOT_FOUND_STRING.format("Cookies file", cookies_path))
|
||||
return
|
||||
apple_music_api = AppleMusicApi(
|
||||
stream_handler = logging.StreamHandler()
|
||||
stream_handler.setFormatter(CustomLoggerFormatter())
|
||||
logger.addHandler(stream_handler)
|
||||
|
||||
cookies_path = prompt_path(True, cookies_path, "Cookies file")
|
||||
if wvd_path:
|
||||
wvd_path = prompt_path(True, wvd_path, ".wvd file")
|
||||
|
||||
logger.info("Starting Gamdl")
|
||||
apple_music_api = AppleMusicApi.from_netscape_cookies(
|
||||
cookies_path,
|
||||
language=language,
|
||||
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,
|
||||
)
|
||||
|
||||
downloader = Downloader(
|
||||
apple_music_api,
|
||||
itunes_api,
|
||||
output_path,
|
||||
temp_path,
|
||||
wvd_path,
|
||||
overwrite,
|
||||
save_cover,
|
||||
save_playlist,
|
||||
no_synced_lyrics,
|
||||
synced_lyrics_only,
|
||||
nm3u8dlre_path,
|
||||
mp4decrypt_path,
|
||||
ffmpeg_path,
|
||||
@@ -372,42 +466,47 @@ 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,
|
||||
codec_song,
|
||||
synced_lyrics_format,
|
||||
)
|
||||
downloader_song_legacy = DownloaderSongLegacy(
|
||||
downloader,
|
||||
codec_song,
|
||||
)
|
||||
downloader_music_video = DownloaderMusicVideo(
|
||||
downloader,
|
||||
codec_music_video,
|
||||
remux_format_music_video,
|
||||
resolution,
|
||||
)
|
||||
|
||||
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
|
||||
logger.debug("Setting up CDM")
|
||||
downloader.set_cdm()
|
||||
|
||||
if not downloader.ffmpeg_path_full and (
|
||||
remux_mode == RemuxMode.FFMPEG or download_mode == DownloadMode.NM3U8DLRE
|
||||
):
|
||||
logger.critical(X_NOT_FOUND_STRING.format("ffmpeg", ffmpeg_path))
|
||||
return
|
||||
|
||||
if not downloader.mp4box_path_full and remux_mode == RemuxMode.MP4BOX:
|
||||
logger.critical(X_NOT_FOUND_STRING.format("MP4Box", mp4box_path))
|
||||
return
|
||||
|
||||
if (
|
||||
not downloader.mp4decrypt_path_full
|
||||
and codec_song
|
||||
@@ -419,62 +518,79 @@ def main(
|
||||
):
|
||||
logger.critical(X_NOT_FOUND_STRING.format("mp4decrypt", mp4decrypt_path))
|
||||
return
|
||||
|
||||
if (
|
||||
download_mode == DownloadMode.NM3U8DLRE
|
||||
and not downloader.nm3u8dlre_path_full
|
||||
):
|
||||
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.warn(
|
||||
"You have chosen a non-legacy codec. Support for non-legacy codecs are not guaranteed, "
|
||||
"as most of the songs cannot be downloaded when using non-legacy codecs."
|
||||
|
||||
if not codec_song.is_legacy():
|
||||
logger.warning(
|
||||
"You have chosen an experimental song codec. "
|
||||
"They're not guaranteed to work due to API limitations."
|
||||
)
|
||||
error_count = 0
|
||||
|
||||
if read_urls_as_txt:
|
||||
_urls = []
|
||||
for url in urls:
|
||||
if Path(url).exists():
|
||||
_urls.extend(Path(url).read_text().splitlines())
|
||||
_urls.extend(Path(url).read_text(encoding="utf-8").splitlines())
|
||||
urls = _urls
|
||||
|
||||
error_count = 0
|
||||
|
||||
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)
|
||||
url_info = downloader.parse_url_info(url)
|
||||
if not url_info:
|
||||
error_count += 1
|
||||
logger.error(f"({url_progress}) Invalid URL, skipping")
|
||||
continue
|
||||
download_queue = downloader.get_download_queue(url_info)
|
||||
download_queue_medias_metadata = download_queue.medias_metadata
|
||||
if not download_queue_medias_metadata[0]:
|
||||
error_count += 1
|
||||
logger.error(f"({url_progress}) Media not found, skipping")
|
||||
continue
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
logger.error(
|
||||
f'({url_progress}) Failed to check "{url}"',
|
||||
exc_info=print_exceptions,
|
||||
f'({url_progress}) Failed to process URL "{url}", skipping',
|
||||
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:
|
||||
logger.info(
|
||||
f'({queue_progress}) Downloading "{track["attributes"]["name"]}"'
|
||||
f'({queue_progress}) "{media_metadata["attributes"]["name"]}"'
|
||||
)
|
||||
if not track["attributes"].get("playParams"):
|
||||
logger.warning(
|
||||
f"({queue_progress}) Track is not streamable, skipping"
|
||||
)
|
||||
continue
|
||||
|
||||
if (
|
||||
(synced_lyrics_only and track["type"] != "songs")
|
||||
or (track["type"] == "music-videos" and skip_mv)
|
||||
(
|
||||
synced_lyrics_only
|
||||
and media_metadata["type"] not in {"songs", "library-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
|
||||
)
|
||||
@@ -482,228 +598,41 @@ def main(
|
||||
logger.warning(
|
||||
f"({queue_progress}) Track is not downloadable with current configuration, skipping"
|
||||
)
|
||||
elif track["type"] == "songs":
|
||||
logger.debug("Getting lyrics")
|
||||
lyrics = downloader_song.get_lyrics(track)
|
||||
logger.debug("Getting webplayback")
|
||||
webplayback = apple_music_api.get_webplayback(track["id"])
|
||||
tags = downloader_song.get_tags(webplayback, lyrics.unsynced)
|
||||
final_path = downloader.get_final_path(tags, ".m4a")
|
||||
lyrics_synced_path = downloader_song.get_lyrics_synced_path(
|
||||
final_path
|
||||
continue
|
||||
|
||||
if media_metadata["type"] in {"songs", "library-songs"}:
|
||||
downloader_song.download(
|
||||
media_metadata=media_metadata,
|
||||
playlist_attributes=download_queue.playlist_attributes,
|
||||
playlist_track=download_index,
|
||||
)
|
||||
cover_path = downloader_song.get_cover_path(final_path)
|
||||
cover_url = downloader.get_cover_url(track)
|
||||
if synced_lyrics_only:
|
||||
pass
|
||||
elif final_path.exists() and not overwrite:
|
||||
logger.warning(
|
||||
f'({queue_progress}) Song already exists at "{final_path}", skipping'
|
||||
)
|
||||
else:
|
||||
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"]
|
||||
)
|
||||
else:
|
||||
stream_info = downloader_song.get_stream_info(track)
|
||||
if not stream_info.stream_url or not stream_info.pssh:
|
||||
logger.warning(
|
||||
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"]
|
||||
)
|
||||
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 LEGACY_CODECS:
|
||||
logger.debug(f"Remuxing/Decrypting to {remuxed_path}")
|
||||
downloader_song_legacy.remux(
|
||||
encrypted_path,
|
||||
decrypted_path,
|
||||
remuxed_path,
|
||||
decryption_key,
|
||||
)
|
||||
else:
|
||||
logger.debug(f"Decrypting to {decrypted_path}")
|
||||
downloader_song.decrypt(
|
||||
encrypted_path, decrypted_path, decryption_key
|
||||
)
|
||||
logger.debug(f"Remuxing to {final_path}")
|
||||
downloader_song.remux(
|
||||
decrypted_path,
|
||||
remuxed_path,
|
||||
stream_info.codec,
|
||||
)
|
||||
logger.debug("Applying tags")
|
||||
downloader.apply_tags(remuxed_path, tags, cover_url)
|
||||
logger.debug(f"Moving to {final_path}")
|
||||
downloader.move_to_output_path(remuxed_path, final_path)
|
||||
if no_synced_lyrics or not lyrics.synced:
|
||||
pass
|
||||
elif lyrics_synced_path.exists() and not overwrite:
|
||||
logger.debug(
|
||||
f'Synced lyrics already exists at "{lyrics_synced_path}", skipping'
|
||||
)
|
||||
else:
|
||||
logger.debug(f'Saving synced lyrics to "{lyrics_synced_path}"')
|
||||
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
|
||||
|
||||
if media_metadata["type"] in {"music-videos", "library-music-videos"}:
|
||||
downloader_music_video.download(
|
||||
media_metadata=media_metadata,
|
||||
playlist_attributes=download_queue.playlist_attributes,
|
||||
playlist_track=download_index,
|
||||
)
|
||||
logger.debug("Getting iTunes page")
|
||||
itunes_page = itunes_api.get_itunes_page(
|
||||
"music-video", music_video_id_alt
|
||||
|
||||
if media_metadata["type"] == "uploaded-videos":
|
||||
downloader_post.download(
|
||||
media_metadata=media_metadata,
|
||||
)
|
||||
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
|
||||
)
|
||||
tags = downloader_music_video.get_tags(
|
||||
itunes_page,
|
||||
m3u8_master_data,
|
||||
track,
|
||||
)
|
||||
final_path = downloader.get_final_path(tags, ".m4v")
|
||||
cover_path = downloader_music_video.get_cover_path(final_path)
|
||||
cover_url = downloader.get_cover_url(track)
|
||||
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"]
|
||||
)
|
||||
decryption_key_audio = downloader.get_decryption_key(
|
||||
stream_info_audio.pssh, track["id"]
|
||||
)
|
||||
encrypted_path_video = (
|
||||
downloader_music_video.get_encrypted_path_video(track["id"])
|
||||
)
|
||||
encrypted_path_audio = (
|
||||
downloader_music_video.get_encrypted_path_audio(track["id"])
|
||||
)
|
||||
decrypted_path_video = (
|
||||
downloader_music_video.get_decrypted_path_video(track["id"])
|
||||
)
|
||||
decrypted_path_audio = (
|
||||
downloader_music_video.get_decrypted_path_audio(track["id"])
|
||||
)
|
||||
remuxed_path = downloader_music_video.get_remuxed_path(
|
||||
track["id"]
|
||||
)
|
||||
logger.debug(f"Downloading video to {encrypted_path_video}")
|
||||
downloader.download(
|
||||
encrypted_path_video, stream_info_video.stream_url
|
||||
)
|
||||
logger.debug(f"Downloading audio to {encrypted_path_audio}")
|
||||
downloader.download(
|
||||
encrypted_path_audio, stream_info_audio.stream_url
|
||||
)
|
||||
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}")
|
||||
downloader_music_video.decrypt(
|
||||
encrypted_path_audio,
|
||||
decryption_key_audio,
|
||||
decrypted_path_audio,
|
||||
)
|
||||
logger.debug(f"Remuxing to {remuxed_path}")
|
||||
downloader_music_video.remux(
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
remuxed_path,
|
||||
stream_info_video.codec,
|
||||
stream_info_audio.codec,
|
||||
)
|
||||
logger.debug("Applying tags")
|
||||
downloader.apply_tags(remuxed_path, tags, cover_url)
|
||||
logger.debug(f"Moving to {final_path}")
|
||||
downloader.move_to_output_path(remuxed_path, final_path)
|
||||
if not save_cover:
|
||||
pass
|
||||
elif cover_path.exists() and not overwrite:
|
||||
logger.debug(
|
||||
f'Cover already exists at "{cover_path}", skipping'
|
||||
)
|
||||
else:
|
||||
logger.debug(f'Saving cover to "{cover_path}"')
|
||||
downloader.save_cover(cover_path, cover_url)
|
||||
elif track["type"] == "uploaded-videos":
|
||||
stream_url = downloader_post.get_stream_url(track)
|
||||
tags = downloader_post.get_tags(track)
|
||||
temp_path = downloader_post.get_temp_path(track["id"])
|
||||
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 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)
|
||||
except KeyboardInterrupt:
|
||||
exit(0)
|
||||
except (
|
||||
MediaNotStreamableException,
|
||||
MediaFileAlreadyExistsException,
|
||||
MediaFormatNotAvailableException,
|
||||
) as e:
|
||||
logger.warning(
|
||||
f"({queue_progress}) {e}, skipping",
|
||||
)
|
||||
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():
|
||||
logger.debug(f'Cleaning up "{temp_path}"')
|
||||
downloader.cleanup_temp_path()
|
||||
logger.info(f"Done ({error_count} error(s))")
|
||||
|
||||
logger.info(f"Done, {error_count} error(s) occurred")
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import configparser
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import typing
|
||||
|
||||
|
||||
class ConfigFile:
|
||||
def __init__(
|
||||
self,
|
||||
config_path: Path,
|
||||
section_name: str = "gamdl",
|
||||
) -> None:
|
||||
self.config_path = config_path
|
||||
self.section_name = section_name
|
||||
|
||||
self._read_config_file()
|
||||
|
||||
def _read_config_file(self) -> None:
|
||||
self.config = configparser.ConfigParser(interpolation=None)
|
||||
|
||||
if self.config_path.exists():
|
||||
self.config.read(self.config_path, encoding="utf-8")
|
||||
else:
|
||||
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not self.config.has_section(self.section_name):
|
||||
self.config.add_section(self.section_name)
|
||||
|
||||
def _write_config_file(self) -> None:
|
||||
with self.config_path.open("w", encoding="utf-8") as config_file:
|
||||
self.config.write(config_file)
|
||||
|
||||
def _serialize_param_default(self, param: click.Parameter) -> str:
|
||||
if not isinstance(param.default, (list, tuple)):
|
||||
param_default = [param.default]
|
||||
else:
|
||||
param_default = param.default
|
||||
|
||||
if not param_default:
|
||||
return ""
|
||||
|
||||
first = param_default[0]
|
||||
|
||||
if isinstance(first, Enum):
|
||||
return ",".join(str(item.value) for item in param_default)
|
||||
if isinstance(first, bool):
|
||||
return ",".join(str(item).lower() for item in param_default)
|
||||
if first is None:
|
||||
return "null"
|
||||
|
||||
return ",".join(str(item) for item in param_default)
|
||||
|
||||
def _add_param_default_to_config(
|
||||
self,
|
||||
param: click.Parameter,
|
||||
) -> bool:
|
||||
if self.config[self.section_name].get(param.name):
|
||||
return False
|
||||
|
||||
value = self._serialize_param_default(param)
|
||||
self.config[self.section_name][param.name] = value
|
||||
|
||||
return True
|
||||
|
||||
def _parse_param_from_config(
|
||||
self,
|
||||
param: click.Parameter,
|
||||
) -> typing.Any:
|
||||
value = self.config[self.section_name].get(param.name)
|
||||
|
||||
if value == "null":
|
||||
return None
|
||||
|
||||
return param.type_cast_value(None, value)
|
||||
|
||||
def add_params_default_to_config(
|
||||
self,
|
||||
params: list[click.Parameter],
|
||||
) -> None:
|
||||
has_changes = False
|
||||
|
||||
for param in params:
|
||||
has_changes = self._add_param_default_to_config(param) or has_changes
|
||||
|
||||
if has_changes:
|
||||
self._write_config_file()
|
||||
|
||||
def parse_params_from_config(
|
||||
self,
|
||||
params: list[click.Parameter],
|
||||
) -> dict[str, typing.Any]:
|
||||
parsed_params = {}
|
||||
|
||||
for param in params:
|
||||
parsed_params[param.name] = self._parse_param_from_config(param)
|
||||
|
||||
return parsed_params
|
||||
@@ -1,5 +1,3 @@
|
||||
from gamdl.enums import MusicVideoCodec, SongCodec, SyncedLyricsFormat
|
||||
|
||||
STOREFRONT_IDS = {
|
||||
"AE": "143481-2,32",
|
||||
"AG": "143540-2,32",
|
||||
@@ -158,55 +156,6 @@ STOREFRONT_IDS = {
|
||||
"ZW": "143605-2,32",
|
||||
}
|
||||
|
||||
MP4_TAGS_MAP = {
|
||||
"album": "\xa9alb",
|
||||
"album_artist": "aART",
|
||||
"album_id": "plID",
|
||||
"album_sort": "soal",
|
||||
"artist": "\xa9ART",
|
||||
"artist_id": "atID",
|
||||
"artist_sort": "soar",
|
||||
"comment": "\xa9cmt",
|
||||
"composer": "\xa9wrt",
|
||||
"composer_id": "cmID",
|
||||
"composer_sort": "soco",
|
||||
"copyright": "cprt",
|
||||
"date": "\xa9day",
|
||||
"genre": "\xa9gen",
|
||||
"genre_id": "geID",
|
||||
"lyrics": "\xa9lyr",
|
||||
"media_type": "stik",
|
||||
"rating": "rtng",
|
||||
"storefront": "sfID",
|
||||
"title": "\xa9nam",
|
||||
"title_id": "cnID",
|
||||
"title_sort": "sonm",
|
||||
"xid": "xid ",
|
||||
}
|
||||
|
||||
SONG_CODEC_REGEX_MAP = {
|
||||
SongCodec.AAC: r"audio-stereo-\d+",
|
||||
SongCodec.AAC_HE: r"audio-HE-stereo-\d+",
|
||||
SongCodec.AAC_BINAURAL: r"audio-stereo-\d+-binaural",
|
||||
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.ATMOS: r"audio-atmos-.*",
|
||||
SongCodec.AC3: r"audio-ac3-.*",
|
||||
SongCodec.ALAC: r"audio-alac-.*",
|
||||
}
|
||||
|
||||
MUSIC_VIDEO_CODEC_MAP = {
|
||||
MusicVideoCodec.H264: "avc1",
|
||||
MusicVideoCodec.H265: "hvc1",
|
||||
}
|
||||
|
||||
SYNCED_LYRICS_FILE_EXTENSION_MAP = {
|
||||
SyncedLyricsFormat.LRC: ".lrc",
|
||||
SyncedLyricsFormat.SRT: ".srt",
|
||||
SyncedLyricsFormat.TTML: ".ttml",
|
||||
}
|
||||
|
||||
|
||||
EXCLUDED_CONFIG_FILE_PARAMS = (
|
||||
"urls",
|
||||
@@ -218,8 +167,3 @@ EXCLUDED_CONFIG_FILE_PARAMS = (
|
||||
)
|
||||
|
||||
X_NOT_FOUND_STRING = '{} not found at "{}"'
|
||||
|
||||
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)
|
||||
+450
-161
@@ -1,39 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import functools
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import typing
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import ciso8601
|
||||
import colorama
|
||||
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 .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 (
|
||||
DecryptionKey,
|
||||
DownloadInfo,
|
||||
DownloadQueue,
|
||||
MediaTags,
|
||||
PlaylistTags,
|
||||
UrlInfo,
|
||||
)
|
||||
from .utils import color_text, raise_response_exception
|
||||
|
||||
logger = logging.getLogger("gamdl")
|
||||
|
||||
|
||||
class Downloader:
|
||||
ILLEGAL_CHARACTERS_REGEX = r'[\\/:*?"<>|;]'
|
||||
ILLEGAL_CHARS_RE = r'[\\/:*?"<>|;]'
|
||||
ILLEGAL_CHAR_REPLACEMENT = "_"
|
||||
VALID_URL_RE = (
|
||||
r"("
|
||||
r"/(?P<storefront>[a-z]{2})"
|
||||
r"/(?P<type>artist|album|playlist|song|music-video|post)"
|
||||
r"(?:/(?P<slug>[a-z0-9-]+))?"
|
||||
r"/(?P<id>[0-9]+|pl\.[0-9a-z]{32}|pl\.u-[a-zA-Z0-9]{15})"
|
||||
r"(?:\?i=(?P<sub_id>[0-9]+))?"
|
||||
r")|("
|
||||
r"(?:/(?P<library_storefront>[a-z]{2}))?"
|
||||
r"/library/(?P<library_type>|playlist|albums)"
|
||||
r"/(?P<library_id>p\.[a-zA-Z0-9]{15}|l\.[a-zA-Z0-9]{7})"
|
||||
r")"
|
||||
)
|
||||
IMAGE_FILE_EXTENSION_MAP = {
|
||||
"jpeg": ".jpg",
|
||||
"tiff": ".tif",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
apple_music_api: AppleMusicApi,
|
||||
itunes_api: ItunesApi,
|
||||
output_path: Path = Path("./Apple Music"),
|
||||
temp_path: Path = Path("./temp"),
|
||||
temp_path: Path = Path("."),
|
||||
wvd_path: Path = None,
|
||||
nm3u8dlre_path: str = "N_m3u8dl-RE",
|
||||
overwrite: bool = False,
|
||||
save_cover: bool = False,
|
||||
save_playlist: bool = False,
|
||||
no_synced_lyrics: bool = False,
|
||||
synced_lyrics_only: bool = False,
|
||||
nm3u8dlre_path: str = "N_m3u8DL-RE",
|
||||
mp4decrypt_path: str = "mp4decrypt",
|
||||
ffmpeg_path: str = "ffmpeg",
|
||||
mp4box_path: str = "MP4Box",
|
||||
@@ -46,17 +84,24 @@ 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,
|
||||
exclude_tags: list[str] = None,
|
||||
cover_size: int = 1200,
|
||||
truncate: int = 40,
|
||||
truncate: int = None,
|
||||
silent: bool = False,
|
||||
skip_processing: bool = False,
|
||||
):
|
||||
self.apple_music_api = apple_music_api
|
||||
self.itunes_api = itunes_api
|
||||
self.output_path = output_path
|
||||
self.temp_path = temp_path
|
||||
self.wvd_path = wvd_path
|
||||
self.overwrite = overwrite
|
||||
self.save_cover = save_cover
|
||||
self.save_playlist = save_playlist
|
||||
self.no_synced_lyrics = no_synced_lyrics
|
||||
self.synced_lyrics_only = synced_lyrics_only
|
||||
self.nm3u8dlre_path = nm3u8dlre_path
|
||||
self.mp4decrypt_path = mp4decrypt_path
|
||||
self.ffmpeg_path = ffmpeg_path
|
||||
@@ -70,31 +115,35 @@ 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.silent = silent
|
||||
self.skip_processing = skip_processing
|
||||
self._set_temp_path()
|
||||
self._set_exclude_tags()
|
||||
self._set_binaries_path_full()
|
||||
self._set_exclude_tags_list()
|
||||
self._set_truncate()
|
||||
self._set_subprocess_additional_args()
|
||||
|
||||
def _set_temp_path(self):
|
||||
random_suffix = uuid.uuid4().hex[:8]
|
||||
self.temp_path = self.temp_path / f"gamdl_temp_{random_suffix}"
|
||||
|
||||
def _set_exclude_tags(self):
|
||||
self.exclude_tags = self.exclude_tags if self.exclude_tags is not None else []
|
||||
|
||||
def _set_binaries_path_full(self):
|
||||
self.nm3u8dlre_path_full = shutil.which(self.nm3u8dlre_path)
|
||||
self.ffmpeg_path_full = shutil.which(self.ffmpeg_path)
|
||||
self.mp4box_path_full = shutil.which(self.mp4box_path)
|
||||
self.mp4decrypt_path_full = shutil.which(self.mp4decrypt_path)
|
||||
|
||||
def _set_exclude_tags_list(self):
|
||||
self.exclude_tags_list = (
|
||||
[i.lower() for i in self.exclude_tags.split(",")]
|
||||
if self.exclude_tags is not None
|
||||
else []
|
||||
)
|
||||
|
||||
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:
|
||||
@@ -111,57 +160,69 @@ class Downloader:
|
||||
else:
|
||||
self.cdm = Cdm.from_device(Device.loads(HARDCODED_WVD))
|
||||
|
||||
def get_url_info(self, url: str) -> UrlInfo:
|
||||
url_info = UrlInfo()
|
||||
def parse_url_info(self, url: str) -> UrlInfo | None:
|
||||
url_regex_result = re.search(
|
||||
r"/([a-z]{2})/(artist|album|playlist|song|music-video|post)/([^/]*)(?:/([^/?]*))?(?:\?i=)?([0-9a-z]*)?",
|
||||
self.VALID_URL_RE,
|
||||
url,
|
||||
)
|
||||
url_info.storefront = url_regex_result.group(1)
|
||||
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)
|
||||
)
|
||||
return url_info
|
||||
if not url_regex_result:
|
||||
return None
|
||||
|
||||
def get_download_queue(self, url_info: UrlInfo) -> list[DownloadQueueItem]:
|
||||
return self._get_download_queue(url_info.type, url_info.id)
|
||||
return UrlInfo(
|
||||
**url_regex_result.groupdict(),
|
||||
)
|
||||
|
||||
def _get_download_queue(self, url_type: str, id: str) -> list[DownloadQueueItem]:
|
||||
download_queue = []
|
||||
def get_download_queue(self, url_info: UrlInfo) -> DownloadQueue:
|
||||
return self._get_download_queue(
|
||||
"song" if url_info.sub_id else url_info.type,
|
||||
url_info.sub_id or url_info.id or url_info.library_id,
|
||||
url_info.library_id is not None,
|
||||
)
|
||||
|
||||
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.extend(self.get_download_queue_from_artist(artist))
|
||||
download_queue.medias_metadata = list(
|
||||
self.get_download_queue_from_artist(artist)
|
||||
)
|
||||
elif url_type == "song":
|
||||
download_queue.append(DownloadQueueItem(self.apple_music_api.get_song(id)))
|
||||
elif url_type == "album":
|
||||
album = self.apple_music_api.get_album(id)
|
||||
download_queue.extend(
|
||||
DownloadQueueItem(track)
|
||||
for track in album["relationships"]["tracks"]["data"]
|
||||
)
|
||||
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 get_download_queue_from_artist(self, artist: dict) -> list[DownloadQueueItem]:
|
||||
def get_download_queue_from_artist(
|
||||
self,
|
||||
artist: dict,
|
||||
) -> typing.Generator[dict, None, None]:
|
||||
media_type = inquirer.select(
|
||||
message=f'Select which type to download for artist "{artist["attributes"]["name"]}":',
|
||||
choices=[
|
||||
@@ -175,18 +236,18 @@ class Downloader:
|
||||
invalid_message="The artist doesn't have any items of this type",
|
||||
).execute()
|
||||
if media_type == "albums":
|
||||
return self.select_albums_from_artist(
|
||||
yield from self.select_albums_from_artist(
|
||||
artist["relationships"]["albums"]["data"]
|
||||
)
|
||||
elif media_type == "music-videos":
|
||||
return self.select_music_videos_from_artist(
|
||||
yield from self.select_music_videos_from_artist(
|
||||
artist["relationships"]["music-videos"]["data"]
|
||||
)
|
||||
|
||||
def select_albums_from_artist(
|
||||
self,
|
||||
albums: list[dict],
|
||||
) -> list[DownloadQueueItem]:
|
||||
) -> typing.Generator[dict, None, None]:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
@@ -206,20 +267,16 @@ class Downloader:
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute()
|
||||
download_queue = []
|
||||
for album in selected:
|
||||
download_queue.extend(
|
||||
DownloadQueueItem(track)
|
||||
for track in self.apple_music_api.get_album(album["id"])[
|
||||
"relationships"
|
||||
]["tracks"]["data"]
|
||||
)
|
||||
return download_queue
|
||||
for track in self.apple_music_api.get_album(album["id"])["relationships"][
|
||||
"tracks"
|
||||
]["data"]:
|
||||
yield track
|
||||
|
||||
def select_music_videos_from_artist(
|
||||
self,
|
||||
music_videos: list[dict],
|
||||
) -> list[DownloadQueueItem]:
|
||||
) -> typing.Generator[dict, None, None]:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
@@ -240,34 +297,112 @@ class Downloader:
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute()
|
||||
return [DownloadQueueItem(music_video) for music_video in selected]
|
||||
for music_video in selected:
|
||||
yield music_video
|
||||
|
||||
def get_media_id_of_library_media(
|
||||
self,
|
||||
library_media_metadata: dict,
|
||||
) -> str:
|
||||
play_params = library_media_metadata["attributes"].get("playParams", {})
|
||||
return play_params.get("catalogId", library_media_metadata["id"])
|
||||
|
||||
def is_media_streamable(
|
||||
self,
|
||||
media_metadata: dict,
|
||||
) -> bool:
|
||||
return bool(media_metadata["attributes"].get("playParams"))
|
||||
|
||||
def get_playlist_tags(
|
||||
self,
|
||||
playlist_attributes: dict,
|
||||
playlist_track: int,
|
||||
) -> PlaylistTags:
|
||||
return PlaylistTags(
|
||||
playlist_artist=playlist_attributes.get("curatorName", "Unknown"),
|
||||
playlist_id=playlist_attributes["playParams"]["id"],
|
||||
playlist_title=playlist_attributes["name"],
|
||||
playlist_track=playlist_track,
|
||||
)
|
||||
|
||||
def get_playlist_file_path(
|
||||
self,
|
||||
tags: PlaylistTags,
|
||||
) -> Path:
|
||||
template_file = self.template_file_playlist.split("/")
|
||||
tags_dict = tags.__dict__.copy()
|
||||
|
||||
return Path(
|
||||
self.output_path,
|
||||
*[
|
||||
self.get_sanitized_string(i.format(**tags_dict), True)
|
||||
for i in template_file[0:-1]
|
||||
],
|
||||
*[
|
||||
self.get_sanitized_string(template_file[-1].format(**tags_dict), False)
|
||||
+ ".m3u8"
|
||||
],
|
||||
)
|
||||
|
||||
def update_playlist_file(
|
||||
self,
|
||||
playlist_file_path: Path,
|
||||
final_path: Path,
|
||||
playlist_track: int,
|
||||
):
|
||||
playlist_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
playlist_file_path_parent_parts_len = len(playlist_file_path.parent.parts)
|
||||
output_path_parts_len = len(self.output_path.parts)
|
||||
final_path_relative = Path(
|
||||
("../" * (playlist_file_path_parent_parts_len - output_path_parts_len)),
|
||||
*final_path.parts[output_path_parts_len:],
|
||||
)
|
||||
playlist_file_lines = (
|
||||
playlist_file_path.open("r", encoding="utf8").readlines()
|
||||
if playlist_file_path.exists()
|
||||
else []
|
||||
)
|
||||
if len(playlist_file_lines) < playlist_track:
|
||||
playlist_file_lines.extend(
|
||||
"\n" for _ in range(playlist_track - len(playlist_file_lines))
|
||||
)
|
||||
playlist_file_lines[playlist_track - 1] = final_path_relative.as_posix() + "\n"
|
||||
with playlist_file_path.open("w", encoding="utf8") as playlist_file:
|
||||
playlist_file.writelines(playlist_file_lines)
|
||||
|
||||
@staticmethod
|
||||
def millis_to_min_sec(millis):
|
||||
def millis_to_min_sec(millis) -> str:
|
||||
minutes, seconds = divmod(millis // 1000, 60)
|
||||
return f"{minutes:02d}:{seconds:02d}"
|
||||
|
||||
def sanitize_date(self, date: str):
|
||||
datetime_obj = ciso8601.parse_datetime(date)
|
||||
return datetime_obj.strftime(self.template_date)
|
||||
def parse_date(self, date: str) -> datetime.datetime:
|
||||
return datetime.datetime.fromisoformat(date.split("Z")[0])
|
||||
|
||||
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,
|
||||
def get_decryption_key(self, pssh: str, track_id: str) -> DecryptionKey:
|
||||
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_info = next(
|
||||
i for i in self.cdm.get_keys(cdm_session) if i.type == "CONTENT"
|
||||
)
|
||||
finally:
|
||||
self.cdm.close(cdm_session)
|
||||
return DecryptionKey(
|
||||
key=decryption_key_info.key.hex(),
|
||||
kid=decryption_key_info.kid.hex,
|
||||
)
|
||||
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)
|
||||
return decryption_key
|
||||
|
||||
def download(self, path: Path, stream_url: str):
|
||||
if self.download_mode == DownloadMode.YTDLP:
|
||||
@@ -313,46 +448,108 @@ class Downloader:
|
||||
)
|
||||
|
||||
def get_sanitized_string(self, dirty_string: str, is_folder: bool) -> str:
|
||||
dirty_string = re.sub(self.ILLEGAL_CHARACTERS_REGEX, "_", dirty_string)
|
||||
dirty_string = re.sub(
|
||||
self.ILLEGAL_CHARS_RE,
|
||||
self.ILLEGAL_CHAR_REPLACEMENT,
|
||||
dirty_string,
|
||||
)
|
||||
if is_folder:
|
||||
dirty_string = dirty_string[: self.truncate]
|
||||
if dirty_string.endswith("."):
|
||||
dirty_string = dirty_string[:-1] + "_"
|
||||
dirty_string = dirty_string[:-1] + self.ILLEGAL_CHAR_REPLACEMENT
|
||||
else:
|
||||
if self.truncate is not None:
|
||||
dirty_string = dirty_string[: self.truncate - 4]
|
||||
return dirty_string.strip()
|
||||
|
||||
def get_final_path(self, tags: dict, file_extension: str) -> Path:
|
||||
if tags.get("album"):
|
||||
final_path_folder = (
|
||||
def get_media_file_extension(
|
||||
self,
|
||||
media_file_format: MediaFileFormat,
|
||||
) -> str:
|
||||
return "." + media_file_format.value
|
||||
|
||||
def get_temp_path(
|
||||
self,
|
||||
media_id: str,
|
||||
tag: str,
|
||||
file_extension: str,
|
||||
):
|
||||
temp_path = self.temp_path / (f"{media_id}_{tag}" + file_extension)
|
||||
return temp_path
|
||||
|
||||
def get_final_path(
|
||||
self,
|
||||
tags: MediaTags,
|
||||
file_extension: str,
|
||||
playlist_tags: PlaylistTags,
|
||||
) -> Path:
|
||||
if tags.album is not None:
|
||||
template_folder = (
|
||||
self.template_folder_compilation.split("/")
|
||||
if tags.get("compilation")
|
||||
if tags.compilation
|
||||
else self.template_folder_album.split("/")
|
||||
)
|
||||
final_path_file = (
|
||||
template_file = (
|
||||
self.template_file_multi_disc.split("/")
|
||||
if tags["disc_total"] > 1
|
||||
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
|
||||
|
||||
tags_dict = tags.__dict__.copy()
|
||||
if playlist_tags:
|
||||
tags_dict.update(playlist_tags.__dict__)
|
||||
|
||||
return Path(
|
||||
self.output_path,
|
||||
*[
|
||||
self.get_sanitized_string(i.format(**tags_dict), True)
|
||||
for i in template_final[0:-1]
|
||||
],
|
||||
(
|
||||
self.get_sanitized_string(template_final[-1].format(**tags_dict), False)
|
||||
+ file_extension
|
||||
),
|
||||
)
|
||||
|
||||
def get_cover_format(self, cover_url: str) -> str | None:
|
||||
cover_bytes = self.get_cover_bytes(cover_url)
|
||||
if cover_bytes is None:
|
||||
return None
|
||||
image_obj = Image.open(io.BytesIO(self.get_cover_bytes(cover_url)))
|
||||
image_format = image_obj.format.lower()
|
||||
return image_format
|
||||
|
||||
def get_cover_file_extension(self, cover_format: str) -> str:
|
||||
return self.IMAGE_FILE_EXTENSION_MAP.get(
|
||||
cover_format,
|
||||
f".{cover_format.lower()}",
|
||||
)
|
||||
|
||||
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",
|
||||
@@ -362,72 +559,164 @@ class Downloader:
|
||||
|
||||
@staticmethod
|
||||
@functools.lru_cache()
|
||||
def get_url_response_bytes(url: str) -> bytes:
|
||||
return requests.get(url).content
|
||||
def get_cover_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,
|
||||
path: Path,
|
||||
tags: dict,
|
||||
tags: MediaTags,
|
||||
cover_url: str,
|
||||
):
|
||||
to_apply_tags = [
|
||||
tag_name
|
||||
for tag_name in tags.keys()
|
||||
if tag_name not in self.exclude_tags_list
|
||||
]
|
||||
mp4_tags = {}
|
||||
for tag_name in to_apply_tags:
|
||||
if tag_name in ("disc", "disc_total"):
|
||||
if mp4_tags.get("disk") is None:
|
||||
mp4_tags["disk"] = [[0, 0]]
|
||||
if tag_name == "disc":
|
||||
mp4_tags["disk"][0][0] = tags[tag_name]
|
||||
elif tag_name == "disc_total":
|
||||
mp4_tags["disk"][0][1] = tags[tag_name]
|
||||
elif tag_name in ("track", "track_total"):
|
||||
if mp4_tags.get("trkn") is None:
|
||||
mp4_tags["trkn"] = [[0, 0]]
|
||||
if tag_name == "track":
|
||||
mp4_tags["trkn"][0][0] = tags[tag_name]
|
||||
elif tag_name == "track_total":
|
||||
mp4_tags["trkn"][0][1] = tags[tag_name]
|
||||
elif tag_name == "compilation":
|
||||
mp4_tags["cpil"] = tags["compilation"]
|
||||
elif tag_name == "gapless":
|
||||
mp4_tags["pgap"] = tags["gapless"]
|
||||
elif (
|
||||
MP4_TAGS_MAP.get(tag_name) is not None
|
||||
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
|
||||
),
|
||||
)
|
||||
]
|
||||
filtered_tags = MediaTags(
|
||||
**{
|
||||
k: v
|
||||
for k, v in tags.__dict__.items()
|
||||
if v is not None and k not in self.exclude_tags
|
||||
}
|
||||
)
|
||||
mp4_tags = filtered_tags.to_mp4_tags(self.template_date)
|
||||
skip_tagging = "all" in self.exclude_tags
|
||||
|
||||
mp4 = MP4(path)
|
||||
mp4.clear()
|
||||
mp4.update(mp4_tags)
|
||||
if not skip_tagging:
|
||||
if (
|
||||
"cover" not in self.exclude_tags
|
||||
and self.cover_format != CoverFormat.RAW
|
||||
):
|
||||
self._apply_cover(mp4, cover_url)
|
||||
mp4.update(mp4_tags)
|
||||
mp4.save()
|
||||
|
||||
def _apply_cover(
|
||||
self,
|
||||
mp4: MP4,
|
||||
cover_url: str,
|
||||
) -> None:
|
||||
cover_bytes = self.get_cover_bytes(cover_url)
|
||||
if cover_bytes is None:
|
||||
return
|
||||
mp4["covr"] = [
|
||||
MP4Cover(
|
||||
data=cover_bytes,
|
||||
imageformat=(
|
||||
MP4Cover.FORMAT_JPEG
|
||||
if self.cover_format == CoverFormat.JPG
|
||||
else MP4Cover.FORMAT_PNG
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
def move_to_output_path(
|
||||
self,
|
||||
remuxed_path: Path,
|
||||
staged_path: Path,
|
||||
final_path: Path,
|
||||
):
|
||||
final_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(remuxed_path, final_path)
|
||||
shutil.move(staged_path, final_path)
|
||||
|
||||
@functools.lru_cache()
|
||||
def save_cover(self, cover_path: Path, cover_url: str):
|
||||
cover_path.write_bytes(self.get_url_response_bytes(cover_url))
|
||||
def write_cover(self, cover_path: Path, cover_url: str):
|
||||
cover_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cover_path.write_bytes(self.get_cover_bytes(cover_url))
|
||||
|
||||
def cleanup_temp_path(self):
|
||||
shutil.rmtree(self.temp_path)
|
||||
def write_synced_lyrics(
|
||||
self,
|
||||
synced_lyrics_path: Path,
|
||||
synced_lyrics: str,
|
||||
):
|
||||
synced_lyrics_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
synced_lyrics_path.write_text(
|
||||
synced_lyrics,
|
||||
encoding="utf8",
|
||||
)
|
||||
|
||||
def cleanup_temp_path(self, override_skip_processing_check: bool = False) -> None:
|
||||
if self.skip_processing and not override_skip_processing_check:
|
||||
return
|
||||
|
||||
if self.temp_path.exists():
|
||||
shutil.rmtree(self.temp_path)
|
||||
|
||||
def _final_processing(
|
||||
self,
|
||||
download_info: DownloadInfo,
|
||||
) -> None:
|
||||
if self.skip_processing:
|
||||
return
|
||||
|
||||
colored_media_id = color_text(download_info.media_id, colorama.Style.DIM)
|
||||
|
||||
if download_info.staged_path:
|
||||
logger.debug(
|
||||
f"[{colored_media_id}] Applying tags to {download_info.staged_path}"
|
||||
)
|
||||
self.apply_tags(
|
||||
download_info.staged_path,
|
||||
download_info.tags,
|
||||
download_info.cover_url,
|
||||
)
|
||||
logger.debug(
|
||||
f'[{colored_media_id}] Moving "{download_info.staged_path}" to "{download_info.final_path}"'
|
||||
)
|
||||
self.move_to_output_path(
|
||||
download_info.staged_path,
|
||||
download_info.final_path,
|
||||
)
|
||||
logger.info(f"[{colored_media_id}] Download completed successfully")
|
||||
|
||||
if (
|
||||
download_info.cover_path and not self.save_cover
|
||||
) or not download_info.cover_path:
|
||||
pass
|
||||
elif download_info.cover_path.exists() and not self.overwrite:
|
||||
logger.debug(
|
||||
f'[{colored_media_id}] Cover already exists at "{download_info.cover_path}", skipping'
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f'[{colored_media_id}] Saving cover to "{download_info.cover_path}"'
|
||||
)
|
||||
self.write_cover(
|
||||
download_info.cover_path,
|
||||
download_info.cover_url,
|
||||
)
|
||||
|
||||
if (
|
||||
self.no_synced_lyrics
|
||||
or not download_info.lyrics
|
||||
or not download_info.lyrics.synced
|
||||
):
|
||||
pass
|
||||
elif download_info.synced_lyrics_path.exists() and not self.overwrite:
|
||||
logger.debug(
|
||||
f'[{colored_media_id}] Synced lyrics already exist at "{download_info.synced_lyrics_path}", skipping'
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f'[{colored_media_id}] Saving synced lyrics to "{download_info.synced_lyrics_path}"'
|
||||
)
|
||||
self.write_synced_lyrics(
|
||||
download_info.synced_lyrics_path,
|
||||
download_info.lyrics.synced,
|
||||
)
|
||||
if download_info.playlist_tags and self.save_playlist:
|
||||
playlist_file_path = self.get_playlist_file_path(
|
||||
download_info.playlist_tags
|
||||
)
|
||||
logger.debug(
|
||||
f'[{colored_media_id}] Updating playlist file "{playlist_file_path}"'
|
||||
)
|
||||
self.update_playlist_file(
|
||||
playlist_file_path,
|
||||
download_info.final_path,
|
||||
download_info.playlist_tags.playlist_track,
|
||||
)
|
||||
|
||||
+444
-148
@@ -1,75 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import logging
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
import colorama
|
||||
import m3u8
|
||||
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,
|
||||
MusicVideoResolution,
|
||||
RemuxFormatMusicVideo,
|
||||
RemuxMode,
|
||||
)
|
||||
from .exceptions import *
|
||||
from .models import (
|
||||
DecryptionKeyAv,
|
||||
DownloadInfo,
|
||||
MediaRating,
|
||||
MediaTags,
|
||||
MediaType,
|
||||
StreamInfo,
|
||||
StreamInfoAv,
|
||||
)
|
||||
from .utils import color_text
|
||||
|
||||
logger = logging.getLogger("gamdl")
|
||||
|
||||
|
||||
class DownloaderMusicVideo:
|
||||
MP4_FORMAT_CODECS = ["hvc1", "ec-3"]
|
||||
MP4_FORMAT_CODECS = ["hvc1", "audio-atmos", "audio-ec3"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
downloader: Downloader,
|
||||
codec: MusicVideoCodec = MusicVideoCodec.H264,
|
||||
):
|
||||
codec: list[MusicVideoCodec] = [MusicVideoCodec.H264, MusicVideoCodec.H265],
|
||||
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
|
||||
resolution: MusicVideoResolution = MusicVideoResolution.R1080P,
|
||||
) -> None:
|
||||
self.downloader = downloader
|
||||
self.codec = codec
|
||||
self.remux_format = remux_format
|
||||
self.resolution = resolution
|
||||
|
||||
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_playlist_video(
|
||||
def get_video_playlist_from_resolution(
|
||||
self,
|
||||
playlists: list[dict],
|
||||
) -> dict:
|
||||
playlists_filtered = [
|
||||
playlist
|
||||
for playlist in playlists
|
||||
if playlist["stream_info"]["codecs"].startswith(
|
||||
MUSIC_VIDEO_CODEC_MAP[self.codec]
|
||||
)
|
||||
]
|
||||
playlists: list[m3u8.Playlist],
|
||||
) -> m3u8.Playlist | None:
|
||||
playlists_filtered = set()
|
||||
for playlist in playlists:
|
||||
for codec in self.codec:
|
||||
if playlist.stream_info.codecs.startswith(codec.fourcc()):
|
||||
playlists_filtered.add(playlist)
|
||||
|
||||
if not playlists_filtered:
|
||||
playlists_filtered = [
|
||||
playlist
|
||||
for playlist in playlists
|
||||
if playlist["stream_info"]["codecs"].startswith(
|
||||
MUSIC_VIDEO_CODEC_MAP[MusicVideoCodec.H264]
|
||||
)
|
||||
]
|
||||
playlists_filtered.sort(key=lambda x: x["stream_info"]["bandwidth"])
|
||||
return playlists_filtered[-1]
|
||||
return None
|
||||
|
||||
def get_playlist_video_from_user(
|
||||
playlists_filtered = list(playlists_filtered)
|
||||
|
||||
def sort_key(playlist: m3u8.Playlist) -> tuple[int, int, int, int]:
|
||||
playlist_resolution = playlist.stream_info.resolution[-1]
|
||||
resolution_difference = abs(playlist_resolution - int(self.resolution))
|
||||
codec_preference = len(self.codec)
|
||||
for i, preferred_codec in enumerate(self.codec):
|
||||
if playlist.stream_info.codecs.startswith(preferred_codec.fourcc()):
|
||||
codec_preference = i
|
||||
break
|
||||
bandwidth = playlist.stream_info.bandwidth
|
||||
return (
|
||||
resolution_difference,
|
||||
codec_preference,
|
||||
-playlist_resolution,
|
||||
-bandwidth,
|
||||
)
|
||||
|
||||
playlists_filtered.sort(key=sort_key)
|
||||
|
||||
return playlists_filtered[0]
|
||||
|
||||
def get_best_stereo_audio_playlist(
|
||||
self,
|
||||
playlists: list[dict],
|
||||
) -> dict:
|
||||
playlist_master_data: dict,
|
||||
) -> dict | None:
|
||||
audio_playlist = next(
|
||||
(
|
||||
media
|
||||
for media in playlist_master_data["media"]
|
||||
if media["group_id"] == "audio-stereo-256"
|
||||
),
|
||||
None,
|
||||
)
|
||||
return audio_playlist
|
||||
|
||||
def get_video_playlist_from_user(
|
||||
self,
|
||||
playlists: list[m3u8.Playlist],
|
||||
) -> m3u8.Playlist:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
playlist["stream_info"]["codecs"][:4],
|
||||
playlist["stream_info"]["resolution"],
|
||||
str(playlist["stream_info"]["bandwidth"]),
|
||||
playlist.stream_info.codecs[:4],
|
||||
"x".join(str(v) for v in playlist.stream_info.resolution),
|
||||
str(playlist.stream_info.bandwidth),
|
||||
]
|
||||
),
|
||||
value=playlist,
|
||||
@@ -80,139 +130,204 @@ class DownloaderMusicVideo:
|
||||
message="Select which video codec to download: (Codec | Resolution | Bitrate)",
|
||||
choices=choices,
|
||||
).execute()
|
||||
|
||||
return selected
|
||||
|
||||
def get_playlist_audio(
|
||||
def get_audio_playlist_from_user(
|
||||
self,
|
||||
playlists: list[dict],
|
||||
) -> dict:
|
||||
stream_url = next(
|
||||
(
|
||||
playlist
|
||||
for playlist in playlists
|
||||
if playlist["group_id"] == "audio-stereo-256"
|
||||
),
|
||||
None,
|
||||
)
|
||||
return stream_url
|
||||
|
||||
def get_playlist_audio_from_user(
|
||||
self,
|
||||
playlists: list[dict],
|
||||
playlist_master_data: dict,
|
||||
) -> dict:
|
||||
choices = [
|
||||
Choice(
|
||||
name=playlist["group_id"],
|
||||
value=playlist,
|
||||
)
|
||||
for playlist in playlists
|
||||
for playlist in playlist_master_data["media"]
|
||||
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):
|
||||
def get_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
return next(
|
||||
(
|
||||
key
|
||||
for key in m3u8_data["keys"]
|
||||
if key["keyformat"] == "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
|
||||
for key in m3u8_obj.keys
|
||||
if key.keyformat == "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
|
||||
),
|
||||
None,
|
||||
)["uri"]
|
||||
).uri
|
||||
|
||||
def get_stream_info_video(self, m3u8_master_data: dict) -> StreamInfo:
|
||||
def get_stream_info_video(
|
||||
self, playlist_master_m3u8_obj: m3u8.M3U8
|
||||
) -> StreamInfo | None:
|
||||
stream_info = StreamInfo()
|
||||
if self.codec != MusicVideoCodec.ASK:
|
||||
playlist = self.get_playlist_video(m3u8_master_data["playlists"])
|
||||
|
||||
if MusicVideoCodec.ASK not in self.codec:
|
||||
playlist = self.get_video_playlist_from_resolution(
|
||||
playlist_master_m3u8_obj.playlists
|
||||
)
|
||||
else:
|
||||
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)
|
||||
playlist = self.get_video_playlist_from_user(
|
||||
playlist_master_m3u8_obj.playlists
|
||||
)
|
||||
if not playlist:
|
||||
return None
|
||||
|
||||
stream_info.stream_url = playlist.uri
|
||||
stream_info.codec = playlist.stream_info.codecs
|
||||
|
||||
playlist_m3u8_obj = m3u8.load(stream_info.stream_url)
|
||||
stream_info.widevine_pssh = self.get_pssh(playlist_m3u8_obj)
|
||||
|
||||
return stream_info
|
||||
|
||||
def get_stream_info_audio(self, m3u8_master_data: dict) -> StreamInfo:
|
||||
def get_stream_info_audio(self, playlist_master_data: dict) -> StreamInfo | None:
|
||||
stream_info = StreamInfo()
|
||||
|
||||
if self.codec != MusicVideoCodec.ASK:
|
||||
playlist = self.get_playlist_audio(m3u8_master_data["media"])
|
||||
playlist = self.get_best_stereo_audio_playlist(playlist_master_data)
|
||||
else:
|
||||
playlist = self.get_playlist_audio_from_user(m3u8_master_data["media"])
|
||||
playlist = self.get_audio_playlist_from_user(playlist_master_data)
|
||||
if not playlist:
|
||||
return None
|
||||
|
||||
stream_info.stream_url = playlist["uri"]
|
||||
stream_info.codec = re.search(r"_([^_]+)\.m3u8", stream_info.stream_url).group(
|
||||
1
|
||||
stream_info.codec = playlist["group_id"]
|
||||
|
||||
playlist_m3u8_obj = m3u8.load(stream_info.stream_url)
|
||||
stream_info.widevine_pssh = self.get_pssh(playlist_m3u8_obj)
|
||||
|
||||
return stream_info
|
||||
|
||||
def _get_stream_info(
|
||||
self,
|
||||
stream_url: str,
|
||||
) -> StreamInfoAv | None:
|
||||
playlist_master_m3u8_obj = m3u8.load(stream_url)
|
||||
|
||||
stream_info_video = self.get_stream_info_video(playlist_master_m3u8_obj)
|
||||
stream_info_audio = self.get_stream_info_audio(playlist_master_m3u8_obj.data)
|
||||
if not stream_info_video or not stream_info_audio:
|
||||
return None
|
||||
|
||||
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
|
||||
)
|
||||
m3u8_data = m3u8.load(stream_info.stream_url).data
|
||||
stream_info.pssh = self.get_pssh(m3u8_data)
|
||||
return stream_info
|
||||
if use_mp4:
|
||||
file_format = MediaFileFormat.MP4
|
||||
else:
|
||||
file_format = MediaFileFormat.M4V
|
||||
|
||||
def get_music_video_id_alt(self, metadata: dict) -> str:
|
||||
return metadata["attributes"]["url"].split("/")[-1].split("?")[0]
|
||||
return StreamInfoAv(
|
||||
video_track=stream_info_video,
|
||||
audio_track=stream_info_audio,
|
||||
file_format=file_format,
|
||||
)
|
||||
|
||||
def get_stream_info_from_webplayback(
|
||||
self,
|
||||
webplayback: dict,
|
||||
) -> StreamInfoAv | None:
|
||||
return self._get_stream_info(self.get_stream_url_from_webplayback(webplayback))
|
||||
|
||||
def get_stream_info_from_itunes_page(
|
||||
self,
|
||||
itunes_page: dict,
|
||||
) -> StreamInfoAv | None:
|
||||
return self._get_stream_info(self.get_stream_url_from_itunes_page(itunes_page))
|
||||
|
||||
def get_decryption_key(
|
||||
self,
|
||||
stream_info: StreamInfoAv,
|
||||
media_id: str,
|
||||
) -> DecryptionKeyAv:
|
||||
decryption_key_video = self.downloader.get_decryption_key(
|
||||
stream_info.video_track.widevine_pssh,
|
||||
media_id,
|
||||
)
|
||||
decryption_key_audio = self.downloader.get_decryption_key(
|
||||
stream_info.audio_track.widevine_pssh,
|
||||
media_id,
|
||||
)
|
||||
|
||||
return DecryptionKeyAv(
|
||||
video_track=decryption_key_video,
|
||||
audio_track=decryption_key_audio,
|
||||
)
|
||||
|
||||
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,
|
||||
):
|
||||
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],
|
||||
"genre_id": int(itunes_page["genres"][0]["genreId"]),
|
||||
"media_type": 6,
|
||||
"title": metadata["attributes"]["name"],
|
||||
"title_id": int(metadata["id"]),
|
||||
}
|
||||
if metadata["attributes"].get("contentRating") == "clean":
|
||||
tags["rating"] = 2
|
||||
elif metadata["attributes"].get("contentRating") == "explicit":
|
||||
tags["rating"] = 1
|
||||
) -> MediaTags:
|
||||
metadata_itunes = self.downloader.itunes_api.get_resource(id_alt)
|
||||
|
||||
explicitness = metadata_itunes[0]["trackExplicitness"]
|
||||
if explicitness == "notExplicit":
|
||||
rating = MediaRating.NONE
|
||||
elif explicitness == "explicit":
|
||||
rating = MediaRating.EXPLICIT
|
||||
else:
|
||||
tags["rating"] = 0
|
||||
if itunes_page.get("collectionId"):
|
||||
metadata_itunes = self.downloader.itunes_api.get_resource(itunes_page["id"])
|
||||
rating = MediaRating.CLEAN
|
||||
|
||||
tags = MediaTags(
|
||||
artist=metadata_itunes[0]["artistName"],
|
||||
artist_id=int(metadata_itunes[0]["artistId"]),
|
||||
copyright=itunes_page.get("copyright"),
|
||||
date=self.downloader.parse_date(metadata_itunes[0]["releaseDate"]),
|
||||
genre=metadata_itunes[0]["primaryGenreName"],
|
||||
genre_id=int(itunes_page["genres"][0]["genreId"]),
|
||||
media_type=MediaType.MUSIC_VIDEO,
|
||||
storefront=int(self.downloader.itunes_api.storefront_id.split("-")[0]),
|
||||
title=metadata_itunes[0]["trackCensoredName"],
|
||||
title_id=int(metadata["id"]),
|
||||
rating=rating,
|
||||
)
|
||||
|
||||
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_id"] = int(itunes_page["collectionId"])
|
||||
tags["disc"] = metadata_itunes[0]["discNumber"]
|
||||
tags["disc_total"] = metadata_itunes[0]["discCount"]
|
||||
tags["compilation"] = album["attributes"]["isCompilation"]
|
||||
tags["track"] = metadata_itunes[0]["trackNumber"]
|
||||
tags["track_total"] = metadata_itunes[0]["trackCount"]
|
||||
if not album:
|
||||
return tags
|
||||
|
||||
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"]
|
||||
tags.compilation = album["attributes"]["isCompilation"]
|
||||
tags.track = metadata_itunes[0]["trackNumber"]
|
||||
tags.track_total = metadata_itunes[0]["trackCount"]
|
||||
|
||||
return tags
|
||||
|
||||
def get_encrypted_path_video(self, track_id: str) -> str:
|
||||
return self.downloader.temp_path / f"encrypted_{track_id}.mp4"
|
||||
|
||||
def get_encrypted_path_audio(self, track_id: str) -> str:
|
||||
return self.downloader.temp_path / f"encrypted_{track_id}.m4a"
|
||||
|
||||
def get_decrypted_path_video(self, track_id: str) -> str:
|
||||
return self.downloader.temp_path / f"decrypted_{track_id}.mp4"
|
||||
|
||||
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 decrypt(self, encrypted_path: Path, decryption_key: str, decrypted_path: Path):
|
||||
def decrypt(
|
||||
self,
|
||||
encrypted_path: Path,
|
||||
decryption_key: str,
|
||||
decrypted_path: Path,
|
||||
) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.mp4decrypt_path_full,
|
||||
@@ -230,7 +345,7 @@ class DownloaderMusicVideo:
|
||||
decrypted_path_audio: Path,
|
||||
decrypted_path_video: Path,
|
||||
fixed_path: Path,
|
||||
):
|
||||
) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.mp4box_path_full,
|
||||
@@ -254,12 +369,7 @@ class DownloaderMusicVideo:
|
||||
decrypted_path_video: Path,
|
||||
decrypte_path_audio: Path,
|
||||
fixed_path: Path,
|
||||
codec_video: str,
|
||||
codec_audio: str,
|
||||
):
|
||||
use_mp4_flag = any(
|
||||
codec_video.startswith(codec) for codec in self.MP4_FORMAT_CODECS
|
||||
) or any(codec_audio.startswith(codec) for codec in self.MP4_FORMAT_CODECS)
|
||||
) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.ffmpeg_path_full,
|
||||
@@ -272,8 +382,6 @@ class DownloaderMusicVideo:
|
||||
decrypte_path_audio,
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-f",
|
||||
"mp4" if use_mp4_flag else "ipod",
|
||||
"-c",
|
||||
"copy",
|
||||
"-c:s",
|
||||
@@ -284,28 +392,216 @@ class DownloaderMusicVideo:
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def remux(
|
||||
def stage(
|
||||
self,
|
||||
encrypted_path_video: Path,
|
||||
encrypted_path_audio: Path,
|
||||
decrypted_path_video: Path,
|
||||
decrypted_path_audio: Path,
|
||||
remuxed_path: Path,
|
||||
codec_video: str,
|
||||
codec_audio: str,
|
||||
):
|
||||
staged_path: Path,
|
||||
decryption_key: DecryptionKeyAv,
|
||||
) -> None:
|
||||
self.decrypt(
|
||||
encrypted_path_video,
|
||||
decryption_key.video_track.key,
|
||||
decrypted_path_video,
|
||||
)
|
||||
self.decrypt(
|
||||
encrypted_path_audio,
|
||||
decryption_key.audio_track.key,
|
||||
decrypted_path_audio,
|
||||
)
|
||||
|
||||
if self.downloader.remux_mode == RemuxMode.MP4BOX:
|
||||
self.remux_mp4box(
|
||||
decrypted_path_audio,
|
||||
decrypted_path_video,
|
||||
remuxed_path,
|
||||
staged_path,
|
||||
)
|
||||
elif self.downloader.remux_mode == RemuxMode.FFMPEG:
|
||||
self.remux_ffmpeg(
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
remuxed_path,
|
||||
codec_video,
|
||||
codec_audio,
|
||||
staged_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, cover_format: str) -> Path:
|
||||
return final_path.with_suffix(
|
||||
self.downloader.get_cover_file_extension(cover_format)
|
||||
)
|
||||
|
||||
def download(
|
||||
self,
|
||||
media_id: str = None,
|
||||
media_metadata: dict = None,
|
||||
playlist_attributes: dict = None,
|
||||
playlist_track: int = None,
|
||||
) -> DownloadInfo:
|
||||
try:
|
||||
download_info = self._download(
|
||||
media_id,
|
||||
media_metadata,
|
||||
playlist_attributes,
|
||||
playlist_track,
|
||||
)
|
||||
self.downloader._final_processing(download_info)
|
||||
finally:
|
||||
self.downloader.cleanup_temp_path()
|
||||
return download_info
|
||||
|
||||
def _download(
|
||||
self,
|
||||
media_id: str = None,
|
||||
media_metadata: dict = None,
|
||||
playlist_attributes: dict = None,
|
||||
playlist_track: int = None,
|
||||
) -> DownloadInfo:
|
||||
download_info = DownloadInfo()
|
||||
|
||||
if playlist_track is None and playlist_attributes:
|
||||
raise ValueError(
|
||||
"playlist_track must be provided if playlist_attributes is provided"
|
||||
)
|
||||
if playlist_attributes:
|
||||
playlist_tags = self.downloader.get_playlist_tags(
|
||||
playlist_attributes,
|
||||
playlist_track,
|
||||
)
|
||||
else:
|
||||
playlist_tags = None
|
||||
download_info.playlist_tags = playlist_tags
|
||||
|
||||
if not media_id and not media_metadata:
|
||||
raise ValueError("Either media_id or media_metadata must be provided")
|
||||
|
||||
if not media_metadata:
|
||||
logger.debug(
|
||||
f"[{color_text(media_id, colorama.Style.DIM)}] "
|
||||
"Getting Music Video metadata"
|
||||
)
|
||||
media_metadata = self.downloader.apple_music_api.get_music_video(media_id)
|
||||
download_info.media_metadata = media_metadata
|
||||
|
||||
if not media_id:
|
||||
media_id = self.downloader.get_media_id_of_library_media(media_metadata)
|
||||
download_info.media_id = media_id
|
||||
colored_media_id = color_text(media_id, colorama.Style.DIM)
|
||||
|
||||
if not self.downloader.is_media_streamable(media_metadata):
|
||||
raise MediaNotStreamableException()
|
||||
|
||||
alt_media_id = self.get_music_video_id_alt(media_metadata) or media_id
|
||||
download_info.alt_media_id = alt_media_id
|
||||
|
||||
logger.debug(f"[{colored_media_id}] Getting iTunes page")
|
||||
itunes_page = self.downloader.itunes_api.get_itunes_page(
|
||||
"music-video",
|
||||
alt_media_id,
|
||||
)
|
||||
|
||||
logger.debug(f"[{colored_media_id}] Getting tags")
|
||||
tags = self.get_tags(
|
||||
alt_media_id,
|
||||
itunes_page,
|
||||
media_metadata,
|
||||
)
|
||||
download_info.tags = tags
|
||||
|
||||
if alt_media_id == media_id:
|
||||
logger.debug(f"[{colored_media_id}] Getting stream info")
|
||||
stream_info = self.get_stream_info_from_itunes_page(itunes_page)
|
||||
else:
|
||||
logger.debug(f"[{colored_media_id}] Getting webplayback info")
|
||||
webplayback = self.downloader.apple_music_api.get_webplayback(media_id)
|
||||
logger.debug(f"[{colored_media_id}] Getting stream info")
|
||||
stream_info = self.get_stream_info_from_webplayback(webplayback)
|
||||
if not stream_info:
|
||||
raise MediaFormatNotAvailableException()
|
||||
download_info.stream_info = stream_info
|
||||
|
||||
final_path = self.downloader.get_final_path(
|
||||
tags,
|
||||
self.downloader.get_media_file_extension(stream_info.file_format),
|
||||
playlist_tags,
|
||||
)
|
||||
download_info.final_path = final_path
|
||||
|
||||
cover_url = self.downloader.get_cover_url(media_metadata)
|
||||
cover_format = self.downloader.get_cover_format(cover_url)
|
||||
if cover_format and self.downloader.save_cover:
|
||||
cover_path = self.get_cover_path(final_path, cover_format)
|
||||
else:
|
||||
cover_path = None
|
||||
download_info.cover_url = cover_url
|
||||
download_info.cover_format = cover_format
|
||||
download_info.cover_path = cover_path
|
||||
|
||||
if final_path.exists() and not self.downloader.overwrite:
|
||||
raise MediaFileAlreadyExistsException()
|
||||
|
||||
logger.debug(f"[{colored_media_id}] Getting decryption key")
|
||||
decryption_key = self.get_decryption_key(
|
||||
stream_info,
|
||||
media_id,
|
||||
)
|
||||
|
||||
encrypted_path_video = self.downloader.get_temp_path(
|
||||
media_id,
|
||||
"encrypted_video",
|
||||
".mp4",
|
||||
)
|
||||
encrypted_path_audio = self.downloader.get_temp_path(
|
||||
media_id,
|
||||
"encrypted_audio",
|
||||
".m4a",
|
||||
)
|
||||
decrypted_path_video = self.downloader.get_temp_path(
|
||||
media_id,
|
||||
"decrypted_video",
|
||||
".mp4",
|
||||
)
|
||||
decrypted_path_audio = self.downloader.get_temp_path(
|
||||
media_id,
|
||||
"decrypted_audio",
|
||||
".m4a",
|
||||
)
|
||||
staged_path = self.downloader.get_temp_path(
|
||||
media_id,
|
||||
"staged",
|
||||
self.downloader.get_media_file_extension(stream_info.file_format),
|
||||
)
|
||||
|
||||
logger.info(f"[{colored_media_id}] Downloading Music Video")
|
||||
|
||||
logger.debug(
|
||||
f'[{colored_media_id}] Downloading video to "{encrypted_path_video}"'
|
||||
)
|
||||
self.downloader.download(
|
||||
encrypted_path_video,
|
||||
stream_info.video_track.stream_url,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'[{colored_media_id}] Downloading audio to "{encrypted_path_audio}"'
|
||||
)
|
||||
self.downloader.download(
|
||||
encrypted_path_audio,
|
||||
stream_info.audio_track.stream_url,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Decrypting video/audio to "
|
||||
f'{decrypted_path_video}"/"{decrypted_path_audio}" '
|
||||
f'and remuxing to "{staged_path}"'
|
||||
)
|
||||
self.stage(
|
||||
encrypted_path_video,
|
||||
encrypted_path_audio,
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
staged_path,
|
||||
decryption_key,
|
||||
)
|
||||
download_info.staged_path = staged_path
|
||||
|
||||
return download_info
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import colorama
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
|
||||
from .downloader import Downloader
|
||||
from .enums import PostQuality
|
||||
from .exceptions import MediaNotStreamableException
|
||||
from .models import DownloadInfo, MediaTags
|
||||
from .utils import color_text
|
||||
|
||||
logger = logging.getLogger("gamdl")
|
||||
|
||||
|
||||
class DownloaderPost:
|
||||
@@ -60,14 +67,96 @@ class DownloaderPost:
|
||||
stream_url = self.get_stream_url_from_user(metadata)
|
||||
return stream_url
|
||||
|
||||
def get_tags(self, metadata: dict) -> list:
|
||||
def get_tags(self, metadata: dict) -> MediaTags:
|
||||
attributes = metadata["attributes"]
|
||||
return {
|
||||
"artist": attributes["artistName"],
|
||||
"date": self.downloader.sanitize_date(attributes["uploadDate"]),
|
||||
"title": attributes["name"],
|
||||
"title_id": int(metadata["id"]),
|
||||
}
|
||||
upload_date = attributes.get("uploadDate")
|
||||
return MediaTags(
|
||||
artist=attributes.get("artistName"),
|
||||
date=self.downloader.parse_date(upload_date) if upload_date else None,
|
||||
title=attributes.get("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:
|
||||
return self.downloader.temp_path / f"{track_id}_temp.m4v"
|
||||
def get_cover_path(self, final_path: Path, cover_format: str) -> Path:
|
||||
return final_path.with_suffix(
|
||||
self.downloader.get_cover_file_extension(cover_format)
|
||||
)
|
||||
|
||||
def download(
|
||||
self,
|
||||
media_id: str = None,
|
||||
media_metadata: dict = None,
|
||||
) -> DownloadInfo:
|
||||
try:
|
||||
download_info = self._download(
|
||||
media_id,
|
||||
media_metadata,
|
||||
)
|
||||
self.downloader._final_processing(download_info)
|
||||
finally:
|
||||
self.downloader.cleanup_temp_path()
|
||||
return download_info
|
||||
|
||||
def _download(
|
||||
self,
|
||||
media_id: str = None,
|
||||
media_metadata: dict = None,
|
||||
) -> DownloadInfo:
|
||||
download_info = DownloadInfo()
|
||||
|
||||
if not media_id and not media_metadata:
|
||||
raise ValueError("Either media_id or media_metadata must be provided")
|
||||
|
||||
if not media_metadata:
|
||||
logger.debug(
|
||||
f"[{color_text(media_id, colorama.Style.DIM)}] "
|
||||
"Getting Post Video metadata"
|
||||
)
|
||||
media_metadata = self.downloader.apple_music_api.get_post(media_id)
|
||||
download_info.media_metadata = media_metadata
|
||||
|
||||
if not media_id:
|
||||
media_id = media_metadata["id"]
|
||||
download_info.media_id = media_id
|
||||
colored_media_id = color_text(media_id, colorama.Style.DIM)
|
||||
|
||||
if not self.downloader.is_media_streamable(media_metadata):
|
||||
raise MediaNotStreamableException()
|
||||
|
||||
tags = self.get_tags(media_metadata)
|
||||
final_path = self.downloader.get_final_path(
|
||||
tags,
|
||||
".m4v",
|
||||
None,
|
||||
)
|
||||
download_info.tags = tags
|
||||
download_info.final_path = final_path
|
||||
|
||||
cover_url = self.downloader.get_cover_url(media_metadata)
|
||||
cover_format = self.downloader.get_cover_format(cover_url)
|
||||
if cover_format and self.downloader.save_cover:
|
||||
cover_path = self.get_cover_path(final_path, cover_format)
|
||||
else:
|
||||
cover_path = None
|
||||
download_info.cover_url = cover_url
|
||||
download_info.cover_format = cover_format
|
||||
download_info.cover_path = cover_path
|
||||
|
||||
stream_url = self.get_stream_url(media_metadata)
|
||||
staged_path = self.downloader.get_temp_path(
|
||||
media_id,
|
||||
"stage",
|
||||
".m4v",
|
||||
)
|
||||
|
||||
logger.info(f"[{colored_media_id}] Downloading Post Video")
|
||||
|
||||
logger.debug(f"[{colored_media_id}] Downloading to {staged_path}")
|
||||
self.downloader.download_ytdlp(
|
||||
staged_path,
|
||||
stream_url,
|
||||
)
|
||||
download_info.staged_path = staged_path
|
||||
|
||||
return download_info
|
||||
|
||||
+509
-134
@@ -3,25 +3,71 @@ from __future__ import annotations
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from xml.dom import minidom
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import colorama
|
||||
import m3u8
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
import m3u8
|
||||
from pywidevine import PSSH
|
||||
from pywidevine.license_protocol_pb2 import WidevinePsshData
|
||||
|
||||
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 .exceptions import *
|
||||
from .models import (
|
||||
DecryptionKey,
|
||||
DecryptionKeyAv,
|
||||
DownloadInfo,
|
||||
Lyrics,
|
||||
MediaRating,
|
||||
MediaTags,
|
||||
MediaType,
|
||||
StreamInfo,
|
||||
StreamInfoAv,
|
||||
)
|
||||
from .utils import color_text
|
||||
|
||||
logger = logging.getLogger("gamdl")
|
||||
|
||||
|
||||
class DownloaderSong:
|
||||
DEFAULT_DECRYPTION_KEY = "32b8ade1769e26b1ffb8986352793fc6"
|
||||
MP4_FORMAT_CODECS = ["ec-3"]
|
||||
SONG_CODEC_REGEX_MAP = {
|
||||
SongCodec.AAC: r"audio-stereo-\d+",
|
||||
SongCodec.AAC_HE: r"audio-HE-stereo-\d+",
|
||||
SongCodec.AAC_BINAURAL: r"audio-stereo-\d+-binaural",
|
||||
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.ATMOS: r"audio-atmos-.*",
|
||||
SongCodec.AC3: r"audio-ac3-.*",
|
||||
SongCodec.ALAC: r"audio-alac-.*",
|
||||
}
|
||||
DRM_DEFAULT_KEY_MAPPING = {
|
||||
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed": (
|
||||
"data:text/plain;base64,AAAAOHBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAABgSEAAAAAA"
|
||||
"AAAAAczEvZTEgICBI88aJmwY="
|
||||
),
|
||||
"com.microsoft.playready": (
|
||||
"data:text/plain;charset=UTF-16;base64,vgEAAAEAAQC0ATwAVwBSAE0ASABFAEEARABF"
|
||||
"AFIAIAB4AG0AbABuAHMAPQAiAGgAdAB0AHAAOgAvAC8AcwBjAGgAZQBtAGEAcwAuAG0AaQBjAH"
|
||||
"IAbwBzAG8AZgB0AC4AYwBvAG0ALwBEAFIATQAvADIAMAAwADcALwAwADMALwBQAGwAYQB5AFIA"
|
||||
"ZQBhAGQAeQBIAGUAYQBkAGUAcgAiACAAdgBlAHIAcwBpAG8AbgA9ACIANAAuADMALgAwAC4AMA"
|
||||
"AiAD4APABEAEEAVABBAD4APABQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsASQBEAFMAPgA8"
|
||||
"AEsASQBEACAAQQBMAEcASQBEAD0AIgBBAEUAUwBDAEIAQwAiACAAVgBBAEwAVQBFAD0AIgBBAE"
|
||||
"EAQQBBAEEAQQBBAEEAQQBBAEIAegBNAFMAOQBsAE0AUwBBAGcASQBBAD0APQAiAD4APAAvAEsA"
|
||||
"SQBEAD4APAAvAEsASQBEAFMAPgA8AC8AUABSAE8AVABFAEMAVABJAE4ARgBPAD4APAAvAEQAQQ"
|
||||
"BUAEEAPgA8AC8AVwBSAE0ASABFAEEARABFAFIAPgA="
|
||||
),
|
||||
"com.apple.streamingkeydelivery": "skd://itunes.apple.com/P000000000/s1/e1",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -33,28 +79,29 @@ class DownloaderSong:
|
||||
self.codec = codec
|
||||
self.synced_lyrics_format = synced_lyrics_format
|
||||
|
||||
def get_drm_infos(self, m3u8_data: dict) -> dict:
|
||||
drm_info_raw = next(
|
||||
def _search_m3u8_metadata(self, m3u8_data: dict, data_id: str) -> dict:
|
||||
searched = next(
|
||||
(
|
||||
session_data
|
||||
for session_data in m3u8_data["session_data"]
|
||||
if session_data["data_id"] == "com.apple.hls.AudioSessionKeyInfo"
|
||||
if session_data["data_id"] == data_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not drm_info_raw:
|
||||
if not searched:
|
||||
return None
|
||||
return json.loads(base64.b64decode(drm_info_raw["value"]).decode("utf-8"))
|
||||
return json.loads(base64.b64decode(searched["value"]).decode("utf-8"))
|
||||
|
||||
def get_asset_infos(self, m3u8_data: dict) -> dict:
|
||||
return json.loads(
|
||||
base64.b64decode(
|
||||
next(
|
||||
session_data
|
||||
for session_data in m3u8_data["session_data"]
|
||||
if session_data["data_id"] == "com.apple.hls.audioAssetMetadata"
|
||||
)["value"]
|
||||
).decode("utf-8")
|
||||
def get_audio_session_key_metadata(self, m3u8_data: dict) -> dict:
|
||||
return self._search_m3u8_metadata(
|
||||
m3u8_data,
|
||||
"com.apple.hls.AudioSessionKeyInfo",
|
||||
)
|
||||
|
||||
def get_asset_metadata(self, m3u8_data: dict) -> dict:
|
||||
return self._search_m3u8_metadata(
|
||||
m3u8_data,
|
||||
"com.apple.hls.audioAssetMetadata",
|
||||
)
|
||||
|
||||
def get_playlist_from_codec(self, m3u8_data: dict) -> dict | None:
|
||||
@@ -62,7 +109,7 @@ class DownloaderSong:
|
||||
playlist
|
||||
for playlist in m3u8_data["playlists"]
|
||||
if re.fullmatch(
|
||||
SONG_CODEC_REGEX_MAP[self.codec], playlist["stream_info"]["audio"]
|
||||
self.SONG_CODEC_REGEX_MAP[self.codec], playlist["stream_info"]["audio"]
|
||||
)
|
||||
]
|
||||
if not m3u8_master_playlists:
|
||||
@@ -85,53 +132,172 @@ class DownloaderSong:
|
||||
).execute()
|
||||
return selected
|
||||
|
||||
def get_pssh(
|
||||
def _get_drm_uri_from_session_key(
|
||||
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:
|
||||
def _get_drm_uri_from_m3u8_keys(
|
||||
self,
|
||||
m3u8_obj: m3u8.M3U8,
|
||||
drm_key: str,
|
||||
) -> str | None:
|
||||
drm_uri = next(
|
||||
(
|
||||
key
|
||||
for key in m3u8_obj.keys
|
||||
if key.keyformat == drm_key
|
||||
and key.uri != self.DRM_DEFAULT_KEY_MAPPING[drm_key]
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not drm_uri:
|
||||
return None
|
||||
return drm_uri.uri
|
||||
|
||||
def _get_stream_info(self, m3u8_url: str) -> StreamInfoAv | None:
|
||||
stream_info = StreamInfo()
|
||||
m3u8_master_obj = m3u8.load(m3u8_url)
|
||||
m3u8_master_data = m3u8_master_obj.data
|
||||
|
||||
if self.codec == SongCodec.ASK:
|
||||
playlist = self.get_playlist_from_user(m3u8_master_data)
|
||||
else:
|
||||
playlist = self.get_playlist_from_codec(m3u8_master_data)
|
||||
if playlist is None:
|
||||
return None
|
||||
stream_info.stream_url = m3u8_master_obj.base_uri + playlist["uri"]
|
||||
|
||||
stream_info.codec = playlist["stream_info"]["codecs"]
|
||||
is_mp4 = any(
|
||||
stream_info.codec.startswith(possible_codec)
|
||||
for possible_codec in self.MP4_FORMAT_CODECS
|
||||
)
|
||||
|
||||
session_key_metadata = self.get_audio_session_key_metadata(m3u8_master_data)
|
||||
if session_key_metadata:
|
||||
asset_metadata = self.get_asset_metadata(m3u8_master_data)
|
||||
variant_id = playlist["stream_info"]["stable_variant_id"]
|
||||
drm_ids = asset_metadata[variant_id]["AUDIO-SESSION-KEY-IDS"]
|
||||
(
|
||||
stream_info.widevine_pssh,
|
||||
stream_info.playready_pssh,
|
||||
stream_info.fairplay_key,
|
||||
) = (
|
||||
self._get_drm_uri_from_session_key(
|
||||
session_key_metadata,
|
||||
drm_ids,
|
||||
drm_key,
|
||||
)
|
||||
for drm_key in self.DRM_DEFAULT_KEY_MAPPING.keys()
|
||||
)
|
||||
else:
|
||||
m3u8_obj = m3u8.load(stream_info.stream_url)
|
||||
(
|
||||
stream_info.widevine_pssh,
|
||||
stream_info.playready_pssh,
|
||||
stream_info.fairplay_key,
|
||||
) = (
|
||||
self._get_drm_uri_from_m3u8_keys(
|
||||
m3u8_obj,
|
||||
drm_key,
|
||||
)
|
||||
for drm_key in self.DRM_DEFAULT_KEY_MAPPING.keys()
|
||||
)
|
||||
|
||||
return StreamInfoAv(
|
||||
audio_track=stream_info,
|
||||
file_format=MediaFileFormat.MP4 if is_mp4 else MediaFileFormat.M4A,
|
||||
)
|
||||
|
||||
def get_stream_info(self, track_metadata: dict) -> StreamInfoAv | None:
|
||||
m3u8_url = track_metadata["attributes"]["extendedAssetUrls"].get("enhancedHls")
|
||||
if not m3u8_url:
|
||||
return StreamInfo()
|
||||
return None
|
||||
return self._get_stream_info(m3u8_url)
|
||||
|
||||
def _get_stream_info(self, m3u8_url: str) -> StreamInfo:
|
||||
def get_stream_info_legacy(self, webplayback: dict) -> StreamInfoAv:
|
||||
flavor = "32:ctrp64" if self.codec == SongCodec.AAC_HE_LEGACY else "28:ctrp256"
|
||||
|
||||
stream_info = StreamInfo()
|
||||
m3u8_obj = m3u8.load(m3u8_url)
|
||||
m3u8_data = m3u8_obj.data
|
||||
drm_infos = self.get_drm_infos(m3u8_data)
|
||||
if not drm_infos:
|
||||
return stream_info
|
||||
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
|
||||
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
|
||||
stream_info.codec = playlist["stream_info"]["codecs"]
|
||||
return stream_info
|
||||
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.widevine_pssh = m3u8_obj.keys[0].uri
|
||||
|
||||
return StreamInfoAv(
|
||||
audio_track=stream_info,
|
||||
file_format=MediaFileFormat.M4A,
|
||||
)
|
||||
|
||||
def get_decryption_key(
|
||||
self,
|
||||
stream_info: StreamInfoAv,
|
||||
media_id: str,
|
||||
) -> DecryptionKeyAv:
|
||||
decryption_key = self.downloader.get_decryption_key(
|
||||
stream_info.audio_track.widevine_pssh,
|
||||
media_id,
|
||||
)
|
||||
return DecryptionKeyAv(
|
||||
audio_track=decryption_key,
|
||||
)
|
||||
|
||||
def get_decryption_key_legacy(
|
||||
self,
|
||||
stream_info: StreamInfoAv,
|
||||
media_id: str,
|
||||
) -> DecryptionKeyAv:
|
||||
stream_info_audio = stream_info.audio_track
|
||||
|
||||
try:
|
||||
cdm_session = self.downloader.cdm.open()
|
||||
|
||||
widevine_pssh_data = WidevinePsshData()
|
||||
widevine_pssh_data.algorithm = 1
|
||||
widevine_pssh_data.key_ids.append(
|
||||
base64.b64decode(stream_info_audio.widevine_pssh.split(",")[1])
|
||||
)
|
||||
pssh_obj = PSSH(widevine_pssh_data.SerializeToString())
|
||||
|
||||
challenge = base64.b64encode(
|
||||
self.downloader.cdm.get_license_challenge(cdm_session, pssh_obj)
|
||||
).decode()
|
||||
license = self.downloader.apple_music_api.get_widevine_license(
|
||||
media_id,
|
||||
stream_info.audio_track.widevine_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"
|
||||
)
|
||||
finally:
|
||||
self.downloader.cdm.close(cdm_session)
|
||||
return DecryptionKeyAv(
|
||||
audio_track=DecryptionKey(
|
||||
kid=decryption_key.kid.hex,
|
||||
key=decryption_key.key.hex(),
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse_datetime_obj_from_timestamp_ttml(
|
||||
@@ -147,7 +313,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)
|
||||
@@ -177,13 +346,13 @@ class DownloaderSong:
|
||||
timestamp_srt_end = self.get_lyrics_synced_timestamp_srt(timestamp_ttml_end)
|
||||
return f"{index}\n{timestamp_srt_start} --> {timestamp_srt_end}\n{text}\n"
|
||||
|
||||
def get_lyrics(self, track_metadata: dict) -> Lyrics:
|
||||
def get_lyrics(self, track_metadata: dict) -> Lyrics | None:
|
||||
lyrics = Lyrics()
|
||||
if not track_metadata["attributes"]["hasLyrics"]:
|
||||
return lyrics
|
||||
return None
|
||||
elif track_metadata.get("relationships") is None:
|
||||
track_metadata = self.downloader.apple_music_api.get_song(
|
||||
track_metadata["id"]
|
||||
self.downloader.get_media_id_of_library_media(track_metadata)
|
||||
)
|
||||
if (
|
||||
track_metadata["relationships"].get("lyrics")
|
||||
@@ -198,80 +367,88 @@ class DownloaderSong:
|
||||
return lyrics
|
||||
|
||||
def _get_lyrics(self, lyrics_ttml: str) -> Lyrics:
|
||||
lyrics = Lyrics("", "")
|
||||
lyrics_ttml_et = ElementTree.fromstring(lyrics_ttml)
|
||||
unsynced_lyrics = []
|
||||
synced_lyrics = []
|
||||
index = 1
|
||||
for div in lyrics_ttml_et.iter("{http://www.w3.org/ns/ttml}div"):
|
||||
stanza = []
|
||||
unsynced_lyrics.append(stanza)
|
||||
|
||||
for p in div.iter("{http://www.w3.org/ns/ttml}p"):
|
||||
if p.text is not None:
|
||||
lyrics.unsynced += p.text + "\n"
|
||||
stanza.append(p.text)
|
||||
|
||||
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)}"
|
||||
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)}"
|
||||
elif self.synced_lyrics_format == SyncedLyricsFormat.TTML:
|
||||
if not lyrics.synced:
|
||||
lyrics.synced = minidom.parseString(
|
||||
lyrics_ttml
|
||||
).toprettyxml()
|
||||
continue
|
||||
lyrics.synced += "\n"
|
||||
index += 1
|
||||
lyrics.unsynced += "\n"
|
||||
lyrics.unsynced = lyrics.unsynced[:-2]
|
||||
return lyrics
|
||||
synced_lyrics.append(
|
||||
f"{self.get_lyrics_synced_line_lrc(p.attrib.get('begin'), p.text)}"
|
||||
)
|
||||
|
||||
def get_tags(self, webplayback: dict, lyrics_unsynced: str) -> dict:
|
||||
tags_raw = webplayback["assets"][0]["metadata"]
|
||||
tags = {
|
||||
"album": tags_raw["playlistName"],
|
||||
"album_artist": tags_raw["playlistArtistName"],
|
||||
"album_id": int(tags_raw["playlistId"]),
|
||||
"album_sort": tags_raw["sort-album"],
|
||||
"artist": tags_raw["artistName"],
|
||||
"artist_id": int(tags_raw["artistId"]),
|
||||
"artist_sort": tags_raw["sort-artist"],
|
||||
"comments": tags_raw.get("comments"),
|
||||
"compilation": tags_raw["compilation"],
|
||||
"composer": tags_raw.get("composerName"),
|
||||
"composer_id": (
|
||||
int(tags_raw.get("composerId")) if tags_raw.get("composerId") else None
|
||||
if self.synced_lyrics_format == SyncedLyricsFormat.SRT:
|
||||
synced_lyrics.append(
|
||||
f"{self.get_lyrics_synced_line_srt(index, p.attrib.get('begin'), p.attrib.get('end'), p.text)}"
|
||||
)
|
||||
|
||||
if self.synced_lyrics_format == SyncedLyricsFormat.TTML:
|
||||
if not synced_lyrics:
|
||||
synced_lyrics.append(
|
||||
minidom.parseString(lyrics_ttml).toprettyxml()
|
||||
)
|
||||
continue
|
||||
|
||||
index += 1
|
||||
|
||||
return Lyrics(
|
||||
synced="\n".join(synced_lyrics) + "\n",
|
||||
unsynced="\n\n".join(
|
||||
["\n".join(lyric_group) for lyric_group in unsynced_lyrics]
|
||||
),
|
||||
"composer_sort": tags_raw.get("sort-composer"),
|
||||
"copyright": tags_raw.get("copyright"),
|
||||
"date": (
|
||||
self.downloader.sanitize_date(tags_raw["releaseDate"])
|
||||
if tags_raw.get("releaseDate")
|
||||
)
|
||||
|
||||
def get_tags(self, webplayback: dict, lyrics_unsynced: str) -> MediaTags:
|
||||
webplayback_metadata = webplayback["assets"][0]["metadata"]
|
||||
tags = MediaTags(
|
||||
album=webplayback_metadata["playlistName"],
|
||||
album_artist=webplayback_metadata["playlistArtistName"],
|
||||
album_id=int(webplayback_metadata["playlistId"]),
|
||||
album_sort=webplayback_metadata["sort-album"],
|
||||
artist=webplayback_metadata["artistName"],
|
||||
artist_id=int(webplayback_metadata["artistId"]),
|
||||
artist_sort=webplayback_metadata["sort-artist"],
|
||||
comment=webplayback_metadata.get("comments"),
|
||||
compilation=webplayback_metadata["compilation"],
|
||||
composer=webplayback_metadata.get("composerName"),
|
||||
composer_id=(
|
||||
int(webplayback_metadata.get("composerId"))
|
||||
if webplayback_metadata.get("composerId")
|
||||
else None
|
||||
),
|
||||
"disc": tags_raw["discNumber"],
|
||||
"disc_total": tags_raw["discCount"],
|
||||
"gapless": tags_raw["gapless"],
|
||||
"genre": tags_raw["genre"],
|
||||
"genre_id": tags_raw["genreId"],
|
||||
"lyrics": lyrics_unsynced if lyrics_unsynced else None,
|
||||
"media_type": 1,
|
||||
"rating": tags_raw["explicit"],
|
||||
"storefront": tags_raw["s"],
|
||||
"title": tags_raw["itemName"],
|
||||
"title_id": int(tags_raw["itemId"]),
|
||||
"title_sort": tags_raw["sort-name"],
|
||||
"track": tags_raw["trackNumber"],
|
||||
"track_total": tags_raw["trackCount"],
|
||||
"xid": tags_raw.get("xid"),
|
||||
}
|
||||
composer_sort=webplayback_metadata.get("sort-composer"),
|
||||
copyright=webplayback_metadata.get("copyright"),
|
||||
date=(
|
||||
self.downloader.parse_date(webplayback_metadata["releaseDate"])
|
||||
if webplayback_metadata.get("releaseDate")
|
||||
else None
|
||||
),
|
||||
disc=webplayback_metadata["discNumber"],
|
||||
disc_total=webplayback_metadata["discCount"],
|
||||
gapless=webplayback_metadata["gapless"],
|
||||
genre=webplayback_metadata.get("genre"),
|
||||
genre_id=int(webplayback_metadata["genreId"]),
|
||||
lyrics=lyrics_unsynced if lyrics_unsynced else None,
|
||||
media_type=MediaType.SONG,
|
||||
rating=MediaRating(webplayback_metadata["explicit"]),
|
||||
storefront=webplayback_metadata["s"],
|
||||
title=webplayback_metadata["itemName"],
|
||||
title_id=int(webplayback_metadata["itemId"]),
|
||||
title_sort=webplayback_metadata["sort-name"],
|
||||
track=webplayback_metadata["trackNumber"],
|
||||
track_total=webplayback_metadata["trackCount"],
|
||||
xid=webplayback_metadata.get("xid"),
|
||||
)
|
||||
return tags
|
||||
|
||||
def get_encrypted_path(self, track_id: str) -> Path:
|
||||
return self.downloader.temp_path / f"{track_id}_encrypted.m4a"
|
||||
|
||||
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 fix_key_id(self, encrypted_path: Path):
|
||||
count = 0
|
||||
with open(encrypted_path, "rb+") as file:
|
||||
@@ -291,27 +468,63 @@ class DownloaderSong:
|
||||
encrypted_path: Path,
|
||||
decrypted_path: Path,
|
||||
decryption_key: str,
|
||||
codec: SongCodec,
|
||||
):
|
||||
self.fix_key_id(encrypted_path)
|
||||
if codec.is_legacy():
|
||||
keys = [
|
||||
"--key",
|
||||
f"1:{decryption_key}",
|
||||
]
|
||||
else:
|
||||
self.fix_key_id(encrypted_path)
|
||||
keys = [
|
||||
"--key",
|
||||
"0" * 31 + "1" + f":{decryption_key}",
|
||||
"--key",
|
||||
"0" * 32 + f":{self.DEFAULT_DECRYPTION_KEY}",
|
||||
]
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.mp4decrypt_path_full,
|
||||
*keys,
|
||||
encrypted_path,
|
||||
"--key",
|
||||
f"00000000000000000000000000000001:{decryption_key}",
|
||||
"--key",
|
||||
f"00000000000000000000000000000000:{self.DEFAULT_DECRYPTION_KEY}",
|
||||
decrypted_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def remux(self, decrypted_path: Path, remuxed_path: Path, codec: str):
|
||||
if self.downloader.remux_mode == RemuxMode.MP4BOX:
|
||||
self.remux_mp4box(decrypted_path, remuxed_path)
|
||||
elif self.downloader.remux_mode == RemuxMode.FFMPEG:
|
||||
self.remux_ffmpeg(decrypted_path, remuxed_path, codec)
|
||||
def stage(
|
||||
self,
|
||||
codec: SongCodec,
|
||||
encrypted_path: Path,
|
||||
decrypted_path: Path,
|
||||
decryption_key: DecryptionKeyAv,
|
||||
staged_path: Path,
|
||||
):
|
||||
if codec.is_legacy() and self.downloader.remux_mode == RemuxMode.FFMPEG:
|
||||
self.remux_ffmpeg(
|
||||
encrypted_path,
|
||||
staged_path,
|
||||
decryption_key.audio_track.key,
|
||||
)
|
||||
else:
|
||||
self.decrypt(
|
||||
encrypted_path,
|
||||
decrypted_path,
|
||||
decryption_key.audio_track.key,
|
||||
codec,
|
||||
)
|
||||
if self.downloader.remux_mode == RemuxMode.FFMPEG:
|
||||
self.remux_ffmpeg(
|
||||
decrypted_path,
|
||||
staged_path,
|
||||
)
|
||||
else:
|
||||
self.remux_mp4box(
|
||||
decrypted_path,
|
||||
staged_path,
|
||||
)
|
||||
|
||||
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path):
|
||||
subprocess.run(
|
||||
@@ -334,24 +547,26 @@ class DownloaderSong:
|
||||
self,
|
||||
decrypted_path: Path,
|
||||
remuxed_path: Path,
|
||||
codec: str,
|
||||
decryption_key: str = None,
|
||||
):
|
||||
use_mp4_format = any(
|
||||
codec.startswith(possible_codec)
|
||||
for possible_codec in self.MP4_FORMAT_CODECS
|
||||
)
|
||||
if decryption_key:
|
||||
decryption_key_arg = [
|
||||
"-decryption_key",
|
||||
decryption_key,
|
||||
]
|
||||
else:
|
||||
decryption_key_arg = []
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.ffmpeg_path_full,
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
*decryption_key_arg,
|
||||
"-i",
|
||||
decrypted_path,
|
||||
"-c",
|
||||
"copy",
|
||||
"-f",
|
||||
"mp4" if use_mp4_format else "ipod",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
remuxed_path,
|
||||
@@ -361,13 +576,173 @@ class DownloaderSong:
|
||||
)
|
||||
|
||||
def get_lyrics_synced_path(self, final_path: Path) -> Path:
|
||||
return final_path.with_suffix(
|
||||
SYNCED_LYRICS_FILE_EXTENSION_MAP[self.synced_lyrics_format]
|
||||
)
|
||||
return final_path.with_suffix("." + self.synced_lyrics_format.value)
|
||||
|
||||
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, cover_format: str) -> Path:
|
||||
return final_path.parent / (
|
||||
"Cover" + self.downloader.get_cover_file_extension(cover_format)
|
||||
)
|
||||
|
||||
def save_lyrics_synced(self, lyrics_synced_path: Path, lyrics_synced: str):
|
||||
lyrics_synced_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lyrics_synced_path.write_text(lyrics_synced, encoding="utf8")
|
||||
|
||||
def download(
|
||||
self,
|
||||
media_id: str = None,
|
||||
media_metadata: dict = None,
|
||||
playlist_attributes: dict = None,
|
||||
playlist_track: int = None,
|
||||
) -> DownloadInfo:
|
||||
try:
|
||||
download_info = self._download(
|
||||
media_id,
|
||||
media_metadata,
|
||||
playlist_attributes,
|
||||
playlist_track,
|
||||
)
|
||||
self.downloader._final_processing(download_info)
|
||||
finally:
|
||||
self.downloader.cleanup_temp_path()
|
||||
return download_info
|
||||
|
||||
def _download(
|
||||
self,
|
||||
media_id: str = None,
|
||||
media_metadata: dict = None,
|
||||
playlist_attributes: dict = None,
|
||||
playlist_track: int = None,
|
||||
) -> DownloadInfo:
|
||||
download_info = DownloadInfo()
|
||||
|
||||
if playlist_track is None and playlist_attributes:
|
||||
raise ValueError(
|
||||
"playlist_track must be provided if playlist_attributes is provided"
|
||||
)
|
||||
if playlist_attributes:
|
||||
playlist_tags = self.downloader.get_playlist_tags(
|
||||
playlist_attributes,
|
||||
playlist_track,
|
||||
)
|
||||
else:
|
||||
playlist_tags = None
|
||||
download_info.playlist_tags = playlist_tags
|
||||
|
||||
if not media_id and not media_metadata:
|
||||
raise ValueError("Either media_id or media_metadata must be provided")
|
||||
|
||||
if not media_metadata:
|
||||
logger.debug(
|
||||
f"[{color_text(media_id, colorama.Style.DIM)}] Getting Song metadata"
|
||||
)
|
||||
media_metadata = self.downloader.apple_music_api.get_song(media_id)
|
||||
download_info.media_metadata = media_metadata
|
||||
|
||||
if not media_id:
|
||||
media_id = self.downloader.get_media_id_of_library_media(media_metadata)
|
||||
download_info.media_id = media_id
|
||||
colored_media_id = color_text(media_id, colorama.Style.DIM)
|
||||
|
||||
if not self.downloader.is_media_streamable(media_metadata):
|
||||
raise MediaNotStreamableException()
|
||||
|
||||
logger.debug(f"[{colored_media_id}] Getting lyrics")
|
||||
lyrics = self.get_lyrics(media_metadata)
|
||||
download_info.lyrics = lyrics
|
||||
|
||||
logger.debug(f"[{colored_media_id}] Getting webplayback info")
|
||||
webplayback = self.downloader.apple_music_api.get_webplayback(
|
||||
media_id,
|
||||
)
|
||||
tags = self.get_tags(
|
||||
webplayback,
|
||||
lyrics.unsynced if lyrics else None,
|
||||
)
|
||||
final_path = self.downloader.get_final_path(tags, ".m4a", playlist_tags)
|
||||
download_info.tags = tags
|
||||
download_info.final_path = final_path
|
||||
|
||||
if lyrics and lyrics.synced:
|
||||
synced_lyrics_path = self.get_lyrics_synced_path(final_path)
|
||||
else:
|
||||
synced_lyrics_path = None
|
||||
download_info.synced_lyrics_path = synced_lyrics_path
|
||||
|
||||
if self.downloader.synced_lyrics_only:
|
||||
logger.info(
|
||||
f"[{colored_media_id}] Downloading synced lyrics only, skipping song download"
|
||||
)
|
||||
return download_info
|
||||
|
||||
cover_url = self.downloader.get_cover_url(media_metadata)
|
||||
cover_format = self.downloader.get_cover_format(cover_url)
|
||||
if cover_format:
|
||||
cover_path = self.get_cover_path(final_path, cover_format)
|
||||
else:
|
||||
cover_path = None
|
||||
download_info.cover_url = cover_url
|
||||
download_info.cover_format = cover_format
|
||||
download_info.cover_path = cover_path
|
||||
|
||||
if final_path.exists() and not self.downloader.overwrite:
|
||||
raise MediaFileAlreadyExistsException()
|
||||
|
||||
logger.debug(f"[{colored_media_id}] Getting stream info")
|
||||
if self.codec.is_legacy():
|
||||
stream_info = self.get_stream_info_legacy(webplayback)
|
||||
logger.debug(f"[{colored_media_id}] Getting decryption key")
|
||||
decryption_key = self.get_decryption_key_legacy(
|
||||
stream_info,
|
||||
media_id,
|
||||
)
|
||||
download_info.stream_info = stream_info
|
||||
download_info.decryption_key = decryption_key
|
||||
else:
|
||||
stream_info = self.get_stream_info(media_metadata)
|
||||
if not stream_info or not stream_info.audio_track.widevine_pssh:
|
||||
raise MediaFormatNotAvailableException()
|
||||
logger.debug(f"[{colored_media_id}] Getting decryption key")
|
||||
decryption_key = self.get_decryption_key(
|
||||
stream_info,
|
||||
media_id,
|
||||
)
|
||||
download_info.stream_info = stream_info
|
||||
download_info.decryption_key = decryption_key
|
||||
|
||||
encrypted_path = self.downloader.get_temp_path(
|
||||
media_id,
|
||||
"encrypted",
|
||||
".m4a",
|
||||
)
|
||||
decrypted_path = self.downloader.get_temp_path(
|
||||
media_id,
|
||||
"decrypted",
|
||||
".m4a",
|
||||
)
|
||||
staged_path = self.downloader.get_temp_path(
|
||||
media_id,
|
||||
"staged",
|
||||
self.downloader.get_media_file_extension(stream_info.file_format),
|
||||
)
|
||||
|
||||
logger.info(f"[{colored_media_id}] Downloading song")
|
||||
|
||||
logger.debug(f'[{colored_media_id}] Downloading to "{encrypted_path}"')
|
||||
self.downloader.download(
|
||||
encrypted_path,
|
||||
download_info.stream_info.audio_track.stream_url,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'[{colored_media_id}] Decryping/remuxing to "{decrypted_path}"/"{staged_path}"'
|
||||
)
|
||||
self.stage(
|
||||
self.codec,
|
||||
encrypted_path,
|
||||
decrypted_path,
|
||||
decryption_key,
|
||||
staged_path,
|
||||
)
|
||||
download_info.staged_path = staged_path
|
||||
|
||||
return download_info
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import m3u8
|
||||
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
|
||||
|
||||
|
||||
class DownloaderSongLegacy(DownloaderSong):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_stream_info(self, webplayback: dict) -> StreamInfo:
|
||||
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
|
||||
|
||||
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)
|
||||
return decryption_key
|
||||
|
||||
def decrypt(
|
||||
self,
|
||||
encrypted_path: Path,
|
||||
decrypted_path: Path,
|
||||
decryption_key: str,
|
||||
):
|
||||
self.fix_key_id(encrypted_path)
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.mp4decrypt_path_full,
|
||||
encrypted_path,
|
||||
"--key",
|
||||
f"1:{decryption_key}",
|
||||
decrypted_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path):
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.mp4box_path_full,
|
||||
"-quiet",
|
||||
"-add",
|
||||
decrypted_path,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-keep-utc",
|
||||
"-new",
|
||||
remuxed_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def remux_ffmpeg(
|
||||
self,
|
||||
decryption_key: str,
|
||||
encrypted_path: Path,
|
||||
remuxed_path: Path,
|
||||
):
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.ffmpeg_path_full,
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-decryption_key",
|
||||
decryption_key,
|
||||
"-i",
|
||||
encrypted_path,
|
||||
"-c",
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
remuxed_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def remux(
|
||||
self,
|
||||
encrypted_path: Path,
|
||||
decrypted_path: Path,
|
||||
remuxed_path: Path,
|
||||
decryption_key: str,
|
||||
):
|
||||
if self.downloader.remux_mode == RemuxMode.FFMPEG:
|
||||
self.remux_ffmpeg(decryption_key, encrypted_path, remuxed_path)
|
||||
elif self.downloader.remux_mode == RemuxMode.MP4BOX:
|
||||
self.decrypt(encrypted_path, decrypted_path, decryption_key)
|
||||
self.remux_mp4box(decrypted_path, remuxed_path)
|
||||
@@ -25,6 +25,9 @@ class SongCodec(Enum):
|
||||
ALAC = "alac"
|
||||
ASK = "ask"
|
||||
|
||||
def is_legacy(self) -> bool:
|
||||
return self in {SongCodec.AAC_LEGACY, SongCodec.AAC_HE_LEGACY}
|
||||
|
||||
|
||||
class SyncedLyricsFormat(Enum):
|
||||
LRC = "lrc"
|
||||
@@ -37,6 +40,37 @@ class MusicVideoCodec(Enum):
|
||||
H265 = "h265"
|
||||
ASK = "ask"
|
||||
|
||||
def fourcc(self) -> str:
|
||||
return {
|
||||
MusicVideoCodec.H264: "avc1",
|
||||
MusicVideoCodec.H265: "hvc1",
|
||||
}.get(self)
|
||||
|
||||
|
||||
class RemuxFormatMusicVideo(Enum):
|
||||
M4V = "m4v"
|
||||
MP4 = "mp4"
|
||||
|
||||
|
||||
class MusicVideoResolution(Enum):
|
||||
R240P = "240p"
|
||||
R360P = "360p"
|
||||
R480P = "480p"
|
||||
R540P = "540p"
|
||||
R720P = "720p"
|
||||
R1080P = "1080p"
|
||||
R1440P = "1440p"
|
||||
R2160P = "2160p"
|
||||
|
||||
def __int__(self) -> int:
|
||||
return int(self.value[:-1])
|
||||
|
||||
|
||||
class MediaFileFormat(Enum):
|
||||
M4A = "m4a"
|
||||
MP4 = "mp4"
|
||||
M4V = "m4v"
|
||||
|
||||
|
||||
class PostQuality(Enum):
|
||||
BEST = "best"
|
||||
@@ -46,3 +80,34 @@ class PostQuality(Enum):
|
||||
class CoverFormat(Enum):
|
||||
JPG = "jpg"
|
||||
PNG = "png"
|
||||
RAW = "raw"
|
||||
|
||||
|
||||
class MediaType(Enum):
|
||||
SONG = 1
|
||||
MUSIC_VIDEO = 6
|
||||
|
||||
def __str__(self) -> str:
|
||||
return {
|
||||
MediaType.SONG: "Song",
|
||||
MediaType.MUSIC_VIDEO: "Music Video",
|
||||
}[self]
|
||||
|
||||
def __int__(self) -> int:
|
||||
return self.value
|
||||
|
||||
|
||||
class MediaRating(Enum):
|
||||
NONE = 0
|
||||
EXPLICIT = 1
|
||||
CLEAN = 2
|
||||
|
||||
def __str__(self) -> str:
|
||||
return {
|
||||
MediaRating.NONE: "None",
|
||||
MediaRating.EXPLICIT: "Explicit",
|
||||
MediaRating.CLEAN: "Clean",
|
||||
}[self]
|
||||
|
||||
def __int__(self) -> int:
|
||||
return self.value
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
class MediaNotStreamableException(Exception):
|
||||
def __init__(self, message: str = "Media is not streamable"):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class MediaFileAlreadyExistsException(Exception):
|
||||
def __init__(self, message: str = "Media file already exists"):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class MediaFormatNotAvailableException(Exception):
|
||||
def __init__(self, message: str = "Requested media format or codec not available"):
|
||||
super().__init__(message)
|
||||
@@ -1 +1,3 @@
|
||||
# Dumped from Android Studio Virtual Device running Android 9
|
||||
|
||||
HARDCODED_WVD = """V1ZEAgIDAASoMIIEpAIBAAKCAQEAwnCFAPXy4U1J7p1NohAS+xl040f5FBaE/59bPp301bGz0UGFT9VoEtY3vaeakKh/d319xTNvCSWsEDRaMmp/wSnMiEZUkkl04872jx2uHuR4k6KYuuJoqhsIo1TwUBueFZynHBUJzXQeW8Eb1tYAROGwp8W7r+b0RIjHC89RFnfVXpYlF5I6McktyzJNSOwlQbMqlVihfSUkv3WRd3HFmA0Oxay51CEIkoTlNTHVlzVyhov5eHCDSp7QENRgaaQ03jC/CcgFOoQymhsBtRCM0CQmfuAHjA9e77R6m/GJPy75G9fqoZM1RMzVDHKbKZPd3sFd0c0+77gLzW8cWEaaHwIDAQABAoIBAQCB2pN46MikHvHZIcTPDt0eRQoDH/YArGl2Lf7J+sOgU2U7wv49KtCug9IGHwDiyyUVsAFmycrF2RroV45FTUq0vi2SdSXV7Kjb20Ren/vBNeQw9M37QWmU8Sj7q6YyWb9hv5T69DHvvDTqIjVtbM4RMojAAxYti5hmjNIh2PrWfVYWhXxCQ/WqAjWLtZBM6Oww1byfr5I/wFogAKkgHi8wYXZ4LnIC8V7jLAhujlToOvMMC9qwcBiPKDP2FO+CPSXaqVhH+LPSEgLggnU3EirihgxovbLNAuDEeEbRTyR70B0lW19tLHixso4ZQa7KxlVUwOmrHSZf7nVuWqPpxd+BAoGBAPQLyJ1IeRavmaU8XXxfMdYDoc8+xB7v2WaxkGXb6ToX1IWPkbMz4yyVGdB5PciIP3rLZ6s1+ruuRRV0IZ98i1OuN5TSR56ShCGg3zkd5C4L/xSMAz+NDfYSDBdO8BVvBsw21KqSRUi1ctL7QiIvfedrtGb5XrE4zhH0gjXlU5qZAoGBAMv2segn0Jx6az4rqRa2Y7zRx4iZ77JUqYDBI8WMnFeR54uiioTQ+rOs3zK2fGIWlrn4ohco/STHQSUTB8oCOFLMx1BkOqiR+UyebO28DJY7+V9ZmxB2Guyi7W8VScJcIdpSOPyJFOWZQKXdQFW3YICD2/toUx/pDAJh1sEVQsV3AoGBANyyp1rthmvoo5cVbymhYQ08vaERDwU3PLCtFXu4E0Ow90VNn6Ki4ueXcv/gFOp7pISk2/yuVTBTGjCblCiJ1en4HFWekJwrvgg3Vodtq8Okn6pyMCHRqvWEPqD5hw6rGEensk0K+FMXnF6GULlfn4mgEkYpb+PvDhSYvQSGfkPJAoGAF/bAKFqlM/1eJEvU7go35bNwEiij9Pvlfm8y2L8Qj2lhHxLV240CJ6IkBz1Rl+S3iNohkT8LnwqaKNT3kVB5daEBufxMuAmOlOX4PmZdxDj/r6hDg8ecmjj6VJbXt7JDd/c5ItKoVeGPqu035dpJyE+1xPAY9CLZel4scTsiQTkCgYBt3buRcZMwnc4qqpOOQcXK+DWD6QvpkcJ55ygHYw97iP/lF4euwdHd+I5b+11pJBAao7G0fHX3eSjqOmzReSKboSe5L8ZLB2cAI8AsKTBfKHWmCa8kDtgQuI86fUfirCGdhdA9AVP2QXN2eNCuPnFWi0WHm4fYuUB5be2c18ucxAb9CAESmgsK3QMIAhIQ071yBlsbLoO2CSB9Ds0cmRif6uevBiKOAjCCAQoCggEBAMJwhQD18uFNSe6dTaIQEvsZdONH+RQWhP+fWz6d9NWxs9FBhU/VaBLWN72nmpCof3d9fcUzbwklrBA0WjJqf8EpzIhGVJJJdOPO9o8drh7keJOimLriaKobCKNU8FAbnhWcpxwVCc10HlvBG9bWAEThsKfFu6/m9ESIxwvPURZ31V6WJReSOjHJLcsyTUjsJUGzKpVYoX0lJL91kXdxxZgNDsWsudQhCJKE5TUx1Zc1coaL+Xhwg0qe0BDUYGmkNN4wvwnIBTqEMpobAbUQjNAkJn7gB4wPXu+0epvxiT8u+RvX6qGTNUTM1QxymymT3d7BXdHNPu+4C81vHFhGmh8CAwEAASjwIkgBUqoBCAEQABqBAQQlRbfiBNDb6eU6aKrsH5WJaYszTioXjPLrWN9dqyW0vwfT11kgF0BbCGkAXew2tLJJqIuD95cjJvyGUSN6VyhL6dp44fWEGDSBIPR0mvRq7bMP+m7Y/RLKf83+OyVJu/BpxivQGC5YDL9f1/A8eLhTDNKXs4Ia5DrmTWdPTPBL8SIgyfUtg3ofI+/I9Tf7it7xXpT0AbQBJfNkcNXGpO3JcBMSgAIL5xsXK5of1mMwAl6ygN1Gsj4aZ052otnwN7kXk12SMsXheWTZ/PYh2KRzmt9RPS1T8hyFx/Kp5VkBV2vTAqqWrGw/dh4URqiHATZJUlhO7PN5m2Kq1LVFdXjWSzP5XBF2S83UMe+YruNHpE5GQrSyZcBqHO0QrdPcU35GBT7S7+IJr2AAXvnjqnb8yrtpPWN2ZW/IWUJN2z4vZ7/HV4aj3OZhkxC1DIMNyvsusUKoQQuf8gwKiEe8cFwbwFSicywlFk9la2IPe8oFShcxAzHLCCn/TIYUAvEL3/4LgaZvqWm80qCPYbgIP5HT8hPYkKWJ4WYknEWK+3InbnkzteFfGrQFCq4CCAESEGnj6Ji7LD+4o7MoHYT4jBQYjtW+kQUijgIwggEKAoIBAQDY9um1ifBRIOmkPtDZTqH+CZUBbb0eK0Cn3NHFf8MFUDzPEz+emK/OTub/hNxCJCao//pP5L8tRNUPFDrrvCBMo7Rn+iUb+mA/2yXiJ6ivqcN9Cu9i5qOU1ygon9SWZRsujFFB8nxVreY5Lzeq0283zn1Cg1stcX4tOHT7utPzFG/ReDFQt0O/GLlzVwB0d1sn3SKMO4XLjhZdncrtF9jljpg7xjMIlnWJUqxDo7TQkTytJmUl0kcM7bndBLerAdJFGaXc6oSY4eNy/IGDluLCQR3KZEQsy/mLeV1ggQ44MFr7XOM+rd+4/314q/deQbjHqjWFuVr8iIaKbq+R63ShAgMBAAEo8CISgAMii2Mw6z+Qs1bvvxGStie9tpcgoO2uAt5Zvv0CDXvrFlwnSbo+qR71Ru2IlZWVSbN5XYSIDwcwBzHjY8rNr3fgsXtSJty425djNQtF5+J2jrAhf3Q2m7EI5aohZGpD2E0cr+dVj9o8x0uJR2NWR8FVoVQSXZpad3M/4QzBLNto/tz+UKyZwa7Sc/eTQc2+ZcDS3ZEO3lGRsH864Kf/cEGvJRBBqcpJXKfG+ItqEW1AAPptjuggzmZEzRq5xTGf6or+bXrKjCpBS9G1SOyvCNF1k5z6lG8KsXhgQxL6ADHMoulxvUIihyPY5MpimdXfUdEQ5HA2EqNiNVNIO4qP007jW51yAeThOry4J22xs8RdkIClOGAauLIl0lLA4flMzW+VfQl5xYxP0E5tuhn0h+844DslU8ZF7U1dU2QprIApffXD9wgAACk26Rggy8e96z8i86/+YYyZQkc9hIdCAERrgEYCEbByzONrdRDs1MrS/ch1moV5pJv63BIKvQHGvLkaFwoMY29tcGFueV9uYW1lEgd1bmtub3duGioKCm1vZGVsX25hbWUSHEFuZHJvaWQgU0RLIGJ1aWx0IGZvciB4ODZfNjQaGwoRYXJjaGl0ZWN0dXJlX25hbWUSBng4Nl82NBodCgtkZXZpY2VfbmFtZRIOZ2VuZXJpY194ODZfNjQaIAoMcHJvZHVjdF9uYW1lEhBzZGtfcGhvbmVfeDg2XzY0GmMKCmJ1aWxkX2luZm8SVUFuZHJvaWQvc2RrX3Bob25lX3g4Nl82NC9nZW5lcmljX3g4Nl82NDo5L1BTUjEuMTgwNzIwLjAxMi80OTIzMjE0OnVzZXJkZWJ1Zy90ZXN0LWtleXMaHgoUd2lkZXZpbmVfY2RtX3ZlcnNpb24SBjE0LjAuMBokCh9vZW1fY3J5cHRvX3NlY3VyaXR5X3BhdGNoX2xldmVsEgEwMg4QASAAKA0wAEAASABQAA=="""
|
||||
|
||||
+8
-11
@@ -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:
|
||||
@@ -40,7 +40,7 @@ class ItunesApi:
|
||||
self,
|
||||
resource_id: str,
|
||||
entity: str = "album",
|
||||
) -> dict:
|
||||
) -> dict | None:
|
||||
response = self.session.get(
|
||||
self.ITUNES_LOOKUP_API_URL,
|
||||
params={
|
||||
@@ -51,21 +51,20 @@ class ItunesApi:
|
||||
try:
|
||||
response.raise_for_status()
|
||||
response_dict = response.json()
|
||||
resource = response_dict.get("results")
|
||||
assert resource
|
||||
except (
|
||||
requests.HTTPError,
|
||||
requests.exceptions.JSONDecodeError,
|
||||
AssertionError,
|
||||
):
|
||||
AppleMusicApi._raise_response_exception(response)
|
||||
return resource
|
||||
raise_response_exception(response)
|
||||
if response_dict.get("results"):
|
||||
return response_dict["results"]
|
||||
return None
|
||||
|
||||
def get_itunes_page(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
) -> dict:
|
||||
) -> dict | None:
|
||||
response = self.session.get(
|
||||
f"{self.ITUNES_PAGE_API_URL}/{resource_type}/{resource_id}"
|
||||
)
|
||||
@@ -75,11 +74,9 @@ class ItunesApi:
|
||||
itunes_page = response_dict["storePlatformData"]["product-dv"][
|
||||
"results"
|
||||
].get(resource_id)
|
||||
assert itunes_page
|
||||
except (
|
||||
requests.HTTPError,
|
||||
requests.exceptions.JSONDecodeError,
|
||||
AssertionError,
|
||||
):
|
||||
AppleMusicApi._raise_response_exception(response)
|
||||
raise_response_exception(response)
|
||||
return itunes_page
|
||||
|
||||
+156
-3
@@ -1,16 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import typing
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .enums import MediaFileFormat, MediaRating, MediaType
|
||||
|
||||
|
||||
@dataclass
|
||||
class UrlInfo:
|
||||
storefront: str = None
|
||||
type: str = None
|
||||
slug: str = None
|
||||
id: str = None
|
||||
sub_id: str = None
|
||||
library_storefront: str = None
|
||||
library_type: str = None
|
||||
library_id: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadQueueItem:
|
||||
metadata: dict = None
|
||||
class DownloadQueue:
|
||||
playlist_attributes: dict = None
|
||||
medias_metadata: list[dict] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -22,5 +35,145 @@ 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
|
||||
|
||||
|
||||
@dataclass
|
||||
class DecryptionKey:
|
||||
kid: str = None
|
||||
key: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DecryptionKeyAv:
|
||||
video_track: DecryptionKey = None
|
||||
audio_track: DecryptionKey = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaTags:
|
||||
album: str = None
|
||||
album_artist: str = None
|
||||
album_id: int = None
|
||||
album_sort: str = None
|
||||
artist: str = None
|
||||
artist_id: int = None
|
||||
artist_sort: str = None
|
||||
comment: str = None
|
||||
compilation: bool = None
|
||||
composer: str = None
|
||||
composer_id: int = None
|
||||
composer_sort: str = None
|
||||
copyright: str = None
|
||||
date: datetime.date | str = None
|
||||
disc: int = None
|
||||
disc_total: int = None
|
||||
gapless: bool = None
|
||||
genre: str = None
|
||||
genre_id: int = None
|
||||
lyrics: str = None
|
||||
media_type: MediaType = None
|
||||
rating: MediaRating = None
|
||||
storefront: str = None
|
||||
title: str = None
|
||||
title_id: int = None
|
||||
title_sort: str = None
|
||||
track: int = None
|
||||
track_total: int = None
|
||||
xid: str = None
|
||||
|
||||
def to_mp4_tags(self, date_format: str = None) -> dict[str, typing.Any]:
|
||||
disc_mp4 = [
|
||||
[
|
||||
self.disc if self.disc is not None else 0,
|
||||
self.disc_total if self.disc_total is not None else 0,
|
||||
]
|
||||
]
|
||||
if disc_mp4[0][0] == 0 and disc_mp4[0][1] == 0:
|
||||
disc_mp4 = [None]
|
||||
|
||||
track_mp4 = [
|
||||
[
|
||||
self.track if self.track is not None else 0,
|
||||
self.track_total if self.track_total is not None else 0,
|
||||
]
|
||||
]
|
||||
if track_mp4[0][0] == 0 and track_mp4[0][1] == 0:
|
||||
track_mp4 = [None]
|
||||
|
||||
if isinstance(self.date, datetime.date):
|
||||
if date_format is None:
|
||||
date_mp4 = self.date.isoformat()
|
||||
else:
|
||||
date_mp4 = self.date.strftime(date_format)
|
||||
elif isinstance(self.date, str):
|
||||
date_mp4 = self.date
|
||||
else:
|
||||
date_mp4 = None
|
||||
|
||||
mp4_tags = {
|
||||
"\xa9alb": [self.album],
|
||||
"aART": [self.album_artist],
|
||||
"plID": [self.album_id],
|
||||
"soal": [self.album_sort],
|
||||
"\xa9ART": [self.artist],
|
||||
"atID": [self.artist_id],
|
||||
"soar": [self.artist_sort],
|
||||
"\xa9cmt": [self.comment],
|
||||
"cpil": [bool(self.compilation) if self.compilation is not None else None],
|
||||
"\xa9wrt": [self.composer],
|
||||
"cmID": [self.composer_id],
|
||||
"soco": [self.composer_sort],
|
||||
"cprt": [self.copyright],
|
||||
"\xa9day": date_mp4,
|
||||
"disk": disc_mp4,
|
||||
"pgap": [bool(self.gapless) if self.gapless is not None else None],
|
||||
"\xa9gen": [self.genre],
|
||||
"\xa9lyr": [self.lyrics],
|
||||
"geID": [self.genre_id],
|
||||
"stik": [int(self.media_type) if self.media_type is not None else None],
|
||||
"rtng": [int(self.rating) if self.rating is not None else None],
|
||||
"sfID": [self.storefront],
|
||||
"\xa9nam": [self.title],
|
||||
"cnID": [self.title_id],
|
||||
"sonm": [self.title_sort],
|
||||
"trkn": track_mp4,
|
||||
"xid ": [self.xid],
|
||||
}
|
||||
return {k: v for k, v in mp4_tags.items() if v[0] is not None}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaylistTags:
|
||||
playlist_artist: str = None
|
||||
playlist_id: int = None
|
||||
playlist_title: str = None
|
||||
playlist_track: int = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadInfo:
|
||||
media_metadata: dict = None
|
||||
media_id: str = None
|
||||
alt_media_id: str = None
|
||||
playlist_tags: PlaylistTags = None
|
||||
lyrics: Lyrics = None
|
||||
tags: MediaTags = None
|
||||
final_path: Path = None
|
||||
cover_url: str = None
|
||||
cover_format: str = None
|
||||
cover_path: Path = None
|
||||
stream_info: StreamInfoAv = None
|
||||
decryption_key: DecryptionKeyAv = None
|
||||
staged_path: Path = None
|
||||
synced_lyrics_path: Path = None
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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,
|
||||
)
|
||||
path_type = "file" if is_file else "folder"
|
||||
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. "
|
||||
f"Move the {path_type} to that location, type a new path "
|
||||
f"or drag and drop the {path_type} 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
|
||||
+5
-4
@@ -1,15 +1,16 @@
|
||||
[project]
|
||||
name = "gamdl"
|
||||
description = "A Python CLI app for downloading Apple Music songs/music videos/albums/playlists/posts."
|
||||
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",
|
||||
"mutagen",
|
||||
"pillow",
|
||||
"pywidevine",
|
||||
"pyyaml",
|
||||
"yt-dlp",
|
||||
]
|
||||
readme = "README.md"
|
||||
|
||||
+4
-1
@@ -1,7 +1,10 @@
|
||||
ciso8601
|
||||
click
|
||||
colorama
|
||||
inquirerpy
|
||||
m3u8
|
||||
mutagen
|
||||
pillow
|
||||
pywidevine
|
||||
pyyaml
|
||||
termcolor
|
||||
yt-dlp
|
||||
|
||||
Reference in New Issue
Block a user