Compare commits

...

411 Commits

Author SHA1 Message Date
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
alacleaker 54d640230a bump version 2024-04-08 18:25:10 -03:00
alacleaker 3d272a6891 use m4v with ffmpeg when compatible codecs 2024-04-08 17:29:45 -03:00
alacleaker e99ed0eb5a fix wrong format and rename to use_mp4_format 2024-04-08 17:00:20 -03:00
alacleaker 86b5029773 add stream info codec to remux 2024-04-08 16:59:44 -03:00
alacleaker 3df0a91d3f adjust mp4 flag codecs 2024-04-08 16:54:17 -03:00
alacleaker d356596cf4 add codec to StreamInfo 2024-04-08 16:54:03 -03:00
alacleaker cbd2df79b7 add -f mp4 for atmos 2024-04-08 16:46:36 -03:00
Rafael Moraes 80c2afde63 Merge pull request #91 from glomatico/dev
Dev
2024-04-08 15:34:29 -03:00
alacleaker e43169b9d3 Update README.md 2024-04-08 15:26:14 -03:00
alacleaker 3c97dfdb1f Update README.md 2024-04-08 15:26:05 -03:00
alacleaker 72e6d624ed Update README.md 2024-04-08 15:22:22 -03:00
alacleaker 1c93c6b7ce Update README.md 2024-04-08 15:20:56 -03:00
alacleaker 3f61a907ff Update README.md 2024-04-08 15:09:20 -03:00
alacleaker c4a38c5c17 Update README.md 2024-04-08 15:08:11 -03:00
alacleaker a49066d30d bump to 2.0 owo 2024-04-08 15:08:08 -03:00
alacleaker 2cb5eb9b2f Update README.md 2024-04-08 14:59:19 -03:00
alacleaker 237fc0c3d1 Update README.md 2024-04-08 14:59:02 -03:00
alacleaker 7b767adc05 move no_config_file to end 2024-04-08 14:34:13 -03:00
alacleaker 2baf05ebc7 change config_location to config_path 2024-04-08 14:28:01 -03:00
alacleaker c3eb66ef0e adjust variable name 2024-04-08 14:21:19 -03:00
alacleaker cf8d22b7dc synced lyrics format 2024-04-08 14:13:44 -03:00
alacleaker 2dccfaef9e adjust lrc references to synced lyrics 2024-04-08 14:13:35 -03:00
alacleaker 6c4c3cdd25 add SYNCED_LYRICS_FILE_EXTENSION_MAP 2024-04-08 14:13:15 -03:00
alacleaker 7cd3d4b705 add SyncedLyricsFormat 2024-04-08 14:13:07 -03:00
alacleaker 65bcb41f83 adjust get_download_queue 2024-04-08 13:06:29 -03:00
alacleaker 0ded3e1b50 fix get_cover_url 2024-04-08 12:49:10 -03:00
alacleaker 2a69969c97 change artwork to cover 2024-04-08 12:38:13 -03:00
alacleaker ab782c98c9 adjust imageformat on apply_tags 2024-04-08 12:35:37 -03:00
alacleaker 9cc1b8a800 Update downloader.py 2024-04-08 12:35:06 -03:00
alacleaker d60e26d4d1 adjust get_cover_url 2024-04-08 12:34:59 -03:00
alacleaker 5d2f50d315 fix get_cover_path 2024-04-08 08:42:34 -03:00
alacleaker fe44cd0d8e adjust for MUSIC_VIDEO_CODEC_MAP 2024-04-08 07:58:50 -03:00
alacleaker 9d3865e7d0 add MUSIC_VIDEO_CODEC_MAP and adjust EXCLUDED_CONFIG_FILE_PARAMS 2024-04-08 07:57:43 -03:00
alacleaker 4636d96dbf Update README.md 2024-04-08 07:12:12 -03:00
alacleaker b4424a110e Update README.md 2024-04-08 07:11:22 -03:00
alacleaker 2773f20931 Update README.md 2024-04-08 07:09:57 -03:00
alacleaker f8005beac9 check if N_m3u8DL-RE is on path 2024-04-08 07:09:49 -03:00
alacleaker b1c02cffef update lrc_only check 2024-04-08 07:08:11 -03:00
alacleaker 89080a0f7f skip unplayable tracks 2024-04-08 07:07:00 -03:00
alacleaker 0bfc702533 add disable_music_video_skip 2024-04-08 07:04:39 -03:00
alacleaker aa4a391309 warn when mp4decrypt is missing 2024-04-08 07:00:31 -03:00
alacleaker a2932b637a update requirements & required version 2024-04-08 06:54:51 -03:00
alacleaker 5b909b9abc remove unused import 2024-04-07 20:09:02 -03:00
alacleaker 617605495e Create downloader_post.py 2024-04-07 20:08:56 -03:00
alacleaker 4a373bbf99 add post download support 2024-04-07 20:08:52 -03:00
alacleaker b93b1a8135 add post quality 2024-04-07 20:08:36 -03:00
alacleaker 1ec3aed080 add post download support 2024-04-07 19:39:22 -03:00
alacleaker 406edc06bc add get_post 2024-04-07 19:18:29 -03:00
alacleaker 1e311aa84e Update cli.py 2024-04-07 19:18:22 -03:00
alacleaker 739debcfb9 fix hvc1 2024-04-07 19:06:19 -03:00
alacleaker b3edae7d91 adjust get_m3u8_master_data 2024-04-07 18:57:23 -03:00
alacleaker 7281a9b8bb rename music-video-codec to codec-music-video 2024-04-07 18:57:17 -03:00
alacleaker a62a52bfa0 Create downloader_music_video.py 2024-04-07 17:24:19 -03:00
alacleaker e78f02812a add lru cache to get_album 2024-04-07 17:24:16 -03:00
alacleaker f500488f13 fix get_cover_path not using specified format 2024-04-07 17:24:02 -03:00
alacleaker 76eb8e62f9 add lru cache decoration to get_resource 2024-04-07 17:23:44 -03:00
alacleaker f46387dacf update music video codec 2024-04-07 15:41:42 -03:00
alacleaker e92a8890f9 new version 2024-04-07 14:45:40 -03:00
alacleaker 48c7d08609 Create downloader_song.py 2024-04-07 14:45:33 -03:00
alacleaker b6e2761af4 Create downloader_song_legacy.py 2024-04-07 14:45:30 -03:00
alacleaker 129ce5d20d add remux mode and ask in song codec 2024-04-07 14:45:26 -03:00
alacleaker 57209cf071 rename cookies_location to cookies_path 2024-04-07 11:56:11 -03:00
alacleaker c7aaca1f29 add SONG_CODEC_REGEX_MAP 2024-04-06 19:22:22 -03:00
alacleaker eb323f2faf fix enums import 2024-04-06 19:22:08 -03:00
alacleaker f28b75b66d add more formats 2024-04-06 19:09:38 -03:00
alacleaker 14d54a7dd8 changed jpeg to jpg 2024-04-06 18:00:19 -03:00
alacleaker 57494e8e0a Create enums.py 2024-04-06 17:59:52 -03:00
alacleaker e54b6b3fc4 Create models.py 2024-04-06 17:59:50 -03:00
alacleaker 61d1876029 Create itunes_api.py 2024-04-06 17:59:48 -03:00
alacleaker dc3e0422bd Create hardcoded_wvd.py 2024-04-06 17:59:44 -03:00
alacleaker c77dc4adba Create apple_music_api.py 2024-04-06 17:59:39 -03:00
Rafael Moraes e76803add1 Merge pull request #86 from Cronocide/main
Added support for configuration from ENV
2024-03-21 13:56:49 -03:00
Cronocide 6551722ef3 Evaluate env in no_config_callback 2024-03-19 14:20:12 -06:00
Cronocide 90d17fb206 Evaluate env in no_config_callback 2024-03-19 14:15:13 -06:00
Cronocide cb09033cca Removed explicit env documentation from README 2024-03-12 09:23:38 -06:00
Cronocide 733a4bb2c6 Added support for configuration from ENV 2024-03-12 00:36:56 -06:00
Rafael Moraes 56f01dc433 Bump version 2024-02-22 14:39:51 -03:00
Rafael Moraes d99cfec349 Bump version 2024-02-22 14:39:40 -03:00
Rafael Moraes 4ecc76ebe4 Fix missing date 2024-02-22 14:39:26 -03:00
Rafael Moraes 2678950fcd Merge pull request #76 from florisdipt/main
Add support for downloading playlists with more than 300 songs
2024-02-17 02:02:53 -03:00
FlorisDipt c0eae562e7 Replace hardcoded API hostname with a constant 2024-02-17 00:21:41 +01:00
FlorisDipt b2b11f3680 Seperated getting additional playlist tracks into its own function 2024-02-17 00:15:06 +01:00
FlorisDipt d2dd8673ca Added support for large playlist downloading.
Tested initial version, not happy the code but happy with the functionality. Pre refactor commit
2024-02-16 23:48:57 +01:00
Rafael Moraes da3ec2b4e7 Merge pull request #64 from glomatico/dev
Dev
2023-11-27 16:12:06 -03:00
R. M bb7579870e bump version 2023-11-27 16:11:45 -03:00
R. M 6c459b566e adjust url regex result 2023-11-27 16:11:29 -03:00
Rafael Moraes 4172ccaf37 Merge pull request #62 from glomatico/dev
Dev
2023-11-21 14:41:50 -03:00
R. M 01c46d588e bump version 2023-11-21 14:41:31 -03:00
R. M f14fd2f42f fix WidevinePsshData import 2023-11-21 14:38:59 -03:00
Rafael Moraes 6febfe9c3d Merge pull request #59 from glomatico/dev
Dev
2023-11-14 22:42:14 -03:00
R. M b648c295b9 bump version 2023-11-14 22:41:40 -03:00
R. M dd96fbb134 adjust apply_tags 2023-11-14 22:41:00 -03:00
Rafael Moraes 72aae6c147 Merge pull request #58 from glomatico/dev
fix array
2023-11-14 22:21:38 -03:00
R. M 5564105d9c fix array 2023-11-14 22:18:12 -03:00
Rafael Moraes 934cb4db93 Merge pull request #57 from glomatico/dev
Dev
2023-11-14 22:09:39 -03:00
R. M 1a86f23187 bump version 2023-11-14 21:22:26 -03:00
R. M 5bf0e6dc09 Update README.md 2023-11-14 21:21:24 -03:00
R. M c98d779c1b make prefer_account_language as downloader attribute and minor improvements 2023-11-14 21:21:13 -03:00
R. M 47a9cfe3ef Update README.md 2023-11-14 18:27:22 -03:00
R. M 743f5bedb9 adjust sanitize_date 2023-11-14 18:27:19 -03:00
R. M eb041894cf adjust template date arg default value and help 2023-11-14 18:27:01 -03:00
Rafael Moraes d5f5398fe1 Merge pull request #56 from reaitten/dev
Add some fixes and improvements
2023-11-14 18:10:22 -03:00
reaitten c55a894212 Add custom date template option & fix README 2023-11-13 21:26:49 -05:00
reaitten 8a3e857cb3 Strip time and timezone from date in tagged metadata 2023-11-13 20:18:31 -05:00
reaitten 71e31d7832 Add option to prefer original language 2023-11-13 20:17:47 -05:00
Rafael Moraes abef15789d Merge pull request #55 from glomatico/dev
Dev
2023-11-09 15:26:26 -03:00
R. M 41a8584023 bump version 2023-11-09 15:25:54 -03:00
R. M 025a5a22cf Fix album download and playlist download 2023-11-09 15:25:39 -03:00
Rafael Moraes 936b878474 Merge pull request #54 from glomatico/dev
Dev
2023-11-09 10:00:24 -03:00
R. M d76d574da9 bump version 2023-11-09 09:59:50 -03:00
R. M 92f65d5c10 Enhance exception verbose 2023-11-08 17:05:14 -03:00
R. M d96a28f5fb Fix download_type check 2023-11-08 16:18:32 -03:00
R. M 4064ebcec5 Create get_song, music_video, album and playlist methods 2023-11-08 16:18:04 -03:00
Rafael Moraes b70792e9a7 Merge pull request #53 from glomatico/dev
adjust get_lyrics_synced_timestamp_lrc
2023-11-02 16:37:19 -03:00
R. M 68ba1556f5 adjust get_lyrics_synced_timestamp_lrc 2023-11-02 16:26:54 -03:00
Rafael Moraes 6fdca2fe2e Merge pull request #52 from glomatico/dev
Dev
2023-10-25 23:29:44 -03:00
R. M 7709a37b88 bump version 2023-10-25 23:29:22 -03:00
R. M 9c4d2d1a13 adjust lyrics ms parsing 2023-10-25 23:28:27 -03:00
Rafael Moraes 6a4b9d8eb1 Merge pull request #51 from glomatico/dev
Dev
2023-10-21 22:08:28 -03:00
R. M 3be4a4bbbc bump version 2023-10-21 22:08:00 -03:00
R. M 70b595b323 add missing subprocess checks 2023-10-21 22:07:43 -03:00
Rafael Moraes 6e3824f448 Merge pull request #50 from glomatico/dev
Dev
2023-10-21 17:54:11 -03:00
R. M b0f98e21ec adjust get_lyrics_synced_timestamp_lrc 2023-10-21 17:50:50 -03:00
R. M 46918ff4a0 bump version 2023-10-21 17:20:04 -03:00
R. M dc25b8a7be Update README.md 2023-10-21 15:59:45 -03:00
R. M 6516499df2 make getting music video stream_urls faster 2023-10-21 15:54:30 -03:00
R. M c550ae3c20 adjust ttml lrc timestamp conversion 2023-10-21 15:32:48 -03:00
Rafael Moraes 908dcf794b Merge pull request #48 from glomatico/dev
Dev
2023-10-18 10:08:37 -03:00
R. M 4dd8df7e70 bump version 2023-10-18 10:04:23 -03:00
R. M 8e6623cb93 lyrics attributes check 2023-10-16 06:37:48 -03:00
Rafael Moraes aabe0aa7ea Merge pull request #46 from glomatico/dev
Dev
2023-10-13 22:43:57 -03:00
R. M 7d122844e2 bump version 2023-10-13 22:43:20 -03:00
R. M 5bb34caf09 adjust has lyrics 2023-10-13 19:42:38 -03:00
R. M d615842c60 has lyrics check 2023-10-10 14:42:45 -03:00
Rafael Moraes 1a5dd94c1c Merge pull request #45 from glomatico/dev
Dev
2023-10-03 14:16:21 -03:00
R. M 17e930cfbd Update README.md 2023-10-02 16:56:26 -03:00
R. M df6b138d30 adjust get_stream_url_music_video 2023-09-30 14:10:05 -03:00
R. M c5271f6d99 Update README.md 2023-09-30 14:00:05 -03:00
R. M 4bc0938eea Update README.md 2023-09-30 01:31:51 -03:00
R. M c3285ac3cd rename x not found string 2023-09-30 01:16:52 -03:00
R. M b0e2e15702 bump version 2023-09-30 01:16:31 -03:00
R. M 1c94d04066 adjust log messages 2023-09-30 01:14:45 -03:00
R. M 13bf369d26 Update README.md 2023-09-30 01:14:32 -03:00
R. M 91e8111d72 add missing return types 2023-09-30 00:13:56 -03:00
R. M af1a356322 rename make_lrc to save_lrc 2023-09-30 00:12:19 -03:00
R. M 8ad83e1f82 Update downloader.py 2023-09-30 00:07:09 -03:00
R. M 0f539115ad rename cleanup to cleanup_temp_path 2023-09-30 00:06:26 -03:00
R. M 70139e9eaa create cleanup function 2023-09-30 00:03:38 -03:00
R. M 56c77a0447 adjust version argument and don't setup cdm with lrc only 2023-09-29 23:42:06 -03:00
R. M d9d7ddf3d7 fix old python version compatibility 2023-09-19 06:58:16 -03:00
Rafael Moraes cd929a0626 Merge pull request #42 from glomatico/dev
Dev
2023-09-09 01:47:07 -03:00
R. M 366698fa86 bump version 2023-09-09 01:24:38 -03:00
R. M 99e8c30826 adjust failed to check url log message 2023-09-09 01:07:59 -03:00
R. M 20f306b026 rename dl to downloader 2023-09-09 01:04:23 -03:00
R. M 014f55a994 lrc first then cover 2023-09-09 01:00:08 -03:00
R. M 813b119e5a adjust get download queue 2023-09-09 00:23:45 -03:00
R. M 186209f99a adjust failed to download log 2023-09-09 00:14:38 -03:00
R. M beb2e10349 remove some log messages 2023-09-09 00:05:42 -03:00
R. M 0203ec4535 adjust download mode choice 2023-09-08 23:40:25 -03:00
R. M 6cdea55eb1 Update README.md 2023-09-08 23:39:26 -03:00
R. M efc5387785 adjust remux mode help 2023-09-08 23:38:52 -03:00
R. M b52b2f050c rename yt-dlp to ytdlp 2023-09-08 23:38:27 -03:00
R. M 8926052751 save cover lru cache 2023-09-08 23:32:41 -03:00
R. M 72df094194 remove failed to setup x constant 2023-09-07 19:42:05 -03:00
R. M 8bc369481c fix line too long 2023-09-07 19:06:29 -03:00
R. M b1f04ab0ce fix failed to download track error log 2023-09-07 19:03:29 -03:00
R. M 2e34754303 return types 2023-09-07 16:40:59 -03:00
R. M a464de8f18 Update README.md 2023-09-07 16:28:30 -03:00
R. M 7e7fbc5a38 rename disable_music_video_skip option 2023-09-07 16:28:27 -03:00
R. M 06ffa8a2ca add return types and rename lyrics variable 2023-09-07 16:20:33 -03:00
R. M 12cf08d489 add starting downloader log 2023-09-07 16:03:13 -03:00
R. M 4ecf5fc6f4 Update README.md 2023-09-07 15:33:13 -03:00
R. M 44de089522 Update README.md 2023-09-07 15:27:38 -03:00
R. M 9a4fdf1874 closed captions with ffmpeg 2023-09-07 15:26:39 -03:00
R. M 8180a850fd adjust log messages 2023-09-07 15:13:55 -03:00
R. M 2b2fe89dcc more debug logs and fix download mode in music video 2023-09-05 17:00:44 -03:00
R. M fca7ee5c3b split download function 2023-09-04 23:22:56 -03:00
R. M 2d510a7703 cover_url 2023-09-04 23:17:58 -03:00
R. M 1cd5221304 tags optimization 2023-09-04 22:27:51 -03:00
R. M 788270c2ab rename constants variables 2023-09-04 22:16:38 -03:00
R. M 3dc9132a60 download queue optimizations 2023-09-04 22:10:37 -03:00
R. M f5a8180fcc constants file 2023-09-04 17:26:15 -03:00
R. M ebc07082d5 rename dl to downloader 2023-09-04 17:17:25 -03:00
R. M 6f96166cf0 start session and start cdm 2023-09-04 17:12:36 -03:00
R. M 9358a8b760 binaries location adjust 2023-09-04 16:57:01 -03:00
Rafael Moraes bea54a73cb Merge pull request #41 from glomatico/dev
Dev
2023-08-27 20:14:30 -03:00
R. M 85f930b298 Bump version 2023-08-27 20:02:38 -03:00
R. M 44cfa36f9a Update README.md 2023-08-27 20:02:24 -03:00
R. M fd60260e6c Update README.md 2023-08-27 20:02:04 -03:00
R. M 919c9a66ec Change songs aac help 2023-08-27 19:59:43 -03:00
R. M f1d609e8d2 Update README.md 2023-08-27 19:59:18 -03:00
R. M e91fa202f0 Add missing rating tag 2023-08-27 11:44:38 -03:00
Rafael Moraes a181f53f48 Merge pull request #39 from glomatico/dev
Dev
2023-08-27 04:00:11 -03:00
R. M f4cb9a9c4e Update .gitignore 2023-08-27 03:50:58 -03:00
R. M b3a328f1a2 Return when ffmpeg is not on path 2023-08-27 02:33:50 -03:00
R. M 18bbb3418c Adjust binaries 2023-08-27 02:32:27 -03:00
R. M d3629a6846 Binaries location type to str 2023-08-27 02:27:31 -03:00
R. M 8a4d6b33ec Fix nm3u8dlre ffmpeg issue 2023-08-27 02:15:43 -03:00
R. M 24858f4ca8 Update README.md 2023-08-27 01:51:05 -03:00
R. M 1b0b00cf08 Add ffmpeg binay path option in nm3u8dlre 2023-08-27 01:47:40 -03:00
R. M 8b6b81466b Update README.md 2023-08-27 01:44:00 -03:00
R. M 752681d3ff Update README.md 2023-08-27 01:43:07 -03:00
R. M 4dc9a25753 Update README.md 2023-08-27 01:42:10 -03:00
R. M 9303722eab Update README.md 2023-08-27 01:39:23 -03:00
R. M 153727c625 Update README.md 2023-08-27 01:37:30 -03:00
R. M aa3a61bca3 Fix spell mistake in get_sanitized_string 2023-08-27 01:14:32 -03:00
R. M aaf2e9181f Update pyproject.toml 2023-08-27 01:07:08 -03:00
R. M 6d598cc294 Update README.md 2023-08-27 00:48:02 -03:00
R. M 0a6c7d78c8 Bump version 2023-08-27 00:47:59 -03:00
R. M cdde615186 Invalid cookies file error 2023-08-27 00:33:22 -03:00
R. M 631650824f Update x not found string 2023-08-26 23:39:52 -03:00
R. M a11a937b89 Update if media is album on get final location 2023-08-26 15:54:29 -03:00
R. M 688816125b Change get_cover arguments 2023-08-25 17:30:31 -03:00
R. M 36395f226f Fix music video skip lrc only 2023-08-25 16:33:00 -03:00
R. M 15b5342707 Lrc only music video skip 2023-08-25 16:31:01 -03:00
R. M c8bda637fe Changed file template argument help 2023-08-25 13:12:03 -03:00
R. M 774df56091 Add click 2023-08-25 12:27:23 -03:00
R. M 157e331a8a Fix unresolved attribute for class dl 2023-08-25 12:27:14 -03:00
R. M f0e1d672a5 Fix lyrics timestamp 2023-08-25 03:46:53 -03:00
R. M ee434e767e Fix skip music video when mp4decrypt is not present 2023-08-25 03:44:36 -03:00
R. M 04719f7587 Skip music videos when mp4decrypt is not present 2023-08-25 03:43:23 -03:00
R. M dc3c673985 Cookies file not found error 2023-08-25 03:05:43 -03:00
R. M f912a19fed Skip remux and download_mode check in lrc_only mode 2023-08-25 03:05:27 -03:00
R. M bd3553048f Remove cover quality 2023-08-25 02:58:08 -03:00
R. M e6cd1a11d5 New lrc timestamp method, heaac and remove unused cover quality argument 2023-08-25 02:54:10 -03:00
R. M dd9968387e Change log level argument position 2023-08-25 01:17:42 -03:00
R. M 10122146f1 Remove old cli 2023-08-25 00:58:24 -03:00
R. M 3d17b3f363 New cli 2023-08-25 00:58:16 -03:00
R. M 4356519dce Create cli.py 2023-08-25 00:57:41 -03:00
R. M 24bac438b7 Create dl.py 2023-08-25 00:57:36 -03:00
R. M 9fb05ef89c Delete gamdl.py 2023-08-25 00:57:13 -03:00
R. M f0966e7b39 Rename to storefronts 2023-08-23 03:06:59 -03:00
R. M ae4c2abfe4 Changed version 2023-06-11 01:09:24 -03:00
R. M 37554e8f49 Added missing line 2023-06-11 01:09:12 -03:00
R. M aee494f464 Changed version 2023-06-11 00:17:05 -03:00
R. M eddd6f9053 Update __init__.py 2023-06-11 00:15:47 -03:00
R. M 24d9fdf9ee Increase video cover size 2023-06-10 12:20:35 -03:00
R. M a0fef9944e Changed URL argument behavior 2023-06-10 12:20:00 -03:00
R. M e37038e67b Remove skip cleanup 2023-06-10 12:15:53 -03:00
R. M 52c59f9a17 Update gamdl.py 2023-06-10 12:09:37 -03:00
R. M 2f80b9dc65 Update __main__.py 2023-06-10 12:09:35 -03:00
R. M caed322fd0 Update __init__.py 2023-06-10 12:09:33 -03:00
R. M 76396f3fed Update README.md 2023-05-20 14:21:10 -03:00
R. M b64cc06641 Version bump 2023-05-20 14:15:22 -03:00
R. M 4e2c54934a LRC only mode 2023-05-20 14:13:26 -03:00
R. M e76c79d9b4 Remove Print Video M3U8 URL 2023-05-20 13:14:32 -03:00
R. M 6dd730c368 Increased cover size 2023-05-20 13:12:14 -03:00
R. M 2a2403c130 Update gamdl.py 2023-05-20 13:10:11 -03:00
R. M fd47acab4f Remove HE-AAC 2023-05-20 13:09:50 -03:00
R. M 66d8211a16 Update get_sanizated_string 2023-05-20 13:05:26 -03:00
R. M 158f0e9f27 Update gamdl.py 2023-05-20 13:04:59 -03:00
R. M 0d9b225fdc Version bump 2023-04-19 22:47:02 -03:00
R. M 254147096a Fix index_js_uri 2023-04-19 22:46:31 -03:00
21 changed files with 3061 additions and 845 deletions
+1
View File
@@ -0,0 +1 @@
ko_fi: glomatico
+3 -1
View File
@@ -1,5 +1,7 @@
/*
__pycache__
!gamdl
!requirements.txt
!.gitignore
!pyproject.toml
!README.md
!requirements.txt
+202 -83
View File
@@ -1,93 +1,212 @@
# Glomatico's Apple Music Downloader
A Python script to download Apple Music songs/music videos/albums/playlists.
# Glomatico's Apple Music Downloader
A Python CLI app for downloading Apple Music songs/music videos/posts.
![Windows CMD usage example](https://i.imgur.com/18Azlg4.png)
**Discord Server:** https://discord.gg/aBjMEZ9tnq
This is a rework of https://github.com/loveyoursupport/AppleMusic-Downloader/tree/661a274d62586b521feec5a7de6bee0e230fdb7d.
## Features
* Download songs in AAC 256kbps and other codecs
* Download music videos up to 4K
* Download synced lyrics in LRC, SRT or TTML
* Choose between FFmpeg and MP4Box for remuxing
* Choose between yt-dlp and N_m3u8DL-RE for downloading
* Highly customizable
* Use artist links to download all of their albums or music videos
Some new features that I added:
* MP4Box for muxing
* Tags for music videos
* Multiple URLs input
* iTunes folder structure
* Embedded lyrics and .lrc file
* Auto set region
* Playlist support
* And much more!
## Setup
1. Install Python 3.7 or newer
2. Install gamdl with pip
```
## Prerequisites
* Python 3.8 or higher
* The cookies file of your Apple Music browser session in Netscape format (requires an active subscription)
* You can get your cookies by using one of the following extensions on your browser of choice at the Apple Music website with your account signed in:
* Firefox: https://addons.mozilla.org/addon/export-cookies-txt
* Chromium based browsers: https://chrome.google.com/webstore/detail/gdocmgbfkjnnpapoeobnolbbkoibbcif
* FFmpeg on your system PATH
* Older versions of FFmpeg may not work.
* Up to date binaries can be obtained from the links below:
* Windows: https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases
* Linux: https://johnvansickle.com/ffmpeg/
* (Optional) mp4decrypt on your system PATH
* Required to download music videos and songs in non-legacy formats.
* Binaries can be obtained from here: https://www.bento4.com/downloads/.
## Installation
1. Install the package `gamdl` using pip
```bash
pip install gamdl
```
3. Add MP4Box and mp4decrypt to your PATH
* You can get them from here:
* MP4Box: https://gpac.wp.imt.fr/downloads/
* mp4decrypt: https://www.bento4.com/downloads/
4. Export your Apple Music cookies as `cookies.txt` to the same folder that you will run the script
* You can export your cookies by using this Google Chrome extension on Apple Music website: https://chrome.google.com/webstore/detail/open-cookiestxt/gdocmgbfkjnnpapoeobnolbbkoibbcif. Make sure to be logged in.
5. Put your Widevine Device file (.wvd) in the same folder that you will run the script
* You can use Dumper to dump your phone's L3 CDM: https://github.com/Diazole/dumper. Once you have the L3 CDM, you can use pywidevine to create the .wvd file from it.
1. Install pywidevine with pip
```
pip install pywidevine pyyaml
```
2. Create the .wvd file
```
pywidevine create-device -t ANDROID -l 3 -k private_key.pem -c client_id.bin -o .
```
6. (optional) Add aria2c to your PATH for faster downloads
* You can get it from here: https://github.com/aria2/aria2/releases.
2. Place your cookies file in the directory from which you will be running gamdl and name it `cookies.txt`.
## Usage
```
usage: gamdl [-h] [-u [URLS_TXT]] [-w WVD_LOCATION] [-f FINAL_PATH] [-t TEMP_PATH] [-c COOKIES_LOCATION] [-m]
[-p] [-a] [-o] [-n] [-s] [-e] [-i] [-v]
[url ...]
Download Apple Music songs/music videos/albums/playlists
positional arguments:
url Apple Music song/music video/album/playlist URL(s) (default: None)
options:
-h, --help show this help message and exit
-u [URLS_TXT], --urls-txt [URLS_TXT]
Read URLs from a text file (default: None)
-w WVD_LOCATION, --wvd-location WVD_LOCATION
.wvd file location (default: *.wvd)
-f FINAL_PATH, --final-path FINAL_PATH
Final Path (default: Apple Music)
-t TEMP_PATH, --temp-path TEMP_PATH
Temp Path (default: temp)
-c COOKIES_LOCATION, --cookies-location COOKIES_LOCATION
Cookies location (default: cookies.txt)
-m, --disable-music-video-skip
Disable music video skip on playlists/albums (default: False)
-p, --prefer-hevc Prefer HEVC over AVC (default: False)
-a, --heaac Download songs/music videos with HE-AAC instead of AAC (default: False)
-o, --overwrite Overwrite existing files (default: False)
-n, --no-lrc Don't create .lrc file (default: False)
-s, --skip-cleanup Skip cleanup (default: False)
-e, --print-exceptions
Print execeptions (default: False)
-i, --print-video-m3u8-url
Print Video M3U8 URL (default: False)
-v, --version show program's version number and exit
```bash
gamdl [OPTIONS] URLS...
```
## Songs/Music Videos quality
* Songs:
* 256kbps AAC / HE-AAC 64kbps
* Music Videos (varies depending on the video):
* 4K HEVC 20mbps, AAC 256kbps / HE-AAC 64kbps
* 4K HEVC 12mbps, AAC 256kbps / HE-AAC 64kbps
* 1080p AVC 10mbps, AAC 256kbps / HE-AAC 64kbps
* 1080p AVC 6.5bps, AAC 256kbps / HE-AAC 64kbps
* 720p AVC 4mbps, AAC 256kbps / HE-AAC 64kbps
* 576p AVC 2mbps, AAC 256kbps / HE-AAC 64kbps
* 480p AVC 1.5mbps, AAC 256kbps / HE-AAC 64kbps
* 360p AVC 1mbps, AAC 256kbps / HE-AAC 64kbps
### 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"
```
* Choose which albums or music videos to download from an artist
```bash
gamdl "https://music.apple.com/us/artist/rick-astley/669771"
```
Some videos may include EIA-608 closed captions.
### Interactive prompt controls
* Arrow keys - Move selection
* Space - Toggle selection
* Ctrl + A - Select all
* Enter - Confirm selection
## Configuration
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` |
| `--print-exceptions` / `print_exceptions` | Print exceptions. | `false` |
| `--cookies-path`, `-c` / `cookies_path` | Path to .txt cookies file. | `./cookies.txt` |
| `--language`, `-l` / `language` | Metadata language as an ISO-2A language code (don't always work for videos). | `en-US` |
| `--output-path`, `-o` / `output_path` | Path to output directory. | `./Apple Music` |
| `--temp-path` / `temp_path` | Path to temporary directory. | `./temp` |
| `--wvd-path` / `wvd_path` | Path to .wvd file. | `null` |
| `--nm3u8dlre-path` / `nm3u8dlre_path` | Path to N_m3u8DL-RE binary. | `N_m3u8DL-RE` |
| `--mp4decrypt-path` / `mp4decrypt_path` | Path to mp4decrypt binary. | `mp4decrypt` |
| `--ffmpeg-path` / `ffmpeg_path` | Path to FFmpeg binary. | `ffmpeg` |
| `--mp4box-path` / `mp4box_path` | Path to MP4Box binary. | `MP4Box` |
| `--download-mode` / `download_mode` | Download mode. | `ytdlp` |
| `--remux-mode` / `remux_mode` | Remux mode. | `ffmpeg` |
| `--cover-format` / `cover_format` | Cover format. | `jpg` |
| `--template-folder-album` / `template_folder_album` | Template folder for tracks that are part of an album. | `{album_artist}/{album}` |
| `--template-folder-compilation` / `template_folder_compilation` | Template folder for tracks that are part of a compilation album. | `Compilations/{album}` |
| `--template-file-single-disc` / `template_file_single_disc` | Template file for the tracks that are part of a single-disc album. | `{track:02d} {title}` |
| `--template-file-multi-disc` / `template_file_multi_disc` | Template file for the tracks that are part of a multi-disc album. | `{disc}-{track:02d} {title}` |
| `--template-folder-no-album` / `template_folder_no_album` | Template folder for the tracks that are not part of an album. | `{artist}/Unknown Album` |
| `--template-file-no-album` / `template_file_no_album` | Template file for the tracks that are not part of an album. | `{title}` |
| `--template-file-playlist` / `template_file_playlist` | Template file for the M3U8 playlist. | `Playlists/{playlist_title}` |
| `--template-date` / `template_date` | Date tag template. | `%Y-%m-%dT%H:%M:%SZ` |
| `--exclude-tags` / `exclude_tags` | Comma-separated tags to exclude. | `null` |
| `--cover-size` / `cover_size` | Cover size. | `1200` |
| `--truncate` / `truncate` | Maximum length of the file/folder names. | `null` |
| `--codec-song` / `codec_song` | Song codec. | `aac-legacy` |
| `--synced-lyrics-format` / `synced_lyrics_format` | Synced lyrics format. | `lrc` |
| `--codec-music-video` / `codec_music_video` | Music video codec. | `h264` |
| `--quality-post` / `quality_post` | Post video quality. | `best` |
| `--no-config-file`, `-n` / - | Do not use a config file. | `false` |
### Tags variables
The following variables can be used in the template folders/files and/or in the `exclude_tags` list:
* `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
The following remux modes are available:
* `ffmpeg`
* Can be used without mp4decrypt only for songs and when using legacy song codecs
* `mp4box`
* Requires mp4decrypt
* Doesn't convert closed captions in music videos that have them
* Can be obtained from here: https://gpac.wp.imt.fr/downloads
### Download modes
The following download modes are available:
* `ytdlp`
* `nm3u8dlre`
* Faster than `ytdlp`
* Requires FFmpeg
* Can be obtained from here: https://github.com/nilaoda/N_m3u8DL-RE/releases
### Song codecs
The following codecs are available:
* `aac-legacy`
* `aac-he-legacy`
The following codecs are also available, **but are not guaranteed to work**, as currently most (or all) of the songs fails to be downloaded when using them:
* `aac`
* `aac-he`
* `aac-binaural`
* `aac-downmix`
* `aac-he-binaural`
* `aac-he-downmix`
* `atmos`
* `ac3`
* `alac`
* `ask`
* When using this option, gamdl will ask you which codec from this list to use that is available for the song.
### Music videos codecs
The following codecs are available:
* `h264` (up to 1080p, with AAC 256kbps)
* `h265` (up to 2160p, with AAC 256kpbs)
* `ask`
* When using this option, gamdl will ask you which audio and video codec to use that is available for the music video.
### Post videos/extra videos qualities
The following qualities are available:
* `best` (up to 1080p, with AAC 256kbps)
* `ask`
* When using this option, gamdl will ask you which video quality to use that is available for the video.
Post videos doesn't require remuxing and are limited to `ytdlp` download mode.
### Synced lyrics formats
The following synced lyrics formats are available:
* `lrc`
* `srt`
* `ttml`
* Native format for Apple Music synced lyrics.
* Highly unsupported by most media players.
### Cover formats
The following cover formats are available:
* `jpg`
* `png`
* `raw`
* This format gets the raw cover without any processing.
* Note that when using this format, the cover image will not be embedded within the files. To address this, you can enable `save_cover` option to save the cover as a separate file.
+1 -188
View File
@@ -1,188 +1 @@
import shutil
import argparse
import traceback
from .gamdl import Gamdl
__version__ = '1.8'
def main():
if not shutil.which('mp4decrypt'):
raise Exception('mp4decrypt is not on PATH')
if not shutil.which('MP4Box'):
raise Exception('MP4Box is not on PATH')
parser = argparse.ArgumentParser(
description = 'Download Apple Music songs/music videos/albums/playlists',
formatter_class = argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
'url',
help = 'Apple Music song/music video/album/playlist URL(s)',
nargs = '*',
)
parser.add_argument(
'-u',
'--urls-txt',
help = 'Read URLs from a text file',
nargs = '?',
)
parser.add_argument(
'-w',
'--wvd-location',
default = '*.wvd',
help = '.wvd file location'
)
parser.add_argument(
'-f',
'--final-path',
default = 'Apple Music',
help = 'Final Path',
)
parser.add_argument(
'-t',
'--temp-path',
default = 'temp',
help = 'Temp Path',
)
parser.add_argument(
'-c',
'--cookies-location',
default = 'cookies.txt',
help = 'Cookies location',
)
parser.add_argument(
'-m',
'--disable-music-video-skip',
action = 'store_true',
help = 'Disable music video skip on playlists/albums',
)
parser.add_argument(
'-p',
'--prefer-hevc',
action = 'store_true',
help = 'Prefer HEVC over AVC',
)
parser.add_argument(
'-a',
'--heaac',
action = 'store_true',
help = 'Download songs/music videos with HE-AAC instead of AAC',
)
parser.add_argument(
'-o',
'--overwrite',
action = 'store_true',
help = 'Overwrite existing files',
)
parser.add_argument(
'-n',
'--no-lrc',
action = 'store_true',
help = "Don't create .lrc file",
)
parser.add_argument(
'-s',
'--skip-cleanup',
action = 'store_true',
help = 'Skip cleanup',
)
parser.add_argument(
'-e',
'--print-exceptions',
action = 'store_true',
help = 'Print execeptions',
)
parser.add_argument(
'-i',
'--print-video-m3u8-url',
action = 'store_true',
help = 'Print Video M3U8 URL',
)
parser.add_argument(
'-v',
'--version',
action = 'version',
version = f'%(prog)s {__version__}',
)
args = parser.parse_args()
if not args.url and not args.urls_txt:
parser.error('you must specify an url or a text file using -u/--urls-txt')
if args.urls_txt:
with open(args.urls_txt, 'r', encoding = 'utf8') as f:
args.url = f.read().splitlines()
dl = Gamdl(
args.wvd_location,
args.cookies_location,
args.disable_music_video_skip,
args.prefer_hevc,
args.heaac,
args.temp_path,
args.final_path,
args.no_lrc,
args.overwrite,
args.skip_cleanup,
)
error_count = 0
download_queue = []
for i, url in enumerate(args.url):
try:
download_queue.append(dl.get_download_queue(url.strip()))
except KeyboardInterrupt:
exit(1)
except:
error_count += 1
print(f'Failed to check URL {i + 1}/{len(args.url)}')
if args.print_exceptions:
traceback.print_exc()
for i, url in enumerate(download_queue):
for j, track in enumerate(url):
print(f'Downloading "{track["attributes"]["name"]}" (track {j + 1}/{len(url)} from URL {i + 1}/{len(download_queue)})')
track_id = track['id']
try:
webplayback = dl.get_webplayback(track_id)
if track['type'] == 'music-videos':
if args.print_video_m3u8_url:
print(webplayback['hls-playlist-url'])
tags = dl.get_tags_music_video(track['attributes']['url'].split('/')[-1].split('?')[0])
final_location = dl.get_final_location('.m4v', tags)
if dl.check_exists(final_location) and not args.overwrite:
continue
stream_url_video, stream_url_audio = dl.get_stream_url_music_video(webplayback)
decryption_keys_audio = dl.get_decryption_keys_music_video(stream_url_audio, track_id)
encrypted_location_audio = dl.get_encrypted_location_audio(track_id)
dl.download(encrypted_location_audio, stream_url_audio)
decrypted_location_audio = dl.get_decrypted_location_audio(track_id)
dl.decrypt(encrypted_location_audio, decrypted_location_audio, decryption_keys_audio)
decryption_keys_video = dl.get_decryption_keys_music_video(stream_url_video, track_id)
encrypted_location_video = dl.get_encrypted_location_video(track_id)
dl.download(encrypted_location_video, stream_url_video)
decrypted_location_video = dl.get_decrypted_location_video(track_id)
dl.decrypt(encrypted_location_video, decrypted_location_video, decryption_keys_video)
fixed_location = dl.get_fixed_location(track_id, '.m4v')
dl.fixup_music_video(decrypted_location_audio, decrypted_location_video, fixed_location)
dl.make_final(final_location, fixed_location, tags)
else:
unsynced_lyrics, synced_lyrics = dl.get_lyrics(track_id)
tags = dl.get_tags_song(webplayback, unsynced_lyrics)
final_location = dl.get_final_location('.m4a', tags)
if dl.check_exists(final_location) and not args.overwrite:
continue
stream_url = dl.get_stream_url_song(webplayback)
decryption_keys = dl.get_decryption_keys_song(stream_url, track_id)
encrypted_location = dl.get_encrypted_location_audio(track_id)
dl.download(encrypted_location, stream_url)
decrypted_location = dl.get_decrypted_location_audio(track_id)
dl.decrypt(encrypted_location, decrypted_location, decryption_keys)
fixed_location = dl.get_fixed_location(track_id, '.m4a')
dl.fixup_song(decrypted_location, fixed_location)
dl.make_final(final_location, fixed_location, tags)
dl.make_lrc(final_location, synced_lyrics)
except KeyboardInterrupt:
exit(1)
except:
error_count += 1
print(f'Failed to download "{track["attributes"]["name"]}" (track {j + 1}/{len(url)} from URL {i + 1}/{len(download_queue)})')
if args.print_exceptions:
traceback.print_exc()
dl.cleanup()
print(f'Done ({error_count} error(s))')
__version__ = "2.3"
+2 -3
View File
@@ -1,4 +1,3 @@
import gamdl
from .cli import main
if __name__ == "__main__":
gamdl.main()
main()
+287
View File
@@ -0,0 +1,287 @@
from __future__ import annotations
import functools
import re
import time
import typing
from http.cookiejar import MozillaCookieJar
from pathlib import Path
import requests
class AppleMusicApi:
APPLE_MUSIC_HOMEPAGE_URL = "https://beta.music.apple.com"
AMP_API_URL = "https://amp-api.music.apple.com"
WEBPLAYBACK_API_URL = (
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback"
)
LICENSE_API_URL = "https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/acquireWebPlaybackLicense"
WAIT_TIME = 2
def __init__(
self,
cookies_path: Path | None = Path("./cookies.txt"),
storefront: None | str = None,
language: str = "en-US",
):
self.cookies_path = cookies_path
self.storefront = storefront
self.language = language
self._set_session()
def _set_session(self):
self.session = requests.Session()
if self.cookies_path:
cookies = MozillaCookieJar(self.cookies_path)
cookies.load(ignore_discard=True, ignore_expires=True)
self.session.cookies.update(cookies)
self.storefront = self.session.cookies.get_dict()["itua"]
self.session.headers.update(
{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0",
"Accept": "application/json",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"content-type": "application/json",
"Media-User-Token": self.session.cookies.get_dict().get(
"media-user-token", ""
),
"x-apple-renewal": "true",
"DNT": "1",
"Connection": "keep-alive",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-site",
"origin": self.APPLE_MUSIC_HOMEPAGE_URL,
}
)
home_page = self.session.get(self.APPLE_MUSIC_HOMEPAGE_URL).text
index_js_uri = re.search(
r"/(assets/index-legacy-[^/]+\.js)",
home_page,
).group(1)
index_js_page = self.session.get(
f"{self.APPLE_MUSIC_HOMEPAGE_URL}/{index_js_uri}"
).text
token = re.search('(?=eyJh)(.*?)(?=")', index_js_page).group(1)
self.session.headers.update({"authorization": f"Bearer {token}"})
self.session.params = {"l": self.language}
@staticmethod
def _raise_response_exception(response: requests.Response):
raise Exception(
f"Request failed with status code {response.status_code}: {response.text}"
)
def _check_amp_api_response(self, response: requests.Response):
try:
response.raise_for_status()
response_dict = response.json()
assert response_dict.get("data") or response_dict.get("results") is not None
except (
requests.HTTPError,
requests.exceptions.JSONDecodeError,
AssertionError,
):
self._raise_response_exception(response)
def get_artist(
self,
artist_id: str,
include: str = "albums,music-videos",
limit: int = 100,
fetch_all: bool = True,
) -> dict:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/artists/{artist_id}",
params={
"include": include,
**{f"limit[{_include}]": limit for _include in include.split(",")},
},
)
self._check_amp_api_response(response)
artist = response.json()["data"][0]
if fetch_all:
for _include in include.split(","):
for additional_data in self._extend_api_data(
artist["relationships"][_include],
limit,
):
artist["relationships"][_include]["data"].extend(additional_data)
return artist
def get_song(
self,
song_id: str,
extend: str = "extendedAssetUrls",
include: str = "lyrics,albums",
) -> dict:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/songs/{song_id}",
params={
"include": include,
"extend": extend,
},
)
self._check_amp_api_response(response)
return response.json()["data"][0]
def get_music_video(
self,
music_video_id: str,
include: str = "albums",
) -> dict:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/music-videos/{music_video_id}",
params={
"include": include,
},
)
self._check_amp_api_response(response)
return response.json()["data"][0]
def get_post(
self,
post_id: str,
) -> dict:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/uploaded-videos/{post_id}"
)
self._check_amp_api_response(response)
return response.json()["data"][0]
@functools.lru_cache()
def get_album(
self,
album_id: str,
extend: str = "extendedAssetUrls",
) -> dict:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/albums/{album_id}",
params={
"extend": extend,
},
)
self._check_amp_api_response(response)
return response.json()["data"][0]
def get_playlist(
self,
playlist_id: str,
limit_tracks: int = 300,
extend: str = "extendedAssetUrls",
fetch_all: bool = True,
) -> dict:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/playlists/{playlist_id}",
params={
"extend": extend,
"limit[tracks]": limit_tracks,
},
)
self._check_amp_api_response(response)
playlist = response.json()["data"][0]
if fetch_all:
for additional_data in self._extend_api_data(
playlist["relationships"]["tracks"],
limit_tracks,
):
playlist["relationships"]["tracks"]["data"].extend(additional_data)
return playlist
def search(
self,
term: str,
types: str = "songs,albums,artists,playlists",
limit: int = 25,
offset: int = 0,
) -> dict:
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 _extend_api_data(
self,
api_response: dict,
limit: int,
) -> typing.Generator[list[dict], None, None]:
next_uri = api_response.get("next")
while next_uri:
playlist_next = self._get_next_uri_response(next_uri, limit)
yield playlist_next["data"]
next_uri = playlist_next.get("next")
time.sleep(self.WAIT_TIME)
def _get_next_uri_response(self, next_uri: str, limit: int) -> dict:
response = self.session.get(
self.AMP_API_URL + next_uri,
params={
"limit": limit,
},
)
self._check_amp_api_response(response)
return response.json()
def get_webplayback(
self,
track_id: str,
) -> dict:
response = self.session.post(
self.WEBPLAYBACK_API_URL,
json={
"salableAdamId": track_id,
"language": self.language,
},
)
try:
response.raise_for_status()
response_dict = response.json()
webplayback = response_dict.get("songList")
assert webplayback
except (
requests.HTTPError,
requests.exceptions.JSONDecodeError,
AssertionError,
):
self._raise_response_exception(response)
return webplayback[0]
def get_widevine_license(
self,
track_id: str,
track_uri: str,
challenge: str,
) -> str:
response = self.session.post(
self.LICENSE_API_URL,
json={
"challenge": challenge,
"key-system": "com.widevine.alpha",
"uri": track_uri,
"adamId": track_id,
"isLibrary": False,
"user-initiated": True,
},
)
try:
response.raise_for_status()
response_dict = response.json()
widevine_license = response_dict.get("license")
assert widevine_license
except (
requests.HTTPError,
requests.exceptions.JSONDecodeError,
AssertionError,
):
self._raise_response_exception(response)
return widevine_license
+766
View File
@@ -0,0 +1,766 @@
from __future__ import annotations
import inspect
import json
import logging
from enum import Enum
from pathlib import Path
import click
from . import __version__
from .apple_music_api import AppleMusicApi
from .constants import *
from .downloader import Downloader
from .downloader_music_video import DownloaderMusicVideo
from .downloader_post import DownloaderPost
from .downloader_song import DownloaderSong
from .downloader_song_legacy import DownloaderSongLegacy
from .enums import CoverFormat, DownloadMode, MusicVideoCodec, PostQuality, RemuxMode
from .itunes_api import ItunesApi
apple_music_api_sig = inspect.signature(AppleMusicApi.__init__)
downloader_sig = inspect.signature(Downloader.__init__)
downloader_song_sig = inspect.signature(DownloaderSong.__init__)
downloader_music_video_sig = inspect.signature(DownloaderMusicVideo.__init__)
downloader_post_sig = inspect.signature(DownloaderPost.__init__)
def get_param_string(param: click.Parameter) -> str:
if isinstance(param.default, Enum):
return param.default.value
elif isinstance(param.default, Path):
return str(param.default)
else:
return param.default
def write_default_config_file(ctx: click.Context):
ctx.params["config_path"].parent.mkdir(parents=True, exist_ok=True)
config_file = {
param.name: get_param_string(param)
for param in ctx.command.params
if param.name not in EXCLUDED_CONFIG_FILE_PARAMS
}
ctx.params["config_path"].write_text(json.dumps(config_file, indent=4))
def load_config_file(
ctx: click.Context,
param: click.Parameter,
no_config_file: bool,
) -> click.Context:
if no_config_file:
return ctx
if not ctx.params["config_path"].exists():
write_default_config_file(ctx)
config_file = dict(json.loads(ctx.params["config_path"].read_text()))
for param in ctx.command.params:
if (
config_file.get(param.name) is not None
and not ctx.get_parameter_source(param.name)
== click.core.ParameterSource.COMMANDLINE
):
ctx.params[param.name] = param.type_cast_value(ctx, config_file[param.name])
return ctx
@click.command()
@click.help_option("-h", "--help")
@click.version_option(__version__, "-v", "--version")
# CLI specific options
@click.argument(
"urls",
nargs=-1,
type=str,
required=True,
)
@click.option(
"--disable-music-video-skip",
is_flag=True,
help="Don't skip downloading music videos in albums/playlists.",
)
@click.option(
"--save-cover",
"-s",
is_flag=True,
help="Save cover as a separate file.",
)
@click.option(
"--overwrite",
is_flag=True,
help="Overwrite existing files.",
)
@click.option(
"--read-urls-as-txt",
"-r",
is_flag=True,
help="Interpret URLs as paths to text files containing URLs 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",
is_flag=True,
help="Download only the synced lyrics.",
)
@click.option(
"--no-synced-lyrics",
is_flag=True,
help="Don't download the synced lyrics.",
)
@click.option(
"--config-path",
type=Path,
default=Path.home() / ".gamdl" / "config.json",
help="Path to config file.",
)
@click.option(
"--log-level",
type=str,
default="INFO",
help="Log level.",
)
@click.option(
"--print-exceptions",
is_flag=True,
help="Print exceptions.",
)
# API specific options
@click.option(
"--cookies-path",
"-c",
type=Path,
default=apple_music_api_sig.parameters["cookies_path"].default,
help="Path to .txt cookies file.",
)
@click.option(
"--language",
"-l",
type=str,
default=apple_music_api_sig.parameters["language"].default,
help="Metadata language as an ISO-2A language code (don't always work for videos).",
)
# Downloader specific options
@click.option(
"--output-path",
"-o",
type=Path,
default=downloader_sig.parameters["output_path"].default,
help="Path to output directory.",
)
@click.option(
"--temp-path",
type=Path,
default=downloader_sig.parameters["temp_path"].default,
help="Path to temporary directory.",
)
@click.option(
"--wvd-path",
type=Path,
default=downloader_sig.parameters["wvd_path"].default,
help="Path to .wvd file.",
)
@click.option(
"--nm3u8dlre-path",
type=str,
default=downloader_sig.parameters["nm3u8dlre_path"].default,
help="Path to N_m3u8DL-RE binary.",
)
@click.option(
"--mp4decrypt-path",
type=str,
default=downloader_sig.parameters["mp4decrypt_path"].default,
help="Path to mp4decrypt binary.",
)
@click.option(
"--ffmpeg-path",
type=str,
default=downloader_sig.parameters["ffmpeg_path"].default,
help="Path to FFmpeg binary.",
)
@click.option(
"--mp4box-path",
type=str,
default=downloader_sig.parameters["mp4box_path"].default,
help="Path to MP4Box binary.",
)
@click.option(
"--download-mode",
type=DownloadMode,
default=downloader_sig.parameters["download_mode"].default,
help="Download mode.",
)
@click.option(
"--remux-mode",
type=RemuxMode,
default=downloader_sig.parameters["remux_mode"].default,
help="Remux mode.",
)
@click.option(
"--cover-format",
type=CoverFormat,
default=downloader_sig.parameters["cover_format"].default,
help="Cover format.",
)
@click.option(
"--template-folder-album",
type=str,
default=downloader_sig.parameters["template_folder_album"].default,
help="Template folder for tracks that are part of an album.",
)
@click.option(
"--template-folder-compilation",
type=str,
default=downloader_sig.parameters["template_folder_compilation"].default,
help="Template folder for tracks that are part of a compilation album.",
)
@click.option(
"--template-file-single-disc",
type=str,
default=downloader_sig.parameters["template_file_single_disc"].default,
help="Template file for the tracks that are part of a single-disc album.",
)
@click.option(
"--template-file-multi-disc",
type=str,
default=downloader_sig.parameters["template_file_multi_disc"].default,
help="Template file for the tracks that are part of a multi-disc album.",
)
@click.option(
"--template-folder-no-album",
type=str,
default=downloader_sig.parameters["template_folder_no_album"].default,
help="Template folder for the tracks that are not part of an album.",
)
@click.option(
"--template-file-no-album",
type=str,
default=downloader_sig.parameters["template_file_no_album"].default,
help="Template file for the tracks that are not part of an album.",
)
@click.option(
"--template-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,
default=downloader_sig.parameters["template_date"].default,
help="Date tag template.",
)
@click.option(
"--exclude-tags",
type=str,
default=downloader_sig.parameters["exclude_tags"].default,
help="Comma-separated tags to exclude.",
)
@click.option(
"--cover-size",
type=int,
default=downloader_sig.parameters["cover_size"].default,
help="Cover size.",
)
@click.option(
"--truncate",
type=int,
default=downloader_sig.parameters["truncate"].default,
help="Maximum length of the file/folder names.",
)
# DownloaderSong specific options
@click.option(
"--codec-song",
type=SongCodec,
default=downloader_song_sig.parameters["codec"].default,
help="Song codec.",
)
@click.option(
"--synced-lyrics-format",
type=SyncedLyricsFormat,
default=downloader_song_sig.parameters["synced_lyrics_format"].default,
help="Synced lyrics format.",
)
# DownloaderMusicVideo specific options
@click.option(
"--codec-music-video",
type=MusicVideoCodec,
default=downloader_music_video_sig.parameters["codec"].default,
help="Music video codec.",
)
# DownloaderPost specific options
@click.option(
"--quality-post",
type=PostQuality,
default=downloader_post_sig.parameters["quality"].default,
help="Post video quality.",
)
# This option should always be last
@click.option(
"--no-config-file",
"-n",
is_flag=True,
callback=load_config_file,
help="Do not use a config file.",
)
def main(
urls: list[str],
disable_music_video_skip: bool,
save_cover: bool,
overwrite: bool,
read_urls_as_txt: bool,
save_playlist: bool,
synced_lyrics_only: bool,
no_synced_lyrics: bool,
config_path: Path,
log_level: str,
print_exceptions: bool,
cookies_path: Path,
language: str,
output_path: Path,
temp_path: Path,
wvd_path: Path,
nm3u8dlre_path: str,
mp4decrypt_path: str,
ffmpeg_path: str,
mp4box_path: str,
download_mode: DownloadMode,
remux_mode: RemuxMode,
cover_format: CoverFormat,
template_folder_album: str,
template_folder_compilation: str,
template_file_single_disc: str,
template_file_multi_disc: str,
template_folder_no_album: str,
template_file_no_album: str,
template_file_playlist: str,
template_date: str,
exclude_tags: str,
cover_size: int,
truncate: int,
codec_song: SongCodec,
synced_lyrics_format: SyncedLyricsFormat,
codec_music_video: MusicVideoCodec,
quality_post: PostQuality,
no_config_file: bool,
):
logging.basicConfig(
format="[%(levelname)-8s %(asctime)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger(__name__)
logger.setLevel(log_level)
logger.debug("Starting downloader")
if not cookies_path.exists():
logger.critical(X_NOT_FOUND_STRING.format("Cookies file", cookies_path))
return
apple_music_api = AppleMusicApi(
cookies_path,
language=language,
)
itunes_api = ItunesApi(
apple_music_api.storefront,
apple_music_api.language,
)
downloader = Downloader(
apple_music_api,
itunes_api,
output_path,
temp_path,
wvd_path,
nm3u8dlre_path,
mp4decrypt_path,
ffmpeg_path,
mp4box_path,
download_mode,
remux_mode,
cover_format,
template_folder_album,
template_folder_compilation,
template_file_single_disc,
template_file_multi_disc,
template_folder_no_album,
template_file_no_album,
template_file_playlist,
template_date,
exclude_tags,
cover_size,
truncate,
)
downloader_song = DownloaderSong(
downloader,
codec_song,
synced_lyrics_format,
)
downloader_song_legacy = DownloaderSongLegacy(
downloader,
codec_song,
)
downloader_music_video = DownloaderMusicVideo(
downloader,
codec_music_video,
)
downloader_post = DownloaderPost(
downloader,
quality_post,
)
if not synced_lyrics_only:
if wvd_path and not wvd_path.exists():
logger.critical(X_NOT_FOUND_STRING.format(".wvd file", wvd_path))
return
logger.debug("Setting up CDM")
downloader.set_cdm()
if not downloader.ffmpeg_path_full and (
remux_mode == RemuxMode.FFMPEG or download_mode == DownloadMode.NM3U8DLRE
):
logger.critical(X_NOT_FOUND_STRING.format("ffmpeg", ffmpeg_path))
return
if not downloader.mp4box_path_full and remux_mode == RemuxMode.MP4BOX:
logger.critical(X_NOT_FOUND_STRING.format("MP4Box", mp4box_path))
return
if (
not downloader.mp4decrypt_path_full
and codec_song
not in (
SongCodec.AAC_LEGACY,
SongCodec.AAC_HE_LEGACY,
)
or (remux_mode == RemuxMode.MP4BOX and not downloader.mp4decrypt_path_full)
):
logger.critical(X_NOT_FOUND_STRING.format("mp4decrypt", mp4decrypt_path))
return
if (
download_mode == DownloadMode.NM3U8DLRE
and not downloader.nm3u8dlre_path_full
):
logger.critical(X_NOT_FOUND_STRING.format("N_m3u8DL-RE", nm3u8dlre_path))
return
if not downloader.mp4decrypt_path_full:
logger.warn(
X_NOT_FOUND_STRING.format("mp4decrypt", mp4decrypt_path)
+ ", music videos will not be downloaded"
)
skip_mv = True
else:
skip_mv = False
if codec_song not in LEGACY_CODECS:
logger.warn(
"You have chosen a non-legacy codec. Support for non-legacy codecs are not guaranteed, "
"as most of the songs cannot be downloaded when using non-legacy codecs."
)
error_count = 0
if read_urls_as_txt:
_urls = []
for url in urls:
if Path(url).exists():
_urls.extend(Path(url).read_text(encoding="utf-8").splitlines())
urls = _urls
for url_index, url in enumerate(urls, start=1):
url_progress = f"URL {url_index}/{len(urls)}"
try:
logger.info(f'({url_progress}) Checking "{url}"')
url_info = downloader.get_url_info(url)
download_queue = downloader.get_download_queue(url_info)
download_queue_tracks_metadata = download_queue.tracks_metadata
except Exception as e:
error_count += 1
logger.error(
f'({url_progress}) Failed to check "{url}"',
exc_info=print_exceptions,
)
continue
for download_index, track_metadata in enumerate(
download_queue_tracks_metadata, start=1
):
queue_progress = f"Track {download_index}/{len(download_queue_tracks_metadata)} from URL {url_index}/{len(urls)}"
try:
remuxed_path = None
if download_queue.playlist_attributes:
playlist_track = download_index
else:
playlist_track = None
logger.info(
f'({queue_progress}) Downloading "{track_metadata["attributes"]["name"]}"'
)
if not track_metadata["attributes"].get("playParams"):
logger.warning(
f"({queue_progress}) Track is not streamable, skipping"
)
continue
if (
(synced_lyrics_only and track_metadata["type"] != "songs")
or (track_metadata["type"] == "music-videos" and skip_mv)
or (
track_metadata["type"] == "music-videos"
and url_info.type == "album"
and not disable_music_video_skip
)
):
logger.warning(
f"({queue_progress}) Track is not downloadable with current configuration, skipping"
)
elif track_metadata["type"] == "songs":
logger.debug("Getting lyrics")
lyrics = downloader_song.get_lyrics(track_metadata)
logger.debug("Getting webplayback")
webplayback = apple_music_api.get_webplayback(track_metadata["id"])
tags = downloader_song.get_tags(webplayback, lyrics.unsynced)
if playlist_track:
tags = {
**tags,
**downloader.get_playlist_tags(
download_queue.playlist_attributes,
playlist_track,
),
}
final_path = downloader.get_final_path(tags, ".m4a")
lyrics_synced_path = downloader_song.get_lyrics_synced_path(
final_path
)
cover_url = downloader.get_cover_url(track_metadata)
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
cover_path = downloader_song.get_cover_path(
final_path,
cover_file_extesion,
)
if synced_lyrics_only:
pass
elif final_path.exists() and not overwrite:
logger.warning(
f'({queue_progress}) Song already exists at "{final_path}", skipping'
)
else:
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_metadata["id"]
)
else:
stream_info = downloader_song.get_stream_info(
track_metadata
)
if not stream_info.stream_url or not stream_info.pssh:
logger.warning(
f"({queue_progress}) Song 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_metadata["id"]
)
encrypted_path = downloader_song.get_encrypted_path(
track_metadata["id"]
)
decrypted_path = downloader_song.get_decrypted_path(
track_metadata["id"]
)
remuxed_path = downloader_song.get_remuxed_path(
track_metadata["id"]
)
logger.debug(f'Downloading to "{encrypted_path}"')
downloader.download(encrypted_path, stream_info.stream_url)
if codec_song in LEGACY_CODECS:
logger.debug(
f'Decrypting/Remuxing to "{decrypted_path}"/"{remuxed_path}"'
)
downloader_song_legacy.remux(
encrypted_path,
decrypted_path,
remuxed_path,
decryption_key,
)
else:
logger.debug(f'Decrypting to "{decrypted_path}"')
downloader_song.decrypt(
encrypted_path, decrypted_path, decryption_key
)
logger.debug(f'Remuxing to "{final_path}"')
downloader_song.remux(
decrypted_path,
remuxed_path,
stream_info.codec,
)
if no_synced_lyrics or not lyrics.synced:
pass
elif lyrics_synced_path.exists() and not overwrite:
logger.debug(
f'Synced lyrics already exists at "{lyrics_synced_path}", skipping'
)
else:
logger.debug(f'Saving synced lyrics to "{lyrics_synced_path}"')
downloader_song.save_lyrics_synced(
lyrics_synced_path, lyrics.synced
)
elif track_metadata["type"] == "music-videos":
music_video_id_alt = downloader_music_video.get_music_video_id_alt(
track_metadata
)
logger.debug("Getting iTunes page")
itunes_page = itunes_api.get_itunes_page(
"music-video", music_video_id_alt
)
stream_url_master = downloader_music_video.get_stream_url_master(
itunes_page
)
logger.debug("Getting M3U8 data")
m3u8_master_data = downloader_music_video.get_m3u8_master_data(
stream_url_master
)
tags = downloader_music_video.get_tags(
music_video_id_alt,
itunes_page,
track_metadata,
)
if playlist_track:
tags = {
**tags,
**downloader.get_playlist_tags(
download_queue.playlist_attributes,
playlist_track,
),
}
final_path = downloader.get_final_path(tags, ".m4v")
cover_url = downloader.get_cover_url(track_metadata)
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
cover_path = downloader_music_video.get_cover_path(
final_path,
cover_file_extesion,
)
if final_path.exists() and not overwrite:
logger.warning(
f'({queue_progress}) Music video already exists at "{final_path}", skipping'
)
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_metadata["id"]
)
decryption_key_audio = downloader.get_decryption_key(
stream_info_audio.pssh, track_metadata["id"]
)
encrypted_path_video = (
downloader_music_video.get_encrypted_path_video(
track_metadata["id"]
)
)
encrypted_path_audio = (
downloader_music_video.get_encrypted_path_audio(
track_metadata["id"]
)
)
decrypted_path_video = (
downloader_music_video.get_decrypted_path_video(
track_metadata["id"]
)
)
decrypted_path_audio = (
downloader_music_video.get_decrypted_path_audio(
track_metadata["id"]
)
)
remuxed_path = downloader_music_video.get_remuxed_path(
track_metadata["id"]
)
logger.debug(f'Downloading video to "{encrypted_path_video}"')
downloader.download(
encrypted_path_video, stream_info_video.stream_url
)
logger.debug(f'Downloading audio to "{encrypted_path_audio}"')
downloader.download(
encrypted_path_audio, stream_info_audio.stream_url
)
logger.debug(f'Decrypting video to "{decrypted_path_video}"')
downloader_music_video.decrypt(
encrypted_path_video,
decryption_key_video,
decrypted_path_video,
)
logger.debug(f'Decrypting audio to "{decrypted_path_audio}"')
downloader_music_video.decrypt(
encrypted_path_audio,
decryption_key_audio,
decrypted_path_audio,
)
logger.debug(f'Remuxing to "{remuxed_path}"')
downloader_music_video.remux(
decrypted_path_video,
decrypted_path_audio,
remuxed_path,
stream_info_video.codec,
stream_info_audio.codec,
)
if not save_cover:
pass
elif cover_path.exists() and not overwrite:
logger.debug(
f'Cover already exists at "{cover_path}", skipping'
)
else:
logger.debug(f'Saving cover to "{cover_path}"')
downloader.save_cover(cover_path, cover_url)
elif track_metadata["type"] == "uploaded-videos":
stream_url = downloader_post.get_stream_url(track_metadata)
tags = downloader_post.get_tags(track_metadata)
remuxed_path = downloader_post.get_post_temp_path(
track_metadata["id"]
)
final_path = downloader.get_final_path(tags, ".m4v")
cover_url = downloader.get_cover_url(track_metadata)
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
cover_path = downloader_music_video.get_cover_path(
final_path,
cover_file_extesion,
)
if final_path.exists() and not overwrite:
logger.warning(
f'({queue_progress}) Post video already exists at "{final_path}", skipping'
)
else:
logger.debug(f'Downloading to "{remuxed_path}"')
downloader.download_ytdlp(remuxed_path, stream_url)
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)
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 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)
except Exception as e:
error_count += 1
logger.error(
f'({queue_progress}) Failed to download "{track_metadata["attributes"]["name"]}"',
exc_info=print_exceptions,
)
finally:
if temp_path.exists():
logger.debug(f'Cleaning up "{temp_path}"')
downloader.cleanup_temp_path()
logger.info(f"Done ({error_count} error(s))")
+231
View File
@@ -0,0 +1,231 @@
from gamdl.enums import MusicVideoCodec, SongCodec, SyncedLyricsFormat
STOREFRONT_IDS = {
"AE": "143481-2,32",
"AG": "143540-2,32",
"AI": "143538-2,32",
"AL": "143575-2,32",
"AM": "143524-2,32",
"AO": "143564-2,32",
"AR": "143505-28,32",
"AT": "143445-4,32",
"AU": "143460-27,32",
"AZ": "143568-2,32",
"BB": "143541-2,32",
"BE": "143446-2,32",
"BF": "143578-2,32",
"BG": "143526-2,32",
"BH": "143559-2,32",
"BJ": "143576-2,32",
"BM": "143542-2,32",
"BN": "143560-2,32",
"BO": "143556-28,32",
"BR": "143503-15,32",
"BS": "143539-2,32",
"BT": "143577-2,32",
"BW": "143525-2,32",
"BY": "143565-2,32",
"BZ": "143555-2,32",
"CA": "143455-6,32",
"CG": "143582-2,32",
"CH": "143459-57,32",
"CL": "143483-28,32",
"CN": "143465-19,32",
"CO": "143501-28,32",
"CR": "143495-28,32",
"CV": "143580-2,32",
"CY": "143557-2,32",
"CZ": "143489-2,32",
"DE": "143443-4,32",
"DK": "143458-2,32",
"DM": "143545-2,32",
"DO": "143508-28,32",
"DZ": "143563-2,32",
"EC": "143509-28,32",
"EE": "143518-2,32",
"EG": "143516-2,32",
"ES": "143454-8,32",
"FI": "143447-2,32",
"FJ": "143583-2,32",
"FM": "143591-2,32",
"FR": "143442-3,32",
"GB": "143444-2,32",
"GD": "143546-2,32",
"GH": "143573-2,32",
"GM": "143584-2,32",
"GR": "143448-2,32",
"GT": "143504-28,32",
"GW": "143585-2,32",
"GY": "143553-2,32",
"HK": "143463-45,32",
"HN": "143510-28,32",
"HR": "143494-2,32",
"HU": "143482-2,32",
"ID": "143476-2,32",
"IE": "143449-2,32",
"IL": "143491-2,32",
"IN": "143467-2,32",
"IS": "143558-2,32",
"IT": "143450-7,32",
"JM": "143511-2,32",
"JO": "143528-2,32",
"JP": "143462-9,32",
"KE": "143529-2,32",
"KG": "143586-2,32",
"KH": "143579-2,32",
"KN": "143548-2,32",
"KR": "143466-13,32",
"KW": "143493-2,32",
"KY": "143544-2,32",
"KZ": "143517-2,32",
"LA": "143587-2,32",
"LB": "143497-2,32",
"LC": "143549-2,32",
"LK": "143486-2,32",
"LR": "143588-2,32",
"LT": "143520-2,32",
"LU": "143451-2,32",
"LV": "143519-2,32",
"MD": "143523-2,32",
"MG": "143531-2,32",
"MK": "143530-2,32",
"ML": "143532-2,32",
"MN": "143592-2,32",
"MO": "143515-45,32",
"MR": "143590-2,32",
"MS": "143547-2,32",
"MT": "143521-2,32",
"MU": "143533-2,32",
"MW": "143589-2,32",
"MX": "143468-28,32",
"MY": "143473-2,32",
"MZ": "143593-2,32",
"NA": "143594-2,32",
"NE": "143534-2,32",
"NG": "143561-2,32",
"NI": "143512-28,32",
"NL": "143452-10,32",
"NO": "143457-2,32",
"NP": "143484-2,32",
"NZ": "143461-27,32",
"OM": "143562-2,32",
"PA": "143485-28,32",
"PE": "143507-28,32",
"PG": "143597-2,32",
"PH": "143474-2,32",
"PK": "143477-2,32",
"PL": "143478-2,32",
"PT": "143453-24,32",
"PW": "143595-2,32",
"PY": "143513-28,32",
"QA": "143498-2,32",
"RO": "143487-2,32",
"RU": "143469-16,32",
"SA": "143479-2,32",
"SB": "143601-2,32",
"SC": "143599-2,32",
"SE": "143456-17,32",
"SG": "143464-19,32",
"SI": "143499-2,32",
"SK": "143496-2,32",
"SL": "143600-2,32",
"SN": "143535-2,32",
"SR": "143554-2,32",
"ST": "143598-2,32",
"SV": "143506-28,32",
"SZ": "143602-2,32",
"TC": "143552-2,32",
"TD": "143581-2,32",
"TH": "143475-2,32",
"TJ": "143603-2,32",
"TM": "143604-2,32",
"TN": "143536-2,32",
"TR": "143480-2,32",
"TT": "143551-2,32",
"TW": "143470-18,32",
"TZ": "143572-2,32",
"UA": "143492-2,32",
"UG": "143537-2,32",
"US": "143441-1,32",
"UY": "143514-2,32",
"UZ": "143566-2,32",
"VC": "143550-2,32",
"VE": "143502-28,32",
"VG": "143543-2,32",
"VN": "143471-2,32",
"YE": "143571-2,32",
"ZA": "143472-2,32",
"ZW": "143605-2,32",
}
MP4_TAGS_MAP = {
"album": "\xa9alb",
"album_artist": "aART",
"album_id": "plID",
"album_sort": "soal",
"artist": "\xa9ART",
"artist_id": "atID",
"artist_sort": "soar",
"comment": "\xa9cmt",
"composer": "\xa9wrt",
"composer_id": "cmID",
"composer_sort": "soco",
"copyright": "cprt",
"date": "\xa9day",
"genre": "\xa9gen",
"genre_id": "geID",
"lyrics": "\xa9lyr",
"media_type": "stik",
"rating": "rtng",
"storefront": "sfID",
"title": "\xa9nam",
"title_id": "cnID",
"title_sort": "sonm",
"xid": "xid ",
}
SONG_CODEC_REGEX_MAP = {
SongCodec.AAC: r"audio-stereo-\d+",
SongCodec.AAC_HE: r"audio-HE-stereo-\d+",
SongCodec.AAC_BINAURAL: r"audio-stereo-\d+-binaural",
SongCodec.AAC_DOWNMIX: r"audio-stereo-\d+-downmix",
SongCodec.AAC_HE_BINAURAL: r"audio-HE-stereo-\d+-binaural",
SongCodec.AAC_HE_DOWNMIX: r"audio-HE-stereo-\d+-downmix",
SongCodec.ATMOS: r"audio-atmos-.*",
SongCodec.AC3: r"audio-ac3-.*",
SongCodec.ALAC: r"audio-alac-.*",
}
MUSIC_VIDEO_CODEC_MAP = {
MusicVideoCodec.H264: "avc1",
MusicVideoCodec.H265: "hvc1",
}
SYNCED_LYRICS_FILE_EXTENSION_MAP = {
SyncedLyricsFormat.LRC: ".lrc",
SyncedLyricsFormat.SRT: ".srt",
SyncedLyricsFormat.TTML: ".ttml",
}
IMAGE_FILE_EXTENSION_MAP = {
"jpeg": ".jpg",
"tiff": ".tif",
}
EXCLUDED_CONFIG_FILE_PARAMS = (
"urls",
"config_path",
"read_urls_as_txt",
"no_config_file",
"version",
"help",
)
X_NOT_FOUND_STRING = '{} not found at "{}"'
LEGACY_CODECS = [
SongCodec.AAC_LEGACY,
SongCodec.AAC_HE_LEGACY,
]
+513
View File
@@ -0,0 +1,513 @@
from __future__ import annotations
import base64
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 IMAGE_FILE_EXTENSION_MAP, MP4_TAGS_MAP
from .enums import CoverFormat, DownloadMode, RemuxMode
from .hardcoded_wvd import HARDCODED_WVD
from .itunes_api import ItunesApi
from .models import DownloadQueue, UrlInfo
class Downloader:
ILLEGAL_CHARS_RE = r'[\\/:*?"<>|;]'
ILLEGAL_CHAR_REPLACEMENT = "_"
VALID_URL_RE = r"/([a-z]{2})/(artist|album|playlist|song|music-video|post)/([^/]*)(?:/([^/?]*))?(?:\?i=)?([0-9a-z]*)?"
def __init__(
self,
apple_music_api: AppleMusicApi,
itunes_api: ItunesApi,
output_path: Path = Path("./Apple Music"),
temp_path: Path = Path("./temp"),
wvd_path: Path = None,
nm3u8dlre_path: str = "N_m3u8DL-RE",
mp4decrypt_path: str = "mp4decrypt",
ffmpeg_path: str = "ffmpeg",
mp4box_path: str = "MP4Box",
download_mode: DownloadMode = DownloadMode.YTDLP,
remux_mode: RemuxMode = RemuxMode.FFMPEG,
cover_format: CoverFormat = CoverFormat.JPG,
template_folder_album: str = "{album_artist}/{album}",
template_folder_compilation: str = "Compilations/{album}",
template_file_single_disc: str = "{track:02d} {title}",
template_file_multi_disc: str = "{disc}-{track:02d} {title}",
template_folder_no_album: str = "{artist}/Unknown Album",
template_file_no_album: str = "{title}",
template_file_playlist: str = "Playlists/{playlist_title}",
template_date: str = "%Y-%m-%dT%H:%M:%SZ",
exclude_tags: str = None,
cover_size: int = 1200,
truncate: int = None,
silent: bool = False,
):
self.apple_music_api = apple_music_api
self.itunes_api = itunes_api
self.output_path = output_path
self.temp_path = temp_path
self.wvd_path = wvd_path
self.nm3u8dlre_path = nm3u8dlre_path
self.mp4decrypt_path = mp4decrypt_path
self.ffmpeg_path = ffmpeg_path
self.mp4box_path = mp4box_path
self.download_mode = download_mode
self.remux_mode = remux_mode
self.cover_format = cover_format
self.template_folder_album = template_folder_album
self.template_folder_compilation = template_folder_compilation
self.template_file_single_disc = template_file_single_disc
self.template_file_multi_disc = template_file_multi_disc
self.template_folder_no_album = template_folder_no_album
self.template_file_no_album = template_file_no_album
self.template_file_playlist = template_file_playlist
self.template_date = template_date
self.exclude_tags = exclude_tags
self.cover_size = cover_size
self.truncate = truncate
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)
self.ffmpeg_path_full = shutil.which(self.ffmpeg_path)
self.mp4box_path_full = shutil.which(self.mp4box_path)
self.mp4decrypt_path_full = shutil.which(self.mp4decrypt_path)
def _set_exclude_tags_list(self):
self.exclude_tags_list = (
[i.lower() for i in self.exclude_tags.split(",")]
if self.exclude_tags is not None
else []
)
def _set_truncate(self):
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:
self.cdm = Cdm.from_device(Device.load(self.wvd_path))
else:
self.cdm = Cdm.from_device(Device.loads(HARDCODED_WVD))
def get_url_info(self, url: str) -> UrlInfo:
url_info = UrlInfo()
url_regex_result = re.search(
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)
)
return url_info
def get_download_queue(self, url_info: UrlInfo) -> DownloadQueue:
return self._get_download_queue(url_info.type, url_info.id)
def _get_download_queue(self, url_type: str, id: str) -> DownloadQueue:
download_queue = DownloadQueue()
if url_type == "artist":
artist = self.apple_music_api.get_artist(id)
download_queue.tracks_metadata = list(
self.get_download_queue_from_artist(artist)
)
elif url_type == "song":
download_queue.tracks_metadata = [self.apple_music_api.get_song(id)]
elif url_type == "album":
album = self.apple_music_api.get_album(id)
download_queue.tracks_metadata = [
track for track in album["relationships"]["tracks"]["data"]
]
elif url_type == "playlist":
playlist = self.apple_music_api.get_playlist(id)
download_queue.playlist_attributes = playlist["attributes"]
download_queue.tracks_metadata = [
track
for track in self.apple_music_api.get_playlist(id)["relationships"][
"tracks"
]["data"]
]
elif url_type == "music-video":
download_queue.tracks_metadata = [self.apple_music_api.get_music_video(id)]
elif url_type == "post":
download_queue.tracks_metadata = [self.apple_music_api.get_post(id)]
return download_queue
def get_download_queue_from_artist(
self,
artist: dict,
) -> typing.Generator[dict, None, None]:
media_type = inquirer.select(
message=f'Select which type to download for artist "{artist["attributes"]["name"]}":',
choices=[
Choice(name="Albums", value="albums"),
Choice(
name="Music Videos",
value="music-videos",
),
],
validate=lambda result: artist["relationships"].get(result, {}).get("data"),
invalid_message="The artist doesn't have any items of this type",
).execute()
if media_type == "albums":
yield from self.select_albums_from_artist(
artist["relationships"]["albums"]["data"]
)
elif media_type == "music-videos":
yield from self.select_music_videos_from_artist(
artist["relationships"]["music-videos"]["data"]
)
def select_albums_from_artist(
self,
albums: list[dict],
) -> typing.Generator[dict, None, None]:
choices = [
Choice(
name=" | ".join(
[
f'{album["attributes"]["trackCount"]:03d}',
f'{album["attributes"]["releaseDate"]:<10}',
f'{album["attributes"].get("contentRating", "None").title():<8}',
f'{album["attributes"]["name"]}',
]
),
value=album,
)
for album in albums
]
selected = inquirer.select(
message="Select which albums to download: (Track Count | Release Date | Rating | Title)",
choices=choices,
multiselect=True,
).execute()
for album in selected:
for track in self.apple_music_api.get_album(album["id"])["relationships"][
"tracks"
]["data"]:
yield track
def select_music_videos_from_artist(
self,
music_videos: list[dict],
) -> typing.Generator[dict, None, None]:
choices = [
Choice(
name=" | ".join(
[
self.millis_to_min_sec(
music_video["attributes"]["durationInMillis"]
),
f'{music_video["attributes"].get("contentRating", "None").title():<8}',
music_video["attributes"]["name"],
],
),
value=music_video,
)
for music_video in music_videos
]
selected = inquirer.select(
message="Select which music videos to download: (Duration | Rating | Title)",
choices=choices,
multiselect=True,
).execute()
for music_video in selected:
yield music_video
def get_playlist_tags(
self,
playlist_attributes: dict,
playlist_track: int,
) -> dict:
tags = {
"playlist_artist": playlist_attributes["curatorName"],
"playlist_id": playlist_attributes["playParams"]["id"],
"playlist_title": playlist_attributes["name"],
"playlist_track": playlist_track,
}
return tags
def get_playlist_file_path(
self,
tags: dict,
):
template_folder = self.template_file_playlist.split("/")[0:-1]
template_file = self.template_file_playlist.split("/")[-1]
return self.output_path.joinpath(
*[
self.get_sanitized_string(i.format(**tags), True)
for i in template_folder
]
).joinpath(
*[self.get_sanitized_string(template_file.format(**tags), False) + ".m3u8"]
)
def update_playlist_file(
self,
playlist_file_path: Path,
final_path: Path,
):
playlist_file_path.parent.mkdir(parents=True, exist_ok=True)
with playlist_file_path.open("a", encoding="utf8") as playlist_file:
playlist_file.write(
final_path.relative_to(
playlist_file_path.parent, walk_up=True
).as_posix()
+ "\n"
)
@staticmethod
def millis_to_min_sec(millis):
minutes, seconds = divmod(millis // 1000, 60)
return f"{minutes:02d}:{seconds:02d}"
def sanitize_date(self, date: str):
datetime_obj = ciso8601.parse_datetime(date)
return datetime_obj.strftime(self.template_date)
def get_decryption_key(self, pssh: str, track_id: str) -> str:
try:
pssh_obj = PSSH(pssh.split(",")[-1])
cdm_session = self.cdm.open()
challenge = base64.b64encode(
self.cdm.get_license_challenge(cdm_session, pssh_obj)
).decode()
license = self.apple_music_api.get_widevine_license(
track_id,
pssh,
challenge,
)
self.cdm.parse_license(cdm_session, license)
decryption_key = next(
i for i in self.cdm.get_keys(cdm_session) if i.type == "CONTENT"
).key.hex()
finally:
self.cdm.close(cdm_session)
return decryption_key
def download(self, path: Path, stream_url: str):
if self.download_mode == DownloadMode.YTDLP:
self.download_ytdlp(path, stream_url)
elif self.download_mode == DownloadMode.NM3U8DLRE:
self.download_nm3u8dlre(path, stream_url)
def download_ytdlp(self, path: Path, stream_url: str):
with YoutubeDL(
{
"quiet": True,
"no_warnings": True,
"outtmpl": str(path),
"allow_unplayable_formats": True,
"fixup": "never",
"allowed_extractors": ["generic"],
"noprogress": self.silent,
}
) as ydl:
ydl.download(stream_url)
def download_nm3u8dlre(self, path: Path, stream_url: str):
path.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
[
self.nm3u8dlre_path_full,
stream_url,
"--binary-merge",
"--no-log",
"--log-level",
"off",
"--ffmpeg-binary-path",
self.ffmpeg_path_full,
"--save-name",
path.stem,
"--save-dir",
path.parent,
"--tmp-dir",
path.parent,
],
check=True,
**self.subprocess_additional_args,
)
def get_sanitized_string(self, dirty_string: str, is_folder: bool) -> str:
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] + 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_path(self, tags: dict, file_extension: str) -> Path:
if tags.get("album"):
template_folder = (
self.template_folder_compilation.split("/")
if tags.get("compilation")
else self.template_folder_album.split("/")
)[0:-1]
template_file = (
self.template_file_multi_disc.split("/")
if tags["disc_total"] > 1
else self.template_file_single_disc.split("/")
)[-1]
else:
template_folder = self.template_folder_no_album.split("/")[0:-1]
template_file = self.template_file_no_album.split("/")[-1]
return self.output_path.joinpath(
*[
self.get_sanitized_string(i.format(**tags), True)
for i in template_folder
]
).joinpath(
self.get_sanitized_string(template_file.format(**tags), False)
+ file_extension
)
def get_cover_file_extension(self, cover_url: str) -> str:
image_obj = Image.open(io.BytesIO(self.get_url_response_bytes(cover_url)))
image_format = image_obj.format.lower()
return IMAGE_FILE_EXTENSION_MAP.get(image_format, f".{image_format}")
def get_cover_url(self, metadata: dict) -> str:
if self.cover_format == CoverFormat.RAW:
return self._get_raw_cover_url(metadata["attributes"]["artwork"]["url"])
return self._get_cover_url(metadata["attributes"]["artwork"]["url"])
def _get_raw_cover_url(self, cover_url_template: str) -> str:
return re.sub(
r"image/thumb/",
"",
re.sub(
r"is1-ssl",
"a1",
re.sub(
r"/\{w\}x\{h\}([a-z]{2})\.jpg",
"",
cover_url_template,
),
),
)
def _get_cover_url(self, cover_url_template: str) -> str:
return re.sub(
r"\{w\}x\{h\}([a-z]{2})\.jpg",
f"{self.cover_size}x{self.cover_size}bb.{self.cover_format.value}",
cover_url_template,
)
@staticmethod
@functools.lru_cache()
def get_url_response_bytes(url: str) -> bytes:
response = requests.get(url)
response.raise_for_status()
return response.content
def apply_tags(
self,
path: Path,
tags: dict,
cover_url: str,
):
to_apply_tags = [
tag_name
for tag_name in tags.keys()
if tag_name not in self.exclude_tags_list
]
mp4_tags = {}
for tag_name in to_apply_tags:
if tag_name in ("disc", "disc_total"):
if mp4_tags.get("disk") is None:
mp4_tags["disk"] = [[0, 0]]
if tag_name == "disc":
mp4_tags["disk"][0][0] = tags[tag_name]
elif tag_name == "disc_total":
mp4_tags["disk"][0][1] = tags[tag_name]
elif tag_name in ("track", "track_total"):
if mp4_tags.get("trkn") is None:
mp4_tags["trkn"] = [[0, 0]]
if tag_name == "track":
mp4_tags["trkn"][0][0] = tags[tag_name]
elif tag_name == "track_total":
mp4_tags["trkn"][0][1] = tags[tag_name]
elif tag_name == "compilation":
mp4_tags["cpil"] = tags["compilation"]
elif tag_name == "gapless":
mp4_tags["pgap"] = tags["gapless"]
elif (
MP4_TAGS_MAP.get(tag_name) is not None
and tags.get(tag_name) is not None
):
mp4_tags[MP4_TAGS_MAP[tag_name]] = [tags[tag_name]]
if (
"cover" not in self.exclude_tags_list
and self.cover_format != CoverFormat.RAW
):
mp4_tags["covr"] = [
MP4Cover(
self.get_url_response_bytes(cover_url),
imageformat=(
MP4Cover.FORMAT_JPEG
if self.cover_format == CoverFormat.JPG
else MP4Cover.FORMAT_PNG
),
)
]
mp4 = MP4(path)
mp4.clear()
mp4.update(mp4_tags)
mp4.save()
def move_to_output_path(
self,
remuxed_path: Path,
final_path: Path,
):
final_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(remuxed_path, final_path)
@functools.lru_cache()
def save_cover(self, cover_path: Path, cover_url: str):
cover_path.write_bytes(self.get_url_response_bytes(cover_url))
def cleanup_temp_path(self):
shutil.rmtree(self.temp_path)
+304
View File
@@ -0,0 +1,304 @@
from __future__ import annotations
import subprocess
import urllib.parse
from pathlib import Path
import m3u8
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from .constants import MUSIC_VIDEO_CODEC_MAP
from .downloader import Downloader
from .enums import MusicVideoCodec, RemuxMode
from .models import StreamInfo
class DownloaderMusicVideo:
MP4_FORMAT_CODECS = ["hvc1", "audio-atmos", "audio-ec3"]
def __init__(
self,
downloader: Downloader,
codec: MusicVideoCodec = MusicVideoCodec.H264,
):
self.downloader = downloader
self.codec = codec
def get_stream_url_master(self, itunes_page: dict) -> str:
return itunes_page["offers"][0]["assets"][0]["hlsUrl"]
def get_m3u8_master_data(self, stream_url_master: str) -> dict:
url_parts = urllib.parse.urlparse(stream_url_master)
query = urllib.parse.parse_qs(url_parts.query, keep_blank_values=True)
query.update({"aec": "HD", "dsid": "1"})
stream_url_master_new = url_parts._replace(
query=urllib.parse.urlencode(query, doseq=True)
).geturl()
return m3u8.load(stream_url_master_new).data
def get_playlist_video(
self,
playlists: list[dict],
) -> dict:
playlists_filtered = [
playlist
for playlist in playlists
if playlist["stream_info"]["codecs"].startswith(
MUSIC_VIDEO_CODEC_MAP[self.codec]
)
]
if not playlists_filtered:
playlists_filtered = [
playlist
for playlist in playlists
if playlist["stream_info"]["codecs"].startswith(
MUSIC_VIDEO_CODEC_MAP[MusicVideoCodec.H264]
)
]
playlists_filtered.sort(key=lambda x: x["stream_info"]["bandwidth"])
return playlists_filtered[-1]
def get_playlist_video_from_user(
self,
playlists: list[dict],
) -> dict:
choices = [
Choice(
name=" | ".join(
[
playlist["stream_info"]["codecs"][:4],
playlist["stream_info"]["resolution"],
str(playlist["stream_info"]["bandwidth"]),
]
),
value=playlist,
)
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,
playlists: list[dict],
) -> dict:
stream_url = next(
(
playlist
for playlist in playlists
if playlist["group_id"] == "audio-stereo-256"
),
None,
)
return stream_url
def get_playlist_audio_from_user(
self,
playlists: list[dict],
) -> dict:
choices = [
Choice(
name=playlist["group_id"],
value=playlist,
)
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(
(
key
for key in m3u8_data["keys"]
if key["keyformat"] == "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
),
None,
)["uri"]
def get_stream_info_video(self, m3u8_master_data: dict) -> StreamInfo:
stream_info = StreamInfo()
if self.codec != MusicVideoCodec.ASK:
playlist = self.get_playlist_video(m3u8_master_data["playlists"])
else:
playlist = self.get_playlist_video_from_user(m3u8_master_data["playlists"])
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)
return stream_info
def get_stream_info_audio(self, m3u8_master_data: dict) -> StreamInfo:
stream_info = StreamInfo()
if self.codec != MusicVideoCodec.ASK:
playlist = self.get_playlist_audio(m3u8_master_data["media"])
else:
playlist = self.get_playlist_audio_from_user(m3u8_master_data["media"])
stream_info.stream_url = playlist["uri"]
stream_info.codec = playlist["group_id"]
m3u8_data = m3u8.load(stream_info.stream_url).data
stream_info.pssh = self.get_pssh(m3u8_data)
return stream_info
def get_music_video_id_alt(self, metadata: dict) -> str:
return metadata["attributes"]["url"].split("/")[-1].split("?")[0]
def get_tags(
self,
id_alt: str,
itunes_page: dict,
metadata: dict,
):
metadata_itunes = self.downloader.itunes_api.get_resource(id_alt)
tags = {
"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,
"storefront": int(self.downloader.itunes_api.storefront_id.split("-")[0]),
"title": metadata_itunes[0]["trackCensoredName"],
"title_id": int(metadata["id"]),
}
if metadata_itunes[0]["trackExplicitness"] == "notExplicit":
tags["rating"] = 0
elif metadata_itunes[0]["trackExplicitness"] == "explicit":
tags["rating"] = 1
else:
tags["rating"] = 2
if len(metadata_itunes) > 1:
album = self.downloader.apple_music_api.get_album(
itunes_page["collectionId"]
)
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"]
tags["compilation"] = album["attributes"]["isCompilation"]
tags["track"] = metadata_itunes[0]["trackNumber"]
tags["track_total"] = metadata_itunes[0]["trackCount"]
return tags
def get_encrypted_path_video(self, track_id: str) -> str:
return self.downloader.temp_path / f"encrypted_{track_id}.mp4"
def get_encrypted_path_audio(self, track_id: str) -> str:
return self.downloader.temp_path / f"encrypted_{track_id}.m4a"
def get_decrypted_path_video(self, track_id: str) -> str:
return self.downloader.temp_path / f"decrypted_{track_id}.mp4"
def get_decrypted_path_audio(self, track_id: str) -> str:
return self.downloader.temp_path / f"decrypted_{track_id}.m4a"
def get_remuxed_path(self, track_id: str) -> str:
return self.downloader.temp_path / f"remuxed_{track_id}.m4v"
def decrypt(self, encrypted_path: Path, decryption_key: str, decrypted_path: Path):
subprocess.run(
[
self.downloader.mp4decrypt_path_full,
encrypted_path,
"--key",
f"1:{decryption_key}",
decrypted_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_mp4box(
self,
decrypted_path_audio: Path,
decrypted_path_video: Path,
fixed_path: Path,
):
subprocess.run(
[
self.downloader.mp4box_path_full,
"-quiet",
"-add",
decrypted_path_audio,
"-add",
decrypted_path_video,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
fixed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_ffmpeg(
self,
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,
"-loglevel",
"error",
"-y",
"-i",
decrypted_path_video,
"-i",
decrypte_path_audio,
"-movflags",
"+faststart",
"-f",
"mp4" if use_mp4_flag else "ipod",
"-c",
"copy",
"-c:s",
"mov_text",
fixed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux(
self,
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(
decrypted_path_audio,
decrypted_path_video,
remuxed_path,
)
elif self.downloader.remux_mode == RemuxMode.FFMPEG:
self.remux_ffmpeg(
decrypted_path_video,
decrypted_path_audio,
remuxed_path,
codec_video,
codec_audio,
)
def get_cover_path(self, final_path: Path, file_extension: str) -> Path:
return final_path.with_suffix(file_extension)
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from pathlib import Path
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from .downloader import Downloader
from .enums import PostQuality
class DownloaderPost:
QUALITY_RANK = [
"1080pHdVideo",
"720pHdVideo",
"sdVideoWithPlusAudio",
"sdVideo",
"sd480pVideo",
"provisionalUploadVideo",
]
def __init__(
self,
downloader: Downloader,
quality: PostQuality = PostQuality.BEST,
):
self.downloader = downloader
self.quality = quality
def get_stream_url_best(self, metadata: dict) -> str:
best_quality = next(
(
quality
for quality in self.QUALITY_RANK
if metadata["attributes"]["assetTokens"].get(quality)
),
None,
)
return metadata["attributes"]["assetTokens"][best_quality]
def get_stream_url_from_user(self, metadata: dict) -> str:
qualities = list(metadata["attributes"]["assetTokens"].keys())
choices = [
Choice(
name=quality,
value=quality,
)
for quality in qualities
]
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:
stream_url = self.get_stream_url_best(metadata)
elif self.quality == PostQuality.ASK:
stream_url = self.get_stream_url_from_user(metadata)
return stream_url
def get_tags(self, metadata: dict) -> list:
attributes = metadata["attributes"]
return {
"artist": attributes["artistName"],
"date": 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_post_temp_path(self, track_id: str) -> Path:
return self.downloader.temp_path / f"{track_id}_temp.m4v"
+373
View File
@@ -0,0 +1,373 @@
from __future__ import annotations
import base64
import datetime
import json
import re
import subprocess
from pathlib import Path
from xml.dom import minidom
from xml.etree import ElementTree
import m3u8
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
class DownloaderSong:
DEFAULT_DECRYPTION_KEY = "32b8ade1769e26b1ffb8986352793fc6"
MP4_FORMAT_CODECS = ["ec-3"]
def __init__(
self,
downloader: Downloader,
codec: SongCodec = SongCodec.AAC_LEGACY,
synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC,
):
self.downloader = downloader
self.codec = codec
self.synced_lyrics_format = synced_lyrics_format
def get_drm_infos(self, m3u8_data: dict) -> dict:
drm_info_raw = next(
(
session_data
for session_data in m3u8_data["session_data"]
if session_data["data_id"] == "com.apple.hls.AudioSessionKeyInfo"
),
None,
)
if not drm_info_raw:
return None
return json.loads(base64.b64decode(drm_info_raw["value"]).decode("utf-8"))
def get_asset_infos(self, m3u8_data: dict) -> dict:
return json.loads(
base64.b64decode(
next(
session_data
for session_data in m3u8_data["session_data"]
if session_data["data_id"] == "com.apple.hls.audioAssetMetadata"
)["value"]
).decode("utf-8")
)
def get_playlist_from_codec(self, m3u8_data: dict) -> dict | None:
m3u8_master_playlists = [
playlist
for playlist in m3u8_data["playlists"]
if re.fullmatch(
SONG_CODEC_REGEX_MAP[self.codec], playlist["stream_info"]["audio"]
)
]
if not m3u8_master_playlists:
return None
m3u8_master_playlists.sort(key=lambda x: x["stream_info"]["average_bandwidth"])
return m3u8_master_playlists[-1]
def get_playlist_from_user(self, m3u8_data: dict) -> dict | None:
m3u8_master_playlists = [playlist for playlist in m3u8_data["playlists"]]
choices = [
Choice(
name=playlist["stream_info"]["audio"],
value=playlist,
)
for playlist in m3u8_master_playlists
]
selected = inquirer.select(
message="Select which codec to download:",
choices=choices,
).execute()
return selected
def get_pssh(
self,
drm_infos: dict,
drm_ids: list,
) -> str | None:
drm_info = next(
(
drm_infos[drm_id]
for drm_id in drm_ids
if drm_infos[drm_id].get(
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
)
and drm_id != "1"
),
None,
)
if not drm_info:
return None
return drm_info["urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"]["URI"]
def get_stream_info(self, track_metadata: dict) -> StreamInfo:
m3u8_url = track_metadata["attributes"]["extendedAssetUrls"].get("enhancedHls")
if not m3u8_url:
return StreamInfo()
return self._get_stream_info(m3u8_url)
def _get_stream_info(self, m3u8_url: str) -> StreamInfo:
stream_info = StreamInfo()
m3u8_obj = m3u8.load(m3u8_url)
m3u8_data = m3u8_obj.data
drm_infos = self.get_drm_infos(m3u8_data)
if not drm_infos:
return stream_info
asset_infos = self.get_asset_infos(m3u8_data)
if self.codec == SongCodec.ASK:
playlist = self.get_playlist_from_user(m3u8_data)
else:
playlist = self.get_playlist_from_codec(m3u8_data)
if playlist is None:
return stream_info
stream_info.stream_url = m3u8_obj.base_uri + playlist["uri"]
variant_id = playlist["stream_info"]["stable_variant_id"]
drm_ids = asset_infos[variant_id]["AUDIO-SESSION-KEY-IDS"]
pssh = self.get_pssh(drm_infos, drm_ids)
stream_info.pssh = pssh
stream_info.codec = playlist["stream_info"]["codecs"]
return stream_info
@staticmethod
def parse_datetime_obj_from_timestamp_ttml(
timestamp_ttml: str,
) -> datetime.datetime:
mins_secs_ms = re.findall(r"\d+", timestamp_ttml)
ms, secs, mins = 0, 0, 0
if len(mins_secs_ms) == 2 and ":" in timestamp_ttml:
secs, mins = int(mins_secs_ms[-1]), int(mins_secs_ms[-2])
elif len(mins_secs_ms) == 1:
ms = int(mins_secs_ms[-1])
else:
secs = float(f"{mins_secs_ms[-2]}.{mins_secs_ms[-1]}")
if len(mins_secs_ms) > 2:
mins = int(mins_secs_ms[-3])
return datetime.datetime.fromtimestamp((mins * 60) + secs + (ms / 1000))
def get_lyrics_synced_timestamp_lrc(self, timestamp_ttml: str) -> str:
datetime_obj = self.parse_datetime_obj_from_timestamp_ttml(timestamp_ttml)
ms_new = datetime_obj.strftime("%f")[:-3]
if int(ms_new[-1]) >= 5:
ms = int(f"{int(ms_new[:2]) + 1}") * 10
datetime_obj += datetime.timedelta(milliseconds=ms) - datetime.timedelta(
microseconds=datetime_obj.microsecond
)
return datetime_obj.strftime("%M:%S.%f")[:-4]
def get_lyrics_synced_timestamp_srt(self, timestamp_ttml: str) -> str:
datetime_obj = self.parse_datetime_obj_from_timestamp_ttml(timestamp_ttml)
return datetime_obj.strftime("00:%M:%S,%f")[:-3]
def get_lyrics_synced_line_lrc(self, timestamp_ttml: str, text: str) -> str:
return f"[{self.get_lyrics_synced_timestamp_lrc(timestamp_ttml)}]{text}"
def get_lyrics_synced_line_srt(
self,
index: int,
timestamp_ttml_start: str,
timestamp_ttml_end: str,
text: str,
) -> str:
timestamp_srt_start = self.get_lyrics_synced_timestamp_srt(timestamp_ttml_start)
timestamp_srt_end = self.get_lyrics_synced_timestamp_srt(timestamp_ttml_end)
return f"{index}\n{timestamp_srt_start} --> {timestamp_srt_end}\n{text}\n"
def get_lyrics(self, track_metadata: dict) -> Lyrics:
lyrics = Lyrics()
if not track_metadata["attributes"]["hasLyrics"]:
return lyrics
elif track_metadata.get("relationships") is None:
track_metadata = self.downloader.apple_music_api.get_song(
track_metadata["id"]
)
if (
track_metadata["relationships"].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"
]
)
return lyrics
def _get_lyrics(self, lyrics_ttml: str) -> Lyrics:
lyrics = Lyrics("", "")
lyrics_ttml_et = ElementTree.fromstring(lyrics_ttml)
index = 1
for div in lyrics_ttml_et.iter("{http://www.w3.org/ns/ttml}div"):
for p in div.iter("{http://www.w3.org/ns/ttml}p"):
if p.text is not None:
lyrics.unsynced += p.text + "\n"
if p.attrib.get("begin"):
if self.synced_lyrics_format == SyncedLyricsFormat.LRC:
lyrics.synced += f"{self.get_lyrics_synced_line_lrc(p.attrib.get('begin'), p.text)}"
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)}"
elif self.synced_lyrics_format == SyncedLyricsFormat.TTML:
if not lyrics.synced:
lyrics.synced = minidom.parseString(
lyrics_ttml
).toprettyxml()
continue
lyrics.synced += "\n"
index += 1
lyrics.unsynced += "\n"
lyrics.unsynced = lyrics.unsynced[:-2]
return lyrics
def get_tags(self, webplayback: dict, lyrics_unsynced: str) -> dict:
tags_raw = webplayback["assets"][0]["metadata"]
tags = {
"album": tags_raw["playlistName"],
"album_artist": tags_raw["playlistArtistName"],
"album_id": int(tags_raw["playlistId"]),
"album_sort": tags_raw["sort-album"],
"artist": tags_raw["artistName"],
"artist_id": int(tags_raw["artistId"]),
"artist_sort": tags_raw["sort-artist"],
"comments": tags_raw.get("comments"),
"compilation": tags_raw["compilation"],
"composer": tags_raw.get("composerName"),
"composer_id": (
int(tags_raw.get("composerId")) if tags_raw.get("composerId") else None
),
"composer_sort": tags_raw.get("sort-composer"),
"copyright": tags_raw.get("copyright"),
"date": (
self.downloader.sanitize_date(tags_raw["releaseDate"])
if tags_raw.get("releaseDate")
else None
),
"disc": tags_raw["discNumber"],
"disc_total": tags_raw["discCount"],
"gapless": tags_raw["gapless"],
"genre": tags_raw["genre"],
"genre_id": tags_raw["genreId"],
"lyrics": lyrics_unsynced if lyrics_unsynced else None,
"media_type": 1,
"rating": tags_raw["explicit"],
"storefront": tags_raw["s"],
"title": tags_raw["itemName"],
"title_id": int(tags_raw["itemId"]),
"title_sort": tags_raw["sort-name"],
"track": tags_raw["trackNumber"],
"track_total": tags_raw["trackCount"],
"xid": tags_raw.get("xid"),
}
return tags
def get_encrypted_path(self, track_id: str) -> Path:
return self.downloader.temp_path / f"{track_id}_encrypted.m4a"
def get_decrypted_path(self, track_id: str) -> Path:
return self.downloader.temp_path / f"{track_id}_decrypted.m4a"
def get_remuxed_path(self, track_id: str) -> Path:
return self.downloader.temp_path / f"{track_id}_remuxed.m4a"
def fix_key_id(self, encrypted_path: Path):
count = 0
with open(encrypted_path, "rb+") as file:
while data := file.read(4096):
pos = file.tell()
i = 0
while tenc := max(0, data.find(b"tenc", i)):
kid = tenc + 12
file.seek(max(0, pos - 4096) + kid, 0)
file.write(bytes.fromhex(f"{count:032}"))
count += 1
i = kid + 1
file.seek(pos, 0)
def decrypt(
self,
encrypted_path: Path,
decrypted_path: Path,
decryption_key: str,
):
self.fix_key_id(encrypted_path)
subprocess.run(
[
self.downloader.mp4decrypt_path_full,
encrypted_path,
"--key",
f"00000000000000000000000000000001:{decryption_key}",
"--key",
f"00000000000000000000000000000000:{self.DEFAULT_DECRYPTION_KEY}",
decrypted_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux(self, decrypted_path: Path, remuxed_path: Path, codec: str):
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)
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path):
subprocess.run(
[
self.downloader.mp4box_path_full,
"-quiet",
"-add",
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,
"-loglevel",
"error",
"-y",
"-i",
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:
return final_path.with_suffix(
SYNCED_LYRICS_FILE_EXTENSION_MAP[self.synced_lyrics_format]
)
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)
lyrics_synced_path.write_text(lyrics_synced, encoding="utf8")
+127
View File
@@ -0,0 +1,127 @@
from __future__ import annotations
import base64
import subprocess
from pathlib import Path
import m3u8
from pywidevine import PSSH
from pywidevine.license_protocol_pb2 import WidevinePsshData
from .downloader_song import DownloaderSong
from .enums import RemuxMode, SongCodec
from .models import StreamInfo
class DownloaderSongLegacy(DownloaderSong):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def get_stream_info(self, webplayback: dict) -> StreamInfo:
flavor = "32:ctrp64" if self.codec == SongCodec.AAC_HE_LEGACY else "28:ctrp256"
stream_info = StreamInfo()
stream_info.stream_url = next(
i for i in webplayback["assets"] if i["flavor"] == flavor
)["URL"]
m3u8_obj = m3u8.load(stream_info.stream_url)
stream_info.pssh = m3u8_obj.keys[0].uri
return stream_info
def get_decryption_key(self, pssh: str, track_id: str) -> str:
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(
self,
encrypted_path: Path,
decrypted_path: Path,
decryption_key: str,
):
subprocess.run(
[
self.downloader.mp4decrypt_path_full,
encrypted_path,
"--key",
f"1:{decryption_key}",
decrypted_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path):
subprocess.run(
[
self.downloader.mp4box_path_full,
"-quiet",
"-add",
decrypted_path,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
remuxed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_ffmpeg(
self,
decryption_key: str,
encrypted_path: Path,
remuxed_path: Path,
):
subprocess.run(
[
self.downloader.ffmpeg_path_full,
"-loglevel",
"error",
"-y",
"-decryption_key",
decryption_key,
"-i",
encrypted_path,
"-c",
"copy",
"-movflags",
"+faststart",
remuxed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux(
self,
encrypted_path: Path,
decrypted_path: Path,
remuxed_path: Path,
decryption_key: str,
):
if self.downloader.remux_mode == RemuxMode.FFMPEG:
self.remux_ffmpeg(decryption_key, encrypted_path, remuxed_path)
elif self.downloader.remux_mode == RemuxMode.MP4BOX:
self.decrypt(encrypted_path, decrypted_path, decryption_key)
self.remux_mp4box(decrypted_path, remuxed_path)
+49
View File
@@ -0,0 +1,49 @@
from enum import Enum
class DownloadMode(Enum):
YTDLP = "ytdlp"
NM3U8DLRE = "nm3u8dlre"
class RemuxMode(Enum):
FFMPEG = "ffmpeg"
MP4BOX = "mp4box"
class SongCodec(Enum):
AAC_LEGACY = "aac-legacy"
AAC_HE_LEGACY = "aac-he-legacy"
AAC = "aac"
AAC_HE = "aac-he"
AAC_BINAURAL = "aac-binaural"
AAC_DOWNMIX = "aac-downmix"
AAC_HE_BINAURAL = "aac-he-binaural"
AAC_HE_DOWNMIX = "aac-he-downmix"
ATMOS = "atmos"
AC3 = "ac3"
ALAC = "alac"
ASK = "ask"
class SyncedLyricsFormat(Enum):
LRC = "lrc"
SRT = "srt"
TTML = "ttml"
class MusicVideoCodec(Enum):
H264 = "h264"
H265 = "h265"
ASK = "ask"
class PostQuality(Enum):
BEST = "best"
ASK = "ask"
class CoverFormat(Enum):
JPG = "jpg"
PNG = "png"
RAW = "raw"
-408
View File
@@ -1,408 +0,0 @@
from pathlib import Path
import glob
from http.cookiejar import MozillaCookieJar
import re
import base64
import datetime
from xml.etree import ElementTree
import functools
import subprocess
import shutil
import gamdl.storefront_ids
from pywidevine import Cdm, Device, PSSH, WidevinePsshData
import requests
import m3u8
from yt_dlp import YoutubeDL
from mutagen.mp4 import MP4, MP4Cover
class Gamdl:
def __init__(self, wvd_location, cookies_location, disable_music_video_skip, prefer_hevc, heaac, temp_path, final_path, no_lrc, overwrite, skip_cleanup):
self.disable_music_video_skip = disable_music_video_skip
self.prefer_hevc = prefer_hevc
if heaac:
self.song_audio_quality = '32:ctrp64'
self.music_video_audio_quality = 'audio-HE-stereo-64'
else:
self.song_audio_quality = '28:ctrp256'
self.music_video_audio_quality = 'audio-stereo-256'
self.temp_path = Path(temp_path)
self.final_path = Path(final_path)
self.no_lrc = no_lrc
self.overwrite = overwrite
self.skip_cleanup = skip_cleanup
wvd_location = glob.glob(wvd_location)
if not wvd_location:
raise Exception('.wvd file not found')
self.cdm = Cdm.from_device(Device.load(Path(wvd_location[0])))
self.cdm_session = self.cdm.open()
cookies = MozillaCookieJar(Path(cookies_location))
cookies.load(ignore_discard=True, ignore_expires=True)
self.session = requests.Session()
self.session.cookies.update(cookies)
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0',
'Accept': 'application/json',
'Accept-Language': 'en-US,en;q=0.5',
'Accept-Encoding': 'gzip, deflate, br',
'content-type': 'application/json',
'Media-User-Token': self.session.cookies.get_dict()['media-user-token'],
'x-apple-renewal': 'true',
'DNT': '1',
'Connection': 'keep-alive',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-site',
'origin': 'https://beta.music.apple.com',
})
web_page = self.session.get('https://beta.music.apple.com').text
index_js_uri = re.search('(?<=index\.)(.*?)(?=\.js")', web_page).group(1)
index_js_page = self.session.get(f'https://beta.music.apple.com/assets/index.{index_js_uri}.js').text
token = re.search('(?=eyJh)(.*?)(?=")', index_js_page).group(1)
self.session.headers.update({"authorization": f'Bearer {token}'})
self.country = self.session.cookies.get_dict()['itua']
self.storefront = getattr(gamdl.storefront_ids, self.country.upper())
def get_download_queue(self, url):
download_queue = []
product_id = url.split('/')[-1].split('i=')[-1].split('&')[0].split('?')[0]
response = self.session.get(f'https://amp-api.music.apple.com/v1/catalog/{self.country}/?ids[songs]={product_id}&ids[albums]={product_id}&ids[playlists]={product_id}&ids[music-videos]={product_id}').json()['data'][0]
if response['type'] in ('songs', 'music-videos') and 'playParams' in response['attributes']:
download_queue.append(response)
if response['type'] == 'albums' or response['type'] == 'playlists':
for track in response['relationships']['tracks']['data']:
if 'playParams' in track['attributes']:
if track['type'] == 'music-videos' and self.disable_music_video_skip:
download_queue.append(track)
if track['type'] == 'songs':
download_queue.append(track)
if not download_queue:
raise Exception('Criteria not met')
return download_queue
def get_webplayback(self, track_id):
response = self.session.post(
'https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback',
json = {
'salableAdamId': track_id,
'language': 'en-US',
}
).json()["songList"][0]
return response
def get_stream_url_song(self, webplayback):
return next(i for i in webplayback["assets"] if i["flavor"] == self.song_audio_quality)['URL']
def get_stream_url_music_video(self, webplayback):
with YoutubeDL({
'allow_unplayable_formats': True,
'quiet': True,
'no_warnings': True,
}) as ydl:
playlist = ydl.extract_info(webplayback['hls-playlist-url'], download = False)
if self.prefer_hevc:
stream_url_video = playlist['formats'][-1]['url']
else:
stream_url_video = [i['url'] for i in playlist['formats'] if i['vcodec'] is not None and 'avc1' in i['vcodec']][-1]
stream_url_audio = next(i['url'] for i in playlist['formats'] if self.music_video_audio_quality in i['format_id'])
return stream_url_video, stream_url_audio
def check_exists(self, final_location):
return Path(final_location).exists()
def get_encrypted_location_video(self, track_id):
return self.temp_path / f'{track_id}_encrypted_video.mp4'
def get_encrypted_location_audio(self, track_id):
return self.temp_path / f'{track_id}_encrypted_audio.mp4'
def get_decrypted_location_video(self, track_id):
return self.temp_path / f'{track_id}_decrypted_video.mp4'
def get_decrypted_location_audio(self, track_id):
return self.temp_path / f'{track_id}_decrypted_audio.mp4'
def get_fixed_location(self, track_id, file_extension):
return self.temp_path / f'{track_id}_fixed{file_extension}'
def download(self, encrypted_location, stream_url):
with YoutubeDL({
'quiet': True,
'no_warnings': True,
'outtmpl': str(encrypted_location),
'allow_unplayable_formats': True,
'fixup': 'never',
'overwrites': self.overwrite,
'external_downloader': 'aria2c',
}) as ydl:
ydl.download(stream_url)
def get_license_b64(self, challenge, track_uri, track_id):
return self.session.post(
'https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/acquireWebPlaybackLicense',
json = {
'challenge': challenge,
'key-system': 'com.widevine.alpha',
'uri': track_uri,
'adamId': track_id,
'isLibrary': False,
'user-initiated': True,
}
).json()['license']
def get_decryption_keys_music_video(self, stream_url, track_id):
playlist = m3u8.load(stream_url)
track_uri = next(i for i in playlist.keys if i.keyformat == "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed").uri
pssh = PSSH(track_uri.split(',')[1])
challenge = base64.b64encode(self.cdm.get_license_challenge(self.cdm_session, pssh)).decode('utf-8')
license_b64 = self.get_license_b64(challenge, track_uri, track_id)
self.cdm.parse_license(self.cdm_session, license_b64)
return f'1:{next(i for i in self.cdm.get_keys(self.cdm_session) if i.type == "CONTENT").key.hex()}'
def get_decryption_keys_song(self, stream_url, track_id):
track_uri = m3u8.load(stream_url).keys[0].uri
widevine_pssh_data = WidevinePsshData()
widevine_pssh_data.algorithm = 1
widevine_pssh_data.key_ids.append(base64.b64decode(track_uri.split(",")[1]))
pssh = PSSH(base64.b64encode(widevine_pssh_data.SerializeToString()).decode('utf-8'))
challenge = base64.b64encode(self.cdm.get_license_challenge(self.cdm_session, pssh)).decode('utf-8')
license_b64 = self.get_license_b64(challenge, track_uri, track_id)
self.cdm.parse_license(self.cdm_session, license_b64)
return f'1:{next(i for i in self.cdm.get_keys(self.cdm_session) if i.type == "CONTENT").key.hex()}'
def decrypt(self, encrypted_location, decrypted_location, decryption_keys):
subprocess.run(
[
'mp4decrypt',
encrypted_location,
'--key',
decryption_keys,
decrypted_location,
],
check=True
)
def get_synced_lyrics_formated_time(self, unformatted_time):
unformatted_time = unformatted_time.replace('m', '').replace('s', '').replace(':', '.')
unformatted_time = unformatted_time.split('.')
m, s, ms = 0, 0, 0
ms = int(unformatted_time[-1])
if len(unformatted_time) >= 2:
s = int(unformatted_time[-2]) * 1000
if len(unformatted_time) >= 3:
m = int(unformatted_time[-3]) * 60000
unformatted_time = datetime.datetime.fromtimestamp((ms + s + m)/1000.0)
ms_new = f'{int(str(unformatted_time.microsecond)[:3]):03d}'
if int(ms_new[2]) >= 5:
ms = int(f'{int(ms_new[:2]) + 1}') * 10
unformatted_time += datetime.timedelta(milliseconds=ms) - datetime.timedelta(microseconds=unformatted_time.microsecond)
return unformatted_time.strftime('%M:%S.%f')[:-4]
def get_lyrics(self, track_id):
try:
lyrics_ttml = ElementTree.fromstring(self.session.get(f'https://amp-api.music.apple.com/v1/catalog/{self.country}/songs/{track_id}/lyrics').json()['data'][0]['attributes']['ttml'])
except:
return None, None
unsynced_lyrics = ''
synced_lyrics = ''
for div in lyrics_ttml.iter('{http://www.w3.org/ns/ttml}div'):
for p in div.iter('{http://www.w3.org/ns/ttml}p'):
if p.attrib.get('begin'):
synced_lyrics += f'[{self.get_synced_lyrics_formated_time(p.attrib.get("begin"))}]{p.text}\n'
if p.text is not None:
unsynced_lyrics += p.text + '\n'
unsynced_lyrics += '\n'
return unsynced_lyrics[:-2], synced_lyrics
@functools.lru_cache()
def get_cover(self, url):
return requests.get(url).content
def get_tags_song(self, webplayback, unsynced_lyrics):
metadata = next(i for i in webplayback["assets"] if i["flavor"] == self.song_audio_quality)['metadata']
cover_url = next(i for i in webplayback["assets"] if i["flavor"] == self.song_audio_quality)['artworkURL']
tags = {
'\xa9nam': [metadata['itemName']],
'\xa9gen': [metadata['genre']],
'aART': [metadata['playlistArtistName']],
'\xa9alb': [metadata['playlistName']],
'soar': [metadata['sort-artist']],
'soal': [metadata['sort-album']],
'sonm': [metadata['sort-name']],
'\xa9ART': [metadata['artistName']],
'geID': [metadata['genreId']],
'atID': [int(metadata['artistId'])],
'plID': [int(metadata['playlistId'])],
'cnID': [int(metadata['itemId'])],
'sfID': [metadata['s']],
'rtng': [metadata['explicit']],
'pgap': metadata['gapless'],
'cpil': metadata['compilation'],
'disk': [(metadata['discNumber'], metadata['discCount'])],
'trkn': [(metadata['trackNumber'], metadata['trackCount'])],
'covr': [MP4Cover(self.get_cover(cover_url), MP4Cover.FORMAT_JPEG)],
'stik': [1],
}
if 'copyright' in metadata:
tags['cprt'] = [metadata['copyright']]
if 'releaseDate' in metadata:
tags['\xa9day'] = [metadata['releaseDate']]
if 'comments' in metadata:
tags['\xa9cmt'] = [metadata['comments']]
if 'xid' in metadata:
tags['xid '] = [metadata['xid']]
if 'composerId' in metadata:
tags['cmID'] = [int(metadata['composerId'])]
tags['\xa9wrt'] = [metadata['composerName']]
tags['soco'] = [metadata['sort-composer']]
if unsynced_lyrics:
tags['\xa9lyr'] = [unsynced_lyrics]
return tags
def get_tags_music_video(self, track_id):
metadata = requests.get(f'https://itunes.apple.com/lookup?id={track_id}&entity=album&country={self.country}&lang=en_US').json()['results']
extra_metadata = requests.get(f'https://music.apple.com/music-video/{metadata[0]["trackId"]}', headers = {'X-Apple-Store-Front': f'{self.storefront} t:music31'}).json()['storePlatformData']['product-dv']['results'][str(metadata[0]['trackId'])]
tags = {
'\xa9ART': [metadata[0]["artistName"]],
'\xa9nam': [metadata[0]["trackCensoredName"]],
'\xa9day': [metadata[0]["releaseDate"]],
'\xa9gen': [metadata[0]['primaryGenreName']],
'stik': [6],
'atID': [metadata[0]['artistId']],
'cnID': [metadata[0]["trackId"]],
'geID': [int(extra_metadata['genres'][0]['genreId'])],
'sfID': [int(self.storefront.split('-')[0])],
'covr': [MP4Cover(self.get_cover(metadata[0]["artworkUrl30"].replace('30x30bb.jpg', '600x600bb.jpg')), MP4Cover.FORMAT_JPEG)],
}
if 'copyright' in extra_metadata:
tags['cprt'] = [extra_metadata['copyright']]
if metadata[0]['trackExplicitness'] == 'notExplicit':
tags['rtng'] = [0]
elif metadata[0]['trackExplicitness'] == 'explicit':
tags['rtng'] = [1]
else:
tags['rtng'] = [2]
if len(metadata) > 1:
tags['\xa9alb'] = [metadata[1]["collectionCensoredName"]]
tags['aART'] = [metadata[1]["artistName"]]
tags['plID'] = [metadata[1]["collectionId"]]
tags['disk'] = [(metadata[0]["discNumber"], metadata[0]["discCount"])]
tags['trkn'] = [(metadata[0]["trackNumber"], metadata[0]["trackCount"])]
return tags
def get_sanizated_string(self, dirty_string, is_folder):
for character in ['\\', '/', ':', '*', '?', '"', '<', '>', '|', ';']:
dirty_string = dirty_string.replace(character, '_')
if is_folder:
dirty_string = dirty_string[:40]
if dirty_string[-1:] == '.':
dirty_string = dirty_string[:-1] + '_'
else:
dirty_string = dirty_string[:36]
return dirty_string.strip()
def get_final_location_overwrite_prevented_music_video(self, final_location):
count = 1
while True:
if final_location.with_name(f'{final_location.stem} {count}.m4v').exists():
count += 1
else:
return final_location.with_name(f'{final_location.stem} {count}.m4v')
def get_final_location(self, file_extension, tags):
final_location = self.final_path
if 'plID' in tags:
if tags['disk'][0][1] > 1:
file_name = self.get_sanizated_string(f'{tags["disk"][0][0]}-{tags["trkn"][0][0]:02d} {tags["©nam"][0]}', False)
else:
file_name = self.get_sanizated_string(f'{tags["trkn"][0][0]:02d} {tags["©nam"][0]}', False)
if 'cpil' in tags and tags['cpil']:
final_location /= f'Compilations/{self.get_sanizated_string(tags["©alb"][0], True)}'
else:
final_location /= f'{self.get_sanizated_string(tags["aART"][0], True)}/{self.get_sanizated_string(tags["©alb"][0], True)}'
else:
file_name = self.get_sanizated_string(tags["©nam"][0], False)
final_location /= f'{self.get_sanizated_string(tags["©ART"][0], True)}/Unknown Album/'
final_location /= f'{file_name}{file_extension}'
try:
if file_extension == '.m4v' and final_location.exists() and MP4(final_location)['cnID'][0] != tags['cnID'][0]:
final_location = self.get_final_location_overwrite_prevented_music_video(final_location)
except:
pass
return final_location
def fixup_music_video(self, decrypted_location_audio, decrypted_location_video, fixed_location):
subprocess.run(
[
'MP4Box',
'-quiet',
'-add',
decrypted_location_audio,
'-add',
decrypted_location_video,
'-itags',
'artist=placeholder',
'-new',
fixed_location,
],
check=True
)
def fixup_song(self, decrypted_location, fixed_location):
subprocess.run(
[
'MP4Box',
'-quiet',
'-add',
decrypted_location,
'-itags',
'artist=placeholder',
'-new',
fixed_location,
],
check=True
)
def make_lrc(self, final_location, synced_lyrics):
if synced_lyrics and not self.no_lrc:
with open(final_location.with_suffix('.lrc'), 'w', encoding = 'utf8') as f:
f.write(synced_lyrics)
def make_final(self, final_location, fixed_location, tags):
final_location.parent.mkdir(parents = True, exist_ok = True)
shutil.copy(fixed_location, final_location)
file = MP4(final_location)
file.update(tags)
file.save()
def cleanup(self):
if self.temp_path.exists() and not self.skip_cleanup:
shutil.rmtree(self.temp_path)
+1
View File
@@ -0,0 +1 @@
HARDCODED_WVD = """V1ZEAgIDAASoMIIEpAIBAAKCAQEAwnCFAPXy4U1J7p1NohAS+xl040f5FBaE/59bPp301bGz0UGFT9VoEtY3vaeakKh/d319xTNvCSWsEDRaMmp/wSnMiEZUkkl04872jx2uHuR4k6KYuuJoqhsIo1TwUBueFZynHBUJzXQeW8Eb1tYAROGwp8W7r+b0RIjHC89RFnfVXpYlF5I6McktyzJNSOwlQbMqlVihfSUkv3WRd3HFmA0Oxay51CEIkoTlNTHVlzVyhov5eHCDSp7QENRgaaQ03jC/CcgFOoQymhsBtRCM0CQmfuAHjA9e77R6m/GJPy75G9fqoZM1RMzVDHKbKZPd3sFd0c0+77gLzW8cWEaaHwIDAQABAoIBAQCB2pN46MikHvHZIcTPDt0eRQoDH/YArGl2Lf7J+sOgU2U7wv49KtCug9IGHwDiyyUVsAFmycrF2RroV45FTUq0vi2SdSXV7Kjb20Ren/vBNeQw9M37QWmU8Sj7q6YyWb9hv5T69DHvvDTqIjVtbM4RMojAAxYti5hmjNIh2PrWfVYWhXxCQ/WqAjWLtZBM6Oww1byfr5I/wFogAKkgHi8wYXZ4LnIC8V7jLAhujlToOvMMC9qwcBiPKDP2FO+CPSXaqVhH+LPSEgLggnU3EirihgxovbLNAuDEeEbRTyR70B0lW19tLHixso4ZQa7KxlVUwOmrHSZf7nVuWqPpxd+BAoGBAPQLyJ1IeRavmaU8XXxfMdYDoc8+xB7v2WaxkGXb6ToX1IWPkbMz4yyVGdB5PciIP3rLZ6s1+ruuRRV0IZ98i1OuN5TSR56ShCGg3zkd5C4L/xSMAz+NDfYSDBdO8BVvBsw21KqSRUi1ctL7QiIvfedrtGb5XrE4zhH0gjXlU5qZAoGBAMv2segn0Jx6az4rqRa2Y7zRx4iZ77JUqYDBI8WMnFeR54uiioTQ+rOs3zK2fGIWlrn4ohco/STHQSUTB8oCOFLMx1BkOqiR+UyebO28DJY7+V9ZmxB2Guyi7W8VScJcIdpSOPyJFOWZQKXdQFW3YICD2/toUx/pDAJh1sEVQsV3AoGBANyyp1rthmvoo5cVbymhYQ08vaERDwU3PLCtFXu4E0Ow90VNn6Ki4ueXcv/gFOp7pISk2/yuVTBTGjCblCiJ1en4HFWekJwrvgg3Vodtq8Okn6pyMCHRqvWEPqD5hw6rGEensk0K+FMXnF6GULlfn4mgEkYpb+PvDhSYvQSGfkPJAoGAF/bAKFqlM/1eJEvU7go35bNwEiij9Pvlfm8y2L8Qj2lhHxLV240CJ6IkBz1Rl+S3iNohkT8LnwqaKNT3kVB5daEBufxMuAmOlOX4PmZdxDj/r6hDg8ecmjj6VJbXt7JDd/c5ItKoVeGPqu035dpJyE+1xPAY9CLZel4scTsiQTkCgYBt3buRcZMwnc4qqpOOQcXK+DWD6QvpkcJ55ygHYw97iP/lF4euwdHd+I5b+11pJBAao7G0fHX3eSjqOmzReSKboSe5L8ZLB2cAI8AsKTBfKHWmCa8kDtgQuI86fUfirCGdhdA9AVP2QXN2eNCuPnFWi0WHm4fYuUB5be2c18ucxAb9CAESmgsK3QMIAhIQ071yBlsbLoO2CSB9Ds0cmRif6uevBiKOAjCCAQoCggEBAMJwhQD18uFNSe6dTaIQEvsZdONH+RQWhP+fWz6d9NWxs9FBhU/VaBLWN72nmpCof3d9fcUzbwklrBA0WjJqf8EpzIhGVJJJdOPO9o8drh7keJOimLriaKobCKNU8FAbnhWcpxwVCc10HlvBG9bWAEThsKfFu6/m9ESIxwvPURZ31V6WJReSOjHJLcsyTUjsJUGzKpVYoX0lJL91kXdxxZgNDsWsudQhCJKE5TUx1Zc1coaL+Xhwg0qe0BDUYGmkNN4wvwnIBTqEMpobAbUQjNAkJn7gB4wPXu+0epvxiT8u+RvX6qGTNUTM1QxymymT3d7BXdHNPu+4C81vHFhGmh8CAwEAASjwIkgBUqoBCAEQABqBAQQlRbfiBNDb6eU6aKrsH5WJaYszTioXjPLrWN9dqyW0vwfT11kgF0BbCGkAXew2tLJJqIuD95cjJvyGUSN6VyhL6dp44fWEGDSBIPR0mvRq7bMP+m7Y/RLKf83+OyVJu/BpxivQGC5YDL9f1/A8eLhTDNKXs4Ia5DrmTWdPTPBL8SIgyfUtg3ofI+/I9Tf7it7xXpT0AbQBJfNkcNXGpO3JcBMSgAIL5xsXK5of1mMwAl6ygN1Gsj4aZ052otnwN7kXk12SMsXheWTZ/PYh2KRzmt9RPS1T8hyFx/Kp5VkBV2vTAqqWrGw/dh4URqiHATZJUlhO7PN5m2Kq1LVFdXjWSzP5XBF2S83UMe+YruNHpE5GQrSyZcBqHO0QrdPcU35GBT7S7+IJr2AAXvnjqnb8yrtpPWN2ZW/IWUJN2z4vZ7/HV4aj3OZhkxC1DIMNyvsusUKoQQuf8gwKiEe8cFwbwFSicywlFk9la2IPe8oFShcxAzHLCCn/TIYUAvEL3/4LgaZvqWm80qCPYbgIP5HT8hPYkKWJ4WYknEWK+3InbnkzteFfGrQFCq4CCAESEGnj6Ji7LD+4o7MoHYT4jBQYjtW+kQUijgIwggEKAoIBAQDY9um1ifBRIOmkPtDZTqH+CZUBbb0eK0Cn3NHFf8MFUDzPEz+emK/OTub/hNxCJCao//pP5L8tRNUPFDrrvCBMo7Rn+iUb+mA/2yXiJ6ivqcN9Cu9i5qOU1ygon9SWZRsujFFB8nxVreY5Lzeq0283zn1Cg1stcX4tOHT7utPzFG/ReDFQt0O/GLlzVwB0d1sn3SKMO4XLjhZdncrtF9jljpg7xjMIlnWJUqxDo7TQkTytJmUl0kcM7bndBLerAdJFGaXc6oSY4eNy/IGDluLCQR3KZEQsy/mLeV1ggQ44MFr7XOM+rd+4/314q/deQbjHqjWFuVr8iIaKbq+R63ShAgMBAAEo8CISgAMii2Mw6z+Qs1bvvxGStie9tpcgoO2uAt5Zvv0CDXvrFlwnSbo+qR71Ru2IlZWVSbN5XYSIDwcwBzHjY8rNr3fgsXtSJty425djNQtF5+J2jrAhf3Q2m7EI5aohZGpD2E0cr+dVj9o8x0uJR2NWR8FVoVQSXZpad3M/4QzBLNto/tz+UKyZwa7Sc/eTQc2+ZcDS3ZEO3lGRsH864Kf/cEGvJRBBqcpJXKfG+ItqEW1AAPptjuggzmZEzRq5xTGf6or+bXrKjCpBS9G1SOyvCNF1k5z6lG8KsXhgQxL6ADHMoulxvUIihyPY5MpimdXfUdEQ5HA2EqNiNVNIO4qP007jW51yAeThOry4J22xs8RdkIClOGAauLIl0lLA4flMzW+VfQl5xYxP0E5tuhn0h+844DslU8ZF7U1dU2QprIApffXD9wgAACk26Rggy8e96z8i86/+YYyZQkc9hIdCAERrgEYCEbByzONrdRDs1MrS/ch1moV5pJv63BIKvQHGvLkaFwoMY29tcGFueV9uYW1lEgd1bmtub3duGioKCm1vZGVsX25hbWUSHEFuZHJvaWQgU0RLIGJ1aWx0IGZvciB4ODZfNjQaGwoRYXJjaGl0ZWN0dXJlX25hbWUSBng4Nl82NBodCgtkZXZpY2VfbmFtZRIOZ2VuZXJpY194ODZfNjQaIAoMcHJvZHVjdF9uYW1lEhBzZGtfcGhvbmVfeDg2XzY0GmMKCmJ1aWxkX2luZm8SVUFuZHJvaWQvc2RrX3Bob25lX3g4Nl82NC9nZW5lcmljX3g4Nl82NDo5L1BTUjEuMTgwNzIwLjAxMi80OTIzMjE0OnVzZXJkZWJ1Zy90ZXN0LWtleXMaHgoUd2lkZXZpbmVfY2RtX3ZlcnNpb24SBjE0LjAuMBokCh9vZW1fY3J5cHRvX3NlY3VyaXR5X3BhdGNoX2xldmVsEgEwMg4QASAAKA0wAEAASABQAA=="""
+85
View File
@@ -0,0 +1,85 @@
from __future__ import annotations
import functools
import requests
from .apple_music_api import AppleMusicApi
from .constants import STOREFRONT_IDS
class ItunesApi:
ITUNES_LOOKUP_API_URL = "https://itunes.apple.com/lookup"
ITUNES_PAGE_API_URL = "https://music.apple.com"
def __init__(
self,
storefront: str = "us",
language: str = "en-US",
):
self.storefront = storefront
self.language = language
self._setup_session()
def _setup_session(self):
try:
self.storefront_id = STOREFRONT_IDS[self.storefront.upper()]
except KeyError:
raise Exception(f"No storefront id for {self.storefront}")
self.session = requests.Session()
self.session.params = {
"country": self.storefront,
"lang": self.language,
}
self.session.headers = {
"X-Apple-Store-Front": f"{self.storefront_id} t:music31",
}
@functools.lru_cache()
def get_resource(
self,
resource_id: str,
entity: str = "album",
) -> dict:
response = self.session.get(
self.ITUNES_LOOKUP_API_URL,
params={
"id": resource_id,
"entity": entity,
},
)
try:
response.raise_for_status()
response_dict = response.json()
resource = response_dict.get("results")
assert resource
except (
requests.HTTPError,
requests.exceptions.JSONDecodeError,
AssertionError,
):
AppleMusicApi._raise_response_exception(response)
return resource
def get_itunes_page(
self,
resource_type: str,
resource_id: str,
) -> dict:
response = self.session.get(
f"{self.ITUNES_PAGE_API_URL}/{resource_type}/{resource_id}"
)
try:
response.raise_for_status()
response_dict = response.json()
itunes_page = response_dict["storePlatformData"]["product-dv"][
"results"
].get(resource_id)
assert itunes_page
except (
requests.HTTPError,
requests.exceptions.JSONDecodeError,
AssertionError,
):
AppleMusicApi._raise_response_exception(response)
return itunes_page
+27
View File
@@ -0,0 +1,27 @@
from dataclasses import dataclass
@dataclass
class UrlInfo:
storefront: str = None
type: str = None
id: str = None
@dataclass
class DownloadQueue:
playlist_attributes: dict = None
tracks_metadata: list[dict] = None
@dataclass
class Lyrics:
synced: str = None
unsynced: str = None
@dataclass
class StreamInfo:
stream_url: str = None
pssh: str = None
codec: str = None
-155
View File
@@ -1,155 +0,0 @@
AE = "143481-2,32"
AG = "143540-2,32"
AI = "143538-2,32"
AL = "143575-2,32"
AM = "143524-2,32"
AO = "143564-2,32"
AR = "143505-28,32"
AT = "143445-4,32"
AU = "143460-27,32"
AZ = "143568-2,32"
BB = "143541-2,32"
BE = "143446-2,32"
BF = "143578-2,32"
BG = "143526-2,32"
BH = "143559-2,32"
BJ = "143576-2,32"
BM = "143542-2,32"
BN = "143560-2,32"
BO = "143556-28,32"
BR = "143503-15,32"
BS = "143539-2,32"
BT = "143577-2,32"
BW = "143525-2,32"
BY = "143565-2,32"
BZ = "143555-2,32"
CA = "143455-6,32"
CG = "143582-2,32"
CH = "143459-57,32"
CL = "143483-28,32"
CN = "143465-19,32"
CO = "143501-28,32"
CR = "143495-28,32"
CV = "143580-2,32"
CY = "143557-2,32"
CZ = "143489-2,32"
DE = "143443-4,32"
DK = "143458-2,32"
DM = "143545-2,32"
DO = "143508-28,32"
DZ = "143563-2,32"
EC = "143509-28,32"
EE = "143518-2,32"
EG = "143516-2,32"
ES = "143454-8,32"
FI = "143447-2,32"
FJ = "143583-2,32"
FM = "143591-2,32"
FR = "143442-3,32"
GB = "143444-2,32"
GD = "143546-2,32"
GH = "143573-2,32"
GM = "143584-2,32"
GR = "143448-2,32"
GT = "143504-28,32"
GW = "143585-2,32"
GY = "143553-2,32"
HK = "143463-45,32"
HN = "143510-28,32"
HR = "143494-2,32"
HU = "143482-2,32"
ID = "143476-2,32"
IE = "143449-2,32"
IL = "143491-2,32"
IN = "143467-2,32"
IS = "143558-2,32"
IT = "143450-7,32"
JM = "143511-2,32"
JO = "143528-2,32"
JP = "143462-9,32"
KE = "143529-2,32"
KG = "143586-2,32"
KH = "143579-2,32"
KN = "143548-2,32"
KR = "143466-13,32"
KW = "143493-2,32"
KY = "143544-2,32"
KZ = "143517-2,32"
LA = "143587-2,32"
LB = "143497-2,32"
LC = "143549-2,32"
LK = "143486-2,32"
LR = "143588-2,32"
LT = "143520-2,32"
LU = "143451-2,32"
LV = "143519-2,32"
MD = "143523-2,32"
MG = "143531-2,32"
MK = "143530-2,32"
ML = "143532-2,32"
MN = "143592-2,32"
MO = "143515-45,32"
MR = "143590-2,32"
MS = "143547-2,32"
MT = "143521-2,32"
MU = "143533-2,32"
MW = "143589-2,32"
MX = "143468-28,32"
MY = "143473-2,32"
MZ = "143593-2,32"
NA = "143594-2,32"
NE = "143534-2,32"
NG = "143561-2,32"
NI = "143512-28,32"
NL = "143452-10,32"
NO = "143457-2,32"
NP = "143484-2,32"
NZ = "143461-27,32"
OM = "143562-2,32"
PA = "143485-28,32"
PE = "143507-28,32"
PG = "143597-2,32"
PH = "143474-2,32"
PK = "143477-2,32"
PL = "143478-2,32"
PT = "143453-24,32"
PW = "143595-2,32"
PY = "143513-28,32"
QA = "143498-2,32"
RO = "143487-2,32"
RU = "143469-16,32"
SA = "143479-2,32"
SB = "143601-2,32"
SC = "143599-2,32"
SE = "143456-17,32"
SG = "143464-19,32"
SI = "143499-2,32"
SK = "143496-2,32"
SL = "143600-2,32"
SN = "143535-2,32"
SR = "143554-2,32"
ST = "143598-2,32"
SV = "143506-28,32"
SZ = "143602-2,32"
TC = "143552-2,32"
TD = "143581-2,32"
TH = "143475-2,32"
TJ = "143603-2,32"
TM = "143604-2,32"
TN = "143536-2,32"
TR = "143480-2,32"
TT = "143551-2,32"
TW = "143470-18,32"
TZ = "143572-2,32"
UA = "143492-2,32"
UG = "143537-2,32"
US = "143441-1,32"
UY = "143514-2,32"
UZ = "143566-2,32"
VC = "143550-2,32"
VE = "143502-28,32"
VG = "143543-2,32"
VN = "143471-2,32"
YE = "143571-2,32"
ZA = "143472-2,32"
ZW = "143605-2,32"
+10 -6
View File
@@ -1,13 +1,17 @@
[project]
name = "gamdl"
description = "Download Apple Music songs/music videos/albums/playlists"
requires-python = ">=3.7"
authors = [{name = "glomatico"}]
description = "A Python CLI app for downloading Apple Music songs/music videos/posts."
requires-python = ">=3.8"
authors = [{ name = "glomatico" }]
dependencies = [
"ciso8601",
"click",
"inquirerpy",
"m3u8",
"pillow",
"pywidevine",
"pyyaml",
"m3u8",
"yt-dlp"
"yt-dlp",
]
readme = "README.md"
dynamic = ["version"]
@@ -21,4 +25,4 @@ requires = ["flit_core"]
build-backend = "flit_core.buildapi"
[project.scripts]
gamdl = "gamdl:main"
gamdl = "gamdl.cli:main"
+5 -1
View File
@@ -1,4 +1,8 @@
ciso8601
click
inquirerpy
m3u8
pillow
pywidevine
pyyaml
m3u8
yt-dlp