Compare commits

...

268 Commits

Author SHA1 Message Date
Rafael Moraes 047e9dbed8 Pass language to AppleMusicApi.from_netscape_cookies 2025-07-02 10:49:28 -03:00
Rafael Moraes e0bba0857a Update supported URL types in README 2025-07-02 10:42:54 -03:00
Rafael Moraes 6736acc5b0 Set Downloader to quiet according to log_level 2025-07-02 10:41:41 -03:00
Rafael Moraes 47521f1a82 Restrict log-level option to specific choices 2025-07-02 10:40:54 -03:00
Rafael Moraes 4d33f3e101 Support 'albums' as url_type in downloader 2025-07-02 10:29:44 -03:00
Rafael Moraes c827e26e43 Bump version 2025-07-02 10:21:23 -03:00
Rafael Moraes 1042e47c0b Handle missing URL in get_music_video_id_alt 2025-07-02 10:21:12 -03:00
Rafael Moraes 7f56f85f35 Handle library-music-videos in media type check 2025-07-02 10:20:58 -03:00
Rafael Moraes 560585eaa8 Improve README formatting and update usage details 2025-07-02 10:06:32 -03:00
Rafael Moraes 0fc2f75e5b Fix NoneType error in stream_info check 2025-07-02 10:03:28 -03:00
Rafael Moraes 82143df91a Update stream info methods to return None on failure 2025-07-02 10:03:17 -03:00
Rafael Moraes e89d1cb19a Refactor cover image download method naming 2025-07-02 09:58:52 -03:00
Rafael Moraes 01dd232565 Handle 400 status code for covers in downloader requests 2025-07-02 09:58:14 -03:00
Rafael Moraes c9e75ae2a2 Fix handling of missing lyrics in tag and sync logic 2025-07-02 09:50:21 -03:00
Rafael Moraes 9c26646636 Update get_lyrics to return None if no lyrics 2025-07-02 09:50:07 -03:00
Rafael Moraes efc452ba47 Rename tracks_metadata to medias_metadata in DownloadQueue 2025-07-02 09:44:51 -03:00
Rafael Moraes 57e9a1ca98 Fix Apple Music lyrics fetch with correct media ID 2025-07-02 09:44:43 -03:00
Rafael Moraes ca939d5760 Add get_media_id method to Downloader class 2025-07-02 09:44:33 -03:00
Rafael Moraes 6786ae393d Refactor media_id extraction in main download loop 2025-07-02 09:44:23 -03:00
Rafael Moraes 5458d7a1d4 Add 'extend' parameter to API pagination methods 2025-07-02 09:44:08 -03:00
Rafael Moraes 49368e7bc9 Rename tracks_metadata to medias_metadata in downloader 2025-07-02 09:24:06 -03:00
Rafael Moraes 621383a0d8 Refactor track_metadata to media_metadata in main loop 2025-07-02 09:23:56 -03:00
Rafael Moraes e7a055b1b8 Add is_library field to UrlInfo dataclass 2025-07-02 09:16:17 -03:00
Rafael Moraes bc070e4279 Add support for Apple Music library URLs in downloader 2025-07-02 09:16:04 -03:00
Rafael Moraes 2b1d02257c Add methods to fetch library albums and playlists 2025-07-02 09:15:41 -03:00
Rafael Moraes 3256aef9f8 update recommended cookies txt extension for chrome 2025-06-08 14:12:08 -03:00
Rafael Moraes 501cd48474 bump required python version 2025-06-08 14:10:14 -03:00
Rafael Moraes 9f31b99642 bump required python version 2025-06-08 14:09:58 -03:00
Rafael Moraes e9525668d6 refactor logger declaration 2025-06-08 14:09:35 -03:00
Rafael Moraes 60a2ca76fb refactor prompt_path 2025-06-08 14:08:27 -03:00
Rafael Moraes b81f740e2b fix wvd_file prompt 2025-06-02 08:58:08 -03:00
Rafael Moraes f8fc4c66e6 refactor for using streaminfoav 2025-06-02 00:03:07 -03:00
Rafael Moraes 74d1772173 add get_final_file_extension, fix cdm_session closing 2025-06-02 00:02:51 -03:00
Rafael Moraes 63830f2444 refactor for using MediaFileFormat and StreamInfoAv 2025-06-02 00:02:34 -03:00
Rafael Moraes f0838de397 add MediaFileFormat 2025-06-02 00:01:32 -03:00
Rafael Moraes dfe4e29ab5 add StreamInfoAv 2025-06-02 00:01:28 -03:00
Rafael Moraes 0782daed51 add music videos remux formats doc 2025-05-31 17:47:42 -03:00
Rafael Moraes 27ad170adf add remux_format_music_video option 2025-05-31 17:42:59 -03:00
Rafael Moraes b9377dc8b0 fix cookies path prompt 2025-05-31 17:20:21 -03:00
Rafael Moraes 5e413deb6d refactor cli skip_mv 2025-05-31 17:19:55 -03:00
Rafael Moraes af26e939e8 refactor api constructor 2025-05-31 17:19:33 -03:00
Rafael Moraes 66a965ecf6 update setup-python action to version 5 2025-05-12 09:35:36 -03:00
Rafael Moraes 24de608bc8 bump version 2025-05-12 09:28:28 -03:00
Rafael Moraes d0e2e08748 use prompt_path function for wvd and cookies 2025-05-12 09:11:58 -03:00
Rafael Moraes 2223d36d5e added prompt_path function 2025-05-12 09:11:07 -03:00
Rafael Moraes 3077456ab7 update cover path retrieval to use downloader_song 2025-05-09 14:19:39 -03:00
Rafael Moraes bbd96cbe6b remove redundant cover path lines 2025-05-09 14:16:33 -03:00
Rafael Moraes ca16a208ba rename custom_formatter to custom_logger_formatter 2025-05-09 14:07:31 -03:00
Rafael Moraes c32c8622b7 add error handling for missing media-user-token in cookies 2025-05-09 14:04:24 -03:00
Rafael Moraes 132ae0ea56 improve storefront retrieval 2025-05-05 23:04:09 -03:00
Rafael Moraes 70238facac better handling for media that has no cover 2025-02-25 02:35:39 -03:00
Rafael Moraes 4fb1fb609b Update custom_formatter.py 2025-02-23 16:28:38 -03:00
Rafael Moraes f97b3dba14 bump version 2025-02-23 04:36:05 -03:00
Rafael Moraes 2da824ecbc add colorama to dependencies 2025-02-23 04:34:28 -03:00
Rafael Moraes 24810da4b6 replace inline response exception handling with utility function 2025-02-23 04:32:50 -03:00
Rafael Moraes f16a30549c refactor logging color handling to use colorama and add utility function for colored text 2025-02-23 04:30:11 -03:00
Rafael Moraes 2001b19d8f bump version 2025-02-23 03:50:23 -03:00
Rafael Moraes 14814dd2da small refactor on cookies user input 2025-02-23 03:16:03 -03:00
Rafael Moraes 6fad41467f add termcolor to dependencies 2025-02-23 03:14:31 -03:00
Rafael Moraes 0868f1c28c set timezone to utc on parse_datetime_obj_from_timestamp_ttml 2025-02-23 03:12:29 -03:00
Rafael Moraes a964011507 implement custom logging formatter with colored output 2025-02-23 03:11:23 -03:00
Rafael Moraes 3a943d0154 refactor media user token retrieval from cookies 2025-02-23 02:13:09 -03:00
Rafael Moraes 84bf0a3c2b prompt user for cookies file path if not found 2025-02-23 02:12:15 -03:00
Rafael Moraes 93dda6889c update Python version requirement to 3.9 2025-02-16 10:43:05 -03:00
Rafael Moraes d62a1377f8 Update README.md 2025-01-29 16:35:36 -03:00
Rafael Moraes 3a2d521352 bump version to 2.3.9 2025-01-29 16:31:23 -03:00
Rafael Moraes c8f45110bd replace deprecated logger.warn with logger.warning 2025-01-29 16:30:08 -03:00
Rafael Moraes 36925025b7 fix: improve formatting in README.md for download modes section 2025-01-29 16:28:50 -03:00
Rafael Moraes d8937d9805 bump required Python version to 3.9 in pyproject.toml 2025-01-29 16:19:25 -03:00
Rafael Moraes 513db83645 update project description for clarity and completeness 2025-01-29 16:17:53 -03:00
Rafael Moraes 11f9b5a75c Update README.md for improved clarity and formatting 2025-01-29 16:15:43 -03:00
Rafael Moraes 1dd01368c3 Update downloader_song.py 2025-01-27 22:47:39 -03:00
Rafael Moraes 4fc8887101 refactor DRM handling to use widevine_pssh and add support for playready and fairplay keys 2025-01-27 22:47:24 -03:00
Rafael Moraes 9169665579 Merge pull request #159 from shafreeck/main
Fix handling of 'Favorite Songs' playlist
2025-01-27 22:39:56 -03:00
Shafreeck d053db96e8 handle missing curatorName in 'Favorite Songs' playlist
'Favorite Songs' is an automatically generated playlist by
Apple Music that lacks the curatorName attribute. Set the
default curator to 'Apple Music' to maintain consistency
with other system playlists.
2025-01-06 07:19:56 +08:00
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
20 changed files with 1421 additions and 669 deletions
+1
View File
@@ -0,0 +1 @@
ko_fi: glomatico
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
- uses: actions/checkout@v3
- name: Set up Python 3.9
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: 3.9
cache: pip
+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.
+199 -153
View File
@@ -1,177 +1,223 @@
# Glomatico's Apple Music Downloader
A Python script to download Apple Music songs/music videos/albums/playlists/post videos.
# Glomaticos Apple Music Downloader
A Python CLI app for downloading Apple Music songs, music videos and post videos.
**Join our Discord Server:** https://discord.gg/aBjMEZ9tnq
## Features
* Download songs in AAC/Spatial AAC/Dolby Atmos/ALAC*
* Download music videos up to 4K
* Download synced lyrics in LRC, SRT or TTML
* Choose between FFmpeg and MP4Box for remuxing
* Choose between yt-dlp and N_m3u8DL-RE for downloading
* Highly customizable
- **High-Quality Songs**: Download songs in AAC 256kbps and other codecs.
- **High-Quality Music Videos**: Download music videos in resolutions up to 4K.
- **Synced Lyrics**: Download synced lyrics in LRC, SRT, or TTML formats.
- **Artist Support**: Download all albums or music videos from an artist using their link.
- **Highly Customizable**: Extensive configuration options for advanced users.
## Prerequisites
* Python 3.8 or higher
* The cookies file of your Apple Music account (requires an active subscription)
* You can get your cookies by using one of the following extensions on your browser of choice at the Apple Music website with your account signed in:
* Firefox: https://addons.mozilla.org/addon/export-cookies-txt
* Chromium based browsers: https://chrome.google.com/webstore/detail/gdocmgbfkjnnpapoeobnolbbkoibbcif
* FFmpeg on your system PATH
* Older versions of FFmpeg may not work.
* Up to date binaries can be obtained from the links below:
* Windows: https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases
* Linux: https://johnvansickle.com/ffmpeg/
* (Optional) mp4decrypt on your system PATH
* Required to download music videos and songs in non-legacy formats.
* Binaries can be obtained from here: https://www.bento4.com/downloads/.
- **Python 3.10 or higher** installed on your system.
- The **cookies file** of your Apple Music browser session in Netscape format (requires an active subscription).
- **Firefox**: Use the [Export Cookies](https://addons.mozilla.org/addon/export-cookies-txt) extension.
- **Chromium-based Browsers**: Use the [Get cookies.txt LOCALLY](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc) extension.
- **FFmpeg** on your system PATH.
- **Windows**: Download from [AnimMouses FFmpeg Builds](https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases).
- **Linux**: Download from [John Van Sickles FFmpeg Builds](https://johnvansickle.com/ffmpeg/).
### 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 for `mp4box` remux mode, music video downloads, and experimental song codecs.
- [MP4Box](https://gpac.io/downloads/gpac-nightly-builds/): Required for `mp4box` remux mode.
- [N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest): Required for `nm3u8dlre` 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`
```bash
pip install gamdl
```
2. Set up the cookies file.
- Move the cookies file to the directory where youll run Gamdl and rename it to `cookies.txt`.
- Alternatively, specify the path to the cookies file using command-line arguments or the config file.
## Usage
* 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"
```
Run Gamdl with the following command:
```bash
gamdl [OPTIONS] URLS...
```
### Supported URL types
- Song
- Public/Library Album
- Public/Library Playlist
- Music video
- Artist
- Post video
### Examples
- Download a Song:
```bash
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/1624945511"
```
- 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` |
| `--remux-format-music-video` / `remux_format_music_video` | Music video remux format. | `m4v` |
| `--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:
* `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 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
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`
- `playlist_artist`
- `playlist_id`
- `playlist_title`
- `playlist_track`
- `rating`
- `storefront`
- `title`
- `title_id`
- `title_sort`
- `track`
- `track_total`
- `xid`
### Remux Modes
- `ffmpeg`: Default remuxing mode.
- `mp4box`: Alternative remuxing mode (doesnt convert closed captions in music videos).
### 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
- `ytdlp`: Default download mode.
- `nm3u8dlre`: Faster than `ytdlp`.
### Song codecs
The following codecs are available:
* `aac-legacy`
* `aac-he-legacy`
* `aac`
* `aac-he`
* `aac-binaural`
* `aac-downmix`
* `aac-he-binaural`
* `aac-he-downmix`
* `alac`
* `atmos`
### Song Codecs
**Support for non-legacy codecs are not guaranteed, as most of the songs cannot be decrypted when using non-legacy codecs.**
- Supported Codecs:
- `aac-legacy`: AAC 256kbps 44.1kHz.
- `aac-he-legacy`: AAC-HE 64kbps 44.1kHz.
- Experimental Codecs (not guaranteed to work due to API limitations):
- `aac`: AAC 256kbps up to 48kHz.
- `aac-he`: AAC-HE 64kbps up to 48kHz.
- `aac-binaural`: AAC 256kbps binaural.
- `aac-downmix`: AAC 256kbps downmix.
- `aac-he-binaural`: AAC-HE 64kbps binaural.
- `aac-he-downmix`: AAC-HE 64kbps downmix.
- `atmos`: Dolby Atmos 768kbps.
- `ac3`: AC3 640kbps.
- `alac`: ALAC up to 24-bit/192 kHz.
- `ask`: Prompt to choose available audio codec.
### Music Videos Codecs
- `h264`: Up to 1080p with AAC 256kbps.
- `h265`: Up to 2160p with AAC 256kpbs.
- `ask`: Prompt to choose available video and audio codecs.
### Music Videos Remux Formats
- `m4v`: Default remux format.
- `mp4`
### Music videos codecs
The following codecs are available:
* `h264-best` (with AAC 256kbps, up to 1080p)
* `h265-best` (With AAC 256kpbs, up to 2160p)
* `ask`
* When using this option, the script will ask you which audio and video codec to use.
### Post videos/extra videos qualities
The following qualities are available:
* `best` (with AAC 256kbps, up to 1080p)
* `ask`
* When using this option, the script will ask you which video quality to use.
Post videos doesn't require remuxing and are limited to `ytdlp` download mode.
- `best`: Up to 1080p with AAC 256kbps.
- `ask`: Prompt to choose available video quality.
### Synced lyrics formats
The following synced lyrics formats are available:
* `lrc`
* `srt`
* `ttml`
* Native format for Apple Music synced lyrics.
* Highly unsupported by media players.
### Cover formats
The following cover formats are available:
* `jpg`
* `png`
- `lrc`: Lightweight and widely supported.
- `srt`: SubRip format (has more accurate timestamps).
- `ttml`: Native Apple Music format (unsupported by most media players).
### Cover formats
- `jpg`: Default format.
- `png`: Lossless format.
- `raw`: Raw cover without processing (requires `save_cover` to save separately).
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.1"
__version__ = "2.5"
+184 -50
View File
@@ -3,14 +3,18 @@ from __future__ import annotations
import functools
import re
import time
import typing
from http.cookiejar import MozillaCookieJar
from pathlib import Path
from urllib.parse import urlparse
import requests
from .utils import raise_response_exception
class AppleMusicApi:
APPLE_MUSIC_HOMEPAGE_URL = "https://beta.music.apple.com"
APPLE_MUSIC_HOMEPAGE_URL = "https://music.apple.com"
AMP_API_URL = "https://amp-api.music.apple.com"
WEBPLAYBACK_API_URL = (
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback"
@@ -20,41 +24,72 @@ class AppleMusicApi:
def __init__(
self,
cookies_path: Path | None = Path("./cookies.txt"),
storefront: None | str = None,
storefront: str,
media_user_token: str | None = None,
language: str = "en-US",
):
self.cookies_path = cookies_path
self.media_user_token = media_user_token
self.storefront = storefront
self.language = language
self._set_session()
@classmethod
def from_netscape_cookies(
cls,
cookies_path: Path = Path("./cookies.txt"),
language: str = "en-US",
) -> AppleMusicApi:
parse_cookie = lambda name: next(
(
cookie.value
for cookie in cookies
if cookie.name == name
and cookie.domain.endswith(
urlparse(cls.APPLE_MUSIC_HOMEPAGE_URL).netloc
)
),
None,
)
cookies = MozillaCookieJar(cookies_path)
cookies.load(ignore_discard=True, ignore_expires=True)
media_user_token = parse_cookie("media-user-token")
if not media_user_token:
raise ValueError(
'"media-user-token" cookie not found in cookies. '
"Make sure you have exported the cookies from Apple Music webpage and are logged in "
"with an active subscription."
)
storefront = parse_cookie("itua")
return cls(
storefront=storefront,
media_user_token=media_user_token,
language=language,
)
def _set_session(self):
self.session = requests.Session()
if self.cookies_path:
cookies = MozillaCookieJar(self.cookies_path)
cookies.load(ignore_discard=True, ignore_expires=True)
self.session.cookies.update(cookies)
self.storefront = self.session.cookies.get_dict()["itua"]
self.session.headers.update(
{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0",
"Accept": "application/json",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"content-type": "application/json",
"Media-User-Token": self.session.cookies.get_dict().get(
"media-user-token", ""
),
"x-apple-renewal": "true",
"DNT": "1",
"Connection": "keep-alive",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-site",
"accept": "*/*",
"accept-language": "en-US",
"origin": self.APPLE_MUSIC_HOMEPAGE_URL,
"priority": "u=1, i",
"referer": self.APPLE_MUSIC_HOMEPAGE_URL,
"sec-ch-ua": '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
}
)
if self.media_user_token:
self.session.cookies.update(
{
"media-user-token": self.media_user_token,
}
)
home_page = self.session.get(self.APPLE_MUSIC_HOMEPAGE_URL).text
index_js_uri = re.search(
r"/(assets/index-legacy-[^/]+\.js)",
@@ -66,24 +101,56 @@ class AppleMusicApi:
token = re.search('(?=eyJh)(.*?)(?=")', index_js_page).group(1)
self.session.headers.update({"authorization": f"Bearer {token}"})
self.session.params = {"l": self.language}
@staticmethod
def _raise_response_exception(response: requests.Response):
raise Exception(
f"Request failed with status code {response.status_code}: {response.text}"
)
if not self.storefront:
self._fetch_storefront()
def _check_amp_api_response(self, response: requests.Response):
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,
AssertionError,
):
self._raise_response_exception(response)
raise_response_exception(response)
def _fetch_storefront(self):
self.storefront = self.get_user_storefront()["id"]
def get_user_storefront(
self,
) -> dict:
response = self.session.get(f"{self.AMP_API_URL}/v1/me/storefront")
self._check_amp_api_response(response)
return response.json()["data"][0]
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,
@@ -143,13 +210,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 +223,96 @@ 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,
extend,
):
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)
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/search",
params={
"term": term,
"types": types,
"limit": limit,
"offset": offset,
},
)
self._check_amp_api_response(response)
return response.json()["results"]
def get_library_album(self, album_id: str, extend: str = "extendedAssetUrls"):
response = self.session.get(
f"{self.AMP_API_URL}/v1/me/library/albums/{album_id}",
params={
"extend": extend,
},
)
self._check_amp_api_response(response)
return response.json()["data"][0]
def get_library_playlist(
self,
playlist_id: str,
include: str = "tracks",
limit: int = 100,
extend: str = "extendedAssetUrls",
fetch_all: bool = True,
) -> dict:
response = self.session.get(
f"{self.AMP_API_URL}/v1/me/library/playlists/{playlist_id}",
params={
"include": include,
**{f"limit[{_include}]": limit for _include in include.split(",")},
"extend": extend,
},
)
self._check_amp_api_response(response)
playlist = response.json()["data"][0]
if fetch_all:
for additional_data in self._extend_api_data(
playlist["relationships"]["tracks"],
limit,
extend,
):
playlist["relationships"]["tracks"]["data"].extend(additional_data)
return playlist
def _get_playlist_next(self, playlist_next_uri: str, limit_tracks: int) -> dict:
def _extend_api_data(
self,
api_response: dict,
limit: int,
extend: str,
) -> 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, extend)
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,
extend: str,
) -> dict:
response = self.session.get(
self.AMP_API_URL + playlist_next_uri,
self.AMP_API_URL + next_uri,
params={
"limit[tracks]": limit_tracks,
"limit": limit,
"extend": extend,
},
)
self._check_amp_api_response(response)
@@ -205,7 +339,7 @@ class AppleMusicApi:
requests.exceptions.JSONDecodeError,
AssertionError,
):
self._raise_response_exception(response)
raise_response_exception(response)
return webplayback[0]
def get_widevine_license(
@@ -235,5 +369,5 @@ class AppleMusicApi:
requests.exceptions.JSONDecodeError,
AssertionError,
):
self._raise_response_exception(response)
raise_response_exception(response)
return widevine_license
+273 -158
View File
@@ -7,24 +7,38 @@ from enum import Enum
from pathlib import Path
import click
import colorama
from . import __version__
from .apple_music_api import AppleMusicApi
from .constants import *
from .custom_logger_formatter import CustomLoggerFormatter
from .downloader import Downloader
from .downloader_music_video import DownloaderMusicVideo
from .downloader_post import DownloaderPost
from .downloader_song import DownloaderSong
from .downloader_song_legacy import DownloaderSongLegacy
from .enums import CoverFormat, DownloadMode, MusicVideoCodec, PostQuality, RemuxMode
from .enums import (
CoverFormat,
DownloadMode,
MusicVideoCodec,
PostQuality,
RemuxFormatMusicVideo,
RemuxMode,
)
from .itunes_api import ItunesApi
from .utils import color_text, prompt_path
apple_music_api_sig = inspect.signature(AppleMusicApi.__init__)
apple_music_api_from_netscape_cookies_sig = inspect.signature(
AppleMusicApi.from_netscape_cookies
)
downloader_sig = inspect.signature(Downloader.__init__)
downloader_song_sig = inspect.signature(DownloaderSong.__init__)
downloader_music_video_sig = inspect.signature(DownloaderMusicVideo.__init__)
downloader_post_sig = inspect.signature(DownloaderPost.__init__)
logger = logging.getLogger("gamdl")
def get_param_string(param: click.Parameter) -> str:
if isinstance(param.default, Enum):
@@ -35,7 +49,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 +109,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",
@@ -115,23 +134,32 @@ def load_config_file(
)
@click.option(
"--log-level",
type=str,
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]),
default="INFO",
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(
"--cookies-path",
"-c",
type=Path,
default=apple_music_api_sig.parameters["cookies_path"].default,
default=apple_music_api_from_netscape_cookies_sig.parameters[
"cookies_path"
].default,
help="Path to .txt cookies file.",
)
@click.option(
"--language",
"-l",
type=str,
default=apple_music_api_from_netscape_cookies_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 +258,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,
@@ -274,6 +308,12 @@ def load_config_file(
default=downloader_music_video_sig.parameters["codec"].default,
help="Music video codec.",
)
@click.option(
"--remux-format-music-video",
type=RemuxFormatMusicVideo,
default=downloader_music_video_sig.parameters["remux_format"].default,
help="Music video remux format.",
)
# DownloaderPost specific options
@click.option(
"--quality-post",
@@ -295,12 +335,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 +359,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,
@@ -324,17 +367,21 @@ def main(
codec_song: SongCodec,
synced_lyrics_format: SyncedLyricsFormat,
codec_music_video: MusicVideoCodec,
remux_format_music_video: RemuxFormatMusicVideo,
quality_post: PostQuality,
no_config_file: bool,
):
logging.basicConfig(
format="[%(levelname)-8s %(asctime)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger(__name__)
colorama.just_fix_windows_console()
logger.setLevel(log_level)
logger.debug("Starting downloader")
apple_music_api = AppleMusicApi(cookies_path)
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(CustomLoggerFormatter())
logger.addHandler(stream_handler)
logger.info("Starting Gamdl")
cookies_path = prompt_path(True, cookies_path, "Cookies file")
apple_music_api = AppleMusicApi.from_netscape_cookies(
cookies_path,
language,
)
itunes_api = ItunesApi(
apple_music_api.storefront,
apple_music_api.language,
@@ -358,10 +405,12 @@ def main(
template_file_multi_disc,
template_folder_no_album,
template_file_no_album,
template_file_playlist,
template_date,
exclude_tags,
cover_size,
truncate,
log_level in ("WARNING", "ERROR"),
)
downloader_song = DownloaderSong(
downloader,
@@ -375,15 +424,16 @@ def main(
downloader_music_video = DownloaderMusicVideo(
downloader,
codec_music_video,
remux_format_music_video,
)
downloader_post = DownloaderPost(
downloader,
quality_post,
)
skip_mv = False
if not synced_lyrics_only:
if wvd_path and not wvd_path.exists():
logger.critical(X_NOT_FOUND_STRING.format(".wvd file", wvd_path))
return
if wvd_path:
wvd_path = prompt_path(True, wvd_path, ".wvd file")
logger.debug("Setting up CDM")
downloader.set_cdm()
if not downloader.ffmpeg_path_full and (
@@ -412,45 +462,64 @@ def main(
logger.critical(X_NOT_FOUND_STRING.format("N_m3u8DL-RE", nm3u8dlre_path))
return
if not downloader.mp4decrypt_path_full:
logger.warn(
logger.warning(
X_NOT_FOUND_STRING.format("mp4decrypt", mp4decrypt_path)
+ ", music videos will not be downloaded"
)
skip_mv = True
else:
skip_mv = False
if codec_song not in LEGACY_CODECS:
logger.warning(
"You have chosen an experimental codec. "
"They're not guaranteed to work due to API limitations."
)
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)}"
url_progress = color_text(f"URL {url_index}/{len(urls)}", colorama.Style.DIM)
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_medias_metadata = download_queue.medias_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, media_metadata in enumerate(
download_queue_medias_metadata, start=1
):
queue_progress = color_text(
f"Track {download_index}/{len(download_queue_medias_metadata)} from URL {url_index}/{len(urls)}",
colorama.Style.DIM,
)
try:
media_id = downloader.get_media_id(media_metadata)
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 "{media_metadata["attributes"]["name"]}"'
)
if not track["attributes"].get("playParams"):
if media_id is None:
logger.warning(
f"({queue_progress}) Track is not streamable, skipping"
f"({queue_progress}) Track is not streamable or downloadable, skipping"
)
continue
if (
(synced_lyrics_only and track["type"] != "songs")
or (track["type"] == "music-videos" and skip_mv)
(synced_lyrics_only and media_metadata["type"] != "songs")
or (media_metadata["type"] == "music-videos" and skip_mv)
or (
track["type"] == "music-videos"
media_metadata["type"] == "music-videos"
and url_info.type == "album"
and not disable_music_video_skip
)
@@ -458,18 +527,37 @@ def main(
logger.warning(
f"({queue_progress}) Track is not downloadable with current configuration, skipping"
)
elif track["type"] == "songs":
continue
elif media_metadata["type"] in ("songs", "library-songs"):
logger.debug("Getting lyrics")
lyrics = downloader_song.get_lyrics(track)
lyrics = downloader_song.get_lyrics(media_metadata)
logger.debug("Getting webplayback")
webplayback = apple_music_api.get_webplayback(track["id"])
tags = downloader_song.get_tags(webplayback, lyrics.unsynced)
webplayback = apple_music_api.get_webplayback(media_id)
tags = downloader_song.get_tags(
webplayback,
lyrics.unsynced if lyrics else None,
)
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(media_metadata)
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
if cover_file_extesion:
cover_path = downloader_song.get_cover_path(
final_path,
cover_file_extesion,
)
else:
cover_path = None
if synced_lyrics_only:
pass
elif final_path.exists() and not overwrite:
@@ -477,44 +565,49 @@ 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.audio_track.widevine_pssh,
media_id,
)
else:
stream_info = downloader_song.get_stream_info(track)
if not stream_info.pssh:
stream_info = downloader_song.get_stream_info(
media_metadata
)
if (
stream_info is None
or not stream_info.audio_track.widevine_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.audio_track.widevine_pssh,
media_id,
)
encrypted_path = downloader_song.get_encrypted_path(media_id)
decrypted_path = downloader_song.get_decrypted_path(media_id)
remuxed_path = downloader_song.get_remuxed_path(
media_id,
stream_info.file_format,
)
logger.debug(f'Downloading to "{encrypted_path}"')
downloader.download(
encrypted_path,
stream_info.audio_track.stream_url,
)
if codec_song in LEGACY_CODECS:
logger.debug(
f'Decrypting/Remuxing to "{decrypted_path}"/"{remuxed_path}"'
)
encrypted_path = downloader_song.get_encrypted_path(track["id"])
decrypted_path = downloader_song.get_decrypted_path(track["id"])
remuxed_path = downloader_song.get_remuxed_path(track["id"])
logger.debug(f"Downloading to {encrypted_path}")
downloader.download(encrypted_path, stream_info.stream_url)
if codec_song in (
SongCodec.AAC_LEGACY,
SongCodec.AAC_HE_LEGACY,
):
logger.debug(f"Remuxing/Decrypting to {remuxed_path}")
downloader_song_legacy.remux(
encrypted_path,
decrypted_path,
@@ -522,21 +615,18 @@ 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
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:
if no_synced_lyrics or not lyrics or not lyrics.synced:
pass
elif lyrics_synced_path.exists() and not overwrite:
logger.debug(
@@ -547,146 +637,171 @@ 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":
music_video_id_alt = downloader_music_video.get_music_video_id_alt(
track
elif media_metadata["type"] in ("music-videos", "library-music-videos"):
music_video_id_alt = (
downloader_music_video.get_music_video_id_alt(media_metadata)
or media_id
)
logger.debug("Getting iTunes page")
itunes_page = itunes_api.get_itunes_page(
"music-video", music_video_id_alt
)
stream_url_master = downloader_music_video.get_stream_url_master(
itunes_page
)
logger.debug("Getting M3U8 data")
m3u8_master_data = downloader_music_video.get_m3u8_master_data(
stream_url_master
)
if music_video_id_alt == media_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(media_id)
stream_url = (
downloader_music_video.get_stream_url_from_webplayback(
webplayback
)
)
logger.debug("Getting tags")
tags = downloader_music_video.get_tags(
music_video_id_alt,
itunes_page,
m3u8_master_data,
track,
media_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)
if playlist_track:
tags = {
**tags,
**downloader.get_playlist_tags(
download_queue.playlist_attributes,
playlist_track,
),
}
logger.debug("Getting M3U8 data")
m3u8_data = downloader_music_video.get_m3u8_master_data(stream_url)
stream_info_av = downloader_music_video.get_stream_info(
m3u8_data,
)
final_file_extesion = downloader.get_final_file_extension(
stream_info_av.file_format,
)
final_path = downloader.get_final_path(
tags,
final_file_extesion,
)
cover_url = downloader.get_cover_url(media_metadata)
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
if cover_file_extesion:
cover_path = downloader_music_video.get_cover_path(
final_path,
cover_file_extesion,
)
else:
cover_path = None
if final_path.exists() and not overwrite:
logger.warning(
f'({queue_progress}) Music video already exists at "{final_path}", skipping'
)
else:
logger.debug("Getting stream info")
stream_info_video, stream_info_audio = (
downloader_music_video.get_stream_info_video(
m3u8_master_data
),
downloader_music_video.get_stream_info_audio(
m3u8_master_data
),
)
decryption_key_video = downloader.get_decryption_key(
stream_info_video.pssh, track["id"]
stream_info_av.video_track.widevine_pssh,
media_id,
)
decryption_key_audio = downloader.get_decryption_key(
stream_info_audio.pssh, track["id"]
stream_info_av.audio_track.widevine_pssh,
media_id,
)
encrypted_path_video = (
downloader_music_video.get_encrypted_path_video(track["id"])
downloader_music_video.get_encrypted_path_video(media_id)
)
encrypted_path_audio = (
downloader_music_video.get_encrypted_path_audio(track["id"])
downloader_music_video.get_encrypted_path_audio(media_id)
)
decrypted_path_video = (
downloader_music_video.get_decrypted_path_video(track["id"])
downloader_music_video.get_decrypted_path_video(media_id)
)
decrypted_path_audio = (
downloader_music_video.get_decrypted_path_audio(track["id"])
downloader_music_video.get_decrypted_path_audio(media_id)
)
remuxed_path = downloader_music_video.get_remuxed_path(
track["id"]
media_id,
final_file_extesion,
)
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
encrypted_path_video,
stream_info_av.video_track.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
encrypted_path_audio,
stream_info_av.audio_track.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,
remuxed_path,
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'
elif media_metadata["type"] == "uploaded-videos":
stream_url = downloader_post.get_stream_url(media_metadata)
tags = downloader_post.get_tags(media_metadata)
final_path = downloader.get_final_path(tags, ".m4v")
cover_url = downloader.get_cover_url(media_metadata)
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
if cover_file_extesion:
cover_path = downloader_music_video.get_cover_path(
final_path,
cover_file_extesion,
)
else:
logger.debug(f'Saving cover to "{cover_path}"')
downloader.save_cover(cover_path, cover_url)
elif track["type"] == "uploaded-videos":
stream_url = downloader_post.get_stream_url(track)
tags = downloader_post.get_tags(track)
temp_path = downloader_post.get_temp_path(track["id"])
final_path = downloader.get_final_path(tags, ".m4v")
cover_path = downloader_music_video.get_cover_path(final_path)
cover_url = downloader.get_cover_url(track)
cover_path = None
if final_path.exists() and not overwrite:
logger.warning(
f'({queue_progress}) Post video already exists at "{final_path}", skipping'
)
else:
logger.debug(f"Downloading to {final_path}")
downloader.download_ytdlp(temp_path, stream_url)
logger.debug("Applying tags")
downloader.apply_tags(temp_path, tags, cover_url)
logger.debug(f"Moving to {final_path}")
downloader.move_to_output_path(temp_path, final_path)
if not save_cover:
pass
elif cover_path.exists() and not overwrite:
logger.debug(
f'Cover already exists at "{cover_path}", skipping'
)
else:
logger.debug(f'Saving cover to "{cover_path}"')
downloader.save_cover(cover_path, cover_url)
remuxed_path = downloader_post.get_post_temp_path(media_id)
logger.debug(f'Downloading to "{remuxed_path}"')
downloader.download_ytdlp(remuxed_path, stream_url)
if synced_lyrics_only or not save_cover or cover_path is None:
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 "{media_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,
]
+24
View File
@@ -0,0 +1,24 @@
import logging
import colorama
from .utils import color_text
class CustomLoggerFormatter(logging.Formatter):
base_format = "[%(levelname)-8s %(asctime)s]"
format_colors = {
logging.DEBUG: colorama.Style.DIM,
logging.INFO: colorama.Fore.GREEN,
logging.WARNING: colorama.Fore.YELLOW,
logging.ERROR: colorama.Fore.RED,
logging.CRITICAL: colorama.Fore.RED,
}
date_format = "%H:%M:%S"
def format(self, record: logging.LogRecord) -> str:
return logging.Formatter(
color_text(self.base_format, self.format_colors.get(record.levelno))
+ " %(message)s",
datefmt=self.date_format,
).format(record)
+340 -101
View File
@@ -1,26 +1,39 @@
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 .enums import CoverFormat, DownloadMode, RemuxMode
from .constants import IMAGE_FILE_EXTENSION_MAP, MP4_TAGS_MAP
from .enums import CoverFormat, DownloadMode, MediaFileFormat, RemuxMode
from .hardcoded_wvd import HARDCODED_WVD
from .itunes_api import ItunesApi
from .models import DownloadQueueItem, UrlInfo
from .models import DownloadQueue, UrlInfo
from .utils import raise_response_exception
class Downloader:
ILLEGAL_CHARACTERS_REGEX = r'[\\/:*?"<>|;]'
ILLEGAL_CHARS_RE = r'[\\/:*?"<>|;]'
ILLEGAL_CHAR_REPLACEMENT = "_"
VALID_URL_RE = (
r"(/(?P<storefront>[a-z]{2})/(?P<type>artist|album|playlist|song|music-video|post)/(?P<slug>[^/]*)(?:/(?P<id>[^/?]*))?(?:\?i=)?(?P<sub_id>[0-9a-z]*)?)|"
r"(/library/(?P<library_type>|playlist|albums)/(?P<library_id>[a-z]\.[0-9a-zA-Z]*))"
)
def __init__(
self,
@@ -29,7 +42,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 +55,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 +80,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 +105,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,70 +126,241 @@ 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)
url_info.type = (
"song" if url_regex_result.group(5) else url_regex_result.group(2)
)
url_info.id = (
url_regex_result.group(5)
or url_regex_result.group(4)
or url_regex_result.group(3)
)
is_library = url_regex_result.group("library_type") is not None
if is_library:
url_info.type = url_regex_result.group("library_type")
url_info.id = url_regex_result.group("library_id")
else:
url_info.storefront = url_regex_result.group("storefront")
url_info.type = (
"song"
if url_regex_result.group("sub_id")
else url_regex_result.group("type")
)
url_info.id = (
url_regex_result.group("sub_id")
or url_regex_result.group("id")
or url_regex_result.group("sub_id")
)
url_info.is_library = is_library
return url_info
def get_download_queue(self, url_info: UrlInfo) -> list[DownloadQueueItem]:
return self._get_download_queue(url_info.type, url_info.id)
def get_download_queue(self, url_info: UrlInfo) -> DownloadQueue:
return self._get_download_queue(url_info.type, url_info.id, url_info.is_library)
def _get_download_queue(self, url_type: str, id: str) -> list[DownloadQueueItem]:
download_queue = []
if url_type == "song":
download_queue.append(DownloadQueueItem(self.apple_music_api.get_song(id)))
elif url_type == "album":
album = self.apple_music_api.get_album(id)
download_queue.extend(
DownloadQueueItem(track)
for track in album["relationships"]["tracks"]["data"]
def _get_download_queue(
self,
url_type: str,
id: str,
is_library: bool,
) -> DownloadQueue:
download_queue = DownloadQueue()
if url_type == "artist":
artist = self.apple_music_api.get_artist(id)
download_queue.medias_metadata = list(
self.get_download_queue_from_artist(artist)
)
elif url_type == "song":
download_queue.medias_metadata = [self.apple_music_api.get_song(id)]
elif url_type in ("album", "albums"):
if is_library:
album = self.apple_music_api.get_library_album(id)
else:
album = self.apple_music_api.get_album(id)
download_queue.medias_metadata = [
track for track in album["relationships"]["tracks"]["data"]
]
elif url_type == "playlist":
download_queue.extend(
DownloadQueueItem(track)
for track in self.apple_music_api.get_playlist(id)["relationships"][
"tracks"
]["data"]
)
if is_library:
playlist = self.apple_music_api.get_library_playlist(id)
download_queue.medias_metadata = [
track for track in playlist["relationships"]["tracks"]["data"]
]
else:
playlist = self.apple_music_api.get_playlist(id)
download_queue.medias_metadata = [
track for track in playlist["relationships"]["tracks"]["data"]
]
download_queue.playlist_attributes = playlist["attributes"]
elif url_type == "music-video":
download_queue.append(
DownloadQueueItem(self.apple_music_api.get_music_video(id))
)
download_queue.medias_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.medias_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_media_id(
self,
media_metadata: dict,
) -> str | None:
play_params = media_metadata["attributes"].get("playParams", {})
return play_params.get("catalogId") or play_params.get("id")
def get_playlist_tags(
self,
playlist_attributes: dict,
playlist_track: int,
) -> dict:
tags = {
"playlist_artist": playlist_attributes.get("curatorName", "Apple Music"),
"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:
cdm_session = self.cdm.open()
pssh_obj = PSSH(pssh.split(",")[-1])
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 +378,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,50 +403,86 @@ 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]
return dirty_string.strip()
def get_final_file_extension(
self,
file_format: MediaFileFormat,
) -> str:
return "." + file_format.value
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 | None:
cover_bytes = self.get_cover_url_response_bytes(cover_url)
if cover_bytes is None:
return None
image_obj = Image.open(io.BytesIO(self.get_cover_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",
@@ -266,8 +492,15 @@ class Downloader:
@staticmethod
@functools.lru_cache()
def get_url_response_bytes(url: str) -> bytes:
return requests.get(url).content
def get_cover_url_response_bytes(url: str) -> bytes | None:
response = requests.get(url)
if response.status_code == 200:
return response.content
elif response.status_code in (404, 400):
return None
else:
raise_response_exception(response)
return response.content
def apply_tags(
self,
@@ -305,17 +538,22 @@ 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:
mp4_tags["covr"] = [
MP4Cover(
self.get_url_response_bytes(cover_url),
imageformat=(
MP4Cover.FORMAT_JPEG
if self.cover_format == CoverFormat.JPG
else MP4Cover.FORMAT_PNG
),
)
]
if (
"cover" not in self.exclude_tags_list
and self.cover_format != CoverFormat.RAW
):
cover_bytes = self.get_cover_url_response_bytes(cover_url)
if cover_bytes is not None:
mp4_tags["covr"] = [
MP4Cover(
self.get_cover_url_response_bytes(cover_url),
imageformat=(
MP4Cover.FORMAT_JPEG
if self.cover_format == CoverFormat.JPG
else MP4Cover.FORMAT_PNG
),
)
]
mp4 = MP4(path)
mp4.clear()
mp4.update(mp4_tags)
@@ -331,7 +569,8 @@ class Downloader:
@functools.lru_cache()
def save_cover(self, cover_path: Path, cover_url: str):
cover_path.write_bytes(self.get_url_response_bytes(cover_url))
cover_path.parent.mkdir(parents=True, exist_ok=True)
cover_path.write_bytes(self.get_cover_url_response_bytes(cover_url))
def cleanup_temp_path(self):
shutil.rmtree(self.temp_path)
+115 -94
View File
@@ -1,40 +1,47 @@
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 urllib.parse import urlparse
from .constants import MUSIC_VIDEO_CODEC_MAP
from .downloader import Downloader
from .enums import MusicVideoCodec, RemuxMode
from .models import StreamInfo
from .enums import MediaFileFormat, MusicVideoCodec, RemuxFormatMusicVideo, RemuxMode
from .models import StreamInfo, StreamInfoAv
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,
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
):
self.downloader = downloader
self.codec = codec
self.remux_format = remux_format
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 +59,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 +69,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 +106,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(
@@ -137,7 +139,7 @@ class DownloaderMusicVideo:
stream_info.stream_url = playlist["uri"]
stream_info.codec = playlist["stream_info"]["codecs"]
m3u8_data = m3u8.load(stream_info.stream_url).data
stream_info.pssh = self.get_pssh(m3u8_data)
stream_info.widevine_pssh = self.get_pssh(m3u8_data)
return stream_info
def get_stream_info_audio(self, m3u8_master_data: dict) -> StreamInfo:
@@ -147,53 +149,75 @@ 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)
stream_info.widevine_pssh = self.get_pssh(m3u8_data)
return stream_info
def get_music_video_id_alt(self, metadata: dict) -> str:
return metadata["attributes"]["url"].split("/")[-1].split("?")[0]
def get_stream_info(
self,
m3u8_master_data: dict,
) -> StreamInfoAv:
stream_info_video = self.get_stream_info_video(m3u8_master_data)
stream_info_audio = self.get_stream_info_audio(m3u8_master_data)
use_mp4 = (
any(
stream_info_video.codec.startswith(codec)
for codec in self.MP4_FORMAT_CODECS
)
or any(
stream_info_audio.codec.startswith(codec)
for codec in self.MP4_FORMAT_CODECS
)
or self.remux_format == RemuxFormatMusicVideo.MP4
)
if use_mp4:
file_format = MediaFileFormat.MP4
else:
file_format = MediaFileFormat.M4V
return StreamInfoAv(
video_track=stream_info_video,
audio_track=stream_info_audio,
file_format=file_format,
)
def get_music_video_id_alt(self, metadata: dict) -> str | None:
music_video_url = metadata["attributes"].get("url")
if music_video_url is None:
return None
return music_video_url.split("/")[-1].split("?")[0]
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"],
"title_id": int(metadata["id"]),
"storefront": int(self.downloader.itunes_api.storefront_id.split("-")[0]),
"title": metadata_itunes[0]["trackCensoredName"],
"title_id": int(self.downloader.get_media_id(metadata)),
}
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"]
@@ -214,8 +238,12 @@ class DownloaderMusicVideo:
def get_decrypted_path_audio(self, track_id: str) -> str:
return self.downloader.temp_path / f"decrypted_{track_id}.m4a"
def get_remuxed_path(self, track_id: str) -> str:
return self.downloader.temp_path / f"remuxed_{track_id}.m4v"
def get_remuxed_path(
self,
track_id: str,
file_extension: str,
) -> str:
return self.downloader.temp_path / (f"remuxed_{track_id}" + file_extension)
def decrypt(self, encrypted_path: Path, decryption_key: str, decrypted_path: Path):
subprocess.run(
@@ -227,6 +255,7 @@ class DownloaderMusicVideo:
decrypted_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_mp4box(
@@ -234,7 +263,7 @@ class DownloaderMusicVideo:
decrypted_path_audio: Path,
decrypted_path_video: Path,
fixed_path: Path,
) -> None:
):
subprocess.run(
[
self.downloader.mp4box_path_full,
@@ -245,10 +274,12 @@ class DownloaderMusicVideo:
decrypted_path_video,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
fixed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_ffmpeg(
@@ -256,12 +287,7 @@ class DownloaderMusicVideo:
decrypted_path_video: Path,
decrypte_path_audio: Path,
fixed_path: Path,
codec_video: str,
codec_audio: str,
):
use_mp4_flag = any(
codec_video.startswith(codec) for codec in self.MP4_FORMAT_CODECS
) or any(codec_audio.startswith(codec) for codec in self.MP4_FORMAT_CODECS)
subprocess.run(
[
self.downloader.ffmpeg_path_full,
@@ -274,8 +300,6 @@ class DownloaderMusicVideo:
decrypte_path_audio,
"-movflags",
"+faststart",
"-f",
"mp4" if use_mp4_flag else "ipod",
"-c",
"copy",
"-c:s",
@@ -283,6 +307,7 @@ class DownloaderMusicVideo:
fixed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux(
@@ -290,8 +315,6 @@ class DownloaderMusicVideo:
decrypted_path_video: Path,
decrypted_path_audio: Path,
remuxed_path: Path,
codec_video: str,
codec_audio: str,
):
if self.downloader.remux_mode == RemuxMode.MP4BOX:
self.remux_mp4box(
@@ -304,9 +327,7 @@ class DownloaderMusicVideo:
decrypted_path_video,
decrypted_path_audio,
remuxed_path,
codec_video,
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"
+102 -52
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import base64
import datetime
import json
@@ -7,14 +9,14 @@ 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
from .enums import RemuxMode, SongCodec, SyncedLyricsFormat
from .models import Lyrics, StreamInfo
from .enums import MediaFileFormat, RemuxMode, SongCodec, SyncedLyricsFormat
from .models import Lyrics, StreamInfo, StreamInfoAv
class DownloaderSong:
@@ -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,62 +72,102 @@ 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(
def _get_drm_data(
self,
drm_infos: dict,
drm_ids: list,
drm_key: str,
) -> str | None:
drm_info = next(
(
drm_infos[drm_id]
for drm_id in drm_ids
if drm_infos[drm_id].get(
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
)
and drm_id != "1"
if drm_infos[drm_id].get(drm_key) and drm_id != "1"
),
None,
)
if not drm_info:
return None
return drm_info["urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"]["URI"]
return drm_info[drm_key]["URI"]
def get_stream_info(self, track_metadata: dict) -> StreamInfo:
m3u8_url = track_metadata["attributes"]["extendedAssetUrls"]["enhancedHls"]
def get_widevine_pssh(
self,
drm_infos: dict,
drm_ids: list,
) -> str | None:
return self._get_drm_data(
drm_infos,
drm_ids,
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
)
def get_playready_pssh(self, drm_infos: dict, drm_ids: list) -> str | None:
return self._get_drm_data(
drm_infos,
drm_ids,
"com.microsoft.playready",
)
def get_fairplay_key(self, drm_infos: dict, drm_ids: list) -> str | None:
return self._get_drm_data(
drm_infos,
drm_ids,
"com.apple.streamingkeydelivery",
)
def get_stream_info(self, track_metadata: dict) -> StreamInfoAv | None:
m3u8_url = track_metadata["attributes"]["extendedAssetUrls"].get("enhancedHls")
if not m3u8_url:
return None
return self._get_stream_info(m3u8_url)
def _get_stream_info(self, m3u8_url: str) -> StreamInfo:
def _get_stream_info(self, m3u8_url: str) -> StreamInfoAv | None:
stream_info = StreamInfo()
m3u8_obj = m3u8.load(m3u8_url)
m3u8_data = m3u8_obj.data
drm_infos = self.get_drm_infos(m3u8_data)
if not drm_infos:
return None
asset_infos = self.get_asset_infos(m3u8_data)
if self.codec == SongCodec.ASK:
playlist = self.get_playlist_from_user(m3u8_data)
else:
playlist = self.get_playlist_from_codec(m3u8_data)
if playlist is None:
return stream_info
return None
stream_info.stream_url = m3u8_obj.base_uri + playlist["uri"]
variant_id = playlist["stream_info"]["stable_variant_id"]
drm_ids = asset_infos[variant_id]["AUDIO-SESSION-KEY-IDS"]
pssh = self.get_pssh(drm_infos, drm_ids)
stream_info.pssh = pssh
widevine_pssh, playready_pssh, fairplay_key = (
self.get_widevine_pssh(drm_infos, drm_ids),
self.get_playready_pssh(drm_infos, drm_ids),
self.get_fairplay_key(drm_infos, drm_ids),
)
stream_info.widevine_pssh = widevine_pssh
stream_info.playready_pssh = playready_pssh
stream_info.fairplay_key = fairplay_key
stream_info.codec = playlist["stream_info"]["codecs"]
return stream_info
is_mp4 = any(
stream_info.codec.startswith(possible_codec)
for possible_codec in self.MP4_FORMAT_CODECS
)
return StreamInfoAv(
audio_track=stream_info,
file_format=MediaFileFormat.MP4 if is_mp4 else MediaFileFormat.M4A,
)
@staticmethod
def parse_datetime_obj_from_timestamp_ttml(
@@ -141,7 +183,10 @@ class DownloaderSong:
secs = float(f"{mins_secs_ms[-2]}.{mins_secs_ms[-1]}")
if len(mins_secs_ms) > 2:
mins = int(mins_secs_ms[-3])
return datetime.datetime.fromtimestamp((mins * 60) + secs + (ms / 1000))
return datetime.datetime.fromtimestamp(
(mins * 60) + secs + (ms / 1000),
tz=datetime.timezone.utc,
)
def get_lyrics_synced_timestamp_lrc(self, timestamp_ttml: str) -> str:
datetime_obj = self.parse_datetime_obj_from_timestamp_ttml(timestamp_ttml)
@@ -171,21 +216,25 @@ class DownloaderSong:
timestamp_srt_end = self.get_lyrics_synced_timestamp_srt(timestamp_ttml_end)
return f"{index}\n{timestamp_srt_start} --> {timestamp_srt_end}\n{text}\n"
def get_lyrics(self, track_metadata: dict) -> Lyrics:
def get_lyrics(self, track_metadata: dict) -> Lyrics | None:
lyrics = Lyrics()
if not track_metadata["attributes"]["hasLyrics"]:
return Lyrics()
return None
elif track_metadata.get("relationships") is None:
track_metadata = self.downloader.apple_music_api.get_song(
track_metadata["id"]
self.downloader.get_media_id(track_metadata)
)
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 +246,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 +287,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,
@@ -259,8 +308,11 @@ class DownloaderSong:
def get_decrypted_path(self, track_id: str) -> Path:
return self.downloader.temp_path / f"{track_id}_decrypted.m4a"
def get_remuxed_path(self, track_id: str) -> Path:
return self.downloader.temp_path / f"{track_id}_remuxed.m4a"
def get_remuxed_path(self, track_id: str, file_format: MediaFileFormat) -> Path:
return (
self.downloader.temp_path
/ f"{track_id}_remuxed.{"m4a" if file_format == MediaFileFormat.M4A else "mp4"}"
)
def fix_key_id(self, encrypted_path: Path):
count = 0
@@ -294,13 +346,14 @@ class DownloaderSong:
decrypted_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux(self, decrypted_path: Path, remuxed_path: Path, codec: str):
def remux(self, decrypted_path: Path, remuxed_path: Path):
if self.downloader.remux_mode == RemuxMode.MP4BOX:
self.remux_mp4box(decrypted_path, remuxed_path)
elif self.downloader.remux_mode == RemuxMode.FFMPEG:
self.remux_ffmpeg(decrypted_path, remuxed_path, codec)
self.remux_ffmpeg(decrypted_path, remuxed_path)
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path):
subprocess.run(
@@ -311,21 +364,19 @@ class DownloaderSong:
decrypted_path,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
remuxed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_ffmpeg(
self,
decrypted_path: Path,
remuxed_path: Path,
codec: str,
):
use_mp4_format = any(
codec.startswith(possible_codec) for possible_codec in self.MP4_FORMAT_CODECS
)
subprocess.run(
[
self.downloader.ffmpeg_path_full,
@@ -336,13 +387,12 @@ class DownloaderSong:
decrypted_path,
"-c",
"copy",
"-f",
"mp4" if use_mp4_format else "ipod",
"-movflags",
"+faststart",
remuxed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def get_lyrics_synced_path(self, final_path: Path) -> Path:
@@ -350,8 +400,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)
+37 -25
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import base64
import subprocess
from pathlib import Path
@@ -7,43 +9,50 @@ from pywidevine import PSSH
from pywidevine.license_protocol_pb2 import WidevinePsshData
from .downloader_song import DownloaderSong
from .enums import RemuxMode, SongCodec
from .models import StreamInfo
from .enums import MediaFileFormat, RemuxMode, SongCodec
from .models import StreamInfo, StreamInfoAv
class DownloaderSongLegacy(DownloaderSong):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def get_stream_info(self, webplayback: dict) -> StreamInfo:
def get_stream_info(self, webplayback: dict) -> StreamInfoAv:
flavor = "32:ctrp64" if self.codec == SongCodec.AAC_HE_LEGACY else "28:ctrp256"
stream_info = StreamInfo()
stream_info.stream_url = next(
i for i in webplayback["assets"] if i["flavor"] == flavor
)["URL"]
m3u8_obj = m3u8.load(stream_info.stream_url)
stream_info.pssh = m3u8_obj.keys[0].uri
return stream_info
stream_info.widevine_pssh = m3u8_obj.keys[0].uri
return StreamInfoAv(
audio_track=stream_info,
file_format=MediaFileFormat.M4A,
)
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 +61,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 +70,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 +82,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 +113,7 @@ class DownloaderSongLegacy(DownloaderSong):
remuxed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux(
+16 -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,11 +33,22 @@ class SyncedLyricsFormat(Enum):
class MusicVideoCodec(Enum):
H264_BEST = "h264-best"
H265_BEST = "h265-best"
H264 = "h264"
H265 = "h265"
ASK = "ask"
class RemuxFormatMusicVideo(Enum):
M4V = "m4v"
MP4 = "mp4"
class MediaFileFormat(Enum):
M4A = "m4a"
MP4 = "mp4"
M4V = "m4v"
class PostQuality(Enum):
BEST = "best"
ASK = "ask"
@@ -45,3 +57,4 @@ class PostQuality(Enum):
class CoverFormat(Enum):
JPG = "jpg"
PNG = "png"
RAW = "raw"
+3 -3
View File
@@ -4,8 +4,8 @@ import functools
import requests
from .apple_music_api import AppleMusicApi
from .constants import STOREFRONT_IDS
from .utils import raise_response_exception
class ItunesApi:
@@ -58,7 +58,7 @@ class ItunesApi:
requests.exceptions.JSONDecodeError,
AssertionError,
):
AppleMusicApi._raise_response_exception(response)
raise_response_exception(response)
return resource
def get_itunes_page(
@@ -81,5 +81,5 @@ class ItunesApi:
requests.exceptions.JSONDecodeError,
AssertionError,
):
AppleMusicApi._raise_response_exception(response)
raise_response_exception(response)
return itunes_page
+18 -3
View File
@@ -1,16 +1,22 @@
from __future__ import annotations
from dataclasses import dataclass
from .enums import MediaFileFormat
@dataclass
class UrlInfo:
storefront: str = None
type: str = None
id: str = None
is_library: bool = None
@dataclass
class DownloadQueueItem:
metadata: dict = None
class DownloadQueue:
playlist_attributes: dict = None
medias_metadata: list[dict] = None
@dataclass
@@ -22,5 +28,14 @@ class Lyrics:
@dataclass
class StreamInfo:
stream_url: str = None
pssh: str = None
widevine_pssh: str = None
playready_pssh: str = None
fairplay_key: str = None
codec: str = None
@dataclass
class StreamInfoAv:
video_track: StreamInfo = None
audio_track: StreamInfo = None
file_format: MediaFileFormat = None
+44
View File
@@ -0,0 +1,44 @@
from pathlib import Path
import click
import colorama
import requests
from .constants import X_NOT_FOUND_STRING
def color_text(text: str, color) -> str:
return color + text + colorama.Style.RESET_ALL
def raise_response_exception(response: requests.Response):
raise Exception(
f"Request failed with status code {response.status_code}: {response.text}"
)
def prompt_path(is_file: bool, initial_path: Path, description: str) -> Path:
path_validator = click.Path(
exists=True,
file_okay=is_file,
dir_okay=not is_file,
path_type=Path,
)
while True:
try:
path_obj = path_validator.convert(initial_path, None, None)
break
except click.BadParameter as e:
path_str = click.prompt(
(
f"{X_NOT_FOUND_STRING.format(description, initial_path.absolute())} or "
"the specified path is not valid. "
"Move it to that location, type the path or drag and drop it here. "
"Then, press enter to continue"
),
default=str(initial_path),
show_default=False,
)
path_str = path_str.strip('"')
initial_path = Path(path_str)
return path_obj
+6 -5
View File
@@ -1,15 +1,16 @@
[project]
name = "gamdl"
description = "Download Apple Music songs/music videos/albums/playlists"
requires-python = ">=3.8"
description = "A Python CLI app for downloading Apple Music songs, music videos and post videos."
requires-python = ">=3.10"
authors = [{ name = "glomatico" }]
dependencies = [
"ciso8601",
"click",
"colorama",
"inquirerpy",
"m3u8",
"tabulate",
"mutagen",
"pillow",
"pywidevine",
"pyyaml",
"yt-dlp",
]
readme = "README.md"
+5 -2
View File
@@ -1,7 +1,10 @@
ciso8601
click
colorama
inquirerpy
m3u8
tabulate
mutagen
pillow
pywidevine
pyyaml
termcolor
yt-dlp