Compare commits

...

193 Commits

Author SHA1 Message Date
Rafael Moraes 1013bd20b9 add mutagen to depedencies 2024-11-23 17:05:39 -03:00
Rafael Moraes 2c1fa9d99b bump version 2024-11-23 17:05:28 -03:00
Rafael Moraes fc1c161e30 Update README.md 2024-11-02 03:54:37 -03:00
Rafael Moraes 2f87902163 Update README.md 2024-11-01 13:38:54 -03:00
Rafael Moraes 9f7bb0d404 Update README.md 2024-11-01 13:37:28 -03:00
Rafael Moraes c653db00cf bump version 2024-10-31 13:59:02 -03:00
Rafael Moraes cdd574a349 Update README.md 2024-10-31 13:57:27 -03:00
Rafael Moraes afbe65707a replace print_exceptions with no_exceptions 2024-10-31 13:47:35 -03:00
Rafael Moraes 3998b698e0 fix for songs that don't have genre 2024-10-31 13:45:08 -03:00
Rafael Moraes a67c81bd22 remove ciso8601 from requirements 2024-10-27 12:37:40 -03:00
Rafael Moraes 9b0a2acc6f bump version 2024-10-27 12:36:46 -03:00
Rafael Moraes 4d904e2e7c address old python compatibility when parsing date 2024-10-27 12:36:12 -03:00
Rafael Moraes 2d3b2b6b1f replace ciso8601 with python datetime 2024-10-27 12:32:38 -03:00
Rafael Moraes 1ee8e2aa13 Merge pull request #139 from glomatico/glomatico-patch-1
Create LICENSE
2024-09-22 00:00:37 -03:00
Rafael Moraes fd6d8a0689 Create LICENSE 2024-09-22 00:00:26 -03:00
Rafael Moraes 50904e9c08 bump version 2024-09-13 21:20:33 -03:00
Rafael Moraes 66556eac0a update update_playlist_file 2024-09-13 21:20:20 -03:00
Rafael Moraes d97445ec9e check for synced_lyrics_only before saving cover 2024-09-08 00:26:56 -03:00
Rafael Moraes d6f30aa0a2 bump version 2024-09-08 00:21:47 -03:00
Rafael Moraes 42a17ca90f optimize stream_url fetching for music videos 2024-09-08 00:21:35 -03:00
Rafael Moraes 3ee0d28727 add from __future__ import annotations 2024-09-08 00:09:30 -03:00
Rafael Moraes 7b8875250c refactor get_playlist_file_path 2024-09-08 00:08:19 -03:00
Rafael Moraes 16734b8b64 refactor update_playlist_file 2024-09-08 00:07:54 -03:00
Rafael Moraes 475bddb5f7 refactor get_final_path 2024-09-08 00:07:30 -03:00
Rafael Moraes 63ba4b0824 change default value for template_file_playlist 2024-09-08 00:07:07 -03:00
Rafael Moraes 9d67e8f0f0 bump version 2024-08-29 22:50:49 -03:00
Rafael Moraes fcbe596a80 refactor to use webplayback api to get the stream url instead of itunes page 2024-08-29 22:50:19 -03:00
Rafael Moraes acd5fefb76 bump version 2024-08-05 11:14:39 -03:00
Rafael Moraes ed584cc9b9 skip to next track when track is not downloadable 2024-08-04 20:04:52 -03:00
Rafael Moraes bac8eb9254 remove useless save cover lines in music video 2024-08-04 20:02:41 -03:00
Rafael Moraes 71ac17cce2 improve post video download logic 2024-08-03 17:53:25 -03:00
Rafael Moraes a35a3835aa create parent directory before saving cover image 2024-08-03 17:49:42 -03:00
Rafael Moraes 091ca3bf53 fix get_playlist_file_path 2024-08-03 14:15:14 -03:00
Rafael Moraes e4498e11c0 bump version 2024-08-03 14:13:55 -03:00
Rafael Moraes 5a8c5d2c25 fix get_final_path 2024-08-03 14:13:38 -03:00
Rafael Moraes c21d50479f adjust updating m3u8 log message 2024-08-03 02:21:34 -03:00
Rafael Moraes b6d1f36281 run update_playlist_file only when track is downloaded 2024-08-03 02:19:15 -03:00
Rafael Moraes c0d1ec2383 specify netscape format on cookies instructions 2024-08-03 02:02:43 -03:00
Rafael Moraes 8c1a3dbe7d set default truncate value to null for avoiding confusion 2024-08-03 02:00:23 -03:00
Rafael Moraes aa71239eba fix artist links 2024-08-03 01:55:48 -03:00
Rafael Moraes c890068eb7 create ILLEGAL_CHAR_REPLACEMENT var 2024-08-03 01:45:53 -03:00
Rafael Moraes e3f96d8684 add encoding="utf8" when saving playlist 2024-08-03 01:41:22 -03:00
Rafael Moraes 238a8377e0 bump version 2024-08-03 01:38:14 -03:00
Rafael Moraes 6c3dff566b adjust m3u8 playlist log message 2024-08-03 00:39:37 -03:00
Rafael Moraes 07c847a788 rename save_playlist_file to save_playlist 2024-08-03 00:37:45 -03:00
Rafael Moraes 0ca56d24d7 update default file for truncate on config section 2024-08-03 00:37:08 -03:00
Rafael Moraes 566a8aa498 add save_playlist and template_file_playlist to config section 2024-08-03 00:36:47 -03:00
Rafael Moraes a7af9e704f add playlist tags to tags variables 2024-08-03 00:31:55 -03:00
Rafael Moraes 540009fc1b improve update_playlist_file 2024-08-03 00:28:06 -03:00
Rafael Moraes 19fdd85c35 add save_playlist_file option 2024-08-03 00:11:11 -03:00
Rafael Moraes 0cd87254d3 rework DownloadQueue and add playlist tags support 2024-08-02 23:47:31 -03:00
Rafael Moraes 6593644c72 rename metadata attribute from models to track_metadata 2024-08-02 22:39:30 -03:00
Rafael Moraes 005af07fcc create VALID_URL_REGEX variable 2024-08-02 22:22:19 -03:00
Rafael Moraes adabfd95bc adjust default truncate value to 0 2024-08-02 22:21:12 -03:00
Rafael Moraes 564ece387c handle no search results 2024-07-31 07:11:58 -03:00
Rafael Moraes 328428a520 Merge pull request #131 from dracarys69/patch-2
Update apple_music_api.py
2024-07-30 21:03:01 -03:00
dracarys69 2c70a23e59 Update apple_music_api.py
added search
2024-07-27 15:10:24 +03:00
Rafael Moraes 52288bb7af Update encoding for reading URLs from file 2024-07-08 16:04:45 -03:00
Rafael Moraes 281a357863 Update README.md 2024-06-21 00:15:49 -03:00
Rafael Moraes 744300e36b Bump version 2024-06-03 01:53:40 -03:00
Rafael Moraes e86f990395 Add pillow library to dependencies 2024-06-03 01:52:54 -03:00
Rafael Moraes abc2f8f2f2 Refactor get_cover_file_extension method to use IMAGE_FILE_EXTENSION_MAP 2024-06-03 01:51:48 -03:00
Rafael Moraes 2dabb1c6fe Add IMAGE_FILE_EXTENSION_MAP 2024-06-03 01:51:21 -03:00
Rafael Moraes 8b80b0c6c5 Update raw cover format description in README.md 2024-06-03 00:50:20 -03:00
Rafael Moraes eef659bac8 Update raw cover format description in README.md 2024-06-03 00:49:16 -03:00
Rafael Moraes 0f7c3795a7 Refactor cover image handling for downloader module in apply_tags 2024-06-03 00:34:35 -03:00
Rafael Moraes c84b1137c2 chore: Update cover path generation in downloader modules 2024-06-03 00:21:01 -03:00
Rafael Moraes ebdc82d68b docs: Clarify description for raw cover format in README.md 2024-06-02 21:24:22 -03:00
Rafael Moraes 85c1fdbfbb chore: Refactor get_url_response_bytes method to handle HTTP errors 2024-06-02 21:20:38 -03:00
Rafael Moraes 5990e5f722 Bump version to 2.2.5 2024-06-02 21:15:19 -03:00
Rafael Moraes b8bd406d74 chore: Add support for fetching raw cover images 2024-06-02 21:15:03 -03:00
Rafael Moraes 57ee6e1db8 Update README.md 2024-05-25 01:49:38 -03:00
Rafael Moraes a20feb2aa7 Update README.md 2024-05-25 01:47:21 -03:00
Rafael Moraes 60db7e0339 Update README.md 2024-05-25 01:44:59 -03:00
glomatico 575d2ee154 Update README.md 2024-05-20 15:58:12 -03:00
glomatico f5bb56cab7 lol 2024-05-20 15:58:04 -03:00
glomatico ecc7979d7e adjust log message 2024-05-20 12:52:21 -03:00
glomatico d129551b55 adjust log message 2024-05-20 12:51:47 -03:00
glomatico 08a5ac00d8 adjust get_playlist 2024-05-20 12:50:15 -03:00
glomatico 628c9786d5 remove unused import 2024-05-20 04:36:53 -03:00
glomatico 7de12c3da7 add storefront tag for post videos 2024-05-20 04:21:17 -03:00
glomatico 39d724c488 Update cli.py 2024-05-20 04:17:25 -03:00
glomatico 79e00e5e19 adjust some log messages 2024-05-20 04:15:54 -03:00
glomatico e90fd24af0 adjust temp_path for posts 2024-05-20 04:13:32 -03:00
glomatico d68edd5393 Update README.md 2024-05-20 04:09:31 -03:00
glomatico 5acefd9a06 Update README.md 2024-05-20 04:08:22 -03:00
glomatico 93b62cdde9 update description 2024-05-20 04:08:19 -03:00
glomatico fc61a51da2 Update README.md 2024-05-20 04:06:37 -03:00
glomatico 81b44a808d Add missing storefront id tag for music videos 2024-05-20 04:02:07 -03:00
glomatico 24f3af1a5e Update README.md 2024-05-16 23:11:26 -03:00
glomatico 4a469d74d3 Update README.md 2024-05-10 19:33:11 -03:00
glomatico 6122835caa Update README.md 2024-05-10 19:24:01 -03:00
glomatico be597f0de4 bump version 2024-05-10 12:42:41 -03:00
glomatico b10ab5332d adjust default nm3u8dlre path 2024-05-10 12:42:22 -03:00
glomatico 080413b183 bump version 2024-05-09 20:17:15 -03:00
glomatico f6443081ae add try-finally block in get_decryption_key 2024-05-09 20:16:56 -03:00
glomatico 8dcf10c221 whoops 2024-05-05 13:33:35 -03:00
glomatico 6f5efd1779 Update README.md 2024-05-05 13:32:21 -03:00
alacleaker 06e43fdbbe bump version 2024-04-24 21:30:28 -03:00
alacleaker 646125b93f adjust get_tags for music videos 2024-04-24 21:29:35 -03:00
alacleaker ea281766ba check if playlist has uri in get_playlist_audio_from_user 2024-04-24 14:04:42 -03:00
alacleaker a1b0ad35ee bump version 2024-04-24 14:01:29 -03:00
alacleaker 2f715b3d9d adjust MP4_FORMAT_CODECS and stream info audio codec 2024-04-24 13:58:22 -03:00
alacleaker 461fcedf30 remove fix key id 2024-04-23 15:50:28 -03:00
alacleaker 6d7cb3ada4 update description 2024-04-23 00:32:22 -03:00
alacleaker b87d406ffa update requirements 2024-04-23 00:31:40 -03:00
alacleaker f6efdb3332 Update README.md 2024-04-23 00:19:59 -03:00
alacleaker 29a006c304 update --read-urls-as-txt help 2024-04-23 00:19:16 -03:00
alacleaker a49a9c90cc read all url arguments instead of just the first when -r 2024-04-23 00:16:27 -03:00
alacleaker 43fd1dd2e3 Update README.md 2024-04-23 00:01:27 -03:00
alacleaker 2ed6ac05ba Update README.md 2024-04-23 00:00:25 -03:00
alacleaker 0f0e17f4cd adjust inquirer message 2024-04-22 23:56:36 -03:00
alacleaker 8c4d2713f7 switch from fstring to string 2024-04-22 23:37:42 -03:00
alacleaker 1baca4151b switch to InquirerPy in get_stream_url_from_user 2024-04-22 23:36:50 -03:00
alacleaker 6f08a4b2f9 adjust select_albums_from_artist 2024-04-22 23:12:56 -03:00
alacleaker 38f708e2e9 add validation to get_download_queue_from_artist 2024-04-22 23:11:25 -03:00
alacleaker f27adf98df add column indication when listing artist mvs/albums 2024-04-22 23:01:52 -03:00
alacleaker 9f0b25e1d1 add rating info when listing artist mvs 2024-04-22 22:57:27 -03:00
alacleaker 3590d99063 add rating info when listing artist albums 2024-04-22 22:55:18 -03:00
alacleaker b200dade5a change checking url log from debug to info 2024-04-22 21:12:23 -03:00
alacleaker 118d23e9db remove _best prefix from music_video_codec enum 2024-04-22 20:41:39 -03:00
alacleaker 7bc8c6668f bump version 2024-04-22 20:37:42 -03:00
alacleaker 0e9fb3702d adjust getting streaminfo log 2024-04-22 20:26:21 -03:00
alacleaker e706f0fa82 switch from tabulate to InquirerPy for selecting codecs 2024-04-22 20:24:37 -03:00
alacleaker 3014fb112d add support for artist urls 2024-04-22 20:05:15 -03:00
alacleaker d7f17b8b6f add debug message for url checking 2024-04-22 20:04:50 -03:00
alacleaker 947e2df81a rename some variables 2024-04-22 17:02:48 -03:00
alacleaker c8fe96b31d add get_artist 2024-04-22 13:01:38 -03:00
alacleaker 83a3efc1fa add _extend_api_data and _get_next_uri_response methods 2024-04-22 12:54:28 -03:00
alacleaker 345afbf174 adjust lyrics checking 2024-04-16 21:19:45 -03:00
alacleaker c35051a7ec bump version 2024-04-16 21:13:48 -03:00
alacleaker b286ee84e2 add from __future__ import annotations 2024-04-16 21:12:29 -03:00
alacleaker 9094f2c7b4 remove -> None 2024-04-16 21:09:04 -03:00
alacleaker 5feb5b274a remove "-isma" 2024-04-16 21:07:08 -03:00
alacleaker 1375af929c add keep utc to remux mp4box 2024-04-16 00:37:54 -03:00
alacleaker d280f1fad2 Merge branch 'main' of https://github.com/glomatico/gamdl 2024-04-15 10:43:45 -03:00
alacleaker 3a04d7927e adjust get_lyrics 2024-04-15 10:43:37 -03:00
Rafael Moraes 942a812308 Update README.md 2024-04-14 13:42:48 -03:00
Rafael Moraes 66e01293e6 Update README.md 2024-04-14 13:26:03 -03:00
alacleaker c0561da592 Update README.md 2024-04-13 02:58:04 -03:00
alacleaker 56d238fb1b update description 2024-04-13 02:58:01 -03:00
alacleaker d3a53bf93b Update README.md 2024-04-12 18:15:56 -03:00
alacleaker bb7a3ff77e Update README.md 2024-04-12 18:14:55 -03:00
alacleaker bf6293a0a0 Update README.md 2024-04-12 18:12:05 -03:00
alacleaker c421b3e855 fix incorrect spacing on synced lyrics 2024-04-12 18:05:04 -03:00
alacleaker 7e495300f9 bump version 2024-04-12 08:30:09 -03:00
alacleaker ccef00e39f add additional lyrics presence check 2024-04-12 07:14:53 -03:00
alacleaker 94cdba313c add missing subprocess_additional_args 2024-04-11 07:08:47 -03:00
alacleaker d7b19e8c67 add cookies file not found error 2024-04-11 07:07:31 -03:00
alacleaker a6809df2ef update description 2024-04-11 07:01:37 -03:00
alacleaker 40f3616bc3 Update README.md 2024-04-11 06:59:14 -03:00
alacleaker 104100e091 Update README.md 2024-04-11 06:58:36 -03:00
alacleaker ce4a7d7880 Update README.md 2024-04-11 06:52:15 -03:00
alacleaker ec09bacd39 Update README.md 2024-04-11 06:49:32 -03:00
alacleaker e0d3f46159 Update README.md 2024-04-10 19:10:27 -03:00
alacleaker 0bfb4d80b8 Update README.md 2024-04-10 19:06:03 -03:00
alacleaker 54d6d93967 Update README.md 2024-04-10 19:03:15 -03:00
alacleaker c13160b999 Update README.md 2024-04-10 19:02:34 -03:00
alacleaker ea4d574810 Update README.md 2024-04-10 18:57:20 -03:00
alacleaker 9da35c3f57 update --language help 2024-04-10 18:57:17 -03:00
alacleaker ebb7ec1da7 Update README.md 2024-04-10 18:55:27 -03:00
alacleaker d155a42e3a update --language help 2024-04-10 18:54:20 -03:00
alacleaker 8f18562e1c bump version 2024-04-10 17:07:50 -03:00
alacleaker 25ed506b82 add language option 2024-04-10 17:07:17 -03:00
alacleaker ba76241032 add silent and subprocess_additional_args 2024-04-10 16:51:15 -03:00
alacleaker c6f7e99135 add ac3 2024-04-09 14:43:35 -03:00
alacleaker dbaa1faa6b Merge branch 'main' of https://github.com/glomatico/gamdl 2024-04-09 14:40:32 -03:00
alacleaker a61a9c4975 catch abort exception when getting codec from user 2024-04-09 14:40:30 -03:00
Rafael Moraes 8ffe5c86ca Update FUNDING.yml 2024-04-09 13:20:03 -03:00
alacleaker 88bdf64825 Merge branch 'main' of https://github.com/glomatico/gamdl 2024-04-09 13:16:54 -03:00
alacleaker 3849df9adb add missing sanitize_date 2024-04-09 13:16:46 -03:00
Rafael Moraes a989ff6c34 Merge pull request #96 from glomatico/glomatico-patch-1
Create FUNDING.yml
2024-04-09 13:12:05 -03:00
Rafael Moraes f3d583aab2 Create FUNDING.yml 2024-04-09 13:11:53 -03:00
alacleaker 7ac3d3e400 bump version 2024-04-09 12:47:25 -03:00
alacleaker 085e8f1b5d check if m3u8 url exists 2024-04-09 12:47:11 -03:00
alacleaker 4df36e60d9 add from __future__ import annotations 2024-04-09 12:27:40 -03:00
alacleaker f6d726e466 bump version 2024-04-09 07:16:16 -03:00
alacleaker 61b1bf1e55 fix song not downloadable detection 2024-04-09 07:16:07 -03:00
alacleaker 3ae6709ccb bump version 2024-04-09 06:54:07 -03:00
alacleaker 1f00e4fb9f adjust non-legacy message 2024-04-09 06:53:53 -03:00
alacleaker 714d47bb13 Update README.md 2024-04-09 06:52:01 -03:00
alacleaker 46e3a92d4f adjust non-legacy warn 2024-04-09 06:51:56 -03:00
alacleaker 42b536d271 warn about non-legacy 2024-04-09 06:49:43 -03:00
alacleaker dac8d5eed9 add LEGACY_CODECS 2024-04-09 06:49:32 -03:00
alacleaker 2956f20dfa Update README.md 2024-04-08 21:18:53 -03:00
alacleaker 8f76743a3b Update README.md 2024-04-08 21:13:17 -03:00
alacleaker 3096bbc79d adjust song is not available message 2024-04-08 21:06:57 -03:00
alacleaker ed49d7bd5f adjust song is not available message 2024-04-08 21:06:47 -03:00
alacleaker 0ea72d0b78 adjust song is not available message 2024-04-08 21:06:37 -03:00
alacleaker ae490320ad bump version 2024-04-08 21:04:57 -03:00
alacleaker e40668e6ec adjust song is not available message 2024-04-08 21:04:22 -03:00
alacleaker 62c695b5ff bump version 2024-04-08 19:50:50 -03:00
alacleaker 5d9c8c1f0b add from __future__ import annotations 2024-04-08 19:50:39 -03:00
16 changed files with 855 additions and 431 deletions
+1
View File
@@ -0,0 +1 @@
ko_fi: glomatico
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Glomatico
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+109 -63
View File
@@ -1,18 +1,21 @@
# Glomatico's Apple Music Downloader
A Python script to download Apple Music songs/music videos/albums/playlists/post videos.
A Python CLI app for downloading Apple Music songs/music videos/posts.
**Discord Server:** https://discord.gg/aBjMEZ9tnq
## Features
* Download songs in AAC/Spatial AAC/Dolby Atmos/ALAC*
* Download songs in AAC 256kbps and other codecs
* Download music videos up to 4K
* Download synced lyrics in LRC, SRT or TTML
* Choose between FFmpeg and MP4Box for remuxing
* Choose between yt-dlp and N_m3u8DL-RE for downloading
* Highly customizable
* Use artist links to download all of their albums or music videos
## Prerequisites
* Python 3.8 or higher
* The cookies file of your Apple Music account (requires an active subscription)
* You can get your cookies by using one of the following extensions on your browser of choice at the Apple Music website with your account signed in:
* The cookies file of your Apple Music browser session in Netscape format (requires an active subscription)
* To export your cookies, use one of the following browser extensions while signed in to Apple Music:
* Firefox: https://addons.mozilla.org/addon/export-cookies-txt
* Chromium based browsers: https://chrome.google.com/webstore/detail/gdocmgbfkjnnpapoeobnolbbkoibbcif
* FFmpeg on your system PATH
@@ -20,66 +23,105 @@ A Python script to download Apple Music songs/music videos/albums/playlists/post
* Up to date binaries can be obtained from the links below:
* Windows: https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases
* Linux: https://johnvansickle.com/ffmpeg/
* (Optional) mp4decrypt on your system PATH
* Required to download music videos and songs in non-legacy formats.
* Binaries can be obtained from here: https://www.bento4.com/downloads/.
### Optional dependencies
The following tools are optional but required for specific features. Add them to your systems PATH or specify their paths using command-line arguments or the config file.
* [mp4decrypt](https://www.bento4.com/downloads/)
* Required when setting `mp4box` as remux mode, for downloading music videos and for downloading songs in non-legacy formats.
* [MP4Box](https://gpac.io/downloads/gpac-nightly-builds/)
* Required when setting `mp4box` as remux mode.
* [N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest)
* Required when setting `nm3u8dlre` as download mode.
## Installation
1. Install the package `gamdl` using pip
```bash
pip install gamdl
```
2. Place your cookies in the same directory you will run the script from and name it as `cookies.txt`
2. Set up the cookies file.
* You can either move to the current directory from which you will be running Gamdl as `cookies.txt` or specify its path using the command-line arguments/config file.
## Usage
```bash
gamdl [OPTIONS] URLS...
```
### Supported URL types
Gamdl supports the following types of URLs:
* Song
* Album
* Playlist
* Music video
* Artist
* Post video/extra video
### Examples
* Download a song
```bash
gamdl "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1626265761?i=1626265765"
gamdl "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
```
* Download an album
```bash
gamdl "https://music.apple.com/us/album/whenever-you-need-somebody-2022-remaster/1626265761"
gamdl "https://music.apple.com/us/album/whenever-you-need-somebody-2022-remaster/1624945511"
```
* Choose which albums or music videos to download from an artist
```bash
gamdl "https://music.apple.com/us/artist/rick-astley/669771"
```
### Interactive prompt controls
* Arrow keys - Move selection
* Space - Toggle selection
* Ctrl + A - Select all
* Enter - Confirm selection
## Configuration
You can configure gamdl by using the command line arguments or the config file. The config file is created automatically when you run gamdl for the first time at `~/.gamdl/config.json` on Linux and `%USERPROFILE%\.gamdl\config.json` on Windows. Config file values can be overridden using command line arguments.
| Command line argument / Config file key | Description | Default value |
| --------------------------------------------------------------- | ------------------------------------------------------------------ | -------------------------------------------- |
| `--disable-music-video-skip` / `disable_music_video_skip` | Don't skip downloading music videos in albums/playlists. | `false` |
| `--save-cover`, `-s` / `save_cover` | Save cover as a separate file. | `false` |
| `--overwrite` / `overwrite` | Overwrite existing files. | `false` |
| `--read-urls-as-txt`, `-r` / - | Interpret URLs as paths to text files containing URLs. | `false` |
| `--synced-lyrics-only` / `synced_lyrics_only` | Download only the synced lyrics. | `false` |
| `--no-synced-lyrics` / `no_synced_lyrics` | Don't download the synced lyrics. | `false` |
| `--config-path` / - | Path to config file. | `<home>/.spotify-web-downloader/config.json` |
| `--log-level` / `log_level` | Log level. | `INFO` |
| `--print-exceptions` / `print_exceptions` | Print exceptions. | `false` |
| `--cookies-path`, `-c` / `cookies_path` | Path to .txt cookies file. | `./cookies.txt` |
| `--output-path`, `-o` / `output_path` | Path to output directory. | `./Apple Music` |
| `--temp-path` / `temp_path` | Path to temporary directory. | `./temp` |
| `--wvd-path` / `wvd_path` | Path to .wvd file. | `null` |
| `--nm3u8dlre-path` / `nm3u8dlre_path` | Path to N_m3u8DL-RE binary. | `N_m3u8dl-RE` |
| `--mp4decrypt-path` / `mp4decrypt_path` | Path to mp4decrypt binary. | `mp4decrypt` |
| `--ffmpeg-path` / `ffmpeg_path` | Path to FFmpeg binary. | `ffmpeg` |
| `--mp4box-path` / `mp4box_path` | Path to MP4Box binary. | `MP4Box` |
| `--download-mode` / `download_mode` | Download mode. | `ytdlp` |
| `--remux-mode` / `remux_mode` | Remux mode. | `ffmpeg` |
| `--cover-format` / `cover_format` | Cover format. | `jpg` |
| `--template-folder-album` / `template_folder_album` | Template folder for tracks that are part of an album. | `{album_artist}/{album}` |
| `--template-folder-compilation` / `template_folder_compilation` | Template folder for tracks that are part of a compilation album. | `Compilations/{album}` |
| `--template-file-single-disc` / `template_file_single_disc` | Template file for the tracks that are part of a single-disc album. | `{track:02d} {title}` |
| `--template-file-multi-disc` / `template_file_multi_disc` | Template file for the tracks that are part of a multi-disc album. | `{disc}-{track:02d} {title}` |
| `--template-folder-no-album` / `template_folder_no_album` | Template folder for the tracks that are not part of an album. | `{artist}/Unknown Album` |
| `--template-file-no-album` / `template_file_no_album` | Template file for the tracks that are not part of an album. | `{title}` |
| `--template-date` / `template_date` | Date tag template. | `%Y-%m-%dT%H:%M:%SZ` |
| `--exclude-tags` / `exclude_tags` | Comma-separated tags to exclude. | `null` |
| `--cover-size` / `cover_size` | Cover size. | `1200` |
| `--truncate` / `truncate` | Maximum length of the file/folder names. | `40` |
| `--codec-song` / `codec_song` | Song codec. | `aac-legacy` |
| `--synced-lyrics-format` / `synced_lyrics_format` | Synced lyrics format. | `lrc` |
| `--codec-music-video` / `codec_music_video` | Music video codec. | `h264-best` |
| `--quality-post` / `quality_post` | Post video quality. | `best` |
| `--no-config-file`, `-n` / - | Do not use a config file. | `false` |
Gamdl can be configured by using the command line arguments or the config file.
The config file is created automatically when you run Gamdl for the first time at `~/.gamdl/config.json` on Linux and `%USERPROFILE%\.gamdl\config.json` on Windows.
Config file values can be overridden using command line arguments.
| Command line argument / Config file key | Description | Default value |
| --------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------- |
| `--disable-music-video-skip` / `disable_music_video_skip` | Don't skip downloading music videos in albums/playlists. | `false` |
| `--save-cover`, `-s` / `save_cover` | Save cover as a separate file. | `false` |
| `--overwrite` / `overwrite` | Overwrite existing files. | `false` |
| `--read-urls-as-txt`, `-r` / - | Interpret URLs as paths to text files containing URLs separated by newlines. | `false` |
| `--save-playlist` / `save_playlist` | Save a M3U8 playlist file when downloading a playlist. | `false` |
| `--synced-lyrics-only` / `synced_lyrics_only` | Download only the synced lyrics. | `false` |
| `--no-synced-lyrics` / `no_synced_lyrics` | Don't download the synced lyrics. | `false` |
| `--config-path` / - | Path to config file. | `<home>/.gamdl/config.json` |
| `--log-level` / `log_level` | Log level. | `INFO` |
| `--no-exceptions` / `no_exceptions` | Don't print exceptions. | `false` |
| `--cookies-path`, `-c` / `cookies_path` | Path to .txt cookies file. | `./cookies.txt` |
| `--language`, `-l` / `language` | Metadata language as an ISO-2A language code (don't always work for videos). | `en-US` |
| `--output-path`, `-o` / `output_path` | Path to output directory. | `./Apple Music` |
| `--temp-path` / `temp_path` | Path to temporary directory. | `./temp` |
| `--wvd-path` / `wvd_path` | Path to .wvd file. | `null` |
| `--nm3u8dlre-path` / `nm3u8dlre_path` | Path to N_m3u8DL-RE binary. | `N_m3u8DL-RE` |
| `--mp4decrypt-path` / `mp4decrypt_path` | Path to mp4decrypt binary. | `mp4decrypt` |
| `--ffmpeg-path` / `ffmpeg_path` | Path to FFmpeg binary. | `ffmpeg` |
| `--mp4box-path` / `mp4box_path` | Path to MP4Box binary. | `MP4Box` |
| `--download-mode` / `download_mode` | Download mode. | `ytdlp` |
| `--remux-mode` / `remux_mode` | Remux mode. | `ffmpeg` |
| `--cover-format` / `cover_format` | Cover format. | `jpg` |
| `--template-folder-album` / `template_folder_album` | Template folder for tracks that are part of an album. | `{album_artist}/{album}` |
| `--template-folder-compilation` / `template_folder_compilation` | Template folder for tracks that are part of a compilation album. | `Compilations/{album}` |
| `--template-file-single-disc` / `template_file_single_disc` | Template file for the tracks that are part of a single-disc album. | `{track:02d} {title}` |
| `--template-file-multi-disc` / `template_file_multi_disc` | Template file for the tracks that are part of a multi-disc album. | `{disc}-{track:02d} {title}` |
| `--template-folder-no-album` / `template_folder_no_album` | Template folder for the tracks that are not part of an album. | `{artist}/Unknown Album` |
| `--template-file-no-album` / `template_file_no_album` | Template file for the tracks that are not part of an album. | `{title}` |
| `--template-file-playlist` / `template_file_playlist` | Template file for the M3U8 playlist. | `Playlists/{playlist_title}` |
| `--template-date` / `template_date` | Date tag template. | `%Y-%m-%dT%H:%M:%SZ` |
| `--exclude-tags` / `exclude_tags` | Comma-separated tags to exclude. | `null` |
| `--cover-size` / `cover_size` | Cover size. | `1200` |
| `--truncate` / `truncate` | Maximum length of the file/folder names. | `null` |
| `--codec-song` / `codec_song` | Song codec. | `aac-legacy` |
| `--synced-lyrics-format` / `synced_lyrics_format` | Synced lyrics format. | `lrc` |
| `--codec-music-video` / `codec_music_video` | Music video codec. | `h264` |
| `--quality-post` / `quality_post` | Post video quality. | `best` |
| `--no-config-file`, `-n` / - | Do not use a config file. | `false` |
### Tags variables
The following variables can be used in the template folders/files and/or in the `exclude_tags` list:
@@ -105,6 +147,10 @@ The following variables can be used in the template folders/files and/or in the
* `genre_id`
* `lyrics`
* `media_type`
* `playlist_artist`
* `playlist_id`
* `playlist_title`
* `playlist_track`
* `rating`
* `storefront`
* `title`
@@ -117,48 +163,46 @@ The following variables can be used in the template folders/files and/or in the
### Remux modes
The following remux modes are available:
* `ffmpeg`
* Can be used without mp4decrypt only for songs and when using legacy song codecs
* `mp4box`
* Requires mp4decrypt
* Doesn't convert closed captions in music videos that have them
* Can be obtained from here: https://gpac.wp.imt.fr/downloads
### Download modes
The following download modes are available:
* `ytdlp`
* `nm3u8dlre`
* Faster than `ytdlp`
* Requires FFmpeg
* Can be obtained from here: https://github.com/nilaoda/N_m3u8DL-RE/releases
### Song codecs
The following codecs are available:
* `aac-legacy`
* `aac-he-legacy`
The following codecs are also available, **but are not guaranteed to work**, as currently most (or all) of the songs fails to be downloaded when using them:
* `aac`
* `aac-he`
* `aac-binaural`
* `aac-downmix`
* `aac-he-binaural`
* `aac-he-downmix`
* `alac`
* `atmos`
**Support for non-legacy codecs are not guaranteed, as most of the songs cannot be decrypted when using non-legacy codecs.**
* `ac3`
* `alac`
* `ask`
* When using this option, Gamdl will ask you which codec from this list to use that is available for the song.
### Music videos codecs
The following codecs are available:
* `h264-best` (with AAC 256kbps, up to 1080p)
* `h265-best` (With AAC 256kpbs, up to 2160p)
* `h264` (up to 1080p, with AAC 256kbps)
* `h265` (up to 2160p, with AAC 256kpbs)
* `ask`
* When using this option, the script will ask you which audio and video codec to use.
* When using this option, Gamdl will ask you which audio and video codec to use that is available for the music video.
### Post videos/extra videos qualities
The following qualities are available:
* `best` (with AAC 256kbps, up to 1080p)
* `best` (up to 1080p, with AAC 256kbps)
* `ask`
* When using this option, the script will ask you which video quality to use.
* When using this option, Gamdl will ask you which video quality to use that is available for the video.
Post videos doesn't require remuxing and are limited to `ytdlp` download mode.
@@ -168,10 +212,12 @@ The following synced lyrics formats are available:
* `srt`
* `ttml`
* Native format for Apple Music synced lyrics.
* Highly unsupported by media players.
* Highly unsupported by most media players.
### Cover formats
The following cover formats are available:
* `jpg`
* `png`
* `raw`
* This format gets the raw cover without any processing.
* Note that when using this format, the cover image will not be embedded within the files. To address this, you can enable `save_cover` option to save the cover as a separate file.
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.1"
__version__ = "2.3.8"
+67 -19
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import functools
import re
import time
import typing
from http.cookiejar import MozillaCookieJar
from pathlib import Path
@@ -77,7 +78,7 @@ class AppleMusicApi:
try:
response.raise_for_status()
response_dict = response.json()
assert response_dict.get("data")
assert response_dict.get("data") or response_dict.get("results") is not None
except (
requests.HTTPError,
requests.exceptions.JSONDecodeError,
@@ -85,6 +86,31 @@ class AppleMusicApi:
):
self._raise_response_exception(response)
def get_artist(
self,
artist_id: str,
include: str = "albums,music-videos",
limit: int = 100,
fetch_all: bool = True,
) -> dict:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/artists/{artist_id}",
params={
"include": include,
**{f"limit[{_include}]": limit for _include in include.split(",")},
},
)
self._check_amp_api_response(response)
artist = response.json()["data"][0]
if fetch_all:
for _include in include.split(","):
for additional_data in self._extend_api_data(
artist["relationships"][_include],
limit,
):
artist["relationships"][_include]["data"].extend(additional_data)
return artist
def get_song(
self,
song_id: str,
@@ -143,13 +169,12 @@ class AppleMusicApi:
def get_playlist(
self,
playlist_id: str,
is_library: bool = False,
limit_tracks: int = 300,
extend: str = "extendedAssetUrls",
full_playlist: bool = True,
fetch_all: bool = True,
) -> dict:
response = self.session.get(
f"{self.AMP_API_URL}/v1/{'me' if is_library else 'catalog'}/{self.storefront}/playlists/{playlist_id}",
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/playlists/{playlist_id}",
params={
"extend": extend,
"limit[tracks]": limit_tracks,
@@ -157,28 +182,51 @@ class AppleMusicApi:
)
self._check_amp_api_response(response)
playlist = response.json()["data"][0]
if full_playlist:
playlist = self._extend_playlists_tracks(playlist, limit_tracks)
if fetch_all:
for additional_data in self._extend_api_data(
playlist["relationships"]["tracks"],
limit_tracks,
):
playlist["relationships"]["tracks"]["data"].extend(additional_data)
return playlist
def _extend_playlists_tracks(
def search(
self,
playlist: dict,
limit_tracks: int,
term: str,
types: str = "songs,albums,artists,playlists",
limit: int = 25,
offset: int = 0,
) -> dict:
playlist_next_uri = playlist["relationships"]["tracks"].get("next")
while playlist_next_uri:
playlist_next = self._get_playlist_next(playlist_next_uri, limit_tracks)
playlist["relationships"]["tracks"]["data"].extend(playlist_next["data"])
playlist_next_uri = playlist_next.get("next")
time.sleep(self.WAIT_TIME)
return playlist
def _get_playlist_next(self, playlist_next_uri: str, limit_tracks: int) -> dict:
response = self.session.get(
self.AMP_API_URL + playlist_next_uri,
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/search",
params={
"limit[tracks]": limit_tracks,
"term": term,
"types": types,
"limit": limit,
"offset": offset,
},
)
self._check_amp_api_response(response)
return response.json()["results"]
def _extend_api_data(
self,
api_response: dict,
limit: int,
) -> typing.Generator[list[dict], None, None]:
next_uri = api_response.get("next")
while next_uri:
playlist_next = self._get_next_uri_response(next_uri, limit)
yield playlist_next["data"]
next_uri = playlist_next.get("next")
time.sleep(self.WAIT_TIME)
def _get_next_uri_response(self, next_uri: str, limit: int) -> dict:
response = self.session.get(
self.AMP_API_URL + next_uri,
params={
"limit": limit,
},
)
self._check_amp_api_response(response)
+202 -126
View File
@@ -35,7 +35,7 @@ def get_param_string(param: click.Parameter) -> str:
return param.default
def write_default_config_file(ctx: click.Context) -> None:
def write_default_config_file(ctx: click.Context):
ctx.params["config_path"].parent.mkdir(parents=True, exist_ok=True)
config_file = {
param.name: get_param_string(param)
@@ -95,7 +95,12 @@ def load_config_file(
"--read-urls-as-txt",
"-r",
is_flag=True,
help="Interpret URLs as paths to text files containing URLs.",
help="Interpret URLs as paths to text files containing URLs separated by newlines",
)
@click.option(
"--save-playlist",
is_flag=True,
help="Save a M3U8 playlist file when downloading a playlist.",
)
@click.option(
"--synced-lyrics-only",
@@ -120,9 +125,9 @@ def load_config_file(
help="Log level.",
)
@click.option(
"--print-exceptions",
"--no-exceptions",
is_flag=True,
help="Print exceptions.",
help="Don't print exceptions.",
)
# API specific options
@click.option(
@@ -132,6 +137,13 @@ def load_config_file(
default=apple_music_api_sig.parameters["cookies_path"].default,
help="Path to .txt cookies file.",
)
@click.option(
"--language",
"-l",
type=str,
default=apple_music_api_sig.parameters["language"].default,
help="Metadata language as an ISO-2A language code (don't always work for videos).",
)
# Downloader specific options
@click.option(
"--output-path",
@@ -230,6 +242,12 @@ def load_config_file(
default=downloader_sig.parameters["template_file_no_album"].default,
help="Template file for the tracks that are not part of an album.",
)
@click.option(
"--template-file-playlist",
type=str,
default=downloader_sig.parameters["template_file_playlist"].default,
help="Template file for the M3U8 playlist.",
)
@click.option(
"--template-date",
type=str,
@@ -295,12 +313,14 @@ def main(
save_cover: bool,
overwrite: bool,
read_urls_as_txt: bool,
save_playlist: bool,
synced_lyrics_only: bool,
no_synced_lyrics: bool,
config_path: Path,
log_level: str,
print_exceptions: bool,
no_exceptions: bool,
cookies_path: Path,
language: str,
output_path: Path,
temp_path: Path,
wvd_path: Path,
@@ -317,6 +337,7 @@ def main(
template_file_multi_disc: str,
template_folder_no_album: str,
template_file_no_album: str,
template_file_playlist: str,
template_date: str,
exclude_tags: str,
cover_size: int,
@@ -333,8 +354,14 @@ def main(
)
logger = logging.getLogger(__name__)
logger.setLevel(log_level)
logger.debug("Starting downloader")
apple_music_api = AppleMusicApi(cookies_path)
logger.info("Starting Gamdl")
if not cookies_path.exists():
logger.critical(X_NOT_FOUND_STRING.format("Cookies file", cookies_path))
return
apple_music_api = AppleMusicApi(
cookies_path,
language=language,
)
itunes_api = ItunesApi(
apple_music_api.storefront,
apple_music_api.language,
@@ -358,6 +385,7 @@ def main(
template_file_multi_disc,
template_folder_no_album,
template_file_no_album,
template_file_playlist,
template_date,
exclude_tags,
cover_size,
@@ -419,38 +447,55 @@ def main(
skip_mv = True
else:
skip_mv = False
if codec_song not in LEGACY_CODECS:
logger.warn(
"You have chosen a non-legacy codec. Support for non-legacy codecs are not guaranteed, "
"as most of the songs cannot be downloaded when using non-legacy codecs."
)
error_count = 0
if read_urls_as_txt:
urls = [url.strip() for url in Path(urls[0]).read_text().splitlines()]
_urls = []
for url in urls:
if Path(url).exists():
_urls.extend(Path(url).read_text(encoding="utf-8").splitlines())
urls = _urls
for url_index, url in enumerate(urls, start=1):
url_progress = f"URL {url_index}/{len(urls)}"
try:
logger.info(f'({url_progress}) Checking "{url}"')
url_info = downloader.get_url_info(url)
download_queue = downloader.get_download_queue(url_info)
download_queue_tracks_metadata = download_queue.tracks_metadata
except Exception as e:
error_count += 1
logger.error(
f'({url_progress}) Failed to check "{url}"',
exc_info=print_exceptions,
exc_info=not no_exceptions,
)
continue
for queue_index, queue_item in enumerate(download_queue, start=1):
queue_progress = f"Track {queue_index}/{len(download_queue)} from URL {url_index}/{len(urls)}"
track = queue_item.metadata
for download_index, track_metadata in enumerate(
download_queue_tracks_metadata, start=1
):
queue_progress = f"Track {download_index}/{len(download_queue_tracks_metadata)} from URL {url_index}/{len(urls)}"
try:
remuxed_path = None
if download_queue.playlist_attributes:
playlist_track = download_index
else:
playlist_track = None
logger.info(
f'({queue_progress}) Downloading "{track["attributes"]["name"]}"'
f'({queue_progress}) Downloading "{track_metadata["attributes"]["name"]}"'
)
if not track["attributes"].get("playParams"):
if not track_metadata["attributes"].get("playParams"):
logger.warning(
f"({queue_progress}) Track is not streamable, skipping"
)
continue
if (
(synced_lyrics_only and track["type"] != "songs")
or (track["type"] == "music-videos" and skip_mv)
(synced_lyrics_only and track_metadata["type"] != "songs")
or (track_metadata["type"] == "music-videos" and skip_mv)
or (
track["type"] == "music-videos"
track_metadata["type"] == "music-videos"
and url_info.type == "album"
and not disable_music_video_skip
)
@@ -458,18 +503,31 @@ def main(
logger.warning(
f"({queue_progress}) Track is not downloadable with current configuration, skipping"
)
elif track["type"] == "songs":
continue
elif track_metadata["type"] == "songs":
logger.debug("Getting lyrics")
lyrics = downloader_song.get_lyrics(track)
lyrics = downloader_song.get_lyrics(track_metadata)
logger.debug("Getting webplayback")
webplayback = apple_music_api.get_webplayback(track["id"])
webplayback = apple_music_api.get_webplayback(track_metadata["id"])
tags = downloader_song.get_tags(webplayback, lyrics.unsynced)
if playlist_track:
tags = {
**tags,
**downloader.get_playlist_tags(
download_queue.playlist_attributes,
playlist_track,
),
}
final_path = downloader.get_final_path(tags, ".m4a")
lyrics_synced_path = downloader_song.get_lyrics_synced_path(
final_path
)
cover_path = downloader_song.get_cover_path(final_path)
cover_url = downloader.get_cover_url(track)
cover_url = downloader.get_cover_url(track_metadata)
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
cover_path = downloader_song.get_cover_path(
final_path,
cover_file_extesion,
)
if synced_lyrics_only:
pass
elif final_path.exists() and not overwrite:
@@ -477,44 +535,44 @@ def main(
f'({queue_progress}) Song already exists at "{final_path}", skipping'
)
else:
if codec_song in (
SongCodec.AAC_LEGACY,
SongCodec.AAC_HE_LEGACY,
):
logger.debug("Getting stream info")
logger.debug("Getting stream info")
if codec_song in LEGACY_CODECS:
stream_info = downloader_song_legacy.get_stream_info(
webplayback
)
logger.debug("Getting decryption key")
decryption_key = downloader_song_legacy.get_decryption_key(
stream_info.pssh, track["id"]
stream_info.pssh, track_metadata["id"]
)
else:
stream_info = downloader_song.get_stream_info(track)
if not stream_info.pssh:
stream_info = downloader_song.get_stream_info(
track_metadata
)
if not stream_info.stream_url or not stream_info.pssh:
logger.warning(
f"({queue_progress}) Song does not contain Widevine DRM, skipping"
)
continue
elif not stream_info.stream_url:
logger.warning(
f"({queue_progress}) Song is not available with the selected codec, skipping"
f"({queue_progress}) Song is not downloadable or is not"
" available in the chosen codec, skipping"
)
continue
logger.debug("Getting decryption key")
decryption_key = downloader.get_decryption_key(
stream_info.pssh, track["id"]
stream_info.pssh, track_metadata["id"]
)
encrypted_path = downloader_song.get_encrypted_path(track["id"])
decrypted_path = downloader_song.get_decrypted_path(track["id"])
remuxed_path = downloader_song.get_remuxed_path(track["id"])
logger.debug(f"Downloading to {encrypted_path}")
encrypted_path = downloader_song.get_encrypted_path(
track_metadata["id"]
)
decrypted_path = downloader_song.get_decrypted_path(
track_metadata["id"]
)
remuxed_path = downloader_song.get_remuxed_path(
track_metadata["id"]
)
logger.debug(f'Downloading to "{encrypted_path}"')
downloader.download(encrypted_path, stream_info.stream_url)
if codec_song in (
SongCodec.AAC_LEGACY,
SongCodec.AAC_HE_LEGACY,
):
logger.debug(f"Remuxing/Decrypting to {remuxed_path}")
if codec_song in LEGACY_CODECS:
logger.debug(
f'Decrypting/Remuxing to "{decrypted_path}"/"{remuxed_path}"'
)
downloader_song_legacy.remux(
encrypted_path,
decrypted_path,
@@ -522,20 +580,16 @@ def main(
decryption_key,
)
else:
logger.debug(f"Decrypting to {decrypted_path}")
logger.debug(f'Decrypting to "{decrypted_path}"')
downloader_song.decrypt(
encrypted_path, decrypted_path, decryption_key
)
logger.debug(f"Remuxing to {final_path}")
logger.debug(f'Remuxing to "{final_path}"')
downloader_song.remux(
decrypted_path,
remuxed_path,
stream_info.codec,
)
logger.debug("Applying tags")
downloader.apply_tags(remuxed_path, tags, cover_url)
logger.debug(f"Moving to {final_path}")
downloader.move_to_output_path(remuxed_path, final_path)
if no_synced_lyrics or not lyrics.synced:
pass
elif lyrics_synced_path.exists() and not overwrite:
@@ -547,38 +601,52 @@ def main(
downloader_song.save_lyrics_synced(
lyrics_synced_path, lyrics.synced
)
if synced_lyrics_only or not save_cover:
pass
elif cover_path.exists() and not overwrite:
logger.debug(
f'Cover already exists at "{cover_path}", skipping'
)
else:
logger.debug(f'Saving cover to "{cover_path}"')
downloader.save_cover(cover_path, cover_url)
elif track["type"] == "music-videos":
elif track_metadata["type"] == "music-videos":
music_video_id_alt = downloader_music_video.get_music_video_id_alt(
track
track_metadata
)
logger.debug("Getting iTunes page")
itunes_page = itunes_api.get_itunes_page(
"music-video", music_video_id_alt
)
stream_url_master = downloader_music_video.get_stream_url_master(
itunes_page
)
if music_video_id_alt == track_metadata["id"]:
stream_url = (
downloader_music_video.get_stream_url_from_itunes_page(
itunes_page
)
)
else:
logger.debug("Getting webplayback")
webplayback = apple_music_api.get_webplayback(
track_metadata["id"]
)
stream_url = (
downloader_music_video.get_stream_url_from_webplayback(
webplayback
)
)
logger.debug("Getting M3U8 data")
m3u8_master_data = downloader_music_video.get_m3u8_master_data(
stream_url_master
)
m3u8_data = downloader_music_video.get_m3u8_master_data(stream_url)
tags = downloader_music_video.get_tags(
music_video_id_alt,
itunes_page,
m3u8_master_data,
track,
track_metadata,
)
if playlist_track:
tags = {
**tags,
**downloader.get_playlist_tags(
download_queue.playlist_attributes,
playlist_track,
),
}
final_path = downloader.get_final_path(tags, ".m4v")
cover_path = downloader_music_video.get_cover_path(final_path)
cover_url = downloader.get_cover_url(track)
cover_url = downloader.get_cover_url(track_metadata)
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
cover_path = downloader_music_video.get_cover_path(
final_path,
cover_file_extesion,
)
if final_path.exists() and not overwrite:
logger.warning(
f'({queue_progress}) Music video already exists at "{final_path}", skipping'
@@ -586,55 +654,59 @@ def main(
else:
logger.debug("Getting stream info")
stream_info_video, stream_info_audio = (
downloader_music_video.get_stream_info_video(
m3u8_master_data
),
downloader_music_video.get_stream_info_audio(
m3u8_master_data
),
downloader_music_video.get_stream_info_video(m3u8_data),
downloader_music_video.get_stream_info_audio(m3u8_data),
)
decryption_key_video = downloader.get_decryption_key(
stream_info_video.pssh, track["id"]
stream_info_video.pssh, track_metadata["id"]
)
decryption_key_audio = downloader.get_decryption_key(
stream_info_audio.pssh, track["id"]
stream_info_audio.pssh, track_metadata["id"]
)
encrypted_path_video = (
downloader_music_video.get_encrypted_path_video(track["id"])
downloader_music_video.get_encrypted_path_video(
track_metadata["id"]
)
)
encrypted_path_audio = (
downloader_music_video.get_encrypted_path_audio(track["id"])
downloader_music_video.get_encrypted_path_audio(
track_metadata["id"]
)
)
decrypted_path_video = (
downloader_music_video.get_decrypted_path_video(track["id"])
downloader_music_video.get_decrypted_path_video(
track_metadata["id"]
)
)
decrypted_path_audio = (
downloader_music_video.get_decrypted_path_audio(track["id"])
downloader_music_video.get_decrypted_path_audio(
track_metadata["id"]
)
)
remuxed_path = downloader_music_video.get_remuxed_path(
track["id"]
track_metadata["id"]
)
logger.debug(f"Downloading video to {encrypted_path_video}")
logger.debug(f'Downloading video to "{encrypted_path_video}"')
downloader.download(
encrypted_path_video, stream_info_video.stream_url
)
logger.debug(f"Downloading audio to {encrypted_path_audio}")
logger.debug(f'Downloading audio to "{encrypted_path_audio}"')
downloader.download(
encrypted_path_audio, stream_info_audio.stream_url
)
logger.debug(f"Decrypting video to {decrypted_path_video}")
logger.debug(f'Decrypting video to "{decrypted_path_video}"')
downloader_music_video.decrypt(
encrypted_path_video,
decryption_key_video,
decrypted_path_video,
)
logger.debug(f"Decrypting audio to {decrypted_path_audio}")
logger.debug(f'Decrypting audio to "{decrypted_path_audio}"')
downloader_music_video.decrypt(
encrypted_path_audio,
decryption_key_audio,
decrypted_path_audio,
)
logger.debug(f"Remuxing to {remuxed_path}")
logger.debug(f'Remuxing to "{remuxed_path}"')
downloader_music_video.remux(
decrypted_path_video,
decrypted_path_audio,
@@ -642,51 +714,55 @@ def main(
stream_info_video.codec,
stream_info_audio.codec,
)
logger.debug("Applying tags")
downloader.apply_tags(remuxed_path, tags, cover_url)
logger.debug(f"Moving to {final_path}")
downloader.move_to_output_path(remuxed_path, final_path)
if not save_cover:
pass
elif cover_path.exists() and not overwrite:
logger.debug(
f'Cover already exists at "{cover_path}", skipping'
)
else:
logger.debug(f'Saving cover to "{cover_path}"')
downloader.save_cover(cover_path, cover_url)
elif track["type"] == "uploaded-videos":
stream_url = downloader_post.get_stream_url(track)
tags = downloader_post.get_tags(track)
temp_path = downloader_post.get_temp_path(track["id"])
elif track_metadata["type"] == "uploaded-videos":
stream_url = downloader_post.get_stream_url(track_metadata)
tags = downloader_post.get_tags(track_metadata)
final_path = downloader.get_final_path(tags, ".m4v")
cover_path = downloader_music_video.get_cover_path(final_path)
cover_url = downloader.get_cover_url(track)
cover_url = downloader.get_cover_url(track_metadata)
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
cover_path = downloader_music_video.get_cover_path(
final_path,
cover_file_extesion,
)
if final_path.exists() and not overwrite:
logger.warning(
f'({queue_progress}) Post video already exists at "{final_path}", skipping'
)
else:
logger.debug(f"Downloading to {final_path}")
downloader.download_ytdlp(temp_path, stream_url)
logger.debug("Applying tags")
downloader.apply_tags(temp_path, tags, cover_url)
logger.debug(f"Moving to {final_path}")
downloader.move_to_output_path(temp_path, final_path)
if not save_cover:
pass
elif cover_path.exists() and not overwrite:
logger.debug(
f'Cover already exists at "{cover_path}", skipping'
remuxed_path = downloader_post.get_post_temp_path(
track_metadata["id"]
)
else:
logger.debug(f'Saving cover to "{cover_path}"')
downloader.save_cover(cover_path, cover_url)
logger.debug(f'Downloading to "{remuxed_path}"')
downloader.download_ytdlp(remuxed_path, stream_url)
if synced_lyrics_only or not save_cover:
pass
elif cover_path.exists() and not overwrite:
logger.debug(f'Cover already exists at "{cover_path}", skipping')
else:
logger.debug(f'Saving cover to "{cover_path}"')
downloader.save_cover(cover_path, cover_url)
if remuxed_path:
logger.debug("Applying tags")
downloader.apply_tags(remuxed_path, tags, cover_url)
logger.debug(f'Moving to "{final_path}"')
downloader.move_to_output_path(remuxed_path, final_path)
if (
not synced_lyrics_only
and save_playlist
and download_queue.playlist_attributes
):
playlist_file_path = downloader.get_playlist_file_path(tags)
logger.debug(f'Updating M3U8 playlist from "{playlist_file_path}"')
downloader.update_playlist_file(
playlist_file_path,
final_path,
playlist_track,
)
except Exception as e:
error_count += 1
logger.error(
f'({queue_progress}) Failed to download "{track["attributes"]["name"]}"',
exc_info=print_exceptions,
f'({queue_progress}) Failed to download "{track_metadata["attributes"]["name"]}"',
exc_info=not no_exceptions,
)
finally:
if temp_path.exists():
+14 -4
View File
@@ -191,13 +191,14 @@ SONG_CODEC_REGEX_MAP = {
SongCodec.AAC_DOWNMIX: r"audio-stereo-\d+-downmix",
SongCodec.AAC_HE_BINAURAL: r"audio-HE-stereo-\d+-binaural",
SongCodec.AAC_HE_DOWNMIX: r"audio-HE-stereo-\d+-downmix",
SongCodec.ALAC: r"audio-alac-.*",
SongCodec.ATMOS: r"audio-atmos-.*",
SongCodec.AC3: r"audio-ac3-.*",
SongCodec.ALAC: r"audio-alac-.*",
}
MUSIC_VIDEO_CODEC_MAP = {
MusicVideoCodec.H264_BEST: "avc1",
MusicVideoCodec.H265_BEST: "hvc1",
MusicVideoCodec.H264: "avc1",
MusicVideoCodec.H265: "hvc1",
}
SYNCED_LYRICS_FILE_EXTENSION_MAP = {
@@ -207,6 +208,12 @@ SYNCED_LYRICS_FILE_EXTENSION_MAP = {
}
IMAGE_FILE_EXTENSION_MAP = {
"jpeg": ".jpg",
"tiff": ".tif",
}
EXCLUDED_CONFIG_FILE_PARAMS = (
"urls",
"config_path",
@@ -218,4 +225,7 @@ EXCLUDED_CONFIG_FILE_PARAMS = (
X_NOT_FOUND_STRING = '{} not found at "{}"'
AMP_API_HOSTNAME = "https://amp-api.music.apple.com"
LEGACY_CODECS = [
SongCodec.AAC_LEGACY,
SongCodec.AAC_HE_LEGACY,
]
+267 -74
View File
@@ -1,26 +1,35 @@
from __future__ import annotations
import base64
import datetime
import functools
import io
import re
import shutil
import subprocess
import typing
from pathlib import Path
import ciso8601
import requests
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from mutagen.mp4 import MP4, MP4Cover
from PIL import Image
from pywidevine import PSSH, Cdm, Device
from yt_dlp import YoutubeDL
from .apple_music_api import AppleMusicApi
from .constants import MP4_TAGS_MAP
from .constants import IMAGE_FILE_EXTENSION_MAP, MP4_TAGS_MAP
from .enums import CoverFormat, DownloadMode, RemuxMode
from .hardcoded_wvd import HARDCODED_WVD
from .itunes_api import ItunesApi
from .models import DownloadQueueItem, UrlInfo
from .models import DownloadQueue, UrlInfo
class Downloader:
ILLEGAL_CHARACTERS_REGEX = r'[\\/:*?"<>|;]'
ILLEGAL_CHARS_RE = r'[\\/:*?"<>|;]'
ILLEGAL_CHAR_REPLACEMENT = "_"
VALID_URL_RE = r"/([a-z]{2})/(artist|album|playlist|song|music-video|post)/([^/]*)(?:/([^/?]*))?(?:\?i=)?([0-9a-z]*)?"
def __init__(
self,
@@ -29,7 +38,7 @@ class Downloader:
output_path: Path = Path("./Apple Music"),
temp_path: Path = Path("./temp"),
wvd_path: Path = None,
nm3u8dlre_path: str = "N_m3u8dl-RE",
nm3u8dlre_path: str = "N_m3u8DL-RE",
mp4decrypt_path: str = "mp4decrypt",
ffmpeg_path: str = "ffmpeg",
mp4box_path: str = "MP4Box",
@@ -42,11 +51,12 @@ class Downloader:
template_file_multi_disc: str = "{disc}-{track:02d} {title}",
template_folder_no_album: str = "{artist}/Unknown Album",
template_file_no_album: str = "{title}",
template_file_playlist: str = "Playlists/{playlist_artist}/{playlist_title}",
template_date: str = "%Y-%m-%dT%H:%M:%SZ",
exclude_tags: str = None,
cover_size: int = 1200,
truncate: int = 40,
no_progress: bool = False,
truncate: int = None,
silent: bool = False,
):
self.apple_music_api = apple_music_api
self.itunes_api = itunes_api
@@ -66,14 +76,16 @@ class Downloader:
self.template_file_multi_disc = template_file_multi_disc
self.template_folder_no_album = template_folder_no_album
self.template_file_no_album = template_file_no_album
self.template_file_playlist = template_file_playlist
self.template_date = template_date
self.exclude_tags = exclude_tags
self.cover_size = cover_size
self.truncate = truncate
self.no_progress = no_progress
self.silent = silent
self._set_binaries_path_full()
self._set_exclude_tags_list()
self._set_truncate()
self._set_subprocess_additional_args()
def _set_binaries_path_full(self):
self.nm3u8dlre_path_full = shutil.which(self.nm3u8dlre_path)
@@ -89,7 +101,17 @@ class Downloader:
)
def _set_truncate(self):
self.truncate = None if self.truncate < 4 else self.truncate
if self.truncate is not None:
self.truncate = None if self.truncate < 4 else self.truncate
def _set_subprocess_additional_args(self):
if self.silent:
self.subprocess_additional_args = {
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
}
else:
self.subprocess_additional_args = {}
def set_cdm(self):
if self.wvd_path:
@@ -100,7 +122,7 @@ class Downloader:
def get_url_info(self, url: str) -> UrlInfo:
url_info = UrlInfo()
url_regex_result = re.search(
r"/([a-z]{2})/(album|playlist|song|music-video|post)/([^/]*)(?:/([^/?]*))?(?:\?i=)?([0-9a-z]*)?",
self.VALID_URL_RE,
url,
)
url_info.storefront = url_regex_result.group(1)
@@ -114,56 +136,201 @@ class Downloader:
)
return url_info
def get_download_queue(self, url_info: UrlInfo) -> list[DownloadQueueItem]:
def get_download_queue(self, url_info: UrlInfo) -> DownloadQueue:
return self._get_download_queue(url_info.type, url_info.id)
def _get_download_queue(self, url_type: str, id: str) -> list[DownloadQueueItem]:
download_queue = []
if url_type == "song":
download_queue.append(DownloadQueueItem(self.apple_music_api.get_song(id)))
def _get_download_queue(self, url_type: str, id: str) -> DownloadQueue:
download_queue = DownloadQueue()
if url_type == "artist":
artist = self.apple_music_api.get_artist(id)
download_queue.tracks_metadata = list(
self.get_download_queue_from_artist(artist)
)
elif url_type == "song":
download_queue.tracks_metadata = [self.apple_music_api.get_song(id)]
elif url_type == "album":
album = self.apple_music_api.get_album(id)
download_queue.extend(
DownloadQueueItem(track)
for track in album["relationships"]["tracks"]["data"]
)
download_queue.tracks_metadata = [
track for track in album["relationships"]["tracks"]["data"]
]
elif url_type == "playlist":
download_queue.extend(
DownloadQueueItem(track)
playlist = self.apple_music_api.get_playlist(id)
download_queue.playlist_attributes = playlist["attributes"]
download_queue.tracks_metadata = [
track
for track in self.apple_music_api.get_playlist(id)["relationships"][
"tracks"
]["data"]
)
]
elif url_type == "music-video":
download_queue.append(
DownloadQueueItem(self.apple_music_api.get_music_video(id))
)
download_queue.tracks_metadata = [self.apple_music_api.get_music_video(id)]
elif url_type == "post":
download_queue.append(DownloadQueueItem(self.apple_music_api.get_post(id)))
else:
raise Exception(f"Invalid url type: {url_type}")
download_queue.tracks_metadata = [self.apple_music_api.get_post(id)]
return download_queue
def sanitize_date(self, date: str):
datetime_obj = ciso8601.parse_datetime(date)
return datetime_obj.strftime(self.template_date)
def get_download_queue_from_artist(
self,
artist: dict,
) -> typing.Generator[dict, None, None]:
media_type = inquirer.select(
message=f'Select which type to download for artist "{artist["attributes"]["name"]}":',
choices=[
Choice(name="Albums", value="albums"),
Choice(
name="Music Videos",
value="music-videos",
),
],
validate=lambda result: artist["relationships"].get(result, {}).get("data"),
invalid_message="The artist doesn't have any items of this type",
).execute()
if media_type == "albums":
yield from self.select_albums_from_artist(
artist["relationships"]["albums"]["data"]
)
elif media_type == "music-videos":
yield from self.select_music_videos_from_artist(
artist["relationships"]["music-videos"]["data"]
)
def select_albums_from_artist(
self,
albums: list[dict],
) -> typing.Generator[dict, None, None]:
choices = [
Choice(
name=" | ".join(
[
f'{album["attributes"]["trackCount"]:03d}',
f'{album["attributes"]["releaseDate"]:<10}',
f'{album["attributes"].get("contentRating", "None").title():<8}',
f'{album["attributes"]["name"]}',
]
),
value=album,
)
for album in albums
]
selected = inquirer.select(
message="Select which albums to download: (Track Count | Release Date | Rating | Title)",
choices=choices,
multiselect=True,
).execute()
for album in selected:
for track in self.apple_music_api.get_album(album["id"])["relationships"][
"tracks"
]["data"]:
yield track
def select_music_videos_from_artist(
self,
music_videos: list[dict],
) -> typing.Generator[dict, None, None]:
choices = [
Choice(
name=" | ".join(
[
self.millis_to_min_sec(
music_video["attributes"]["durationInMillis"]
),
f'{music_video["attributes"].get("contentRating", "None").title():<8}',
music_video["attributes"]["name"],
],
),
value=music_video,
)
for music_video in music_videos
]
selected = inquirer.select(
message="Select which music videos to download: (Duration | Rating | Title)",
choices=choices,
multiselect=True,
).execute()
for music_video in selected:
yield music_video
def get_playlist_tags(
self,
playlist_attributes: dict,
playlist_track: int,
) -> dict:
tags = {
"playlist_artist": playlist_attributes["curatorName"],
"playlist_id": playlist_attributes["playParams"]["id"],
"playlist_title": playlist_attributes["name"],
"playlist_track": playlist_track,
}
return tags
def get_playlist_file_path(
self,
tags: dict,
):
template_file = self.template_file_playlist.split("/")
return Path(
self.output_path,
*[
self.get_sanitized_string(i.format(**tags), True)
for i in template_file[0:-1]
],
*[
self.get_sanitized_string(template_file[-1].format(**tags), False)
+ ".m3u8"
],
)
def update_playlist_file(
self,
playlist_file_path: Path,
final_path: Path,
playlist_track: int,
):
playlist_file_path.parent.mkdir(parents=True, exist_ok=True)
playlist_file_path_parent_parts_len = len(playlist_file_path.parent.parts)
output_path_parts_len = len(self.output_path.parts)
final_path_relative = Path(
("../" * (playlist_file_path_parent_parts_len - output_path_parts_len)),
*final_path.parts[output_path_parts_len:],
)
playlist_file_lines = (
playlist_file_path.open("r", encoding="utf8").readlines()
if playlist_file_path.exists()
else []
)
if len(playlist_file_lines) < playlist_track:
playlist_file_lines.extend(
"\n" for _ in range(playlist_track - len(playlist_file_lines))
)
playlist_file_lines[playlist_track - 1] = final_path_relative.as_posix() + "\n"
with playlist_file_path.open("w", encoding="utf8") as playlist_file:
playlist_file.writelines(playlist_file_lines)
@staticmethod
def millis_to_min_sec(millis) -> str:
minutes, seconds = divmod(millis // 1000, 60)
return f"{minutes:02d}:{seconds:02d}"
def sanitize_date(self, date: str) -> datetime.datetime:
return datetime.datetime.fromisoformat(date[:-1]).strftime(self.template_date)
def get_decryption_key(self, pssh: str, track_id: str) -> str:
pssh_obj = PSSH(pssh.split(",")[-1])
cdm_session = self.cdm.open()
challenge = base64.b64encode(
self.cdm.get_license_challenge(cdm_session, pssh_obj)
).decode()
license = self.apple_music_api.get_widevine_license(
track_id,
pssh,
challenge,
)
self.cdm.parse_license(cdm_session, license)
decryption_key = next(
i for i in self.cdm.get_keys(cdm_session) if i.type == "CONTENT"
).key.hex()
self.cdm.close(cdm_session)
try:
pssh_obj = PSSH(pssh.split(",")[-1])
cdm_session = self.cdm.open()
challenge = base64.b64encode(
self.cdm.get_license_challenge(cdm_session, pssh_obj)
).decode()
license = self.apple_music_api.get_widevine_license(
track_id,
pssh,
challenge,
)
self.cdm.parse_license(cdm_session, license)
decryption_key = next(
i for i in self.cdm.get_keys(cdm_session) if i.type == "CONTENT"
).key.hex()
finally:
self.cdm.close(cdm_session)
return decryption_key
def download(self, path: Path, stream_url: str):
@@ -181,19 +348,12 @@ class Downloader:
"allow_unplayable_formats": True,
"fixup": "never",
"allowed_extractors": ["generic"],
"noprogress": self.no_progress,
"noprogress": self.silent,
}
) as ydl:
ydl.download(stream_url)
def download_nm3u8dlre(self, path: Path, stream_url: str):
if self.no_progress:
subprocess_additional_args = {
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
}
else:
subprocess_additional_args = {}
path.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
[
@@ -213,15 +373,19 @@ class Downloader:
path.parent,
],
check=True,
**subprocess_additional_args,
**self.subprocess_additional_args,
)
def get_sanitized_string(self, dirty_string: str, is_folder: bool) -> str:
dirty_string = re.sub(self.ILLEGAL_CHARACTERS_REGEX, "_", dirty_string)
dirty_string = re.sub(
self.ILLEGAL_CHARS_RE,
self.ILLEGAL_CHAR_REPLACEMENT,
dirty_string,
)
if is_folder:
dirty_string = dirty_string[: self.truncate]
if dirty_string.endswith("."):
dirty_string = dirty_string[:-1] + "_"
dirty_string = dirty_string[:-1] + self.ILLEGAL_CHAR_REPLACEMENT
else:
if self.truncate is not None:
dirty_string = dirty_string[: self.truncate - 4]
@@ -229,34 +393,57 @@ class Downloader:
def get_final_path(self, tags: dict, file_extension: str) -> Path:
if tags.get("album"):
final_path_folder = (
template_folder = (
self.template_folder_compilation.split("/")
if tags.get("compilation")
else self.template_folder_album.split("/")
)
final_path_file = (
template_file = (
self.template_file_multi_disc.split("/")
if tags["disc_total"] > 1
else self.template_file_single_disc.split("/")
)
else:
final_path_folder = self.template_folder_no_album.split("/")
final_path_file = self.template_file_no_album.split("/")
final_path_folder = [
self.get_sanitized_string(i.format(**tags), True) for i in final_path_folder
]
final_path_file = [
self.get_sanitized_string(i.format(**tags), True)
for i in final_path_file[:-1]
] + [
self.get_sanitized_string(final_path_file[-1].format(**tags), False)
+ file_extension
]
return self.output_path.joinpath(*final_path_folder).joinpath(*final_path_file)
template_folder = self.template_folder_no_album.split("/")
template_file = self.template_file_no_album.split("/")
template_final = template_folder + template_file
return Path(
self.output_path,
*[
self.get_sanitized_string(i.format(**tags), True)
for i in template_final[0:-1]
],
(
self.get_sanitized_string(template_final[-1].format(**tags), False)
+ file_extension
),
)
def get_cover_file_extension(self, cover_url: str) -> str:
image_obj = Image.open(io.BytesIO(self.get_url_response_bytes(cover_url)))
image_format = image_obj.format.lower()
return IMAGE_FILE_EXTENSION_MAP.get(image_format, f".{image_format}")
def get_cover_url(self, metadata: dict) -> str:
if self.cover_format == CoverFormat.RAW:
return self._get_raw_cover_url(metadata["attributes"]["artwork"]["url"])
return self._get_cover_url(metadata["attributes"]["artwork"]["url"])
def _get_raw_cover_url(self, cover_url_template: str) -> str:
return re.sub(
r"image/thumb/",
"",
re.sub(
r"is1-ssl",
"a1",
re.sub(
r"/\{w\}x\{h\}([a-z]{2})\.jpg",
"",
cover_url_template,
),
),
)
def _get_cover_url(self, cover_url_template: str) -> str:
return re.sub(
r"\{w\}x\{h\}([a-z]{2})\.jpg",
@@ -267,7 +454,9 @@ class Downloader:
@staticmethod
@functools.lru_cache()
def get_url_response_bytes(url: str) -> bytes:
return requests.get(url).content
response = requests.get(url)
response.raise_for_status()
return response.content
def apply_tags(
self,
@@ -305,7 +494,10 @@ class Downloader:
and tags.get(tag_name) is not None
):
mp4_tags[MP4_TAGS_MAP[tag_name]] = [tags[tag_name]]
if "cover" not in self.exclude_tags_list:
if (
"cover" not in self.exclude_tags_list
and self.cover_format != CoverFormat.RAW
):
mp4_tags["covr"] = [
MP4Cover(
self.get_url_response_bytes(cover_url),
@@ -331,6 +523,7 @@ class Downloader:
@functools.lru_cache()
def save_cover(self, cover_path: Path, cover_url: str):
cover_path.parent.mkdir(parents=True, exist_ok=True)
cover_path.write_bytes(self.get_url_response_bytes(cover_url))
def cleanup_temp_path(self):
+69 -74
View File
@@ -1,11 +1,12 @@
import re
from __future__ import annotations
import subprocess
import urllib.parse
from pathlib import Path
import click
import m3u8
from tabulate import tabulate
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from .constants import MUSIC_VIDEO_CODEC_MAP
from .downloader import Downloader
@@ -14,27 +15,30 @@ from .models import StreamInfo
class DownloaderMusicVideo:
MP4_FORMAT_CODECS = ["hvc1", "ec-3"]
MP4_FORMAT_CODECS = ["hvc1", "audio-atmos", "audio-ec3"]
def __init__(
self,
downloader: Downloader,
codec: MusicVideoCodec = MusicVideoCodec.H264_BEST,
codec: MusicVideoCodec = MusicVideoCodec.H264,
):
self.downloader = downloader
self.codec = codec
def get_stream_url_master(self, itunes_page: dict) -> str:
return itunes_page["offers"][0]["assets"][0]["hlsUrl"]
def get_stream_url_from_webplayback(self, webplayback: dict) -> str:
return webplayback["hls-playlist-url"]
def get_m3u8_master_data(self, stream_url_master: str) -> dict:
url_parts = urllib.parse.urlparse(stream_url_master)
def get_stream_url_from_itunes_page(self, itunes_page: dict) -> dict:
stream_url = itunes_page["offers"][0]["assets"][0]["hlsUrl"]
url_parts = urllib.parse.urlparse(stream_url)
query = urllib.parse.parse_qs(url_parts.query, keep_blank_values=True)
query.update({"aec": "HD", "dsid": "1"})
stream_url_master_new = url_parts._replace(
return url_parts._replace(
query=urllib.parse.urlencode(query, doseq=True)
).geturl()
return m3u8.load(stream_url_master_new).data
def get_m3u8_master_data(self, stream_url_master: str) -> dict:
return m3u8.load(stream_url_master).data
def get_playlist_video(
self,
@@ -52,7 +56,7 @@ class DownloaderMusicVideo:
playlist
for playlist in playlists
if playlist["stream_info"]["codecs"].startswith(
MUSIC_VIDEO_CODEC_MAP[MusicVideoCodec.H264_BEST]
MUSIC_VIDEO_CODEC_MAP[MusicVideoCodec.H264]
)
]
playlists_filtered.sort(key=lambda x: x["stream_info"]["bandwidth"])
@@ -62,24 +66,24 @@ class DownloaderMusicVideo:
self,
playlists: list[dict],
) -> dict:
table = [
[
i,
playlist["stream_info"]["codecs"],
playlist["stream_info"]["resolution"],
playlist["stream_info"]["bandwidth"],
]
for i, playlist in enumerate(playlists, 1)
]
print(tabulate(table))
try:
choice = (
click.prompt("Choose a video codec", type=click.IntRange(1, len(table)))
- 1
choices = [
Choice(
name=" | ".join(
[
playlist["stream_info"]["codecs"][:4],
playlist["stream_info"]["resolution"],
str(playlist["stream_info"]["bandwidth"]),
]
),
value=playlist,
)
except click.exceptions.Abort:
raise KeyboardInterrupt()
return playlists[choice]
for playlist in playlists
]
selected = inquirer.select(
message="Select which video codec to download: (Codec | Resolution | Bitrate)",
choices=choices,
).execute()
return selected
def get_playlist_audio(
self,
@@ -99,24 +103,19 @@ class DownloaderMusicVideo:
self,
playlists: list[dict],
) -> dict:
table = [
[
i,
playlist["group_id"],
]
for i, playlist in enumerate(playlists, 1)
]
print(tabulate(table))
try:
choice = (
click.prompt(
"Choose an audio codec", type=click.IntRange(1, len(table))
)
- 1
choices = [
Choice(
name=playlist["group_id"],
value=playlist,
)
except click.exceptions.Abort:
raise KeyboardInterrupt()
return playlists[choice]
for playlist in playlists
if playlist.get("uri")
]
selected = inquirer.select(
message="Select which audio codec to download:",
choices=choices,
).execute()
return selected
def get_pssh(self, m3u8_data: dict):
return next(
@@ -147,9 +146,7 @@ class DownloaderMusicVideo:
else:
playlist = self.get_playlist_audio_from_user(m3u8_master_data["media"])
stream_info.stream_url = playlist["uri"]
stream_info.codec = re.search(r"_([^_]+)\.m3u8", stream_info.stream_url).group(
1
)
stream_info.codec = playlist["group_id"]
m3u8_data = m3u8.load(stream_info.stream_url).data
stream_info.pssh = self.get_pssh(m3u8_data)
return stream_info
@@ -159,41 +156,35 @@ class DownloaderMusicVideo:
def get_tags(
self,
id_alt: str,
itunes_page: dict,
m3u8_master_data: dict,
metadata: dict,
):
metadata_itunes = self.downloader.itunes_api.get_resource(id_alt)
tags = {
"artist": metadata["attributes"]["artistName"],
"artist_id": int(itunes_page["artistId"]),
"copyright": itunes_page["copyright"],
"date": next(
(
session_data
for session_data in m3u8_master_data["session_data"]
if session_data["data_id"] == "com.apple.hls.release-date"
),
None,
)["value"],
"genre": metadata["attributes"]["genreNames"][0],
"artist": metadata_itunes[0]["artistName"],
"artist_id": int(metadata_itunes[0]["artistId"]),
"copyright": itunes_page.get("copyright"),
"date": self.downloader.sanitize_date(metadata_itunes[0]["releaseDate"]),
"genre": metadata_itunes[0]["primaryGenreName"],
"genre_id": int(itunes_page["genres"][0]["genreId"]),
"media_type": 6,
"title": metadata["attributes"]["name"],
"storefront": int(self.downloader.itunes_api.storefront_id.split("-")[0]),
"title": metadata_itunes[0]["trackCensoredName"],
"title_id": int(metadata["id"]),
}
if metadata["attributes"].get("contentRating") == "clean":
tags["rating"] = 2
elif metadata["attributes"].get("contentRating") == "explicit":
if metadata_itunes[0]["trackExplicitness"] == "notExplicit":
tags["rating"] = 0
elif metadata_itunes[0]["trackExplicitness"] == "explicit":
tags["rating"] = 1
else:
tags["rating"] = 0
if itunes_page.get("collectionId"):
metadata_itunes = self.downloader.itunes_api.get_resource(itunes_page["id"])
tags["rating"] = 2
if len(metadata_itunes) > 1:
album = self.downloader.apple_music_api.get_album(
itunes_page["collectionId"]
)
tags["album"] = album["attributes"]["name"]
tags["album_artist"] = album["attributes"]["artistName"]
tags["album"] = metadata_itunes[1]["collectionCensoredName"]
tags["album_artist"] = metadata_itunes[1]["artistName"]
tags["album_id"] = int(itunes_page["collectionId"])
tags["disc"] = metadata_itunes[0]["discNumber"]
tags["disc_total"] = metadata_itunes[0]["discCount"]
@@ -227,6 +218,7 @@ class DownloaderMusicVideo:
decrypted_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_mp4box(
@@ -234,7 +226,7 @@ class DownloaderMusicVideo:
decrypted_path_audio: Path,
decrypted_path_video: Path,
fixed_path: Path,
) -> None:
):
subprocess.run(
[
self.downloader.mp4box_path_full,
@@ -245,10 +237,12 @@ class DownloaderMusicVideo:
decrypted_path_video,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
fixed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_ffmpeg(
@@ -283,6 +277,7 @@ class DownloaderMusicVideo:
fixed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux(
@@ -308,5 +303,5 @@ class DownloaderMusicVideo:
codec_audio,
)
def get_cover_path(self, final_path: Path) -> Path:
return final_path.with_suffix(f".{self.downloader.cover_format.value}")
def get_cover_path(self, final_path: Path, file_extension: str) -> Path:
return final_path.with_suffix(file_extension)
+17 -14
View File
@@ -1,9 +1,11 @@
from __future__ import annotations
from pathlib import Path
import click
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from .downloader import Downloader
from tabulate import tabulate
from .enums import PostQuality
@@ -38,18 +40,18 @@ class DownloaderPost:
def get_stream_url_from_user(self, metadata: dict) -> str:
qualities = list(metadata["attributes"]["assetTokens"].keys())
table = [
[index, quality]
for index, quality in enumerate(
qualities,
start=1,
choices = [
Choice(
name=quality,
value=quality,
)
for quality in qualities
]
print(tabulate(table))
choice = (
click.prompt("Choose a quality", type=click.IntRange(1, len(table))) - 1
)
return metadata["attributes"]["assetTokens"][qualities[choice]]
selected = inquirer.select(
message="Select which quality to download:",
choices=choices,
).execute()
return metadata["attributes"]["assetTokens"][selected]
def get_stream_url(self, metadata: dict) -> str:
if self.quality == PostQuality.BEST:
@@ -62,10 +64,11 @@ class DownloaderPost:
attributes = metadata["attributes"]
return {
"artist": attributes["artistName"],
"date": attributes["uploadDate"],
"date": self.downloader.sanitize_date(attributes["uploadDate"]),
"title": attributes["name"],
"title_id": int(metadata["id"]),
"storefront": int(self.downloader.itunes_api.storefront_id.split("-")[0]),
}
def get_temp_path(self, track_id: str) -> Path:
def get_post_temp_path(self, track_id: str) -> Path:
return self.downloader.temp_path / f"{track_id}_temp.m4v"
+41 -26
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import base64
import datetime
import json
@@ -7,9 +9,9 @@ from pathlib import Path
from xml.dom import minidom
from xml.etree import ElementTree
import click
import m3u8
from tabulate import tabulate
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from .constants import SONG_CODEC_REGEX_MAP, SYNCED_LYRICS_FILE_EXTENSION_MAP
from .downloader import Downloader
@@ -41,7 +43,7 @@ class DownloaderSong:
None,
)
if not drm_info_raw:
raise Exception("DRM info not found")
return None
return json.loads(base64.b64decode(drm_info_raw["value"]).decode("utf-8"))
def get_asset_infos(self, m3u8_data: dict) -> dict:
@@ -70,18 +72,18 @@ class DownloaderSong:
def get_playlist_from_user(self, m3u8_data: dict) -> dict | None:
m3u8_master_playlists = [playlist for playlist in m3u8_data["playlists"]]
table = [
[i, playlist["stream_info"]["audio"]]
for i, playlist in enumerate(m3u8_master_playlists, 1)
]
print(tabulate(table))
try:
choice = (
click.prompt("Choose a codec", type=click.IntRange(1, len(table))) - 1
choices = [
Choice(
name=playlist["stream_info"]["audio"],
value=playlist,
)
except click.exceptions.Abort:
raise KeyboardInterrupt()
return m3u8_master_playlists[choice]
for playlist in m3u8_master_playlists
]
selected = inquirer.select(
message="Select which codec to download:",
choices=choices,
).execute()
return selected
def get_pssh(
self,
@@ -104,7 +106,9 @@ class DownloaderSong:
return drm_info["urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"]["URI"]
def get_stream_info(self, track_metadata: dict) -> StreamInfo:
m3u8_url = track_metadata["attributes"]["extendedAssetUrls"]["enhancedHls"]
m3u8_url = track_metadata["attributes"]["extendedAssetUrls"].get("enhancedHls")
if not m3u8_url:
return StreamInfo()
return self._get_stream_info(m3u8_url)
def _get_stream_info(self, m3u8_url: str) -> StreamInfo:
@@ -112,6 +116,8 @@ class DownloaderSong:
m3u8_obj = m3u8.load(m3u8_url)
m3u8_data = m3u8_obj.data
drm_infos = self.get_drm_infos(m3u8_data)
if not drm_infos:
return stream_info
asset_infos = self.get_asset_infos(m3u8_data)
if self.codec == SongCodec.ASK:
playlist = self.get_playlist_from_user(m3u8_data)
@@ -172,20 +178,24 @@ class DownloaderSong:
return f"{index}\n{timestamp_srt_start} --> {timestamp_srt_end}\n{text}\n"
def get_lyrics(self, track_metadata: dict) -> Lyrics:
lyrics = Lyrics()
if not track_metadata["attributes"]["hasLyrics"]:
return Lyrics()
return lyrics
elif track_metadata.get("relationships") is None:
track_metadata = self.downloader.apple_music_api.get_song(
track_metadata["id"]
)
if track_metadata["relationships"]["lyrics"]["data"]:
return self._get_lyrics(
if (
track_metadata["relationships"].get("lyrics")
and track_metadata["relationships"]["lyrics"].get("data")
and track_metadata["relationships"]["lyrics"]["data"][0].get("attributes")
):
lyrics = self._get_lyrics(
track_metadata["relationships"]["lyrics"]["data"][0]["attributes"][
"ttml"
]
)
else:
return Lyrics()
return lyrics
def _get_lyrics(self, lyrics_ttml: str) -> Lyrics:
lyrics = Lyrics("", "")
@@ -197,9 +207,9 @@ class DownloaderSong:
lyrics.unsynced += p.text + "\n"
if p.attrib.get("begin"):
if self.synced_lyrics_format == SyncedLyricsFormat.LRC:
lyrics.synced += f"{self.get_lyrics_synced_line_lrc(p.attrib.get('begin'), p.text)}\n"
lyrics.synced += f"{self.get_lyrics_synced_line_lrc(p.attrib.get('begin'), p.text)}"
elif self.synced_lyrics_format == SyncedLyricsFormat.SRT:
lyrics.synced += f"{self.get_lyrics_synced_line_srt(index, p.attrib.get('begin'), p.attrib.get('end'), p.text)}\n"
lyrics.synced += f"{self.get_lyrics_synced_line_srt(index, p.attrib.get('begin'), p.attrib.get('end'), p.text)}"
elif self.synced_lyrics_format == SyncedLyricsFormat.TTML:
if not lyrics.synced:
lyrics.synced = minidom.parseString(
@@ -238,7 +248,7 @@ class DownloaderSong:
"disc": tags_raw["discNumber"],
"disc_total": tags_raw["discCount"],
"gapless": tags_raw["gapless"],
"genre": tags_raw["genre"],
"genre": tags_raw.get("genre"),
"genre_id": tags_raw["genreId"],
"lyrics": lyrics_unsynced if lyrics_unsynced else None,
"media_type": 1,
@@ -294,6 +304,7 @@ class DownloaderSong:
decrypted_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux(self, decrypted_path: Path, remuxed_path: Path, codec: str):
@@ -311,10 +322,12 @@ class DownloaderSong:
decrypted_path,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
remuxed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_ffmpeg(
@@ -324,7 +337,8 @@ class DownloaderSong:
codec: str,
):
use_mp4_format = any(
codec.startswith(possible_codec) for possible_codec in self.MP4_FORMAT_CODECS
codec.startswith(possible_codec)
for possible_codec in self.MP4_FORMAT_CODECS
)
subprocess.run(
[
@@ -343,6 +357,7 @@ class DownloaderSong:
remuxed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def get_lyrics_synced_path(self, final_path: Path) -> Path:
@@ -350,8 +365,8 @@ class DownloaderSong:
SYNCED_LYRICS_FILE_EXTENSION_MAP[self.synced_lyrics_format]
)
def get_cover_path(self, final_path: Path) -> Path:
return final_path.parent / f"Cover.{self.downloader.cover_format.value}"
def get_cover_path(self, final_path: Path, file_extension: str) -> Path:
return final_path.parent / ("Cover" + file_extension)
def save_lyrics_synced(self, lyrics_synced_path: Path, lyrics_synced: str):
lyrics_synced_path.parent.mkdir(parents=True, exist_ok=True)
+29 -20
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import base64
import subprocess
from pathlib import Path
@@ -26,24 +28,28 @@ class DownloaderSongLegacy(DownloaderSong):
return stream_info
def get_decryption_key(self, pssh: str, track_id: str) -> str:
widevine_pssh_data = WidevinePsshData()
widevine_pssh_data.algorithm = 1
widevine_pssh_data.key_ids.append(base64.b64decode(pssh.split(",")[1]))
pssh_obj = PSSH(widevine_pssh_data.SerializeToString())
cdm_session = self.downloader.cdm.open()
challenge = base64.b64encode(
self.downloader.cdm.get_license_challenge(cdm_session, pssh_obj)
).decode()
license = self.downloader.apple_music_api.get_widevine_license(
track_id,
pssh,
challenge,
)
self.downloader.cdm.parse_license(cdm_session, license)
decryption_key = next(
i for i in self.downloader.cdm.get_keys(cdm_session) if i.type == "CONTENT"
).key.hex()
self.downloader.cdm.close(cdm_session)
try:
widevine_pssh_data = WidevinePsshData()
widevine_pssh_data.algorithm = 1
widevine_pssh_data.key_ids.append(base64.b64decode(pssh.split(",")[1]))
pssh_obj = PSSH(widevine_pssh_data.SerializeToString())
cdm_session = self.downloader.cdm.open()
challenge = base64.b64encode(
self.downloader.cdm.get_license_challenge(cdm_session, pssh_obj)
).decode()
license = self.downloader.apple_music_api.get_widevine_license(
track_id,
pssh,
challenge,
)
self.downloader.cdm.parse_license(cdm_session, license)
decryption_key = next(
i
for i in self.downloader.cdm.get_keys(cdm_session)
if i.type == "CONTENT"
).key.hex()
finally:
self.downloader.cdm.close(cdm_session)
return decryption_key
def decrypt(
@@ -52,7 +58,6 @@ class DownloaderSongLegacy(DownloaderSong):
decrypted_path: Path,
decryption_key: str,
):
self.fix_key_id(encrypted_path)
subprocess.run(
[
self.downloader.mp4decrypt_path_full,
@@ -62,9 +67,10 @@ class DownloaderSongLegacy(DownloaderSong):
decrypted_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path) -> None:
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path):
subprocess.run(
[
self.downloader.mp4box_path_full,
@@ -73,10 +79,12 @@ class DownloaderSongLegacy(DownloaderSong):
decrypted_path,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
remuxed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_ffmpeg(
@@ -102,6 +110,7 @@ class DownloaderSongLegacy(DownloaderSong):
remuxed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux(
+5 -3
View File
@@ -20,8 +20,9 @@ class SongCodec(Enum):
AAC_DOWNMIX = "aac-downmix"
AAC_HE_BINAURAL = "aac-he-binaural"
AAC_HE_DOWNMIX = "aac-he-downmix"
ALAC = "alac"
ATMOS = "atmos"
AC3 = "ac3"
ALAC = "alac"
ASK = "ask"
@@ -32,8 +33,8 @@ class SyncedLyricsFormat(Enum):
class MusicVideoCodec(Enum):
H264_BEST = "h264-best"
H265_BEST = "h265-best"
H264 = "h264"
H265 = "h265"
ASK = "ask"
@@ -45,3 +46,4 @@ class PostQuality(Enum):
class CoverFormat(Enum):
JPG = "jpg"
PNG = "png"
RAW = "raw"
+5 -2
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
from dataclasses import dataclass
@@ -9,8 +11,9 @@ class UrlInfo:
@dataclass
class DownloadQueueItem:
metadata: dict = None
class DownloadQueue:
playlist_attributes: dict = None
tracks_metadata: list[dict] = None
@dataclass
+4 -3
View File
@@ -1,13 +1,14 @@
[project]
name = "gamdl"
description = "Download Apple Music songs/music videos/albums/playlists"
description = "A Python CLI app for downloading Apple Music songs/music videos/posts."
requires-python = ">=3.8"
authors = [{ name = "glomatico" }]
dependencies = [
"ciso8601",
"click",
"inquirerpy",
"m3u8",
"tabulate",
"mutagen",
"pillow",
"pywidevine",
"pyyaml",
"yt-dlp",
+3 -2
View File
@@ -1,7 +1,8 @@
ciso8601
click
inquirerpy
m3u8
tabulate
mutagen
pillow
pywidevine
pyyaml
yt-dlp