mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-13 12:15:18 +03:00
Compare commits
161 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+3
-1
@@ -1,5 +1,7 @@
|
||||
/*
|
||||
__pycache__
|
||||
!gamdl
|
||||
!requirements.txt
|
||||
!.gitignore
|
||||
!pyproject.toml
|
||||
!README.md
|
||||
!requirements.txt
|
||||
|
||||
@@ -1,91 +1,145 @@
|
||||
# Glomatico's ✨ Apple Music ✨ Downloader
|
||||
A Python script to download Apple Music songs/music videos/albums/playlists.
|
||||
# gamdl - Glomatico's Apple Music Downloader
|
||||
A Python script to download Apple Music songs/music videos/albums/playlists. This is a rework of https://github.com/loveyoursupport/AppleMusic-Downloader/tree/661a274d62586b521feec5a7de6bee0e230fdb7d.
|
||||
|
||||

|
||||
|
||||
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
|
||||
```
|
||||
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` to the same folder that you will run the script
|
||||
## Features
|
||||
* Download songs in 256kbps AAC or in 64kbps HE-AAC
|
||||
* Download music videos up to 4K
|
||||
* Download synced lyrics
|
||||
* Choose between FFmpeg and MP4Box for remuxing
|
||||
* Choose between yt-dlp and N_m3u8DL-RE for downloading
|
||||
* Highly customizable
|
||||
|
||||
## Installation
|
||||
1. Install Python 3.7 or higher
|
||||
2. Add [FFmpeg](https://ffmpeg.org/download.html) and [mp4decrypt](https://www.bento4.com/downloads/) to PATH
|
||||
* mp4decrypt is only needed if you want to download music videos
|
||||
3. Place your cookies in the same folder that you will run gamdl as `cookies.txt`
|
||||
* You can export your cookies by using this Google Chrome extension on Apple Music website: https://chrome.google.com/webstore/detail/open-cookiestxt/gdocmgbfkjnnpapoeobnolbbkoibbcif. Make sure to be logged in.
|
||||
5. Put your Widevine Device file (.wvd) in the same folder that you will run the script
|
||||
* You can use Dumper to dump your phone's L3 CDM: https://github.com/Diazole/dumper. Once you have the L3 CDM, you can use pywidevine to create the .wvd file from it.
|
||||
4. Place your .wvd file in the same folder that you will run gamdl as `device.wvd`
|
||||
* To get a .wvd file, you can use [dumper](https://github.com/wvdumper/dumper) to dump a L3 CDM from an Android device. Once you have the L3 CDM, use pywidevine to create the .wvd file from it.
|
||||
1. Install pywidevine with pip
|
||||
```
|
||||
```bash
|
||||
pip install pywidevine pyyaml
|
||||
```
|
||||
2. Create the .wvd file
|
||||
```
|
||||
```bash
|
||||
pywidevine create-device -t ANDROID -l 3 -k private_key.pem -c client_id.bin -o .
|
||||
```
|
||||
6. (optional) Add aria2c to your PATH for faster downloads
|
||||
* You can get it from here: https://github.com/aria2/aria2/releases.
|
||||
5. Install gamdl using pip
|
||||
```bash
|
||||
pip install gamdl
|
||||
```
|
||||
|
||||
## Usage
|
||||
```
|
||||
usage: gamdl [-h] [-u [URLS_TXT]] [-w WVD_LOCATION] [-f FINAL_PATH] [-t TEMP_PATH] [-c COOKIES_LOCATION] [-m] [-p]
|
||||
[-o] [-n] [-s] [-e] [-i] [-v]
|
||||
[url ...]
|
||||
## Examples
|
||||
* 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 |
|
||||
| --------------------------------------------------------------- | ---------------------------------------------------------------------- | ---------------------------------- |
|
||||
| `-f`, `--final-path` / `final_path` | Path where the downloaded files will be saved. | `./Apple Music` |
|
||||
| `-t`, `--temp-path` / `temp_path` | Path where the temporary files will be saved. | `./temp` |
|
||||
| `-c`, `--cookies-location` / `cookies_location` | Location of the cookies file. | `./cookies.txt` |
|
||||
| `-w`, `--wvd-location` / `wvd_location` | Location of the .wvd file. | `./device.wvd` |
|
||||
| `--ffmpeg-location` / `ffmpeg_location` | Location of the FFmpeg binary. | `ffmpeg` |
|
||||
| `--mp4box-location` / `mp4box_location` | Location of the MP4Box binary. | `MP4Box` |
|
||||
| `--mp4decrypt-location` / `mp4decrypt_location` | Location of the mp4decrypt binary. | `mp4decrypt` |
|
||||
| `--nm3u8dlre-location` / `nm3u8dlre_location` | Location of the N_m3u8DL-RE binary. | `N_m3u8DL-RE` |
|
||||
| `--config-location` / - | Location of the config file. | `<home_folder>/.gamdl/config.json` |
|
||||
| `--template-folder-album` / `template_folder_album` | Template of the album folders as a format string. | `{album_artist}/{album}` |
|
||||
| `--template-folder-compilation` / `template_folder_compilation` | Template of the compilation album folders as a format string. | `Compilations/{album}` |
|
||||
| `--template-file-single-disc` / `template_file_single_disc` | Template of the track files for single-disc albums as a format string. | `{track:02d} {title}` |
|
||||
| `--template-file-multi-disc` / `template_file_multi_disc` | Template of the track files for multi-disc albums as a format string. | `{disc}-{track:02d} {title}` |
|
||||
| `--template-folder-music-video` / `template_folder_music_video` | Template of the music video folders as a format string. | `{artist}/Unknown Album` |
|
||||
| `--template-file-music-video` / `template_file_music_video` | Template of the music video files as a format string. | `{title}` |
|
||||
| `--cover-size` / `cover_size` | Size of the cover. | `1200` |
|
||||
| `--cover-format` / `cover_format` | Format of the cover. | `jpg` |
|
||||
| `--remux-mode` / `remux_mode` | Remux mode. | `ffmpeg` |
|
||||
| `--download-mode` / `download_mode` | Download mode. | `ytdlp` |
|
||||
| `-e`, `--exclude-tags` / `exclude_tags` | List of tags to exclude from file tagging separated by commas. | `null` |
|
||||
| `--truncate` / `truncate` | Maximum length of the file/folder names. | `40` |
|
||||
| `-l`, `--log-level` / `log_level` | Log level. | `INFO` |
|
||||
| `--prefer-hevc` / `prefer_hevc` | Prefer HEVC over AVC when downloading music videos. | `false` |
|
||||
| `--ask-video-format` / `ask_video_format` | Ask for the video format when downloading music videos. | `false` |
|
||||
| `--disable-music-video-skip` / `disable_music_video_skip` | Don't skip downloading music videos in albums/playlists. | `false` |
|
||||
| `-l`, `--lrc-only` / `lrc_only` | Download only the synced lyrics. | `false` |
|
||||
| `-n`, `--no-lrc` / `no_lrc` | Don't download the synced lyrics. | `false` |
|
||||
| `-s`, `--save-cover` / `save_cover` | Save cover as a separate file. | `false` |
|
||||
| `--songs-heaac` / `songs_heaac` | Download songs in HE-AAC 64kbps. | `false` |
|
||||
| `-o`, `--overwrite` / `overwrite` | Overwrite existing files. | `false` |
|
||||
| `--print-exceptions` / `print_exceptions` | Print exceptions. | `false` |
|
||||
| `-u`, `--url-txt` / - | Read URLs as location of text files containing URLs. | `false` |
|
||||
| `-n`, `--no-config-file` / - | Don't use the config file. | `false` |
|
||||
|
||||
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`
|
||||
|
||||
### Remux mode
|
||||
The following remux modes are available:
|
||||
* `ffmpeg`
|
||||
* Can decrypt and remux songs but can't decrypt music videos by itself
|
||||
* Decryption may not work on older versions of FFmpeg
|
||||
* `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
|
||||
|
||||
options:
|
||||
-h, --help show this help message and exit
|
||||
-u [URLS_TXT], --urls-txt [URLS_TXT]
|
||||
Read URLs from a text file (default: None)
|
||||
-w WVD_LOCATION, --wvd-location WVD_LOCATION
|
||||
.wvd file location (default: *.wvd)
|
||||
-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)
|
||||
-o, --overwrite Overwrite existing files (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)
|
||||
-i, --print-video-m3u8-url
|
||||
Print Video M3U8 URL (default: False)
|
||||
-v, --version show program's version number and exit
|
||||
```
|
||||
### Download mode
|
||||
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
|
||||
|
||||
## 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
|
||||
## Music videos quality
|
||||
Music videos will be downloaded in the highest quality available by default. The available qualities are:
|
||||
* AVC 1080p 10mbps, AAC 256kbps
|
||||
* AVC 1080p 6.5mbps, AAC 256kbps
|
||||
* AVC 720p 4mbps, AAC 256kbps
|
||||
* AVC 576p 2mbps, AAC 256kbps
|
||||
* AVC 480p 1.5mbps, AAC 256kbps
|
||||
* AVC 360p 1mbps, AAC 256kbps
|
||||
|
||||
Some videos may include EIA-608 closed captions.
|
||||
By enabling the `prefer_hevc` option, music videos will be downloaded in the highest HEVC quality available. The available qualities are:
|
||||
* HEVC 4K 20mbps, AAC 256kbps
|
||||
* HEVC 4K 12mbps, AAC 256kbps
|
||||
|
||||
Enable `ask_video_format` to select a custom audio/video format.
|
||||
|
||||
+1
-183
@@ -1,183 +1 @@
|
||||
import shutil
|
||||
import argparse
|
||||
import traceback
|
||||
from .gamdl import Gamdl
|
||||
|
||||
__version__ = '1.2'
|
||||
|
||||
|
||||
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(
|
||||
'-w',
|
||||
'--wvd-location',
|
||||
default = '*.wvd',
|
||||
help = '.wvd file location'
|
||||
)
|
||||
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(
|
||||
'-o',
|
||||
'--overwrite',
|
||||
action = 'store_true',
|
||||
help = 'Overwrite existing files'
|
||||
)
|
||||
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(
|
||||
'-i',
|
||||
'--print-video-m3u8-url',
|
||||
action = 'store_true',
|
||||
help = 'Print Video M3U8 URL'
|
||||
)
|
||||
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.wvd_location,
|
||||
args.cookies_location,
|
||||
args.disable_music_video_skip,
|
||||
args.prefer_hevc,
|
||||
args.temp_path,
|
||||
args.final_path,
|
||||
args.no_lrc,
|
||||
args.overwrite,
|
||||
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}/{len(url)} from URL {i + 1}/{len(download_queue)})')
|
||||
track_id = track['id']
|
||||
try:
|
||||
webplayback = dl.get_webplayback(track_id)
|
||||
if track['type'] == 'music-videos':
|
||||
if args.print_video_m3u8_url:
|
||||
print(webplayback['hls-playlist-url'])
|
||||
tags = dl.get_tags_music_video(track['attributes']['url'].split('/')[-1].split('?')[0])
|
||||
final_location = dl.get_final_location('.m4v', tags)
|
||||
if dl.check_exists(final_location) and not args.overwrite:
|
||||
continue
|
||||
playlist = dl.get_playlist_music_video(webplayback)
|
||||
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)
|
||||
fixed_location = dl.get_fixed_location(track_id, '.m4v')
|
||||
dl.fixup_music_video(decrypted_location_audio, decrypted_location_video, fixed_location)
|
||||
dl.make_final(final_location, fixed_location, tags)
|
||||
else:
|
||||
unsynced_lyrics, synced_lyrics = dl.get_lyrics(track_id)
|
||||
tags = dl.get_tags_song(webplayback, unsynced_lyrics)
|
||||
final_location = dl.get_final_location('.m4a', tags)
|
||||
if dl.check_exists(final_location) and not args.overwrite:
|
||||
continue
|
||||
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)
|
||||
fixed_location = dl.get_fixed_location(track_id, '.m4a')
|
||||
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}/{len(url)} from URL {i + 1}/{len(download_queue)})')
|
||||
if args.print_exceptions:
|
||||
traceback.print_exc()
|
||||
dl.cleanup()
|
||||
print(f'Done ({error_count} error(s))')
|
||||
__version__ = "1.9.9.2"
|
||||
|
||||
+2
-3
@@ -1,4 +1,3 @@
|
||||
import gamdl
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
gamdl.main()
|
||||
main()
|
||||
|
||||
+584
@@ -0,0 +1,584 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from . import __version__
|
||||
from .constants import *
|
||||
from .downloader import Downloader
|
||||
|
||||
|
||||
def write_default_config_file(ctx: click.Context) -> None:
|
||||
ctx.params["config_location"].parent.mkdir(parents=True, exist_ok=True)
|
||||
config_file = {
|
||||
param.name: param.default
|
||||
for param in ctx.command.params
|
||||
if param.name not in EXCLUDED_CONFIG_FILE_PARAMS
|
||||
}
|
||||
with open(ctx.params["config_location"], "w") as f:
|
||||
f.write(json.dumps(config_file, indent=4))
|
||||
|
||||
|
||||
def no_config_callback(
|
||||
ctx: click.Context, param: click.Parameter, no_config_file: bool
|
||||
) -> click.Context:
|
||||
if no_config_file:
|
||||
return ctx
|
||||
if not ctx.params["config_location"].exists():
|
||||
write_default_config_file(ctx)
|
||||
with open(ctx.params["config_location"], "r") as f:
|
||||
config_file = dict(json.load(f))
|
||||
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.argument(
|
||||
"urls",
|
||||
nargs=-1,
|
||||
type=str,
|
||||
required=True,
|
||||
)
|
||||
@click.option(
|
||||
"--final-path",
|
||||
"-f",
|
||||
type=Path,
|
||||
default="./Apple Music",
|
||||
help="Path where the downloaded files will be saved.",
|
||||
)
|
||||
@click.option(
|
||||
"--temp-path",
|
||||
"-t",
|
||||
type=Path,
|
||||
default="./temp",
|
||||
help="Path where the temporary files will be saved.",
|
||||
)
|
||||
@click.option(
|
||||
"--cookies-location",
|
||||
"-c",
|
||||
type=Path,
|
||||
default="./cookies.txt",
|
||||
help="Location of the cookies file.",
|
||||
)
|
||||
@click.option(
|
||||
"--wvd-location",
|
||||
"-w",
|
||||
type=Path,
|
||||
default="./device.wvd",
|
||||
help="Location of the .wvd file.",
|
||||
)
|
||||
@click.option(
|
||||
"--ffmpeg-location",
|
||||
type=str,
|
||||
default="ffmpeg",
|
||||
help="Location of the FFmpeg binary.",
|
||||
)
|
||||
@click.option(
|
||||
"--mp4box-location",
|
||||
type=str,
|
||||
default="MP4Box",
|
||||
help="Location of the MP4Box binary.",
|
||||
)
|
||||
@click.option(
|
||||
"--mp4decrypt-location",
|
||||
type=str,
|
||||
default="mp4decrypt",
|
||||
help="Location of the mp4decrypt binary.",
|
||||
)
|
||||
@click.option(
|
||||
"--nm3u8dlre-location",
|
||||
type=str,
|
||||
default="N_m3u8DL-RE",
|
||||
help="Location of the N_m3u8DL-RE binary.",
|
||||
)
|
||||
@click.option(
|
||||
"--config-location",
|
||||
type=Path,
|
||||
default=Path.home() / ".gamdl" / "config.json",
|
||||
help="Location of the config file.",
|
||||
)
|
||||
@click.option(
|
||||
"--template-folder-album",
|
||||
type=str,
|
||||
default="{album_artist}/{album}",
|
||||
help="Template of the album folders as a format string.",
|
||||
)
|
||||
@click.option(
|
||||
"--template-folder-compilation",
|
||||
type=str,
|
||||
default="Compilations/{album}",
|
||||
help="Template of the compilation album folders as a format string.",
|
||||
)
|
||||
@click.option(
|
||||
"--template-file-single-disc",
|
||||
type=str,
|
||||
default="{track:02d} {title}",
|
||||
help="Template of the track files for single-disc albums as a format string.",
|
||||
)
|
||||
@click.option(
|
||||
"--template-file-multi-disc",
|
||||
type=str,
|
||||
default="{disc}-{track:02d} {title}",
|
||||
help="Template of the track files for multi-disc albums as a format string.",
|
||||
)
|
||||
@click.option(
|
||||
"--template-folder-music-video",
|
||||
type=str,
|
||||
default="{artist}/Unknown Album",
|
||||
help="Template of the music video folders as a format string.",
|
||||
)
|
||||
@click.option(
|
||||
"--template-file-music-video",
|
||||
type=str,
|
||||
default="{title}",
|
||||
help="Template of the music video files as a format string.",
|
||||
)
|
||||
@click.option(
|
||||
"--cover-size",
|
||||
type=int,
|
||||
default=1200,
|
||||
help="Size of the cover.",
|
||||
)
|
||||
@click.option(
|
||||
"--cover-format",
|
||||
type=click.Choice(["jpg", "png"]),
|
||||
default="jpg",
|
||||
help="Format of the cover.",
|
||||
)
|
||||
@click.option(
|
||||
"--remux-mode",
|
||||
type=click.Choice(["ffmpeg", "mp4box"]),
|
||||
default="ffmpeg",
|
||||
help="Remux mode.",
|
||||
)
|
||||
@click.option(
|
||||
"--download-mode",
|
||||
type=click.Choice(["ytdlp", "nm3u8dlre"]),
|
||||
default="ytdlp",
|
||||
help="Download mode.",
|
||||
)
|
||||
@click.option(
|
||||
"--exclude-tags",
|
||||
"-e",
|
||||
type=str,
|
||||
default=None,
|
||||
help="List of tags to exclude from file tagging separated by commas.",
|
||||
)
|
||||
@click.option(
|
||||
"--truncate",
|
||||
type=int,
|
||||
default=40,
|
||||
help="Maximum length of the file/folder names.",
|
||||
)
|
||||
@click.option(
|
||||
"--log-level",
|
||||
"-l",
|
||||
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
|
||||
default="INFO",
|
||||
help="Log level.",
|
||||
)
|
||||
@click.option(
|
||||
"--prefer-hevc",
|
||||
is_flag=True,
|
||||
help="Prefer HEVC over AVC when downloading music videos.",
|
||||
)
|
||||
@click.option(
|
||||
"--ask-video-format",
|
||||
is_flag=True,
|
||||
help="Ask for the video format when downloading music videos.",
|
||||
)
|
||||
@click.option(
|
||||
"--disable-music-video-skip",
|
||||
is_flag=True,
|
||||
help="Don't skip downloading music videos in albums/playlists.",
|
||||
)
|
||||
@click.option(
|
||||
"--lrc-only",
|
||||
"-l",
|
||||
is_flag=True,
|
||||
help="Download only the synced lyrics.",
|
||||
)
|
||||
@click.option(
|
||||
"--no-lrc",
|
||||
"-n",
|
||||
is_flag=True,
|
||||
help="Don't download the synced lyrics.",
|
||||
)
|
||||
@click.option(
|
||||
"--save-cover",
|
||||
"-s",
|
||||
is_flag=True,
|
||||
help="Save cover as a separate file.",
|
||||
)
|
||||
@click.option(
|
||||
"--songs-heaac",
|
||||
is_flag=True,
|
||||
help="Download songs in HE-AAC 64kbps.",
|
||||
)
|
||||
@click.option(
|
||||
"--overwrite",
|
||||
"-o",
|
||||
is_flag=True,
|
||||
help="Overwrite existing files.",
|
||||
)
|
||||
@click.option(
|
||||
"--print-exceptions",
|
||||
is_flag=True,
|
||||
help="Print exceptions.",
|
||||
)
|
||||
@click.option(
|
||||
"--url-txt",
|
||||
"-u",
|
||||
is_flag=True,
|
||||
help="Read URLs as location of text files containing URLs.",
|
||||
)
|
||||
@click.option(
|
||||
"--no-config-file",
|
||||
"-n",
|
||||
is_flag=True,
|
||||
callback=no_config_callback,
|
||||
help="Don't use the config file.",
|
||||
)
|
||||
@click.version_option(__version__, "-v", "--version")
|
||||
@click.help_option("-h", "--help")
|
||||
def main(
|
||||
urls: tuple[str],
|
||||
final_path: Path,
|
||||
temp_path: Path,
|
||||
cookies_location: Path,
|
||||
wvd_location: Path,
|
||||
ffmpeg_location: Path,
|
||||
mp4box_location: Path,
|
||||
mp4decrypt_location: Path,
|
||||
nm3u8dlre_location: Path,
|
||||
config_location: Path,
|
||||
template_folder_album: str,
|
||||
template_folder_compilation: str,
|
||||
template_file_single_disc: str,
|
||||
template_file_multi_disc: str,
|
||||
template_folder_music_video: str,
|
||||
template_file_music_video: str,
|
||||
cover_size: int,
|
||||
cover_format: str,
|
||||
remux_mode: str,
|
||||
download_mode: str,
|
||||
exclude_tags: str,
|
||||
truncate: int,
|
||||
log_level: str,
|
||||
prefer_hevc: bool,
|
||||
ask_video_format: bool,
|
||||
disable_music_video_skip: bool,
|
||||
lrc_only: bool,
|
||||
no_lrc: bool,
|
||||
save_cover: bool,
|
||||
songs_heaac: bool,
|
||||
overwrite: bool,
|
||||
print_exceptions: bool,
|
||||
url_txt: bool,
|
||||
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")
|
||||
downloader = Downloader(**locals())
|
||||
if not cookies_location.exists():
|
||||
logger.critical(X_NOT_FOUND_STRING.format("Cookies file", cookies_location))
|
||||
return
|
||||
if remux_mode == "ffmpeg" and not lrc_only:
|
||||
if not downloader.ffmpeg_location:
|
||||
logger.critical(X_NOT_FOUND_STRING.format("FFmpeg", ffmpeg_location))
|
||||
return
|
||||
if not downloader.mp4decrypt_location:
|
||||
logger.warning(
|
||||
X_NOT_FOUND_STRING.format("mp4decrypt", mp4decrypt_location)
|
||||
+ ", music videos videos will not be downloaded"
|
||||
)
|
||||
if remux_mode == "mp4box" and not lrc_only:
|
||||
if not downloader.mp4box_location:
|
||||
logger.critical(X_NOT_FOUND_STRING.format("MP4Box", mp4box_location))
|
||||
return
|
||||
if not downloader.mp4decrypt_location:
|
||||
logger.critical(
|
||||
X_NOT_FOUND_STRING.format("mp4decrypt", mp4decrypt_location)
|
||||
)
|
||||
return
|
||||
if download_mode == "nm3u8dlre" and not lrc_only:
|
||||
if not downloader.nm3u8dlre_location:
|
||||
logger.critical(
|
||||
X_NOT_FOUND_STRING.format("N_m3u8DL-RE", nm3u8dlre_location)
|
||||
)
|
||||
return
|
||||
if not downloader.ffmpeg_location:
|
||||
logger.critical(X_NOT_FOUND_STRING.format("FFmpeg", ffmpeg_location))
|
||||
return
|
||||
logger.debug("Setting up session")
|
||||
downloader.setup_session()
|
||||
if not lrc_only:
|
||||
if not wvd_location.exists():
|
||||
logger.critical(X_NOT_FOUND_STRING.format(".wvd file", wvd_location))
|
||||
return
|
||||
logger.debug("Setting up CDM")
|
||||
downloader.setup_cdm()
|
||||
error_count = 0
|
||||
download_queue = []
|
||||
if url_txt:
|
||||
logger.debug("Reading URLs from text files")
|
||||
_urls = []
|
||||
for url in urls:
|
||||
with open(url, "r") as f:
|
||||
_urls.extend(f.read().splitlines())
|
||||
urls = tuple(_urls)
|
||||
for url_index, url in enumerate(urls, start=1):
|
||||
current_url = f"URL {url_index}/{len(urls)}"
|
||||
try:
|
||||
logger.debug(f'({current_url}) Checking "{url}"')
|
||||
download_queue.append(downloader.get_download_queue(url))
|
||||
except Exception:
|
||||
error_count += 1
|
||||
logger.error(
|
||||
f'({current_url}) Failed to check "{url}"',
|
||||
exc_info=print_exceptions,
|
||||
)
|
||||
for queue_item_index, queue_item in enumerate(download_queue, start=1):
|
||||
download_type, tracks = queue_item
|
||||
for track_index, track in enumerate(tracks, start=1):
|
||||
current_track = f"Track {track_index}/{len(tracks)} from URL {queue_item_index}/{len(download_queue)}"
|
||||
try:
|
||||
logger.info(
|
||||
f'({current_track}) Downloading "{track["attributes"]["name"]}"'
|
||||
)
|
||||
if not track["attributes"].get("playParams"):
|
||||
logger.warning(
|
||||
f"({current_track}) Track is not streamable, skipping"
|
||||
)
|
||||
continue
|
||||
track_id = track["id"]
|
||||
logger.debug("Getting webplayback")
|
||||
webplayback = downloader.get_webplayback(track_id)
|
||||
cover_url = downloader.get_cover_url(webplayback)
|
||||
if track["type"] == "songs":
|
||||
if track["attributes"]["hasLyrics"]:
|
||||
logger.debug("Getting lyrics")
|
||||
lyrics_unsynced, lyrics_synced = downloader.get_lyrics(
|
||||
track_id,
|
||||
)
|
||||
else:
|
||||
lyrics_unsynced, lyrics_synced = None, None
|
||||
logger.debug("Getting tags")
|
||||
tags = downloader.get_tags_song(webplayback, lyrics_unsynced)
|
||||
final_location = downloader.get_final_location(tags)
|
||||
lrc_location = downloader.get_lrc_location(final_location)
|
||||
cover_location = downloader.get_cover_location_song(final_location)
|
||||
logger.debug(f'Final location is "{final_location}"')
|
||||
if lrc_only:
|
||||
pass
|
||||
elif final_location.exists() and not overwrite:
|
||||
logger.warning(
|
||||
f'({current_track}) Track already exists at "{final_location}", skipping'
|
||||
)
|
||||
else:
|
||||
logger.debug("Getting stream URL")
|
||||
stream_url = downloader.get_stream_url_song(webplayback)
|
||||
logger.debug("Getting decryption key")
|
||||
decryption_key = downloader.get_decryption_key_song(
|
||||
stream_url, track_id
|
||||
)
|
||||
encrypted_location = downloader.get_encrypted_location_audio(
|
||||
track_id
|
||||
)
|
||||
logger.debug(f'Downloading to "{encrypted_location}"')
|
||||
if download_mode == "ytdlp":
|
||||
downloader.download_ytdlp(encrypted_location, stream_url)
|
||||
if download_mode == "nm3u8dlre":
|
||||
downloader.download_nm3u8dlre(
|
||||
encrypted_location, stream_url
|
||||
)
|
||||
decrypted_location = downloader.get_decrypted_location_audio(
|
||||
track_id
|
||||
)
|
||||
fixed_location = downloader.get_fixed_location(track_id, ".m4a")
|
||||
if remux_mode == "ffmpeg":
|
||||
logger.debug(
|
||||
f'Decrypting and remuxing to "{fixed_location}"'
|
||||
)
|
||||
downloader.fixup_song_ffmpeg(
|
||||
encrypted_location, decryption_key, fixed_location
|
||||
)
|
||||
if remux_mode == "mp4box":
|
||||
logger.debug(f'Decrypting to "{decrypted_location}"')
|
||||
downloader.decrypt(
|
||||
encrypted_location,
|
||||
decrypted_location,
|
||||
decryption_key,
|
||||
)
|
||||
logger.debug(f'Remuxing to "{fixed_location}"')
|
||||
downloader.fixup_song_mp4box(
|
||||
decrypted_location, fixed_location
|
||||
)
|
||||
logger.debug("Applying tags")
|
||||
downloader.apply_tags(fixed_location, tags, cover_url)
|
||||
logger.debug("Moving to final location")
|
||||
downloader.move_to_final_location(
|
||||
fixed_location, final_location
|
||||
)
|
||||
if no_lrc or not lyrics_synced:
|
||||
pass
|
||||
elif lrc_location.exists() and not overwrite:
|
||||
logger.debug(
|
||||
f'Synced lyrics already exists at "{lrc_location}", skipping'
|
||||
)
|
||||
else:
|
||||
logger.debug(f'Saving synced lyrics to "{lrc_location}"')
|
||||
downloader.save_lrc(lrc_location, lyrics_synced)
|
||||
if not save_cover or lrc_only:
|
||||
pass
|
||||
elif cover_location.exists() and not overwrite:
|
||||
logger.debug(
|
||||
f'Cover already exists at "{cover_location}", skipping'
|
||||
)
|
||||
else:
|
||||
logger.debug(f'Saving cover to "{cover_location}"')
|
||||
downloader.save_cover(cover_location, cover_url)
|
||||
if track["type"] == "music-videos":
|
||||
if (
|
||||
not disable_music_video_skip
|
||||
and download_type in ("albums", "playlists")
|
||||
or lrc_only
|
||||
or not downloader.mp4decrypt_location
|
||||
):
|
||||
logger.warning(
|
||||
f"({current_track}) Music video is not downloadable with current settings, skipping"
|
||||
)
|
||||
continue
|
||||
tags = downloader.get_tags_music_video(
|
||||
track["attributes"]["url"].split("/")[-1].split("?")[0]
|
||||
)
|
||||
final_location = downloader.get_final_location(tags)
|
||||
cover_location = downloader.get_cover_location_music_video(
|
||||
final_location
|
||||
)
|
||||
logger.debug(f'Final location is "{final_location}"')
|
||||
if final_location.exists() and not overwrite:
|
||||
logger.warning(
|
||||
f'({current_track}) Music video already exists at "{final_location}", skipping'
|
||||
)
|
||||
else:
|
||||
logger.debug("Getting stream URLs")
|
||||
(
|
||||
stream_url_video,
|
||||
stream_url_audio,
|
||||
) = downloader.get_stream_url_music_video(webplayback)
|
||||
logger.debug("Getting decryption keys")
|
||||
decryption_key_video = (
|
||||
downloader.get_decryption_key_music_video(
|
||||
stream_url_video, track_id
|
||||
)
|
||||
)
|
||||
decryption_key_audio = (
|
||||
downloader.get_decryption_key_music_video(
|
||||
stream_url_audio, track_id
|
||||
)
|
||||
)
|
||||
encrypted_location_video = (
|
||||
downloader.get_encrypted_location_video(track_id)
|
||||
)
|
||||
encrypted_location_audio = (
|
||||
downloader.get_encrypted_location_audio(track_id)
|
||||
)
|
||||
decrypted_location_video = (
|
||||
downloader.get_decrypted_location_video(track_id)
|
||||
)
|
||||
decrypted_location_audio = (
|
||||
downloader.get_decrypted_location_audio(track_id)
|
||||
)
|
||||
logger.debug(
|
||||
f'Downloading video to "{encrypted_location_video}"'
|
||||
)
|
||||
if download_mode == "ytdlp":
|
||||
downloader.download_ytdlp(
|
||||
encrypted_location_video, stream_url_video
|
||||
)
|
||||
if download_mode == "nm3u8dlre":
|
||||
downloader.download_nm3u8dlre(
|
||||
encrypted_location_video, stream_url_video
|
||||
)
|
||||
logger.debug(
|
||||
f'Downloading audio to "{encrypted_location_audio}"'
|
||||
)
|
||||
if download_mode == "ytdlp":
|
||||
downloader.download_ytdlp(
|
||||
encrypted_location_audio, stream_url_audio
|
||||
)
|
||||
if download_mode == "nm3u8dlre":
|
||||
downloader.download_nm3u8dlre(
|
||||
encrypted_location_audio, stream_url_audio
|
||||
)
|
||||
logger.debug(
|
||||
f'Decrypting video to "{decrypted_location_video}"'
|
||||
)
|
||||
downloader.decrypt(
|
||||
encrypted_location_audio,
|
||||
decrypted_location_audio,
|
||||
decryption_key_audio,
|
||||
)
|
||||
logger.debug(
|
||||
f'Decrypting audio to "{decrypted_location_audio}"'
|
||||
)
|
||||
downloader.decrypt(
|
||||
encrypted_location_video,
|
||||
decrypted_location_video,
|
||||
decryption_key_video,
|
||||
)
|
||||
fixed_location = downloader.get_fixed_location(track_id, ".m4v")
|
||||
logger.debug(f'Remuxing to "{fixed_location}"')
|
||||
if remux_mode == "ffmpeg":
|
||||
downloader.fixup_music_video_ffmpeg(
|
||||
decrypted_location_video,
|
||||
decrypted_location_audio,
|
||||
fixed_location,
|
||||
)
|
||||
if remux_mode == "mp4box":
|
||||
downloader.fixup_music_video_mp4box(
|
||||
decrypted_location_audio,
|
||||
decrypted_location_video,
|
||||
fixed_location,
|
||||
)
|
||||
logger.debug("Applying tags")
|
||||
downloader.apply_tags(fixed_location, tags, cover_url)
|
||||
logger.debug("Moving to final location")
|
||||
downloader.move_to_final_location(
|
||||
fixed_location, final_location
|
||||
)
|
||||
if not save_cover:
|
||||
pass
|
||||
elif cover_location.exists() and not overwrite:
|
||||
logger.debug(
|
||||
f'Cover already exists at "{cover_location}", skipping'
|
||||
)
|
||||
else:
|
||||
logger.debug(f'Saving cover to "{cover_location}"')
|
||||
downloader.save_cover(cover_location, cover_url)
|
||||
except Exception:
|
||||
error_count += 1
|
||||
logger.error(
|
||||
f'({current_track}) 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,194 @@
|
||||
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 ",
|
||||
}
|
||||
|
||||
EXCLUDED_CONFIG_FILE_PARAMS = (
|
||||
"urls",
|
||||
"config_location",
|
||||
"url_txt",
|
||||
"no_config_file",
|
||||
"version",
|
||||
"help",
|
||||
)
|
||||
|
||||
X_NOT_FOUND_STRING = '{} not found at "{}"'
|
||||
@@ -0,0 +1,644 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import functools
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from http.cookiejar import MozillaCookieJar
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import m3u8
|
||||
import requests
|
||||
from mutagen.mp4 import MP4, MP4Cover
|
||||
from pywidevine import PSSH, Cdm, Device, WidevinePsshData
|
||||
from yt_dlp import YoutubeDL
|
||||
|
||||
from gamdl.constants import MP4_TAGS_MAP, STOREFRONT_IDS
|
||||
|
||||
|
||||
class Downloader:
|
||||
def __init__(
|
||||
self,
|
||||
final_path: Path = None,
|
||||
temp_path: Path = None,
|
||||
cookies_location: Path = None,
|
||||
wvd_location: Path = None,
|
||||
ffmpeg_location: str = None,
|
||||
mp4box_location: str = None,
|
||||
mp4decrypt_location: str = None,
|
||||
nm3u8dlre_location: str = None,
|
||||
template_folder_album: str = None,
|
||||
template_folder_compilation: str = None,
|
||||
template_file_single_disc: str = None,
|
||||
template_file_multi_disc: str = None,
|
||||
template_folder_music_video: str = None,
|
||||
template_file_music_video: str = None,
|
||||
cover_size: int = None,
|
||||
cover_format: str = None,
|
||||
exclude_tags: str = None,
|
||||
truncate: int = None,
|
||||
prefer_hevc: bool = None,
|
||||
ask_video_format: bool = None,
|
||||
songs_heaac: bool = None,
|
||||
**kwargs,
|
||||
):
|
||||
self.final_path = final_path
|
||||
self.temp_path = temp_path
|
||||
self.cookies_location = cookies_location
|
||||
self.wvd_location = wvd_location
|
||||
self.ffmpeg_location = (
|
||||
shutil.which(ffmpeg_location) if ffmpeg_location else None
|
||||
)
|
||||
self.mp4box_location = (
|
||||
shutil.which(mp4box_location) if mp4box_location else None
|
||||
)
|
||||
self.mp4decrypt_location = (
|
||||
shutil.which(mp4decrypt_location) if mp4decrypt_location else None
|
||||
)
|
||||
self.nm3u8dlre_location = (
|
||||
shutil.which(nm3u8dlre_location) if nm3u8dlre_location else None
|
||||
)
|
||||
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_music_video = template_folder_music_video
|
||||
self.template_file_music_video = template_file_music_video
|
||||
self.cover_size = cover_size
|
||||
self.cover_format = cover_format
|
||||
self.exclude_tags = (
|
||||
[i.lower() for i in exclude_tags.split(",")]
|
||||
if exclude_tags is not None
|
||||
else []
|
||||
)
|
||||
self.truncate = None if truncate is not None and truncate < 4 else truncate
|
||||
self.prefer_hevc = prefer_hevc
|
||||
self.ask_video_format = ask_video_format
|
||||
self.songs_flavor = "32:ctrp64" if songs_heaac else "28:ctrp256"
|
||||
|
||||
def setup_session(self) -> None:
|
||||
cookies = MozillaCookieJar(self.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",
|
||||
}
|
||||
)
|
||||
home_page = self.session.get("https://beta.music.apple.com").text
|
||||
index_js_uri = re.search(r"/(assets/index-legacy-[^/]+\.js)", home_page).group(
|
||||
1
|
||||
)
|
||||
index_js_page = self.session.get(
|
||||
f"https://beta.music.apple.com/{index_js_uri}"
|
||||
).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 = STOREFRONT_IDS[self.country.upper()]
|
||||
|
||||
def setup_cdm(self) -> None:
|
||||
self.cdm = Cdm.from_device(Device.load(self.wvd_location))
|
||||
self.cdm_session = self.cdm.open()
|
||||
|
||||
def get_download_queue(self, url: str) -> tuple[str, list[dict]]:
|
||||
download_queue = []
|
||||
track_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}",
|
||||
params={
|
||||
"ids[songs]": track_id,
|
||||
"ids[albums]": track_id,
|
||||
"ids[playlists]": track_id,
|
||||
"ids[music-videos]": track_id,
|
||||
},
|
||||
).json()["data"][0]
|
||||
if response["type"] in ("songs", "music-videos"):
|
||||
download_queue.append(response)
|
||||
if response["type"] in ("albums", "playlists"):
|
||||
download_queue.extend(response["relationships"]["tracks"]["data"])
|
||||
if not download_queue:
|
||||
raise Exception("Criteria not met")
|
||||
return response["type"], download_queue
|
||||
|
||||
def get_webplayback(self, track_id: str) -> dict:
|
||||
response = self.session.post(
|
||||
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback",
|
||||
json={
|
||||
"salableAdamId": track_id,
|
||||
"language": "en-US",
|
||||
},
|
||||
).json()["songList"][0]
|
||||
return response
|
||||
|
||||
def get_stream_url_song(self, webplayback: dict) -> str:
|
||||
return next(
|
||||
i for i in webplayback["assets"] if i["flavor"] == self.songs_flavor
|
||||
)["URL"]
|
||||
|
||||
def get_stream_url_music_video(self, webplayback: dict) -> tuple[str, str]:
|
||||
ydl = YoutubeDL(
|
||||
{
|
||||
"allow_unplayable_formats": True,
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"allowed_extractors": ["generic"],
|
||||
}
|
||||
)
|
||||
playlist = ydl.extract_info(
|
||||
webplayback["hls-playlist-url"].replace("&aec=HD", ""),
|
||||
download=False,
|
||||
)
|
||||
if self.ask_video_format:
|
||||
ydl.list_formats(playlist)
|
||||
stream_url_video = None
|
||||
stream_url_audio = None
|
||||
while stream_url_video is None or stream_url_audio is None:
|
||||
format_ids = input("Enter video and audio id: ").split()
|
||||
if len(format_ids) != 2:
|
||||
continue
|
||||
video_id, audio_id = format_ids
|
||||
matching_formats = [
|
||||
i
|
||||
for i in playlist["formats"]
|
||||
if i["format_id"] in (video_id, audio_id)
|
||||
]
|
||||
stream_url_video = next(
|
||||
(i["url"] for i in matching_formats if i["video_ext"] != "none"),
|
||||
None,
|
||||
)
|
||||
stream_url_audio = next(
|
||||
(i["url"] for i in matching_formats if i["audio_ext"] != "none"),
|
||||
None,
|
||||
)
|
||||
else:
|
||||
if self.prefer_hevc:
|
||||
stream_url_video = playlist["formats"][-1]["url"]
|
||||
else:
|
||||
stream_url_video = list(
|
||||
i["url"]
|
||||
for i in playlist["formats"]
|
||||
if i["video_ext"] != "none" and "avc1" in i["vcodec"]
|
||||
)[-1]
|
||||
stream_url_audio = next(
|
||||
i["url"]
|
||||
for i in playlist["formats"]
|
||||
if "audio-stereo-256" in i["format_id"]
|
||||
)
|
||||
return stream_url_video, stream_url_audio
|
||||
|
||||
def get_encrypted_location_video(self, track_id: str) -> Path:
|
||||
return self.temp_path / f"{track_id}_encrypted_video.mp4"
|
||||
|
||||
def get_encrypted_location_audio(self, track_id: str) -> Path:
|
||||
return self.temp_path / f"{track_id}_encrypted_audio.m4a"
|
||||
|
||||
def get_decrypted_location_video(self, track_id: str) -> Path:
|
||||
return self.temp_path / f"{track_id}_decrypted_video.mp4"
|
||||
|
||||
def get_decrypted_location_audio(self, track_id: str) -> Path:
|
||||
return self.temp_path / f"{track_id}_decrypted_audio.m4a"
|
||||
|
||||
def get_fixed_location(self, track_id: str, file_extension: str) -> Path:
|
||||
return self.temp_path / f"{track_id}_fixed{file_extension}"
|
||||
|
||||
def get_cover_location_song(self, final_location: Path) -> Path:
|
||||
return final_location.parent / f"Cover.{self.cover_format}"
|
||||
|
||||
def get_cover_location_music_video(self, final_location: Path) -> Path:
|
||||
return final_location.with_suffix(f".{self.cover_format}")
|
||||
|
||||
def get_lrc_location(self, final_location: Path) -> Path:
|
||||
return final_location.with_suffix(".lrc")
|
||||
|
||||
def download_ytdlp(self, encrypted_location: Path, stream_url: str) -> None:
|
||||
with YoutubeDL(
|
||||
{
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"outtmpl": str(encrypted_location),
|
||||
"allow_unplayable_formats": True,
|
||||
"fixup": "never",
|
||||
"allowed_extractors": ["generic"],
|
||||
}
|
||||
) as ydl:
|
||||
ydl.download(stream_url)
|
||||
|
||||
def download_nm3u8dlre(self, encrypted_location: Path, stream_url: str) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
self.nm3u8dlre_location,
|
||||
stream_url,
|
||||
"--binary-merge",
|
||||
"--no-log",
|
||||
"--log-level",
|
||||
"off",
|
||||
"--ffmpeg-binary-path",
|
||||
self.ffmpeg_location,
|
||||
"--save-name",
|
||||
encrypted_location.stem,
|
||||
"--save-dir",
|
||||
encrypted_location.parent,
|
||||
"--tmp-dir",
|
||||
encrypted_location.parent,
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
def get_license_b64(self, challenge: str, track_uri: str, track_id: str) -> str:
|
||||
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_key_music_video(self, stream_url: str, track_id: str) -> str:
|
||||
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()
|
||||
license_b64 = self.get_license_b64(challenge, track_uri, track_id)
|
||||
self.cdm.parse_license(self.cdm_session, license_b64)
|
||||
return next(
|
||||
i for i in self.cdm.get_keys(self.cdm_session) if i.type == "CONTENT"
|
||||
).key.hex()
|
||||
|
||||
def get_decryption_key_song(self, stream_url: str, track_id: str) -> str:
|
||||
track_uri = m3u8.load(stream_url).keys[0].uri
|
||||
widevine_pssh_data = WidevinePsshData()
|
||||
widevine_pssh_data.algorithm = 1
|
||||
widevine_pssh_data.key_ids.append(base64.b64decode(track_uri.split(",")[1]))
|
||||
pssh = PSSH(base64.b64encode(widevine_pssh_data.SerializeToString()).decode())
|
||||
challenge = base64.b64encode(
|
||||
self.cdm.get_license_challenge(self.cdm_session, pssh)
|
||||
).decode()
|
||||
license_b64 = self.get_license_b64(challenge, track_uri, track_id)
|
||||
self.cdm.parse_license(self.cdm_session, license_b64)
|
||||
return next(
|
||||
i for i in self.cdm.get_keys(self.cdm_session) if i.type == "CONTENT"
|
||||
).key.hex()
|
||||
|
||||
def get_lyrics_synced_timestamp_lrc(self, timestamp_ttml: str) -> str:
|
||||
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]}")
|
||||
try:
|
||||
mins = int(mins_secs_ms[-3])
|
||||
except IndexError:
|
||||
pass
|
||||
timestamp_lrc = datetime.datetime.fromtimestamp(
|
||||
(mins * 60) + secs + (ms / 1000)
|
||||
)
|
||||
ms_new = timestamp_lrc.strftime("%f")[:-3]
|
||||
if int(ms_new[-1]) >= 5:
|
||||
ms = int(f"{int(ms_new[:2]) + 1}") * 10
|
||||
timestamp_lrc += datetime.timedelta(milliseconds=ms) - datetime.timedelta(
|
||||
microseconds=timestamp_lrc.microsecond
|
||||
)
|
||||
return timestamp_lrc.strftime("%M:%S.%f")[:-4]
|
||||
|
||||
def get_lyrics(self, track_id: str) -> tuple[str, str]:
|
||||
lyrics_response = self.session.get(
|
||||
f"https://amp-api.music.apple.com/v1/catalog/{self.country}/songs/{track_id}/lyrics"
|
||||
).json()
|
||||
if lyrics_response["data"][0].get("attributes") is None:
|
||||
return None, None
|
||||
lyrics_ttml = ElementTree.fromstring(
|
||||
lyrics_response["data"][0]["attributes"]["ttml"]
|
||||
)
|
||||
lyrics_unsynced = ""
|
||||
lyrics_synced = ""
|
||||
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"):
|
||||
lyrics_synced += f'[{self.get_lyrics_synced_timestamp_lrc(p.attrib.get("begin"))}]{p.text}\n'
|
||||
if p.text is not None:
|
||||
lyrics_unsynced += p.text + "\n"
|
||||
lyrics_unsynced += "\n"
|
||||
return lyrics_unsynced[:-2], lyrics_synced
|
||||
|
||||
def get_cover_url(self, webplayback: dict) -> str:
|
||||
return (
|
||||
webplayback["artwork-urls"]["default"]["url"].rsplit("/", 1)[0]
|
||||
+ f"/{self.cover_size}x{self.cover_size}bb.{self.cover_format}"
|
||||
)
|
||||
|
||||
@functools.lru_cache()
|
||||
def get_cover(self, cover_url: str) -> bytes:
|
||||
return requests.get(cover_url).content
|
||||
|
||||
def get_tags_song(self, webplayback: dict, lyrics_unsynced: str) -> dict:
|
||||
flavor = next(
|
||||
i for i in webplayback["assets"] if i["flavor"] == self.songs_flavor
|
||||
)
|
||||
metadata = flavor["metadata"]
|
||||
tags = {
|
||||
"album": metadata["playlistName"],
|
||||
"album_artist": metadata["playlistArtistName"],
|
||||
"album_id": int(metadata["playlistId"]),
|
||||
"album_sort": metadata["sort-album"],
|
||||
"artist": metadata["artistName"],
|
||||
"artist_id": int(metadata["artistId"]),
|
||||
"artist_sort": metadata["sort-artist"],
|
||||
"comments": metadata.get("comments"),
|
||||
"compilation": metadata["compilation"],
|
||||
"composer": metadata.get("composerName"),
|
||||
"composer_id": int(metadata.get("composerId"))
|
||||
if metadata.get("composerId")
|
||||
else None,
|
||||
"composer_sort": metadata.get("sort-composer"),
|
||||
"copyright": metadata.get("copyright"),
|
||||
"date": metadata.get("releaseDate"),
|
||||
"disc": metadata["discNumber"],
|
||||
"disc_total": metadata["discCount"],
|
||||
"gapless": metadata["gapless"],
|
||||
"genre": metadata["genre"],
|
||||
"genre_id": metadata["genreId"],
|
||||
"lyrics": lyrics_unsynced if lyrics_unsynced else None,
|
||||
"media_type": 1,
|
||||
"rating": metadata["explicit"],
|
||||
"storefront": metadata["s"],
|
||||
"title": metadata["itemName"],
|
||||
"title_id": int(metadata["itemId"]),
|
||||
"title_sort": metadata["sort-name"],
|
||||
"track": metadata["trackNumber"],
|
||||
"track_total": metadata["trackCount"],
|
||||
"xid": metadata.get("xid"),
|
||||
}
|
||||
return tags
|
||||
|
||||
def get_tags_music_video(self, track_id: str) -> dict:
|
||||
metadata = requests.get(
|
||||
f"https://itunes.apple.com/lookup",
|
||||
params={
|
||||
"id": track_id,
|
||||
"entity": "album",
|
||||
"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 = {
|
||||
"artist": metadata[0]["artistName"],
|
||||
"artist_id": metadata[0]["artistId"],
|
||||
"copyright": extra_metadata.get("copyright"),
|
||||
"date": metadata[0]["releaseDate"],
|
||||
"genre": metadata[0]["primaryGenreName"],
|
||||
"genre_id": int(extra_metadata["genres"][0]["genreId"]),
|
||||
"media_type": 6,
|
||||
"storefront": int(self.storefront.split("-")[0]),
|
||||
"title": metadata[0]["trackCensoredName"],
|
||||
"title_id": metadata[0]["trackId"],
|
||||
}
|
||||
if metadata[0]["trackExplicitness"] == "notExplicit":
|
||||
tags["rating"] = 0
|
||||
elif metadata[0]["trackExplicitness"] == "explicit":
|
||||
tags["rating"] = 1
|
||||
else:
|
||||
tags["rating"] = 2
|
||||
if len(metadata) > 1:
|
||||
tags["album"] = metadata[1]["collectionCensoredName"]
|
||||
tags["album_artist"] = metadata[1]["artistName"]
|
||||
tags["album_id"] = metadata[1]["collectionId"]
|
||||
tags["disc"] = metadata[0]["discNumber"]
|
||||
tags["disc_total"] = metadata[0]["discCount"]
|
||||
tags["track"] = metadata[0]["trackNumber"]
|
||||
tags["track_total"] = metadata[0]["trackCount"]
|
||||
return tags
|
||||
|
||||
def get_sanitized_string(self, dirty_string: str, is_folder: bool) -> str:
|
||||
dirty_string = re.sub(r'[\\/:*?"<>|;]', "_", 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_location(self, tags: dict) -> Path:
|
||||
if "album" in tags:
|
||||
final_location_folder = (
|
||||
self.template_folder_compilation.split("/")
|
||||
if "compilation" in tags and tags["compilation"]
|
||||
else self.template_folder_album.split("/")
|
||||
)
|
||||
final_location_file = (
|
||||
self.template_file_multi_disc.split("/")
|
||||
if tags["disc_total"] > 1
|
||||
else self.template_file_single_disc.split("/")
|
||||
)
|
||||
else:
|
||||
final_location_folder = self.template_folder_music_video.split("/")
|
||||
final_location_file = self.template_file_music_video.split("/")
|
||||
file_extension = ".m4a" if tags["media_type"] == 1 else ".m4v"
|
||||
final_location_folder = [
|
||||
self.get_sanitized_string(i.format(**tags), True)
|
||||
for i in final_location_folder
|
||||
]
|
||||
final_location_file = [
|
||||
self.get_sanitized_string(i.format(**tags), True)
|
||||
for i in final_location_file[:-1]
|
||||
] + [
|
||||
self.get_sanitized_string(final_location_file[-1].format(**tags), False)
|
||||
+ file_extension
|
||||
]
|
||||
return self.final_path.joinpath(*final_location_folder).joinpath(
|
||||
*final_location_file
|
||||
)
|
||||
|
||||
def decrypt(
|
||||
self, encrypted_location: Path, decrypted_location: Path, decryption_key: str
|
||||
) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
self.mp4decrypt_location,
|
||||
encrypted_location,
|
||||
"--key",
|
||||
f"1:{decryption_key}",
|
||||
decrypted_location,
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
def fixup_song_mp4box(self, decrypted_location: Path, fixed_location: Path) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
self.mp4box_location,
|
||||
"-quiet",
|
||||
"-add",
|
||||
decrypted_location,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-new",
|
||||
fixed_location,
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
def fixup_music_video_mp4box(
|
||||
self,
|
||||
decrypted_location_audio: Path,
|
||||
decrypted_location_video: Path,
|
||||
fixed_location: Path,
|
||||
) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
self.mp4box_location,
|
||||
"-quiet",
|
||||
"-add",
|
||||
decrypted_location_audio,
|
||||
"-add",
|
||||
decrypted_location_video,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-new",
|
||||
fixed_location,
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
def fixup_song_ffmpeg(
|
||||
self, encrypted_location: Path, decryption_key: str, fixed_location: Path
|
||||
) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
self.ffmpeg_location,
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-decryption_key",
|
||||
decryption_key,
|
||||
"-i",
|
||||
encrypted_location,
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-c",
|
||||
"copy",
|
||||
fixed_location,
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
def fixup_music_video_ffmpeg(
|
||||
self,
|
||||
decrypted_location_video: Path,
|
||||
decrypted_location_audio: Path,
|
||||
fixed_location: Path,
|
||||
) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
self.ffmpeg_location,
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-i",
|
||||
decrypted_location_video,
|
||||
"-i",
|
||||
decrypted_location_audio,
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-f",
|
||||
"mp4",
|
||||
"-c",
|
||||
"copy",
|
||||
"-c:s",
|
||||
"mov_text",
|
||||
fixed_location,
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
def apply_tags(self, fixed_location: Path, tags: dict, cover_url: str) -> None:
|
||||
mp4_tags = {
|
||||
v: [tags[k]]
|
||||
for k, v in MP4_TAGS_MAP.items()
|
||||
if k not in self.exclude_tags and tags.get(k) is not None
|
||||
}
|
||||
if not {"disc", "disc_total"} & set(self.exclude_tags) and "disc" in tags:
|
||||
mp4_tags["disk"] = [[0, 0]]
|
||||
if not {"track", "track_total"} & set(self.exclude_tags) and "track" in tags:
|
||||
mp4_tags["trkn"] = [[0, 0]]
|
||||
if "compilation" not in self.exclude_tags and "compilation" in tags:
|
||||
mp4_tags["cpil"] = tags["compilation"]
|
||||
if "cover" not in self.exclude_tags:
|
||||
mp4_tags["covr"] = [
|
||||
MP4Cover(
|
||||
self.get_cover(cover_url),
|
||||
imageformat=MP4Cover.FORMAT_JPEG
|
||||
if self.cover_format == "jpg"
|
||||
else MP4Cover.FORMAT_PNG,
|
||||
)
|
||||
]
|
||||
if "disc" not in self.exclude_tags and "disc" in tags:
|
||||
mp4_tags["disk"][0][0] = tags["disc"]
|
||||
if "disc_total" not in self.exclude_tags and "disc_total" in tags:
|
||||
mp4_tags["disk"][0][1] = tags["disc_total"]
|
||||
if "gapless" not in self.exclude_tags and "gapless" in tags:
|
||||
mp4_tags["pgap"] = tags["gapless"]
|
||||
if "track" not in self.exclude_tags and "track" in tags:
|
||||
mp4_tags["trkn"][0][0] = tags["track"]
|
||||
if "track_total" not in self.exclude_tags and "track_total" in tags:
|
||||
mp4_tags["trkn"][0][1] = tags["track_total"]
|
||||
mp4 = MP4(fixed_location)
|
||||
mp4.clear()
|
||||
mp4.update(mp4_tags)
|
||||
mp4.save()
|
||||
|
||||
def move_to_final_location(
|
||||
self, fixed_location: Path, final_location: Path
|
||||
) -> None:
|
||||
final_location.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(fixed_location, final_location)
|
||||
|
||||
@functools.lru_cache()
|
||||
def save_cover(self, cover_location: Path, cover_url: str) -> None:
|
||||
with open(cover_location, "wb") as f:
|
||||
f.write(self.get_cover(cover_url))
|
||||
|
||||
def save_lrc(self, lrc_location: Path, lyrics_synced: str) -> None:
|
||||
lrc_location.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(lrc_location, "w", encoding="utf8") as f:
|
||||
f.write(lyrics_synced)
|
||||
|
||||
def cleanup_temp_path(self) -> None:
|
||||
shutil.rmtree(self.temp_path)
|
||||
-404
@@ -1,404 +0,0 @@
|
||||
from pathlib import Path
|
||||
import glob
|
||||
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, wvd_location, cookies_location, disable_music_video_skip, prefer_hevc, temp_path, final_path, no_lrc, overwrite, 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.overwrite = overwrite
|
||||
self.skip_cleanup = skip_cleanup
|
||||
wvd_location = glob.glob(wvd_location)
|
||||
if not wvd_location:
|
||||
raise Exception('.wvd file not found')
|
||||
self.cdm = Cdm.from_device(Device.load(Path(wvd_location[0])))
|
||||
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}').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,
|
||||
'language': 'en-US'
|
||||
}
|
||||
).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 check_exists(self, final_location):
|
||||
return Path(final_location).exists()
|
||||
|
||||
|
||||
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': self.overwrite,
|
||||
'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
|
||||
widevine_pssh_data = WidevinePsshData()
|
||||
widevine_pssh_data.algorithm = 1
|
||||
widevine_pssh_data.key_ids.append(base64.b64decode(track_uri.split(",")[1]))
|
||||
pssh = PSSH(base64.b64encode(widevine_pssh_data.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):
|
||||
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': [metadata['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)
|
||||
file.update(tags)
|
||||
file.save()
|
||||
|
||||
|
||||
def cleanup(self):
|
||||
if self.temp_path.exists() and not self.skip_cleanup:
|
||||
shutil.rmtree(self.temp_path)
|
||||
@@ -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"
|
||||
+3
-2
@@ -4,9 +4,10 @@ description = "Download Apple Music songs/music videos/albums/playlists"
|
||||
requires-python = ">=3.7"
|
||||
authors = [{name = "glomatico"}]
|
||||
dependencies = [
|
||||
"click",
|
||||
"m3u8",
|
||||
"pywidevine",
|
||||
"pyyaml",
|
||||
"m3u8",
|
||||
"yt-dlp"
|
||||
]
|
||||
readme = "README.md"
|
||||
@@ -21,4 +22,4 @@ requires = ["flit_core"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
|
||||
[project.scripts]
|
||||
gamdl = "gamdl:main"
|
||||
gamdl = "gamdl.cli:main"
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
click
|
||||
m3u8
|
||||
pywidevine
|
||||
pyyaml
|
||||
m3u8
|
||||
yt-dlp
|
||||
|
||||
Reference in New Issue
Block a user