mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-13 12:15:18 +03:00
Compare commits
322 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c653db00cf | |||
| cdd574a349 | |||
| afbe65707a | |||
| 3998b698e0 | |||
| a67c81bd22 | |||
| 9b0a2acc6f | |||
| 4d904e2e7c | |||
| 2d3b2b6b1f | |||
| 1ee8e2aa13 | |||
| fd6d8a0689 | |||
| 50904e9c08 | |||
| 66556eac0a | |||
| d97445ec9e | |||
| d6f30aa0a2 | |||
| 42a17ca90f | |||
| 3ee0d28727 | |||
| 7b8875250c | |||
| 16734b8b64 | |||
| 475bddb5f7 | |||
| 63ba4b0824 | |||
| 9d67e8f0f0 | |||
| fcbe596a80 | |||
| acd5fefb76 | |||
| ed584cc9b9 | |||
| bac8eb9254 | |||
| 71ac17cce2 | |||
| a35a3835aa | |||
| 091ca3bf53 | |||
| e4498e11c0 | |||
| 5a8c5d2c25 | |||
| c21d50479f | |||
| b6d1f36281 | |||
| c0d1ec2383 | |||
| 8c1a3dbe7d | |||
| aa71239eba | |||
| c890068eb7 | |||
| e3f96d8684 | |||
| 238a8377e0 | |||
| 6c3dff566b | |||
| 07c847a788 | |||
| 0ca56d24d7 | |||
| 566a8aa498 | |||
| a7af9e704f | |||
| 540009fc1b | |||
| 19fdd85c35 | |||
| 0cd87254d3 | |||
| 6593644c72 | |||
| 005af07fcc | |||
| adabfd95bc | |||
| 564ece387c | |||
| 328428a520 | |||
| 2c70a23e59 | |||
| 52288bb7af | |||
| 281a357863 | |||
| 744300e36b | |||
| e86f990395 | |||
| abc2f8f2f2 | |||
| 2dabb1c6fe | |||
| 8b80b0c6c5 | |||
| eef659bac8 | |||
| 0f7c3795a7 | |||
| c84b1137c2 | |||
| ebdc82d68b | |||
| 85c1fdbfbb | |||
| 5990e5f722 | |||
| b8bd406d74 | |||
| 57ee6e1db8 | |||
| a20feb2aa7 | |||
| 60db7e0339 | |||
| 575d2ee154 | |||
| f5bb56cab7 | |||
| ecc7979d7e | |||
| d129551b55 | |||
| 08a5ac00d8 | |||
| 628c9786d5 | |||
| 7de12c3da7 | |||
| 39d724c488 | |||
| 79e00e5e19 | |||
| e90fd24af0 | |||
| d68edd5393 | |||
| 5acefd9a06 | |||
| 93b62cdde9 | |||
| fc61a51da2 | |||
| 81b44a808d | |||
| 24f3af1a5e | |||
| 4a469d74d3 | |||
| 6122835caa | |||
| be597f0de4 | |||
| b10ab5332d | |||
| 080413b183 | |||
| f6443081ae | |||
| 8dcf10c221 | |||
| 6f5efd1779 | |||
| 06e43fdbbe | |||
| 646125b93f | |||
| ea281766ba | |||
| a1b0ad35ee | |||
| 2f715b3d9d | |||
| 461fcedf30 | |||
| 6d7cb3ada4 | |||
| b87d406ffa | |||
| f6efdb3332 | |||
| 29a006c304 | |||
| a49a9c90cc | |||
| 43fd1dd2e3 | |||
| 2ed6ac05ba | |||
| 0f0e17f4cd | |||
| 8c4d2713f7 | |||
| 1baca4151b | |||
| 6f08a4b2f9 | |||
| 38f708e2e9 | |||
| f27adf98df | |||
| 9f0b25e1d1 | |||
| 3590d99063 | |||
| b200dade5a | |||
| 118d23e9db | |||
| 7bc8c6668f | |||
| 0e9fb3702d | |||
| e706f0fa82 | |||
| 3014fb112d | |||
| d7f17b8b6f | |||
| 947e2df81a | |||
| c8fe96b31d | |||
| 83a3efc1fa | |||
| 345afbf174 | |||
| c35051a7ec | |||
| b286ee84e2 | |||
| 9094f2c7b4 | |||
| 5feb5b274a | |||
| 1375af929c | |||
| d280f1fad2 | |||
| 3a04d7927e | |||
| 942a812308 | |||
| 66e01293e6 | |||
| c0561da592 | |||
| 56d238fb1b | |||
| d3a53bf93b | |||
| bb7a3ff77e | |||
| bf6293a0a0 | |||
| c421b3e855 | |||
| 7e495300f9 | |||
| ccef00e39f | |||
| 94cdba313c | |||
| d7b19e8c67 | |||
| a6809df2ef | |||
| 40f3616bc3 | |||
| 104100e091 | |||
| ce4a7d7880 | |||
| ec09bacd39 | |||
| e0d3f46159 | |||
| 0bfb4d80b8 | |||
| 54d6d93967 | |||
| c13160b999 | |||
| ea4d574810 | |||
| 9da35c3f57 | |||
| ebb7ec1da7 | |||
| d155a42e3a | |||
| 8f18562e1c | |||
| 25ed506b82 | |||
| ba76241032 | |||
| c6f7e99135 | |||
| dbaa1faa6b | |||
| a61a9c4975 | |||
| 8ffe5c86ca | |||
| 88bdf64825 | |||
| 3849df9adb | |||
| a989ff6c34 | |||
| f3d583aab2 | |||
| 7ac3d3e400 | |||
| 085e8f1b5d | |||
| 4df36e60d9 | |||
| f6d726e466 | |||
| 61b1bf1e55 | |||
| 3ae6709ccb | |||
| 1f00e4fb9f | |||
| 714d47bb13 | |||
| 46e3a92d4f | |||
| 42b536d271 | |||
| dac8d5eed9 | |||
| 2956f20dfa | |||
| 8f76743a3b | |||
| 3096bbc79d | |||
| ed49d7bd5f | |||
| 0ea72d0b78 | |||
| ae490320ad | |||
| e40668e6ec | |||
| 62c695b5ff | |||
| 5d9c8c1f0b | |||
| 54d640230a | |||
| 3d272a6891 | |||
| e99ed0eb5a | |||
| 86b5029773 | |||
| 3df0a91d3f | |||
| d356596cf4 | |||
| cbd2df79b7 | |||
| 80c2afde63 | |||
| e43169b9d3 | |||
| 3c97dfdb1f | |||
| 72e6d624ed | |||
| 1c93c6b7ce | |||
| 3f61a907ff | |||
| c4a38c5c17 | |||
| a49066d30d | |||
| 2cb5eb9b2f | |||
| 237fc0c3d1 | |||
| 7b767adc05 | |||
| 2baf05ebc7 | |||
| c3eb66ef0e | |||
| cf8d22b7dc | |||
| 2dccfaef9e | |||
| 6c4c3cdd25 | |||
| 7cd3d4b705 | |||
| 65bcb41f83 | |||
| 0ded3e1b50 | |||
| 2a69969c97 | |||
| ab782c98c9 | |||
| 9cc1b8a800 | |||
| d60e26d4d1 | |||
| 5d2f50d315 | |||
| fe44cd0d8e | |||
| 9d3865e7d0 | |||
| 4636d96dbf | |||
| b4424a110e | |||
| 2773f20931 | |||
| f8005beac9 | |||
| b1c02cffef | |||
| 89080a0f7f | |||
| 0bfc702533 | |||
| aa4a391309 | |||
| a2932b637a | |||
| 5b909b9abc | |||
| 617605495e | |||
| 4a373bbf99 | |||
| b93b1a8135 | |||
| 1ec3aed080 | |||
| 406edc06bc | |||
| 1e311aa84e | |||
| 739debcfb9 | |||
| b3edae7d91 | |||
| 7281a9b8bb | |||
| a62a52bfa0 | |||
| e78f02812a | |||
| f500488f13 | |||
| 76eb8e62f9 | |||
| f46387dacf | |||
| e92a8890f9 | |||
| 48c7d08609 | |||
| b6e2761af4 | |||
| 129ce5d20d | |||
| 57209cf071 | |||
| c7aaca1f29 | |||
| eb323f2faf | |||
| f28b75b66d | |||
| 14d54a7dd8 | |||
| 57494e8e0a | |||
| e54b6b3fc4 | |||
| 61d1876029 | |||
| dc3e0422bd | |||
| c77dc4adba | |||
| e76803add1 | |||
| 6551722ef3 | |||
| 90d17fb206 | |||
| cb09033cca | |||
| 733a4bb2c6 | |||
| 56f01dc433 | |||
| d99cfec349 | |||
| 4ecc76ebe4 | |||
| 2678950fcd | |||
| c0eae562e7 | |||
| b2b11f3680 | |||
| d2dd8673ca | |||
| da3ec2b4e7 | |||
| bb7579870e | |||
| 6c459b566e | |||
| 4172ccaf37 | |||
| 01c46d588e | |||
| f14fd2f42f | |||
| 6febfe9c3d | |||
| b648c295b9 | |||
| dd96fbb134 | |||
| 72aae6c147 | |||
| 5564105d9c | |||
| 934cb4db93 | |||
| 1a86f23187 | |||
| 5bf0e6dc09 | |||
| c98d779c1b | |||
| 47a9cfe3ef | |||
| 743f5bedb9 | |||
| eb041894cf | |||
| d5f5398fe1 | |||
| c55a894212 | |||
| 8a3e857cb3 | |||
| 71e31d7832 | |||
| abef15789d | |||
| 41a8584023 | |||
| 025a5a22cf | |||
| 936b878474 | |||
| d76d574da9 | |||
| 92f65d5c10 | |||
| d96a28f5fb | |||
| 4064ebcec5 | |||
| b70792e9a7 | |||
| 68ba1556f5 | |||
| 6fdca2fe2e | |||
| 7709a37b88 | |||
| 9c4d2d1a13 | |||
| 6a4b9d8eb1 | |||
| 3be4a4bbbc | |||
| 70b595b323 | |||
| 6e3824f448 | |||
| b0f98e21ec | |||
| 46918ff4a0 | |||
| dc25b8a7be | |||
| 6516499df2 | |||
| c550ae3c20 | |||
| 908dcf794b | |||
| 4dd8df7e70 | |||
| 8e6623cb93 | |||
| aabe0aa7ea | |||
| 7d122844e2 | |||
| 5bb34caf09 | |||
| d615842c60 |
@@ -0,0 +1 @@
|
||||
ko_fi: glomatico
|
||||
@@ -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,82 +1,127 @@
|
||||
# gamdl - Glomatico's Apple Music Downloader
|
||||
A Python script to download Apple Music songs/music videos/albums/playlists. This is a rework of https://github.com/loveyoursupport/AppleMusic-Downloader/tree/661a274d62586b521feec5a7de6bee0e230fdb7d.
|
||||
# Glomatico's Apple Music Downloader
|
||||
A Python CLI app for downloading Apple Music songs/music videos/posts.
|
||||
|
||||
**Discord Server:** https://discord.gg/aBjMEZ9tnq
|
||||
|
||||
## Features
|
||||
* Download songs in 256kbps AAC or in 64kbps HE-AAC
|
||||
* Download songs in AAC 256kbps and other codecs
|
||||
* Download music videos up to 4K
|
||||
* Download synced lyrics
|
||||
* 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
|
||||
|
||||
## Prerequisites
|
||||
* Python 3.8 or higher
|
||||
* The cookies file of your Apple Music browser session in Netscape format (requires an active subscription)
|
||||
* To export your cookies, use one of the following browser extensions while signed in to Apple Music:
|
||||
* Firefox: https://addons.mozilla.org/addon/export-cookies-txt
|
||||
* Chromium based browsers: https://chrome.google.com/webstore/detail/gdocmgbfkjnnpapoeobnolbbkoibbcif
|
||||
* FFmpeg on your system PATH
|
||||
* 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 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.
|
||||
* [MP4Box](https://gpac.io/downloads/gpac-nightly-builds/)
|
||||
* Required when setting `mp4box` as remux mode.
|
||||
* [mp4decrypt](https://www.bento4.com/downloads/)
|
||||
* Required when setting `mp4box` as remux mode, for downloading songs in non-legacy formats and for downloading music videos.
|
||||
* [N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest)
|
||||
* Required when setting `nm3u8dlre` as download mode.
|
||||
|
||||
## Installation
|
||||
1. Install Python 3.7 or higher
|
||||
2. Add [FFmpeg](https://ffmpeg.org/download.html) and [mp4decrypt](https://www.bento4.com/downloads/) to PATH
|
||||
* mp4decrypt is only needed if you want to download music videos
|
||||
3. Place your cookies in the same folder that you will run gamdl as `cookies.txt`
|
||||
* You can export your cookies by using this Google Chrome extension on Apple Music website: https://chrome.google.com/webstore/detail/open-cookiestxt/gdocmgbfkjnnpapoeobnolbbkoibbcif. Make sure to be logged in.
|
||||
4. Place your .wvd file in the same folder that you will run gamdl as `device.wvd`
|
||||
* To get a .wvd file, you can use [dumper](https://github.com/wvdumper/dumper) to dump your phone's L3 CDM. Once you have the L3 CDM, use pywidevine to create the .wvd file from it.
|
||||
1. Install pywidevine with pip
|
||||
```bash
|
||||
pip install pywidevine pyyaml
|
||||
```
|
||||
2. Create the .wvd file
|
||||
```bash
|
||||
pywidevine create-device -t ANDROID -l 3 -k private_key.pem -c client_id.bin -o .
|
||||
```
|
||||
5. Install gamdl using pip
|
||||
1. Install the package `gamdl` using pip
|
||||
```bash
|
||||
pip install gamdl
|
||||
```
|
||||
2. Set up the cookies file.
|
||||
* You can either move to the current directory from which you will be running Gamdl as `cookies.txt` or specify its path using the command-line arguments/config file.
|
||||
|
||||
## Examples
|
||||
## Usage
|
||||
```bash
|
||||
gamdl [OPTIONS] URLS...
|
||||
```
|
||||
|
||||
### Supported URL types
|
||||
Gamdl supports the following types of URLs:
|
||||
* Song
|
||||
* Album
|
||||
* Playlist
|
||||
* Music video
|
||||
* Artist
|
||||
* Post video/extra video
|
||||
|
||||
### Examples
|
||||
* Download a song
|
||||
```bash
|
||||
gamdl "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1626265761?i=1626265765"
|
||||
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/1626265761"
|
||||
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"
|
||||
```
|
||||
|
||||
### 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 |
|
||||
| --------------------------------------------------------------- | ---------------------------------------------------------------------- | ---------------------------------- |
|
||||
| `-f`, `--final-path` / `final_path` | Path where the downloaded files will be saved. | `./Apple Music` |
|
||||
| `-t`, `--temp-path` / `temp_path` | Path where the temporary files will be saved. | `./temp` |
|
||||
| `-c`, `--cookies-location` / `cookies_location` | Location of the cookies file. | `./cookies.txt` |
|
||||
| `-w`, `--wvd-location` / `wvd_location` | Location of the .wvd file. | `./device.wvd` |
|
||||
| `--ffmpeg-location` / `ffmpeg_location` | Location of the FFmpeg binary. | `ffmpeg` |
|
||||
| `--mp4box-location` / `mp4box_location` | Location of the MP4Box binary. | `MP4Box` |
|
||||
| `--mp4decrypt-location` / `mp4decrypt_location` | Location of the mp4decrypt binary. | `mp4decrypt` |
|
||||
| `--nm3u8dlre-location` / `nm3u8dlre_location` | Location of the N_m3u8DL-RE binary. | `N_m3u8DL-RE` |
|
||||
| `--config-location` / - | Location of the config file. | `<home_folder>/.gamdl/config.json` |
|
||||
| `--template-folder-album` / `template_folder_album` | Template of the album folders as a format string. | `{album_artist}/{album}` |
|
||||
| `--template-folder-compilation` / `template_folder_compilation` | Template of the compilation album folders as a format string. | `Compilations/{album}` |
|
||||
| `--template-file-single-disc` / `template_file_single_disc` | Template of the track files for single-disc albums as a format string. | `{track:02d} {title}` |
|
||||
| `--template-file-multi-disc` / `template_file_multi_disc` | Template of the track files for multi-disc albums as a format string. | `{disc}-{track:02d} {title}` |
|
||||
| `--template-folder-music-video` / `template_folder_music_video` | Template of the music video folders as a format string. | `{artist}/Unknown Album` |
|
||||
| `--template-file-music-video` / `template_file_music_video` | Template of the music video files as a format string. | `{title}` |
|
||||
| `--cover-size` / `cover_size` | Size of the cover. | `1200` |
|
||||
| `--cover-format` / `cover_format` | Format of the cover. | `jpg` |
|
||||
| `--remux-mode` / `remux_mode` | Remux mode. | `ffmpeg` |
|
||||
| `--download-mode` / `download_mode` | Download mode. | `ytdlp` |
|
||||
| `-e`, `--exclude-tags` / `exclude_tags` | List of tags to exclude from file tagging separated by commas. | `null` |
|
||||
| `--truncate` / `truncate` | Maximum length of the file/folder names. | `40` |
|
||||
| `-l`, `--log-level` / `log_level` | Log level. | `INFO` |
|
||||
| `--prefer-hevc` / `prefer_hevc` | Prefer HEVC over AVC when downloading music videos. | `false` |
|
||||
| `--ask-video-format` / `ask_video_format` | Ask for the video format when downloading music videos. | `false` |
|
||||
| `--disable-music-video-skip` / `disable_music_video_skip` | Don't skip downloading music videos in albums/playlists. | `false` |
|
||||
| `-l`, `--lrc-only` / `lrc_only` | Download only the synced lyrics. | `false` |
|
||||
| `-n`, `--no-lrc` / `no_lrc` | Don't download the synced lyrics. | `false` |
|
||||
| `-s`, `--save-cover` / `save_cover` | Save cover as a separate file. | `false` |
|
||||
| `--songs-heaac` / `songs_heaac` | Download songs in HE-AAC 64kbps. | `false` |
|
||||
| `-o`, `--overwrite` / `overwrite` | Overwrite existing files. | `false` |
|
||||
| `--print-exceptions` / `print_exceptions` | Print exceptions. | `false` |
|
||||
| `-u`, `--url-txt` / - | Read URLs as location of text files containing URLs. | `false` |
|
||||
| `-n`, `--no-config-file` / - | Don't use the config file. | `false` |
|
||||
Gamdl can be configured by using the command line arguments or the config file.
|
||||
|
||||
The config file is created automatically when you run Gamdl for the first time at `~/.gamdl/config.json` on Linux and `%USERPROFILE%\.gamdl\config.json` on Windows.
|
||||
|
||||
Config file values can be overridden using command line arguments.
|
||||
| Command line argument / Config file key | Description | Default value |
|
||||
| --------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------- |
|
||||
| `--disable-music-video-skip` / `disable_music_video_skip` | Don't skip downloading music videos in albums/playlists. | `false` |
|
||||
| `--save-cover`, `-s` / `save_cover` | Save cover as a separate file. | `false` |
|
||||
| `--overwrite` / `overwrite` | Overwrite existing files. | `false` |
|
||||
| `--read-urls-as-txt`, `-r` / - | Interpret URLs as paths to text files containing URLs separated by newlines. | `false` |
|
||||
| `--save-playlist` / `save_playlist` | Save a M3U8 playlist file when downloading a playlist. | `false` |
|
||||
| `--synced-lyrics-only` / `synced_lyrics_only` | Download only the synced lyrics. | `false` |
|
||||
| `--no-synced-lyrics` / `no_synced_lyrics` | Don't download the synced lyrics. | `false` |
|
||||
| `--config-path` / - | Path to config file. | `<home>/.gamdl/config.json` |
|
||||
| `--log-level` / `log_level` | Log level. | `INFO` |
|
||||
| `--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-file-playlist` / `template_file_playlist` | Template file for the M3U8 playlist. | `Playlists/{playlist_title}` |
|
||||
| `--template-date` / `template_date` | Date tag template. | `%Y-%m-%dT%H:%M:%SZ` |
|
||||
| `--exclude-tags` / `exclude_tags` | Comma-separated tags to exclude. | `null` |
|
||||
| `--cover-size` / `cover_size` | Cover size. | `1200` |
|
||||
| `--truncate` / `truncate` | Maximum length of the file/folder names. | `null` |
|
||||
| `--codec-song` / `codec_song` | Song codec. | `aac-legacy` |
|
||||
| `--synced-lyrics-format` / `synced_lyrics_format` | Synced lyrics format. | `lrc` |
|
||||
| `--codec-music-video` / `codec_music_video` | Music video codec. | `h264` |
|
||||
| `--quality-post` / `quality_post` | Post video quality. | `best` |
|
||||
| `--no-config-file`, `-n` / - | Do not use a config file. | `false` |
|
||||
|
||||
|
||||
### Tags variables
|
||||
The following variables can be used in the template folders/files and/or in the `exclude_tags` list:
|
||||
@@ -102,6 +147,10 @@ The following variables can be used in the template folders/files and/or in the
|
||||
* `genre_id`
|
||||
* `lyrics`
|
||||
* `media_type`
|
||||
* `playlist_artist`
|
||||
* `playlist_id`
|
||||
* `playlist_title`
|
||||
* `playlist_track`
|
||||
* `rating`
|
||||
* `storefront`
|
||||
* `title`
|
||||
@@ -110,36 +159,70 @@ The following variables can be used in the template folders/files and/or in the
|
||||
* `track`
|
||||
* `track_total`
|
||||
* `xid`
|
||||
|
||||
### Remux mode
|
||||
|
||||
### Remux modes
|
||||
The following remux modes are available:
|
||||
* `ffmpeg` (default)
|
||||
* Can decrypt and remux songs but can't decrypt music videos by itself
|
||||
* Decryption may not work on older versions of FFmpeg
|
||||
* `ffmpeg`
|
||||
* Can be used without mp4decrypt only for songs and when using legacy song codecs
|
||||
* `mp4box`
|
||||
* Requires mp4decrypt
|
||||
* Doesn't convert closed captions in music videos that have them
|
||||
* Can be obtained from here: https://gpac.wp.imt.fr/downloads
|
||||
|
||||
### Download mode
|
||||
### Download modes
|
||||
The following download modes are available:
|
||||
* `ytdlp` (default)
|
||||
* `ytdlp`
|
||||
* `nm3u8dlre`
|
||||
* Faster than `ytdlp`
|
||||
* Requires FFmpeg
|
||||
* Can be obtained from here: https://github.com/nilaoda/N_m3u8DL-RE/releases
|
||||
|
||||
## Music videos quality
|
||||
Music videos will be downloaded in the highest quality available by default. The available qualities are:
|
||||
* AVC 1080p 10mbps, AAC 256kbps
|
||||
* AVC 1080p 6.5mbps, AAC 256kbps
|
||||
* AVC 720p 4mbps, AAC 256kbps
|
||||
* AVC 576p 2mbps, AAC 256kbps
|
||||
* AVC 480p 1.5mbps, AAC 256kbps
|
||||
* AVC 360p 1mbps, AAC 256kbps
|
||||
|
||||
By enabling the `prefer_hevc` option, music videos will be downloaded in the highest HEVC quality available. The available qualities are:
|
||||
* HEVC 4K 20mbps, AAC 256kbps
|
||||
* HEVC 4K 12mbps, AAC 256kbps
|
||||
### Song codecs
|
||||
The following codecs are available:
|
||||
* `aac-legacy`
|
||||
* `aac-he-legacy`
|
||||
|
||||
Enable `ask_video_format` to select a custom audio/video format.
|
||||
The following codecs are also available, **but are not guaranteed to work**, as currently most (or all) of the songs fails to be downloaded when using them:
|
||||
* `aac`
|
||||
* `aac-he`
|
||||
* `aac-binaural`
|
||||
* `aac-downmix`
|
||||
* `aac-he-binaural`
|
||||
* `aac-he-downmix`
|
||||
* `atmos`
|
||||
* `ac3`
|
||||
* `alac`
|
||||
* `ask`
|
||||
* When using this option, Gamdl will ask you which codec from this list to use that is available for the song.
|
||||
|
||||
### Music videos codecs
|
||||
The following codecs are available:
|
||||
* `h264` (up to 1080p, with AAC 256kbps)
|
||||
* `h265` (up to 2160p, with AAC 256kpbs)
|
||||
* `ask`
|
||||
* When using this option, Gamdl will ask you which audio and video codec to use that is available for the music video.
|
||||
|
||||
### 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.
|
||||
|
||||
### Synced lyrics formats
|
||||
The following synced lyrics formats are available:
|
||||
* `lrc`
|
||||
* `srt`
|
||||
* `ttml`
|
||||
* Native format for Apple Music synced lyrics.
|
||||
* Highly unsupported by most media players.
|
||||
|
||||
### Cover formats
|
||||
The following cover formats are available:
|
||||
* `jpg`
|
||||
* `png`
|
||||
* `raw`
|
||||
* This format gets the raw cover without any processing.
|
||||
* Note that when using this format, the cover image will not be embedded within the files. To address this, you can enable `save_cover` option to save the cover as a separate file.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
__version__ = "1.9.7"
|
||||
__version__ = "2.3.7"
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import re
|
||||
import time
|
||||
import typing
|
||||
from http.cookiejar import MozillaCookieJar
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class AppleMusicApi:
|
||||
APPLE_MUSIC_HOMEPAGE_URL = "https://beta.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"
|
||||
)
|
||||
LICENSE_API_URL = "https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/acquireWebPlaybackLicense"
|
||||
WAIT_TIME = 2
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cookies_path: Path | None = Path("./cookies.txt"),
|
||||
storefront: None | str = None,
|
||||
language: str = "en-US",
|
||||
):
|
||||
self.cookies_path = cookies_path
|
||||
self.storefront = storefront
|
||||
self.language = language
|
||||
self._set_session()
|
||||
|
||||
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",
|
||||
"origin": self.APPLE_MUSIC_HOMEPAGE_URL,
|
||||
}
|
||||
)
|
||||
home_page = self.session.get(self.APPLE_MUSIC_HOMEPAGE_URL).text
|
||||
index_js_uri = re.search(
|
||||
r"/(assets/index-legacy-[^/]+\.js)",
|
||||
home_page,
|
||||
).group(1)
|
||||
index_js_page = self.session.get(
|
||||
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}"
|
||||
)
|
||||
|
||||
def _check_amp_api_response(self, response: requests.Response):
|
||||
try:
|
||||
response.raise_for_status()
|
||||
response_dict = response.json()
|
||||
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)
|
||||
|
||||
def get_artist(
|
||||
self,
|
||||
artist_id: str,
|
||||
include: str = "albums,music-videos",
|
||||
limit: int = 100,
|
||||
fetch_all: bool = True,
|
||||
) -> dict:
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/artists/{artist_id}",
|
||||
params={
|
||||
"include": include,
|
||||
**{f"limit[{_include}]": limit for _include in include.split(",")},
|
||||
},
|
||||
)
|
||||
self._check_amp_api_response(response)
|
||||
artist = response.json()["data"][0]
|
||||
if fetch_all:
|
||||
for _include in include.split(","):
|
||||
for additional_data in self._extend_api_data(
|
||||
artist["relationships"][_include],
|
||||
limit,
|
||||
):
|
||||
artist["relationships"][_include]["data"].extend(additional_data)
|
||||
return artist
|
||||
|
||||
def get_song(
|
||||
self,
|
||||
song_id: str,
|
||||
extend: str = "extendedAssetUrls",
|
||||
include: str = "lyrics,albums",
|
||||
) -> dict:
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/songs/{song_id}",
|
||||
params={
|
||||
"include": include,
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
self._check_amp_api_response(response)
|
||||
return response.json()["data"][0]
|
||||
|
||||
def get_music_video(
|
||||
self,
|
||||
music_video_id: str,
|
||||
include: str = "albums",
|
||||
) -> dict:
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/music-videos/{music_video_id}",
|
||||
params={
|
||||
"include": include,
|
||||
},
|
||||
)
|
||||
self._check_amp_api_response(response)
|
||||
return response.json()["data"][0]
|
||||
|
||||
def get_post(
|
||||
self,
|
||||
post_id: str,
|
||||
) -> dict:
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/uploaded-videos/{post_id}"
|
||||
)
|
||||
self._check_amp_api_response(response)
|
||||
return response.json()["data"][0]
|
||||
|
||||
@functools.lru_cache()
|
||||
def get_album(
|
||||
self,
|
||||
album_id: str,
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict:
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/albums/{album_id}",
|
||||
params={
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
self._check_amp_api_response(response)
|
||||
return response.json()["data"][0]
|
||||
|
||||
def get_playlist(
|
||||
self,
|
||||
playlist_id: str,
|
||||
limit_tracks: int = 300,
|
||||
extend: str = "extendedAssetUrls",
|
||||
fetch_all: bool = True,
|
||||
) -> dict:
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/playlists/{playlist_id}",
|
||||
params={
|
||||
"extend": extend,
|
||||
"limit[tracks]": limit_tracks,
|
||||
},
|
||||
)
|
||||
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,
|
||||
):
|
||||
playlist["relationships"]["tracks"]["data"].extend(additional_data)
|
||||
return playlist
|
||||
|
||||
def search(
|
||||
self,
|
||||
term: str,
|
||||
types: str = "songs,albums,artists,playlists",
|
||||
limit: int = 25,
|
||||
offset: int = 0,
|
||||
) -> dict:
|
||||
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/search",
|
||||
params={
|
||||
"term": term,
|
||||
"types": types,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
},
|
||||
)
|
||||
self._check_amp_api_response(response)
|
||||
return response.json()["results"]
|
||||
|
||||
def _extend_api_data(
|
||||
self,
|
||||
api_response: dict,
|
||||
limit: int,
|
||||
) -> 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)
|
||||
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:
|
||||
response = self.session.get(
|
||||
self.AMP_API_URL + next_uri,
|
||||
params={
|
||||
"limit": limit,
|
||||
},
|
||||
)
|
||||
self._check_amp_api_response(response)
|
||||
return response.json()
|
||||
|
||||
def get_webplayback(
|
||||
self,
|
||||
track_id: str,
|
||||
) -> dict:
|
||||
response = self.session.post(
|
||||
self.WEBPLAYBACK_API_URL,
|
||||
json={
|
||||
"salableAdamId": track_id,
|
||||
"language": self.language,
|
||||
},
|
||||
)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
response_dict = response.json()
|
||||
webplayback = response_dict.get("songList")
|
||||
assert webplayback
|
||||
except (
|
||||
requests.HTTPError,
|
||||
requests.exceptions.JSONDecodeError,
|
||||
AssertionError,
|
||||
):
|
||||
self._raise_response_exception(response)
|
||||
return webplayback[0]
|
||||
|
||||
def get_widevine_license(
|
||||
self,
|
||||
track_id: str,
|
||||
track_uri: str,
|
||||
challenge: str,
|
||||
) -> str:
|
||||
response = self.session.post(
|
||||
self.LICENSE_API_URL,
|
||||
json={
|
||||
"challenge": challenge,
|
||||
"key-system": "com.widevine.alpha",
|
||||
"uri": track_uri,
|
||||
"adamId": track_id,
|
||||
"isLibrary": False,
|
||||
"user-initiated": True,
|
||||
},
|
||||
)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
response_dict = response.json()
|
||||
widevine_license = response_dict.get("license")
|
||||
assert widevine_license
|
||||
except (
|
||||
requests.HTTPError,
|
||||
requests.exceptions.JSONDecodeError,
|
||||
AssertionError,
|
||||
):
|
||||
self._raise_response_exception(response)
|
||||
return widevine_license
|
||||
+626
-430
File diff suppressed because it is too large
Load Diff
+39
-2
@@ -1,3 +1,5 @@
|
||||
from gamdl.enums import MusicVideoCodec, SongCodec, SyncedLyricsFormat
|
||||
|
||||
STOREFRONT_IDS = {
|
||||
"AE": "143481-2,32",
|
||||
"AG": "143540-2,32",
|
||||
@@ -182,13 +184,48 @@ MP4_TAGS_MAP = {
|
||||
"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",
|
||||
}
|
||||
|
||||
|
||||
IMAGE_FILE_EXTENSION_MAP = {
|
||||
"jpeg": ".jpg",
|
||||
"tiff": ".tif",
|
||||
}
|
||||
|
||||
|
||||
EXCLUDED_CONFIG_FILE_PARAMS = (
|
||||
"urls",
|
||||
"config_location",
|
||||
"url_txt",
|
||||
"config_path",
|
||||
"read_urls_as_txt",
|
||||
"no_config_file",
|
||||
"version",
|
||||
"help",
|
||||
)
|
||||
|
||||
X_NOT_FOUND_STRING = '{} not found at "{}"'
|
||||
|
||||
LEGACY_CODECS = [
|
||||
SongCodec.AAC_LEGACY,
|
||||
SongCodec.AAC_HE_LEGACY,
|
||||
]
|
||||
|
||||
+427
-533
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,307 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
|
||||
|
||||
class DownloaderMusicVideo:
|
||||
MP4_FORMAT_CODECS = ["hvc1", "audio-atmos", "audio-ec3"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
downloader: Downloader,
|
||||
codec: MusicVideoCodec = MusicVideoCodec.H264,
|
||||
):
|
||||
self.downloader = downloader
|
||||
self.codec = codec
|
||||
|
||||
def get_stream_url_from_webplayback(self, webplayback: dict) -> str:
|
||||
return webplayback["hls-playlist-url"]
|
||||
|
||||
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"})
|
||||
return url_parts._replace(
|
||||
query=urllib.parse.urlencode(query, doseq=True)
|
||||
).geturl()
|
||||
|
||||
def get_m3u8_master_data(self, stream_url_master: str) -> dict:
|
||||
return m3u8.load(stream_url_master).data
|
||||
|
||||
def get_playlist_video(
|
||||
self,
|
||||
playlists: list[dict],
|
||||
) -> dict:
|
||||
playlists_filtered = [
|
||||
playlist
|
||||
for playlist in playlists
|
||||
if playlist["stream_info"]["codecs"].startswith(
|
||||
MUSIC_VIDEO_CODEC_MAP[self.codec]
|
||||
)
|
||||
]
|
||||
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]
|
||||
|
||||
def get_playlist_video_from_user(
|
||||
self,
|
||||
playlists: list[dict],
|
||||
) -> dict:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
playlist["stream_info"]["codecs"][:4],
|
||||
playlist["stream_info"]["resolution"],
|
||||
str(playlist["stream_info"]["bandwidth"]),
|
||||
]
|
||||
),
|
||||
value=playlist,
|
||||
)
|
||||
for playlist in playlists
|
||||
]
|
||||
selected = inquirer.select(
|
||||
message="Select which video codec to download: (Codec | Resolution | Bitrate)",
|
||||
choices=choices,
|
||||
).execute()
|
||||
return selected
|
||||
|
||||
def get_playlist_audio(
|
||||
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],
|
||||
) -> dict:
|
||||
choices = [
|
||||
Choice(
|
||||
name=playlist["group_id"],
|
||||
value=playlist,
|
||||
)
|
||||
for playlist in playlists
|
||||
if playlist.get("uri")
|
||||
]
|
||||
selected = inquirer.select(
|
||||
message="Select which audio codec to download:",
|
||||
choices=choices,
|
||||
).execute()
|
||||
return selected
|
||||
|
||||
def get_pssh(self, m3u8_data: dict):
|
||||
return next(
|
||||
(
|
||||
key
|
||||
for key in m3u8_data["keys"]
|
||||
if key["keyformat"] == "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
|
||||
),
|
||||
None,
|
||||
)["uri"]
|
||||
|
||||
def get_stream_info_video(self, m3u8_master_data: dict) -> StreamInfo:
|
||||
stream_info = StreamInfo()
|
||||
if self.codec != MusicVideoCodec.ASK:
|
||||
playlist = self.get_playlist_video(m3u8_master_data["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)
|
||||
return stream_info
|
||||
|
||||
def get_stream_info_audio(self, m3u8_master_data: dict) -> StreamInfo:
|
||||
stream_info = StreamInfo()
|
||||
if self.codec != MusicVideoCodec.ASK:
|
||||
playlist = self.get_playlist_audio(m3u8_master_data["media"])
|
||||
else:
|
||||
playlist = self.get_playlist_audio_from_user(m3u8_master_data["media"])
|
||||
stream_info.stream_url = playlist["uri"]
|
||||
stream_info.codec = playlist["group_id"]
|
||||
m3u8_data = m3u8.load(stream_info.stream_url).data
|
||||
stream_info.pssh = self.get_pssh(m3u8_data)
|
||||
return stream_info
|
||||
|
||||
def get_music_video_id_alt(self, metadata: dict) -> str:
|
||||
return metadata["attributes"]["url"].split("/")[-1].split("?")[0]
|
||||
|
||||
def get_tags(
|
||||
self,
|
||||
id_alt: str,
|
||||
itunes_page: dict,
|
||||
metadata: dict,
|
||||
):
|
||||
metadata_itunes = self.downloader.itunes_api.get_resource(id_alt)
|
||||
tags = {
|
||||
"artist": metadata_itunes[0]["artistName"],
|
||||
"artist_id": int(metadata_itunes[0]["artistId"]),
|
||||
"copyright": itunes_page.get("copyright"),
|
||||
"date": self.downloader.sanitize_date(metadata_itunes[0]["releaseDate"]),
|
||||
"genre": metadata_itunes[0]["primaryGenreName"],
|
||||
"genre_id": int(itunes_page["genres"][0]["genreId"]),
|
||||
"media_type": 6,
|
||||
"storefront": int(self.downloader.itunes_api.storefront_id.split("-")[0]),
|
||||
"title": metadata_itunes[0]["trackCensoredName"],
|
||||
"title_id": int(metadata["id"]),
|
||||
}
|
||||
if metadata_itunes[0]["trackExplicitness"] == "notExplicit":
|
||||
tags["rating"] = 0
|
||||
elif metadata_itunes[0]["trackExplicitness"] == "explicit":
|
||||
tags["rating"] = 1
|
||||
else:
|
||||
tags["rating"] = 2
|
||||
if len(metadata_itunes) > 1:
|
||||
album = self.downloader.apple_music_api.get_album(
|
||||
itunes_page["collectionId"]
|
||||
)
|
||||
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):
|
||||
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_audio: Path,
|
||||
decrypted_path_video: Path,
|
||||
fixed_path: Path,
|
||||
):
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.mp4box_path_full,
|
||||
"-quiet",
|
||||
"-add",
|
||||
decrypted_path_audio,
|
||||
"-add",
|
||||
decrypted_path_video,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-keep-utc",
|
||||
"-new",
|
||||
fixed_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def remux_ffmpeg(
|
||||
self,
|
||||
decrypted_path_video: Path,
|
||||
decrypte_path_audio: Path,
|
||||
fixed_path: Path,
|
||||
codec_video: str,
|
||||
codec_audio: str,
|
||||
):
|
||||
use_mp4_flag = any(
|
||||
codec_video.startswith(codec) for codec in self.MP4_FORMAT_CODECS
|
||||
) or any(codec_audio.startswith(codec) for codec in self.MP4_FORMAT_CODECS)
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.ffmpeg_path_full,
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-i",
|
||||
decrypted_path_video,
|
||||
"-i",
|
||||
decrypte_path_audio,
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-f",
|
||||
"mp4" if use_mp4_flag else "ipod",
|
||||
"-c",
|
||||
"copy",
|
||||
"-c:s",
|
||||
"mov_text",
|
||||
fixed_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def remux(
|
||||
self,
|
||||
decrypted_path_video: Path,
|
||||
decrypted_path_audio: Path,
|
||||
remuxed_path: Path,
|
||||
codec_video: str,
|
||||
codec_audio: str,
|
||||
):
|
||||
if self.downloader.remux_mode == RemuxMode.MP4BOX:
|
||||
self.remux_mp4box(
|
||||
decrypted_path_audio,
|
||||
decrypted_path_video,
|
||||
remuxed_path,
|
||||
)
|
||||
elif self.downloader.remux_mode == RemuxMode.FFMPEG:
|
||||
self.remux_ffmpeg(
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
remuxed_path,
|
||||
codec_video,
|
||||
codec_audio,
|
||||
)
|
||||
|
||||
def get_cover_path(self, final_path: Path, file_extension: str) -> Path:
|
||||
return final_path.with_suffix(file_extension)
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
|
||||
from .downloader import Downloader
|
||||
from .enums import PostQuality
|
||||
|
||||
|
||||
class DownloaderPost:
|
||||
QUALITY_RANK = [
|
||||
"1080pHdVideo",
|
||||
"720pHdVideo",
|
||||
"sdVideoWithPlusAudio",
|
||||
"sdVideo",
|
||||
"sd480pVideo",
|
||||
"provisionalUploadVideo",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
downloader: Downloader,
|
||||
quality: PostQuality = PostQuality.BEST,
|
||||
):
|
||||
self.downloader = downloader
|
||||
self.quality = quality
|
||||
|
||||
def get_stream_url_best(self, metadata: dict) -> str:
|
||||
best_quality = next(
|
||||
(
|
||||
quality
|
||||
for quality in self.QUALITY_RANK
|
||||
if metadata["attributes"]["assetTokens"].get(quality)
|
||||
),
|
||||
None,
|
||||
)
|
||||
return metadata["attributes"]["assetTokens"][best_quality]
|
||||
|
||||
def get_stream_url_from_user(self, metadata: dict) -> str:
|
||||
qualities = list(metadata["attributes"]["assetTokens"].keys())
|
||||
choices = [
|
||||
Choice(
|
||||
name=quality,
|
||||
value=quality,
|
||||
)
|
||||
for quality in qualities
|
||||
]
|
||||
selected = inquirer.select(
|
||||
message="Select which quality to download:",
|
||||
choices=choices,
|
||||
).execute()
|
||||
return metadata["attributes"]["assetTokens"][selected]
|
||||
|
||||
def get_stream_url(self, metadata: dict) -> str:
|
||||
if self.quality == PostQuality.BEST:
|
||||
stream_url = self.get_stream_url_best(metadata)
|
||||
elif self.quality == PostQuality.ASK:
|
||||
stream_url = self.get_stream_url_from_user(metadata)
|
||||
return stream_url
|
||||
|
||||
def get_tags(self, metadata: dict) -> list:
|
||||
attributes = metadata["attributes"]
|
||||
return {
|
||||
"artist": attributes["artistName"],
|
||||
"date": self.downloader.sanitize_date(attributes["uploadDate"]),
|
||||
"title": attributes["name"],
|
||||
"title_id": int(metadata["id"]),
|
||||
"storefront": int(self.downloader.itunes_api.storefront_id.split("-")[0]),
|
||||
}
|
||||
|
||||
def get_post_temp_path(self, track_id: str) -> Path:
|
||||
return self.downloader.temp_path / f"{track_id}_temp.m4v"
|
||||
@@ -0,0 +1,373 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from xml.dom import minidom
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import m3u8
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
|
||||
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
|
||||
|
||||
|
||||
class DownloaderSong:
|
||||
DEFAULT_DECRYPTION_KEY = "32b8ade1769e26b1ffb8986352793fc6"
|
||||
MP4_FORMAT_CODECS = ["ec-3"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
downloader: Downloader,
|
||||
codec: SongCodec = SongCodec.AAC_LEGACY,
|
||||
synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC,
|
||||
):
|
||||
self.downloader = downloader
|
||||
self.codec = codec
|
||||
self.synced_lyrics_format = synced_lyrics_format
|
||||
|
||||
def get_drm_infos(self, m3u8_data: dict) -> dict:
|
||||
drm_info_raw = next(
|
||||
(
|
||||
session_data
|
||||
for session_data in m3u8_data["session_data"]
|
||||
if session_data["data_id"] == "com.apple.hls.AudioSessionKeyInfo"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not drm_info_raw:
|
||||
return None
|
||||
return json.loads(base64.b64decode(drm_info_raw["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_playlist_from_codec(self, m3u8_data: dict) -> dict | None:
|
||||
m3u8_master_playlists = [
|
||||
playlist
|
||||
for playlist in m3u8_data["playlists"]
|
||||
if re.fullmatch(
|
||||
SONG_CODEC_REGEX_MAP[self.codec], playlist["stream_info"]["audio"]
|
||||
)
|
||||
]
|
||||
if not m3u8_master_playlists:
|
||||
return None
|
||||
m3u8_master_playlists.sort(key=lambda x: x["stream_info"]["average_bandwidth"])
|
||||
return m3u8_master_playlists[-1]
|
||||
|
||||
def get_playlist_from_user(self, m3u8_data: dict) -> dict | None:
|
||||
m3u8_master_playlists = [playlist for playlist in m3u8_data["playlists"]]
|
||||
choices = [
|
||||
Choice(
|
||||
name=playlist["stream_info"]["audio"],
|
||||
value=playlist,
|
||||
)
|
||||
for playlist in m3u8_master_playlists
|
||||
]
|
||||
selected = inquirer.select(
|
||||
message="Select which codec to download:",
|
||||
choices=choices,
|
||||
).execute()
|
||||
return selected
|
||||
|
||||
def get_pssh(
|
||||
self,
|
||||
drm_infos: dict,
|
||||
drm_ids: list,
|
||||
) -> 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"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not drm_info:
|
||||
return None
|
||||
return drm_info["urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"]["URI"]
|
||||
|
||||
def get_stream_info(self, track_metadata: dict) -> StreamInfo:
|
||||
m3u8_url = track_metadata["attributes"]["extendedAssetUrls"].get("enhancedHls")
|
||||
if not m3u8_url:
|
||||
return StreamInfo()
|
||||
return self._get_stream_info(m3u8_url)
|
||||
|
||||
def _get_stream_info(self, m3u8_url: str) -> StreamInfo:
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
def parse_datetime_obj_from_timestamp_ttml(
|
||||
timestamp_ttml: str,
|
||||
) -> datetime.datetime:
|
||||
mins_secs_ms = re.findall(r"\d+", timestamp_ttml)
|
||||
ms, secs, mins = 0, 0, 0
|
||||
if len(mins_secs_ms) == 2 and ":" in timestamp_ttml:
|
||||
secs, mins = int(mins_secs_ms[-1]), int(mins_secs_ms[-2])
|
||||
elif len(mins_secs_ms) == 1:
|
||||
ms = int(mins_secs_ms[-1])
|
||||
else:
|
||||
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))
|
||||
|
||||
def get_lyrics_synced_timestamp_lrc(self, timestamp_ttml: str) -> str:
|
||||
datetime_obj = self.parse_datetime_obj_from_timestamp_ttml(timestamp_ttml)
|
||||
ms_new = datetime_obj.strftime("%f")[:-3]
|
||||
if int(ms_new[-1]) >= 5:
|
||||
ms = int(f"{int(ms_new[:2]) + 1}") * 10
|
||||
datetime_obj += datetime.timedelta(milliseconds=ms) - datetime.timedelta(
|
||||
microseconds=datetime_obj.microsecond
|
||||
)
|
||||
return datetime_obj.strftime("%M:%S.%f")[:-4]
|
||||
|
||||
def get_lyrics_synced_timestamp_srt(self, timestamp_ttml: str) -> str:
|
||||
datetime_obj = self.parse_datetime_obj_from_timestamp_ttml(timestamp_ttml)
|
||||
return datetime_obj.strftime("00:%M:%S,%f")[:-3]
|
||||
|
||||
def get_lyrics_synced_line_lrc(self, timestamp_ttml: str, text: str) -> str:
|
||||
return f"[{self.get_lyrics_synced_timestamp_lrc(timestamp_ttml)}]{text}"
|
||||
|
||||
def get_lyrics_synced_line_srt(
|
||||
self,
|
||||
index: int,
|
||||
timestamp_ttml_start: str,
|
||||
timestamp_ttml_end: str,
|
||||
text: str,
|
||||
) -> str:
|
||||
timestamp_srt_start = self.get_lyrics_synced_timestamp_srt(timestamp_ttml_start)
|
||||
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:
|
||||
lyrics = Lyrics()
|
||||
if not track_metadata["attributes"]["hasLyrics"]:
|
||||
return lyrics
|
||||
elif track_metadata.get("relationships") is None:
|
||||
track_metadata = self.downloader.apple_music_api.get_song(
|
||||
track_metadata["id"]
|
||||
)
|
||||
if (
|
||||
track_metadata["relationships"].get("lyrics")
|
||||
and track_metadata["relationships"]["lyrics"].get("data")
|
||||
and track_metadata["relationships"]["lyrics"]["data"][0].get("attributes")
|
||||
):
|
||||
lyrics = self._get_lyrics(
|
||||
track_metadata["relationships"]["lyrics"]["data"][0]["attributes"][
|
||||
"ttml"
|
||||
]
|
||||
)
|
||||
return lyrics
|
||||
|
||||
def _get_lyrics(self, lyrics_ttml: str) -> Lyrics:
|
||||
lyrics = Lyrics("", "")
|
||||
lyrics_ttml_et = ElementTree.fromstring(lyrics_ttml)
|
||||
index = 1
|
||||
for div in lyrics_ttml_et.iter("{http://www.w3.org/ns/ttml}div"):
|
||||
for p in div.iter("{http://www.w3.org/ns/ttml}p"):
|
||||
if p.text is not None:
|
||||
lyrics.unsynced += p.text + "\n"
|
||||
if p.attrib.get("begin"):
|
||||
if self.synced_lyrics_format == SyncedLyricsFormat.LRC:
|
||||
lyrics.synced += f"{self.get_lyrics_synced_line_lrc(p.attrib.get('begin'), p.text)}"
|
||||
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
|
||||
|
||||
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
|
||||
),
|
||||
"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")
|
||||
else None
|
||||
),
|
||||
"disc": tags_raw["discNumber"],
|
||||
"disc_total": tags_raw["discCount"],
|
||||
"gapless": tags_raw["gapless"],
|
||||
"genre": tags_raw.get("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"),
|
||||
}
|
||||
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:
|
||||
while data := file.read(4096):
|
||||
pos = file.tell()
|
||||
i = 0
|
||||
while tenc := max(0, data.find(b"tenc", i)):
|
||||
kid = tenc + 12
|
||||
file.seek(max(0, pos - 4096) + kid, 0)
|
||||
file.write(bytes.fromhex(f"{count:032}"))
|
||||
count += 1
|
||||
i = kid + 1
|
||||
file.seek(pos, 0)
|
||||
|
||||
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"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 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,
|
||||
decrypted_path: Path,
|
||||
remuxed_path: Path,
|
||||
codec: str,
|
||||
):
|
||||
use_mp4_format = any(
|
||||
codec.startswith(possible_codec)
|
||||
for possible_codec in self.MP4_FORMAT_CODECS
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.ffmpeg_path_full,
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-i",
|
||||
decrypted_path,
|
||||
"-c",
|
||||
"copy",
|
||||
"-f",
|
||||
"mp4" if use_mp4_format else "ipod",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
remuxed_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def get_lyrics_synced_path(self, final_path: Path) -> Path:
|
||||
return final_path.with_suffix(
|
||||
SYNCED_LYRICS_FILE_EXTENSION_MAP[self.synced_lyrics_format]
|
||||
)
|
||||
|
||||
def get_cover_path(self, final_path: Path, file_extension: str) -> Path:
|
||||
return final_path.parent / ("Cover" + file_extension)
|
||||
|
||||
def save_lyrics_synced(self, lyrics_synced_path: Path, lyrics_synced: str):
|
||||
lyrics_synced_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lyrics_synced_path.write_text(lyrics_synced, encoding="utf8")
|
||||
@@ -0,0 +1,127 @@
|
||||
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:
|
||||
try:
|
||||
widevine_pssh_data = WidevinePsshData()
|
||||
widevine_pssh_data.algorithm = 1
|
||||
widevine_pssh_data.key_ids.append(base64.b64decode(pssh.split(",")[1]))
|
||||
pssh_obj = PSSH(widevine_pssh_data.SerializeToString())
|
||||
cdm_session = self.downloader.cdm.open()
|
||||
challenge = base64.b64encode(
|
||||
self.downloader.cdm.get_license_challenge(cdm_session, pssh_obj)
|
||||
).decode()
|
||||
license = self.downloader.apple_music_api.get_widevine_license(
|
||||
track_id,
|
||||
pssh,
|
||||
challenge,
|
||||
)
|
||||
self.downloader.cdm.parse_license(cdm_session, license)
|
||||
decryption_key = next(
|
||||
i
|
||||
for i in self.downloader.cdm.get_keys(cdm_session)
|
||||
if i.type == "CONTENT"
|
||||
).key.hex()
|
||||
finally:
|
||||
self.downloader.cdm.close(cdm_session)
|
||||
return decryption_key
|
||||
|
||||
def decrypt(
|
||||
self,
|
||||
encrypted_path: Path,
|
||||
decrypted_path: Path,
|
||||
decryption_key: str,
|
||||
):
|
||||
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)
|
||||
@@ -0,0 +1,49 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class DownloadMode(Enum):
|
||||
YTDLP = "ytdlp"
|
||||
NM3U8DLRE = "nm3u8dlre"
|
||||
|
||||
|
||||
class RemuxMode(Enum):
|
||||
FFMPEG = "ffmpeg"
|
||||
MP4BOX = "mp4box"
|
||||
|
||||
|
||||
class SongCodec(Enum):
|
||||
AAC_LEGACY = "aac-legacy"
|
||||
AAC_HE_LEGACY = "aac-he-legacy"
|
||||
AAC = "aac"
|
||||
AAC_HE = "aac-he"
|
||||
AAC_BINAURAL = "aac-binaural"
|
||||
AAC_DOWNMIX = "aac-downmix"
|
||||
AAC_HE_BINAURAL = "aac-he-binaural"
|
||||
AAC_HE_DOWNMIX = "aac-he-downmix"
|
||||
ATMOS = "atmos"
|
||||
AC3 = "ac3"
|
||||
ALAC = "alac"
|
||||
ASK = "ask"
|
||||
|
||||
|
||||
class SyncedLyricsFormat(Enum):
|
||||
LRC = "lrc"
|
||||
SRT = "srt"
|
||||
TTML = "ttml"
|
||||
|
||||
|
||||
class MusicVideoCodec(Enum):
|
||||
H264 = "h264"
|
||||
H265 = "h265"
|
||||
ASK = "ask"
|
||||
|
||||
|
||||
class PostQuality(Enum):
|
||||
BEST = "best"
|
||||
ASK = "ask"
|
||||
|
||||
|
||||
class CoverFormat(Enum):
|
||||
JPG = "jpg"
|
||||
PNG = "png"
|
||||
RAW = "raw"
|
||||
@@ -0,0 +1 @@
|
||||
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=="""
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
|
||||
import requests
|
||||
|
||||
from .apple_music_api import AppleMusicApi
|
||||
from .constants import STOREFRONT_IDS
|
||||
|
||||
|
||||
class ItunesApi:
|
||||
ITUNES_LOOKUP_API_URL = "https://itunes.apple.com/lookup"
|
||||
ITUNES_PAGE_API_URL = "https://music.apple.com"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
storefront: str = "us",
|
||||
language: str = "en-US",
|
||||
):
|
||||
self.storefront = storefront
|
||||
self.language = language
|
||||
self._setup_session()
|
||||
|
||||
def _setup_session(self):
|
||||
try:
|
||||
self.storefront_id = STOREFRONT_IDS[self.storefront.upper()]
|
||||
except KeyError:
|
||||
raise Exception(f"No storefront id for {self.storefront}")
|
||||
self.session = requests.Session()
|
||||
self.session.params = {
|
||||
"country": self.storefront,
|
||||
"lang": self.language,
|
||||
}
|
||||
self.session.headers = {
|
||||
"X-Apple-Store-Front": f"{self.storefront_id} t:music31",
|
||||
}
|
||||
|
||||
@functools.lru_cache()
|
||||
def get_resource(
|
||||
self,
|
||||
resource_id: str,
|
||||
entity: str = "album",
|
||||
) -> dict:
|
||||
response = self.session.get(
|
||||
self.ITUNES_LOOKUP_API_URL,
|
||||
params={
|
||||
"id": resource_id,
|
||||
"entity": entity,
|
||||
},
|
||||
)
|
||||
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
|
||||
|
||||
def get_itunes_page(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
) -> dict:
|
||||
response = self.session.get(
|
||||
f"{self.ITUNES_PAGE_API_URL}/{resource_type}/{resource_id}"
|
||||
)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
response_dict = response.json()
|
||||
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)
|
||||
return itunes_page
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class UrlInfo:
|
||||
storefront: str = None
|
||||
type: str = None
|
||||
id: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadQueue:
|
||||
playlist_attributes: dict = None
|
||||
tracks_metadata: list[dict] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Lyrics:
|
||||
synced: str = None
|
||||
unsynced: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamInfo:
|
||||
stream_url: str = None
|
||||
pssh: str = None
|
||||
codec: str = None
|
||||
+6
-4
@@ -1,14 +1,16 @@
|
||||
[project]
|
||||
name = "gamdl"
|
||||
description = "Download Apple Music songs/music videos/albums/playlists"
|
||||
requires-python = ">=3.7"
|
||||
authors = [{name = "glomatico"}]
|
||||
description = "A Python CLI app for downloading Apple Music songs/music videos/posts."
|
||||
requires-python = ">=3.8"
|
||||
authors = [{ name = "glomatico" }]
|
||||
dependencies = [
|
||||
"click",
|
||||
"inquirerpy",
|
||||
"m3u8",
|
||||
"pillow",
|
||||
"pywidevine",
|
||||
"pyyaml",
|
||||
"yt-dlp"
|
||||
"yt-dlp",
|
||||
]
|
||||
readme = "README.md"
|
||||
dynamic = ["version"]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
click
|
||||
inquirerpy
|
||||
m3u8
|
||||
pillow
|
||||
pywidevine
|
||||
pyyaml
|
||||
yt-dlp
|
||||
|
||||
Reference in New Issue
Block a user