mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-13 20:25:13 +03:00
Compare commits
269 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 1a5dd94c1c | |||
| 17e930cfbd | |||
| df6b138d30 | |||
| c5271f6d99 | |||
| 4bc0938eea | |||
| c3285ac3cd | |||
| b0e2e15702 | |||
| 1c94d04066 | |||
| 13bf369d26 | |||
| 91e8111d72 | |||
| af1a356322 | |||
| 8ad83e1f82 | |||
| 0f539115ad | |||
| 70139e9eaa | |||
| 56c77a0447 | |||
| d9d7ddf3d7 | |||
| cd929a0626 | |||
| 366698fa86 | |||
| 99e8c30826 | |||
| 20f306b026 | |||
| 014f55a994 | |||
| 813b119e5a | |||
| 186209f99a | |||
| beb2e10349 | |||
| 0203ec4535 | |||
| 6cdea55eb1 | |||
| efc5387785 | |||
| b52b2f050c | |||
| 8926052751 | |||
| 72df094194 | |||
| 8bc369481c | |||
| b1f04ab0ce | |||
| 2e34754303 | |||
| a464de8f18 | |||
| 7e7fbc5a38 | |||
| 06ffa8a2ca | |||
| 12cf08d489 | |||
| 4ecf5fc6f4 | |||
| 44de089522 | |||
| 9a4fdf1874 | |||
| 8180a850fd | |||
| 2b2fe89dcc | |||
| fca7ee5c3b | |||
| 2d510a7703 | |||
| 1cd5221304 | |||
| 788270c2ab | |||
| 3dc9132a60 | |||
| f5a8180fcc | |||
| ebc07082d5 | |||
| 6f96166cf0 | |||
| 9358a8b760 | |||
| bea54a73cb | |||
| 85f930b298 | |||
| 44cfa36f9a | |||
| fd60260e6c | |||
| 919c9a66ec | |||
| f1d609e8d2 | |||
| e91fa202f0 | |||
| a181f53f48 | |||
| f4cb9a9c4e | |||
| b3a328f1a2 | |||
| 18bbb3418c | |||
| d3629a6846 | |||
| 8a4d6b33ec | |||
| 24858f4ca8 | |||
| 1b0b00cf08 | |||
| 8b6b81466b | |||
| 752681d3ff | |||
| 4dc9a25753 | |||
| 9303722eab | |||
| 153727c625 | |||
| aa3a61bca3 | |||
| aaf2e9181f | |||
| 6d598cc294 | |||
| 0a6c7d78c8 | |||
| cdde615186 | |||
| 631650824f | |||
| a11a937b89 | |||
| 688816125b | |||
| 36395f226f | |||
| 15b5342707 | |||
| c8bda637fe | |||
| 774df56091 | |||
| 157e331a8a | |||
| f0e1d672a5 | |||
| ee434e767e | |||
| 04719f7587 | |||
| dc3c673985 | |||
| f912a19fed | |||
| bd3553048f | |||
| e6cd1a11d5 | |||
| dd9968387e | |||
| 10122146f1 | |||
| 3d17b3f363 | |||
| 4356519dce | |||
| 24bac438b7 | |||
| 9fb05ef89c | |||
| f0966e7b39 | |||
| ae4c2abfe4 | |||
| 37554e8f49 | |||
| aee494f464 | |||
| eddd6f9053 | |||
| 24d9fdf9ee | |||
| a0fef9944e | |||
| e37038e67b | |||
| 52c59f9a17 | |||
| 2f80b9dc65 | |||
| caed322fd0 | |||
| 76396f3fed | |||
| b64cc06641 | |||
| 4e2c54934a | |||
| e76c79d9b4 | |||
| 6dd730c368 | |||
| 2a2403c130 | |||
| fd47acab4f | |||
| 66d8211a16 | |||
| 158f0e9f27 | |||
| 0d9b225fdc | |||
| 254147096a | |||
| 3a10069c76 | |||
| 9e07aee4e6 | |||
| 2c18a285a0 | |||
| c854af5b2c | |||
| f10a4a731b | |||
| 527dd9935a | |||
| 06d5c10725 | |||
| 58a8e3944d | |||
| 4c7e563d4c | |||
| f05dace5c1 | |||
| eb81728475 | |||
| 96c90e1716 | |||
| 7459d95df0 | |||
| b2521e2933 | |||
| f87bee7732 | |||
| 3aa36c1323 | |||
| 7c60b2cd31 | |||
| 68ff155a9e | |||
| 0d41ef0895 | |||
| 575f652813 | |||
| 11db7154a1 | |||
| 8285c41617 | |||
| a531bb4898 |
+3
-1
@@ -1,5 +1,7 @@
|
||||
/*
|
||||
__pycache__
|
||||
!gamdl
|
||||
!requirements.txt
|
||||
!.gitignore
|
||||
!pyproject.toml
|
||||
!README.md
|
||||
!requirements.txt
|
||||
|
||||
@@ -1,83 +1,177 @@
|
||||
# Glomatico's ✨ Apple Music ✨ Downloader
|
||||
A Python script to download Apple Music songs/music videos/albums/playlists.
|
||||
# Glomatico's Apple Music Downloader
|
||||
A Python script to download Apple Music songs/music videos/albums/playlists/post videos.
|
||||
|
||||

|
||||
## 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
|
||||
|
||||
This is a rework of https://github.com/loveyoursupport/AppleMusic-Downloader/tree/661a274d62586b521feec5a7de6bee0e230fdb7d.
|
||||
|
||||
Some new features that I added:
|
||||
* MP4Box for muxing
|
||||
* Tags for music videos
|
||||
* Multiple URLs input
|
||||
* iTunes folder structure
|
||||
* Embedded lyrics and .lrc file
|
||||
* Auto set region
|
||||
* Playlist support
|
||||
* And much more!
|
||||
|
||||
## Setup
|
||||
1. Install Python 3.7 or newer
|
||||
2. Install gamdl with pip
|
||||
```
|
||||
## 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/.
|
||||
|
||||
## Installation
|
||||
1. Install the package `gamdl` using pip
|
||||
```bash
|
||||
pip install gamdl
|
||||
```
|
||||
3. Add MP4Box and mp4decrypt to your PATH
|
||||
* You can get them from here:
|
||||
* MP4Box: https://gpac.wp.imt.fr/downloads/
|
||||
* mp4decrypt: https://www.bento4.com/downloads/
|
||||
4. Export your Apple Music cookies as `cookies.txt` and put it on the same folder that you will run the script
|
||||
* 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.
|
||||
5. Put your L3 Widevine Device Keys (`device_client_id_blob` and `device_private_key` files) on the same folder that you will run the script
|
||||
* You can get your L3 Widevine Device Keys by using Dumper: https://github.com/Diazole/dumper
|
||||
* The generated `private_key.pem` and `client_id.bin` files should be renamed to `device_private_key` and `device_client_id_blob` respectively.
|
||||
6. (optional) Add aria2c to your PATH for faster downloads
|
||||
* You can get it from here: https://github.com/aria2/aria2/releases.
|
||||
2. Place your cookies in the same directory you will run the script from and name it as `cookies.txt`
|
||||
|
||||
## Usage
|
||||
```
|
||||
usage: gamdl [-h] [-u [URLS_TXT]] [-d DEVICE_PATH] [-f FINAL_PATH] [-t TEMP_PATH] [-c COOKIES_LOCATION] [-m]
|
||||
[-p] [-n] [-s] [-e] [-y] [-v]
|
||||
[url ...]
|
||||
* Download a song
|
||||
```bash
|
||||
gamdl "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1626265761?i=1626265765"
|
||||
```
|
||||
* Download an album
|
||||
```bash
|
||||
gamdl "https://music.apple.com/us/album/whenever-you-need-somebody-2022-remaster/1626265761"
|
||||
```
|
||||
|
||||
Download Apple Music songs/music videos/albums/playlists
|
||||
## Configuration
|
||||
You can configure gamdl by using the command line arguments or the config file. The config file is created automatically when you run gamdl for the first time at `~/.gamdl/config.json` on Linux and `%USERPROFILE%\.gamdl\config.json` on Windows. Config file values can be overridden using command line arguments.
|
||||
| Command line argument / Config file key | Description | Default value |
|
||||
| --------------------------------------------------------------- | ------------------------------------------------------------------ | -------------------------------------------- |
|
||||
| `--disable-music-video-skip` / `disable_music_video_skip` | Don't skip downloading music videos in albums/playlists. | `false` |
|
||||
| `--save-cover`, `-s` / `save_cover` | Save cover as a separate file. | `false` |
|
||||
| `--overwrite` / `overwrite` | Overwrite existing files. | `false` |
|
||||
| `--read-urls-as-txt`, `-r` / - | Interpret URLs as paths to text files containing URLs. | `false` |
|
||||
| `--synced-lyrics-only` / `synced_lyrics_only` | Download only the synced lyrics. | `false` |
|
||||
| `--no-synced-lyrics` / `no_synced_lyrics` | Don't download the synced lyrics. | `false` |
|
||||
| `--config-path` / - | Path to config file. | `<home>/.spotify-web-downloader/config.json` |
|
||||
| `--log-level` / `log_level` | Log level. | `INFO` |
|
||||
| `--print-exceptions` / `print_exceptions` | Print exceptions. | `false` |
|
||||
| `--cookies-path`, `-c` / `cookies_path` | Path to .txt cookies file. | `./cookies.txt` |
|
||||
| `--output-path`, `-o` / `output_path` | Path to output directory. | `./Apple Music` |
|
||||
| `--temp-path` / `temp_path` | Path to temporary directory. | `./temp` |
|
||||
| `--wvd-path` / `wvd_path` | Path to .wvd file. | `null` |
|
||||
| `--nm3u8dlre-path` / `nm3u8dlre_path` | Path to N_m3u8DL-RE binary. | `N_m3u8dl-RE` |
|
||||
| `--mp4decrypt-path` / `mp4decrypt_path` | Path to mp4decrypt binary. | `mp4decrypt` |
|
||||
| `--ffmpeg-path` / `ffmpeg_path` | Path to FFmpeg binary. | `ffmpeg` |
|
||||
| `--mp4box-path` / `mp4box_path` | Path to MP4Box binary. | `MP4Box` |
|
||||
| `--download-mode` / `download_mode` | Download mode. | `ytdlp` |
|
||||
| `--remux-mode` / `remux_mode` | Remux mode. | `ffmpeg` |
|
||||
| `--cover-format` / `cover_format` | Cover format. | `jpg` |
|
||||
| `--template-folder-album` / `template_folder_album` | Template folder for tracks that are part of an album. | `{album_artist}/{album}` |
|
||||
| `--template-folder-compilation` / `template_folder_compilation` | Template folder for tracks that are part of a compilation album. | `Compilations/{album}` |
|
||||
| `--template-file-single-disc` / `template_file_single_disc` | Template file for the tracks that are part of a single-disc album. | `{track:02d} {title}` |
|
||||
| `--template-file-multi-disc` / `template_file_multi_disc` | Template file for the tracks that are part of a multi-disc album. | `{disc}-{track:02d} {title}` |
|
||||
| `--template-folder-no-album` / `template_folder_no_album` | Template folder for the tracks that are not part of an album. | `{artist}/Unknown Album` |
|
||||
| `--template-file-no-album` / `template_file_no_album` | Template file for the tracks that are not part of an album. | `{title}` |
|
||||
| `--template-date` / `template_date` | Date tag template. | `%Y-%m-%dT%H:%M:%SZ` |
|
||||
| `--exclude-tags` / `exclude_tags` | Comma-separated tags to exclude. | `null` |
|
||||
| `--cover-size` / `cover_size` | Cover size. | `1200` |
|
||||
| `--truncate` / `truncate` | Maximum length of the file/folder names. | `40` |
|
||||
| `--codec-song` / `codec_song` | Song codec. | `aac-legacy` |
|
||||
| `--synced-lyrics-format` / `synced_lyrics_format` | Synced lyrics format. | `lrc` |
|
||||
| `--codec-music-video` / `codec_music_video` | Music video codec. | `h264-best` |
|
||||
| `--quality-post` / `quality_post` | Post video quality. | `best` |
|
||||
| `--no-config-file`, `-n` / - | Do not use a config file. | `false` |
|
||||
|
||||
positional arguments:
|
||||
url Apple Music song/music video/album/playlist URL(s) (default: None)
|
||||
### 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`
|
||||
|
||||
options:
|
||||
-h, --help show this help message and exit
|
||||
-u [URLS_TXT], --urls-txt [URLS_TXT]
|
||||
Read URLs from a text file (default: None)
|
||||
-d DEVICE_PATH, --device-path DEVICE_PATH
|
||||
Widevine L3 device keys path (default: .)
|
||||
-f FINAL_PATH, --final-path FINAL_PATH
|
||||
Final Path (default: Apple Music)
|
||||
-t TEMP_PATH, --temp-path TEMP_PATH
|
||||
Temp Path (default: temp)
|
||||
-c COOKIES_LOCATION, --cookies-location COOKIES_LOCATION
|
||||
Cookies location (default: cookies.txt)
|
||||
-m, --disable-music-video-skip
|
||||
Disable music video skip on playlists/albums (default: False)
|
||||
-p, --prefer-hevc Prefer HEVC over AVC (default: False)
|
||||
-n, --no-lrc Don't create .lrc file (default: False)
|
||||
-s, --skip-cleanup Skip cleanup (default: False)
|
||||
-e, --print-exceptions
|
||||
Print execeptions (default: False)
|
||||
-y, --print-video-playlist
|
||||
Print Video M3U8 Playlist (default: False)
|
||||
-v, --version show program's version number and exit
|
||||
```
|
||||
### 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
|
||||
|
||||
## Songs/Music Videos quality
|
||||
* Songs:
|
||||
* 256kbps AAC
|
||||
* Music Videos (varies depending on the video):
|
||||
* 4K HEVC 20mbps / AAC 256kbps
|
||||
* 4K HEVC 12mbps / AAC 256kbps
|
||||
* 1080p AVC 10mbps / AAC 256kbps
|
||||
* 1080p AVC 6.5bps / AAC 256kbps
|
||||
* 720p AVC 4mbps / AAC 256kbps
|
||||
* 480p AVC 1.5mbps / AAC 256kbps
|
||||
* 360p AVC 1mbps / AAC 256kbps
|
||||
### Download modes
|
||||
The following download modes are available:
|
||||
* `ytdlp`
|
||||
* `nm3u8dlre`
|
||||
* Faster than `ytdlp`
|
||||
* Requires FFmpeg
|
||||
* Can be obtained from here: https://github.com/nilaoda/N_m3u8DL-RE/releases
|
||||
|
||||
|
||||
### Song codecs
|
||||
The following codecs are available:
|
||||
* `aac-legacy`
|
||||
* `aac-he-legacy`
|
||||
* `aac`
|
||||
* `aac-he`
|
||||
* `aac-binaural`
|
||||
* `aac-downmix`
|
||||
* `aac-he-binaural`
|
||||
* `aac-he-downmix`
|
||||
* `alac`
|
||||
* `atmos`
|
||||
|
||||
**Support for non-legacy codecs are not guaranteed, as most of the songs cannot be decrypted when using non-legacy codecs.**
|
||||
|
||||
### Music videos codecs
|
||||
The following codecs are available:
|
||||
* `h264-best` (with AAC 256kbps, up to 1080p)
|
||||
* `h265-best` (With AAC 256kpbs, up to 2160p)
|
||||
* `ask`
|
||||
* When using this option, the script will ask you which audio and video codec to use.
|
||||
|
||||
### Post videos/extra videos qualities
|
||||
The following qualities are available:
|
||||
* `best` (with AAC 256kbps, up to 1080p)
|
||||
* `ask`
|
||||
* When using this option, the script will ask you which video quality to use.
|
||||
|
||||
Post videos doesn't require remuxing and are limited to `ytdlp` download mode.
|
||||
|
||||
### 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`
|
||||
|
||||
Some videos may include EIA-608 closed captions.
|
||||
|
||||
+1
-172
@@ -1,172 +1 @@
|
||||
import shutil
|
||||
import argparse
|
||||
import traceback
|
||||
from .gamdl import Gamdl
|
||||
|
||||
__version__ = '1.0'
|
||||
|
||||
|
||||
def main():
|
||||
if not shutil.which('mp4decrypt'):
|
||||
raise Exception('mp4decrypt is not on PATH')
|
||||
if not shutil.which('MP4Box'):
|
||||
raise Exception('MP4Box is not on PATH')
|
||||
parser = argparse.ArgumentParser(
|
||||
description = 'Download Apple Music songs/music videos/albums/playlists',
|
||||
formatter_class = argparse.ArgumentDefaultsHelpFormatter
|
||||
)
|
||||
parser.add_argument(
|
||||
'url',
|
||||
help = 'Apple Music song/music video/album/playlist URL(s)',
|
||||
nargs = '*'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-u',
|
||||
'--urls-txt',
|
||||
help = 'Read URLs from a text file',
|
||||
nargs = '?'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-d',
|
||||
'--device-path',
|
||||
default = '.',
|
||||
help = 'Widevine L3 device keys path'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-f',
|
||||
'--final-path',
|
||||
default = 'Apple Music',
|
||||
help = 'Final Path'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-t',
|
||||
'--temp-path',
|
||||
default = 'temp',
|
||||
help = 'Temp Path'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-c',
|
||||
'--cookies-location',
|
||||
default = 'cookies.txt',
|
||||
help = 'Cookies location'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-m',
|
||||
'--disable-music-video-skip',
|
||||
action = 'store_true',
|
||||
help = 'Disable music video skip on playlists/albums'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-p',
|
||||
'--prefer-hevc',
|
||||
action = 'store_true',
|
||||
help = 'Prefer HEVC over AVC'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-n',
|
||||
'--no-lrc',
|
||||
action = 'store_true',
|
||||
help = "Don't create .lrc file"
|
||||
)
|
||||
parser.add_argument(
|
||||
'-s',
|
||||
'--skip-cleanup',
|
||||
action = 'store_true',
|
||||
help = 'Skip cleanup'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-e',
|
||||
'--print-exceptions',
|
||||
action = 'store_true',
|
||||
help = 'Print execeptions'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-y',
|
||||
'--print-video-playlist',
|
||||
action = 'store_true',
|
||||
help = 'Print Video M3U8 Playlist'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-v',
|
||||
'--version',
|
||||
action = 'version',
|
||||
version = f'%(prog)s {__version__}'
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if not args.url and not args.urls_txt:
|
||||
parser.error('you must specify an url or a text file using -u/--urls-txt')
|
||||
if args.urls_txt:
|
||||
with open(args.urls_txt, 'r', encoding = 'utf8') as f:
|
||||
args.url = f.read().splitlines()
|
||||
dl = Gamdl(
|
||||
args.device_path,
|
||||
args.cookies_location,
|
||||
args.disable_music_video_skip,
|
||||
args.prefer_hevc,
|
||||
args.temp_path,
|
||||
args.final_path,
|
||||
args.no_lrc,
|
||||
args.skip_cleanup
|
||||
)
|
||||
error_count = 0
|
||||
download_queue = []
|
||||
for i, url in enumerate(args.url):
|
||||
try:
|
||||
download_queue.append(dl.get_download_queue(url.strip()))
|
||||
except KeyboardInterrupt:
|
||||
exit(1)
|
||||
except:
|
||||
error_count += 1
|
||||
print(f'* Failed to check URL {i + 1}.')
|
||||
if args.print_exceptions:
|
||||
traceback.print_exc()
|
||||
for i, url in enumerate(download_queue):
|
||||
for j, track in enumerate(url):
|
||||
print(f'Downloading "{track["attributes"]["name"]}" (track {j + 1} from URL {i + 1})...')
|
||||
track_id = track['id']
|
||||
try:
|
||||
webplayback = dl.get_webplayback(track_id)
|
||||
if track['type'] == 'music-videos':
|
||||
playlist = dl.get_playlist_music_video(webplayback)
|
||||
if args.print_video_playlist:
|
||||
print(playlist.dumps())
|
||||
stream_url_audio = dl.get_stream_url_music_video_audio(playlist)
|
||||
decryption_keys_audio = dl.get_decryption_keys_music_video(stream_url_audio, track_id)
|
||||
encrypted_location_audio = dl.get_encrypted_location_audio(track_id)
|
||||
dl.download(encrypted_location_audio, stream_url_audio)
|
||||
decrypted_location_audio = dl.get_decrypted_location_audio(track_id)
|
||||
dl.decrypt(encrypted_location_audio, decrypted_location_audio, decryption_keys_audio)
|
||||
stream_url_video = dl.get_stream_url_music_video_video(playlist)
|
||||
decryption_keys_video = dl.get_decryption_keys_music_video(stream_url_video, track_id)
|
||||
encrypted_location_video = dl.get_encrypted_location_video(track_id)
|
||||
dl.download(encrypted_location_video, stream_url_video)
|
||||
decrypted_location_video = dl.get_decrypted_location_video(track_id)
|
||||
dl.decrypt(encrypted_location_video, decrypted_location_video, decryption_keys_video)
|
||||
tags = dl.get_tags_music_video(track['attributes']['url'].split('/')[-1])
|
||||
fixed_location = dl.get_fixed_location(track_id, '.m4v')
|
||||
final_location = dl.get_final_location('.m4v', tags)
|
||||
dl.fixup_music_video(decrypted_location_audio, decrypted_location_video, fixed_location)
|
||||
dl.make_final(final_location, fixed_location, tags)
|
||||
else:
|
||||
stream_url = dl.get_stream_url_song(webplayback)
|
||||
decryption_keys = dl.get_decryption_keys_song(stream_url, track_id)
|
||||
encrypted_location = dl.get_encrypted_location_audio(track_id)
|
||||
dl.download(encrypted_location, stream_url)
|
||||
decrypted_location = dl.get_decrypted_location_audio(track_id)
|
||||
dl.decrypt(encrypted_location, decrypted_location, decryption_keys)
|
||||
unsynced_lyrics, synced_lyrics = dl.get_lyrics(track_id)
|
||||
tags = dl.get_tags_song(webplayback, unsynced_lyrics, track['attributes']['genreNames'][0])
|
||||
fixed_location = dl.get_fixed_location(track_id, '.m4a')
|
||||
final_location = dl.get_final_location('.m4a', tags)
|
||||
dl.fixup_song(decrypted_location, fixed_location)
|
||||
dl.make_final(final_location, fixed_location, tags)
|
||||
dl.make_lrc(final_location, synced_lyrics)
|
||||
except KeyboardInterrupt:
|
||||
exit(1)
|
||||
except:
|
||||
error_count += 1
|
||||
print(f'* Failed to download "{track["attributes"]["name"]}" (track {j + 1} from URL {i + 1}).')
|
||||
if args.print_exceptions:
|
||||
traceback.print_exc()
|
||||
dl.cleanup()
|
||||
print(f'Done ({error_count} error(s)).')
|
||||
__version__ = "2.0"
|
||||
|
||||
+2
-3
@@ -1,4 +1,3 @@
|
||||
import gamdl
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
gamdl.main()
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import re
|
||||
import time
|
||||
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")
|
||||
except (
|
||||
requests.HTTPError,
|
||||
requests.exceptions.JSONDecodeError,
|
||||
AssertionError,
|
||||
):
|
||||
self._raise_response_exception(response)
|
||||
|
||||
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,
|
||||
is_library: bool = False,
|
||||
limit_tracks: int = 300,
|
||||
extend: str = "extendedAssetUrls",
|
||||
full_playlist: bool = True,
|
||||
) -> dict:
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/{'me' if is_library else '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 full_playlist:
|
||||
playlist = self._extend_playlists_tracks(playlist, limit_tracks)
|
||||
return playlist
|
||||
|
||||
def _extend_playlists_tracks(
|
||||
self,
|
||||
playlist: dict,
|
||||
limit_tracks: int,
|
||||
) -> dict:
|
||||
playlist_next_uri = playlist["relationships"]["tracks"].get("next")
|
||||
while playlist_next_uri:
|
||||
playlist_next = self._get_playlist_next(playlist_next_uri, limit_tracks)
|
||||
playlist["relationships"]["tracks"]["data"].extend(playlist_next["data"])
|
||||
playlist_next_uri = playlist_next.get("next")
|
||||
time.sleep(self.WAIT_TIME)
|
||||
return playlist
|
||||
|
||||
def _get_playlist_next(self, playlist_next_uri: str, limit_tracks: int) -> dict:
|
||||
response = self.session.get(
|
||||
self.AMP_API_URL + playlist_next_uri,
|
||||
params={
|
||||
"limit[tracks]": limit_tracks,
|
||||
},
|
||||
)
|
||||
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
|
||||
+689
@@ -0,0 +1,689 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from . import __version__
|
||||
from .apple_music_api import AppleMusicApi
|
||||
from .constants import *
|
||||
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 .itunes_api import ItunesApi
|
||||
|
||||
apple_music_api_sig = inspect.signature(AppleMusicApi.__init__)
|
||||
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
|
||||
|
||||
|
||||
def write_default_config_file(ctx: click.Context) -> None:
|
||||
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))
|
||||
|
||||
|
||||
def load_config_file(
|
||||
ctx: click.Context,
|
||||
param: click.Parameter,
|
||||
no_config_file: bool,
|
||||
) -> 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])
|
||||
return ctx
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.help_option("-h", "--help")
|
||||
@click.version_option(__version__, "-v", "--version")
|
||||
# CLI specific options
|
||||
@click.argument(
|
||||
"urls",
|
||||
nargs=-1,
|
||||
type=str,
|
||||
required=True,
|
||||
)
|
||||
@click.option(
|
||||
"--disable-music-video-skip",
|
||||
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.",
|
||||
)
|
||||
@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",
|
||||
help="Path to config file.",
|
||||
)
|
||||
@click.option(
|
||||
"--log-level",
|
||||
type=str,
|
||||
default="INFO",
|
||||
help="Log level.",
|
||||
)
|
||||
@click.option(
|
||||
"--print-exceptions",
|
||||
is_flag=True,
|
||||
help="Print exceptions.",
|
||||
)
|
||||
# API specific options
|
||||
@click.option(
|
||||
"--cookies-path",
|
||||
"-c",
|
||||
type=Path,
|
||||
default=apple_music_api_sig.parameters["cookies_path"].default,
|
||||
help="Path to .txt cookies file.",
|
||||
)
|
||||
# Downloader specific options
|
||||
@click.option(
|
||||
"--output-path",
|
||||
"-o",
|
||||
type=Path,
|
||||
default=downloader_sig.parameters["output_path"].default,
|
||||
help="Path to output directory.",
|
||||
)
|
||||
@click.option(
|
||||
"--temp-path",
|
||||
type=Path,
|
||||
default=downloader_sig.parameters["temp_path"].default,
|
||||
help="Path to temporary directory.",
|
||||
)
|
||||
@click.option(
|
||||
"--wvd-path",
|
||||
type=Path,
|
||||
default=downloader_sig.parameters["wvd_path"].default,
|
||||
help="Path to .wvd file.",
|
||||
)
|
||||
@click.option(
|
||||
"--nm3u8dlre-path",
|
||||
type=str,
|
||||
default=downloader_sig.parameters["nm3u8dlre_path"].default,
|
||||
help="Path to N_m3u8DL-RE binary.",
|
||||
)
|
||||
@click.option(
|
||||
"--mp4decrypt-path",
|
||||
type=str,
|
||||
default=downloader_sig.parameters["mp4decrypt_path"].default,
|
||||
help="Path to mp4decrypt binary.",
|
||||
)
|
||||
@click.option(
|
||||
"--ffmpeg-path",
|
||||
type=str,
|
||||
default=downloader_sig.parameters["ffmpeg_path"].default,
|
||||
help="Path to FFmpeg binary.",
|
||||
)
|
||||
@click.option(
|
||||
"--mp4box-path",
|
||||
type=str,
|
||||
default=downloader_sig.parameters["mp4box_path"].default,
|
||||
help="Path to MP4Box binary.",
|
||||
)
|
||||
@click.option(
|
||||
"--download-mode",
|
||||
type=DownloadMode,
|
||||
default=downloader_sig.parameters["download_mode"].default,
|
||||
help="Download mode.",
|
||||
)
|
||||
@click.option(
|
||||
"--remux-mode",
|
||||
type=RemuxMode,
|
||||
default=downloader_sig.parameters["remux_mode"].default,
|
||||
help="Remux mode.",
|
||||
)
|
||||
@click.option(
|
||||
"--cover-format",
|
||||
type=CoverFormat,
|
||||
default=downloader_sig.parameters["cover_format"].default,
|
||||
help="Cover format.",
|
||||
)
|
||||
@click.option(
|
||||
"--template-folder-album",
|
||||
type=str,
|
||||
default=downloader_sig.parameters["template_folder_album"].default,
|
||||
help="Template folder for tracks that are part of an album.",
|
||||
)
|
||||
@click.option(
|
||||
"--template-folder-compilation",
|
||||
type=str,
|
||||
default=downloader_sig.parameters["template_folder_compilation"].default,
|
||||
help="Template folder for tracks that are part of a compilation album.",
|
||||
)
|
||||
@click.option(
|
||||
"--template-file-single-disc",
|
||||
type=str,
|
||||
default=downloader_sig.parameters["template_file_single_disc"].default,
|
||||
help="Template file for the tracks that are part of a single-disc album.",
|
||||
)
|
||||
@click.option(
|
||||
"--template-file-multi-disc",
|
||||
type=str,
|
||||
default=downloader_sig.parameters["template_file_multi_disc"].default,
|
||||
help="Template file for the tracks that are part of a multi-disc album.",
|
||||
)
|
||||
@click.option(
|
||||
"--template-folder-no-album",
|
||||
type=str,
|
||||
default=downloader_sig.parameters["template_folder_no_album"].default,
|
||||
help="Template folder for the tracks that are not part of an album.",
|
||||
)
|
||||
@click.option(
|
||||
"--template-file-no-album",
|
||||
type=str,
|
||||
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-date",
|
||||
type=str,
|
||||
default=downloader_sig.parameters["template_date"].default,
|
||||
help="Date tag template.",
|
||||
)
|
||||
@click.option(
|
||||
"--exclude-tags",
|
||||
type=str,
|
||||
default=downloader_sig.parameters["exclude_tags"].default,
|
||||
help="Comma-separated tags to exclude.",
|
||||
)
|
||||
@click.option(
|
||||
"--cover-size",
|
||||
type=int,
|
||||
default=downloader_sig.parameters["cover_size"].default,
|
||||
help="Cover size.",
|
||||
)
|
||||
@click.option(
|
||||
"--truncate",
|
||||
type=int,
|
||||
default=downloader_sig.parameters["truncate"].default,
|
||||
help="Maximum length of the file/folder names.",
|
||||
)
|
||||
# DownloaderSong specific options
|
||||
@click.option(
|
||||
"--codec-song",
|
||||
type=SongCodec,
|
||||
default=downloader_song_sig.parameters["codec"].default,
|
||||
help="Song codec.",
|
||||
)
|
||||
@click.option(
|
||||
"--synced-lyrics-format",
|
||||
type=SyncedLyricsFormat,
|
||||
default=downloader_song_sig.parameters["synced_lyrics_format"].default,
|
||||
help="Synced lyrics format.",
|
||||
)
|
||||
# DownloaderMusicVideo specific options
|
||||
@click.option(
|
||||
"--codec-music-video",
|
||||
type=MusicVideoCodec,
|
||||
default=downloader_music_video_sig.parameters["codec"].default,
|
||||
help="Music video codec.",
|
||||
)
|
||||
# DownloaderPost specific options
|
||||
@click.option(
|
||||
"--quality-post",
|
||||
type=PostQuality,
|
||||
default=downloader_post_sig.parameters["quality"].default,
|
||||
help="Post video quality.",
|
||||
)
|
||||
# This option should always be last
|
||||
@click.option(
|
||||
"--no-config-file",
|
||||
"-n",
|
||||
is_flag=True,
|
||||
callback=load_config_file,
|
||||
help="Do not use a 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,
|
||||
cookies_path: Path,
|
||||
output_path: Path,
|
||||
temp_path: Path,
|
||||
wvd_path: Path,
|
||||
nm3u8dlre_path: str,
|
||||
mp4decrypt_path: str,
|
||||
ffmpeg_path: str,
|
||||
mp4box_path: str,
|
||||
download_mode: DownloadMode,
|
||||
remux_mode: RemuxMode,
|
||||
cover_format: CoverFormat,
|
||||
template_folder_album: str,
|
||||
template_folder_compilation: str,
|
||||
template_file_single_disc: str,
|
||||
template_file_multi_disc: str,
|
||||
template_folder_no_album: str,
|
||||
template_file_no_album: str,
|
||||
template_date: str,
|
||||
exclude_tags: str,
|
||||
cover_size: int,
|
||||
truncate: int,
|
||||
codec_song: SongCodec,
|
||||
synced_lyrics_format: SyncedLyricsFormat,
|
||||
codec_music_video: MusicVideoCodec,
|
||||
quality_post: PostQuality,
|
||||
no_config_file: bool,
|
||||
):
|
||||
logging.basicConfig(
|
||||
format="[%(levelname)-8s %(asctime)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(log_level)
|
||||
logger.debug("Starting downloader")
|
||||
apple_music_api = AppleMusicApi(cookies_path)
|
||||
itunes_api = ItunesApi(
|
||||
apple_music_api.storefront,
|
||||
apple_music_api.language,
|
||||
)
|
||||
downloader = Downloader(
|
||||
apple_music_api,
|
||||
itunes_api,
|
||||
output_path,
|
||||
temp_path,
|
||||
wvd_path,
|
||||
nm3u8dlre_path,
|
||||
mp4decrypt_path,
|
||||
ffmpeg_path,
|
||||
mp4box_path,
|
||||
download_mode,
|
||||
remux_mode,
|
||||
cover_format,
|
||||
template_folder_album,
|
||||
template_folder_compilation,
|
||||
template_file_single_disc,
|
||||
template_file_multi_disc,
|
||||
template_folder_no_album,
|
||||
template_file_no_album,
|
||||
template_date,
|
||||
exclude_tags,
|
||||
cover_size,
|
||||
truncate,
|
||||
)
|
||||
downloader_song = DownloaderSong(
|
||||
downloader,
|
||||
codec_song,
|
||||
synced_lyrics_format,
|
||||
)
|
||||
downloader_song_legacy = DownloaderSongLegacy(
|
||||
downloader,
|
||||
codec_song,
|
||||
)
|
||||
downloader_music_video = DownloaderMusicVideo(
|
||||
downloader,
|
||||
codec_music_video,
|
||||
)
|
||||
downloader_post = DownloaderPost(
|
||||
downloader,
|
||||
quality_post,
|
||||
)
|
||||
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
|
||||
not in (
|
||||
SongCodec.AAC_LEGACY,
|
||||
SongCodec.AAC_HE_LEGACY,
|
||||
)
|
||||
or (remux_mode == RemuxMode.MP4BOX and not downloader.mp4decrypt_path_full)
|
||||
):
|
||||
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(
|
||||
X_NOT_FOUND_STRING.format("mp4decrypt", mp4decrypt_path)
|
||||
+ ", music videos will not be downloaded"
|
||||
)
|
||||
skip_mv = True
|
||||
else:
|
||||
skip_mv = False
|
||||
error_count = 0
|
||||
if read_urls_as_txt:
|
||||
urls = [url.strip() for url in Path(urls[0]).read_text().splitlines()]
|
||||
for url_index, url in enumerate(urls, start=1):
|
||||
url_progress = f"URL {url_index}/{len(urls)}"
|
||||
try:
|
||||
url_info = downloader.get_url_info(url)
|
||||
download_queue = downloader.get_download_queue(url_info)
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
logger.error(
|
||||
f'({url_progress}) Failed to check "{url}"',
|
||||
exc_info=print_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
|
||||
try:
|
||||
logger.info(
|
||||
f'({queue_progress}) Downloading "{track["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)
|
||||
or (
|
||||
track["type"] == "music-videos"
|
||||
and url_info.type == "album"
|
||||
and not disable_music_video_skip
|
||||
)
|
||||
):
|
||||
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
|
||||
)
|
||||
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:
|
||||
if codec_song in (
|
||||
SongCodec.AAC_LEGACY,
|
||||
SongCodec.AAC_HE_LEGACY,
|
||||
):
|
||||
logger.debug("Getting stream info")
|
||||
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.pssh:
|
||||
logger.warning(
|
||||
f"({queue_progress}) Song does not contain Widevine DRM, skipping"
|
||||
)
|
||||
continue
|
||||
elif not stream_info.stream_url:
|
||||
logger.warning(
|
||||
f"({queue_progress}) Song is not available with the selected codec, skipping"
|
||||
)
|
||||
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 (
|
||||
SongCodec.AAC_LEGACY,
|
||||
SongCodec.AAC_HE_LEGACY,
|
||||
):
|
||||
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)
|
||||
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
|
||||
)
|
||||
logger.debug("Getting iTunes page")
|
||||
itunes_page = itunes_api.get_itunes_page(
|
||||
"music-video", music_video_id_alt
|
||||
)
|
||||
stream_url_master = downloader_music_video.get_stream_url_master(
|
||||
itunes_page
|
||||
)
|
||||
logger.debug("Getting M3U8 data")
|
||||
m3u8_master_data = downloader_music_video.get_m3u8_master_data(
|
||||
stream_url_master
|
||||
)
|
||||
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,
|
||||
)
|
||||
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 Exception as e:
|
||||
error_count += 1
|
||||
logger.error(
|
||||
f'({queue_progress}) Failed to download "{track["attributes"]["name"]}"',
|
||||
exc_info=print_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))")
|
||||
@@ -0,0 +1,221 @@
|
||||
from gamdl.enums import MusicVideoCodec, SongCodec, SyncedLyricsFormat
|
||||
|
||||
STOREFRONT_IDS = {
|
||||
"AE": "143481-2,32",
|
||||
"AG": "143540-2,32",
|
||||
"AI": "143538-2,32",
|
||||
"AL": "143575-2,32",
|
||||
"AM": "143524-2,32",
|
||||
"AO": "143564-2,32",
|
||||
"AR": "143505-28,32",
|
||||
"AT": "143445-4,32",
|
||||
"AU": "143460-27,32",
|
||||
"AZ": "143568-2,32",
|
||||
"BB": "143541-2,32",
|
||||
"BE": "143446-2,32",
|
||||
"BF": "143578-2,32",
|
||||
"BG": "143526-2,32",
|
||||
"BH": "143559-2,32",
|
||||
"BJ": "143576-2,32",
|
||||
"BM": "143542-2,32",
|
||||
"BN": "143560-2,32",
|
||||
"BO": "143556-28,32",
|
||||
"BR": "143503-15,32",
|
||||
"BS": "143539-2,32",
|
||||
"BT": "143577-2,32",
|
||||
"BW": "143525-2,32",
|
||||
"BY": "143565-2,32",
|
||||
"BZ": "143555-2,32",
|
||||
"CA": "143455-6,32",
|
||||
"CG": "143582-2,32",
|
||||
"CH": "143459-57,32",
|
||||
"CL": "143483-28,32",
|
||||
"CN": "143465-19,32",
|
||||
"CO": "143501-28,32",
|
||||
"CR": "143495-28,32",
|
||||
"CV": "143580-2,32",
|
||||
"CY": "143557-2,32",
|
||||
"CZ": "143489-2,32",
|
||||
"DE": "143443-4,32",
|
||||
"DK": "143458-2,32",
|
||||
"DM": "143545-2,32",
|
||||
"DO": "143508-28,32",
|
||||
"DZ": "143563-2,32",
|
||||
"EC": "143509-28,32",
|
||||
"EE": "143518-2,32",
|
||||
"EG": "143516-2,32",
|
||||
"ES": "143454-8,32",
|
||||
"FI": "143447-2,32",
|
||||
"FJ": "143583-2,32",
|
||||
"FM": "143591-2,32",
|
||||
"FR": "143442-3,32",
|
||||
"GB": "143444-2,32",
|
||||
"GD": "143546-2,32",
|
||||
"GH": "143573-2,32",
|
||||
"GM": "143584-2,32",
|
||||
"GR": "143448-2,32",
|
||||
"GT": "143504-28,32",
|
||||
"GW": "143585-2,32",
|
||||
"GY": "143553-2,32",
|
||||
"HK": "143463-45,32",
|
||||
"HN": "143510-28,32",
|
||||
"HR": "143494-2,32",
|
||||
"HU": "143482-2,32",
|
||||
"ID": "143476-2,32",
|
||||
"IE": "143449-2,32",
|
||||
"IL": "143491-2,32",
|
||||
"IN": "143467-2,32",
|
||||
"IS": "143558-2,32",
|
||||
"IT": "143450-7,32",
|
||||
"JM": "143511-2,32",
|
||||
"JO": "143528-2,32",
|
||||
"JP": "143462-9,32",
|
||||
"KE": "143529-2,32",
|
||||
"KG": "143586-2,32",
|
||||
"KH": "143579-2,32",
|
||||
"KN": "143548-2,32",
|
||||
"KR": "143466-13,32",
|
||||
"KW": "143493-2,32",
|
||||
"KY": "143544-2,32",
|
||||
"KZ": "143517-2,32",
|
||||
"LA": "143587-2,32",
|
||||
"LB": "143497-2,32",
|
||||
"LC": "143549-2,32",
|
||||
"LK": "143486-2,32",
|
||||
"LR": "143588-2,32",
|
||||
"LT": "143520-2,32",
|
||||
"LU": "143451-2,32",
|
||||
"LV": "143519-2,32",
|
||||
"MD": "143523-2,32",
|
||||
"MG": "143531-2,32",
|
||||
"MK": "143530-2,32",
|
||||
"ML": "143532-2,32",
|
||||
"MN": "143592-2,32",
|
||||
"MO": "143515-45,32",
|
||||
"MR": "143590-2,32",
|
||||
"MS": "143547-2,32",
|
||||
"MT": "143521-2,32",
|
||||
"MU": "143533-2,32",
|
||||
"MW": "143589-2,32",
|
||||
"MX": "143468-28,32",
|
||||
"MY": "143473-2,32",
|
||||
"MZ": "143593-2,32",
|
||||
"NA": "143594-2,32",
|
||||
"NE": "143534-2,32",
|
||||
"NG": "143561-2,32",
|
||||
"NI": "143512-28,32",
|
||||
"NL": "143452-10,32",
|
||||
"NO": "143457-2,32",
|
||||
"NP": "143484-2,32",
|
||||
"NZ": "143461-27,32",
|
||||
"OM": "143562-2,32",
|
||||
"PA": "143485-28,32",
|
||||
"PE": "143507-28,32",
|
||||
"PG": "143597-2,32",
|
||||
"PH": "143474-2,32",
|
||||
"PK": "143477-2,32",
|
||||
"PL": "143478-2,32",
|
||||
"PT": "143453-24,32",
|
||||
"PW": "143595-2,32",
|
||||
"PY": "143513-28,32",
|
||||
"QA": "143498-2,32",
|
||||
"RO": "143487-2,32",
|
||||
"RU": "143469-16,32",
|
||||
"SA": "143479-2,32",
|
||||
"SB": "143601-2,32",
|
||||
"SC": "143599-2,32",
|
||||
"SE": "143456-17,32",
|
||||
"SG": "143464-19,32",
|
||||
"SI": "143499-2,32",
|
||||
"SK": "143496-2,32",
|
||||
"SL": "143600-2,32",
|
||||
"SN": "143535-2,32",
|
||||
"SR": "143554-2,32",
|
||||
"ST": "143598-2,32",
|
||||
"SV": "143506-28,32",
|
||||
"SZ": "143602-2,32",
|
||||
"TC": "143552-2,32",
|
||||
"TD": "143581-2,32",
|
||||
"TH": "143475-2,32",
|
||||
"TJ": "143603-2,32",
|
||||
"TM": "143604-2,32",
|
||||
"TN": "143536-2,32",
|
||||
"TR": "143480-2,32",
|
||||
"TT": "143551-2,32",
|
||||
"TW": "143470-18,32",
|
||||
"TZ": "143572-2,32",
|
||||
"UA": "143492-2,32",
|
||||
"UG": "143537-2,32",
|
||||
"US": "143441-1,32",
|
||||
"UY": "143514-2,32",
|
||||
"UZ": "143566-2,32",
|
||||
"VC": "143550-2,32",
|
||||
"VE": "143502-28,32",
|
||||
"VG": "143543-2,32",
|
||||
"VN": "143471-2,32",
|
||||
"YE": "143571-2,32",
|
||||
"ZA": "143472-2,32",
|
||||
"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.ALAC: r"audio-alac-.*",
|
||||
SongCodec.ATMOS: r"audio-atmos-.*",
|
||||
}
|
||||
|
||||
MUSIC_VIDEO_CODEC_MAP = {
|
||||
MusicVideoCodec.H264_BEST: "avc1",
|
||||
MusicVideoCodec.H265_BEST: "hvc1",
|
||||
}
|
||||
|
||||
SYNCED_LYRICS_FILE_EXTENSION_MAP = {
|
||||
SyncedLyricsFormat.LRC: ".lrc",
|
||||
SyncedLyricsFormat.SRT: ".srt",
|
||||
SyncedLyricsFormat.TTML: ".ttml",
|
||||
}
|
||||
|
||||
|
||||
EXCLUDED_CONFIG_FILE_PARAMS = (
|
||||
"urls",
|
||||
"config_path",
|
||||
"read_urls_as_txt",
|
||||
"no_config_file",
|
||||
"version",
|
||||
"help",
|
||||
)
|
||||
|
||||
X_NOT_FOUND_STRING = '{} not found at "{}"'
|
||||
|
||||
AMP_API_HOSTNAME = "https://amp-api.music.apple.com"
|
||||
@@ -0,0 +1,337 @@
|
||||
import base64
|
||||
import functools
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import ciso8601
|
||||
import requests
|
||||
from mutagen.mp4 import MP4, MP4Cover
|
||||
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 .hardcoded_wvd import HARDCODED_WVD
|
||||
from .itunes_api import ItunesApi
|
||||
from .models import DownloadQueueItem, UrlInfo
|
||||
|
||||
|
||||
class Downloader:
|
||||
ILLEGAL_CHARACTERS_REGEX = r'[\\/:*?"<>|;]'
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
apple_music_api: AppleMusicApi,
|
||||
itunes_api: ItunesApi,
|
||||
output_path: Path = Path("./Apple Music"),
|
||||
temp_path: Path = Path("./temp"),
|
||||
wvd_path: Path = None,
|
||||
nm3u8dlre_path: str = "N_m3u8dl-RE",
|
||||
mp4decrypt_path: str = "mp4decrypt",
|
||||
ffmpeg_path: str = "ffmpeg",
|
||||
mp4box_path: str = "MP4Box",
|
||||
download_mode: DownloadMode = DownloadMode.YTDLP,
|
||||
remux_mode: RemuxMode = RemuxMode.FFMPEG,
|
||||
cover_format: CoverFormat = CoverFormat.JPG,
|
||||
template_folder_album: str = "{album_artist}/{album}",
|
||||
template_folder_compilation: str = "Compilations/{album}",
|
||||
template_file_single_disc: str = "{track:02d} {title}",
|
||||
template_file_multi_disc: str = "{disc}-{track:02d} {title}",
|
||||
template_folder_no_album: str = "{artist}/Unknown Album",
|
||||
template_file_no_album: str = "{title}",
|
||||
template_date: str = "%Y-%m-%dT%H:%M:%SZ",
|
||||
exclude_tags: str = None,
|
||||
cover_size: int = 1200,
|
||||
truncate: int = 40,
|
||||
no_progress: bool = False,
|
||||
):
|
||||
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.nm3u8dlre_path = nm3u8dlre_path
|
||||
self.mp4decrypt_path = mp4decrypt_path
|
||||
self.ffmpeg_path = ffmpeg_path
|
||||
self.mp4box_path = mp4box_path
|
||||
self.download_mode = download_mode
|
||||
self.remux_mode = remux_mode
|
||||
self.cover_format = cover_format
|
||||
self.template_folder_album = template_folder_album
|
||||
self.template_folder_compilation = template_folder_compilation
|
||||
self.template_file_single_disc = template_file_single_disc
|
||||
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_date = template_date
|
||||
self.exclude_tags = exclude_tags
|
||||
self.cover_size = cover_size
|
||||
self.truncate = truncate
|
||||
self.no_progress = no_progress
|
||||
self._set_binaries_path_full()
|
||||
self._set_exclude_tags_list()
|
||||
self._set_truncate()
|
||||
|
||||
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
|
||||
|
||||
def set_cdm(self):
|
||||
if self.wvd_path:
|
||||
self.cdm = Cdm.from_device(Device.load(self.wvd_path))
|
||||
else:
|
||||
self.cdm = Cdm.from_device(Device.loads(HARDCODED_WVD))
|
||||
|
||||
def get_url_info(self, url: str) -> UrlInfo:
|
||||
url_info = UrlInfo()
|
||||
url_regex_result = re.search(
|
||||
r"/([a-z]{2})/(album|playlist|song|music-video|post)/([^/]*)(?:/([^/?]*))?(?:\?i=)?([0-9a-z]*)?",
|
||||
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
|
||||
|
||||
def get_download_queue(self, url_info: UrlInfo) -> list[DownloadQueueItem]:
|
||||
return self._get_download_queue(url_info.type, url_info.id)
|
||||
|
||||
def _get_download_queue(self, url_type: str, id: str) -> list[DownloadQueueItem]:
|
||||
download_queue = []
|
||||
if url_type == "song":
|
||||
download_queue.append(DownloadQueueItem(self.apple_music_api.get_song(id)))
|
||||
elif url_type == "album":
|
||||
album = self.apple_music_api.get_album(id)
|
||||
download_queue.extend(
|
||||
DownloadQueueItem(track)
|
||||
for track in album["relationships"]["tracks"]["data"]
|
||||
)
|
||||
elif url_type == "playlist":
|
||||
download_queue.extend(
|
||||
DownloadQueueItem(track)
|
||||
for track in self.apple_music_api.get_playlist(id)["relationships"][
|
||||
"tracks"
|
||||
]["data"]
|
||||
)
|
||||
elif url_type == "music-video":
|
||||
download_queue.append(
|
||||
DownloadQueueItem(self.apple_music_api.get_music_video(id))
|
||||
)
|
||||
elif url_type == "post":
|
||||
download_queue.append(DownloadQueueItem(self.apple_music_api.get_post(id)))
|
||||
else:
|
||||
raise Exception(f"Invalid url type: {url_type}")
|
||||
return download_queue
|
||||
|
||||
def sanitize_date(self, date: str):
|
||||
datetime_obj = ciso8601.parse_datetime(date)
|
||||
return datetime_obj.strftime(self.template_date)
|
||||
|
||||
def get_decryption_key(self, pssh: str, track_id: str) -> str:
|
||||
pssh_obj = PSSH(pssh.split(",")[-1])
|
||||
cdm_session = self.cdm.open()
|
||||
challenge = base64.b64encode(
|
||||
self.cdm.get_license_challenge(cdm_session, pssh_obj)
|
||||
).decode()
|
||||
license = self.apple_music_api.get_widevine_license(
|
||||
track_id,
|
||||
pssh,
|
||||
challenge,
|
||||
)
|
||||
self.cdm.parse_license(cdm_session, license)
|
||||
decryption_key = next(
|
||||
i for i in self.cdm.get_keys(cdm_session) if i.type == "CONTENT"
|
||||
).key.hex()
|
||||
self.cdm.close(cdm_session)
|
||||
return decryption_key
|
||||
|
||||
def download(self, path: Path, stream_url: str):
|
||||
if self.download_mode == DownloadMode.YTDLP:
|
||||
self.download_ytdlp(path, stream_url)
|
||||
elif self.download_mode == DownloadMode.NM3U8DLRE:
|
||||
self.download_nm3u8dlre(path, stream_url)
|
||||
|
||||
def download_ytdlp(self, path: Path, stream_url: str):
|
||||
with YoutubeDL(
|
||||
{
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"outtmpl": str(path),
|
||||
"allow_unplayable_formats": True,
|
||||
"fixup": "never",
|
||||
"allowed_extractors": ["generic"],
|
||||
"noprogress": self.no_progress,
|
||||
}
|
||||
) as ydl:
|
||||
ydl.download(stream_url)
|
||||
|
||||
def download_nm3u8dlre(self, path: Path, stream_url: str):
|
||||
if self.no_progress:
|
||||
subprocess_additional_args = {
|
||||
"stdout": subprocess.DEVNULL,
|
||||
"stderr": subprocess.DEVNULL,
|
||||
}
|
||||
else:
|
||||
subprocess_additional_args = {}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(
|
||||
[
|
||||
self.nm3u8dlre_path_full,
|
||||
stream_url,
|
||||
"--binary-merge",
|
||||
"--no-log",
|
||||
"--log-level",
|
||||
"off",
|
||||
"--ffmpeg-binary-path",
|
||||
self.ffmpeg_path_full,
|
||||
"--save-name",
|
||||
path.stem,
|
||||
"--save-dir",
|
||||
path.parent,
|
||||
"--tmp-dir",
|
||||
path.parent,
|
||||
],
|
||||
check=True,
|
||||
**subprocess_additional_args,
|
||||
)
|
||||
|
||||
def get_sanitized_string(self, dirty_string: str, is_folder: bool) -> str:
|
||||
dirty_string = re.sub(self.ILLEGAL_CHARACTERS_REGEX, "_", dirty_string)
|
||||
if is_folder:
|
||||
dirty_string = dirty_string[: self.truncate]
|
||||
if dirty_string.endswith("."):
|
||||
dirty_string = dirty_string[:-1] + "_"
|
||||
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 = (
|
||||
self.template_folder_compilation.split("/")
|
||||
if tags.get("compilation")
|
||||
else self.template_folder_album.split("/")
|
||||
)
|
||||
final_path_file = (
|
||||
self.template_file_multi_disc.split("/")
|
||||
if tags["disc_total"] > 1
|
||||
else self.template_file_single_disc.split("/")
|
||||
)
|
||||
else:
|
||||
final_path_folder = self.template_folder_no_album.split("/")
|
||||
final_path_file = self.template_file_no_album.split("/")
|
||||
final_path_folder = [
|
||||
self.get_sanitized_string(i.format(**tags), True) for i in final_path_folder
|
||||
]
|
||||
final_path_file = [
|
||||
self.get_sanitized_string(i.format(**tags), True)
|
||||
for i in final_path_file[:-1]
|
||||
] + [
|
||||
self.get_sanitized_string(final_path_file[-1].format(**tags), False)
|
||||
+ file_extension
|
||||
]
|
||||
return self.output_path.joinpath(*final_path_folder).joinpath(*final_path_file)
|
||||
|
||||
def get_cover_url(self, metadata: dict) -> str:
|
||||
return self._get_cover_url(metadata["attributes"]["artwork"]["url"])
|
||||
|
||||
def _get_cover_url(self, cover_url_template: str) -> str:
|
||||
return re.sub(
|
||||
r"\{w\}x\{h\}([a-z]{2})\.jpg",
|
||||
f"{self.cover_size}x{self.cover_size}bb.{self.cover_format.value}",
|
||||
cover_url_template,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@functools.lru_cache()
|
||||
def get_url_response_bytes(url: str) -> bytes:
|
||||
return requests.get(url).content
|
||||
|
||||
def apply_tags(
|
||||
self,
|
||||
path: Path,
|
||||
tags: dict,
|
||||
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
|
||||
),
|
||||
)
|
||||
]
|
||||
mp4 = MP4(path)
|
||||
mp4.clear()
|
||||
mp4.update(mp4_tags)
|
||||
mp4.save()
|
||||
|
||||
def move_to_output_path(
|
||||
self,
|
||||
remuxed_path: Path,
|
||||
final_path: Path,
|
||||
):
|
||||
final_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(remuxed_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 cleanup_temp_path(self):
|
||||
shutil.rmtree(self.temp_path)
|
||||
@@ -0,0 +1,302 @@
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import m3u8
|
||||
from tabulate import tabulate
|
||||
|
||||
from .constants import MUSIC_VIDEO_CODEC_MAP
|
||||
from .downloader import Downloader
|
||||
from .enums import MusicVideoCodec, RemuxMode
|
||||
from .models import StreamInfo
|
||||
|
||||
|
||||
class DownloaderMusicVideo:
|
||||
def __init__(
|
||||
self,
|
||||
downloader: Downloader,
|
||||
codec: MusicVideoCodec = MusicVideoCodec.H264_BEST,
|
||||
):
|
||||
self.downloader = downloader
|
||||
self.codec = codec
|
||||
|
||||
def get_stream_url_master(self, itunes_page: dict) -> str:
|
||||
return itunes_page["offers"][0]["assets"][0]["hlsUrl"]
|
||||
|
||||
def get_m3u8_master_data(self, stream_url_master: str) -> dict:
|
||||
url_parts = urllib.parse.urlparse(stream_url_master)
|
||||
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(
|
||||
query=urllib.parse.urlencode(query, doseq=True)
|
||||
).geturl()
|
||||
return m3u8.load(stream_url_master_new).data
|
||||
|
||||
def get_stream_url_video(
|
||||
self,
|
||||
playlists: list[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_BEST]
|
||||
)
|
||||
]
|
||||
playlists_filtered.sort(key=lambda x: x["stream_info"]["bandwidth"])
|
||||
return playlists_filtered[-1]["uri"]
|
||||
|
||||
def get_stream_url_video_from_user(
|
||||
self,
|
||||
playlists: list[dict],
|
||||
):
|
||||
table = [
|
||||
[
|
||||
i,
|
||||
playlist["stream_info"]["codecs"],
|
||||
playlist["stream_info"]["resolution"],
|
||||
playlist["stream_info"]["bandwidth"],
|
||||
]
|
||||
for i, playlist in enumerate(playlists, 1)
|
||||
]
|
||||
print(tabulate(table))
|
||||
try:
|
||||
choice = (
|
||||
click.prompt("Choose a video codec", type=click.IntRange(1, len(table)))
|
||||
- 1
|
||||
)
|
||||
except click.exceptions.Abort:
|
||||
raise KeyboardInterrupt()
|
||||
return playlists[choice]["uri"]
|
||||
|
||||
def get_stream_url_audio(
|
||||
self,
|
||||
playlists: list[dict],
|
||||
) -> str:
|
||||
stream_url = next(
|
||||
(
|
||||
playlist
|
||||
for playlist in playlists
|
||||
if playlist["group_id"] == "audio-stereo-256"
|
||||
),
|
||||
None,
|
||||
)["uri"]
|
||||
return stream_url
|
||||
|
||||
def get_stream_url_audio_from_user(
|
||||
self,
|
||||
playlists: list[dict],
|
||||
):
|
||||
table = [
|
||||
[
|
||||
i,
|
||||
playlist["group_id"],
|
||||
]
|
||||
for i, playlist in enumerate(playlists, 1)
|
||||
]
|
||||
print(tabulate(table))
|
||||
try:
|
||||
choice = (
|
||||
click.prompt(
|
||||
"Choose an audio codec", type=click.IntRange(1, len(table))
|
||||
)
|
||||
- 1
|
||||
)
|
||||
except click.exceptions.Abort:
|
||||
raise KeyboardInterrupt()
|
||||
return playlists[choice]["uri"]
|
||||
|
||||
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:
|
||||
stream_info.stream_url = self.get_stream_url_video(
|
||||
m3u8_master_data["playlists"]
|
||||
)
|
||||
else:
|
||||
stream_info.stream_url = self.get_stream_url_video_from_user(
|
||||
m3u8_master_data["playlists"]
|
||||
)
|
||||
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:
|
||||
stream_info.stream_url = self.get_stream_url_audio(
|
||||
m3u8_master_data["media"]
|
||||
)
|
||||
else:
|
||||
stream_info.stream_url = self.get_stream_url_audio_from_user(
|
||||
m3u8_master_data["media"]
|
||||
)
|
||||
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,
|
||||
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
|
||||
else:
|
||||
tags["rating"] = 0
|
||||
if itunes_page.get("collectionId"):
|
||||
metadata_itunes = self.downloader.itunes_api.get_resource(itunes_page["id"])
|
||||
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"]
|
||||
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,
|
||||
)
|
||||
|
||||
def remux_mp4box(
|
||||
self,
|
||||
decrypted_path_audio: Path,
|
||||
decrypted_path_video: Path,
|
||||
fixed_path: Path,
|
||||
) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.mp4box_path_full,
|
||||
"-quiet",
|
||||
"-add",
|
||||
decrypted_path_audio,
|
||||
"-add",
|
||||
decrypted_path_video,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-new",
|
||||
fixed_path,
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
def remux_ffmpeg(
|
||||
self,
|
||||
decrypted_path_video: Path,
|
||||
decrypte_path_audio: Path,
|
||||
fixed_path: Path,
|
||||
):
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.ffmpeg_path_full,
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-i",
|
||||
decrypted_path_video,
|
||||
"-i",
|
||||
decrypte_path_audio,
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-f",
|
||||
"mp4",
|
||||
"-c",
|
||||
"copy",
|
||||
"-c:s",
|
||||
"mov_text",
|
||||
fixed_path,
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
def remux(
|
||||
self,
|
||||
decrypted_path_video: Path,
|
||||
decrypted_path_audio: Path,
|
||||
remuxed_path: Path,
|
||||
):
|
||||
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,
|
||||
)
|
||||
|
||||
def get_cover_path(self, final_path: Path) -> Path:
|
||||
return final_path.with_suffix(f".{self.downloader.cover_format.value}")
|
||||
@@ -0,0 +1,71 @@
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from .downloader import Downloader
|
||||
from tabulate import tabulate
|
||||
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())
|
||||
table = [
|
||||
[index, quality]
|
||||
for index, quality in enumerate(
|
||||
qualities,
|
||||
start=1,
|
||||
)
|
||||
]
|
||||
print(tabulate(table))
|
||||
choice = (
|
||||
click.prompt("Choose a quality", type=click.IntRange(1, len(table))) - 1
|
||||
)
|
||||
return metadata["attributes"]["assetTokens"][qualities[choice]]
|
||||
|
||||
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": attributes["uploadDate"],
|
||||
"title": attributes["name"],
|
||||
"title_id": int(metadata["id"]),
|
||||
}
|
||||
|
||||
def get_temp_path(self, track_id: str) -> Path:
|
||||
return self.downloader.temp_path / f"{track_id}_temp.m4v"
|
||||
@@ -0,0 +1,346 @@
|
||||
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 click
|
||||
import m3u8
|
||||
from tabulate import tabulate
|
||||
|
||||
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"
|
||||
|
||||
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:
|
||||
raise Exception("DRM info not found")
|
||||
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"]]
|
||||
table = [
|
||||
[i, playlist["stream_info"]["audio"]]
|
||||
for i, playlist in enumerate(m3u8_master_playlists, 1)
|
||||
]
|
||||
print(tabulate(table))
|
||||
try:
|
||||
choice = (
|
||||
click.prompt("Choose a codec", type=click.IntRange(1, len(table))) - 1
|
||||
)
|
||||
except click.exceptions.Abort:
|
||||
raise KeyboardInterrupt()
|
||||
return m3u8_master_playlists[choice]
|
||||
|
||||
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"]["enhancedHls"]
|
||||
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)
|
||||
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
|
||||
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:
|
||||
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"]["lyrics"]["data"]:
|
||||
return self._get_lyrics(
|
||||
track_metadata["relationships"]["lyrics"]["data"][0]["attributes"][
|
||||
"ttml"
|
||||
]
|
||||
)
|
||||
else:
|
||||
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)}\n"
|
||||
elif self.synced_lyrics_format == SyncedLyricsFormat.SRT:
|
||||
lyrics.synced += f"{self.get_lyrics_synced_line_srt(index, p.attrib.get('begin'), p.attrib.get('end'), p.text)}\n"
|
||||
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["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,
|
||||
)
|
||||
|
||||
def remux(self, decrypted_path: Path, remuxed_path: Path) -> None:
|
||||
if self.downloader.remux_mode == RemuxMode.MP4BOX:
|
||||
self.remux_mp4box(decrypted_path, remuxed_path)
|
||||
elif self.downloader.remux_mode == RemuxMode.FFMPEG:
|
||||
self.remux_ffmpeg(decrypted_path, remuxed_path)
|
||||
|
||||
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.mp4box_path_full,
|
||||
"-quiet",
|
||||
"-add",
|
||||
decrypted_path,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-new",
|
||||
remuxed_path,
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
def remux_ffmpeg(self, decrypted_path: Path, remuxed_path: Path) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.ffmpeg_path_full,
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-i",
|
||||
decrypted_path,
|
||||
"-c",
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
remuxed_path,
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
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) -> Path:
|
||||
return final_path.parent / f"Cover.{self.downloader.cover_format.value}"
|
||||
|
||||
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,118 @@
|
||||
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,
|
||||
)
|
||||
|
||||
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.mp4box_path_full,
|
||||
"-quiet",
|
||||
"-add",
|
||||
decrypted_path,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-new",
|
||||
remuxed_path,
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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,47 @@
|
||||
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"
|
||||
ALAC = "alac"
|
||||
ATMOS = "atmos"
|
||||
ASK = "ask"
|
||||
|
||||
|
||||
class SyncedLyricsFormat(Enum):
|
||||
LRC = "lrc"
|
||||
SRT = "srt"
|
||||
TTML = "ttml"
|
||||
|
||||
|
||||
class MusicVideoCodec(Enum):
|
||||
H264_BEST = "h264-best"
|
||||
H265_BEST = "h265-best"
|
||||
ASK = "ask"
|
||||
|
||||
|
||||
class PostQuality(Enum):
|
||||
BEST = "best"
|
||||
ASK = "ask"
|
||||
|
||||
|
||||
class CoverFormat(Enum):
|
||||
JPG = "jpg"
|
||||
PNG = "png"
|
||||
-404
@@ -1,404 +0,0 @@
|
||||
from pathlib import Path
|
||||
from http.cookiejar import MozillaCookieJar
|
||||
import re
|
||||
import base64
|
||||
import datetime
|
||||
from xml.etree import ElementTree
|
||||
import functools
|
||||
import subprocess
|
||||
import shutil
|
||||
import gamdl.storefront_ids
|
||||
from pywidevine import Cdm
|
||||
from pywidevine import Device
|
||||
import requests
|
||||
import m3u8
|
||||
from yt_dlp import YoutubeDL
|
||||
from pywidevine.pssh import PSSH
|
||||
from pywidevine.license_protocol_pb2 import WidevinePsshData
|
||||
from mutagen.mp4 import MP4, MP4Cover
|
||||
|
||||
|
||||
class Gamdl:
|
||||
def __init__(self, device_path, cookies_location, disable_music_video_skip, prefer_hevc, temp_path, final_path, no_lrc, skip_cleanup):
|
||||
self.disable_music_video_skip = disable_music_video_skip
|
||||
self.prefer_hevc = prefer_hevc
|
||||
self.temp_path = Path(temp_path)
|
||||
self.final_path = Path(final_path)
|
||||
self.no_lrc = no_lrc
|
||||
self.skip_cleanup = skip_cleanup
|
||||
with open(Path(device_path) / 'device_client_id_blob', 'rb') as client_id, open(Path(device_path) / 'device_private_key', 'rb') as private_key:
|
||||
self.cdm = Cdm.from_device(
|
||||
Device(
|
||||
type_ = 'ANDROID',
|
||||
security_level = 3,
|
||||
flags = None,
|
||||
private_key = private_key.read(),
|
||||
client_id = client_id.read()
|
||||
)
|
||||
)
|
||||
self.cdm_session = self.cdm.open()
|
||||
cookies = MozillaCookieJar(Path(cookies_location))
|
||||
cookies.load(ignore_discard = True, ignore_expires = True)
|
||||
self.session = requests.Session()
|
||||
self.session.cookies.update(cookies)
|
||||
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()['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': 'https://beta.music.apple.com'
|
||||
})
|
||||
web_page = self.session.get('https://beta.music.apple.com').text
|
||||
index_js_uri = re.search('(?<=index\.)(.*?)(?=\.js")', web_page).group(1)
|
||||
index_js_page = self.session.get(f'https://beta.music.apple.com/assets/index.{index_js_uri}.js').text
|
||||
token = re.search('(?=eyJh)(.*?)(?=")', index_js_page).group(1)
|
||||
self.session.headers.update({"authorization": f'Bearer {token}'})
|
||||
self.country = self.session.cookies.get_dict()['itua']
|
||||
self.storefront = getattr(gamdl.storefront_ids, self.country.upper())
|
||||
|
||||
|
||||
def get_download_queue(self, url):
|
||||
download_queue = []
|
||||
product_id = url.split('/')[-1].split('i=')[-1].split('&')[0].split('?')[0]
|
||||
response = self.session.get(f'https://amp-api.music.apple.com/v1/catalog/{self.country}/?ids[songs]={product_id}&ids[albums]={product_id}&ids[playlists]={product_id}&ids[music-videos]={product_id}&l=en').json()['data'][0]
|
||||
if response['type'] in ('songs', 'music-videos') and 'playParams' in response['attributes']:
|
||||
download_queue.append(response)
|
||||
if response['type'] == 'albums' or response['type'] == 'playlists':
|
||||
for track in response['relationships']['tracks']['data']:
|
||||
if 'playParams' in track['attributes']:
|
||||
if track['type'] == 'music-videos' and self.disable_music_video_skip:
|
||||
download_queue.append(track)
|
||||
if track['type'] == 'songs':
|
||||
download_queue.append(track)
|
||||
if not download_queue:
|
||||
raise Exception('Criteria not met')
|
||||
return download_queue
|
||||
|
||||
|
||||
def get_webplayback(self, track_id):
|
||||
response = self.session.post(
|
||||
'https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback',
|
||||
json = {
|
||||
'salableAdamId': track_id
|
||||
}
|
||||
).json()["songList"][0]
|
||||
return response
|
||||
|
||||
|
||||
def get_playlist_music_video(self, webplayback):
|
||||
return m3u8.load(webplayback['hls-playlist-url'])
|
||||
|
||||
|
||||
def get_stream_url_song(self, webplayback):
|
||||
return next(i for i in webplayback["assets"] if i["flavor"] == "28:ctrp256")['URL']
|
||||
|
||||
|
||||
def get_stream_url_music_video_audio(self, playlist):
|
||||
return [i for i in playlist.media if i.type == "AUDIO"][-1].uri
|
||||
|
||||
|
||||
def get_stream_url_music_video_video(self, playlist):
|
||||
if self.prefer_hevc:
|
||||
return playlist.playlists[-1].uri
|
||||
else:
|
||||
return [i for i in playlist.playlists if 'avc' in i.stream_info.codecs][-1].uri
|
||||
|
||||
|
||||
def get_encrypted_location_video(self, track_id):
|
||||
return self.temp_path / f'{track_id}_encrypted_video.mp4'
|
||||
|
||||
|
||||
def get_encrypted_location_audio(self, track_id):
|
||||
return self.temp_path / f'{track_id}_encrypted_audio.mp4'
|
||||
|
||||
|
||||
def get_decrypted_location_video(self, track_id):
|
||||
return self.temp_path / f'{track_id}_decrypted_video.mp4'
|
||||
|
||||
|
||||
def get_decrypted_location_audio(self, track_id):
|
||||
return self.temp_path / f'{track_id}_decrypted_audio.mp4'
|
||||
|
||||
|
||||
def get_fixed_location(self, track_id, file_extension):
|
||||
return self.temp_path / f'{track_id}_fixed{file_extension}'
|
||||
|
||||
|
||||
def download(self, encrypted_location, stream_url):
|
||||
with YoutubeDL({
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
'outtmpl': str(encrypted_location),
|
||||
'allow_unplayable_formats': True,
|
||||
'fixup': 'never',
|
||||
'overwrites': True,
|
||||
'external_downloader': 'aria2c'
|
||||
}) as ydl:
|
||||
ydl.download(stream_url)
|
||||
|
||||
|
||||
def get_license_b64(self, challenge, track_uri, track_id):
|
||||
return self.session.post(
|
||||
'https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/acquireWebPlaybackLicense',
|
||||
json = {
|
||||
'challenge': challenge,
|
||||
'key-system': 'com.widevine.alpha',
|
||||
'uri': track_uri,
|
||||
'adamId': track_id,
|
||||
'isLibrary': False,
|
||||
'user-initiated': True
|
||||
}
|
||||
).json()['license']
|
||||
|
||||
|
||||
def get_decryption_keys_music_video(self, stream_url, track_id):
|
||||
playlist = m3u8.load(stream_url)
|
||||
track_uri = next(i for i in playlist.keys if i.keyformat == "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed").uri
|
||||
pssh = PSSH(track_uri.split(',')[1])
|
||||
challenge = base64.b64encode(self.cdm.get_license_challenge(self.cdm_session, pssh)).decode('utf-8')
|
||||
license_b64 = self.get_license_b64(challenge, track_uri, track_id)
|
||||
self.cdm.parse_license(self.cdm_session, license_b64)
|
||||
return f'1:{next(i for i in self.cdm.get_keys(self.cdm_session) if i.type == "CONTENT").key.hex()}'
|
||||
|
||||
|
||||
def get_decryption_keys_song(self, stream_url, track_id):
|
||||
track_uri = m3u8.load(stream_url).keys[0].uri
|
||||
wvpsshdata = WidevinePsshData()
|
||||
wvpsshdata.algorithm = 1
|
||||
wvpsshdata.key_ids.append(base64.b64decode(track_uri.split(",")[1]))
|
||||
pssh = PSSH(base64.b64encode(wvpsshdata.SerializeToString()).decode('utf-8'))
|
||||
challenge = base64.b64encode(self.cdm.get_license_challenge(self.cdm_session, pssh)).decode('utf-8')
|
||||
license_b64 = self.get_license_b64(challenge, track_uri, track_id)
|
||||
self.cdm.parse_license(self.cdm_session, license_b64)
|
||||
return f'1:{next(i for i in self.cdm.get_keys(self.cdm_session) if i.type == "CONTENT").key.hex()}'
|
||||
|
||||
|
||||
def decrypt(self, encrypted_location, decrypted_location, decryption_keys):
|
||||
subprocess.run(
|
||||
[
|
||||
'mp4decrypt',
|
||||
encrypted_location,
|
||||
'--key',
|
||||
decryption_keys,
|
||||
decrypted_location
|
||||
],
|
||||
check = True
|
||||
)
|
||||
|
||||
|
||||
def get_synced_lyrics_formated_time(self, unformatted_time):
|
||||
if 's' in unformatted_time:
|
||||
unformatted_time = unformatted_time.replace('s', '')
|
||||
if '.' not in unformatted_time:
|
||||
unformatted_time += '.0'
|
||||
s = int(unformatted_time.split('.')[-2].split(':')[-1]) * 1000
|
||||
try:
|
||||
m = int(unformatted_time.split('.')[-2].split(':')[-2]) * 60000
|
||||
except:
|
||||
m = 0
|
||||
ms = f'{int(unformatted_time.split(".")[-1]):03d}'
|
||||
if int(ms[2]) >= 5:
|
||||
ms = int(f'{int(ms[:2]) + 1}') * 10
|
||||
else:
|
||||
ms = int(ms)
|
||||
formated_time = datetime.datetime.fromtimestamp((s + m + ms)/1000.0)
|
||||
return formated_time.strftime('%M:%S.%f')[:-4]
|
||||
|
||||
|
||||
def get_lyrics(self, track_id):
|
||||
try:
|
||||
lyrics_ttml = ElementTree.fromstring(self.session.get(f'https://amp-api.music.apple.com/v1/catalog/{self.country}/songs/{track_id}/lyrics').json()['data'][0]['attributes']['ttml'])
|
||||
except:
|
||||
return None, None
|
||||
unsynced_lyrics = ''
|
||||
synced_lyrics = ''
|
||||
for div in lyrics_ttml.iter('{http://www.w3.org/ns/ttml}div'):
|
||||
for p in div.iter('{http://www.w3.org/ns/ttml}p'):
|
||||
if p.attrib.get('begin'):
|
||||
synced_lyrics += f'[{self.get_synced_lyrics_formated_time(p.attrib.get("begin"))}]{p.text}\n'
|
||||
if p.text is not None:
|
||||
unsynced_lyrics += p.text + '\n'
|
||||
unsynced_lyrics += '\n'
|
||||
return unsynced_lyrics[:-2], synced_lyrics
|
||||
|
||||
|
||||
@functools.lru_cache()
|
||||
def get_cover(self, url):
|
||||
return requests.get(url).content
|
||||
|
||||
|
||||
def get_tags_song(self, webplayback, unsynced_lyrics, genre):
|
||||
metadata = next(i for i in webplayback["assets"] if i["flavor"] == "28:ctrp256")['metadata']
|
||||
cover_url = next(i for i in webplayback["assets"] if i["flavor"] == "28:ctrp256")['artworkURL']
|
||||
tags = {
|
||||
'\xa9nam': [metadata['itemName']],
|
||||
'\xa9gen': [genre],
|
||||
'aART': [metadata['playlistArtistName']],
|
||||
'\xa9alb': [metadata['playlistName']],
|
||||
'soar': [metadata['sort-artist']],
|
||||
'soal': [metadata['sort-album']],
|
||||
'sonm': [metadata['sort-name']],
|
||||
'\xa9ART': [metadata['artistName']],
|
||||
'geID': [metadata['genreId']],
|
||||
'atID': [int(metadata['artistId'])],
|
||||
'plID': [int(metadata['playlistId'])],
|
||||
'cnID': [int(metadata['itemId'])],
|
||||
'sfID': [metadata['s']],
|
||||
'rtng': [metadata['explicit']],
|
||||
'pgap': metadata['gapless'],
|
||||
'cpil': metadata['compilation'],
|
||||
'disk': [(metadata['discNumber'], metadata['discCount'])],
|
||||
'trkn': [(metadata['trackNumber'], metadata['trackCount'])],
|
||||
'covr': [MP4Cover(self.get_cover(cover_url), MP4Cover.FORMAT_JPEG)],
|
||||
'stik': [1]
|
||||
}
|
||||
if 'copyright' in metadata:
|
||||
tags['cprt'] = [metadata['copyright']]
|
||||
if 'releaseDate' in metadata:
|
||||
tags['\xa9day'] = [metadata['releaseDate']]
|
||||
if 'comments' in metadata:
|
||||
tags['\xa9cmt'] = [metadata['comments']]
|
||||
if 'xid' in metadata:
|
||||
tags['xid '] = [metadata['xid']]
|
||||
if 'composerId' in metadata:
|
||||
tags['cmID'] = [int(metadata['composerId'])]
|
||||
tags['\xa9wrt'] = [metadata['composerName']]
|
||||
tags['soco'] = [metadata['sort-composer']]
|
||||
if unsynced_lyrics:
|
||||
tags['\xa9lyr'] = [unsynced_lyrics]
|
||||
return tags
|
||||
|
||||
|
||||
def get_tags_music_video(self, track_id):
|
||||
metadata = requests.get(f'https://itunes.apple.com/lookup?id={track_id}&entity=album&limit=200&country={self.country}&lang=en_US').json()['results']
|
||||
extra_metadata = requests.get(f'https://music.apple.com/music-video/{metadata[0]["trackId"]}', headers = {'X-Apple-Store-Front': f'{self.storefront} t:music31'}).json()['storePlatformData']['product-dv']['results'][str(metadata[0]['trackId'])]
|
||||
tags = {
|
||||
'\xa9ART': [metadata[0]["artistName"]],
|
||||
'\xa9nam': [metadata[0]["trackCensoredName"]],
|
||||
'\xa9day': [metadata[0]["releaseDate"]],
|
||||
'cprt': [extra_metadata['copyright']],
|
||||
'\xa9gen': [metadata[0]['primaryGenreName']],
|
||||
'stik': [6],
|
||||
'atID': [metadata[0]['artistId']],
|
||||
'cnID': [metadata[0]["trackId"]],
|
||||
'geID': [int(extra_metadata['genres'][0]['genreId'])],
|
||||
'sfID': [int(self.storefront.split('-')[0])],
|
||||
'covr': [MP4Cover(self.get_cover(metadata[0]["artworkUrl30"].replace('30x30bb.jpg', '600x600bb.jpg')), MP4Cover.FORMAT_JPEG)]
|
||||
}
|
||||
if metadata[0]['trackExplicitness'] == 'notExplicit':
|
||||
tags['rtng'] = [0]
|
||||
elif metadata[0]['trackExplicitness'] == 'explicit':
|
||||
tags['rtng'] = [1]
|
||||
else:
|
||||
tags['rtng'] = [2]
|
||||
if len(metadata) > 1:
|
||||
tags['\xa9alb'] = [metadata[1]["collectionCensoredName"]]
|
||||
tags['aART'] = [metadata[1]["artistName"]]
|
||||
tags['plID'] = [metadata[1]["collectionId"]]
|
||||
tags['disk'] = [(metadata[0]["discNumber"], metadata[0]["discCount"])]
|
||||
tags['trkn'] = [(metadata[0]["trackNumber"], metadata[0]["trackCount"])]
|
||||
return tags
|
||||
|
||||
|
||||
def get_sanizated_string(self, dirty_string, is_folder):
|
||||
for character in ['\\', '/', ':', '*', '?', '"', '<', '>', '|', ';']:
|
||||
dirty_string = dirty_string.replace(character, '_')
|
||||
if is_folder:
|
||||
dirty_string = dirty_string[:40]
|
||||
if dirty_string[-1:] == '.':
|
||||
dirty_string = dirty_string[:-1] + '_'
|
||||
else:
|
||||
dirty_string = dirty_string[:36]
|
||||
return dirty_string.strip()
|
||||
|
||||
|
||||
def get_final_location_overwrite_prevented_music_video(self, final_location):
|
||||
count = 1
|
||||
while True:
|
||||
if final_location.with_name(f'{final_location.stem} {count}.m4v').exists():
|
||||
count += 1
|
||||
else:
|
||||
return final_location.with_name(f'{final_location.stem} {count}.m4v')
|
||||
|
||||
|
||||
def get_final_location(self, file_extension, tags):
|
||||
final_location = self.final_path
|
||||
if 'plID' in tags:
|
||||
if tags['disk'][0][1] > 1:
|
||||
file_name = self.get_sanizated_string(f'{tags["disk"][0][0]}-{tags["trkn"][0][0]:02d} {tags["©nam"][0]}', False)
|
||||
else:
|
||||
file_name = self.get_sanizated_string(f'{tags["trkn"][0][0]:02d} {tags["©nam"][0]}', False)
|
||||
if 'cpil' in tags and tags['cpil']:
|
||||
final_location /= f'Compilations/{self.get_sanizated_string(tags["©alb"][0], True)}'
|
||||
else:
|
||||
final_location /= f'{self.get_sanizated_string(tags["aART"][0], True)}/{self.get_sanizated_string(tags["©alb"][0], True)}'
|
||||
else:
|
||||
file_name = self.get_sanizated_string(tags["©nam"][0], False)
|
||||
final_location /= f'{self.get_sanizated_string(tags["©ART"][0], True)}/Unknown Album/'
|
||||
final_location /= f'{file_name}{file_extension}'
|
||||
try:
|
||||
if file_extension == '.m4v' and final_location.exists() and MP4(final_location).tags['cnID'][0] != tags['cnID'][0]:
|
||||
final_location = self.get_final_location_overwrite_prevented_music_video(final_location)
|
||||
except:
|
||||
pass
|
||||
return final_location
|
||||
|
||||
|
||||
def fixup_music_video(self, decrypted_location_audio, decrypted_location_video, fixed_location):
|
||||
subprocess.run(
|
||||
[
|
||||
'MP4Box',
|
||||
'-quiet',
|
||||
'-add',
|
||||
decrypted_location_audio,
|
||||
'-add',
|
||||
decrypted_location_video,
|
||||
'-itags',
|
||||
'artist=placeholder',
|
||||
fixed_location
|
||||
],
|
||||
check = True
|
||||
)
|
||||
|
||||
|
||||
def fixup_song(self, decrypted_location, fixed_location):
|
||||
subprocess.run(
|
||||
[
|
||||
'MP4Box',
|
||||
'-quiet',
|
||||
'-add',
|
||||
decrypted_location,
|
||||
'-itags',
|
||||
'artist=placeholder',
|
||||
fixed_location
|
||||
],
|
||||
check = True
|
||||
)
|
||||
|
||||
|
||||
def make_lrc(self, final_location, synced_lyrics):
|
||||
if synced_lyrics and not self.no_lrc:
|
||||
with open(final_location.with_suffix('.lrc'), 'w', encoding = 'utf8') as f:
|
||||
f.write(synced_lyrics)
|
||||
|
||||
|
||||
def make_final(self, final_location, fixed_location, tags):
|
||||
final_location.parent.mkdir(parents = True, exist_ok = True)
|
||||
shutil.copy(fixed_location, final_location)
|
||||
file = MP4(final_location).tags
|
||||
file.update(tags)
|
||||
file.save(final_location)
|
||||
|
||||
|
||||
def cleanup(self):
|
||||
if self.temp_path.exists() and not self.skip_cleanup:
|
||||
shutil.rmtree(self.temp_path)
|
||||
|
||||
@@ -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,25 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class UrlInfo:
|
||||
storefront: str = None
|
||||
type: str = None
|
||||
id: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadQueueItem:
|
||||
metadata: dict = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Lyrics:
|
||||
synced: str = None
|
||||
unsynced: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamInfo:
|
||||
stream_url: str = None
|
||||
pssh: str = None
|
||||
@@ -1,155 +0,0 @@
|
||||
AE = "143481-2,32"
|
||||
AG = "143540-2,32"
|
||||
AI = "143538-2,32"
|
||||
AL = "143575-2,32"
|
||||
AM = "143524-2,32"
|
||||
AO = "143564-2,32"
|
||||
AR = "143505-28,32"
|
||||
AT = "143445-4,32"
|
||||
AU = "143460-27,32"
|
||||
AZ = "143568-2,32"
|
||||
BB = "143541-2,32"
|
||||
BE = "143446-2,32"
|
||||
BF = "143578-2,32"
|
||||
BG = "143526-2,32"
|
||||
BH = "143559-2,32"
|
||||
BJ = "143576-2,32"
|
||||
BM = "143542-2,32"
|
||||
BN = "143560-2,32"
|
||||
BO = "143556-28,32"
|
||||
BR = "143503-15,32"
|
||||
BS = "143539-2,32"
|
||||
BT = "143577-2,32"
|
||||
BW = "143525-2,32"
|
||||
BY = "143565-2,32"
|
||||
BZ = "143555-2,32"
|
||||
CA = "143455-6,32"
|
||||
CG = "143582-2,32"
|
||||
CH = "143459-57,32"
|
||||
CL = "143483-28,32"
|
||||
CN = "143465-19,32"
|
||||
CO = "143501-28,32"
|
||||
CR = "143495-28,32"
|
||||
CV = "143580-2,32"
|
||||
CY = "143557-2,32"
|
||||
CZ = "143489-2,32"
|
||||
DE = "143443-4,32"
|
||||
DK = "143458-2,32"
|
||||
DM = "143545-2,32"
|
||||
DO = "143508-28,32"
|
||||
DZ = "143563-2,32"
|
||||
EC = "143509-28,32"
|
||||
EE = "143518-2,32"
|
||||
EG = "143516-2,32"
|
||||
ES = "143454-8,32"
|
||||
FI = "143447-2,32"
|
||||
FJ = "143583-2,32"
|
||||
FM = "143591-2,32"
|
||||
FR = "143442-3,32"
|
||||
GB = "143444-2,32"
|
||||
GD = "143546-2,32"
|
||||
GH = "143573-2,32"
|
||||
GM = "143584-2,32"
|
||||
GR = "143448-2,32"
|
||||
GT = "143504-28,32"
|
||||
GW = "143585-2,32"
|
||||
GY = "143553-2,32"
|
||||
HK = "143463-45,32"
|
||||
HN = "143510-28,32"
|
||||
HR = "143494-2,32"
|
||||
HU = "143482-2,32"
|
||||
ID = "143476-2,32"
|
||||
IE = "143449-2,32"
|
||||
IL = "143491-2,32"
|
||||
IN = "143467-2,32"
|
||||
IS = "143558-2,32"
|
||||
IT = "143450-7,32"
|
||||
JM = "143511-2,32"
|
||||
JO = "143528-2,32"
|
||||
JP = "143462-9,32"
|
||||
KE = "143529-2,32"
|
||||
KG = "143586-2,32"
|
||||
KH = "143579-2,32"
|
||||
KN = "143548-2,32"
|
||||
KR = "143466-13,32"
|
||||
KW = "143493-2,32"
|
||||
KY = "143544-2,32"
|
||||
KZ = "143517-2,32"
|
||||
LA = "143587-2,32"
|
||||
LB = "143497-2,32"
|
||||
LC = "143549-2,32"
|
||||
LK = "143486-2,32"
|
||||
LR = "143588-2,32"
|
||||
LT = "143520-2,32"
|
||||
LU = "143451-2,32"
|
||||
LV = "143519-2,32"
|
||||
MD = "143523-2,32"
|
||||
MG = "143531-2,32"
|
||||
MK = "143530-2,32"
|
||||
ML = "143532-2,32"
|
||||
MN = "143592-2,32"
|
||||
MO = "143515-45,32"
|
||||
MR = "143590-2,32"
|
||||
MS = "143547-2,32"
|
||||
MT = "143521-2,32"
|
||||
MU = "143533-2,32"
|
||||
MW = "143589-2,32"
|
||||
MX = "143468-28,32"
|
||||
MY = "143473-2,32"
|
||||
MZ = "143593-2,32"
|
||||
NA = "143594-2,32"
|
||||
NE = "143534-2,32"
|
||||
NG = "143561-2,32"
|
||||
NI = "143512-28,32"
|
||||
NL = "143452-10,32"
|
||||
NO = "143457-2,32"
|
||||
NP = "143484-2,32"
|
||||
NZ = "143461-27,32"
|
||||
OM = "143562-2,32"
|
||||
PA = "143485-28,32"
|
||||
PE = "143507-28,32"
|
||||
PG = "143597-2,32"
|
||||
PH = "143474-2,32"
|
||||
PK = "143477-2,32"
|
||||
PL = "143478-2,32"
|
||||
PT = "143453-24,32"
|
||||
PW = "143595-2,32"
|
||||
PY = "143513-28,32"
|
||||
QA = "143498-2,32"
|
||||
RO = "143487-2,32"
|
||||
RU = "143469-16,32"
|
||||
SA = "143479-2,32"
|
||||
SB = "143601-2,32"
|
||||
SC = "143599-2,32"
|
||||
SE = "143456-17,32"
|
||||
SG = "143464-19,32"
|
||||
SI = "143499-2,32"
|
||||
SK = "143496-2,32"
|
||||
SL = "143600-2,32"
|
||||
SN = "143535-2,32"
|
||||
SR = "143554-2,32"
|
||||
ST = "143598-2,32"
|
||||
SV = "143506-28,32"
|
||||
SZ = "143602-2,32"
|
||||
TC = "143552-2,32"
|
||||
TD = "143581-2,32"
|
||||
TH = "143475-2,32"
|
||||
TJ = "143603-2,32"
|
||||
TM = "143604-2,32"
|
||||
TN = "143536-2,32"
|
||||
TR = "143480-2,32"
|
||||
TT = "143551-2,32"
|
||||
TW = "143470-18,32"
|
||||
TZ = "143572-2,32"
|
||||
UA = "143492-2,32"
|
||||
UG = "143537-2,32"
|
||||
US = "143441-1,32"
|
||||
UY = "143514-2,32"
|
||||
UZ = "143566-2,32"
|
||||
VC = "143550-2,32"
|
||||
VE = "143502-28,32"
|
||||
VG = "143543-2,32"
|
||||
VN = "143471-2,32"
|
||||
YE = "143571-2,32"
|
||||
ZA = "143472-2,32"
|
||||
ZW = "143605-2,32"
|
||||
+8
-5
@@ -1,13 +1,16 @@
|
||||
[project]
|
||||
name = "gamdl"
|
||||
description = "Download Apple Music songs/music videos/albums/playlists"
|
||||
requires-python = ">=3.7"
|
||||
authors = [{name = "glomatico"}]
|
||||
requires-python = ">=3.8"
|
||||
authors = [{ name = "glomatico" }]
|
||||
dependencies = [
|
||||
"ciso8601",
|
||||
"click",
|
||||
"m3u8",
|
||||
"tabulate",
|
||||
"pywidevine",
|
||||
"pyyaml",
|
||||
"m3u8",
|
||||
"yt-dlp"
|
||||
"yt-dlp",
|
||||
]
|
||||
readme = "README.md"
|
||||
dynamic = ["version"]
|
||||
@@ -21,4 +24,4 @@ requires = ["flit_core"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
|
||||
[project.scripts]
|
||||
gamdl = "gamdl:main"
|
||||
gamdl = "gamdl.cli:main"
|
||||
|
||||
+4
-1
@@ -1,4 +1,7 @@
|
||||
ciso8601
|
||||
click
|
||||
m3u8
|
||||
tabulate
|
||||
pywidevine
|
||||
pyyaml
|
||||
m3u8
|
||||
yt-dlp
|
||||
|
||||
Reference in New Issue
Block a user