Compare commits

...

161 Commits

Author SHA1 Message Date
Rafael Moraes b70792e9a7 Merge pull request #53 from glomatico/dev
adjust get_lyrics_synced_timestamp_lrc
2023-11-02 16:37:19 -03:00
R. M 68ba1556f5 adjust get_lyrics_synced_timestamp_lrc 2023-11-02 16:26:54 -03:00
Rafael Moraes 6fdca2fe2e Merge pull request #52 from glomatico/dev
Dev
2023-10-25 23:29:44 -03:00
R. M 7709a37b88 bump version 2023-10-25 23:29:22 -03:00
R. M 9c4d2d1a13 adjust lyrics ms parsing 2023-10-25 23:28:27 -03:00
Rafael Moraes 6a4b9d8eb1 Merge pull request #51 from glomatico/dev
Dev
2023-10-21 22:08:28 -03:00
R. M 3be4a4bbbc bump version 2023-10-21 22:08:00 -03:00
R. M 70b595b323 add missing subprocess checks 2023-10-21 22:07:43 -03:00
Rafael Moraes 6e3824f448 Merge pull request #50 from glomatico/dev
Dev
2023-10-21 17:54:11 -03:00
R. M b0f98e21ec adjust get_lyrics_synced_timestamp_lrc 2023-10-21 17:50:50 -03:00
R. M 46918ff4a0 bump version 2023-10-21 17:20:04 -03:00
R. M dc25b8a7be Update README.md 2023-10-21 15:59:45 -03:00
R. M 6516499df2 make getting music video stream_urls faster 2023-10-21 15:54:30 -03:00
R. M c550ae3c20 adjust ttml lrc timestamp conversion 2023-10-21 15:32:48 -03:00
Rafael Moraes 908dcf794b Merge pull request #48 from glomatico/dev
Dev
2023-10-18 10:08:37 -03:00
R. M 4dd8df7e70 bump version 2023-10-18 10:04:23 -03:00
R. M 8e6623cb93 lyrics attributes check 2023-10-16 06:37:48 -03:00
Rafael Moraes aabe0aa7ea Merge pull request #46 from glomatico/dev
Dev
2023-10-13 22:43:57 -03:00
R. M 7d122844e2 bump version 2023-10-13 22:43:20 -03:00
R. M 5bb34caf09 adjust has lyrics 2023-10-13 19:42:38 -03:00
R. M d615842c60 has lyrics check 2023-10-10 14:42:45 -03:00
Rafael Moraes 1a5dd94c1c Merge pull request #45 from glomatico/dev
Dev
2023-10-03 14:16:21 -03:00
R. M 17e930cfbd Update README.md 2023-10-02 16:56:26 -03:00
R. M df6b138d30 adjust get_stream_url_music_video 2023-09-30 14:10:05 -03:00
R. M c5271f6d99 Update README.md 2023-09-30 14:00:05 -03:00
R. M 4bc0938eea Update README.md 2023-09-30 01:31:51 -03:00
R. M c3285ac3cd rename x not found string 2023-09-30 01:16:52 -03:00
R. M b0e2e15702 bump version 2023-09-30 01:16:31 -03:00
R. M 1c94d04066 adjust log messages 2023-09-30 01:14:45 -03:00
R. M 13bf369d26 Update README.md 2023-09-30 01:14:32 -03:00
R. M 91e8111d72 add missing return types 2023-09-30 00:13:56 -03:00
R. M af1a356322 rename make_lrc to save_lrc 2023-09-30 00:12:19 -03:00
R. M 8ad83e1f82 Update downloader.py 2023-09-30 00:07:09 -03:00
R. M 0f539115ad rename cleanup to cleanup_temp_path 2023-09-30 00:06:26 -03:00
R. M 70139e9eaa create cleanup function 2023-09-30 00:03:38 -03:00
R. M 56c77a0447 adjust version argument and don't setup cdm with lrc only 2023-09-29 23:42:06 -03:00
R. M d9d7ddf3d7 fix old python version compatibility 2023-09-19 06:58:16 -03:00
Rafael Moraes cd929a0626 Merge pull request #42 from glomatico/dev
Dev
2023-09-09 01:47:07 -03:00
R. M 366698fa86 bump version 2023-09-09 01:24:38 -03:00
R. M 99e8c30826 adjust failed to check url log message 2023-09-09 01:07:59 -03:00
R. M 20f306b026 rename dl to downloader 2023-09-09 01:04:23 -03:00
R. M 014f55a994 lrc first then cover 2023-09-09 01:00:08 -03:00
R. M 813b119e5a adjust get download queue 2023-09-09 00:23:45 -03:00
R. M 186209f99a adjust failed to download log 2023-09-09 00:14:38 -03:00
R. M beb2e10349 remove some log messages 2023-09-09 00:05:42 -03:00
R. M 0203ec4535 adjust download mode choice 2023-09-08 23:40:25 -03:00
R. M 6cdea55eb1 Update README.md 2023-09-08 23:39:26 -03:00
R. M efc5387785 adjust remux mode help 2023-09-08 23:38:52 -03:00
R. M b52b2f050c rename yt-dlp to ytdlp 2023-09-08 23:38:27 -03:00
R. M 8926052751 save cover lru cache 2023-09-08 23:32:41 -03:00
R. M 72df094194 remove failed to setup x constant 2023-09-07 19:42:05 -03:00
R. M 8bc369481c fix line too long 2023-09-07 19:06:29 -03:00
R. M b1f04ab0ce fix failed to download track error log 2023-09-07 19:03:29 -03:00
R. M 2e34754303 return types 2023-09-07 16:40:59 -03:00
R. M a464de8f18 Update README.md 2023-09-07 16:28:30 -03:00
R. M 7e7fbc5a38 rename disable_music_video_skip option 2023-09-07 16:28:27 -03:00
R. M 06ffa8a2ca add return types and rename lyrics variable 2023-09-07 16:20:33 -03:00
R. M 12cf08d489 add starting downloader log 2023-09-07 16:03:13 -03:00
R. M 4ecf5fc6f4 Update README.md 2023-09-07 15:33:13 -03:00
R. M 44de089522 Update README.md 2023-09-07 15:27:38 -03:00
R. M 9a4fdf1874 closed captions with ffmpeg 2023-09-07 15:26:39 -03:00
R. M 8180a850fd adjust log messages 2023-09-07 15:13:55 -03:00
R. M 2b2fe89dcc more debug logs and fix download mode in music video 2023-09-05 17:00:44 -03:00
R. M fca7ee5c3b split download function 2023-09-04 23:22:56 -03:00
R. M 2d510a7703 cover_url 2023-09-04 23:17:58 -03:00
R. M 1cd5221304 tags optimization 2023-09-04 22:27:51 -03:00
R. M 788270c2ab rename constants variables 2023-09-04 22:16:38 -03:00
R. M 3dc9132a60 download queue optimizations 2023-09-04 22:10:37 -03:00
R. M f5a8180fcc constants file 2023-09-04 17:26:15 -03:00
R. M ebc07082d5 rename dl to downloader 2023-09-04 17:17:25 -03:00
R. M 6f96166cf0 start session and start cdm 2023-09-04 17:12:36 -03:00
R. M 9358a8b760 binaries location adjust 2023-09-04 16:57:01 -03:00
Rafael Moraes bea54a73cb Merge pull request #41 from glomatico/dev
Dev
2023-08-27 20:14:30 -03:00
R. M 85f930b298 Bump version 2023-08-27 20:02:38 -03:00
R. M 44cfa36f9a Update README.md 2023-08-27 20:02:24 -03:00
R. M fd60260e6c Update README.md 2023-08-27 20:02:04 -03:00
R. M 919c9a66ec Change songs aac help 2023-08-27 19:59:43 -03:00
R. M f1d609e8d2 Update README.md 2023-08-27 19:59:18 -03:00
R. M e91fa202f0 Add missing rating tag 2023-08-27 11:44:38 -03:00
Rafael Moraes a181f53f48 Merge pull request #39 from glomatico/dev
Dev
2023-08-27 04:00:11 -03:00
R. M f4cb9a9c4e Update .gitignore 2023-08-27 03:50:58 -03:00
R. M b3a328f1a2 Return when ffmpeg is not on path 2023-08-27 02:33:50 -03:00
R. M 18bbb3418c Adjust binaries 2023-08-27 02:32:27 -03:00
R. M d3629a6846 Binaries location type to str 2023-08-27 02:27:31 -03:00
R. M 8a4d6b33ec Fix nm3u8dlre ffmpeg issue 2023-08-27 02:15:43 -03:00
R. M 24858f4ca8 Update README.md 2023-08-27 01:51:05 -03:00
R. M 1b0b00cf08 Add ffmpeg binay path option in nm3u8dlre 2023-08-27 01:47:40 -03:00
R. M 8b6b81466b Update README.md 2023-08-27 01:44:00 -03:00
R. M 752681d3ff Update README.md 2023-08-27 01:43:07 -03:00
R. M 4dc9a25753 Update README.md 2023-08-27 01:42:10 -03:00
R. M 9303722eab Update README.md 2023-08-27 01:39:23 -03:00
R. M 153727c625 Update README.md 2023-08-27 01:37:30 -03:00
R. M aa3a61bca3 Fix spell mistake in get_sanitized_string 2023-08-27 01:14:32 -03:00
R. M aaf2e9181f Update pyproject.toml 2023-08-27 01:07:08 -03:00
R. M 6d598cc294 Update README.md 2023-08-27 00:48:02 -03:00
R. M 0a6c7d78c8 Bump version 2023-08-27 00:47:59 -03:00
R. M cdde615186 Invalid cookies file error 2023-08-27 00:33:22 -03:00
R. M 631650824f Update x not found string 2023-08-26 23:39:52 -03:00
R. M a11a937b89 Update if media is album on get final location 2023-08-26 15:54:29 -03:00
R. M 688816125b Change get_cover arguments 2023-08-25 17:30:31 -03:00
R. M 36395f226f Fix music video skip lrc only 2023-08-25 16:33:00 -03:00
R. M 15b5342707 Lrc only music video skip 2023-08-25 16:31:01 -03:00
R. M c8bda637fe Changed file template argument help 2023-08-25 13:12:03 -03:00
R. M 774df56091 Add click 2023-08-25 12:27:23 -03:00
R. M 157e331a8a Fix unresolved attribute for class dl 2023-08-25 12:27:14 -03:00
R. M f0e1d672a5 Fix lyrics timestamp 2023-08-25 03:46:53 -03:00
R. M ee434e767e Fix skip music video when mp4decrypt is not present 2023-08-25 03:44:36 -03:00
R. M 04719f7587 Skip music videos when mp4decrypt is not present 2023-08-25 03:43:23 -03:00
R. M dc3c673985 Cookies file not found error 2023-08-25 03:05:43 -03:00
R. M f912a19fed Skip remux and download_mode check in lrc_only mode 2023-08-25 03:05:27 -03:00
R. M bd3553048f Remove cover quality 2023-08-25 02:58:08 -03:00
R. M e6cd1a11d5 New lrc timestamp method, heaac and remove unused cover quality argument 2023-08-25 02:54:10 -03:00
R. M dd9968387e Change log level argument position 2023-08-25 01:17:42 -03:00
R. M 10122146f1 Remove old cli 2023-08-25 00:58:24 -03:00
R. M 3d17b3f363 New cli 2023-08-25 00:58:16 -03:00
R. M 4356519dce Create cli.py 2023-08-25 00:57:41 -03:00
R. M 24bac438b7 Create dl.py 2023-08-25 00:57:36 -03:00
R. M 9fb05ef89c Delete gamdl.py 2023-08-25 00:57:13 -03:00
R. M f0966e7b39 Rename to storefronts 2023-08-23 03:06:59 -03:00
R. M ae4c2abfe4 Changed version 2023-06-11 01:09:24 -03:00
R. M 37554e8f49 Added missing line 2023-06-11 01:09:12 -03:00
R. M aee494f464 Changed version 2023-06-11 00:17:05 -03:00
R. M eddd6f9053 Update __init__.py 2023-06-11 00:15:47 -03:00
R. M 24d9fdf9ee Increase video cover size 2023-06-10 12:20:35 -03:00
R. M a0fef9944e Changed URL argument behavior 2023-06-10 12:20:00 -03:00
R. M e37038e67b Remove skip cleanup 2023-06-10 12:15:53 -03:00
R. M 52c59f9a17 Update gamdl.py 2023-06-10 12:09:37 -03:00
R. M 2f80b9dc65 Update __main__.py 2023-06-10 12:09:35 -03:00
R. M caed322fd0 Update __init__.py 2023-06-10 12:09:33 -03:00
R. M 76396f3fed Update README.md 2023-05-20 14:21:10 -03:00
R. M b64cc06641 Version bump 2023-05-20 14:15:22 -03:00
R. M 4e2c54934a LRC only mode 2023-05-20 14:13:26 -03:00
R. M e76c79d9b4 Remove Print Video M3U8 URL 2023-05-20 13:14:32 -03:00
R. M 6dd730c368 Increased cover size 2023-05-20 13:12:14 -03:00
R. M 2a2403c130 Update gamdl.py 2023-05-20 13:10:11 -03:00
R. M fd47acab4f Remove HE-AAC 2023-05-20 13:09:50 -03:00
R. M 66d8211a16 Update get_sanizated_string 2023-05-20 13:05:26 -03:00
R. M 158f0e9f27 Update gamdl.py 2023-05-20 13:04:59 -03:00
R. M 0d9b225fdc Version bump 2023-04-19 22:47:02 -03:00
R. M 254147096a Fix index_js_uri 2023-04-19 22:46:31 -03:00
R. M 3a10069c76 heaac 2023-04-05 23:43:46 -03:00
R. M 9e07aee4e6 Update __init__.py 2023-04-04 16:13:30 -03:00
R. M 2c18a285a0 Update gamdl.py 2023-04-04 16:13:21 -03:00
R. M c854af5b2c Changed version 2023-04-04 16:08:22 -03:00
R. M f10a4a731b Gapless tag 2023-04-04 16:07:47 -03:00
R. M 527dd9935a Update __init__.py 2023-03-28 01:30:37 -03:00
R. M 06d5c10725 Update gamdl.py 2023-03-28 01:30:20 -03:00
R. M 58a8e3944d Update gamdl.py 2023-03-28 01:15:23 -03:00
R. M 4c7e563d4c Remove useless if 2023-03-28 01:13:02 -03:00
R. M f05dace5c1 Changed version 2023-03-28 01:10:11 -03:00
R. M eb81728475 Remove unused methods 2023-03-28 01:08:41 -03:00
R. M 96c90e1716 Better synced lyrics time format 2023-03-28 01:08:09 -03:00
R. M 7459d95df0 Added missing SD quality 2023-03-27 08:40:10 -03:00
R. M b2521e2933 Changed video get stream url method 2023-03-27 08:39:09 -03:00
R. M f87bee7732 Changed version 2023-02-22 12:11:36 -03:00
R. M 3aa36c1323 Minor change 2023-02-22 12:10:54 -03:00
R. M 7c60b2cd31 Added -new on MP4Box fixup 2023-02-22 12:06:20 -03:00
R. M 68ff155a9e Changed pywidevine import 2023-02-22 12:04:24 -03:00
R. M 0d41ef0895 Minor change 2023-02-22 12:02:30 -03:00
R. M 575f652813 Fixed music video copyright tag error 2023-02-22 11:54:09 -03:00
R. M 11db7154a1 Changed URL check error message 2023-02-22 11:51:36 -03:00
11 changed files with 1564 additions and 826 deletions
+3 -1
View File
@@ -1,5 +1,7 @@
/*
__pycache__
!gamdl
!requirements.txt
!.gitignore
!pyproject.toml
!README.md
!requirements.txt
+131 -77
View File
@@ -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.
![Windows CMD usage example](https://i.imgur.com/18Azlg4.png)
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
View File
@@ -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
View File
@@ -1,4 +1,3 @@
import gamdl
from .cli import main
if __name__ == "__main__":
gamdl.main()
main()
+584
View File
@@ -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))")
+194
View File
@@ -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 "{}"'
+644
View File
@@ -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
View File
@@ -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)
-155
View File
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,5 @@
click
m3u8
pywidevine
pyyaml
m3u8
yt-dlp