Compare commits

...

277 Commits

Author SHA1 Message Date
Rafael Moraes 04351c8e34 Add skip_processing option to Downloader 2025-08-29 14:26:32 -03:00
Rafael Moraes 758f64ce38 Merge pull request #211 from glomatico/dev
Pull from dev branch
2025-08-29 14:07:43 -03:00
Rafael Moraes e797690a13 Handle missing uploadDate in get_tags method 2025-08-29 14:05:29 -03:00
Rafael Moraes 332dc9baad Refactor prompt_path to move path_type assignment 2025-08-29 14:02:49 -03:00
Rafael Moraes 8be3d0babd Add comment indicating WVD source and environment 2025-08-29 13:59:21 -03:00
Rafael Moraes d1a32adcf8 Update completion log message for clarity 2025-08-29 13:53:11 -03:00
Rafael Moraes bb5652c2f9 Expand type checks to use sets for media types 2025-08-29 13:51:25 -03:00
Rafael Moraes b6a756d661 Update default config file extension in README 2025-08-29 13:48:20 -03:00
Rafael Moraes a4e4c9d0fd Format default values as code in options table 2025-08-29 13:45:42 -03:00
Rafael Moraes 993872acde Clarify prerequisites instructions in README 2025-08-29 13:41:49 -03:00
Rafael Moraes 9de1ec033a Update README.md 2025-08-29 13:35:03 -03:00
Rafael Moraes 3fb28d4e2d Handle missing results in iTunes API methods 2025-08-29 13:33:19 -03:00
Rafael Moraes 678e3cbad6 Move .wvd file prompt earlier in CLI flow 2025-08-29 13:23:28 -03:00
Rafael Moraes 0384944589 Rename max_resolution option to resolution in CLI 2025-08-28 20:09:24 -03:00
Rafael Moraes 3eb9dd3fbd Refactor resolution handling in music video downloader 2025-08-28 18:41:05 -03:00
Rafael Moraes 1fbb3f1da6 Remove unused methods from MusicVideoResolution enum 2025-08-28 18:40:51 -03:00
Rafael Moraes cd787e66cd Clarify ALAC codec note in README 2025-08-27 13:34:30 -03:00
Rafael Moraes b4e41cbdd8 Fix incorrect resolution label from 576p to 540p 2025-08-27 13:10:04 -03:00
Rafael Moraes 16d0c046ad Fix MusicVideoResolution 576p value to 540p 2025-08-27 13:08:56 -03:00
Rafael Moraes ec81808fd8 Fix logic in MusicVideoResolution is_not_exceeding method 2025-08-27 12:58:25 -03:00
Rafael Moraes 4113e8435c Refactor video playlist selection logic 2025-08-27 12:54:55 -03:00
Rafael Moraes 3d3251fef7 Add music videos maximum resolutions to README 2025-08-27 12:32:21 -03:00
Rafael Moraes b1dae8c21c Add max_resolution option to README 2025-08-27 12:28:30 -03:00
Rafael Moraes a4af50b4a0 Add max resolution option for music video downloads 2025-08-27 12:26:52 -03:00
Rafael Moraes d88cf3438a Fix download queue type selection logic 2025-08-26 12:55:52 -03:00
Rafael Moraes 138154974f Update download queue selection logic 2025-08-26 12:48:45 -03:00
Rafael Moraes f6ede92322 Replace random.choices with uuid for temp path suffix 2025-08-26 12:46:57 -03:00
Rafael Moraes 65d8289d2e Refactor lyrics stanza collection logic 2025-08-26 11:07:49 -03:00
Rafael Moraes bb6a922c0a Refactor lyrics parsing to use lists for aggregation 2025-08-26 11:05:52 -03:00
Rafael Moraes 534c6d6f7b Randomize temp directory for downloads 2025-08-25 21:00:32 -03:00
Rafael Moraes 3ca50af186 Update ALAC codec note in README 2025-08-25 18:39:02 -03:00
Rafael Moraes 16d7d857d4 Refactor media_id assignment and typing in downloaders 2025-08-25 16:11:00 -03:00
Rafael Moraes 85004e6f5e Change 'cpil' tag to use boolean for compilation 2025-08-25 15:22:27 -03:00
Rafael Moraes 98698e999c Update config file path in README 2025-08-25 15:15:08 -03:00
Rafael Moraes 828c4e494a Clarify date variable usage in README 2025-08-25 15:11:58 -03:00
Rafael Moraes e8310c6ea2 Add option to skip all MP4 tagging 2025-08-25 15:11:21 -03:00
Rafael Moraes 7a8311628d Document 'all' variable in template variables list 2025-08-25 15:07:26 -03:00
Rafael Moraes b5406ca31d Fix logic for disc and track total assignment in MediaTags 2025-08-25 15:07:20 -03:00
Rafael Moraes e7c0e0e7a0 Refactor Csv param type parsing logic 2025-08-25 15:07:13 -03:00
Rafael Moraes 141a18e223 Bump version to 2.6 2025-08-25 14:55:37 -03:00
Rafael Moraes 8df23c84cf Document strftime support for date variable 2025-08-25 14:54:28 -03:00
Rafael Moraes bd6310d39b Update config options table in README 2025-08-25 14:50:10 -03:00
Rafael Moraes b7ea0aef19 Improve URL validation and error handling in downloader 2025-08-25 14:09:42 -03:00
Rafael Moraes 569a35eaaf Handle missing media in download queue 2025-08-25 14:07:12 -03:00
Rafael Moraes 3bc01ad075 Fix legacy codec check and update warning message 2025-08-25 14:03:39 -03:00
Rafael Moraes 8369c41725 Handle missing album in Apple Music API response 2025-08-25 14:03:05 -03:00
Rafael Moraes 082f30ed4a Handle 404 responses in AppleMusicApi methods 2025-08-25 14:01:33 -03:00
Rafael Moraes a2b284403f Use parse_url_info instead of get_url_info 2025-08-25 13:52:27 -03:00
Rafael Moraes ae32670c2e Improve URL parsing and UrlInfo structure 2025-08-25 13:52:20 -03:00
Rafael Moraes cc3592951f Clarify prompt messages in prompt_path function 2025-08-25 11:46:40 -03:00
Rafael Moraes 8a4a30f047 Import SongCodec and SyncedLyricsFormat enums 2025-08-24 11:18:05 -03:00
Rafael Moraes ce942d30f1 Improve parameter default serialization in config file 2025-08-24 11:17:57 -03:00
Rafael Moraes 68fd1d5ae5 Remove unused enum imports from constants.py
Deleted imports of MusicVideoCodec, SongCodec, and SyncedLyricsFormat from gamdl.enums as they are not used in constants.py.
2025-08-23 19:08:43 -03:00
Rafael Moraes d86f42ef22 Replace smart quotes with straight quotes in README 2025-08-23 16:26:30 -03:00
Rafael Moraes 7b71dc4e1c Fix apostrophe in project title in README 2025-08-23 16:26:06 -03:00
Rafael Moraes 591dd6c71d Add module imports to package __init__.py 2025-08-23 16:24:38 -03:00
Rafael Moraes da1a896c7b Add example for using Gamdl as a library 2025-08-23 16:24:31 -03:00
Rafael Moraes 65ca041fb6 Refactor and improve music video stream selection logic 2025-08-23 16:16:52 -03:00
Rafael Moraes 4f5cf185aa Improve Csv param type to handle non-string values 2025-08-23 16:16:15 -03:00
Rafael Moraes 9f16469a1b Support multiple music video codecs via CSV input 2025-08-23 16:07:33 -03:00
Rafael Moraes 25d5f422fd Refactor legacy codec checks to use is_legacy() method 2025-08-23 15:30:22 -03:00
Rafael Moraes 74ff16b487 Add is_legacy method to SongCodec enum 2025-08-23 15:29:42 -03:00
Rafael Moraes 165e78c69b Add skip_final_move option to downloader classes 2025-08-22 17:52:02 -03:00
Rafael Moraes 6fd01557af Change exclude_tags type from tuple to list in CLI 2025-08-22 17:30:38 -03:00
Rafael Moraes 68a88e8aec Remove unused code for splitting multiple values 2025-08-22 17:27:04 -03:00
Rafael Moraes cf44b59757 Add Csv ParamType for comma-separated CLI options 2025-08-22 17:26:58 -03:00
Rafael Moraes 438fa1087c Fix logger message formatting in downloader 2025-08-22 16:38:18 -03:00
Rafael Moraes 8ba73ea952 Fix log message typo in downloader_music_video.py 2025-08-22 16:37:26 -03:00
Rafael Moraes 45b49cd22e Move synced lyrics path assignment after tags setup 2025-08-22 16:33:25 -03:00
Rafael Moraes 8decb3001e Fix synced lyrics download condition 2025-08-22 16:29:14 -03:00
Rafael Moraes fdfcb24efb Fix synced lyrics path assignment and logic 2025-08-22 16:29:06 -03:00
Rafael Moraes e47aa7dbea Add spacing for readability in downloader.py 2025-08-22 16:19:30 -03:00
Rafael Moraes c7caba519e Refactor DRM metadata extraction and handling 2025-08-22 16:16:53 -03:00
Rafael Moraes 66a0e2b5f7 Fix logic for cover and lyrics handling in downloader 2025-08-22 16:16:44 -03:00
Rafael Moraes 7f5f2a7524 Remove debug print statement from downloader_post.py 2025-08-22 14:58:20 -03:00
Rafael Moraes 19589bf683 Refactor CLI options and streamline download logic 2025-08-22 14:57:53 -03:00
Rafael Moraes b7a0545151 Refactor lyrics and cover handling in DownloaderSong 2025-08-22 14:57:42 -03:00
Rafael Moraes f77ac9861f Add options for synced lyrics handling in Downloader 2025-08-22 14:57:33 -03:00
Rafael Moraes c785acb69e Capitalize 'Post Video' in log messages 2025-08-22 14:40:05 -03:00
Rafael Moraes 1afdd4c4b5 Change error log to warning for undownloadable songs 2025-08-22 14:39:30 -03:00
Rafael Moraes c265b4be50 Simplify media_id assignment in downloaders 2025-08-22 14:39:05 -03:00
Rafael Moraes 0b43049dc8 Fallback to media ID if catalogId is missing 2025-08-22 14:38:54 -03:00
Rafael Moraes 4cf54b6221 Add staged_path checks before file operations 2025-08-22 14:35:11 -03:00
Rafael Moraes 33b2d08aa9 Fix decryption key handling and staged file extension usage 2025-08-22 14:31:40 -03:00
Rafael Moraes fa80558050 Add playlist_track parameter to download method 2025-08-22 14:21:00 -03:00
Rafael Moraes 9964bc5022 Fix log message wording for music video download 2025-08-22 14:18:51 -03:00
Rafael Moraes 90b59152dc Move download completion log to downloader.py 2025-08-22 14:18:12 -03:00
Rafael Moraes 9a7ae643d8 Update log messages for Music Video downloads 2025-08-22 14:18:02 -03:00
Rafael Moraes d5e0ef0823 Fix method call for video download 2025-08-22 14:09:51 -03:00
Rafael Moraes d2b2dff223 Add post video download support to DownloaderPost 2025-08-22 14:09:37 -03:00
Rafael Moraes 58093887b6 Fix ISO date parsing to handle 'Z' suffix 2025-08-22 14:06:48 -03:00
Rafael Moraes 66564ef2ba Add playlist_track parameter to download method 2025-08-22 13:59:56 -03:00
Rafael Moraes fbe64946e8 Refine playlist parameter validation in downloaders 2025-08-22 12:18:44 -03:00
Rafael Moraes 7792e581e7 Refactor playlist tag handling in download logic 2025-08-22 12:16:39 -03:00
Rafael Moraes 349dbd0fc6 Refactor music video download logic in CLI 2025-08-22 12:14:45 -03:00
Rafael Moraes 51d4addd7a Use dynamic file extension for staged path 2025-08-22 12:14:37 -03:00
Rafael Moraes 38fede14fb Add return type annotations to DownloaderMusicVideo methods 2025-08-22 12:11:40 -03:00
Rafael Moraes 6e31633d01 Refactor and extend music video downloader logic 2025-08-22 12:10:27 -03:00
Rafael Moraes 136b46309e Rename get_final_file_extension to get_media_file_extension 2025-08-22 12:00:31 -03:00
Rafael Moraes b916ac2715 Pass decryption keys to mp4decrypt subprocess 2025-08-22 11:57:52 -03:00
Rafael Moraes 5b970e4e5b Remove unused path helper methods from DownloaderSong 2025-08-22 11:57:42 -03:00
Rafael Moraes 9c517226b5 Update get_cover_path to use cover file extension method 2025-08-22 11:38:48 -03:00
Rafael Moraes bde5749084 Fix title_id assignment in music video downloader 2025-08-22 11:38:29 -03:00
Rafael Moraes fec3682655 Log when downloading synced lyrics only 2025-08-22 11:30:54 -03:00
Rafael Moraes 1248228394 Remove unused variable in DownloaderSong 2025-08-22 11:29:36 -03:00
Rafael Moraes 9b556ff736 Update download log message in CLI 2025-08-22 11:28:07 -03:00
Rafael Moraes 363da82556 Refactor CLI to streamline song download logic 2025-08-22 11:27:46 -03:00
Rafael Moraes 2478135561 Fix playParams check in downloader 2025-08-22 11:27:38 -03:00
Rafael Moraes ccee28f61e Move download log message to after file path setup 2025-08-22 11:25:21 -03:00
Rafael Moraes 8f5683b870 Improve logging and variable naming in DownloaderSong 2025-08-22 11:23:13 -03:00
Rafael Moraes 174c351edf Fix mp4decrypt key argument formatting and log message 2025-08-22 11:21:13 -03:00
Rafael Moraes 363013f4c7 Add brackets to non-streamable track log message 2025-08-22 11:17:14 -03:00
Rafael Moraes 5b484d6f1d Improve handling of library songs and streamable checks 2025-08-22 11:11:02 -03:00
Rafael Moraes a4a5a916b2 Add is_media_streamable method to Downloader 2025-08-22 10:53:42 -03:00
Rafael Moraes 026dc1a83b Refactor media ID retrieval for library media 2025-08-22 10:53:27 -03:00
Rafael Moraes 7fd61ad850 Log successful download with colored media ID 2025-08-22 10:39:23 -03:00
Rafael Moraes fbf181c732 Improve song exists warning with media ID 2025-08-22 10:33:36 -03:00
Rafael Moraes 44e52697f6 Improve cover and lyrics overwrite handling 2025-08-22 10:31:50 -03:00
Rafael Moraes 2f1779690b Update get_cover_path to use cover_format and extension method 2025-08-22 10:25:36 -03:00
Rafael Moraes 115becc3d9 Add method to get cover file extension 2025-08-22 10:25:28 -03:00
Rafael Moraes 3342938a6a Add colored media_id to debug log messages 2025-08-22 10:23:10 -03:00
Rafael Moraes 577f55a005 Add option to disable synced lyrics download 2025-08-22 10:21:32 -03:00
Rafael Moraes 51bc3876ec Fix crash if temp path does not exist in cleanup 2025-08-22 10:16:32 -03:00
Rafael Moraes dc04bfc5b4 Delete downloader_song_legacy.py 2025-08-22 10:11:57 -03:00
Rafael Moraes ab2f1becc8 Enforce paired playlist attributes and track in download 2025-08-22 10:11:02 -03:00
Rafael Moraes c38a17b44c Add playlist file update after download 2025-08-22 10:10:18 -03:00
Rafael Moraes a3444ef6ef Add save_playlist option to Downloader 2025-08-22 10:08:22 -03:00
Rafael Moraes ed5491c87d Add return type and fix return in download_song 2025-08-22 10:02:20 -03:00
Rafael Moraes fc16df44ab Remove final processing call in DownloaderSong 2025-08-22 10:01:15 -03:00
Rafael Moraes 282c6a407b Refactor download logic and add cover path handling 2025-08-22 10:00:55 -03:00
Rafael Moraes b32f921f6c Add method to write synced lyrics to file 2025-08-22 09:59:03 -03:00
Rafael Moraes 3183e04c78 Rename save_cover to write_cover in Downloader 2025-08-22 09:58:15 -03:00
Rafael Moraes f4469fb332 Remove no_synced_lyrics parameter from Downloader 2025-08-22 09:57:24 -03:00
Rafael Moraes 33d422e5d2 Add cover_path and synced_lyrics_path to DownloadInfo 2025-08-22 09:53:54 -03:00
Rafael Moraes b0e5bdad28 Add _final_processing method and logging to Downloader 2025-08-22 09:53:01 -03:00
Rafael Moraes e243b2b3b5 Add save_cover and no_synced_lyrics options to Downloader 2025-08-22 09:46:18 -03:00
Rafael Moraes fe72c2ca0f Fix remux_ffmpeg to accept encrypted input and decryption key 2025-08-22 09:41:40 -03:00
Rafael Moraes fe1aa5e62d Refactor media_id and media_metadata validation logic 2025-08-22 09:39:22 -03:00
Rafael Moraes 3c9d6da2d8 Add legacy codec support and refactor song download flow 2025-08-22 09:31:48 -03:00
Rafael Moraes 1e3449d850 Add media_metadata field to DownloadInfo dataclass 2025-08-22 09:12:50 -03:00
Rafael Moraes 3de0bff6ff Add DownloadInfo dataclass to models 2025-08-22 09:08:49 -03:00
Rafael Moraes d907d2131f Add get_temp_path method and rename move param 2025-08-22 09:07:02 -03:00
Rafael Moraes ca9fec9efd Refactor cover file extension method 2025-08-22 09:06:07 -03:00
Rafael Moraes fc1f8fc639 Add overwrite option to Downloader class 2025-08-22 09:05:38 -03:00
Rafael Moraes ea37530df1 Refactor decryption key retrieval for music videos 2025-08-21 17:07:35 -03:00
Rafael Moraes 5264c045f8 Add get_decryption_key method to DownloaderMusicVideo 2025-08-21 17:07:27 -03:00
Rafael Moraes 429eb5c1d2 Update decryption key handling in main function 2025-08-21 16:33:29 -03:00
Rafael Moraes b325ebc04e Fix missing return statement in decryption method 2025-08-21 16:33:10 -03:00
Rafael Moraes 2f64cf4fea Refactor get_decryption_key to use DecryptionKeyAv 2025-08-21 16:31:08 -03:00
Rafael Moraes b9d049562c Return DecryptionKey object from get_decryption_key 2025-08-21 16:30:37 -03:00
Rafael Moraes 9a479c34dd Add get_decryption_key method to DownloaderSong 2025-08-21 16:12:47 -03:00
Rafael Moraes 8805b31c6e Add DecryptionKey and DecryptionKeyAv dataclasses 2025-08-21 16:12:28 -03:00
Rafael Moraes 664072b5a0 Fix playlist file path retrieval in CLI 2025-08-21 15:48:25 -03:00
Rafael Moraes 121056d0f5 Refactor get_playlist_file_path to use PlaylistTags type 2025-08-21 15:48:19 -03:00
Rafael Moraes d93b353a00 Refactor playlist tag handling in download process 2025-08-21 15:42:35 -03:00
Rafael Moraes f19b27416f Refactor playlist tag handling and final path generation 2025-08-21 15:42:26 -03:00
Rafael Moraes bb66b221d7 Add PlaylistTags dataclass to models.py 2025-08-21 15:42:14 -03:00
Rafael Moraes 01c66279db Refactor cli.py to improve code readability 2025-08-21 14:39:32 -03:00
Rafael Moraes 0faaacbe91 Move IMAGE_FILE_EXTENSION_MAP to Downloader class 2025-08-21 14:32:20 -03:00
Rafael Moraes b29033f4cd Refactor codec filtering in DownloaderMusicVideo 2025-08-21 14:30:49 -03:00
Rafael Moraes 77a849fed3 Remove unused MUSIC_VIDEO_CODEC_MAP constant 2025-08-21 14:30:42 -03:00
Rafael Moraes fe6d1e5378 Add fourcc method to MusicVideoCodec enum 2025-08-21 14:30:13 -03:00
Rafael Moraes 3e298425cc Move SONG_CODEC_REGEX_MAP to DownloaderSong class 2025-08-21 14:27:24 -03:00
Rafael Moraes 239bb1255b Refactor synced lyrics file extension handling 2025-08-21 14:26:47 -03:00
Rafael Moraes 873cf48812 Remove unused MP4 tags and lyrics extension maps 2025-08-21 14:26:36 -03:00
Rafael Moraes 80f1c3a4a3 Refactor get_tags to return MediaTags instance 2025-08-21 14:19:45 -03:00
Rafael Moraes b781ccacd5 Use parse_date instead of sanitize_date for releaseDate 2025-08-21 14:18:00 -03:00
Rafael Moraes 807878b8ae Refactor music video tag extraction to use MediaTags 2025-08-21 14:17:48 -03:00
Rafael Moraes e901cfc6e5 Refactor date handling and MP4 tag generation 2025-08-21 14:17:06 -03:00
Rafael Moraes 77c20d76a5 Support datetime.date for MediaTags date field 2025-08-21 14:15:27 -03:00
Rafael Moraes bef05689b4 Refactor tag handling and cover methods in downloader 2025-08-21 14:01:23 -03:00
Rafael Moraes db22291167 Refactor get_tags to use MediaTags dataclass 2025-08-21 14:01:15 -03:00
Rafael Moraes 08146f3a95 Refactor MediaType enum to use integer values 2025-08-21 14:01:08 -03:00
Rafael Moraes 74a28933a2 Refactor MediaTags ID types and MP4 tag conversion 2025-08-21 14:01:00 -03:00
Rafael Moraes 27be0116a0 Refactor MediaRating enum to use integer values 2025-08-21 13:09:59 -03:00
Rafael Moraes aed9bc3bc8 Add MediaTags dataclass with MP4 tag export 2025-08-21 12:56:24 -03:00
Rafael Moraes 5b3ef3a17e Add MediaType and MediaRating enums 2025-08-21 12:34:58 -03:00
Rafael Moraes c13ed8593f Add future annotations import to config_file.py 2025-08-21 10:56:33 -03:00
Rafael Moraes 8b762c21ee Add support for custom config section names 2025-08-21 10:55:55 -03:00
Rafael Moraes e5aa261eea Optimize config file writes for default params 2025-08-20 21:39:27 -03:00
Rafael Moraes f6741a440d Refactor tuple comprehensions to list comprehensions 2025-08-20 21:18:07 -03:00
Rafael Moraes b47b293ef7 Refactor config file handling to use ConfigFile class 2025-08-20 21:09:04 -03:00
Rafael Moraes 82a102a893 Add ConfigFile class for config file management 2025-08-20 21:08:38 -03:00
Rafael Moraes a46370c8fc Refactor exclude_tags handling to use lists 2025-08-20 08:09:42 -03:00
Rafael Moraes 68c51e0ad6 Add minor formatting improvements to cli.py 2025-08-20 08:00:57 -03:00
Rafael Moraes c647872828 Add minor formatting improvements to cli.py 2025-08-20 07:58:39 -03:00
Rafael Moraes b8ae10bc55 Switch config file from JSON to INI format 2025-08-19 21:57:48 -03:00
Rafael Moraes da6c84f3c0 Improve README formatting and config table 2025-08-13 21:15:18 -03:00
Rafael Moraes 636a227ba8 Bump version 2025-08-13 21:05:00 -03:00
Rafael Moraes 71643e04a3 Add checks for Apple Music subscription and restrictions 2025-08-13 21:04:47 -03:00
Rafael Moraes cd995ffcbd Refactor AppleMusicApi authentication and storefront logic 2025-08-13 21:04:39 -03:00
Rafael Moraes eab33bc02c Bump verision 2025-07-20 13:54:25 -03:00
Rafael Moraes ac0d9374fb Refactor get_remuxed_path for older Python compatibility 2025-07-17 16:27:08 -03:00
Rafael Moraes 7a72ecd301 Update project description 2025-07-09 13:37:55 -03:00
Rafael Moraes 2de68d5985 Update project description in pyproject.toml 2025-07-09 13:35:04 -03:00
Rafael Moraes 2e920b7306 Update README description for clarity 2025-07-09 13:31:51 -03:00
Rafael Moraes 8120e9e855 Remove unused import urlparse 2025-07-07 21:35:46 -03:00
Rafael Moraes 047e9dbed8 Pass language to AppleMusicApi.from_netscape_cookies 2025-07-02 10:49:28 -03:00
Rafael Moraes e0bba0857a Update supported URL types in README 2025-07-02 10:42:54 -03:00
Rafael Moraes 6736acc5b0 Set Downloader to quiet according to log_level 2025-07-02 10:41:41 -03:00
Rafael Moraes 47521f1a82 Restrict log-level option to specific choices 2025-07-02 10:40:54 -03:00
Rafael Moraes 4d33f3e101 Support 'albums' as url_type in downloader 2025-07-02 10:29:44 -03:00
Rafael Moraes c827e26e43 Bump version 2025-07-02 10:21:23 -03:00
Rafael Moraes 1042e47c0b Handle missing URL in get_music_video_id_alt 2025-07-02 10:21:12 -03:00
Rafael Moraes 7f56f85f35 Handle library-music-videos in media type check 2025-07-02 10:20:58 -03:00
Rafael Moraes 560585eaa8 Improve README formatting and update usage details 2025-07-02 10:06:32 -03:00
Rafael Moraes 0fc2f75e5b Fix NoneType error in stream_info check 2025-07-02 10:03:28 -03:00
Rafael Moraes 82143df91a Update stream info methods to return None on failure 2025-07-02 10:03:17 -03:00
Rafael Moraes e89d1cb19a Refactor cover image download method naming 2025-07-02 09:58:52 -03:00
Rafael Moraes 01dd232565 Handle 400 status code for covers in downloader requests 2025-07-02 09:58:14 -03:00
Rafael Moraes c9e75ae2a2 Fix handling of missing lyrics in tag and sync logic 2025-07-02 09:50:21 -03:00
Rafael Moraes 9c26646636 Update get_lyrics to return None if no lyrics 2025-07-02 09:50:07 -03:00
Rafael Moraes efc452ba47 Rename tracks_metadata to medias_metadata in DownloadQueue 2025-07-02 09:44:51 -03:00
Rafael Moraes 57e9a1ca98 Fix Apple Music lyrics fetch with correct media ID 2025-07-02 09:44:43 -03:00
Rafael Moraes ca939d5760 Add get_media_id method to Downloader class 2025-07-02 09:44:33 -03:00
Rafael Moraes 6786ae393d Refactor media_id extraction in main download loop 2025-07-02 09:44:23 -03:00
Rafael Moraes 5458d7a1d4 Add 'extend' parameter to API pagination methods 2025-07-02 09:44:08 -03:00
Rafael Moraes 49368e7bc9 Rename tracks_metadata to medias_metadata in downloader 2025-07-02 09:24:06 -03:00
Rafael Moraes 621383a0d8 Refactor track_metadata to media_metadata in main loop 2025-07-02 09:23:56 -03:00
Rafael Moraes e7a055b1b8 Add is_library field to UrlInfo dataclass 2025-07-02 09:16:17 -03:00
Rafael Moraes bc070e4279 Add support for Apple Music library URLs in downloader 2025-07-02 09:16:04 -03:00
Rafael Moraes 2b1d02257c Add methods to fetch library albums and playlists 2025-07-02 09:15:41 -03:00
Rafael Moraes 3256aef9f8 update recommended cookies txt extension for chrome 2025-06-08 14:12:08 -03:00
Rafael Moraes 501cd48474 bump required python version 2025-06-08 14:10:14 -03:00
Rafael Moraes 9f31b99642 bump required python version 2025-06-08 14:09:58 -03:00
Rafael Moraes e9525668d6 refactor logger declaration 2025-06-08 14:09:35 -03:00
Rafael Moraes 60a2ca76fb refactor prompt_path 2025-06-08 14:08:27 -03:00
Rafael Moraes b81f740e2b fix wvd_file prompt 2025-06-02 08:58:08 -03:00
Rafael Moraes f8fc4c66e6 refactor for using streaminfoav 2025-06-02 00:03:07 -03:00
Rafael Moraes 74d1772173 add get_final_file_extension, fix cdm_session closing 2025-06-02 00:02:51 -03:00
Rafael Moraes 63830f2444 refactor for using MediaFileFormat and StreamInfoAv 2025-06-02 00:02:34 -03:00
Rafael Moraes f0838de397 add MediaFileFormat 2025-06-02 00:01:32 -03:00
Rafael Moraes dfe4e29ab5 add StreamInfoAv 2025-06-02 00:01:28 -03:00
Rafael Moraes 0782daed51 add music videos remux formats doc 2025-05-31 17:47:42 -03:00
Rafael Moraes 27ad170adf add remux_format_music_video option 2025-05-31 17:42:59 -03:00
Rafael Moraes b9377dc8b0 fix cookies path prompt 2025-05-31 17:20:21 -03:00
Rafael Moraes 5e413deb6d refactor cli skip_mv 2025-05-31 17:19:55 -03:00
Rafael Moraes af26e939e8 refactor api constructor 2025-05-31 17:19:33 -03:00
Rafael Moraes 66a965ecf6 update setup-python action to version 5 2025-05-12 09:35:36 -03:00
Rafael Moraes 24de608bc8 bump version 2025-05-12 09:28:28 -03:00
Rafael Moraes d0e2e08748 use prompt_path function for wvd and cookies 2025-05-12 09:11:58 -03:00
Rafael Moraes 2223d36d5e added prompt_path function 2025-05-12 09:11:07 -03:00
Rafael Moraes 3077456ab7 update cover path retrieval to use downloader_song 2025-05-09 14:19:39 -03:00
Rafael Moraes bbd96cbe6b remove redundant cover path lines 2025-05-09 14:16:33 -03:00
Rafael Moraes ca16a208ba rename custom_formatter to custom_logger_formatter 2025-05-09 14:07:31 -03:00
Rafael Moraes c32c8622b7 add error handling for missing media-user-token in cookies 2025-05-09 14:04:24 -03:00
Rafael Moraes 132ae0ea56 improve storefront retrieval 2025-05-05 23:04:09 -03:00
Rafael Moraes 70238facac better handling for media that has no cover 2025-02-25 02:35:39 -03:00
Rafael Moraes 4fb1fb609b Update custom_formatter.py 2025-02-23 16:28:38 -03:00
Rafael Moraes f97b3dba14 bump version 2025-02-23 04:36:05 -03:00
Rafael Moraes 2da824ecbc add colorama to dependencies 2025-02-23 04:34:28 -03:00
Rafael Moraes 24810da4b6 replace inline response exception handling with utility function 2025-02-23 04:32:50 -03:00
Rafael Moraes f16a30549c refactor logging color handling to use colorama and add utility function for colored text 2025-02-23 04:30:11 -03:00
Rafael Moraes 2001b19d8f bump version 2025-02-23 03:50:23 -03:00
Rafael Moraes 14814dd2da small refactor on cookies user input 2025-02-23 03:16:03 -03:00
Rafael Moraes 6fad41467f add termcolor to dependencies 2025-02-23 03:14:31 -03:00
Rafael Moraes 0868f1c28c set timezone to utc on parse_datetime_obj_from_timestamp_ttml 2025-02-23 03:12:29 -03:00
Rafael Moraes a964011507 implement custom logging formatter with colored output 2025-02-23 03:11:23 -03:00
Rafael Moraes 3a943d0154 refactor media user token retrieval from cookies 2025-02-23 02:13:09 -03:00
Rafael Moraes 84bf0a3c2b prompt user for cookies file path if not found 2025-02-23 02:12:15 -03:00
Rafael Moraes 93dda6889c update Python version requirement to 3.9 2025-02-16 10:43:05 -03:00
Rafael Moraes d62a1377f8 Update README.md 2025-01-29 16:35:36 -03:00
Rafael Moraes 3a2d521352 bump version to 2.3.9 2025-01-29 16:31:23 -03:00
Rafael Moraes c8f45110bd replace deprecated logger.warn with logger.warning 2025-01-29 16:30:08 -03:00
Rafael Moraes 36925025b7 fix: improve formatting in README.md for download modes section 2025-01-29 16:28:50 -03:00
Rafael Moraes d8937d9805 bump required Python version to 3.9 in pyproject.toml 2025-01-29 16:19:25 -03:00
Rafael Moraes 513db83645 update project description for clarity and completeness 2025-01-29 16:17:53 -03:00
Rafael Moraes 11f9b5a75c Update README.md for improved clarity and formatting 2025-01-29 16:15:43 -03:00
Rafael Moraes 1dd01368c3 Update downloader_song.py 2025-01-27 22:47:39 -03:00
Rafael Moraes 4fc8887101 refactor DRM handling to use widevine_pssh and add support for playready and fairplay keys 2025-01-27 22:47:24 -03:00
Rafael Moraes 9169665579 Merge pull request #159 from shafreeck/main
Fix handling of 'Favorite Songs' playlist
2025-01-27 22:39:56 -03:00
Shafreeck d053db96e8 handle missing curatorName in 'Favorite Songs' playlist
'Favorite Songs' is an automatically generated playlist by
Apple Music that lacks the curatorName attribute. Set the
default curator to 'Apple Music' to maintain consistency
with other system playlists.
2025-01-06 07:19:56 +08:00
Rafael Moraes 1013bd20b9 add mutagen to depedencies 2024-11-23 17:05:39 -03:00
Rafael Moraes 2c1fa9d99b bump version 2024-11-23 17:05:28 -03:00
Rafael Moraes fc1c161e30 Update README.md 2024-11-02 03:54:37 -03:00
Rafael Moraes 2f87902163 Update README.md 2024-11-01 13:38:54 -03:00
Rafael Moraes 9f7bb0d404 Update README.md 2024-11-01 13:37:28 -03:00
20 changed files with 2422 additions and 1205 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
- uses: actions/checkout@v3
- name: Set up Python 3.9
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: 3.9
cache: pip
+229 -187
View File
@@ -1,228 +1,270 @@
# Glomatico's Apple Music Downloader
A Python CLI app for downloading Apple Music songs/music videos/posts.
**Discord Server:** https://discord.gg/aBjMEZ9tnq
A command-line app for downloading Apple Music songs, music videos and post videos.
**Join our Discord Server:** <https://discord.gg/aBjMEZ9tnq>
## 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
- **High-Quality Songs**: Download songs in AAC 256kbps and other codecs.
- **High-Quality Music Videos**: Download music videos in resolutions up to 4K.
- **Synced Lyrics**: Download synced lyrics in LRC, SRT, or TTML formats.
- **Artist Support**: Download all albums or music videos from an artist using their link.
- **Highly Customizable**: Extensive configuration options for advanced users.
## Prerequisites
* Python 3.8 or higher
* The cookies file of your Apple Music browser session in Netscape format (requires an active subscription)
* To export your cookies, use one of the following browser extensions while signed in to Apple Music:
* Firefox: https://addons.mozilla.org/addon/export-cookies-txt
* Chromium based browsers: https://chrome.google.com/webstore/detail/gdocmgbfkjnnpapoeobnolbbkoibbcif
* FFmpeg on your system PATH
* 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/
- **Python 3.10 or higher** installed on your system.
- The **cookies file** of your Apple Music browser session in Netscape format. Use one of the following extensions at the Apple Music homepage while logged in and with an active subscription to export the cookies:
- **Firefox**: [Export Cookies](https://addons.mozilla.org/addon/export-cookies-txt).
- **Chromium-based Browsers**: [Get cookies.txt LOCALLY](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc).
- **FFmpeg** on your system PATH. Use one of the recommended builds:
- **Windows**: [AnimMouse's FFmpeg Builds](https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases).
- **Linux**: [John Van Sickle's FFmpeg Builds](https://johnvansickle.com/ffmpeg/).
### Optional dependencies
The following tools are optional but required for specific features. Add them to your systems PATH or specify their paths using command-line arguments or the config file.
* [MP4Box](https://gpac.io/downloads/gpac-nightly-builds/)
* Required when setting `mp4box` as remux mode.
* [mp4decrypt](https://www.bento4.com/downloads/)
* Required when setting `mp4box` as remux mode, for downloading songs in non-legacy formats and for downloading music videos.
* [N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest)
* Required when setting `nm3u8dlre` as download mode.
The following tools are optional but required for specific features. Add them to your system's PATH or specify their paths using command-line arguments or the config file.
- [mp4decrypt](https://www.bento4.com/downloads/): Required for `mp4box` remux mode, music video downloads, and experimental song codecs.
- [MP4Box](https://gpac.io/downloads/gpac-nightly-builds/): Required for `mp4box` remux mode.
- [N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest): Required for `nm3u8dlre` download mode.
## Installation
1. Install the package `gamdl` using pip
```bash
pip install gamdl
```
```bash
pip install gamdl
```
2. Set up the cookies file.
* You can either move to the current directory from which you will be running Gamdl as `cookies.txt` or specify its path using the command-line arguments/config file.
- Move the cookies file to the directory where you'll run Gamdl and rename it to `cookies.txt`.
- Alternatively, specify the path to the cookies file using command-line arguments or the config file.
## Usage
Run Gamdl with the following command:
```bash
gamdl [OPTIONS] URLS...
```
### Supported URL types
Gamdl supports the following types of URLs:
* Song
* Album
* Playlist
* Music video
* Artist
* Post video/extra video
- Song
- Public/Library Album
- Public/Library Playlist
- Music video
- Artist
- Post video
### Examples
* Download a song
```bash
gamdl "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
```
* Download an album
```bash
gamdl "https://music.apple.com/us/album/whenever-you-need-somebody-2022-remaster/1624945511"
```
* Choose which albums or music videos to download from an artist
```bash
gamdl "https://music.apple.com/us/artist/rick-astley/669771"
```
- Download a Song:
```bash
gamdl "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
```
- Download an Album:
```bash
gamdl "https://music.apple.com/us/album/whenever-you-need-somebody-2022-remaster/1624945511"
```
- Download from an Artist:
```bash
gamdl "https://music.apple.com/us/artist/rick-astley/669771"
```
### Interactive prompt controls
* Arrow keys - Move selection
* Space - Toggle selection
* Ctrl + A - Select all
* Enter - Confirm selection
- **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.
Gamdl can be configured by using the command-line arguments or the config file.
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` |
The config file is created automatically when you run Gamdl for the first time at `~/.gamdl/config.ini` on Linux and `%USERPROFILE%\.gamdl\config.ini` 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` |
| `--read-urls-as-txt`, `-r` / - | Interpret URLs as paths to text files containing URLs separated by newlines | `false` |
| `--config-path` / - | Path to config file. | `<home>/.gamdl/config.ini` |
| `--log-level` / `log_level` | Log level. | `INFO` |
| `--no-exceptions` / `no_exceptions` | Don't print exceptions. | `false` |
| `--cookies-path`, `-c` / `cookies_path` | Path to .txt cookies file. | `./cookies.txt` |
| `--language`, `-l` / `language` | Metadata language as an ISO-2A language code (don't always work for videos). | `en-US` |
| `--output-path`, `-o` / `output_path` | Path to output directory. | `./Apple Music` |
| `--temp-path` / `temp_path` | Path to temporary directory. | `./temp` |
| `--wvd-path` / `wvd_path` | Path to .wvd file. | `null` |
| `--overwrite` / `overwrite` | Overwrite existing files. | `false` |
| `--save-cover`, `-s` / `save_cover` | Save cover as a separate file. | `false` |
| `--save-playlist` / `save_playlist` | Save a M3U8 playlist file when downloading a playlist. | `false` |
| `--no-synced-lyrics` / `no_synced_lyrics` | Don't download the synced lyrics. | `false` |
| `--synced-lyrics-only` / `synced_lyrics_only` | Download only the synced lyrics. | `false` |
| `--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_artist}/{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` | Comma-separated music video codec priority. | `h264,h265` |
| `--remux-format-music-video` / `remux_format_music_video` | Music video remux format. | `m4v` |
| `--quality-post` / `quality_post` | Post video quality. | `best` |
| `--resolution` / `resolution` | Target video resolution for music videos. | `1080p` |
| `--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
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`: Supports strftime formats. For example, `{date:%Y}` will be replaced with the year of the release 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`
- `all`: Skip tagging.
### Remux Modes
- `ffmpeg`: Default remuxing mode.
- `mp4box`: Alternative remuxing mode (doesn't convert closed captions in music videos).
### Download modes
The following download modes are available:
* `ytdlp`
* `nm3u8dlre`
* Faster than `ytdlp`
* Requires FFmpeg
* Can be obtained from here: https://github.com/nilaoda/N_m3u8DL-RE/releases
- `ytdlp`: Default download mode.
- `nm3u8dlre`: Faster than `ytdlp`.
### Song codecs
The following codecs are available:
* `aac-legacy`
* `aac-he-legacy`
### Song Codecs
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.
- Supported Codecs:
- `aac-legacy`: AAC 256kbps 44.1kHz.
- `aac-he-legacy`: AAC-HE 64kbps 44.1kHz.
- Experimental Codecs (not guaranteed to work due to API limitations):
- `aac`: AAC 256kbps up to 48kHz.
- `aac-he`: AAC-HE 64kbps up to 48kHz.
- `aac-binaural`: AAC 256kbps binaural.
- `aac-downmix`: AAC 256kbps downmix.
- `aac-he-binaural`: AAC-HE 64kbps binaural.
- `aac-he-downmix`: AAC-HE 64kbps downmix.
- `atmos`: Dolby Atmos 768kbps.
- `ac3`: AC3 640kbps.
- `alac`: ALAC up to 24-bit/192 kHz (no reports of successful downloads have been made).
- `ask`: Prompt to choose available audio codec.
### Music Videos Codecs
- `h264`
- `h265`
- `ask`: Prompt to choose available video and audio codecs.
### Music Videos Remux Formats
- `m4v`: Default remux format.
- `mp4`
### Music Videos Maximum Resolutions
- H.264 Resolutions:
- `240p`
- `360p`
- `480p`
- `540p`
- `720p`
- `1080p`
- H.265-only Resolutions:
- `1440p`
- `2160p`
### 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.
- `best`: Up to 1080p with AAC 256kbps.
- `ask`: Prompt to choose available video quality.
### Synced lyrics formats
The following synced lyrics formats are available:
* `lrc`
* `srt`
* `ttml`
* Native format for Apple Music synced lyrics.
* Highly unsupported by most media players.
- `lrc`: Lightweight and widely supported.
- `srt`: SubRip format (has more accurate timestamps).
- `ttml`: Native Apple Music format (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.
- `jpg`: Default format.
- `png`: Lossless format.
- `raw`: Raw cover without processing (requires `save_cover` to save separately).
## Embedding
Gamdl can be used as a library in Python scripts. Here's a basic example of downloading a song by its ID:
```python
from gamdl import AppleMusicApi, ItunesApi, Downloader, DownloaderSong
apple_music_api = AppleMusicApi.from_netscape_cookies(cookies_path="cookies.txt")
itunes_api = ItunesApi(
storefront=apple_music_api.storefront,
language=apple_music_api.language,
)
downloader = Downloader(
apple_music_api=apple_music_api,
itunes_api=itunes_api,
)
downloader.set_cdm()
downloader_song = DownloaderSong(downloader=downloader)
downloader_song.download(media_id="1624945512")
```
+8 -1
View File
@@ -1 +1,8 @@
__version__ = "2.3.7"
from .apple_music_api import AppleMusicApi
from .downloader import Downloader
from .downloader_music_video import DownloaderMusicVideo
from .downloader_post import DownloaderPost
from .downloader_song import DownloaderSong
from .itunes_api import ItunesApi
__version__ = "2.6"
+170 -42
View File
@@ -6,12 +6,15 @@ import time
import typing
from http.cookiejar import MozillaCookieJar
from pathlib import Path
from urllib.parse import urlparse
import requests
from .utils import raise_response_exception
class AppleMusicApi:
APPLE_MUSIC_HOMEPAGE_URL = "https://beta.music.apple.com"
APPLE_MUSIC_HOMEPAGE_URL = "https://music.apple.com"
AMP_API_URL = "https://amp-api.music.apple.com"
WEBPLAYBACK_API_URL = (
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback"
@@ -21,41 +24,69 @@ class AppleMusicApi:
def __init__(
self,
cookies_path: Path | None = Path("./cookies.txt"),
storefront: None | str = None,
storefront: str,
media_user_token: str | None = None,
language: str = "en-US",
):
self.cookies_path = cookies_path
self.media_user_token = media_user_token
self.storefront = storefront
self.language = language
self._set_session()
@classmethod
def from_netscape_cookies(
cls,
cookies_path: Path = Path("./cookies.txt"),
language: str = "en-US",
) -> AppleMusicApi:
parse_cookie = lambda name: next(
(
cookie.value
for cookie in cookies
if cookie.name == name
and cookie.domain.endswith(
urlparse(cls.APPLE_MUSIC_HOMEPAGE_URL).netloc
)
),
None,
)
cookies = MozillaCookieJar(cookies_path)
cookies.load(ignore_discard=True, ignore_expires=True)
media_user_token = parse_cookie("media-user-token")
if not media_user_token:
raise ValueError(
'"media-user-token" cookie not found in cookies. '
"Make sure you have exported the cookies from Apple Music webpage and are logged in "
"with an active subscription."
)
return cls(
storefront=None,
media_user_token=media_user_token,
language=language,
)
def _set_session(self):
self.session = requests.Session()
if self.cookies_path:
cookies = MozillaCookieJar(self.cookies_path)
cookies.load(ignore_discard=True, ignore_expires=True)
self.session.cookies.update(cookies)
self.storefront = self.session.cookies.get_dict()["itua"]
self.session.headers.update(
{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0",
"Accept": "application/json",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"content-type": "application/json",
"Media-User-Token": self.session.cookies.get_dict().get(
"media-user-token", ""
),
"x-apple-renewal": "true",
"DNT": "1",
"Connection": "keep-alive",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-site",
"accept": "*/*",
"accept-language": "en-US",
"origin": self.APPLE_MUSIC_HOMEPAGE_URL,
"priority": "u=1, i",
"referer": self.APPLE_MUSIC_HOMEPAGE_URL,
"sec-ch-ua": '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
}
)
home_page = self.session.get(self.APPLE_MUSIC_HOMEPAGE_URL).text
index_js_uri = re.search(
r"/(assets/index-legacy-[^/]+\.js)",
@@ -65,16 +96,23 @@ class AppleMusicApi:
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}"
)
if self.media_user_token:
self.session.cookies.update(
{
"media-user-token": self.media_user_token,
}
)
self._set_account_info()
def _check_amp_api_response(self, response: requests.Response):
def _set_account_info(self):
self.account_info = self.get_account_info()
self.storefront = self.account_info["meta"]["subscription"]["storefront"]
def _check_amp_api_response(self, response: requests.Response) -> None:
try:
response.raise_for_status()
response_dict = response.json()
@@ -84,7 +122,16 @@ class AppleMusicApi:
requests.exceptions.JSONDecodeError,
AssertionError,
):
self._raise_response_exception(response)
raise_response_exception(response)
def get_account_info(self, meta: str = "subscription") -> dict:
response = self.session.get(
f"{self.AMP_API_URL}/v1/me/account",
params={"meta": meta},
)
self._check_amp_api_response(response)
return response.json()
def get_artist(
self,
@@ -92,7 +139,7 @@ class AppleMusicApi:
include: str = "albums,music-videos",
limit: int = 100,
fetch_all: bool = True,
) -> dict:
) -> dict | None:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/artists/{artist_id}",
params={
@@ -100,13 +147,17 @@ class AppleMusicApi:
**{f"limit[{_include}]": limit for _include in include.split(",")},
},
)
if response.status_code == 404:
return None
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
@@ -116,7 +167,7 @@ class AppleMusicApi:
song_id: str,
extend: str = "extendedAssetUrls",
include: str = "lyrics,albums",
) -> dict:
) -> dict | None:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/songs/{song_id}",
params={
@@ -124,31 +175,40 @@ class AppleMusicApi:
"extend": extend,
},
)
if response.status_code == 404:
return None
self._check_amp_api_response(response)
return response.json()["data"][0]
def get_music_video(
self,
music_video_id: str,
include: str = "albums",
) -> dict:
) -> dict | None:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/music-videos/{music_video_id}",
params={
"include": include,
},
)
if response.status_code == 404:
return None
self._check_amp_api_response(response)
return response.json()["data"][0]
def get_post(
self,
post_id: str,
) -> dict:
) -> dict | None:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/uploaded-videos/{post_id}"
)
if response.status_code == 404:
return None
self._check_amp_api_response(response)
return response.json()["data"][0]
@functools.lru_cache()
@@ -156,14 +216,17 @@ class AppleMusicApi:
self,
album_id: str,
extend: str = "extendedAssetUrls",
) -> dict:
) -> dict | None:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/albums/{album_id}",
params={
"extend": extend,
},
)
if response.status_code == 404:
return None
self._check_amp_api_response(response)
return response.json()["data"][0]
def get_playlist(
@@ -172,7 +235,7 @@ class AppleMusicApi:
limit_tracks: int = 300,
extend: str = "extendedAssetUrls",
fetch_all: bool = True,
) -> dict:
) -> dict | None:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/playlists/{playlist_id}",
params={
@@ -180,12 +243,16 @@ class AppleMusicApi:
"limit[tracks]": limit_tracks,
},
)
if response.status_code == 404:
return None
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,
extend,
):
playlist["relationships"]["tracks"]["data"].extend(additional_data)
return playlist
@@ -196,8 +263,7 @@ class AppleMusicApi:
types: str = "songs,albums,artists,playlists",
limit: int = 25,
offset: int = 0,
) -> dict:
) -> dict | None:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/search",
params={
@@ -207,29 +273,87 @@ class AppleMusicApi:
"offset": offset,
},
)
if response.status_code == 404:
return None
self._check_amp_api_response(response)
return response.json()["results"]
def get_library_album(
self,
album_id: str,
extend: str = "extendedAssetUrls",
) -> dict | None:
response = self.session.get(
f"{self.AMP_API_URL}/v1/me/library/albums/{album_id}",
params={
"extend": extend,
},
)
if response.status_code == 404:
return None
self._check_amp_api_response(response)
return response.json()["data"][0]
def get_library_playlist(
self,
playlist_id: str,
include: str = "tracks",
limit: int = 100,
extend: str = "extendedAssetUrls",
fetch_all: bool = True,
) -> dict | None:
response = self.session.get(
f"{self.AMP_API_URL}/v1/me/library/playlists/{playlist_id}",
params={
"include": include,
**{f"limit[{_include}]": limit for _include in include.split(",")},
"extend": extend,
},
)
if response.status_code == 404:
return None
self._check_amp_api_response(response)
playlist = response.json()["data"][0]
if fetch_all:
for additional_data in self._extend_api_data(
playlist["relationships"]["tracks"],
limit,
extend,
):
playlist["relationships"]["tracks"]["data"].extend(additional_data)
return playlist
def _extend_api_data(
self,
api_response: dict,
limit: int,
extend: str,
) -> typing.Generator[list[dict], None, None]:
next_uri = api_response.get("next")
while next_uri:
playlist_next = self._get_next_uri_response(next_uri, limit)
playlist_next = self._get_next_uri_response(next_uri, limit, extend)
yield playlist_next["data"]
next_uri = playlist_next.get("next")
time.sleep(self.WAIT_TIME)
def _get_next_uri_response(self, next_uri: str, limit: int) -> dict:
def _get_next_uri_response(
self,
next_uri: str,
limit: int,
extend: str,
) -> dict:
response = self.session.get(
self.AMP_API_URL + next_uri,
params={
"limit": limit,
"extend": extend,
},
)
self._check_amp_api_response(response)
return response.json()
def get_webplayback(
@@ -243,6 +367,7 @@ class AppleMusicApi:
"language": self.language,
},
)
try:
response.raise_for_status()
response_dict = response.json()
@@ -253,7 +378,8 @@ class AppleMusicApi:
requests.exceptions.JSONDecodeError,
AssertionError,
):
self._raise_response_exception(response)
raise_response_exception(response)
return webplayback[0]
def get_widevine_license(
@@ -273,6 +399,7 @@ class AppleMusicApi:
"user-initiated": True,
},
)
try:
response.raise_for_status()
response_dict = response.json()
@@ -283,5 +410,6 @@ class AppleMusicApi:
requests.exceptions.JSONDecodeError,
AssertionError,
):
self._raise_response_exception(response)
raise_response_exception(response)
return widevine_license
+231 -373
View File
@@ -1,48 +1,76 @@
from __future__ import annotations
import inspect
import json
import logging
from enum import Enum
import typing
from pathlib import Path
import click
import colorama
from . import __version__
from .apple_music_api import AppleMusicApi
from .config_file import ConfigFile
from .constants import *
from .custom_logger_formatter import CustomLoggerFormatter
from .downloader import Downloader
from .downloader_music_video import DownloaderMusicVideo
from .downloader_post import DownloaderPost
from .downloader_song import DownloaderSong
from .downloader_song_legacy import DownloaderSongLegacy
from .enums import CoverFormat, DownloadMode, MusicVideoCodec, PostQuality, RemuxMode
from .enums import (
CoverFormat,
DownloadMode,
MusicVideoCodec,
MusicVideoResolution,
PostQuality,
RemuxFormatMusicVideo,
RemuxMode,
SongCodec,
SyncedLyricsFormat,
)
from .itunes_api import ItunesApi
from .utils import color_text, prompt_path
apple_music_api_sig = inspect.signature(AppleMusicApi.__init__)
apple_music_api_from_netscape_cookies_sig = inspect.signature(
AppleMusicApi.from_netscape_cookies
)
downloader_sig = inspect.signature(Downloader.__init__)
downloader_song_sig = inspect.signature(DownloaderSong.__init__)
downloader_music_video_sig = inspect.signature(DownloaderMusicVideo.__init__)
downloader_post_sig = inspect.signature(DownloaderPost.__init__)
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
logger = logging.getLogger("gamdl")
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))
class Csv(click.ParamType):
name = "csv"
def __init__(
self,
subtype: typing.Any,
) -> None:
self.subtype = subtype
def convert(
self,
value: str | typing.Any,
param: click.Parameter,
ctx: click.Context,
) -> list[typing.Any]:
if not isinstance(value, str):
return value
items = [v.strip() for v in value.split(",") if v.strip()]
result = []
for item in items:
try:
result.append(self.subtype(item))
except ValueError as e:
self.fail(
f"'{item}' is not a valid value for {self.subtype.__name__}",
param,
ctx,
)
return result
def load_config_file(
@@ -52,16 +80,27 @@ def load_config_file(
) -> 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])
filtered_params = [
param
for param in ctx.command.params
if param.name not in EXCLUDED_CONFIG_FILE_PARAMS
]
config_file = ConfigFile(ctx.params["config_path"])
config_file.add_params_default_to_config(
filtered_params,
)
parsed_params = config_file.parse_params_from_config(
[
param
for param in filtered_params
if ctx.get_parameter_source(param.name)
!= click.core.ParameterSource.COMMANDLINE
]
)
ctx.params.update(parsed_params)
return ctx
@@ -80,47 +119,21 @@ def load_config_file(
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",
default=Path.home() / ".gamdl" / "config.ini",
help="Path to config file.",
)
@click.option(
"--log-level",
type=str,
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]),
default="INFO",
help="Log level.",
)
@@ -134,14 +147,16 @@ def load_config_file(
"--cookies-path",
"-c",
type=Path,
default=apple_music_api_sig.parameters["cookies_path"].default,
default=apple_music_api_from_netscape_cookies_sig.parameters[
"cookies_path"
].default,
help="Path to .txt cookies file.",
)
@click.option(
"--language",
"-l",
type=str,
default=apple_music_api_sig.parameters["language"].default,
default=apple_music_api_from_netscape_cookies_sig.parameters["language"].default,
help="Metadata language as an ISO-2A language code (don't always work for videos).",
)
# Downloader specific options
@@ -164,6 +179,37 @@ def load_config_file(
default=downloader_sig.parameters["wvd_path"].default,
help="Path to .wvd file.",
)
@click.option(
"--overwrite",
is_flag=True,
help="Overwrite existing files.",
default=downloader_sig.parameters["overwrite"].default,
)
@click.option(
"--save-cover",
"-s",
is_flag=True,
help="Save cover as a separate file.",
default=downloader_sig.parameters["save_cover"].default,
)
@click.option(
"--save-playlist",
is_flag=True,
help="Save a M3U8 playlist file when downloading a playlist.",
default=downloader_sig.parameters["save_playlist"].default,
)
@click.option(
"--no-synced-lyrics",
is_flag=True,
help="Don't download the synced lyrics.",
default=downloader_sig.parameters["no_synced_lyrics"].default,
)
@click.option(
"--synced-lyrics-only",
is_flag=True,
help="Download only the synced lyrics.",
default=downloader_sig.parameters["synced_lyrics_only"].default,
)
@click.option(
"--nm3u8dlre-path",
type=str,
@@ -256,7 +302,7 @@ def load_config_file(
)
@click.option(
"--exclude-tags",
type=str,
type=Csv(str),
default=downloader_sig.parameters["exclude_tags"].default,
help="Comma-separated tags to exclude.",
)
@@ -288,9 +334,21 @@ def load_config_file(
# DownloaderMusicVideo specific options
@click.option(
"--codec-music-video",
type=MusicVideoCodec,
type=Csv(MusicVideoCodec),
default=downloader_music_video_sig.parameters["codec"].default,
help="Music video codec.",
help="Comma-separated music video codec priority.",
)
@click.option(
"--remux-format-music-video",
type=RemuxFormatMusicVideo,
default=downloader_music_video_sig.parameters["remux_format"].default,
help="Music video remux format.",
)
@click.option(
"--resolution",
type=MusicVideoResolution,
default=downloader_music_video_sig.parameters["resolution"].default,
help="Target video resolution for music videos.",
)
# DownloaderPost specific options
@click.option(
@@ -310,12 +368,7 @@ def load_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,
no_exceptions: bool,
@@ -324,6 +377,11 @@ def main(
output_path: Path,
temp_path: Path,
wvd_path: Path,
overwrite: bool,
save_cover: bool,
save_playlist: bool,
no_synced_lyrics: bool,
synced_lyrics_only: bool,
nm3u8dlre_path: str,
mp4decrypt_path: str,
ffmpeg_path: str,
@@ -339,39 +397,61 @@ def main(
template_file_no_album: str,
template_file_playlist: str,
template_date: str,
exclude_tags: str,
exclude_tags: list[str],
cover_size: int,
truncate: int,
codec_song: SongCodec,
synced_lyrics_format: SyncedLyricsFormat,
codec_music_video: MusicVideoCodec,
codec_music_video: list[MusicVideoCodec],
remux_format_music_video: RemuxFormatMusicVideo,
resolution: MusicVideoResolution,
quality_post: PostQuality,
no_config_file: bool,
):
logging.basicConfig(
format="[%(levelname)-8s %(asctime)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger(__name__)
colorama.just_fix_windows_console()
logger.setLevel(log_level)
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(CustomLoggerFormatter())
logger.addHandler(stream_handler)
cookies_path = prompt_path(True, cookies_path, "Cookies file")
if wvd_path:
wvd_path = prompt_path(True, wvd_path, ".wvd file")
logger.info("Starting Gamdl")
if not cookies_path.exists():
logger.critical(X_NOT_FOUND_STRING.format("Cookies file", cookies_path))
return
apple_music_api = AppleMusicApi(
apple_music_api = AppleMusicApi.from_netscape_cookies(
cookies_path,
language=language,
language,
)
if not apple_music_api.account_info["meta"]["subscription"]["active"]:
logger.critical(
"No active Apple Music subscription found, you won't be able to download"
" anything"
)
return
if apple_music_api.account_info["data"][0]["attributes"].get("restrictions"):
logger.warning(
"Your account has content restrictions enabled, some content may not be"
" downloadable"
)
itunes_api = ItunesApi(
apple_music_api.storefront,
apple_music_api.language,
)
downloader = Downloader(
apple_music_api,
itunes_api,
output_path,
temp_path,
wvd_path,
overwrite,
save_cover,
save_playlist,
no_synced_lyrics,
synced_lyrics_only,
nm3u8dlre_path,
mp4decrypt_path,
ffmpeg_path,
@@ -390,38 +470,42 @@ def main(
exclude_tags,
cover_size,
truncate,
log_level in ("WARNING", "ERROR"),
)
downloader_song = DownloaderSong(
downloader,
codec_song,
synced_lyrics_format,
)
downloader_song_legacy = DownloaderSongLegacy(
downloader,
codec_song,
)
downloader_music_video = DownloaderMusicVideo(
downloader,
codec_music_video,
remux_format_music_video,
resolution,
)
downloader_post = DownloaderPost(
downloader,
quality_post,
)
skip_mv = False
if not synced_lyrics_only:
if wvd_path and not wvd_path.exists():
logger.critical(X_NOT_FOUND_STRING.format(".wvd file", wvd_path))
return
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
@@ -433,39 +517,51 @@ def main(
):
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(
logger.warning(
X_NOT_FOUND_STRING.format("mp4decrypt", mp4decrypt_path)
+ ", music videos will not be downloaded"
)
skip_mv = True
else:
skip_mv = False
if codec_song not in LEGACY_CODECS:
logger.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."
if not codec_song.is_legacy():
logger.warning(
"You have chosen an experimental song codec. "
"They're not guaranteed to work due to API limitations."
)
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
error_count = 0
for url_index, url in enumerate(urls, start=1):
url_progress = f"URL {url_index}/{len(urls)}"
url_progress = color_text(f"URL {url_index}/{len(urls)}", colorama.Style.DIM)
try:
logger.info(f'({url_progress}) Checking "{url}"')
url_info = downloader.get_url_info(url)
url_info = downloader.parse_url_info(url)
if not url_info:
error_count += 1
logger.error(f"({url_progress}) Invalid URL, skipping")
continue
download_queue = downloader.get_download_queue(url_info)
download_queue_tracks_metadata = download_queue.tracks_metadata
download_queue_medias_metadata = download_queue.medias_metadata
if not download_queue_medias_metadata[0]:
error_count += 1
logger.error(f"({url_progress}) Media not found, skipping")
continue
except Exception as e:
error_count += 1
logger.error(
@@ -473,29 +569,27 @@ def main(
exc_info=not no_exceptions,
)
continue
for download_index, track_metadata in enumerate(
download_queue_tracks_metadata, start=1
for download_index, media_metadata in enumerate(
download_queue_medias_metadata,
start=1,
):
queue_progress = f"Track {download_index}/{len(download_queue_tracks_metadata)} from URL {url_index}/{len(urls)}"
queue_progress = color_text(
f"Track {download_index}/{len(download_queue_medias_metadata)} from URL {url_index}/{len(urls)}",
colorama.Style.DIM,
)
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"]}"'
f'({queue_progress}) "{media_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)
(
synced_lyrics_only
and media_metadata["type"] not in {"songs", "library-songs"}
)
or (media_metadata["type"] == "music-videos" and skip_mv)
or (
track_metadata["type"] == "music-videos"
media_metadata["type"] == "music-videos"
and url_info.type == "album"
and not disable_music_video_skip
)
@@ -504,268 +598,32 @@ def main(
f"({queue_progress}) Track is not downloadable with current configuration, skipping"
)
continue
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
if media_metadata["type"] in {"songs", "library-songs"}:
downloader_song.download(
media_metadata=media_metadata,
playlist_attributes=download_queue.playlist_attributes,
playlist_track=download_index,
)
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 media_metadata["type"] in {"music-videos", "library-music-videos"}:
downloader_music_video.download(
media_metadata=media_metadata,
playlist_attributes=download_queue.playlist_attributes,
playlist_track=download_index,
)
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
)
if music_video_id_alt == track_metadata["id"]:
stream_url = (
downloader_music_video.get_stream_url_from_itunes_page(
itunes_page
)
)
else:
logger.debug("Getting webplayback")
webplayback = apple_music_api.get_webplayback(
track_metadata["id"]
)
stream_url = (
downloader_music_video.get_stream_url_from_webplayback(
webplayback
)
)
logger.debug("Getting M3U8 data")
m3u8_data = downloader_music_video.get_m3u8_master_data(stream_url)
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_data),
downloader_music_video.get_stream_info_audio(m3u8_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,
)
elif track_metadata["type"] == "uploaded-videos":
stream_url = downloader_post.get_stream_url(track_metadata)
tags = downloader_post.get_tags(track_metadata)
final_path = downloader.get_final_path(tags, ".m4v")
cover_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:
remuxed_path = downloader_post.get_post_temp_path(
track_metadata["id"]
)
logger.debug(f'Downloading to "{remuxed_path}"')
downloader.download_ytdlp(remuxed_path, stream_url)
if synced_lyrics_only or not save_cover:
pass
elif cover_path.exists() and not overwrite:
logger.debug(f'Cover already exists at "{cover_path}", skipping')
else:
logger.debug(f'Saving cover to "{cover_path}"')
downloader.save_cover(cover_path, cover_url)
if remuxed_path:
logger.debug("Applying tags")
downloader.apply_tags(remuxed_path, tags, cover_url)
logger.debug(f'Moving to "{final_path}"')
downloader.move_to_output_path(remuxed_path, final_path)
if (
not synced_lyrics_only
and save_playlist
and download_queue.playlist_attributes
):
playlist_file_path = downloader.get_playlist_file_path(tags)
logger.debug(f'Updating M3U8 playlist from "{playlist_file_path}"')
downloader.update_playlist_file(
playlist_file_path,
final_path,
playlist_track,
if media_metadata["type"] == "uploaded-videos":
downloader_post.download(
media_metadata=media_metadata,
)
except KeyboardInterrupt:
exit(0)
except Exception as e:
error_count += 1
logger.error(
f'({queue_progress}) Failed to download "{track_metadata["attributes"]["name"]}"',
f'({queue_progress}) Failed to download "{media_metadata["attributes"]["name"]}"',
exc_info=not no_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))")
logger.info(f"Done, {error_count} error(s) occurred")
+101
View File
@@ -0,0 +1,101 @@
from __future__ import annotations
import configparser
from enum import Enum
from pathlib import Path
import click
import typing
class ConfigFile:
def __init__(
self,
config_path: Path,
section_name: str = "gamdl",
) -> None:
self.config_path = config_path
self.section_name = section_name
self._read_config_file()
def _read_config_file(self) -> None:
self.config = configparser.ConfigParser(interpolation=None)
if self.config_path.exists():
self.config.read(self.config_path, encoding="utf-8")
else:
self.config_path.parent.mkdir(parents=True, exist_ok=True)
if not self.config.has_section(self.section_name):
self.config.add_section(self.section_name)
def _write_config_file(self) -> None:
with self.config_path.open("w", encoding="utf-8") as config_file:
self.config.write(config_file)
def _serialize_param_default(self, param: click.Parameter) -> str:
if not isinstance(param.default, (list, tuple)):
param_default = [param.default]
else:
param_default = param.default
if not param_default:
return ""
first = param_default[0]
if isinstance(first, Enum):
return ",".join(str(item.value) for item in param_default)
if isinstance(first, bool):
return ",".join(str(item).lower() for item in param_default)
if first is None:
return "null"
return ",".join(str(item) for item in param_default)
def _add_param_default_to_config(
self,
param: click.Parameter,
) -> bool:
if self.config[self.section_name].get(param.name):
return False
value = self._serialize_param_default(param)
self.config[self.section_name][param.name] = value
return True
def _parse_param_from_config(
self,
param: click.Parameter,
) -> typing.Any:
value = self.config[self.section_name].get(param.name)
if value == "null":
return None
return param.type_cast_value(None, value)
def add_params_default_to_config(
self,
params: list[click.Parameter],
) -> None:
has_changes = False
for param in params:
has_changes = self._add_param_default_to_config(param) or has_changes
if has_changes:
self._write_config_file()
def parse_params_from_config(
self,
params: list[click.Parameter],
) -> dict[str, typing.Any]:
parsed_params = {}
for param in params:
parsed_params[param.name] = self._parse_param_from_config(param)
return parsed_params
-62
View File
@@ -1,5 +1,3 @@
from gamdl.enums import MusicVideoCodec, SongCodec, SyncedLyricsFormat
STOREFRONT_IDS = {
"AE": "143481-2,32",
"AG": "143540-2,32",
@@ -158,61 +156,6 @@ STOREFRONT_IDS = {
"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",
@@ -224,8 +167,3 @@ EXCLUDED_CONFIG_FILE_PARAMS = (
)
X_NOT_FOUND_STRING = '{} not found at "{}"'
LEGACY_CODECS = [
SongCodec.AAC_LEGACY,
SongCodec.AAC_HE_LEGACY,
]
+24
View File
@@ -0,0 +1,24 @@
import logging
import colorama
from .utils import color_text
class CustomLoggerFormatter(logging.Formatter):
base_format = "[%(levelname)-8s %(asctime)s]"
format_colors = {
logging.DEBUG: colorama.Style.DIM,
logging.INFO: colorama.Fore.GREEN,
logging.WARNING: colorama.Fore.YELLOW,
logging.ERROR: colorama.Fore.RED,
logging.CRITICAL: colorama.Fore.RED,
}
date_format = "%H:%M:%S"
def format(self, record: logging.LogRecord) -> str:
return logging.Formatter(
color_text(self.base_format, self.format_colors.get(record.levelno))
+ " %(message)s",
datefmt=self.date_format,
).format(record)
+315 -122
View File
@@ -4,12 +4,15 @@ import base64
import datetime
import functools
import io
import logging
import re
import shutil
import subprocess
import typing
import uuid
from pathlib import Path
import colorama
import requests
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
@@ -19,25 +22,55 @@ 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 .enums import CoverFormat, DownloadMode, MediaFileFormat, RemuxMode
from .hardcoded_wvd import HARDCODED_WVD
from .itunes_api import ItunesApi
from .models import DownloadQueue, UrlInfo
from .models import (
DecryptionKey,
DownloadInfo,
DownloadQueue,
MediaTags,
PlaylistTags,
UrlInfo,
)
from .utils import color_text, raise_response_exception
logger = logging.getLogger("gamdl")
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]*)?"
VALID_URL_RE = (
r"("
r"/(?P<storefront>[a-z]{2})"
r"/(?P<type>artist|album|playlist|song|music-video|post)"
r"(?:/(?P<slug>[a-z0-9-]+))?"
r"/(?P<id>[0-9]+|pl\.[0-9a-z]{32}|pl\.u-[a-zA-Z0-9]{15})"
r"(?:\?i=(?P<sub_id>[0-9]+))?"
r")|("
r"(?:/(?P<library_storefront>[a-z]{2}))?"
r"/library/(?P<library_type>|playlist|albums)"
r"/(?P<library_id>p\.[a-zA-Z0-9]{15}|l\.[a-zA-Z0-9]{7})"
r")"
)
IMAGE_FILE_EXTENSION_MAP = {
"jpeg": ".jpg",
"tiff": ".tif",
}
def __init__(
self,
apple_music_api: AppleMusicApi,
itunes_api: ItunesApi,
output_path: Path = Path("./Apple Music"),
temp_path: Path = Path("./temp"),
temp_path: Path = Path("."),
wvd_path: Path = None,
overwrite: bool = False,
save_cover: bool = False,
save_playlist: bool = False,
no_synced_lyrics: bool = False,
synced_lyrics_only: bool = False,
nm3u8dlre_path: str = "N_m3u8DL-RE",
mp4decrypt_path: str = "mp4decrypt",
ffmpeg_path: str = "ffmpeg",
@@ -53,16 +86,22 @@ class Downloader:
template_file_no_album: str = "{title}",
template_file_playlist: str = "Playlists/{playlist_artist}/{playlist_title}",
template_date: str = "%Y-%m-%dT%H:%M:%SZ",
exclude_tags: str = None,
exclude_tags: list[str] = None,
cover_size: int = 1200,
truncate: int = None,
silent: bool = False,
skip_processing: 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.overwrite = overwrite
self.save_cover = save_cover
self.save_playlist = save_playlist
self.no_synced_lyrics = no_synced_lyrics
self.synced_lyrics_only = synced_lyrics_only
self.nm3u8dlre_path = nm3u8dlre_path
self.mp4decrypt_path = mp4decrypt_path
self.ffmpeg_path = ffmpeg_path
@@ -82,24 +121,26 @@ class Downloader:
self.cover_size = cover_size
self.truncate = truncate
self.silent = silent
self.skip_processing = skip_processing
self._set_temp_path()
self._set_exclude_tags()
self._set_binaries_path_full()
self._set_exclude_tags_list()
self._set_truncate()
self._set_subprocess_additional_args()
def _set_temp_path(self):
random_suffix = uuid.uuid4().hex[:8]
self.temp_path = self.temp_path / f"gamdl_temp_{random_suffix}"
def _set_exclude_tags(self):
self.exclude_tags = self.exclude_tags if self.exclude_tags is not None else []
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
@@ -119,53 +160,63 @@ class Downloader:
else:
self.cdm = Cdm.from_device(Device.loads(HARDCODED_WVD))
def get_url_info(self, url: str) -> UrlInfo:
url_info = UrlInfo()
def parse_url_info(self, url: str) -> UrlInfo | None:
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)
if not url_regex_result:
return None
return UrlInfo(
**url_regex_result.groupdict(),
)
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)
return self._get_download_queue(
"song" if url_info.sub_id else url_info.type,
url_info.sub_id or url_info.id or url_info.library_id,
url_info.library_id is not None,
)
def _get_download_queue(self, url_type: str, id: str) -> DownloadQueue:
def _get_download_queue(
self,
url_type: str,
id: str,
is_library: bool,
) -> DownloadQueue:
download_queue = DownloadQueue()
if url_type == "artist":
artist = self.apple_music_api.get_artist(id)
download_queue.tracks_metadata = list(
download_queue.medias_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 = [
download_queue.medias_metadata = [self.apple_music_api.get_song(id)]
elif url_type in {"album", "albums"}:
if is_library:
album = self.apple_music_api.get_library_album(id)
else:
album = self.apple_music_api.get_album(id)
download_queue.medias_metadata = [
track for track in album["relationships"]["tracks"]["data"]
]
elif url_type == "playlist":
playlist = self.apple_music_api.get_playlist(id)
if is_library:
playlist = self.apple_music_api.get_library_playlist(id)
download_queue.medias_metadata = [
track for track in playlist["relationships"]["tracks"]["data"]
]
else:
playlist = self.apple_music_api.get_playlist(id)
download_queue.medias_metadata = [
track for track in playlist["relationships"]["tracks"]["data"]
]
download_queue.playlist_attributes = playlist["attributes"]
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)]
download_queue.medias_metadata = [self.apple_music_api.get_music_video(id)]
elif url_type == "post":
download_queue.tracks_metadata = [self.apple_music_api.get_post(id)]
download_queue.medias_metadata = [self.apple_music_api.get_post(id)]
return download_queue
def get_download_queue_from_artist(
@@ -249,32 +300,46 @@ class Downloader:
for music_video in selected:
yield music_video
def get_media_id_of_library_media(
self,
library_media_metadata: dict,
) -> str:
play_params = library_media_metadata["attributes"].get("playParams", {})
return play_params.get("catalogId", library_media_metadata["id"])
def is_media_streamable(
self,
media_metadata: dict,
) -> bool:
return bool(media_metadata["attributes"].get("playParams"))
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
) -> PlaylistTags:
return PlaylistTags(
playlist_artist=playlist_attributes.get("curatorName", "Unknown"),
playlist_id=playlist_attributes["playParams"]["id"],
playlist_title=playlist_attributes["name"],
playlist_track=playlist_track,
)
def get_playlist_file_path(
self,
tags: dict,
):
tags: PlaylistTags,
) -> Path:
template_file = self.template_file_playlist.split("/")
tags_dict = tags.__dict__.copy()
return Path(
self.output_path,
*[
self.get_sanitized_string(i.format(**tags), True)
self.get_sanitized_string(i.format(**tags_dict), True)
for i in template_file[0:-1]
],
*[
self.get_sanitized_string(template_file[-1].format(**tags), False)
self.get_sanitized_string(template_file[-1].format(**tags_dict), False)
+ ".m3u8"
],
)
@@ -310,13 +375,15 @@ class Downloader:
minutes, seconds = divmod(millis // 1000, 60)
return f"{minutes:02d}:{seconds:02d}"
def sanitize_date(self, date: str) -> datetime.datetime:
return datetime.datetime.fromisoformat(date[:-1]).strftime(self.template_date)
def parse_date(self, date: str) -> datetime.datetime:
return datetime.datetime.fromisoformat(date.split("Z")[0])
def get_decryption_key(self, pssh: str, track_id: str) -> str:
def get_decryption_key(self, pssh: str, track_id: str) -> DecryptionKey:
try:
pssh_obj = PSSH(pssh.split(",")[-1])
cdm_session = self.cdm.open()
pssh_obj = PSSH(pssh.split(",")[-1])
challenge = base64.b64encode(
self.cdm.get_license_challenge(cdm_session, pssh_obj)
).decode()
@@ -325,13 +392,17 @@ class Downloader:
pssh,
challenge,
)
self.cdm.parse_license(cdm_session, license)
decryption_key = next(
decryption_key_info = 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
return DecryptionKey(
key=decryption_key_info.key.hex(),
kid=decryption_key_info.kid.hex,
)
def download(self, path: Path, stream_url: str):
if self.download_mode == DownloadMode.YTDLP:
@@ -391,38 +462,73 @@ class Downloader:
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"):
def get_media_file_extension(
self,
media_file_format: MediaFileFormat,
) -> str:
return "." + media_file_format.value
def get_temp_path(
self,
media_id: str,
tag: str,
file_extension: str,
):
temp_path = self.temp_path / (f"{media_id}_{tag}" + file_extension)
return temp_path
def get_final_path(
self,
tags: MediaTags,
file_extension: str,
playlist_tags: PlaylistTags,
) -> Path:
if tags.album is not None:
template_folder = (
self.template_folder_compilation.split("/")
if tags.get("compilation")
if tags.compilation
else self.template_folder_album.split("/")
)
template_file = (
self.template_file_multi_disc.split("/")
if tags["disc_total"] > 1
if tags.disc_total > 1
else self.template_file_single_disc.split("/")
)
else:
template_folder = self.template_folder_no_album.split("/")
template_file = self.template_file_no_album.split("/")
template_final = template_folder + template_file
tags_dict = tags.__dict__.copy()
if playlist_tags:
tags_dict.update(playlist_tags.__dict__)
return Path(
self.output_path,
*[
self.get_sanitized_string(i.format(**tags), True)
self.get_sanitized_string(i.format(**tags_dict), True)
for i in template_final[0:-1]
],
(
self.get_sanitized_string(template_final[-1].format(**tags), False)
self.get_sanitized_string(template_final[-1].format(**tags_dict), 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)))
def get_cover_format(self, cover_url: str) -> str | None:
cover_bytes = self.get_cover_bytes(cover_url)
if cover_bytes is None:
return None
image_obj = Image.open(io.BytesIO(self.get_cover_bytes(cover_url)))
image_format = image_obj.format.lower()
return IMAGE_FILE_EXTENSION_MAP.get(image_format, f".{image_format}")
return image_format
def get_cover_file_extension(self, cover_format: str) -> str:
return self.IMAGE_FILE_EXTENSION_MAP.get(
cover_format,
f".{cover_format.lower()}",
)
def get_cover_url(self, metadata: dict) -> str:
if self.cover_format == CoverFormat.RAW:
@@ -453,78 +559,165 @@ class Downloader:
@staticmethod
@functools.lru_cache()
def get_url_response_bytes(url: str) -> bytes:
def get_cover_bytes(url: str) -> bytes | None:
response = requests.get(url)
response.raise_for_status()
if response.status_code == 200:
return response.content
elif response.status_code in (404, 400):
return None
else:
raise_response_exception(response)
return response.content
def apply_tags(
self,
path: Path,
tags: dict,
tags: MediaTags,
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
),
)
]
filtered_tags = MediaTags(
**{
k: v
for k, v in tags.__dict__.items()
if v is not None and k not in self.exclude_tags
}
)
mp4_tags = filtered_tags.to_mp4_tags(self.template_date)
skip_tagging = "all" in self.exclude_tags
mp4 = MP4(path)
mp4.clear()
mp4.update(mp4_tags)
if not skip_tagging:
if (
"cover" not in self.exclude_tags
and self.cover_format != CoverFormat.RAW
):
self._apply_cover(mp4, cover_url)
mp4.update(mp4_tags)
mp4.save()
def _apply_cover(
self,
mp4: MP4,
cover_url: str,
) -> None:
cover_bytes = self.get_cover_bytes(cover_url)
if cover_bytes is None:
return
mp4["covr"] = [
MP4Cover(
data=cover_bytes,
imageformat=(
MP4Cover.FORMAT_JPEG
if self.cover_format == CoverFormat.JPG
else MP4Cover.FORMAT_PNG
),
)
]
def move_to_output_path(
self,
remuxed_path: Path,
staged_path: Path,
final_path: Path,
):
final_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(remuxed_path, final_path)
shutil.move(staged_path, final_path)
@functools.lru_cache()
def save_cover(self, cover_path: Path, cover_url: str):
def write_cover(self, cover_path: Path, cover_url: str):
cover_path.parent.mkdir(parents=True, exist_ok=True)
cover_path.write_bytes(self.get_url_response_bytes(cover_url))
cover_path.write_bytes(self.get_cover_bytes(cover_url))
def write_synced_lyrics(
self,
synced_lyrics_path: Path,
synced_lyrics: str,
):
synced_lyrics_path.parent.mkdir(parents=True, exist_ok=True)
synced_lyrics_path.write_text(
synced_lyrics,
encoding="utf8",
)
def cleanup_temp_path(self):
shutil.rmtree(self.temp_path)
if self.temp_path.exists() and not self.skip_processing:
shutil.rmtree(self.temp_path)
def _final_processing(
self,
download_info: DownloadInfo,
) -> None:
if self.skip_processing:
return
colored_media_id = color_text(download_info.media_id, colorama.Style.DIM)
if download_info.staged_path:
logger.debug(
f"[{colored_media_id}] Applying tags to {download_info.staged_path}"
)
self.apply_tags(
download_info.staged_path,
download_info.tags,
download_info.cover_url,
)
logger.debug(
f'[{colored_media_id}] Moving "{download_info.staged_path}" to "{download_info.final_path}"'
)
self.move_to_output_path(
download_info.staged_path,
download_info.final_path,
)
logger.info(f"[{colored_media_id}] Download completed successfully")
if (
download_info.cover_path and not self.save_cover
) or not download_info.cover_path:
pass
elif download_info.cover_path.exists() and not self.overwrite:
logger.debug(
f'[{colored_media_id}] Cover already exists at "{download_info.cover_path}", skipping'
)
else:
logger.debug(
f'[{colored_media_id}] Saving cover to "{download_info.cover_path}"'
)
self.write_cover(
download_info.cover_path,
download_info.cover_url,
)
if (
self.no_synced_lyrics
or not download_info.lyrics
or not download_info.lyrics.synced
):
pass
elif download_info.synced_lyrics_path.exists() and not self.overwrite:
logger.debug(
f'[{colored_media_id}] Synced lyrics already exist at "{download_info.synced_lyrics_path}", skipping'
)
else:
logger.debug(
f'[{colored_media_id}] Saving synced lyrics to "{download_info.synced_lyrics_path}"'
)
self.write_synced_lyrics(
download_info.synced_lyrics_path,
download_info.lyrics.synced,
)
if (
download_info.playlist_tags
and self.save_playlist
and download_info.staged_path
):
playlist_file_path = self.get_playlist_file_path(
download_info.playlist_tags
)
logger.debug(
f'[{colored_media_id}] Updating playlist file "{playlist_file_path}"'
)
self.update_playlist_file(
playlist_file_path,
download_info.final_path,
download_info.playlist_tags.playlist_track,
)
+440 -131
View File
@@ -1,17 +1,35 @@
from __future__ import annotations
import logging
import subprocess
import urllib.parse
from pathlib import Path
import colorama
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
from .enums import (
MediaFileFormat,
MusicVideoCodec,
MusicVideoResolution,
RemuxFormatMusicVideo,
RemuxMode,
)
from .models import (
DecryptionKeyAv,
DownloadInfo,
MediaRating,
MediaTags,
MediaType,
StreamInfo,
StreamInfoAv,
)
from .utils import color_text
logger = logging.getLogger("gamdl")
class DownloaderMusicVideo:
@@ -20,10 +38,14 @@ class DownloaderMusicVideo:
def __init__(
self,
downloader: Downloader,
codec: MusicVideoCodec = MusicVideoCodec.H264,
):
codec: list[MusicVideoCodec] = [MusicVideoCodec.H264, MusicVideoCodec.H265],
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
resolution: MusicVideoResolution = MusicVideoResolution.R1080P,
) -> None:
self.downloader = downloader
self.codec = codec
self.remux_format = remux_format
self.resolution = resolution
def get_stream_url_from_webplayback(self, webplayback: dict) -> str:
return webplayback["hls-playlist-url"]
@@ -37,42 +59,66 @@ class DownloaderMusicVideo:
query=urllib.parse.urlencode(query, doseq=True)
).geturl()
def get_m3u8_master_data(self, stream_url_master: str) -> dict:
return m3u8.load(stream_url_master).data
def get_playlist_video(
def get_video_playlist_from_resolution(
self,
playlists: list[dict],
) -> dict:
playlists_filtered = [
playlist
for playlist in playlists
if playlist["stream_info"]["codecs"].startswith(
MUSIC_VIDEO_CODEC_MAP[self.codec]
)
]
playlists: list[m3u8.Playlist],
) -> m3u8.Playlist | None:
playlists_filtered = set()
for playlist in playlists:
for codec in self.codec:
if playlist.stream_info.codecs.startswith(codec.fourcc()):
playlists_filtered.add(playlist)
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]
return None
def get_playlist_video_from_user(
playlists_filtered = list(playlists_filtered)
def sort_key(playlist: m3u8.Playlist) -> tuple[int, int, int, int]:
playlist_resolution = playlist.stream_info.resolution[-1]
resolution_difference = abs(playlist_resolution - int(self.resolution))
codec_preference = len(self.codec)
for i, preferred_codec in enumerate(self.codec):
if playlist.stream_info.codecs.startswith(preferred_codec.fourcc()):
codec_preference = i
break
bandwidth = playlist.stream_info.bandwidth
return (
resolution_difference,
codec_preference,
-playlist_resolution,
-bandwidth,
)
playlists_filtered.sort(key=sort_key)
return playlists_filtered[0]
def get_best_stereo_audio_playlist(
self,
playlists: list[dict],
) -> dict:
playlist_master_data: dict,
) -> dict | None:
audio_playlist = next(
(
media
for media in playlist_master_data["media"]
if media["group_id"] == "audio-stereo-256"
),
None,
)
return audio_playlist
def get_video_playlist_from_user(
self,
playlists: list[m3u8.Playlist],
) -> m3u8.Playlist:
choices = [
Choice(
name=" | ".join(
[
playlist["stream_info"]["codecs"][:4],
playlist["stream_info"]["resolution"],
str(playlist["stream_info"]["bandwidth"]),
playlist.stream_info.codecs[:4],
"x".join(str(v) for v in playlist.stream_info.resolution),
str(playlist.stream_info.bandwidth),
]
),
value=playlist,
@@ -83,132 +129,204 @@ class DownloaderMusicVideo:
message="Select which video codec to download: (Codec | Resolution | Bitrate)",
choices=choices,
).execute()
return selected
def get_playlist_audio(
def get_audio_playlist_from_user(
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],
playlist_master_data: dict,
) -> dict:
choices = [
Choice(
name=playlist["group_id"],
value=playlist,
)
for playlist in playlists
for playlist in playlist_master_data["media"]
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):
def get_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
return next(
(
key
for key in m3u8_data["keys"]
if key["keyformat"] == "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
for key in m3u8_obj.keys
if key.keyformat == "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
),
None,
)["uri"]
).uri
def get_stream_info_video(self, m3u8_master_data: dict) -> StreamInfo:
def get_stream_info_video(
self, playlist_master_m3u8_obj: m3u8.M3U8
) -> StreamInfo | None:
stream_info = StreamInfo()
if self.codec != MusicVideoCodec.ASK:
playlist = self.get_playlist_video(m3u8_master_data["playlists"])
if MusicVideoCodec.ASK not in self.codec:
playlist = self.get_video_playlist_from_resolution(
playlist_master_m3u8_obj.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)
playlist = self.get_video_playlist_from_user(
playlist_master_m3u8_obj.playlists
)
if not playlist:
return None
stream_info.stream_url = playlist.uri
stream_info.codec = playlist.stream_info.codecs
playlist_m3u8_obj = m3u8.load(stream_info.stream_url)
stream_info.widevine_pssh = self.get_pssh(playlist_m3u8_obj)
return stream_info
def get_stream_info_audio(self, m3u8_master_data: dict) -> StreamInfo:
def get_stream_info_audio(self, playlist_master_data: dict) -> StreamInfo | None:
stream_info = StreamInfo()
if self.codec != MusicVideoCodec.ASK:
playlist = self.get_playlist_audio(m3u8_master_data["media"])
playlist = self.get_best_stereo_audio_playlist(playlist_master_data)
else:
playlist = self.get_playlist_audio_from_user(m3u8_master_data["media"])
playlist = self.get_audio_playlist_from_user(playlist_master_data)
if not playlist:
return None
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)
playlist_m3u8_obj = m3u8.load(stream_info.stream_url)
stream_info.widevine_pssh = self.get_pssh(playlist_m3u8_obj)
return stream_info
def get_music_video_id_alt(self, metadata: dict) -> str:
return metadata["attributes"]["url"].split("/")[-1].split("?")[0]
def _get_stream_info(
self,
stream_url: str,
) -> StreamInfoAv | None:
playlist_master_m3u8_obj = m3u8.load(stream_url)
stream_info_video = self.get_stream_info_video(playlist_master_m3u8_obj)
stream_info_audio = self.get_stream_info_audio(playlist_master_m3u8_obj.data)
if not stream_info_video or not stream_info_audio:
return None
use_mp4 = (
any(
stream_info_video.codec.startswith(codec)
for codec in self.MP4_FORMAT_CODECS
)
or any(
stream_info_audio.codec.startswith(codec)
for codec in self.MP4_FORMAT_CODECS
)
or self.remux_format == RemuxFormatMusicVideo.MP4
)
if use_mp4:
file_format = MediaFileFormat.MP4
else:
file_format = MediaFileFormat.M4V
return StreamInfoAv(
video_track=stream_info_video,
audio_track=stream_info_audio,
file_format=file_format,
)
def get_stream_info_from_webplayback(
self,
webplayback: dict,
) -> StreamInfoAv | None:
return self._get_stream_info(self.get_stream_url_from_webplayback(webplayback))
def get_stream_info_from_itunes_page(
self,
itunes_page: dict,
) -> StreamInfoAv | None:
return self._get_stream_info(self.get_stream_url_from_itunes_page(itunes_page))
def get_decryption_key(
self,
stream_info: StreamInfoAv,
media_id: str,
) -> DecryptionKeyAv:
decryption_key_video = self.downloader.get_decryption_key(
stream_info.video_track.widevine_pssh,
media_id,
)
decryption_key_audio = self.downloader.get_decryption_key(
stream_info.audio_track.widevine_pssh,
media_id,
)
return DecryptionKeyAv(
video_track=decryption_key_video,
audio_track=decryption_key_audio,
)
def get_music_video_id_alt(self, metadata: dict) -> str | None:
music_video_url = metadata["attributes"].get("url")
if music_video_url is None:
return None
return music_video_url.split("/")[-1].split("?")[0]
def get_tags(
self,
id_alt: str,
itunes_page: dict,
metadata: dict,
):
) -> MediaTags:
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
explicitness = metadata_itunes[0]["trackExplicitness"]
if explicitness == "notExplicit":
rating = MediaRating.NONE
elif explicitness == "explicit":
rating = MediaRating.EXPLICIT
else:
tags["rating"] = 2
rating = MediaRating.CLEAN
tags = MediaTags(
artist=metadata_itunes[0]["artistName"],
artist_id=int(metadata_itunes[0]["artistId"]),
copyright=itunes_page.get("copyright"),
date=self.downloader.parse_date(metadata_itunes[0]["releaseDate"]),
genre=metadata_itunes[0]["primaryGenreName"],
genre_id=int(itunes_page["genres"][0]["genreId"]),
media_type=MediaType.MUSIC_VIDEO,
storefront=int(self.downloader.itunes_api.storefront_id.split("-")[0]),
title=metadata_itunes[0]["trackCensoredName"],
title_id=int(metadata["id"]),
rating=rating,
)
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"]
if not album:
return tags
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):
def decrypt(
self,
encrypted_path: Path,
decryption_key: str,
decrypted_path: Path,
) -> None:
subprocess.run(
[
self.downloader.mp4decrypt_path_full,
@@ -226,7 +344,7 @@ class DownloaderMusicVideo:
decrypted_path_audio: Path,
decrypted_path_video: Path,
fixed_path: Path,
):
) -> None:
subprocess.run(
[
self.downloader.mp4box_path_full,
@@ -250,12 +368,7 @@ class DownloaderMusicVideo:
decrypted_path_video: Path,
decrypte_path_audio: Path,
fixed_path: Path,
codec_video: str,
codec_audio: str,
):
use_mp4_flag = any(
codec_video.startswith(codec) for codec in self.MP4_FORMAT_CODECS
) or any(codec_audio.startswith(codec) for codec in self.MP4_FORMAT_CODECS)
) -> None:
subprocess.run(
[
self.downloader.ffmpeg_path_full,
@@ -268,8 +381,6 @@ class DownloaderMusicVideo:
decrypte_path_audio,
"-movflags",
"+faststart",
"-f",
"mp4" if use_mp4_flag else "ipod",
"-c",
"copy",
"-c:s",
@@ -280,28 +391,226 @@ class DownloaderMusicVideo:
**self.downloader.subprocess_additional_args,
)
def remux(
def stage(
self,
encrypted_path_video: Path,
encrypted_path_audio: Path,
decrypted_path_video: Path,
decrypted_path_audio: Path,
remuxed_path: Path,
codec_video: str,
codec_audio: str,
):
staged_path: Path,
decryption_key: DecryptionKeyAv,
) -> None:
self.decrypt(
encrypted_path_video,
decryption_key.video_track.key,
decrypted_path_video,
)
self.decrypt(
encrypted_path_audio,
decryption_key.audio_track.key,
decrypted_path_audio,
)
if self.downloader.remux_mode == RemuxMode.MP4BOX:
self.remux_mp4box(
decrypted_path_audio,
decrypted_path_video,
remuxed_path,
staged_path,
)
elif self.downloader.remux_mode == RemuxMode.FFMPEG:
self.remux_ffmpeg(
decrypted_path_video,
decrypted_path_audio,
remuxed_path,
codec_video,
codec_audio,
staged_path,
)
def get_cover_path(self, final_path: Path, file_extension: str) -> Path:
return final_path.with_suffix(file_extension)
def get_cover_path(self, final_path: Path, cover_format: str) -> Path:
return final_path.with_suffix(
self.downloader.get_cover_file_extension(cover_format)
)
def download(
self,
media_id: str = None,
media_metadata: dict = None,
playlist_attributes: dict = None,
playlist_track: int = None,
) -> DownloadInfo:
try:
download_info = self._download(
media_id,
media_metadata,
playlist_attributes,
playlist_track,
)
self.downloader._final_processing(download_info)
finally:
self.downloader.cleanup_temp_path()
return download_info
def _download(
self,
media_id: str = None,
media_metadata: dict = None,
playlist_attributes: dict = None,
playlist_track: int = None,
) -> DownloadInfo:
download_info = DownloadInfo()
if playlist_track is None and playlist_attributes:
raise ValueError(
"playlist_track must be provided if playlist_attributes is provided"
)
if playlist_attributes:
playlist_tags = self.downloader.get_playlist_tags(
playlist_attributes,
playlist_track,
)
else:
playlist_tags = None
download_info.playlist_tags = playlist_tags
if not media_id and not media_metadata:
raise ValueError("Either media_id or media_metadata must be provided")
if not media_metadata:
logger.debug(
f"[{color_text(media_id, colorama.Style.DIM)}] "
"Getting Music Video metadata"
)
media_metadata = self.downloader.apple_music_api.get_music_video(media_id)
download_info.media_metadata = media_metadata
if not media_id:
media_id = self.downloader.get_media_id_of_library_media(media_metadata)
download_info.media_id = media_id
colored_media_id = color_text(media_id, colorama.Style.DIM)
if not self.downloader.is_media_streamable(media_metadata):
logger.warning(
f"[{colored_media_id}] "
"Music Video is not streamable or downloadable, skipping"
)
return download_info
alt_media_id = self.get_music_video_id_alt(media_metadata) or media_id
download_info.alt_media_id = alt_media_id
logger.debug(f"[{colored_media_id}] Getting iTunes page")
itunes_page = self.downloader.itunes_api.get_itunes_page(
"music-video",
alt_media_id,
)
logger.debug(f"[{colored_media_id}] Getting tags")
tags = self.get_tags(
alt_media_id,
itunes_page,
media_metadata,
)
download_info.tags = tags
if alt_media_id == media_id:
logger.debug(f"[{colored_media_id}] Getting stream info")
stream_info = self.get_stream_info_from_itunes_page(itunes_page)
else:
logger.debug(f"[{colored_media_id}] Getting webplayback info")
webplayback = self.downloader.apple_music_api.get_webplayback(media_id)
logger.debug(f"[{colored_media_id}] Getting stream info")
stream_info = self.get_stream_info_from_webplayback(webplayback)
if not stream_info:
logger.warning(
f"[{colored_media_id}] Video/Audio stream with the selected codec(s) not found, skipping"
)
return download_info
download_info.stream_info = stream_info
final_path = self.downloader.get_final_path(
tags,
self.downloader.get_media_file_extension(stream_info.file_format),
playlist_tags,
)
download_info.final_path = final_path
cover_url = self.downloader.get_cover_url(media_metadata)
cover_format = self.downloader.get_cover_format(cover_url)
if cover_format and self.downloader.save_cover:
cover_path = self.get_cover_path(final_path, cover_format)
else:
cover_path = None
download_info.cover_url = cover_url
download_info.cover_format = cover_format
download_info.cover_path = cover_path
if final_path.exists() and not self.downloader.overwrite:
logger.warning(
f'[{colored_media_id}] Music Video already exists at "{final_path}", skipping'
)
return download_info
logger.debug(f"[{colored_media_id}] Getting decryption key")
decryption_key = self.get_decryption_key(
stream_info,
media_id,
)
encrypted_path_video = self.downloader.get_temp_path(
media_id,
"encrypted_video",
".mp4",
)
encrypted_path_audio = self.downloader.get_temp_path(
media_id,
"encrypted_audio",
".m4a",
)
decrypted_path_video = self.downloader.get_temp_path(
media_id,
"decrypted_video",
".mp4",
)
decrypted_path_audio = self.downloader.get_temp_path(
media_id,
"decrypted_audio",
".m4a",
)
staged_path = self.downloader.get_temp_path(
media_id,
"staged",
self.downloader.get_media_file_extension(stream_info.file_format),
)
logger.info(f"[{colored_media_id}] Downloading Music Video")
logger.debug(
f'[{colored_media_id}] Downloading video to "{encrypted_path_video}"'
)
self.downloader.download(
encrypted_path_video,
stream_info.video_track.stream_url,
)
logger.debug(
f'[{colored_media_id}] Downloading audio to "{encrypted_path_audio}"'
)
self.downloader.download(
encrypted_path_audio,
stream_info.audio_track.stream_url,
)
logger.debug(
"Decrypting video/audio to "
f'{decrypted_path_video}"/"{decrypted_path_audio}" '
f'and remuxing to "{staged_path}"'
)
self.stage(
encrypted_path_video,
encrypted_path_audio,
decrypted_path_video,
decrypted_path_audio,
staged_path,
decryption_key,
)
download_info.staged_path = staged_path
return download_info
+101 -10
View File
@@ -1,12 +1,18 @@
from __future__ import annotations
import logging
from pathlib import Path
import colorama
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from .downloader import Downloader
from .enums import PostQuality
from .models import DownloadInfo, MediaTags
from .utils import color_text
logger = logging.getLogger("gamdl")
class DownloaderPost:
@@ -60,15 +66,100 @@ class DownloaderPost:
stream_url = self.get_stream_url_from_user(metadata)
return stream_url
def get_tags(self, metadata: dict) -> list:
def get_tags(self, metadata: dict) -> MediaTags:
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]),
}
upload_date = attributes.get("uploadDate")
return MediaTags(
artist=attributes.get("artistName"),
date=self.downloader.parse_date(upload_date) if upload_date else None,
title=attributes.get("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"
def get_cover_path(self, final_path: Path, cover_format: str) -> Path:
return final_path.with_suffix(
self.downloader.get_cover_file_extension(cover_format)
)
def download(
self,
media_id: str = None,
media_metadata: dict = None,
) -> DownloadInfo:
try:
download_info = self._download(
media_id,
media_metadata,
)
self.downloader._final_processing(download_info)
finally:
self.downloader.cleanup_temp_path()
return download_info
def _download(
self,
media_id: str = None,
media_metadata: dict = None,
) -> DownloadInfo:
download_info = DownloadInfo()
if not media_id and not media_metadata:
raise ValueError("Either media_id or media_metadata must be provided")
if not media_metadata:
logger.debug(
f"[{color_text(media_id, colorama.Style.DIM)}] "
"Getting Post Video metadata"
)
media_metadata = self.downloader.apple_music_api.get_post(media_id)
download_info.media_metadata = media_metadata
if not media_id:
media_id = media_metadata["id"]
download_info.media_id = media_id
colored_media_id = color_text(media_id, colorama.Style.DIM)
if not self.downloader.is_media_streamable(media_metadata):
logger.warning(
f"[{colored_media_id}] "
"Post Video is not streamable or downloadable, skipping"
)
return download_info
tags = self.get_tags(media_metadata)
final_path = self.downloader.get_final_path(
tags,
".m4v",
None,
)
download_info.tags = tags
download_info.final_path = final_path
cover_url = self.downloader.get_cover_url(media_metadata)
cover_format = self.downloader.get_cover_format(cover_url)
if cover_format and self.downloader.save_cover:
cover_path = self.get_cover_path(final_path, cover_format)
else:
cover_path = None
download_info.cover_url = cover_url
download_info.cover_format = cover_format
download_info.cover_path = cover_path
stream_url = self.get_stream_url(media_metadata)
staged_path = self.downloader.get_temp_path(
media_id,
"stage",
".m4v",
)
logger.info(f"[{colored_media_id}] Downloading Post Video")
logger.debug(f"[{colored_media_id}] Downloading to {staged_path}")
self.downloader.download_ytdlp(
staged_path,
stream_url,
)
download_info.staged_path = staged_path
return download_info
+525 -133
View File
@@ -3,25 +3,70 @@ from __future__ import annotations
import base64
import datetime
import json
import logging
import re
import subprocess
from pathlib import Path
from xml.dom import minidom
from xml.etree import ElementTree
import colorama
import m3u8
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from pywidevine import PSSH
from pywidevine.license_protocol_pb2 import WidevinePsshData
from .constants import SONG_CODEC_REGEX_MAP, SYNCED_LYRICS_FILE_EXTENSION_MAP
from .downloader import Downloader
from .enums import RemuxMode, SongCodec, SyncedLyricsFormat
from .models import Lyrics, StreamInfo
from .enums import MediaFileFormat, RemuxMode, SongCodec, SyncedLyricsFormat
from .models import (
DecryptionKey,
DecryptionKeyAv,
DownloadInfo,
Lyrics,
MediaRating,
MediaTags,
MediaType,
StreamInfo,
StreamInfoAv,
)
from .utils import color_text
logger = logging.getLogger("gamdl")
class DownloaderSong:
DEFAULT_DECRYPTION_KEY = "32b8ade1769e26b1ffb8986352793fc6"
MP4_FORMAT_CODECS = ["ec-3"]
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-.*",
}
DRM_DEFAULT_KEY_MAPPING = {
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed": (
"data:text/plain;base64,AAAAOHBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAABgSEAAAAAA"
"AAAAAczEvZTEgICBI88aJmwY="
),
"com.microsoft.playready": (
"data:text/plain;charset=UTF-16;base64,vgEAAAEAAQC0ATwAVwBSAE0ASABFAEEARABF"
"AFIAIAB4AG0AbABuAHMAPQAiAGgAdAB0AHAAOgAvAC8AcwBjAGgAZQBtAGEAcwAuAG0AaQBjAH"
"IAbwBzAG8AZgB0AC4AYwBvAG0ALwBEAFIATQAvADIAMAAwADcALwAwADMALwBQAGwAYQB5AFIA"
"ZQBhAGQAeQBIAGUAYQBkAGUAcgAiACAAdgBlAHIAcwBpAG8AbgA9ACIANAAuADMALgAwAC4AMA"
"AiAD4APABEAEEAVABBAD4APABQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsASQBEAFMAPgA8"
"AEsASQBEACAAQQBMAEcASQBEAD0AIgBBAEUAUwBDAEIAQwAiACAAVgBBAEwAVQBFAD0AIgBBAE"
"EAQQBBAEEAQQBBAEEAQQBBAEIAegBNAFMAOQBsAE0AUwBBAGcASQBBAD0APQAiAD4APAAvAEsA"
"SQBEAD4APAAvAEsASQBEAFMAPgA8AC8AUABSAE8AVABFAEMAVABJAE4ARgBPAD4APAAvAEQAQQ"
"BUAEEAPgA8AC8AVwBSAE0ASABFAEEARABFAFIAPgA="
),
"com.apple.streamingkeydelivery": "skd://itunes.apple.com/P000000000/s1/e1",
}
def __init__(
self,
@@ -33,28 +78,29 @@ class DownloaderSong:
self.codec = codec
self.synced_lyrics_format = synced_lyrics_format
def get_drm_infos(self, m3u8_data: dict) -> dict:
drm_info_raw = next(
def _search_m3u8_metadata(self, m3u8_data: dict, data_id: str) -> dict:
searched = next(
(
session_data
for session_data in m3u8_data["session_data"]
if session_data["data_id"] == "com.apple.hls.AudioSessionKeyInfo"
if session_data["data_id"] == data_id
),
None,
)
if not drm_info_raw:
if not searched:
return None
return json.loads(base64.b64decode(drm_info_raw["value"]).decode("utf-8"))
return json.loads(base64.b64decode(searched["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_audio_session_key_metadata(self, m3u8_data: dict) -> dict:
return self._search_m3u8_metadata(
m3u8_data,
"com.apple.hls.AudioSessionKeyInfo",
)
def get_asset_metadata(self, m3u8_data: dict) -> dict:
return self._search_m3u8_metadata(
m3u8_data,
"com.apple.hls.audioAssetMetadata",
)
def get_playlist_from_codec(self, m3u8_data: dict) -> dict | None:
@@ -62,7 +108,7 @@ class DownloaderSong:
playlist
for playlist in m3u8_data["playlists"]
if re.fullmatch(
SONG_CODEC_REGEX_MAP[self.codec], playlist["stream_info"]["audio"]
self.SONG_CODEC_REGEX_MAP[self.codec], playlist["stream_info"]["audio"]
)
]
if not m3u8_master_playlists:
@@ -85,53 +131,172 @@ class DownloaderSong:
).execute()
return selected
def get_pssh(
def _get_drm_uri_from_session_key(
self,
drm_infos: dict,
drm_ids: list,
drm_key: str,
) -> str | None:
drm_info = next(
(
drm_infos[drm_id]
for drm_id in drm_ids
if drm_infos[drm_id].get(
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
)
and drm_id != "1"
if drm_infos[drm_id].get(drm_key) and drm_id != "1"
),
None,
)
if not drm_info:
return None
return drm_info["urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"]["URI"]
return drm_info[drm_key]["URI"]
def get_stream_info(self, track_metadata: dict) -> StreamInfo:
def _get_drm_uri_from_m3u8_keys(
self,
m3u8_obj: m3u8.M3U8,
drm_key: str,
) -> str | None:
drm_uri = next(
(
key
for key in m3u8_obj.keys
if key.keyformat == drm_key
and key.uri != self.DRM_DEFAULT_KEY_MAPPING[drm_key]
),
None,
)
if not drm_uri:
return None
return drm_uri.uri
def _get_stream_info(self, m3u8_url: str) -> StreamInfoAv | None:
stream_info = StreamInfo()
m3u8_master_obj = m3u8.load(m3u8_url)
m3u8_master_data = m3u8_master_obj.data
if self.codec == SongCodec.ASK:
playlist = self.get_playlist_from_user(m3u8_master_data)
else:
playlist = self.get_playlist_from_codec(m3u8_master_data)
if playlist is None:
return None
stream_info.stream_url = m3u8_master_obj.base_uri + playlist["uri"]
stream_info.codec = playlist["stream_info"]["codecs"]
is_mp4 = any(
stream_info.codec.startswith(possible_codec)
for possible_codec in self.MP4_FORMAT_CODECS
)
session_key_metadata = self.get_audio_session_key_metadata(m3u8_master_data)
if session_key_metadata:
asset_metadata = self.get_asset_metadata(m3u8_master_data)
variant_id = playlist["stream_info"]["stable_variant_id"]
drm_ids = asset_metadata[variant_id]["AUDIO-SESSION-KEY-IDS"]
(
stream_info.widevine_pssh,
stream_info.playready_pssh,
stream_info.fairplay_key,
) = (
self._get_drm_uri_from_session_key(
session_key_metadata,
drm_ids,
drm_key,
)
for drm_key in self.DRM_DEFAULT_KEY_MAPPING.keys()
)
else:
m3u8_obj = m3u8.load(stream_info.stream_url)
(
stream_info.widevine_pssh,
stream_info.playready_pssh,
stream_info.fairplay_key,
) = (
self._get_drm_uri_from_m3u8_keys(
m3u8_obj,
drm_key,
)
for drm_key in self.DRM_DEFAULT_KEY_MAPPING.keys()
)
return StreamInfoAv(
audio_track=stream_info,
file_format=MediaFileFormat.MP4 if is_mp4 else MediaFileFormat.M4A,
)
def get_stream_info(self, track_metadata: dict) -> StreamInfoAv | None:
m3u8_url = track_metadata["attributes"]["extendedAssetUrls"].get("enhancedHls")
if not m3u8_url:
return StreamInfo()
return None
return self._get_stream_info(m3u8_url)
def _get_stream_info(self, m3u8_url: str) -> StreamInfo:
def get_stream_info_legacy(self, webplayback: dict) -> StreamInfoAv:
flavor = "32:ctrp64" if self.codec == SongCodec.AAC_HE_LEGACY else "28:ctrp256"
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
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.widevine_pssh = m3u8_obj.keys[0].uri
return StreamInfoAv(
audio_track=stream_info,
file_format=MediaFileFormat.M4A,
)
def get_decryption_key(
self,
stream_info: StreamInfoAv,
media_id: str,
) -> DecryptionKeyAv:
decryption_key = self.downloader.get_decryption_key(
stream_info.audio_track.widevine_pssh,
media_id,
)
return DecryptionKeyAv(
audio_track=decryption_key,
)
def get_decryption_key_legacy(
self,
stream_info: StreamInfoAv,
media_id: str,
) -> DecryptionKeyAv:
stream_info_audio = stream_info.audio_track
try:
cdm_session = self.downloader.cdm.open()
widevine_pssh_data = WidevinePsshData()
widevine_pssh_data.algorithm = 1
widevine_pssh_data.key_ids.append(
base64.b64decode(stream_info_audio.widevine_pssh.split(",")[1])
)
pssh_obj = PSSH(widevine_pssh_data.SerializeToString())
challenge = base64.b64encode(
self.downloader.cdm.get_license_challenge(cdm_session, pssh_obj)
).decode()
license = self.downloader.apple_music_api.get_widevine_license(
media_id,
stream_info.audio_track.widevine_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"
)
finally:
self.downloader.cdm.close(cdm_session)
return DecryptionKeyAv(
audio_track=DecryptionKey(
kid=decryption_key.kid.hex,
key=decryption_key.key.hex(),
)
)
@staticmethod
def parse_datetime_obj_from_timestamp_ttml(
@@ -147,7 +312,10 @@ class DownloaderSong:
secs = float(f"{mins_secs_ms[-2]}.{mins_secs_ms[-1]}")
if len(mins_secs_ms) > 2:
mins = int(mins_secs_ms[-3])
return datetime.datetime.fromtimestamp((mins * 60) + secs + (ms / 1000))
return datetime.datetime.fromtimestamp(
(mins * 60) + secs + (ms / 1000),
tz=datetime.timezone.utc,
)
def get_lyrics_synced_timestamp_lrc(self, timestamp_ttml: str) -> str:
datetime_obj = self.parse_datetime_obj_from_timestamp_ttml(timestamp_ttml)
@@ -177,13 +345,13 @@ class DownloaderSong:
timestamp_srt_end = self.get_lyrics_synced_timestamp_srt(timestamp_ttml_end)
return f"{index}\n{timestamp_srt_start} --> {timestamp_srt_end}\n{text}\n"
def get_lyrics(self, track_metadata: dict) -> Lyrics:
def get_lyrics(self, track_metadata: dict) -> Lyrics | None:
lyrics = Lyrics()
if not track_metadata["attributes"]["hasLyrics"]:
return lyrics
return None
elif track_metadata.get("relationships") is None:
track_metadata = self.downloader.apple_music_api.get_song(
track_metadata["id"]
self.downloader.get_media_id_of_library_media(track_metadata)
)
if (
track_metadata["relationships"].get("lyrics")
@@ -198,80 +366,88 @@ class DownloaderSong:
return lyrics
def _get_lyrics(self, lyrics_ttml: str) -> Lyrics:
lyrics = Lyrics("", "")
lyrics_ttml_et = ElementTree.fromstring(lyrics_ttml)
unsynced_lyrics = []
synced_lyrics = []
index = 1
for div in lyrics_ttml_et.iter("{http://www.w3.org/ns/ttml}div"):
stanza = []
unsynced_lyrics.append(stanza)
for p in div.iter("{http://www.w3.org/ns/ttml}p"):
if p.text is not None:
lyrics.unsynced += p.text + "\n"
stanza.append(p.text)
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
synced_lyrics.append(
f"{self.get_lyrics_synced_line_lrc(p.attrib.get('begin'), p.text)}"
)
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
if self.synced_lyrics_format == SyncedLyricsFormat.SRT:
synced_lyrics.append(
f"{self.get_lyrics_synced_line_srt(index, p.attrib.get('begin'), p.attrib.get('end'), p.text)}"
)
if self.synced_lyrics_format == SyncedLyricsFormat.TTML:
if not synced_lyrics:
synced_lyrics.append(
minidom.parseString(lyrics_ttml).toprettyxml()
)
continue
index += 1
return Lyrics(
synced="\n".join(synced_lyrics) + "\n",
unsynced="\n\n".join(
["\n".join(lyric_group) for lyric_group in unsynced_lyrics]
),
"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")
)
def get_tags(self, webplayback: dict, lyrics_unsynced: str) -> MediaTags:
webplayback_metadata = webplayback["assets"][0]["metadata"]
tags = MediaTags(
album=webplayback_metadata["playlistName"],
album_artist=webplayback_metadata["playlistArtistName"],
album_id=int(webplayback_metadata["playlistId"]),
album_sort=webplayback_metadata["sort-album"],
artist=webplayback_metadata["artistName"],
artist_id=int(webplayback_metadata["artistId"]),
artist_sort=webplayback_metadata["sort-artist"],
comment=webplayback_metadata.get("comments"),
compilation=webplayback_metadata["compilation"],
composer=webplayback_metadata.get("composerName"),
composer_id=(
int(webplayback_metadata.get("composerId"))
if webplayback_metadata.get("composerId")
else None
),
"disc": tags_raw["discNumber"],
"disc_total": tags_raw["discCount"],
"gapless": tags_raw["gapless"],
"genre": tags_raw.get("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"),
}
composer_sort=webplayback_metadata.get("sort-composer"),
copyright=webplayback_metadata.get("copyright"),
date=(
self.downloader.parse_date(webplayback_metadata["releaseDate"])
if webplayback_metadata.get("releaseDate")
else None
),
disc=webplayback_metadata["discNumber"],
disc_total=webplayback_metadata["discCount"],
gapless=webplayback_metadata["gapless"],
genre=webplayback_metadata.get("genre"),
genre_id=int(webplayback_metadata["genreId"]),
lyrics=lyrics_unsynced if lyrics_unsynced else None,
media_type=MediaType.SONG,
rating=MediaRating(webplayback_metadata["explicit"]),
storefront=webplayback_metadata["s"],
title=webplayback_metadata["itemName"],
title_id=int(webplayback_metadata["itemId"]),
title_sort=webplayback_metadata["sort-name"],
track=webplayback_metadata["trackNumber"],
track_total=webplayback_metadata["trackCount"],
xid=webplayback_metadata.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:
@@ -291,27 +467,63 @@ class DownloaderSong:
encrypted_path: Path,
decrypted_path: Path,
decryption_key: str,
codec: SongCodec,
):
self.fix_key_id(encrypted_path)
if codec.is_legacy():
keys = [
"--key",
f"1:{decryption_key}",
]
else:
self.fix_key_id(encrypted_path)
keys = [
"--key",
"0" * 31 + "1" + f":{decryption_key}",
"--key",
"0" * 32 + f":{self.DEFAULT_DECRYPTION_KEY}",
]
subprocess.run(
[
self.downloader.mp4decrypt_path_full,
*keys,
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 stage(
self,
codec: SongCodec,
encrypted_path: Path,
decrypted_path: Path,
decryption_key: DecryptionKeyAv,
staged_path: Path,
):
if codec.is_legacy() and self.downloader.remux_mode == RemuxMode.FFMPEG:
self.remux_ffmpeg(
encrypted_path,
staged_path,
decryption_key.audio_track.key,
)
else:
self.decrypt(
encrypted_path,
decrypted_path,
decryption_key.audio_track.key,
codec,
)
if self.downloader.remux_mode == RemuxMode.FFMPEG:
self.remux_ffmpeg(
decrypted_path,
staged_path,
)
else:
self.remux_mp4box(
decrypted_path,
staged_path,
)
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path):
subprocess.run(
@@ -334,24 +546,26 @@ class DownloaderSong:
self,
decrypted_path: Path,
remuxed_path: Path,
codec: str,
decryption_key: str = None,
):
use_mp4_format = any(
codec.startswith(possible_codec)
for possible_codec in self.MP4_FORMAT_CODECS
)
if decryption_key:
decryption_key_arg = [
"-decryption_key",
decryption_key,
]
else:
decryption_key_arg = []
subprocess.run(
[
self.downloader.ffmpeg_path_full,
"-loglevel",
"error",
"-y",
*decryption_key_arg,
"-i",
decrypted_path,
"-c",
"copy",
"-f",
"mp4" if use_mp4_format else "ipod",
"-movflags",
"+faststart",
remuxed_path,
@@ -361,13 +575,191 @@ class DownloaderSong:
)
def get_lyrics_synced_path(self, final_path: Path) -> Path:
return final_path.with_suffix(
SYNCED_LYRICS_FILE_EXTENSION_MAP[self.synced_lyrics_format]
)
return final_path.with_suffix("." + self.synced_lyrics_format.value)
def get_cover_path(self, final_path: Path, file_extension: str) -> Path:
return final_path.parent / ("Cover" + file_extension)
def get_cover_path(self, final_path: Path, cover_format: str) -> Path:
return final_path.parent / (
"Cover" + self.downloader.get_cover_file_extension(cover_format)
)
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")
def download(
self,
media_id: str = None,
media_metadata: dict = None,
playlist_attributes: dict = None,
playlist_track: int = None,
) -> DownloadInfo:
try:
download_info = self._download(
media_id,
media_metadata,
playlist_attributes,
playlist_track,
)
self.downloader._final_processing(download_info)
finally:
self.downloader.cleanup_temp_path()
return download_info
def _download(
self,
media_id: str = None,
media_metadata: dict = None,
playlist_attributes: dict = None,
playlist_track: int = None,
) -> DownloadInfo:
download_info = DownloadInfo()
if playlist_track is None and playlist_attributes:
raise ValueError(
"playlist_track must be provided if playlist_attributes is provided"
)
if playlist_attributes:
playlist_tags = self.downloader.get_playlist_tags(
playlist_attributes,
playlist_track,
)
else:
playlist_tags = None
download_info.playlist_tags = playlist_tags
if not media_id and not media_metadata:
raise ValueError("Either media_id or media_metadata must be provided")
if not media_metadata:
logger.debug(
f"[{color_text(media_id, colorama.Style.DIM)}] Getting Song metadata"
)
media_metadata = self.downloader.apple_music_api.get_song(media_id)
download_info.media_metadata = media_metadata
if not media_id:
media_id = self.downloader.get_media_id_of_library_media(media_metadata)
download_info.media_id = media_id
colored_media_id = color_text(media_id, colorama.Style.DIM)
if not self.downloader.is_media_streamable(media_metadata):
logger.warning(
f"[{colored_media_id}] "
"Song is not streamable or downloadable, skipping"
)
return download_info
if not self.downloader.is_media_streamable(media_metadata):
logger.warning(
f"[{colored_media_id}] "
"Track is not streamable or downloadable, skipping"
)
return download_info
logger.debug(f"[{colored_media_id}] Getting lyrics")
lyrics = self.get_lyrics(media_metadata)
download_info.lyrics = lyrics
logger.debug(f"[{colored_media_id}] Getting webplayback info")
webplayback = self.downloader.apple_music_api.get_webplayback(
media_id,
)
tags = self.get_tags(
webplayback,
lyrics.unsynced if lyrics else None,
)
final_path = self.downloader.get_final_path(tags, ".m4a", playlist_tags)
download_info.tags = tags
download_info.final_path = final_path
if lyrics and lyrics.synced:
synced_lyrics_path = self.get_lyrics_synced_path(final_path)
else:
synced_lyrics_path = None
download_info.synced_lyrics_path = synced_lyrics_path
if self.downloader.synced_lyrics_only:
logger.info(
f"[{colored_media_id}] Downloading synced lyrics only, skipping song download"
)
return download_info
cover_url = self.downloader.get_cover_url(media_metadata)
cover_format = self.downloader.get_cover_format(cover_url)
if cover_format:
cover_path = self.get_cover_path(final_path, cover_format)
else:
cover_path = None
download_info.cover_url = cover_url
download_info.cover_format = cover_format
download_info.cover_path = cover_path
if final_path.exists() and not self.downloader.overwrite:
logger.warning(
f'[{colored_media_id}] Song already exists at "{final_path}", skipping'
)
return download_info
logger.debug(f"[{colored_media_id}] Getting stream info")
if self.codec.is_legacy():
stream_info = self.get_stream_info_legacy(webplayback)
logger.debug(f"[{colored_media_id}] Getting decryption key")
decryption_key = self.get_decryption_key_legacy(
stream_info,
media_id,
)
download_info.stream_info = stream_info
download_info.decryption_key = decryption_key
else:
stream_info = self.get_stream_info(media_metadata)
if not stream_info or not stream_info.audio_track.widevine_pssh:
logger.warning(
f"[{colored_media_id}] Song is not downloadable or is not "
"available in the selected codec, skipping",
)
return download_info
logger.debug(f"[{colored_media_id}] Getting decryption key")
decryption_key = self.get_decryption_key(
stream_info,
media_id,
)
download_info.stream_info = stream_info
download_info.decryption_key = decryption_key
encrypted_path = self.downloader.get_temp_path(
media_id,
"encrypted",
".m4a",
)
decrypted_path = self.downloader.get_temp_path(
media_id,
"decrypted",
".m4a",
)
staged_path = self.downloader.get_temp_path(
media_id,
"staged",
self.downloader.get_media_file_extension(stream_info.file_format),
)
logger.info(f"[{colored_media_id}] Downloading song")
logger.debug(f'[{colored_media_id}] Downloading to "{encrypted_path}"')
self.downloader.download(
encrypted_path,
download_info.stream_info.audio_track.stream_url,
)
logger.debug(
f'[{colored_media_id}] Decryping/remuxing to "{decrypted_path}"/"{staged_path}"'
)
self.stage(
self.codec,
encrypted_path,
decrypted_path,
decryption_key,
staged_path,
)
download_info.staged_path = staged_path
return download_info
-127
View File
@@ -1,127 +0,0 @@
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)
+64
View File
@@ -25,6 +25,9 @@ class SongCodec(Enum):
ALAC = "alac"
ASK = "ask"
def is_legacy(self) -> bool:
return self in {SongCodec.AAC_LEGACY, SongCodec.AAC_HE_LEGACY}
class SyncedLyricsFormat(Enum):
LRC = "lrc"
@@ -37,6 +40,37 @@ class MusicVideoCodec(Enum):
H265 = "h265"
ASK = "ask"
def fourcc(self) -> str:
return {
MusicVideoCodec.H264: "avc1",
MusicVideoCodec.H265: "hvc1",
}.get(self)
class RemuxFormatMusicVideo(Enum):
M4V = "m4v"
MP4 = "mp4"
class MusicVideoResolution(Enum):
R240P = "240p"
R360P = "360p"
R480P = "480p"
R540P = "540p"
R720P = "720p"
R1080P = "1080p"
R1440P = "1440p"
R2160P = "2160p"
def __int__(self) -> int:
return int(self.value[:-1])
class MediaFileFormat(Enum):
M4A = "m4a"
MP4 = "mp4"
M4V = "m4v"
class PostQuality(Enum):
BEST = "best"
@@ -47,3 +81,33 @@ class CoverFormat(Enum):
JPG = "jpg"
PNG = "png"
RAW = "raw"
class MediaType(Enum):
SONG = 1
MUSIC_VIDEO = 6
def __str__(self) -> str:
return {
MediaType.SONG: "Song",
MediaType.MUSIC_VIDEO: "Music Video",
}[self]
def __int__(self) -> int:
return self.value
class MediaRating(Enum):
NONE = 0
EXPLICIT = 1
CLEAN = 2
def __str__(self) -> str:
return {
MediaRating.NONE: "None",
MediaRating.EXPLICIT: "Explicit",
MediaRating.CLEAN: "Clean",
}[self]
def __int__(self) -> int:
return self.value
+2
View File
@@ -1 +1,3 @@
# Dumped from Android Studio Virtual Device running Android 9
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=="""
+8 -11
View File
@@ -4,8 +4,8 @@ import functools
import requests
from .apple_music_api import AppleMusicApi
from .constants import STOREFRONT_IDS
from .utils import raise_response_exception
class ItunesApi:
@@ -40,7 +40,7 @@ class ItunesApi:
self,
resource_id: str,
entity: str = "album",
) -> dict:
) -> dict | None:
response = self.session.get(
self.ITUNES_LOOKUP_API_URL,
params={
@@ -51,21 +51,20 @@ class ItunesApi:
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
raise_response_exception(response)
if response_dict.get("results"):
return response_dict["results"]
return None
def get_itunes_page(
self,
resource_type: str,
resource_id: str,
) -> dict:
) -> dict | None:
response = self.session.get(
f"{self.ITUNES_PAGE_API_URL}/{resource_type}/{resource_id}"
)
@@ -75,11 +74,9 @@ class ItunesApi:
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)
raise_response_exception(response)
return itunes_page
+150 -2
View File
@@ -1,19 +1,29 @@
from __future__ import annotations
import datetime
import typing
from dataclasses import dataclass
from pathlib import Path
from .enums import MediaFileFormat, MediaRating, MediaType
@dataclass
class UrlInfo:
storefront: str = None
type: str = None
slug: str = None
id: str = None
sub_id: str = None
library_storefront: str = None
library_type: str = None
library_id: str = None
@dataclass
class DownloadQueue:
playlist_attributes: dict = None
tracks_metadata: list[dict] = None
medias_metadata: list[dict] = None
@dataclass
@@ -25,5 +35,143 @@ class Lyrics:
@dataclass
class StreamInfo:
stream_url: str = None
pssh: str = None
widevine_pssh: str = None
playready_pssh: str = None
fairplay_key: str = None
codec: str = None
@dataclass
class StreamInfoAv:
video_track: StreamInfo = None
audio_track: StreamInfo = None
file_format: MediaFileFormat = None
@dataclass
class DecryptionKey:
kid: str = None
key: str = None
@dataclass
class DecryptionKeyAv:
video_track: DecryptionKey = None
audio_track: DecryptionKey = None
@dataclass
class MediaTags:
album: str = None
album_artist: str = None
album_id: int = None
album_sort: str = None
artist: str = None
artist_id: int = None
artist_sort: str = None
comment: str = None
compilation: bool = None
composer: str = None
composer_id: int = None
composer_sort: str = None
copyright: str = None
date: datetime.date | str = None
disc: int = None
disc_total: int = None
gapless: bool = None
genre: str = None
genre_id: int = None
lyrics: str = None
media_type: MediaType = None
rating: MediaRating = None
storefront: str = None
title: str = None
title_id: int = None
title_sort: str = None
track: int = None
track_total: int = None
xid: str = None
def to_mp4_tags(self, date_format: str = None) -> dict[str, typing.Any]:
disc_mp4 = [
[
self.disc if self.disc is not None else 0,
self.disc_total if self.disc_total is not None else 0,
]
]
if disc_mp4[0][0] == 0 and disc_mp4[0][1] == 0:
disc_mp4 = [None]
track_mp4 = [
[
self.track if self.track is not None else 0,
self.track_total if self.track_total is not None else 0,
]
]
if track_mp4[0][0] == 0 and track_mp4[0][1] == 0:
track_mp4 = [None]
if isinstance(self.date, datetime.date):
if date_format is None:
date_mp4 = self.date.isoformat()
else:
date_mp4 = self.date.strftime(date_format)
elif isinstance(self.date, str):
date_mp4 = self.date
mp4_tags = {
"\xa9alb": [self.album],
"aART": [self.album_artist],
"plID": [self.album_id],
"soal": [self.album_sort],
"\xa9ART": [self.artist],
"atID": [self.artist_id],
"soar": [self.artist_sort],
"\xa9cmt": [self.comment],
"cpil": [bool(self.compilation) if self.compilation is not None else None],
"\xa9wrt": [self.composer],
"cmID": [self.composer_id],
"soco": [self.composer_sort],
"cprt": [self.copyright],
"\xa9day": date_mp4,
"disk": disc_mp4,
"pgap": [bool(self.gapless) if self.gapless is not None else None],
"\xa9gen": [self.genre],
"\xa9lyr": [self.lyrics],
"geID": [self.genre_id],
"stik": [int(self.media_type) if self.media_type is not None else None],
"rtng": [int(self.rating) if self.rating is not None else None],
"sfID": [self.storefront],
"\xa9nam": [self.title],
"cnID": [self.title_id],
"sonm": [self.title_sort],
"trkn": track_mp4,
"xid ": [self.xid],
}
return {k: v for k, v in mp4_tags.items() if v[0] is not None}
@dataclass
class PlaylistTags:
playlist_artist: str = None
playlist_id: int = None
playlist_title: str = None
playlist_track: int = None
@dataclass
class DownloadInfo:
media_metadata: dict = None
media_id: str = None
alt_media_id: str = None
playlist_tags: PlaylistTags = None
lyrics: Lyrics = None
tags: MediaTags = None
final_path: Path = None
cover_url: str = None
cover_format: str = None
cover_path: Path = None
stream_info: StreamInfoAv = None
decryption_key: DecryptionKeyAv = None
staged_path: Path = None
synced_lyrics_path: Path = None
+46
View File
@@ -0,0 +1,46 @@
from pathlib import Path
import click
import colorama
import requests
from .constants import X_NOT_FOUND_STRING
def color_text(text: str, color) -> str:
return color + text + colorama.Style.RESET_ALL
def raise_response_exception(response: requests.Response):
raise Exception(
f"Request failed with status code {response.status_code}: {response.text}"
)
def prompt_path(is_file: bool, initial_path: Path, description: str) -> Path:
path_validator = click.Path(
exists=True,
file_okay=is_file,
dir_okay=not is_file,
path_type=Path,
)
path_type = "file" if is_file else "folder"
while True:
try:
path_obj = path_validator.convert(initial_path, None, None)
break
except click.BadParameter as e:
path_str = click.prompt(
(
f"{X_NOT_FOUND_STRING.format(description, initial_path.absolute())} or "
"the specified path is not valid. "
f"Move the {path_type} to that location, type a new path "
f"or drag and drop the {path_type} here. "
"Then, press enter to continue"
),
default=str(initial_path),
show_default=False,
)
path_str = path_str.strip('"')
initial_path = Path(path_str)
return path_obj
+4 -3
View File
@@ -1,15 +1,16 @@
[project]
name = "gamdl"
description = "A Python CLI app for downloading Apple Music songs/music videos/posts."
requires-python = ">=3.8"
description = "A command-line app for downloading Apple Music songs, music videos and post videos."
requires-python = ">=3.10"
authors = [{ name = "glomatico" }]
dependencies = [
"click",
"colorama",
"inquirerpy",
"m3u8",
"mutagen",
"pillow",
"pywidevine",
"pyyaml",
"yt-dlp",
]
readme = "README.md"
+3
View File
@@ -1,7 +1,10 @@
click
colorama
inquirerpy
m3u8
mutagen
pillow
pywidevine
pyyaml
termcolor
yt-dlp