Compare commits

..

543 Commits

Author SHA1 Message Date
Rafael Moraes 6b67c435fa Fix spacing in CLI warning message 2026-02-25 15:12:46 -03:00
Rafael Moraes 240ba7d4de Handle 404 ApiError for Apple Music calls 2026-02-25 15:09:52 -03:00
Rafael Moraes 02c19963b4 Clarify wrapper requirements in README 2026-02-25 14:55:33 -03:00
Rafael Moraes 2e2fef1426 Bump version to 2.9 2026-02-25 14:54:28 -03:00
Rafael Moraes ae3b2e1c6d Skip fetching covers when CoverFormat.RAW 2026-02-25 14:48:47 -03:00
Rafael Moraes 6516855be9 Fix Apple Music cover URL and async image read 2026-02-25 14:48:35 -03:00
Rafael Moraes 77cbb8a7ca Clarify README prerequisites and config table 2026-02-25 14:33:50 -03:00
Rafael Moraes 18bc6595a9 Add music_video_remux_mode and adjust checks 2026-02-25 14:33:32 -03:00
Rafael Moraes da2c3d5f1e Move remux_mode to music video downloader 2026-02-25 14:33:08 -03:00
Rafael Moraes abe364aad1 Remove unused imports in downloader_song.py 2026-02-25 14:32:29 -03:00
Rafael Moraes 10b529d6fd Remove hardcoded song decryption key 2026-02-25 14:08:57 -03:00
Rafael Moraes afe42848d0 Refactor song decryption and staging 2026-02-25 14:08:35 -03:00
Rafael Moraes b3b5e6d1b2 Add sample encryption parsing and hex-key decryption 2026-02-25 14:08:09 -03:00
Rafael Moraes 9f86c7436d Bump version to 2.8.7 2026-02-25 12:36:31 -03:00
Rafael Moraes 74a26d0342 Preserve original moov boxes and metadata 2026-02-25 12:30:29 -03:00
Rafael Moraes 37895dea1c Add AI-generated notice to amdecrypt.py 2026-02-25 00:13:58 -03:00
Rafael Moraes 04396a7f3f Bump version to 2.8.6 2026-02-25 00:09:53 -03:00
Rafael Moraes bde49305c9 Select audio track for moof/mdat extraction 2026-02-25 00:08:36 -03:00
Rafael Moraes b0c3b4630d Make decrypt_samples async and use asyncio streams 2026-02-24 23:09:32 -03:00
Rafael Moraes fd30ab861b Update help text for --use-wrapper 2026-02-23 23:56:06 -03:00
Rafael Moraes b1827e8d1b Bump version to 2.8.5 2026-02-23 23:50:47 -03:00
Rafael Moraes fe020442b1 Fetch song details when extendedAssetUrls missing 2026-02-23 23:50:20 -03:00
Rafael Moraes 87b8492b4f Include legacy codec in wrapper bypass check 2026-02-23 23:46:54 -03:00
Rafael Moraes f961ade8d8 Remove forced AAC override for wrapper usage 2026-02-23 23:46:40 -03:00
Rafael Moraes 471a2e85ac Include offset from next_uri in AMP requests 2026-02-23 23:43:47 -03:00
Rafael Moraes a17b1296d8 Fix spacing in wrapper codec warning 2026-02-23 23:31:33 -03:00
Rafael Moraes 22628c4c53 Bypass wrapper for music videos 2026-02-23 23:30:46 -03:00
Rafael Moraes 23a5be37b1 Handle wrapper: skip exec checks and adjust codec 2026-02-23 23:18:28 -03:00
Rafael Moraes 9aa7a2e199 Use media_type_key for music-videos check 2026-02-23 23:09:19 -03:00
Rafael Moraes 31d07172a6 Include live albums in artist views 2026-02-23 23:07:09 -03:00
Rafael Moraes fbe0167f0e Add live albums support 2026-02-23 23:06:56 -03:00
Rafael Moraes 1d621568a0 README: simplify wrapper docs and config 2026-02-23 22:59:51 -03:00
Rafael Moraes fa31649d76 Preserve moov box timestamps in decrypted m4a 2026-02-23 22:57:03 -03:00
Rafael Moraes 16d8dc925a Handle wrapper connect errors; remove amdecrypt 2026-02-23 22:00:38 -03:00
Rafael Moraes 46d1ec11dc Add Python amdecrypt and remove amdecrypt dep 2026-02-23 22:00:22 -03:00
Rafael Moraes f68e76ce8b Add ApiError and centralize AMP requests 2026-02-23 21:52:53 -03:00
Rafael Moraes 42df1f7f5e Make safe_json return None on parse error 2026-02-23 21:44:47 -03:00
Rafael Moraes a7c8ff4297 Fix relative import for GamdlError in exceptions.py 2026-01-30 12:22:30 -03:00
Rafael Moraes 5332e0e1c0 Move GamdlError to utils and update imports 2026-01-30 12:21:39 -03:00
Rafael Moraes b8ea1d0039 Add support for downloading artist top songs 2026-01-24 10:54:55 -03:00
Rafael Moraes 4de0e3d1f8 Add 'views' parameter to artist API request 2026-01-24 10:54:49 -03:00
Rafael Moraes c770ff361f Refactor config file loading with decorator
Introduced ConfigFile.loader decorator to handle config file loading in CLI entrypoint. Removed manual config file loading logic from main function for improved modularity and readability.
2026-01-17 01:37:49 -03:00
Rafael Moraes d6afb680be Exclude help and version from CLI config parsing 2026-01-16 23:12:48 -03:00
Rafael Moraes b15f404849 Refactor config file loading in CLI 2026-01-16 23:06:48 -03:00
Rafael Moraes 072d71caaf Remove explicit click param types from CLI config 2026-01-16 22:53:14 -03:00
Rafael Moraes 7e132c27de Refactor config parameter handling to use Click params 2026-01-16 22:49:45 -03:00
Rafael Moraes 073f70afa7 Bump version to 2.8.4 2026-01-15 23:43:24 -03:00
Rafael Moraes a49430018a Remove setuptools packages config from pyproject.toml 2026-01-15 23:42:59 -03:00
Rafael Moraes f0450b93c7 Update import to use relative path in __main__.py 2026-01-15 23:42:55 -03:00
Rafael Moraes 9b701e8ee8 Update license format and add setuptools packages 2026-01-15 22:58:33 -03:00
Rafael Moraes f4e6069e69 Bump version to 2.8.3 2026-01-15 22:50:41 -03:00
Rafael Moraes 841b1edb64 Fix import location for CliConfig in config_file.py 2026-01-15 22:48:58 -03:00
Rafael Moraes ef4b34f3d2 Add pathlib and Csv import to cli_config.py 2026-01-15 22:48:53 -03:00
Rafael Moraes 98980fc130 Refactor CliConfig to separate module 2026-01-15 22:47:51 -03:00
Rafael Moraes 6c84651770 Fix config value check to distinguish None from falsy values 2026-01-15 22:44:08 -03:00
Rafael Moraes f9d3d0a97e Refactor playlist file path formatting logic 2026-01-15 22:41:33 -03:00
Rafael Moraes 9a879c0857 Refactor template variable names for clarity 2026-01-15 22:30:09 -03:00
Rafael Moraes d0ab35383b Remove unnecessary strip() after regex substitution 2026-01-15 22:28:54 -03:00
Rafael Moraes b14004f3e3 Update installation instructions to use pip 2026-01-15 22:26:46 -03:00
Rafael Moraes a6e409d98d Update template variable documentation in README 2026-01-15 22:25:06 -03:00
Rafael Moraes d1c9aea874 Skip config updates for command-line parameters 2026-01-15 22:15:55 -03:00
Rafael Moraes 8c110b4fb9 Refactor file template selection logic 2026-01-15 22:12:03 -03:00
Rafael Moraes e1c8cb51ad Refactor path sanitization and formatting logic 2026-01-15 22:11:14 -03:00
Rafael Moraes 52324d519c Refactor CLI to use dataclass-based config and options 2026-01-15 03:12:39 -03:00
Rafael Moraes 057315524f Add dataclass-click to project dependencies 2026-01-15 03:12:18 -03:00
Rafael Moraes 446636166e Update README with new CLI options 2026-01-04 15:11:09 -03:00
Rafael Moraes 7199cac179 Add support for fetching and applying extra tags 2026-01-04 15:11:02 -03:00
Rafael Moraes be4f30cb54 Add method to extract extra tags from Apple Music previews 2026-01-04 15:10:54 -03:00
Rafael Moraes 83ca91e91c Refactor cover image handling to interface layer 2026-01-03 15:08:35 -03:00
Rafael Moraes 6ed596ca42 Add option to use album release date for songs 2026-01-03 14:43:55 -03:00
Rafael Moraes 414ce749d6 Remove unused httpx import from downloader_base.py 2026-01-03 14:24:06 -03:00
Rafael Moraes 17863b500a Add UnsupportedMediaType exception and checks for downloaders 2026-01-02 13:31:24 -03:00
Rafael Moraes 5e48032f34 Remove redundant error handling in downloaders 2026-01-02 13:23:56 -03:00
Rafael Moraes e2ed443253 Add unified error handling to get_download_item 2026-01-02 13:19:13 -03:00
Rafael Moraes ade78ad7b3 Bump version to 2.8.2 2025-12-21 16:17:01 -03:00
Rafael Moraes 054f636434 Bump version to 2.8.2 2025-12-21 16:07:48 -03:00
Rafael Moraes bf9c74d9d8 Increase concurrency limit in safe_gather to 10 2025-12-21 16:07:33 -03:00
Rafael Moraes 3c48618e84 Remove custom transport retries from AppleMusicApi 2025-12-21 15:56:30 -03:00
Rafael Moraes c940ee2f47 Replace sequential_gather with safe_gather in downloader 2025-12-21 15:55:07 -03:00
Rafael Moraes 7f56dfd0c8 Remove retry logic from safe_gather utility 2025-12-21 15:54:52 -03:00
Rafael Moraes 7c3112421d Refactor AppleMusicApi.create_from_wrapper to use get_response utility 2025-12-21 01:48:16 -03:00
Rafael Moraes 55ce7555a9 Add timeout to iTunes API search request 2025-12-21 01:36:03 -03:00
Rafael Moraes 9c4adbb2c1 Refactor HTTP response handling for m3u8 and cover fetch 2025-12-21 01:34:09 -03:00
Rafael Moraes 1591f0daf2 Set httpx.AsyncClient timeout to 60 seconds 2025-12-18 14:21:56 -03:00
Rafael Moraes 25d028bea4 Add colorama for improved Windows console support 2025-12-14 19:13:30 -03:00
Rafael Moraes ebc28a019e Bump version to 2.8.1 2025-12-10 01:23:32 -03:00
Rafael Moraes 690df6e9d7 Update README example for AppleMusicApi usage 2025-12-10 01:12:52 -03:00
Rafael Moraes 8039c7c86f Reorder error check in AppleMusicDownloader 2025-12-10 01:08:13 -03:00
Rafael Moraes f67ba37d19 Check streamability before downloading media 2025-12-09 23:26:53 -03:00
Rafael Moraes 59f247a90f Fix default language option in CLI 2025-12-06 15:41:44 -03:00
Rafael Moraes 181bdb198d Refactor AppleMusicApi init and factory methods 2025-12-06 15:40:45 -03:00
Rafael Moraes 1945342adc Improve audio track validation in AppleMusicDownloader 2025-12-05 01:11:31 -03:00
Rafael Moraes f19ef4d6dd Fix audio track validation in AppleMusicDownloader 2025-12-05 01:05:44 -03:00
Rafael Moraes 1ceb7fcf46 Instantiate ItunesApi directly in CLI 2025-12-04 17:28:23 -03:00
Rafael Moraes 23ed14ca04 Refactor ItunesApi instantiation and initialization 2025-12-04 17:27:59 -03:00
Rafael Moraes 3e3939d0ee Refactor downloader setup to initialization method 2025-12-04 17:26:35 -03:00
Rafael Moraes 780261a9c8 Update API instantiation to use async factory methods 2025-12-04 17:24:41 -03:00
Rafael Moraes 80cb80e9a2 Refactor AppleMusicApi and ItunesApi initialization 2025-12-04 17:24:32 -03:00
Rafael Moraes f3b7adaad3 Replace safe_gather with sequential_gather in downloader 2025-12-04 16:52:34 -03:00
Rafael Moraes fe6a6e308d Refactor mp4decrypt and amdecrypt path checks in CLI 2025-11-29 14:27:28 -03:00
Rafael Moraes b08bf98759 Reduce retry count in safe_gather utility 2025-11-29 14:24:58 -03:00
Rafael Moraes 37c857b503 Bump version to 2.8 2025-11-28 19:18:32 -03:00
Rafael Moraes 4693ba69c9 Merge branch 'wrapper' 2025-11-28 19:16:44 -03:00
Rafael Moraes 9212319d3b Remove unused STOREFRONT_IDS import 2025-11-28 18:49:23 -03:00
Rafael Moraes e54f318c36 Add wrapper & amdecrypt instructions to README 2025-11-28 11:23:39 -03:00
Rafael Moraes b1e40299ca Refactor AppleMusicApi token setup logic 2025-11-28 00:15:17 -03:00
Rafael Moraes ba86825068 Merge pull request #252 from fredystar200/patch-1
Add constant for 'CM' in constants.py (Cameroon)
2025-11-27 21:09:11 -03:00
Rafael Moraes b5f08753b8 Rename use_wrapper_decrypt to use_wrapper 2025-11-27 18:16:32 -03:00
Rafael Moraes d4bf75c0d1 Rename enable_wrapper_decrypt to use_wrapper_decrypt 2025-11-27 16:09:11 -03:00
Rafael Moraes e998ce1a2e Add support for FairPlay and PlayReady PSSH extraction 2025-11-27 15:17:13 -03:00
Rafael Moraes 5285ca0cfa Update warning for experimental song codec usage 2025-11-27 15:03:57 -03:00
Rafael Moraes f3927b8e6d Add wrapper decryption options to CLI 2025-11-27 15:02:53 -03:00
Rafael Moraes 40b7ce05d3 Fix decryption key check in AppleMusicDownloader 2025-11-27 15:02:47 -03:00
Rafael Moraes 8cd01e7964 Refactor wrapper_decrypt_ip handling in downloaders 2025-11-27 15:02:40 -03:00
Rafael Moraes f769c6b686 Refactor wrapper decrypt flag handling in downloaders 2025-11-27 14:46:56 -03:00
Rafael Moraes ea7356e7c4 Add amdecrypt support for wrapper-based decryption 2025-11-27 14:44:29 -03:00
Rafael Moraes f3d8242110 Add from_wrapper constructor to AppleMusicApi 2025-11-27 14:34:35 -03:00
Rafael Moraes faf3bb3a20 Add optional token parameter to AppleMusicApi 2025-11-27 12:59:59 -03:00
Rafael Moraes 24c3ce8a02 Handle missing stream info in staged path assignment 2025-11-27 11:18:28 -03:00
Rafael Moraes 65eb8c0fb6 Simplify decryption key validation logic 2025-11-27 11:14:04 -03:00
Rafael Moraes f90be057d6 Add decryption key checks to AppleMusicDownloader 2025-11-27 11:10:57 -03:00
Rafael Moraes 76cc80cba8 Refactor error handling to use GamdlError 2025-11-27 00:55:32 -03:00
Rafael Moraes 7a7c1adb22 Check for widevine_pssh in audio track before download 2025-11-27 00:54:40 -03:00
Rafael Moraes 200e392fad Refactor exception classes and usage in downloader 2025-11-27 00:52:02 -03:00
Rafael Moraes 1083957303 Raise error if present in download_item 2025-11-21 20:27:59 -03:00
fredystar200 ae6bed11af Add constant for 'CM' in constants.py (Cameroon) 2025-11-19 10:08:07 +01:00
Rafael Moraes 7da83866cf Update contributing guidelines in README 2025-11-18 15:18:44 -03:00
Rafael Moraes 273b171398 Bump version to 2.7.5 2025-11-12 12:01:27 -03:00
Rafael Moraes 2913d96b70 Filter out items without attributes in selection lists 2025-11-12 11:56:26 -03:00
Rafael Moraes a332516056 Increase retry limit in safe_gather to 10 2025-11-12 11:51:54 -03:00
Rafael Moraes c636e4be33 Mark ALAC codec as unsupported in README 2025-11-11 22:06:09 -03:00
Rafael Moraes 1841a988e2 Handle empty lyrics in AppleMusicSongInterface 2025-11-11 22:04:55 -03:00
Rafael Moraes 8cdaa127d7 Bump version to 2.7.4 2025-11-11 22:02:39 -03:00
Rafael Moraes c31a6eee8e Increase Apple Music API client timeout to 60s 2025-11-11 22:02:14 -03:00
Rafael Moraes 00d301c23d Refactor track metadata extension logic 2025-11-11 22:02:02 -03:00
Rafael Moraes f05aa579d3 Increase HTTP transport retries to 10 2025-11-11 02:14:35 -03:00
Rafael Moraes 7e642ab2f3 Refactor path prompt logic in CLI utilities 2025-11-11 01:53:59 -03:00
Rafael Moraes c34f49faae Rename song codec CLI option for consistency 2025-11-06 15:49:20 -03:00
Rafael Moraes 78c3da5b8c Remove unused imports and parameters in README example 2025-11-06 15:48:13 -03:00
Rafael Moraes 00410aeb77 Fix README table row order for template options 2025-11-06 15:45:57 -03:00
Rafael Moraes 4211ab6f8c Fix option order for no_album_folder_template 2025-11-06 15:45:48 -03:00
Rafael Moraes 599c9140db Remove debug print from load_config_file 2025-11-06 12:57:23 -03:00
Rafael Moraes 73ab79beea Move utility functions from utils.py to cli.py
Relocated the load_config_file and make_sync functions from gamdl/cli/utils.py to gamdl/cli/cli.py to improve code organization and reduce unnecessary imports in utils.py.
2025-11-06 12:54:39 -03:00
Rafael Moraes 2dfed33fe2 Refactor config param default serialization logic 2025-11-06 12:54:23 -03:00
Rafael Moraes 4eb764af17 Update PathPrompt.convert to accept str type only 2025-11-05 23:57:35 -03:00
Rafael Moraes 6cdccf1f4f Refactor Csv param type to use Enum for subtype 2025-11-05 23:42:12 -03:00
Rafael Moraes a999271715 Update README with expanded usage example 2025-11-05 08:52:26 -03:00
Rafael Moraes 633674f45e Refactor MP4 tag generation in MediaTags 2025-11-05 08:49:02 -03:00
Rafael Moraes ceeef6b352 Skip Widevine decryption for ALAC codec 2025-11-02 12:56:19 -03:00
Rafael Moraes 8aa172185a Make CDM operations async using asyncio.to_thread 2025-10-30 12:37:12 -03:00
Rafael Moraes bdbaf7ca05 Make license challenge generation asynchronous 2025-10-30 12:37:05 -03:00
Rafael Moraes a9e1e02ebb Make license parsing asynchronous in AppleMusicInterface 2025-10-30 12:35:31 -03:00
Rafael Moraes 85619a3672 Refactor MP4 tagging to use apply_mp4_tags method 2025-10-30 12:31:16 -03:00
Rafael Moraes 15c1cc45f5 Rename GamdlBinaryNotFoundError to GamdlExecutableNotFoundError 2025-10-30 00:12:19 -03:00
Rafael Moraes b86e938185 Replace MediaDownloadConfigurationError with GamdlSyncedLyricsOnlyError 2025-10-30 00:05:38 -03:00
Rafael Moraes be4596798a Rename media error classes in CLI imports and usage 2025-10-29 23:56:31 -03:00
Rafael Moraes da8e49bd68 Refactor error handling and binary checks in downloader 2025-10-29 23:56:22 -03:00
Rafael Moraes 03c3b0e788 Refactor and add custom downloader exceptions 2025-10-29 23:56:15 -03:00
Rafael Moraes 3aca011b7d Refactor AppleMusicDownloader to remove Exception from return types 2025-10-29 23:30:27 -03:00
Rafael Moraes dfa38c6736 Add error field to DownloadItem dataclass 2025-10-29 23:30:18 -03:00
Rafael Moraes 48a8c940e1 Add error handling to download item methods 2025-10-29 23:30:12 -03:00
Rafael Moraes e80c776835 Bump version 2025-10-28 12:26:36 -03:00
Rafael Moraes 36e85098e5 Improve video playlist selection by codec priority 2025-10-28 12:25:09 -03:00
Rafael Moraes 7610768723 Change download method to return DownloadItem 2025-10-28 00:45:46 -03:00
Rafael Moraes 9afe027f5d Set video resolution in stream info 2025-10-28 00:32:33 -03:00
Rafael Moraes 4c5c43844a Add width and height to StreamInfo for video resolution 2025-10-28 00:32:29 -03:00
Rafael Moraes 025c89d85a Refactor flat filter handling in downloader 2025-10-27 23:09:50 -03:00
Rafael Moraes f8d1036c37 Add flat_filter support to AppleMusicDownloader 2025-10-27 23:01:17 -03:00
Rafael Moraes 0d8e6c4626 Add playlist_metadata and flat fields to DownloadItem 2025-10-27 22:59:29 -03:00
Rafael Moraes 5aff11bcae Add playlist metadata to download items 2025-10-27 22:59:23 -03:00
Rafael Moraes b5ce18ef26 Refactor CLI to use new Apple Music interfaces 2025-10-27 22:03:45 -03:00
Rafael Moraes 70346171b1 Refactor AppleMusicDownloader to use interface 2025-10-27 22:03:38 -03:00
Rafael Moraes 4a63070489 Refactor downloader classes to inherit from base 2025-10-27 22:03:30 -03:00
Rafael Moraes cb60eee694 Refactor interfaces to inherit from AppleMusicInterface 2025-10-27 22:03:19 -03:00
Rafael Moraes 955f649779 Fix cleanup logic in AppleMusicDownloader 2025-10-27 19:52:35 -03:00
Rafael Moraes c833f24fe2 Add skip_processing checks to AppleMusicDownloader 2025-10-27 19:51:38 -03:00
Rafael Moraes bc76032532 Update configuration options table in README 2025-10-27 15:13:05 -03:00
Rafael Moraes 42f782faa5 Update help text for --wvd-path option 2025-10-27 15:12:44 -03:00
Rafael Moraes 862a150c44 Bump version to 2.7.2 2025-10-27 15:08:57 -03:00
Rafael Moraes 4cfb626d00 Remove unknown params from config file 2025-10-27 15:06:24 -03:00
Rafael Moraes fdab6481ea Rename disc folder template options to file templates 2025-10-27 15:04:07 -03:00
Rafael Moraes 9eff34390b Bump version to 2.7.1 in pyproject.toml 2025-10-25 17:56:20 -03:00
Rafael Moraes f2c1961697 Bump version to 2.7.1 2025-10-25 17:37:02 -03:00
Rafael Moraes fff227522f Fix library urls 2025-10-25 17:36:10 -03:00
Rafael Moraes b7c813571e Reduce concurrency limit in safe_gather 2025-10-25 17:32:19 -03:00
Rafael Moraes 2c91982ae0 Update music video resolution option description 2025-10-23 23:08:21 -03:00
Rafael Moraes 04f847a9bf Add project repository URL to pyproject.toml 2025-10-23 17:38:53 -03:00
Rafael Moraes 8351d6dca9 Update project name in README 2025-10-23 14:25:47 -03:00
Rafael Moraes 75595e8de0 Refine music video options section in README 2025-10-23 14:25:04 -03:00
Rafael Moraes e03d134865 Reformat configuration options table in README 2025-10-23 14:22:08 -03:00
Rafael Moraes 0f9ae5f6b5 Expand README with option details and clarifications 2025-10-23 14:21:13 -03:00
Rafael Moraes 909c75dd92 Add async LRU cache for get_album method 2025-10-23 14:10:02 -03:00
Rafael Moraes ef2f0a56ae Move CustomLoggerFormatter to utils.py and update imports 2025-10-23 14:03:14 -03:00
Rafael Moraes 243b3ea45c Standardize get_cover_path method signature and logic 2025-10-23 13:56:29 -03:00
Rafael Moraes 750fc5b9de Skip non-synced lyrics downloads when enabled 2025-10-23 13:50:02 -03:00
Rafael Moraes 65544a56a0 Refactor error handling and processing in AppleMusicDownloader 2025-10-23 13:41:14 -03:00
Rafael Moraes 9a1059b77f Add badges to README for PyPI, Python, license, downloads 2025-10-23 13:29:32 -03:00
Rafael Moraes 2a1014bfd5 Add rich metadata feature to feature list 2025-10-23 13:18:53 -03:00
Rafael Moraes c0e541f513 Update README to use consistent 'Gamdl' capitalization 2025-10-23 13:16:41 -03:00
Rafael Moraes 81ba47e26e Update README to recommend pipx for installation 2025-10-23 13:11:36 -03:00
Rafael Moraes 9d8aac86d6 Update README for Apple Music cookies and formats 2025-10-23 13:08:28 -03:00
Rafael Moraes 87aa300fc1 Simplify and clarify CLI option help texts 2025-10-23 13:05:45 -03:00
Rafael Moraes 883d442668 Reorder codec priority in music video downloader 2025-10-23 13:05:30 -03:00
Rafael Moraes c865817e2c Update README description for clarity 2025-10-23 12:53:40 -03:00
Rafael Moraes 47c718e02a Update contributing guidelines in README 2025-10-23 12:52:49 -03:00
Rafael Moraes 1775c58412 Update codec and format descriptions in README 2025-10-23 12:51:41 -03:00
Rafael Moraes 59435f7a3f Revamp README with improved structure and clarity 2025-10-23 12:48:10 -03:00
Rafael Moraes 81f6449cf7 Update license field to MIT in pyproject.toml 2025-10-23 12:40:51 -03:00
Rafael Moraes 7fb2d5f114 Fix import path for main function in __main__.py 2025-10-23 12:37:37 -03:00
Rafael Moraes d1bde8ce22 Update gamdl package version to 2.7 2025-10-23 12:37:29 -03:00
Rafael Moraes 8ebcd2c524 Add GitHub Actions workflow for Python package publishing
This workflow automates the process of uploading a Python package to PyPI when a release is published on GitHub.
2025-10-23 12:34:07 -03:00
Rafael Moraes 801e2ec8b4 Merge pull request #237 from glomatico/dev
Dev
2025-10-23 12:33:03 -03:00
Rafael Moraes 4b9725bf52 Remove publish workflow configuration 2025-10-23 12:25:42 -03:00
Rafael Moraes fb18d56f06 Add uv.lock and update .gitignore for lock file 2025-10-23 12:23:54 -03:00
Rafael Moraes 5a7d884781 Update project config and dependencies 2025-10-23 12:22:42 -03:00
Rafael Moraes 50dcfa14e7 Refactor CLI utility classes and functions to utils.py 2025-10-23 11:54:39 -03:00
Rafael Moraes 696c9f7537 Update embedding example in README for async usage 2025-10-23 01:16:37 -03:00
Rafael Moraes abd0e27d64 Refactor imports and add package-level exports 2025-10-23 01:14:15 -03:00
Rafael Moraes f09d2050a8 Update README with revised CLI options and templates 2025-10-23 01:08:44 -03:00
Rafael Moraes 9d848cdb99 Remove database_path option from downloader and CLI 2025-10-23 01:06:29 -03:00
Rafael Moraes f719008557 Handle Exception type in download method 2025-10-23 00:51:48 -03:00
Rafael Moraes f1762d5008 Refactor AppleMusicDownloader error handling 2025-10-23 00:49:59 -03:00
Rafael Moraes baaa8637bb Refactor AppleMusic download flow for synced lyrics only 2025-10-23 00:35:00 -03:00
Rafael Moraes d9b1325b94 Add configuration checks and error for media downloads 2025-10-23 00:30:22 -03:00
Rafael Moraes 0107d55b4b Rename quality_post to uploaded_video_quality 2025-10-22 18:50:19 -03:00
Rafael Moraes b368bb3083 Refactor uploaded video interface methods to async 2025-10-22 18:49:11 -03:00
Rafael Moraes de8e1f3215 Add retries and timeout to HTTPX requests 2025-10-22 18:49:04 -03:00
Rafael Moraes e095d84013 Make audio playlist selection async in AppleMusic interface 2025-10-22 18:43:42 -03:00
Rafael Moraes c18fa0c8af Fix webplayback response handling in AppleMusicMusicVideoInterface 2025-10-22 18:40:31 -03:00
Rafael Moraes 4dfa9ec376 Refactor cover URL generation in AppleMusicBaseDownloader 2025-10-22 18:27:34 -03:00
Rafael Moraes c57277d891 Fix video file extension from .m4a to .mp4 2025-10-22 18:20:04 -03:00
Rafael Moraes 035db73da2 Add artist download support to AppleMusicDownloader 2025-10-22 18:16:44 -03:00
Rafael Moraes 73eb0f8dad Set playlist_file_path in AppleMusicMusicVideoDownloader 2025-10-22 17:49:18 -03:00
Rafael Moraes 2e6b3dc6c1 Refactor template options and add playlist file support 2025-10-22 17:48:36 -03:00
Rafael Moraes e104ee72a6 Remove disable-music-video-skip CLI option 2025-10-21 20:02:32 -03:00
Rafael Moraes 6fcb29a8ee Fix Apple Music track data extension and error check 2025-10-21 19:48:06 -03:00
Rafael Moraes de719ac55b Add initial CLI implementation for gamdl 2025-10-21 19:47:58 -03:00
Rafael Moraes 523e29b39c Replace custom file exists error with FileExistsError 2025-10-21 18:23:04 -03:00
Rafael Moraes eed9344e22 Add Apple Music URL parsing and download queue support 2025-10-21 18:22:01 -03:00
Rafael Moraes 70b6e5638f Refactor album download method to support collections 2025-10-21 18:03:15 -03:00
Rafael Moraes 55c2584b9c Set default value for extend parameter in extend_api_data 2025-10-21 18:03:05 -03:00
Rafael Moraes b914df9f26 Rename song_codec to codec in AppleMusicSongDownloader 2025-10-21 17:34:19 -03:00
Rafael Moraes 37e77c4ca2 Rename skip_synced_lyrics to no_synced_lyrics 2025-10-21 17:28:34 -03:00
Rafael Moraes 51cf22fe87 Refactor media type checks to use constants 2025-10-21 16:51:23 -03:00
Rafael Moraes b3b61884b6 Add support for Apple Music uploaded video downloads 2025-10-21 16:08:32 -03:00
Rafael Moraes ee4919b7c2 Move cover_url_template assignment after output path 2025-10-21 16:00:17 -03:00
Rafael Moraes 81d2953cbd Add music video download support 2025-10-21 15:44:09 -03:00
Rafael Moraes f1343b3113 Add MusicVideoResolution enum and update usage 2025-10-21 15:23:29 -03:00
Rafael Moraes 54f13e2ea2 Add music video codec enums and FOURCC mapping 2025-10-21 15:19:33 -03:00
Rafael Moraes f98156401c Add Apple Music music video interface 2025-10-21 15:19:21 -03:00
Rafael Moraes 2742ffb38c Update AppleMusicBaseDownloader interface setup 2025-10-21 15:02:04 -03:00
Rafael Moraes c0ca601ef2 Remove async from setup methods in ItunesApi 2025-10-21 15:01:26 -03:00
Rafael Moraes 8268447357 Add retry logic to safe_gather utility 2025-10-21 14:51:08 -03:00
Rafael Moraes c9a5ff4a0e Handle exceptions in album download items 2025-10-21 14:39:39 -03:00
Rafael Moraes dcf84ade87 Update safe_gather concurrency limit and error handling 2025-10-21 14:36:15 -03:00
Rafael Moraes 8ec8f65f07 Fix Apple Music API usage in song downloader 2025-10-21 14:20:59 -03:00
Rafael Moraes c95330cc5f Refactor AppleMusicBaseDownloader to use ItunesApi 2025-10-21 14:20:53 -03:00
Rafael Moraes ea102b9610 Add ItunesApi to AppleMusicInterface constructor 2025-10-21 13:02:48 -03:00
Rafael Moraes 2f38eedfa4 Respect skip_processing flag in final processing 2025-10-21 12:54:00 -03:00
Rafael Moraes 6a084096b2 Bump version to 2.7 in __init__.py 2025-10-21 12:51:51 -03:00
Rafael Moraes 8da20973fd Add async_subprocess and safe_gather utility functions 2025-10-21 12:51:45 -03:00
Rafael Moraes 19dcb95705 Add Apple Music interface module 2025-10-21 12:51:39 -03:00
Rafael Moraes c51dbf0e8b Add Apple Music downloader core modules 2025-10-21 12:51:30 -03:00
Rafael Moraes 4841e0f356 Add hardcoded Widevine device key dump 2025-10-19 17:45:35 -03:00
Rafael Moraes 77471c2e9c Add async function to fetch response text 2025-10-19 17:45:17 -03:00
Rafael Moraes 0b440fd850 Remove gamdl core modules and CLI implementation 2025-10-19 17:44:56 -03:00
Rafael Moraes ffe261388a Reorder imports in __init__.py for consistency 2025-10-19 16:47:28 -03:00
Rafael Moraes 2935e873f9 Refactor utils to use httpx and simplify functions 2025-10-19 11:36:27 -03:00
Rafael Moraes 5c8e47fc76 Refactor API modules and migrate to async httpx 2025-10-18 17:10:10 -03:00
Rafael Moraes 97703f6512 Merge pull request #232 from glomatico/glomatico-patch-1
Update __init__.py
2025-10-04 17:11:11 -03:00
Rafael Moraes f087b70bee Update __init__.py 2025-10-04 17:10:55 -03:00
Rafael Moraes 5052f7a71c Update regex for index-legacy JS asset detection 2025-10-03 18:13:13 -03:00
Rafael Moraes 48e172a40e Bump version to 2.6.4 2025-09-23 16:34:47 -03:00
Rafael Moraes fb515dc70b Merge pull request #225 from mikepmiller/playlist_parsing_2
Parse variable-length playlist IDs.
2025-09-23 16:33:30 -03:00
mikepmiller 6a2d0d4f39 Parse variable-length playlist IDs. 2025-09-16 07:57:17 -04:00
Rafael Moraes aa5171a820 Bump version to 2.6.3 2025-09-14 12:47:20 -03:00
Rafael Moraes 82df24b21b Fix log formatting in decryption debug message 2025-09-14 12:46:09 -03:00
Rafael Moraes 4752faa555 Check database existence before adding media entry 2025-09-14 12:45:55 -03:00
Rafael Moraes e8e8373b16 Refactor database method in final processing step 2025-09-14 12:44:37 -03:00
Rafael Moraes 3b8954d90d Rename write_media to add_media in Database class 2025-09-14 12:42:10 -03:00
Rafael Moraes e134814fea Update README example to iterate download results 2025-09-14 12:34:08 -03:00
Rafael Moraes 5b884743d8 Refactor download methods to use generators 2025-09-14 12:34:01 -03:00
Rafael Moraes 268d9a71fc Fix uninitialized variable and return type in downloader 2025-09-14 12:33:49 -03:00
Rafael Moraes e36a33be02 Refactor final processing logic in Downloader 2025-09-14 12:17:40 -03:00
Rafael Moraes 287df2caea Quote file path in tag application log message 2025-09-14 12:08:45 -03:00
Rafael Moraes 840987b28e Refactor final processing and database path logic 2025-09-14 11:59:23 -03:00
Rafael Moraes abf8c4c795 Merge pull request #220 from mikepmiller/playlist_parsing
Playlist Parsing
2025-09-14 11:10:36 -03:00
Rafael Moraes e2a96b31db Add media download database support 2025-09-14 11:00:16 -03:00
mikepmiller 448de3a0c0 Fix expected num characters 2025-09-04 10:10:31 -04:00
Rafael Moraes e1f027dcb1 Bump version to 2.6.2 2025-08-31 14:09:15 -03:00
Rafael Moraes ba4e9576bc Improve error handling for missing media in downloader 2025-08-31 14:08:04 -03:00
Rafael Moraes 8c7ad61811 Fix URL parsing for encoded characters in downloader 2025-08-31 14:03:37 -03:00
Rafael Moraes e3d2cfa357 Raise exception if media file already exists 2025-08-31 13:56:53 -03:00
Rafael Moraes 3680afa017 Pass file path to MediaFileAlreadyExistsException 2025-08-31 13:56:06 -03:00
Rafael Moraes 9f93b0e791 Refactor exception classes for clarity and consistency 2025-08-31 13:55:59 -03:00
Rafael Moraes ce2bdc8d61 Refactor temp path handling in Downloader class 2025-08-31 13:49:05 -03:00
Rafael Moraes 30e498aeeb Fix type for MP4 date tag in MediaTags 2025-08-31 13:47:57 -03:00
Rafael Moraes 4d150c35a8 Bump version to 2.6.1 2025-08-31 12:12:28 -03:00
Rafael Moraes be8eeb80c9 Improve error message for failed URL processing 2025-08-31 12:12:14 -03:00
Rafael Moraes b17c31d416 Add override to cleanup_temp_path skip check 2025-08-31 12:10:32 -03:00
Rafael Moraes 42d10d555a Refactor error handling with custom media exceptions 2025-08-31 10:48:55 -03:00
Rafael Moraes 38d131a699 Remove redundant stremeable 2025-08-31 10:35:41 -03:00
Rafael Moraes 322cb7714e Fix playlist saving condition in downloader 2025-08-31 10:15:46 -03:00
Rafael Moraes 6383dd78c4 Handle non-str, non-datetime dates in MediaTags 2025-08-31 10:11:58 -03:00
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
53 changed files with 7809 additions and 3075 deletions
-38
View File
@@ -1,38 +0,0 @@
name: publish
# Controls when the workflow will run
on:
# Workflow will run when a release has been published for the package
release:
types:
- published
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "publish"
publish:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v3
- name: Set up Python 3.9
uses: actions/setup-python@v3
with:
python-version: 3.9
cache: pip
- name: To PyPI using Flit
uses: AsifArmanRahman/to-pypi-using-flit@v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
+70
View File
@@ -0,0 +1,70 @@
# This workflow will upload a Python Package to PyPI when a release is created
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
name: Upload Python Package
on:
release:
types: [published]
permissions:
contents: read
jobs:
release-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Build release distributions
run: |
# NOTE: put your own distribution build steps here.
python -m pip install build
python -m build
- name: Upload distributions
uses: actions/upload-artifact@v4
with:
name: release-dists
path: dist/
pypi-publish:
runs-on: ubuntu-latest
needs:
- release-build
permissions:
# IMPORTANT: this permission is mandatory for trusted publishing
id-token: write
# Dedicated environments with protections for publishing are strongly recommended.
# For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules
environment:
name: pypi
# OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status:
# url: https://pypi.org/p/YOURPROJECT
#
# ALTERNATIVE: if your GitHub Release name is the PyPI project version string
# ALTERNATIVE: exactly, uncomment the following line instead:
# url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }}
steps:
- name: Retrieve release distributions
uses: actions/download-artifact@v4
with:
name: release-dists
path: dist/
- name: Publish release distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist/
+2 -1
View File
@@ -2,6 +2,7 @@
__pycache__
!gamdl
!.gitignore
!.python-version
!pyproject.toml
!README.md
!requirements.txt
!uv.lock
+1
View File
@@ -0,0 +1 @@
3.10
+328 -174
View File
@@ -1,197 +1,351 @@
# Glomaticos Apple Music Downloader
A Python CLI app for downloading Apple Music songs, music videos and post videos.
# Gamdl (Glomatico's Apple Music Downloader)
**Join our Discord Server:** https://discord.gg/aBjMEZ9tnq
[![PyPI version](https://img.shields.io/pypi/v/gamdl?color=blue)](https://pypi.org/project/gamdl/)
[![Python versions](https://img.shields.io/pypi/pyversions/gamdl)](https://pypi.org/project/gamdl/)
[![License](https://img.shields.io/github/license/glomatico/gamdl)](https://github.com/glomatico/gamdl/blob/main/LICENSE)
[![Downloads](https://img.shields.io/pypi/dm/gamdl)](https://pypi.org/project/gamdl/)
## Features
* **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.
A command-line app for downloading Apple Music songs, music videos and post videos.
## Prerequisites
* **Python 3.9 or higher** installed on your system.
* The **cookies file** of your Apple Music browser session in Netscape format (requires an active subscription).
* **Firefox**: Use the [Export Cookies](https://addons.mozilla.org/addon/export-cookies-txt) extension.
* **Chromium-based Browsers**: Use the [Open Cookies.txt](https://chromewebstore.google.com/detail/open-cookiestxt/gdocmgbfkjnnpapoeobnolbbkoibbcif) extension.
* **FFmpeg** on your system PATH.
* **Windows**: Download from [AnimMouses FFmpeg Builds](https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases).
* **Linux**: Download from [John Van Sickles FFmpeg Builds](https://johnvansickle.com/ffmpeg/).
**Join our Discord Server:** <https://discord.gg/aBjMEZ9tnq>
### Optional dependencies
The following tools are optional but required for specific features. Add them to your systems PATH or specify their paths using command-line arguments or the config file.
* [mp4decrypt](https://www.bento4.com/downloads/): Required for `mp4box` remux mode, music video downloads, and experimental song codecs.
* [MP4Box](https://gpac.io/downloads/gpac-nightly-builds/): Required for `mp4box` remux mode.
* [N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest): Required for `nm3u8dlre` download mode.
## Installation
1. Install the package `gamdl` using pip
```bash
pip install gamdl
```
2. Set up the cookies file.
* Move the cookies file to the directory where youll run Gamdl and rename it to `cookies.txt`.
* Alternatively, specify the path to the cookies file using command-line arguments or the config file.
## ✨ Features
- 🎵 **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
- 🏷️ **Rich Metadata** - Automatic tagging with comprehensive metadata
- 🎤 **Artist Support** - Download all albums or music videos from an artist
- ⚙️ **Highly Customizable** - Extensive configuration options for advanced users
## 📋 Prerequisites
### Required
- **Python 3.10 or higher**
- **Apple Music Cookies** - Export your browser cookies in Netscape format while logged in with an active subscription at the Apple Music website:
- **Firefox**: [Export Cookies](https://addons.mozilla.org/addon/export-cookies-txt)
- **Chromium**: [Get cookies.txt LOCALLY](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
### Optional
Add these tools to your system PATH for additional features:
- **[FFmpeg](https://ffmpeg.org/download.html)** - Required for `ffmpeg` music video remux mode
- **[mp4decrypt](https://www.bento4.com/downloads/)** - Required for `mp4box` music video remux mode
- **[MP4Box](https://gpac.io/downloads/gpac-nightly-builds/)** - Required for `mp4box` music video remux mode
- **[N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest)** - Required for `nm3u8dlre` download mode, which is faster than the default downloader
- **[Wrapper](#-wrapper)** - For downloading songs in ALAC and other experimental codecs without API limitations
## 📦 Installation
**Install Gamdl via pip:**
```bash
pip install gamdl
```
**Setup cookies:**
1. Place your cookies file in the working directory as `cookies.txt`, or
2. Specify the path using `--cookies-path` or in the config file
## 🚀 Usage
## Usage
Run Gamdl with the following command:
```bash
gamdl [OPTIONS] URLS...
```
### Supported URL types
* Song
* Album
* Playlist
* Music video
* Artist
* Post video
### Supported URL Types
- Songs
- Albums (Public/Library)
- Playlists (Public/Library)
- Music Videos
- Artists
- Post Videos
### Examples
* Download a Song:
```bash
gamdl "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
```
* Download an Album:
```bash
gamdl "https://music.apple.com/us/album/whenever-you-need-somebody-2022-remaster/1624945511"
```
* Download from an Artist:
```bash
gamdl "https://music.apple.com/us/artist/rick-astley/669771"
```
### Interactive prompt controls
* **Arrow keys**: Move selection
* **Space**: Toggle selection
* **Ctrl + A**: Select all
* **Enter**: Confirm selection
**Download a song:**
## Configuration
Gamdl can be configured by using the command-line arguments or the config file.
```bash
gamdl "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
```
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.
**Download an album:**
Config file values can be overridden using command-line arguments.
| Command-line argument / Config file key | Description | Default value |
| --------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------- |
| `--disable-music-video-skip` / `disable_music_video_skip` | Don't skip downloading music videos in albums/playlists. | `false` |
| `--save-cover`, `-s` / `save_cover` | Save cover as a separate file. | `false` |
| `--overwrite` / `overwrite` | Overwrite existing files. | `false` |
| `--read-urls-as-txt`, `-r` / - | Interpret URLs as paths to text files containing URLs separated by newlines. | `false` |
| `--save-playlist` / `save_playlist` | Save a M3U8 playlist file when downloading a playlist. | `false` |
| `--synced-lyrics-only` / `synced_lyrics_only` | Download only the synced lyrics. | `false` |
| `--no-synced-lyrics` / `no_synced_lyrics` | Don't download the synced lyrics. | `false` |
| `--config-path` / - | Path to config file. | `<home>/.gamdl/config.json` |
| `--log-level` / `log_level` | Log level. | `INFO` |
| `--no-exceptions` / `no_exceptions` | Don't print exceptions. | `false` |
| `--cookies-path`, `-c` / `cookies_path` | Path to .txt cookies file. | `./cookies.txt` |
| `--language`, `-l` / `language` | Metadata language as an ISO-2A language code (don't always work for videos). | `en-US` |
| `--output-path`, `-o` / `output_path` | Path to output directory. | `./Apple Music` |
| `--temp-path` / `temp_path` | Path to temporary directory. | `./temp` |
| `--wvd-path` / `wvd_path` | Path to .wvd file. | `null` |
| `--nm3u8dlre-path` / `nm3u8dlre_path` | Path to N_m3u8DL-RE binary. | `N_m3u8DL-RE` |
| `--mp4decrypt-path` / `mp4decrypt_path` | Path to mp4decrypt binary. | `mp4decrypt` |
| `--ffmpeg-path` / `ffmpeg_path` | Path to FFmpeg binary. | `ffmpeg` |
| `--mp4box-path` / `mp4box_path` | Path to MP4Box binary. | `MP4Box` |
| `--download-mode` / `download_mode` | Download mode. | `ytdlp` |
| `--remux-mode` / `remux_mode` | Remux mode. | `ffmpeg` |
| `--cover-format` / `cover_format` | Cover format. | `jpg` |
| `--template-folder-album` / `template_folder_album` | Template folder for tracks that are part of an album. | `{album_artist}/{album}` |
| `--template-folder-compilation` / `template_folder_compilation` | Template folder for tracks that are part of a compilation album. | `Compilations/{album}` |
| `--template-file-single-disc` / `template_file_single_disc` | Template file for the tracks that are part of a single-disc album. | `{track:02d} {title}` |
| `--template-file-multi-disc` / `template_file_multi_disc` | Template file for the tracks that are part of a multi-disc album. | `{disc}-{track:02d} {title}` |
| `--template-folder-no-album` / `template_folder_no_album` | Template folder for the tracks that are not part of an album. | `{artist}/Unknown Album` |
| `--template-file-no-album` / `template_file_no_album` | Template file for the tracks that are not part of an album. | `{title}` |
| `--template-file-playlist` / `template_file_playlist` | Template file for the M3U8 playlist. | `Playlists/{playlist_title}` |
| `--template-date` / `template_date` | Date tag template. | `%Y-%m-%dT%H:%M:%SZ` |
| `--exclude-tags` / `exclude_tags` | Comma-separated tags to exclude. | `null` |
| `--cover-size` / `cover_size` | Cover size. | `1200` |
| `--truncate` / `truncate` | Maximum length of the file/folder names. | `null` |
| `--codec-song` / `codec_song` | Song codec. | `aac-legacy` |
| `--synced-lyrics-format` / `synced_lyrics_format` | Synced lyrics format. | `lrc` |
| `--codec-music-video` / `codec_music_video` | Music video codec. | `h264` |
| `--quality-post` / `quality_post` | Post video quality. | `best` |
| `--no-config-file`, `-n` / - | Do not use a config file. | `false` |
```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:**
| Key | Action |
| -------------- | ----------------- |
| **Arrow keys** | Move selection |
| **Space** | Toggle selection |
| **Ctrl + A** | Select all |
| **Enter** | Confirm selection |
## ⚙️ Configuration
Configure Gamdl using command-line arguments or a config file.
**Config file location:**
- Linux: `~/.gamdl/config.ini`
- Windows: `%USERPROFILE%\.gamdl\config.ini`
The file is created automatically on first run. Command-line arguments override config values.
### Configuration Options
| Option | Description | Default |
| ------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------- |
| **General Options** | | |
| `--read-urls-as-txt`, `-r` | Read URLs from text files | `false` |
| `--config-path` | Config file path | `<home>/.gamdl/config.ini` |
| `--log-level` | Logging level | `INFO` |
| `--log-file` | Log file path | - |
| `--no-exceptions` | Don't print exceptions | `false` |
| `--no-config-file`, `-n` | Don't use a config file | `false` |
| **Apple Music Options** | | |
| `--cookies-path`, `-c` | Cookies file path | `./cookies.txt` |
| `--wrapper-account-url` | Wrapper account URL | `http://127.0.0.1:30020` |
| `--language`, `-l` | Metadata language | `en-US` |
| **Output Options** | | |
| `--output-path`, `-o` | Output directory path | `./Apple Music` |
| `--temp-path` | Temporary directory path | `.` |
| `--wvd-path` | .wvd file path | - |
| `--overwrite` | Overwrite existing files | `false` |
| `--save-cover`, `-s` | Save cover as separate file | `false` |
| `--save-playlist` | Save M3U8 playlist file | `false` |
| **Download Options** | | |
| `--nm3u8dlre-path` | N_m3u8DL-RE executable path | `N_m3u8DL-RE` |
| `--mp4decrypt-path` | mp4decrypt executable path | `mp4decrypt` |
| `--ffmpeg-path` | FFmpeg executable path | `ffmpeg` |
| `--mp4box-path` | MP4Box executable path | `MP4Box` |
| `--use-wrapper` | Use wrapper | `false` |
| `--wrapper-decrypt-ip` | Wrapper decryption server IP | `127.0.0.1:10020` |
| `--download-mode` | Download mode | `ytdlp` |
| `--cover-format` | Cover format | `jpg` |
| **Template Options** | | |
| `--album-folder-template` | Album folder template | `{album_artist}/{album}` |
| `--compilation-folder-template` | Compilation folder template | `Compilations/{album}` |
| `--no-album-folder-template` | No album folder template | `{artist}/Unknown Album` |
| `--single-disc-file-template` | Single disc file template | `{track:02d} {title}` |
| `--multi-disc-file-template` | Multi disc file template | `{disc}-{track:02d} {title}` |
| `--no-album-file-template` | No album file template | `{title}` |
| `--playlist-file-template` | Playlist file template | `Playlists/{playlist_artist}/{playlist_title}` |
| `--date-tag-template` | Date tag template | `%Y-%m-%dT%H:%M:%SZ` |
| `--exclude-tags` | Comma-separated tags to exclude | - |
| `--cover-size` | Cover size in pixels | `1200` |
| `--truncate` | Max filename length | - |
| **Song Options** | | |
| `--song-codec` | Song codec | `aac-legacy` |
| `--synced-lyrics-format` | Synced lyrics format | `lrc` |
| `--no-synced-lyrics` | Don't download synced lyrics | `false` |
| `--synced-lyrics-only` | Download only synced lyrics | `false` |
| `--use-album-date` | Use album release date for songs | `false` |
| `--fetch-extra-tags` | Fetch extra tags from preview (normalization and smooth playback) | `false` |
| **Music Video Options** | | |
| `--music-video-codec-priority` | Comma-separated codec priority | `h264,h265` |
| `--music-video-remux-mode` | Remux mode | `ffmpeg` |
| `--music-video-remux-format` | Music video remux format | `m4v` |
| `--music-video-resolution` | Max music video resolution | `1080p` |
| **Post Video Options** | | |
| `--uploaded-video-quality` | Post video quality | `best` |
### 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`
### Template Variables
### Remux Modes
* `ffmpeg`: Default remuxing mode.
* `mp4box`: Alternative remuxing mode (doesnt convert closed captions in music videos).
**Tags for templates and exclude-tags:**
### Download modes
* `ytdlp`: Default download mode.
* `nm3u8dlre`: Faster than `ytdlp`.
- `album`, `album_artist`, `album_id`
- `artist`, `artist_id`
- `composer`, `composer_id`
- `date` (supports strftime format: `{date:%Y}`)
- `disc`, `disc_total`
- `media_type`
- `playlist_artist`, `playlist_id`, `playlist_title`, `playlist_track`
- `title`, `title_id`
- `track`, `track_total`
**Tags for exclude-tags only:**
- `album_sort`, `artist_sort`, `composer_sort`, `title_sort`
- `comment`, `compilation`, `copyright`, `cover`, `gapless`, `genre`, `genre_id`, `lyrics`, `rating`, `storefront`, `xid`
- `all` (special: skip all tagging)
### Logging Level
- `DEBUG`, `INFO`, `WARNING`, `ERROR`
### Download Mode
- `ytdlp`, `nm3u8dlre`
### Remux Mode
- `ffmpeg`
- `mp4box` - Preserve the original closed caption track in music videos and some other minor metadata
### Cover Format
- `jpg`
- `png`
- `raw` - Raw format as provided by the artist (requires `save_cover` to be enabled as it doesn't embed covers into files)
### Metadata Language
Use ISO 639-1 language codes (e.g., `en-US`, `es-ES`, `ja-JP`, `pt-BR`). Don't always work for music videos.
### Song Codecs
* Supported Codecs:
* `aac-legacy`: AAC 256kbps 44.1kHz.
* `aac-he-legacy`: AAC-HE 64kbps 44.1kHz.
* Experimental Codecs (not guaranteed to work due to API limitations):
* `aac`: AAC 256kbps up to 48kHz.
* `aac-he`: AAC-HE 64kbps up to 48kHz.
* `aac-binaural`: AAC 256kbps binaural.
* `aac-downmix`: AAC 256kbps downmix.
* `aac-he-binaural`: AAC-HE 64kbps binaural.
* `aac-he-downmix`: AAC-HE 64kbps downmix.
* `atmos`: Dolby Atmos 768kbps.
* `ac3`: AC3 640kbps.
* `alac`: ALAC up to 24-bit/192 kHz.
* `ask`: Prompt to choose available audio codec.
### Music Videos Codecs
* `h264`: Up to 1080p with AAC 256kbps.
* `h265`: Up to 2160p with AAC 256kpbs.
* `ask`: Prompt to choose available video and audio codecs.
### Post videos/extra videos qualities
* `best`: Up to 1080p with AAC 256kbps.
* `ask`: Prompt to choose available video quality.
**Stable:**
### Synced lyrics formats
* `lrc`: Lightweight and widely supported.
* `srt`: SubRip format (has more accurate timestamps).
* `ttml`: Native Apple Music format (unsupported by most media players).
### Cover formats
* `jpg`: Default format.
* `png`: Lossless format.
* `raw`: Raw cover without processing (requires `save_cover` to save separately).
- `aac-legacy` - AAC 256kbps 44.1kHz
- `aac-he-legacy` - AAC-HE 64kbps 44.1kHz
**Experimental** (may not 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/192kHz (unsupported)
- `ask` - Interactive experimental codec selection
### Synced Lyrics Format
- `lrc`
- `srt` - SubRip subtitle format (more accurate timing)
- `ttml` - Native Apple Music format (not compatible with most media players)
### Music Video Codecs
- `h264`
- `h265`
- `ask` - Interactive codec selection
### Music Video Resolutions
- H.264: `240p`, `360p`, `480p`, `540p`, `720p`, `1080p`
- H.265 only: `1440p`, `2160p`
### Music Video Remux Formats
- `m4v`, `mp4`
### Post Video Quality
- `best` - Up to 1080p with AAC 256kbps
- `ask` - Interactive quality selection
## ⚙️ Wrapper
Use the [wrapper](https://github.com/WorldObservationLog/wrapper) to download songs in ALAC and other experimental codecs without API limitations. Cookies are not required when using the wrapper.
### Setup Instructions
1. **Start the wrapper server** - Run the wrapper server
2. **Enable wrapper in Gamdl** - Use `--use-wrapper` flag or set `use_wrapper = true` in config
3. **Run Gamdl** - Download as usual with the wrapper enabled
## 🐍 Embedding
Use Gamdl as a library in your Python projects:
```python
import asyncio
from gamdl.api import AppleMusicApi, ItunesApi
from gamdl.downloader import (
AppleMusicBaseDownloader,
AppleMusicDownloader,
AppleMusicMusicVideoDownloader,
AppleMusicSongDownloader,
AppleMusicUploadedVideoDownloader,
)
from gamdl.interface import (
AppleMusicInterface,
AppleMusicMusicVideoInterface,
AppleMusicSongInterface,
AppleMusicUploadedVideoInterface,
)
async def main():
# Create AppleMusicApi instance (from cookies or wrapper)
apple_music_api = await AppleMusicApi.create_from_netscape_cookies(
cookies_path="cookies.txt",
)
itunes_api = ItunesApi(
apple_music_api.storefront,
apple_music_api.language,
)
# Check subscription
assert apple_music_api.active_subscription
# Set up interfaces
interface = AppleMusicInterface(apple_music_api, itunes_api)
song_interface = AppleMusicSongInterface(interface)
music_video_interface = AppleMusicMusicVideoInterface(interface)
uploaded_video_interface = AppleMusicUploadedVideoInterface(interface)
# Set up base downloader and specialized downloaders
base_downloader = AppleMusicBaseDownloader()
song_downloader = AppleMusicSongDownloader(
base_downloader=base_downloader,
interface=song_interface,
)
music_video_downloader = AppleMusicMusicVideoDownloader(
base_downloader=base_downloader,
interface=music_video_interface,
)
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(
base_downloader=base_downloader,
interface=uploaded_video_interface,
)
# Main downloader
downloader = AppleMusicDownloader(
interface=interface,
base_downloader=base_downloader,
song_downloader=song_downloader,
music_video_downloader=music_video_downloader,
uploaded_video_downloader=uploaded_video_downloader,
)
# Download a song
url = "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
url_info = downloader.get_url_info(url)
if url_info:
download_queue = await downloader.get_download_queue(url_info)
if download_queue:
for download_item in download_queue:
await downloader.download(download_item)
if __name__ == "__main__":
asyncio.run(main())
```
## 📄 License
MIT License - see [LICENSE](LICENSE) file for details
## 🤝 Contributing
Currently, I'm not interested in reviewing pull requests that change or add features. Only critical bug fixes will be considered. However, feel free to open issues for bugs or feature requests.
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.4.2"
__version__ = "2.9"
+1 -1
View File
@@ -1,3 +1,3 @@
from .cli import main
from .cli.cli import main
main()
+2
View File
@@ -0,0 +1,2 @@
from .apple_music_api import AppleMusicApi
from .itunes_api import ItunesApi
+467
View File
@@ -0,0 +1,467 @@
import logging
import re
import typing
from http.cookiejar import MozillaCookieJar
from urllib.parse import parse_qs, urlparse
import httpx
from ..utils import get_response, raise_for_status, safe_json
from .constants import (
AMP_API_URL,
APPLE_MUSIC_COOKIE_DOMAIN,
APPLE_MUSIC_HOMEPAGE_URL,
LICENSE_API_URL,
WEBPLAYBACK_API_URL,
)
from .exceptions import ApiError
logger = logging.getLogger(__name__)
class AppleMusicApi:
def __init__(
self,
storefront: str = "us",
language: str = "en-US",
media_user_token: str | None = None,
developer_token: str | None = None,
) -> None:
self.storefront = storefront
self.language = language
self.media_user_token = media_user_token
self.token = developer_token
@classmethod
async def create_from_netscape_cookies(
cls,
cookies_path: str = "./cookies.txt",
*args,
**kwargs,
) -> "AppleMusicApi":
cookies = MozillaCookieJar(cookies_path)
cookies.load(ignore_discard=True, ignore_expires=True)
parse_cookie = lambda name: next(
(
cookie.value
for cookie in cookies
if cookie.name == name and cookie.domain == APPLE_MUSIC_COOKIE_DOMAIN
),
None,
)
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 the Apple Music webpage "
"and are logged in with an active subscription."
)
return await cls.create(
storefront=None,
media_user_token=media_user_token,
developer_token=None,
*args,
**kwargs,
)
@classmethod
async def create_from_wrapper(
cls,
wrapper_account_url: str = "http://127.0.0.1:30020/",
*args,
**kwargs,
) -> "AppleMusicApi":
wrapper_account_response = await get_response(wrapper_account_url)
wrapper_account_info = safe_json(wrapper_account_response)
return await cls.create(
storefront=None,
media_user_token=wrapper_account_info["music_token"],
developer_token=wrapper_account_info["dev_token"],
*args,
**kwargs,
)
@classmethod
async def create(
cls,
storefront: str | None = "us",
language: str = "en-US",
media_user_token: str | None = None,
developer_token: str | None = None,
) -> "AppleMusicApi":
api = cls(
storefront=storefront,
language=language,
media_user_token=media_user_token,
developer_token=developer_token,
)
await api.initialize()
return api
async def initialize(self) -> None:
await self._initialize_client()
await self._initialize_token()
await self._initialize_account_info()
async def _initialize_client(self) -> None:
self.client = httpx.AsyncClient(
headers={
"accept": "*/*",
"accept-language": "en-US",
"origin": APPLE_MUSIC_HOMEPAGE_URL,
"priority": "u=1, i",
"referer": 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",
},
params={
"l": self.language,
},
follow_redirects=True,
timeout=60.0,
)
async def _get_token(self) -> str:
response = await self.client.get(APPLE_MUSIC_HOMEPAGE_URL)
home_page = response.text
index_js_uri_match = re.search(
r"/(assets/index-legacy[~-][^/\"]+\.js)",
home_page,
)
if not index_js_uri_match:
raise Exception("index.js URI not found in Apple Music homepage")
index_js_uri = index_js_uri_match.group(1)
response = await self.client.get(f"{APPLE_MUSIC_HOMEPAGE_URL}/{index_js_uri}")
index_js_page = response.text
token_match = re.search('(?=eyJh)(.*?)(?=")', index_js_page)
if not token_match:
raise Exception("Token not found in index.js page")
token = token_match.group(1)
logger.debug(f"Token: {token}")
return token
async def _initialize_token(self) -> None:
self.token = self.token or await self._get_token()
self.client.headers.update({"authorization": f"Bearer {self.token}"})
async def _initialize_account_info(self) -> None:
if not self.media_user_token:
return
self.client.cookies.update(
{
"media-user-token": self.media_user_token,
}
)
self.account_info = await self.get_account_info()
self.storefront = self.account_info["meta"]["subscription"]["storefront"]
@property
def active_subscription(self) -> bool:
return (
getattr(self, "account_info", {})
.get("meta", {})
.get("subscription", {})
.get("active", False)
)
@property
def account_restrictions(self) -> dict | None:
data = getattr(self, "account_info", {}).get("data", [])
if not data:
return None
return data[0].get("attributes", {}).get("restrictions")
async def get_account_info(self, meta: str = "subscription") -> dict:
account_info = await self._amp_request(
f"/v1/me/account",
{
"meta": meta,
},
)
logger.debug(f"Account info: {account_info}")
return account_info
async def _amp_request(
self,
endpoint: str,
params: dict | None = None,
) -> dict:
response = await self.client.get(
AMP_API_URL + endpoint,
params=params or {},
)
response_json = safe_json(response)
if (
response.status_code != 200
or response_json is None
or "errors" in response_json
):
raise ApiError(
message=response.text,
status_code=response.status_code,
)
return response_json
async def get_song(
self,
song_id: str,
extend: str = "extendedAssetUrls",
include: str = "lyrics,albums",
) -> dict | None:
song = await self._amp_request(
f"/v1/catalog/{self.storefront}/songs/{song_id}",
{
"extend": extend,
"include": include,
},
)
logger.debug(f"Song: {song}")
return song
async def get_music_video(
self,
music_video_id: str,
include: str = "albums",
) -> dict | None:
music_video = await self._amp_request(
f"/v1/catalog/{self.storefront}/music-videos/{music_video_id}",
{
"include": include,
},
)
logger.debug(f"Music video: {music_video}")
return music_video
async def get_uploaded_video(
self,
post_id: str,
) -> dict | None:
uploaded_video = await self._amp_request(
f"/v1/catalog/{self.storefront}/uploaded-videos/{post_id}",
)
logger.debug(f"Uploaded video: {uploaded_video}")
return uploaded_video
async def get_album(
self,
album_id: str,
extend: str = "extendedAssetUrls",
) -> dict | None:
album = await self._amp_request(
f"/v1/catalog/{self.storefront}/albums/{album_id}",
{
"extend": extend,
},
)
logger.debug(f"Album: {album}")
return album
async def get_playlist(
self,
playlist_id: str,
limit_tracks: int = 300,
extend: str = "extendedAssetUrls",
) -> dict | None:
playlist = await self._amp_request(
f"/v1/catalog/{self.storefront}/playlists/{playlist_id}",
{
"limit[tracks]": limit_tracks,
"extend": extend,
},
)
logger.debug(f"Playlist: {playlist}")
return playlist
async def get_artist(
self,
artist_id: str,
include: str = "albums,music-videos",
views: str = "full-albums,compilation-albums,live-albums,singles,top-songs",
limit: int = 100,
) -> dict | None:
artist = await self._amp_request(
f"/v1/catalog/{self.storefront}/artists/{artist_id}",
{
"include": include,
"views": views,
**{
f"limit[{_include}]": limit
for _include in [*include.split(","), *views.split(",")]
},
},
)
logger.debug(f"Artist: {artist}")
return artist
async def get_library_album(
self,
album_id: str,
extend: str = "extendedAssetUrls",
) -> dict | None:
album = await self._amp_request(
f"/v1/me/library/albums/{album_id}",
{
"extend": extend,
},
)
logger.debug(f"Library album: {album}")
return album
async def get_library_playlist(
self,
playlist_id: str,
include: str = "tracks",
limit: int = 100,
extend: str = "extendedAssetUrls",
) -> dict | None:
playlist = await self._amp_request(
f"/v1/me/library/playlists/{playlist_id}",
{
"include": include,
**{f"limit[{_include}]": limit for _include in include.split(",")},
"extend": extend,
},
)
logger.debug(f"Library playlist: {playlist}")
return playlist
async def get_search_results(
self,
term: str,
types: str = "songs,music-videos,albums,playlists,artists",
limit: int = 50,
offset: int = 0,
) -> dict:
search_results = await self._amp_request(
f"/v1/catalog/{self.storefront}/search",
{
"term": term,
"types": types,
"limit": limit,
"offset": offset,
},
)
logger.debug(f"Search results: {search_results}")
return search_results
async def extend_api_data(
self,
api_response: dict,
extend: str = "extendedAssetUrls",
) -> typing.AsyncGenerator[dict, None]:
next_uri = api_response.get("next")
if not next_uri:
return
next_uri_params = parse_qs(urlparse(next_uri).query)
limit = int(next_uri_params["offset"][0])
while next_uri:
extended_api_data = await self._get_extended_api_data(
next_uri,
limit,
extend,
)
yield extended_api_data
next_uri = extended_api_data.get("next")
async def _get_extended_api_data(
self,
next_uri: str,
limit: int,
extend: str,
) -> dict:
next_uri_params = parse_qs(urlparse(next_uri).query)
params = {
"limit": limit,
"offset": next_uri_params["offset"][0],
"extend": extend,
}
extended_api_data = await self._amp_request(next_uri, params)
logger.debug(f"Extended API data: {extended_api_data}")
return extended_api_data
async def get_webplayback(
self,
track_id: str,
) -> dict:
response = await self.client.post(
WEBPLAYBACK_API_URL,
json={
"salableAdamId": track_id,
"language": self.language,
},
)
webplayback = safe_json(response)
if (
response.status_code != 200
or webplayback is None
or "dialog" in webplayback
):
raise ApiError(
message=response.text,
status_code=response.status_code,
)
return webplayback
async def get_license_exchange(
self,
track_id: str,
track_uri: str,
challenge: str,
key_system: str = "com.widevine.alpha",
) -> dict:
response = await self.client.post(
LICENSE_API_URL,
json={
"challenge": challenge,
"key-system": key_system,
"uri": track_uri,
"adamId": track_id,
"isLibrary": False,
"user-initiated": True,
},
)
license_exchange = safe_json(response)
if (
response.status_code != 200
or license_exchange is None
or license_exchange.get("status") != 0
):
raise ApiError(
message=response.text,
status_code=response.status_code,
)
logger.debug(f"License exchange: {license_exchange}")
return license_exchange
+12 -73
View File
@@ -1,5 +1,15 @@
from gamdl.enums import MusicVideoCodec, SongCodec, SyncedLyricsFormat
APPLE_MUSIC_HOMEPAGE_URL = "https://music.apple.com"
APPLE_MUSIC_COOKIE_DOMAIN = ".music.apple.com"
AMP_API_URL = "https://amp-api.music.apple.com"
WEBPLAYBACK_API_URL = (
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback"
)
LICENSE_API_URL = (
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/acquireWebPlaybackLicense"
)
ITUNES_LOOKUP_API_URL = "https://itunes.apple.com/lookup"
ITUNES_PAGE_API_URL = "https://music.apple.com"
STOREFRONT_IDS = {
"AE": "143481-2,32",
"AG": "143540-2,32",
@@ -29,6 +39,7 @@ STOREFRONT_IDS = {
"CA": "143455-6,32",
"CG": "143582-2,32",
"CH": "143459-57,32",
"CM": "143574-2,32",
"CL": "143483-28,32",
"CN": "143465-19,32",
"CO": "143501-28,32",
@@ -157,75 +168,3 @@ STOREFRONT_IDS = {
"ZA": "143472-2,32",
"ZW": "143605-2,32",
}
MP4_TAGS_MAP = {
"album": "\xa9alb",
"album_artist": "aART",
"album_id": "plID",
"album_sort": "soal",
"artist": "\xa9ART",
"artist_id": "atID",
"artist_sort": "soar",
"comment": "\xa9cmt",
"composer": "\xa9wrt",
"composer_id": "cmID",
"composer_sort": "soco",
"copyright": "cprt",
"date": "\xa9day",
"genre": "\xa9gen",
"genre_id": "geID",
"lyrics": "\xa9lyr",
"media_type": "stik",
"rating": "rtng",
"storefront": "sfID",
"title": "\xa9nam",
"title_id": "cnID",
"title_sort": "sonm",
"xid": "xid ",
}
SONG_CODEC_REGEX_MAP = {
SongCodec.AAC: r"audio-stereo-\d+",
SongCodec.AAC_HE: r"audio-HE-stereo-\d+",
SongCodec.AAC_BINAURAL: r"audio-stereo-\d+-binaural",
SongCodec.AAC_DOWNMIX: r"audio-stereo-\d+-downmix",
SongCodec.AAC_HE_BINAURAL: r"audio-HE-stereo-\d+-binaural",
SongCodec.AAC_HE_DOWNMIX: r"audio-HE-stereo-\d+-downmix",
SongCodec.ATMOS: r"audio-atmos-.*",
SongCodec.AC3: r"audio-ac3-.*",
SongCodec.ALAC: r"audio-alac-.*",
}
MUSIC_VIDEO_CODEC_MAP = {
MusicVideoCodec.H264: "avc1",
MusicVideoCodec.H265: "hvc1",
}
SYNCED_LYRICS_FILE_EXTENSION_MAP = {
SyncedLyricsFormat.LRC: ".lrc",
SyncedLyricsFormat.SRT: ".srt",
SyncedLyricsFormat.TTML: ".ttml",
}
IMAGE_FILE_EXTENSION_MAP = {
"jpeg": ".jpg",
"tiff": ".tif",
}
EXCLUDED_CONFIG_FILE_PARAMS = (
"urls",
"config_path",
"read_urls_as_txt",
"no_config_file",
"version",
"help",
)
X_NOT_FOUND_STRING = '{} not found at "{}"'
LEGACY_CODECS = [
SongCodec.AAC_LEGACY,
SongCodec.AAC_HE_LEGACY,
]
+7
View File
@@ -0,0 +1,7 @@
from ..utils import GamdlError
class ApiError(GamdlError):
def __init__(self, message: str, status_code: int):
super().__init__(f"API Error {status_code}: {message}")
self.status_code = status_code
+86
View File
@@ -0,0 +1,86 @@
import logging
import httpx
from ..utils import safe_json
from .constants import ITUNES_LOOKUP_API_URL, ITUNES_PAGE_API_URL, STOREFRONT_IDS
from .exceptions import ApiError
logger = logging.getLogger(__name__)
class ItunesApi:
def __init__(
self,
storefront: str = "us",
language: str = "en-US",
) -> None:
self.storefront = storefront
self.language = language
self.initialize()
def initialize(self) -> None:
self._initialize_storefront_id()
self._initialize_client()
def _initialize_storefront_id(self) -> None:
try:
self.storefront_id = STOREFRONT_IDS[self.storefront.upper()]
except KeyError:
raise Exception(f"No storefront id for {self.storefront}")
def _initialize_client(self) -> None:
self.client = httpx.AsyncClient(
params={
"country": self.storefront,
"lang": self.language,
},
headers={
"X-Apple-Store-Front": f"{self.storefront_id} t:music31",
},
timeout=60.0,
)
async def get_lookup_result(
self,
media_id: str,
entity: str = "album",
) -> dict:
response = await self.client.get(
ITUNES_LOOKUP_API_URL,
params={
"id": media_id,
"entity": entity,
},
)
lookup_result = safe_json(response)
if response.status_code != 200 or lookup_result is None:
raise ApiError(
message=response.text,
status_code=response.status_code,
)
logger.debug(f"Lookup result: {lookup_result}")
return lookup_result
async def get_itunes_page(
self,
media_type: str,
media_id: str,
) -> dict:
response = await self.client.get(
f"{ITUNES_PAGE_API_URL}/{media_type}/{media_id}"
)
itunes_page = safe_json(response)
if response.status_code != 200 or itunes_page is None:
raise ApiError(
message=response.text,
status_code=response.status_code,
)
logger.debug(f"iTunes page: {itunes_page}")
return itunes_page
-306
View File
@@ -1,306 +0,0 @@
from __future__ import annotations
import functools
import re
import time
import typing
from http.cookiejar import MozillaCookieJar
from pathlib import Path
import requests
from .utils import raise_response_exception
class AppleMusicApi:
APPLE_MUSIC_HOMEPAGE_URL = "https://beta.music.apple.com"
AMP_API_URL = "https://amp-api.music.apple.com"
WEBPLAYBACK_API_URL = (
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback"
)
LICENSE_API_URL = "https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/acquireWebPlaybackLicense"
WAIT_TIME = 2
def __init__(
self,
cookies_path: Path | None = Path("./cookies.txt"),
storefront: None | str = None,
language: str = "en-US",
):
self.cookies_path = cookies_path
self.storefront = storefront
self.language = language
self._set_session()
def _set_session(self):
self.session = requests.Session()
if self.cookies_path:
cookies = MozillaCookieJar(self.cookies_path)
cookies.load(ignore_discard=True, ignore_expires=True)
self.session.cookies.update(cookies)
media_user_token = self.session.cookies.get_dict().get("media-user-token")
if not media_user_token:
raise ValueError(
"media-user-token not found in cookies. "
"Make sure you're logged in to Apple Music, have an active subscription, and "
"exported the cookies from the Apple Music homepage."
)
else:
media_user_token = ""
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": media_user_token,
"x-apple-renewal": "true",
"DNT": "1",
"Connection": "keep-alive",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-site",
"origin": self.APPLE_MUSIC_HOMEPAGE_URL,
}
)
home_page = self.session.get(self.APPLE_MUSIC_HOMEPAGE_URL).text
index_js_uri = re.search(
r"/(assets/index-legacy-[^/]+\.js)",
home_page,
).group(1)
index_js_page = self.session.get(
f"{self.APPLE_MUSIC_HOMEPAGE_URL}/{index_js_uri}"
).text
token = re.search('(?=eyJh)(.*?)(?=")', index_js_page).group(1)
self.session.headers.update({"authorization": f"Bearer {token}"})
self.session.params = {"l": self.language}
self._set_storefront()
def _check_amp_api_response(self, response: requests.Response):
try:
response.raise_for_status()
response_dict = response.json()
assert response_dict.get("data") or response_dict.get("results") is not None
except (
requests.HTTPError,
requests.exceptions.JSONDecodeError,
AssertionError,
):
raise_response_exception(response)
def _set_storefront(self):
if self.cookies_path:
self.storefront = (
self.session.cookies.get_dict().get("itua")
or self.get_user_storefront()["id"]
)
else:
self.storefront = self.storefront or "us"
def get_user_storefront(
self,
) -> dict:
response = self.session.get(f"{self.AMP_API_URL}/v1/me/storefront")
self._check_amp_api_response(response)
return response.json()["data"][0]
def get_artist(
self,
artist_id: str,
include: str = "albums,music-videos",
limit: int = 100,
fetch_all: bool = True,
) -> dict:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/artists/{artist_id}",
params={
"include": include,
**{f"limit[{_include}]": limit for _include in include.split(",")},
},
)
self._check_amp_api_response(response)
artist = response.json()["data"][0]
if fetch_all:
for _include in include.split(","):
for additional_data in self._extend_api_data(
artist["relationships"][_include],
limit,
):
artist["relationships"][_include]["data"].extend(additional_data)
return artist
def get_song(
self,
song_id: str,
extend: str = "extendedAssetUrls",
include: str = "lyrics,albums",
) -> dict:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/songs/{song_id}",
params={
"include": include,
"extend": extend,
},
)
self._check_amp_api_response(response)
return response.json()["data"][0]
def get_music_video(
self,
music_video_id: str,
include: str = "albums",
) -> dict:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/music-videos/{music_video_id}",
params={
"include": include,
},
)
self._check_amp_api_response(response)
return response.json()["data"][0]
def get_post(
self,
post_id: str,
) -> dict:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/uploaded-videos/{post_id}"
)
self._check_amp_api_response(response)
return response.json()["data"][0]
@functools.lru_cache()
def get_album(
self,
album_id: str,
extend: str = "extendedAssetUrls",
) -> dict:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/albums/{album_id}",
params={
"extend": extend,
},
)
self._check_amp_api_response(response)
return response.json()["data"][0]
def get_playlist(
self,
playlist_id: str,
limit_tracks: int = 300,
extend: str = "extendedAssetUrls",
fetch_all: bool = True,
) -> dict:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/playlists/{playlist_id}",
params={
"extend": extend,
"limit[tracks]": limit_tracks,
},
)
self._check_amp_api_response(response)
playlist = response.json()["data"][0]
if fetch_all:
for additional_data in self._extend_api_data(
playlist["relationships"]["tracks"],
limit_tracks,
):
playlist["relationships"]["tracks"]["data"].extend(additional_data)
return playlist
def search(
self,
term: str,
types: str = "songs,albums,artists,playlists",
limit: int = 25,
offset: int = 0,
) -> dict:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/search",
params={
"term": term,
"types": types,
"limit": limit,
"offset": offset,
},
)
self._check_amp_api_response(response)
return response.json()["results"]
def _extend_api_data(
self,
api_response: dict,
limit: int,
) -> typing.Generator[list[dict], None, None]:
next_uri = api_response.get("next")
while next_uri:
playlist_next = self._get_next_uri_response(next_uri, limit)
yield playlist_next["data"]
next_uri = playlist_next.get("next")
time.sleep(self.WAIT_TIME)
def _get_next_uri_response(self, next_uri: str, limit: int) -> dict:
response = self.session.get(
self.AMP_API_URL + next_uri,
params={
"limit": limit,
},
)
self._check_amp_api_response(response)
return response.json()
def get_webplayback(
self,
track_id: str,
) -> dict:
response = self.session.post(
self.WEBPLAYBACK_API_URL,
json={
"salableAdamId": track_id,
"language": self.language,
},
)
try:
response.raise_for_status()
response_dict = response.json()
webplayback = response_dict.get("songList")
assert webplayback
except (
requests.HTTPError,
requests.exceptions.JSONDecodeError,
AssertionError,
):
raise_response_exception(response)
return webplayback[0]
def get_widevine_license(
self,
track_id: str,
track_uri: str,
challenge: str,
) -> str:
response = self.session.post(
self.LICENSE_API_URL,
json={
"challenge": challenge,
"key-system": "com.widevine.alpha",
"uri": track_uri,
"adamId": track_id,
"isLibrary": False,
"user-initiated": True,
},
)
try:
response.raise_for_status()
response_dict = response.json()
widevine_license = response_dict.get("license")
assert widevine_license
except (
requests.HTTPError,
requests.exceptions.JSONDecodeError,
AssertionError,
):
raise_response_exception(response)
return widevine_license
-786
View File
@@ -1,786 +0,0 @@
from __future__ import annotations
import inspect
import json
import logging
from enum import Enum
from pathlib import Path
import click
import colorama
from . import __version__
from .apple_music_api import AppleMusicApi
from .constants import *
from .custom_logger_formatter import CustomLoggerFormatter
from .downloader import Downloader
from .downloader_music_video import DownloaderMusicVideo
from .downloader_post import DownloaderPost
from .downloader_song import DownloaderSong
from .downloader_song_legacy import DownloaderSongLegacy
from .enums import CoverFormat, DownloadMode, MusicVideoCodec, PostQuality, RemuxMode
from .itunes_api import ItunesApi
from .utils import color_text, prompt_path
apple_music_api_sig = inspect.signature(AppleMusicApi.__init__)
downloader_sig = inspect.signature(Downloader.__init__)
downloader_song_sig = inspect.signature(DownloaderSong.__init__)
downloader_music_video_sig = inspect.signature(DownloaderMusicVideo.__init__)
downloader_post_sig = inspect.signature(DownloaderPost.__init__)
def get_param_string(param: click.Parameter) -> str:
if isinstance(param.default, Enum):
return param.default.value
elif isinstance(param.default, Path):
return str(param.default)
else:
return param.default
def write_default_config_file(ctx: click.Context):
ctx.params["config_path"].parent.mkdir(parents=True, exist_ok=True)
config_file = {
param.name: get_param_string(param)
for param in ctx.command.params
if param.name not in EXCLUDED_CONFIG_FILE_PARAMS
}
ctx.params["config_path"].write_text(json.dumps(config_file, indent=4))
def load_config_file(
ctx: click.Context,
param: click.Parameter,
no_config_file: bool,
) -> click.Context:
if no_config_file:
return ctx
if not ctx.params["config_path"].exists():
write_default_config_file(ctx)
config_file = dict(json.loads(ctx.params["config_path"].read_text()))
for param in ctx.command.params:
if (
config_file.get(param.name) is not None
and not ctx.get_parameter_source(param.name)
== click.core.ParameterSource.COMMANDLINE
):
ctx.params[param.name] = param.type_cast_value(ctx, config_file[param.name])
return ctx
@click.command()
@click.help_option("-h", "--help")
@click.version_option(__version__, "-v", "--version")
# CLI specific options
@click.argument(
"urls",
nargs=-1,
type=str,
required=True,
)
@click.option(
"--disable-music-video-skip",
is_flag=True,
help="Don't skip downloading music videos in albums/playlists.",
)
@click.option(
"--save-cover",
"-s",
is_flag=True,
help="Save cover as a separate file.",
)
@click.option(
"--overwrite",
is_flag=True,
help="Overwrite existing files.",
)
@click.option(
"--read-urls-as-txt",
"-r",
is_flag=True,
help="Interpret URLs as paths to text files containing URLs separated by newlines",
)
@click.option(
"--save-playlist",
is_flag=True,
help="Save a M3U8 playlist file when downloading a playlist.",
)
@click.option(
"--synced-lyrics-only",
is_flag=True,
help="Download only the synced lyrics.",
)
@click.option(
"--no-synced-lyrics",
is_flag=True,
help="Don't download the synced lyrics.",
)
@click.option(
"--config-path",
type=Path,
default=Path.home() / ".gamdl" / "config.json",
help="Path to config file.",
)
@click.option(
"--log-level",
type=str,
default="INFO",
help="Log level.",
)
@click.option(
"--no-exceptions",
is_flag=True,
help="Don't print exceptions.",
)
# API specific options
@click.option(
"--cookies-path",
"-c",
type=Path,
default=apple_music_api_sig.parameters["cookies_path"].default,
help="Path to .txt cookies file.",
)
@click.option(
"--language",
"-l",
type=str,
default=apple_music_api_sig.parameters["language"].default,
help="Metadata language as an ISO-2A language code (don't always work for videos).",
)
# Downloader specific options
@click.option(
"--output-path",
"-o",
type=Path,
default=downloader_sig.parameters["output_path"].default,
help="Path to output directory.",
)
@click.option(
"--temp-path",
type=Path,
default=downloader_sig.parameters["temp_path"].default,
help="Path to temporary directory.",
)
@click.option(
"--wvd-path",
type=Path,
default=downloader_sig.parameters["wvd_path"].default,
help="Path to .wvd file.",
)
@click.option(
"--nm3u8dlre-path",
type=str,
default=downloader_sig.parameters["nm3u8dlre_path"].default,
help="Path to N_m3u8DL-RE binary.",
)
@click.option(
"--mp4decrypt-path",
type=str,
default=downloader_sig.parameters["mp4decrypt_path"].default,
help="Path to mp4decrypt binary.",
)
@click.option(
"--ffmpeg-path",
type=str,
default=downloader_sig.parameters["ffmpeg_path"].default,
help="Path to FFmpeg binary.",
)
@click.option(
"--mp4box-path",
type=str,
default=downloader_sig.parameters["mp4box_path"].default,
help="Path to MP4Box binary.",
)
@click.option(
"--download-mode",
type=DownloadMode,
default=downloader_sig.parameters["download_mode"].default,
help="Download mode.",
)
@click.option(
"--remux-mode",
type=RemuxMode,
default=downloader_sig.parameters["remux_mode"].default,
help="Remux mode.",
)
@click.option(
"--cover-format",
type=CoverFormat,
default=downloader_sig.parameters["cover_format"].default,
help="Cover format.",
)
@click.option(
"--template-folder-album",
type=str,
default=downloader_sig.parameters["template_folder_album"].default,
help="Template folder for tracks that are part of an album.",
)
@click.option(
"--template-folder-compilation",
type=str,
default=downloader_sig.parameters["template_folder_compilation"].default,
help="Template folder for tracks that are part of a compilation album.",
)
@click.option(
"--template-file-single-disc",
type=str,
default=downloader_sig.parameters["template_file_single_disc"].default,
help="Template file for the tracks that are part of a single-disc album.",
)
@click.option(
"--template-file-multi-disc",
type=str,
default=downloader_sig.parameters["template_file_multi_disc"].default,
help="Template file for the tracks that are part of a multi-disc album.",
)
@click.option(
"--template-folder-no-album",
type=str,
default=downloader_sig.parameters["template_folder_no_album"].default,
help="Template folder for the tracks that are not part of an album.",
)
@click.option(
"--template-file-no-album",
type=str,
default=downloader_sig.parameters["template_file_no_album"].default,
help="Template file for the tracks that are not part of an album.",
)
@click.option(
"--template-file-playlist",
type=str,
default=downloader_sig.parameters["template_file_playlist"].default,
help="Template file for the M3U8 playlist.",
)
@click.option(
"--template-date",
type=str,
default=downloader_sig.parameters["template_date"].default,
help="Date tag template.",
)
@click.option(
"--exclude-tags",
type=str,
default=downloader_sig.parameters["exclude_tags"].default,
help="Comma-separated tags to exclude.",
)
@click.option(
"--cover-size",
type=int,
default=downloader_sig.parameters["cover_size"].default,
help="Cover size.",
)
@click.option(
"--truncate",
type=int,
default=downloader_sig.parameters["truncate"].default,
help="Maximum length of the file/folder names.",
)
# DownloaderSong specific options
@click.option(
"--codec-song",
type=SongCodec,
default=downloader_song_sig.parameters["codec"].default,
help="Song codec.",
)
@click.option(
"--synced-lyrics-format",
type=SyncedLyricsFormat,
default=downloader_song_sig.parameters["synced_lyrics_format"].default,
help="Synced lyrics format.",
)
# DownloaderMusicVideo specific options
@click.option(
"--codec-music-video",
type=MusicVideoCodec,
default=downloader_music_video_sig.parameters["codec"].default,
help="Music video codec.",
)
# DownloaderPost specific options
@click.option(
"--quality-post",
type=PostQuality,
default=downloader_post_sig.parameters["quality"].default,
help="Post video quality.",
)
# This option should always be last
@click.option(
"--no-config-file",
"-n",
is_flag=True,
callback=load_config_file,
help="Do not use a config file.",
)
def main(
urls: list[str],
disable_music_video_skip: bool,
save_cover: bool,
overwrite: bool,
read_urls_as_txt: bool,
save_playlist: bool,
synced_lyrics_only: bool,
no_synced_lyrics: bool,
config_path: Path,
log_level: str,
no_exceptions: bool,
cookies_path: Path,
language: str,
output_path: Path,
temp_path: Path,
wvd_path: Path,
nm3u8dlre_path: str,
mp4decrypt_path: str,
ffmpeg_path: str,
mp4box_path: str,
download_mode: DownloadMode,
remux_mode: RemuxMode,
cover_format: CoverFormat,
template_folder_album: str,
template_folder_compilation: str,
template_file_single_disc: str,
template_file_multi_disc: str,
template_folder_no_album: str,
template_file_no_album: str,
template_file_playlist: str,
template_date: str,
exclude_tags: str,
cover_size: int,
truncate: int,
codec_song: SongCodec,
synced_lyrics_format: SyncedLyricsFormat,
codec_music_video: MusicVideoCodec,
quality_post: PostQuality,
no_config_file: bool,
):
colorama.just_fix_windows_console()
logger = logging.getLogger(__name__)
logger.setLevel(log_level)
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(CustomLoggerFormatter())
logger.addHandler(stream_handler)
logger.info("Starting Gamdl")
prompt_path("Cookies file", cookies_path)
apple_music_api = AppleMusicApi(
cookies_path,
language=language,
)
itunes_api = ItunesApi(
apple_music_api.storefront,
apple_music_api.language,
)
downloader = Downloader(
apple_music_api,
itunes_api,
output_path,
temp_path,
wvd_path,
nm3u8dlre_path,
mp4decrypt_path,
ffmpeg_path,
mp4box_path,
download_mode,
remux_mode,
cover_format,
template_folder_album,
template_folder_compilation,
template_file_single_disc,
template_file_multi_disc,
template_folder_no_album,
template_file_no_album,
template_file_playlist,
template_date,
exclude_tags,
cover_size,
truncate,
)
downloader_song = DownloaderSong(
downloader,
codec_song,
synced_lyrics_format,
)
downloader_song_legacy = DownloaderSongLegacy(
downloader,
codec_song,
)
downloader_music_video = DownloaderMusicVideo(
downloader,
codec_music_video,
)
downloader_post = DownloaderPost(
downloader,
quality_post,
)
if not synced_lyrics_only:
if wvd_path:
prompt_path(".wvd file", wvd_path)
logger.debug("Setting up CDM")
downloader.set_cdm()
if not downloader.ffmpeg_path_full and (
remux_mode == RemuxMode.FFMPEG or download_mode == DownloadMode.NM3U8DLRE
):
logger.critical(X_NOT_FOUND_STRING.format("ffmpeg", ffmpeg_path))
return
if not downloader.mp4box_path_full and remux_mode == RemuxMode.MP4BOX:
logger.critical(X_NOT_FOUND_STRING.format("MP4Box", mp4box_path))
return
if (
not downloader.mp4decrypt_path_full
and codec_song
not in (
SongCodec.AAC_LEGACY,
SongCodec.AAC_HE_LEGACY,
)
or (remux_mode == RemuxMode.MP4BOX and not downloader.mp4decrypt_path_full)
):
logger.critical(X_NOT_FOUND_STRING.format("mp4decrypt", mp4decrypt_path))
return
if (
download_mode == DownloadMode.NM3U8DLRE
and not downloader.nm3u8dlre_path_full
):
logger.critical(X_NOT_FOUND_STRING.format("N_m3u8DL-RE", nm3u8dlre_path))
return
if not downloader.mp4decrypt_path_full:
logger.warning(
X_NOT_FOUND_STRING.format("mp4decrypt", mp4decrypt_path)
+ ", music videos will not be downloaded"
)
skip_mv = True
else:
skip_mv = False
if codec_song not in LEGACY_CODECS:
logger.warning(
"You have chosen an experimental codec. "
"They're not guaranteed to work due to API limitations."
)
error_count = 0
if read_urls_as_txt:
_urls = []
for url in urls:
if Path(url).exists():
_urls.extend(Path(url).read_text(encoding="utf-8").splitlines())
urls = _urls
for url_index, url in enumerate(urls, start=1):
url_progress = color_text(f"URL {url_index}/{len(urls)}", colorama.Style.DIM)
try:
logger.info(f'({url_progress}) Checking "{url}"')
url_info = downloader.get_url_info(url)
download_queue = downloader.get_download_queue(url_info)
download_queue_tracks_metadata = download_queue.tracks_metadata
except Exception as e:
error_count += 1
logger.error(
f'({url_progress}) Failed to check "{url}"',
exc_info=not no_exceptions,
)
continue
for download_index, track_metadata in enumerate(
download_queue_tracks_metadata, start=1
):
queue_progress = color_text(
f"Track {download_index}/{len(download_queue_tracks_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"]}"'
)
if not track_metadata["attributes"].get("playParams"):
logger.warning(
f"({queue_progress}) Track is not streamable, skipping"
)
continue
if (
(synced_lyrics_only and track_metadata["type"] != "songs")
or (track_metadata["type"] == "music-videos" and skip_mv)
or (
track_metadata["type"] == "music-videos"
and url_info.type == "album"
and not disable_music_video_skip
)
):
logger.warning(
f"({queue_progress}) Track is not downloadable with current configuration, skipping"
)
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
)
cover_url = downloader.get_cover_url(track_metadata)
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
if cover_file_extesion:
cover_path = downloader_song.get_cover_path(
final_path,
cover_file_extesion,
)
else:
cover_path = None
if synced_lyrics_only:
pass
elif final_path.exists() and not overwrite:
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.widevine_pssh, track_metadata["id"]
)
else:
stream_info = downloader_song.get_stream_info(
track_metadata
)
if (
not stream_info.stream_url
or not stream_info.widevine_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.widevine_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)
if cover_file_extesion:
cover_path = downloader_music_video.get_cover_path(
final_path,
cover_file_extesion,
)
else:
cover_path = None
if final_path.exists() and not overwrite:
logger.warning(
f'({queue_progress}) Music video already exists at "{final_path}", skipping'
)
else:
logger.debug("Getting stream info")
stream_info_video, stream_info_audio = (
downloader_music_video.get_stream_info_video(m3u8_data),
downloader_music_video.get_stream_info_audio(m3u8_data),
)
decryption_key_video = downloader.get_decryption_key(
stream_info_video.widevine_pssh, track_metadata["id"]
)
decryption_key_audio = downloader.get_decryption_key(
stream_info_audio.widevine_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)
if cover_file_extesion:
cover_path = downloader_music_video.get_cover_path(
final_path,
cover_file_extesion,
)
else:
cover_path = None
if final_path.exists() and not overwrite:
logger.warning(
f'({queue_progress}) 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 or cover_path is None:
pass
elif cover_path.exists() and not overwrite:
logger.debug(f'Cover already exists at "{cover_path}", skipping')
else:
logger.debug(f'Saving cover to "{cover_path}"')
downloader.save_cover(cover_path, cover_url)
if remuxed_path:
logger.debug("Applying tags")
downloader.apply_tags(remuxed_path, tags, cover_url)
logger.debug(f'Moving to "{final_path}"')
downloader.move_to_output_path(remuxed_path, final_path)
if (
not synced_lyrics_only
and save_playlist
and download_queue.playlist_attributes
):
playlist_file_path = downloader.get_playlist_file_path(tags)
logger.debug(f'Updating M3U8 playlist from "{playlist_file_path}"')
downloader.update_playlist_file(
playlist_file_path,
final_path,
playlist_track,
)
except Exception as e:
error_count += 1
logger.error(
f'({queue_progress}) Failed to download "{track_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))")
View File
+301
View File
@@ -0,0 +1,301 @@
import asyncio
import logging
from functools import wraps
from pathlib import Path
import click
import colorama
from dataclass_click import dataclass_click
from httpx import ConnectError
from .. import __version__
from ..api import AppleMusicApi, ItunesApi
from ..downloader import (
AppleMusicBaseDownloader,
AppleMusicDownloader,
AppleMusicMusicVideoDownloader,
AppleMusicSongDownloader,
AppleMusicUploadedVideoDownloader,
DownloadItem,
DownloadMode,
GamdlError,
RemuxMode,
)
from ..interface import (
AppleMusicInterface,
AppleMusicMusicVideoInterface,
AppleMusicSongInterface,
AppleMusicUploadedVideoInterface,
SongCodec,
)
from .cli_config import CliConfig
from .config_file import ConfigFile
from .constants import X_NOT_IN_PATH
from .utils import CustomLoggerFormatter, prompt_path
logger = logging.getLogger(__name__)
def make_sync(func):
@wraps(func)
def wrapper(*args, **kwargs):
return asyncio.run(func(*args, **kwargs))
return wrapper
@click.command()
@click.help_option("-h", "--help")
@click.version_option(__version__, "-v", "--version")
@dataclass_click(CliConfig)
@ConfigFile.loader
@make_sync
async def main(config: CliConfig):
colorama.just_fix_windows_console()
root_logger = logging.getLogger(__name__.split(".")[0])
root_logger.setLevel(config.log_level)
root_logger.propagate = False
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(CustomLoggerFormatter())
root_logger.addHandler(stream_handler)
if config.log_file:
file_handler = logging.FileHandler(config.log_file, encoding="utf-8")
file_handler.setFormatter(CustomLoggerFormatter(use_colors=False))
root_logger.addHandler(file_handler)
logger.info(f"Starting Gamdl {__version__}")
if config.use_wrapper:
try:
apple_music_api = await AppleMusicApi.create_from_wrapper(
wrapper_account_url=config.wrapper_account_url,
language=config.language,
)
except ConnectError:
logger.critical(
"Could not connect to the wrapper account API. "
"Make sure the wrapper is running and the URL is correct."
)
return
else:
cookies_path = prompt_path(config.cookies_path)
apple_music_api = await AppleMusicApi.create_from_netscape_cookies(
cookies_path=cookies_path,
language=config.language,
)
itunes_api = ItunesApi(
apple_music_api.storefront,
apple_music_api.language,
)
if not apple_music_api.active_subscription:
logger.critical(
"No active Apple Music subscription found, you won't be able to download"
" anything"
)
return
if apple_music_api.account_restrictions:
logger.warning(
"Your account has content restrictions enabled, some content may not be"
" downloadable"
)
interface = AppleMusicInterface(
apple_music_api,
itunes_api,
)
song_interface = AppleMusicSongInterface(interface)
music_video_interface = AppleMusicMusicVideoInterface(interface)
uploaded_video_interface = AppleMusicUploadedVideoInterface(interface)
base_downloader = AppleMusicBaseDownloader(
output_path=config.output_path,
temp_path=config.temp_path,
wvd_path=config.wvd_path,
overwrite=config.overwrite,
save_cover=config.save_cover,
save_playlist=config.save_playlist,
nm3u8dlre_path=config.nm3u8dlre_path,
mp4decrypt_path=config.mp4decrypt_path,
ffmpeg_path=config.ffmpeg_path,
mp4box_path=config.mp4box_path,
use_wrapper=config.use_wrapper,
wrapper_decrypt_ip=config.wrapper_decrypt_ip,
download_mode=config.download_mode,
cover_format=config.cover_format,
album_folder_template=config.album_folder_template,
compilation_folder_template=config.compilation_folder_template,
no_album_folder_template=config.no_album_folder_template,
single_disc_file_template=config.single_disc_file_template,
multi_disc_file_template=config.multi_disc_file_template,
no_album_file_template=config.no_album_file_template,
playlist_file_template=config.playlist_file_template,
date_tag_template=config.date_tag_template,
exclude_tags=config.exclude_tags,
cover_size=config.cover_size,
truncate=config.truncate,
)
song_downloader = AppleMusicSongDownloader(
base_downloader=base_downloader,
interface=song_interface,
codec=config.song_codec,
synced_lyrics_format=config.synced_lyrics_format,
no_synced_lyrics=config.no_synced_lyrics,
synced_lyrics_only=config.synced_lyrics_only,
use_album_date=config.use_album_date,
fetch_extra_tags=config.fetch_extra_tags,
)
music_video_downloader = AppleMusicMusicVideoDownloader(
base_downloader=base_downloader,
interface=music_video_interface,
codec_priority=config.music_video_codec_priority,
remux_mode=config.music_video_remux_mode,
remux_format=config.music_video_remux_format,
resolution=config.music_video_resolution,
)
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(
base_downloader=base_downloader,
interface=uploaded_video_interface,
quality=config.uploaded_video_quality,
)
downloader = AppleMusicDownloader(
interface=interface,
base_downloader=base_downloader,
song_downloader=song_downloader,
music_video_downloader=music_video_downloader,
uploaded_video_downloader=uploaded_video_downloader,
)
if not config.synced_lyrics_only:
if (
config.download_mode == DownloadMode.NM3U8DLRE
and not base_downloader.full_nm3u8dlre_path
):
logger.critical(X_NOT_IN_PATH.format("N_m3u8DL-RE", config.nm3u8dlre_path))
return
missing_music_video_paths = []
if not base_downloader.full_ffmpeg_path and (
config.music_video_remux_mode == RemuxMode.FFMPEG
or config.download_mode == DownloadMode.NM3U8DLRE
):
missing_music_video_paths.append(
X_NOT_IN_PATH.format("ffmpeg", config.ffmpeg_path)
)
if (
not base_downloader.full_mp4box_path
and config.music_video_remux_mode == RemuxMode.MP4BOX
):
missing_music_video_paths.append(
X_NOT_IN_PATH.format("MP4Box", config.mp4box_path)
)
if not base_downloader.full_mp4decrypt_path and (
config.song_codec not in (SongCodec.AAC_LEGACY, SongCodec.AAC_HE_LEGACY)
or config.music_video_remux_mode == RemuxMode.MP4BOX
):
missing_music_video_paths.append(
X_NOT_IN_PATH.format("mp4decrypt", config.mp4decrypt_path)
)
if missing_music_video_paths:
logger.warning(
"Music videos will not be downloaded due to missing dependencies:\n"
+ "\n".join(missing_music_video_paths)
)
if not config.song_codec.is_legacy() and not config.use_wrapper:
logger.warning(
"You have chosen an experimental song codec "
"without enabling wrapper. "
"They're not guaranteed to work due to API limitations."
)
if config.read_urls_as_txt:
urls_from_file = []
for url in config.urls:
if Path(url).is_file() and Path(url).exists():
urls_from_file.extend(
[
line.strip()
for line in Path(url).read_text(encoding="utf-8").splitlines()
if line.strip()
]
)
urls = urls_from_file
else:
urls = config.urls
error_count = 0
for url_index, url in enumerate(urls, 1):
url_progress = click.style(f"[URL {url_index}/{len(urls)}]", dim=True)
logger.info(url_progress + f' Processing "{url}"')
download_queue = None
try:
url_info = downloader.get_url_info(url)
if not url_info:
logger.warning(
url_progress + f' Could not parse "{url}", skipping.',
)
continue
download_queue = await downloader.get_download_queue(url_info)
if not download_queue:
logger.warning(
url_progress
+ f' No downloadable media found for "{url}", skipping.',
)
continue
except KeyboardInterrupt:
exit(1)
except Exception as e:
error_count += 1
logger.error(
url_progress + f' Error processing "{url}"',
exc_info=not config.no_exceptions,
)
if not download_queue:
continue
for download_index, download_item in enumerate(
download_queue,
1,
):
download_queue_progress = click.style(
f"[Track {download_index}/{len(download_queue)}]",
dim=True,
)
media_title = (
download_item.media_metadata["attributes"]["name"]
if isinstance(
download_item,
DownloadItem,
)
else "Unknown Title"
)
logger.info(download_queue_progress + f' Downloading "{media_title}"')
try:
await downloader.download(download_item)
except GamdlError as e:
logger.warning(
download_queue_progress + f' Skipping "{media_title}": {e}'
)
continue
except KeyboardInterrupt:
exit(1)
except Exception as e:
error_count += 1
logger.error(
download_queue_progress + f' Error downloading "{media_title}"',
exc_info=not config.no_exceptions,
)
logger.info(f"Finished with {error_count} error(s)")
+470
View File
@@ -0,0 +1,470 @@
import inspect
from dataclasses import dataclass
from pathlib import Path
from typing import Annotated
import click
from dataclass_click import argument, option
from ..api import AppleMusicApi
from ..downloader import (
AppleMusicBaseDownloader,
AppleMusicMusicVideoDownloader,
AppleMusicSongDownloader,
AppleMusicUploadedVideoDownloader,
DownloadMode,
RemuxFormatMusicVideo,
RemuxMode,
)
from ..interface import (
CoverFormat,
MusicVideoCodec,
MusicVideoResolution,
SongCodec,
SyncedLyricsFormat,
UploadedVideoQuality,
)
from .utils import Csv
api_from_cookies_sig = inspect.signature(AppleMusicApi.create_from_netscape_cookies)
api_from_wrapper_sig = inspect.signature(AppleMusicApi.create_from_wrapper)
api_sig = inspect.signature(AppleMusicApi.__init__)
base_downloader_sig = inspect.signature(AppleMusicBaseDownloader.__init__)
music_video_downloader_sig = inspect.signature(AppleMusicMusicVideoDownloader.__init__)
song_downloader_sig = inspect.signature(AppleMusicSongDownloader.__init__)
uploaded_video_downloader_sig = inspect.signature(
AppleMusicUploadedVideoDownloader.__init__
)
@dataclass
class CliConfig:
# CLI specific options
urls: Annotated[
list[str],
argument(
nargs=-1,
type=str,
required=True,
),
]
read_urls_as_txt: Annotated[
bool,
option(
"--read-urls-as-txt",
"-r",
help="Read URLs from text files",
is_flag=True,
),
]
config_path: Annotated[
str,
option(
"--config-path",
help="Config file path",
default=str(Path.home() / ".gamdl" / "config.ini"),
type=click.Path(
file_okay=True,
dir_okay=False,
writable=True,
resolve_path=True,
),
),
]
log_level: Annotated[
str,
option(
"--log-level",
help="Logging level",
default="INFO",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]),
),
]
log_file: Annotated[
str,
option(
"--log-file",
help="Log file path",
default=None,
type=click.Path(
file_okay=True,
dir_okay=False,
writable=True,
resolve_path=True,
),
),
]
no_exceptions: Annotated[
bool,
option(
"--no-exceptions",
help="Don't print exceptions",
is_flag=True,
),
]
# API specific options
cookies_path: Annotated[
str,
option(
"--cookies-path",
"-c",
help="Cookies file path",
default=api_from_cookies_sig.parameters["cookies_path"].default,
type=click.Path(
file_okay=True,
dir_okay=False,
readable=True,
resolve_path=True,
),
),
]
wrapper_account_url: Annotated[
str,
option(
"--wrapper-account-url",
help="Wrapper account URL",
default=api_from_wrapper_sig.parameters["wrapper_account_url"].default,
),
]
language: Annotated[
str,
option(
"--language",
"-l",
help="Metadata language",
default=api_sig.parameters["language"].default,
),
]
# Base Downloader specific options
output_path: Annotated[
str,
option(
"--output-path",
"-o",
help="Output directory path",
default=base_downloader_sig.parameters["output_path"].default,
type=click.Path(
file_okay=False,
dir_okay=True,
writable=True,
resolve_path=True,
),
),
]
temp_path: Annotated[
str,
option(
"--temp-path",
help="Temporary directory path",
default=base_downloader_sig.parameters["temp_path"].default,
type=click.Path(
file_okay=False,
dir_okay=True,
writable=True,
resolve_path=True,
),
),
]
wvd_path: Annotated[
str,
option(
"--wvd-path",
help=".wvd file path",
default=base_downloader_sig.parameters["wvd_path"].default,
type=click.Path(
file_okay=False,
dir_okay=True,
writable=True,
resolve_path=True,
),
),
]
overwrite: Annotated[
bool,
option(
"--overwrite",
help="Overwrite existing files",
is_flag=True,
),
]
save_cover: Annotated[
bool,
option(
"--save-cover",
"-s",
help="Save cover as separate file",
is_flag=True,
),
]
save_playlist: Annotated[
bool,
option(
"--save-playlist",
help="Save M3U8 playlist file",
is_flag=True,
),
]
nm3u8dlre_path: Annotated[
str,
option(
"--nm3u8dlre-path",
help="N_m3u8DL-RE executable path",
default=base_downloader_sig.parameters["nm3u8dlre_path"].default,
),
]
mp4decrypt_path: Annotated[
str,
option(
"--mp4decrypt-path",
help="mp4decrypt executable path",
default=base_downloader_sig.parameters["mp4decrypt_path"].default,
),
]
ffmpeg_path: Annotated[
str,
option(
"--ffmpeg-path",
help="FFmpeg executable path",
default=base_downloader_sig.parameters["ffmpeg_path"].default,
),
]
mp4box_path: Annotated[
str,
option(
"--mp4box-path",
help="MP4Box executable path",
default=base_downloader_sig.parameters["mp4box_path"].default,
),
]
use_wrapper: Annotated[
bool,
option(
"--use-wrapper",
help="Use wrapper for decrypting songs",
is_flag=True,
),
]
wrapper_decrypt_ip: Annotated[
str,
option(
"--wrapper-decrypt-ip",
help="IP address and port for wrapper decryption",
default=base_downloader_sig.parameters["wrapper_decrypt_ip"].default,
),
]
download_mode: Annotated[
DownloadMode,
option(
"--download-mode",
help="Download mode",
default=base_downloader_sig.parameters["download_mode"].default,
type=DownloadMode,
),
]
cover_format: Annotated[
CoverFormat,
option(
"--cover-format",
help="Cover format",
default=base_downloader_sig.parameters["cover_format"].default,
type=CoverFormat,
),
]
album_folder_template: Annotated[
str,
option(
"--album-folder-template",
help="Album folder template",
default=base_downloader_sig.parameters["album_folder_template"].default,
),
]
compilation_folder_template: Annotated[
str,
option(
"--compilation-folder-template",
help="Compilation folder template",
default=base_downloader_sig.parameters[
"compilation_folder_template"
].default,
),
]
no_album_folder_template: Annotated[
str,
option(
"--no-album-folder-template",
help="No album folder template",
default=base_downloader_sig.parameters["no_album_folder_template"].default,
),
]
single_disc_file_template: Annotated[
str,
option(
"--single-disc-file-template",
help="Single disc file template",
default=base_downloader_sig.parameters["single_disc_file_template"].default,
),
]
multi_disc_file_template: Annotated[
str,
option(
"--multi-disc-file-template",
help="Multi disc file template",
default=base_downloader_sig.parameters["multi_disc_file_template"].default,
),
]
no_album_file_template: Annotated[
str,
option(
"--no-album-file-template",
help="No album file template",
default=base_downloader_sig.parameters["no_album_file_template"].default,
),
]
playlist_file_template: Annotated[
str,
option(
"--playlist-file-template",
help="Playlist file template",
default=base_downloader_sig.parameters["playlist_file_template"].default,
),
]
date_tag_template: Annotated[
str,
option(
"--date-tag-template",
help="Date tag template",
default=base_downloader_sig.parameters["date_tag_template"].default,
),
]
exclude_tags: Annotated[
list[str],
option(
"--exclude-tags",
help="Comma-separated tags to exclude",
default=base_downloader_sig.parameters["exclude_tags"].default,
type=Csv(str),
),
]
cover_size: Annotated[
int,
option(
"--cover-size",
help="Cover size in pixels",
default=base_downloader_sig.parameters["cover_size"].default,
),
]
truncate: Annotated[
int,
option(
"--truncate",
help="Max filename length",
default=base_downloader_sig.parameters["truncate"].default,
),
]
# DownloaderSong specific options
song_codec: Annotated[
SongCodec,
option(
"--song-codec",
help="Song codec",
default=song_downloader_sig.parameters["codec"].default,
type=SongCodec,
),
]
synced_lyrics_format: Annotated[
SyncedLyricsFormat,
option(
"--synced-lyrics-format",
help="Synced lyrics format",
default=song_downloader_sig.parameters["synced_lyrics_format"].default,
type=SyncedLyricsFormat,
),
]
no_synced_lyrics: Annotated[
bool,
option(
"--no-synced-lyrics",
help="Don't download synced lyrics",
is_flag=True,
),
]
synced_lyrics_only: Annotated[
bool,
option(
"--synced-lyrics-only",
help="Download only synced lyrics",
is_flag=True,
),
]
use_album_date: Annotated[
bool,
option(
"--use-album-date",
help="Use album release date for songs",
is_flag=True,
),
]
fetch_extra_tags: Annotated[
bool,
option(
"--fetch-extra-tags",
help="Fetch extra tags from preview (normalization and smooth playback)",
is_flag=True,
),
]
# DownloaderMusicVideo specific options
music_video_codec_priority: Annotated[
list[MusicVideoCodec],
option(
"--music-video-codec-priority",
help="Comma-separated codec priority",
default=music_video_downloader_sig.parameters["codec_priority"].default,
type=Csv(MusicVideoCodec),
),
]
music_video_remux_mode: Annotated[
RemuxMode,
option(
"--music-video-remux-mode",
help="Remux mode",
default=music_video_downloader_sig.parameters["remux_mode"].default,
type=RemuxMode,
),
]
music_video_remux_format: Annotated[
RemuxFormatMusicVideo,
option(
"--music-video-remux-format",
help="Music video remux format",
default=music_video_downloader_sig.parameters["remux_format"].default,
type=RemuxFormatMusicVideo,
),
]
music_video_resolution: Annotated[
MusicVideoResolution,
option(
"--music-video-resolution",
help="Max music video resolution",
default=music_video_downloader_sig.parameters["resolution"].default,
type=MusicVideoResolution,
),
]
# DownloaderUploadedVideo specific options
uploaded_video_quality: Annotated[
UploadedVideoQuality,
option(
"--uploaded-video-quality",
help="Post video quality",
default=uploaded_video_downloader_sig.parameters["quality"].default,
type=UploadedVideoQuality,
),
]
no_config_file: Annotated[
bool,
option(
"--no-config-file",
"-n",
help="Don't use a config file",
is_flag=True,
),
]
+167
View File
@@ -0,0 +1,167 @@
import configparser
import typing
from functools import wraps
from pathlib import Path
import click
import click.types as click_types
from .cli_config import CliConfig
from .constants import EXCLUDED_CONFIG_FILE_PARAMS
from .utils import Csv
class ConfigFile:
def __init__(
self,
config_path: str,
section_name: str = "gamdl",
) -> None:
self.config_path = config_path
self.section_name = section_name
self.click_context = click.get_current_context()
self._read_config_file()
def _read_config_file(self) -> None:
self.config = configparser.ConfigParser(interpolation=None)
if Path(self.config_path).exists():
self.config.read(self.config_path, encoding="utf-8")
else:
Path(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 open(self.config_path, "w", encoding="utf-8") as config_file:
self.config.write(config_file)
def _serialize_param_default(self, param: click.Parameter) -> str:
if param.default is None:
return "null"
if isinstance(param.type, Csv):
return ",".join(
item.value if hasattr(item, "value") else str(item)
for item in param.default
)
if isinstance(param.type, click_types.FuncParamType):
return param.default.value
if isinstance(param.type, click_types.BoolParamType):
return "true" if param.default else "false"
if isinstance(
param.type,
click_types.Choice
| click_types.Path
| click_types.StringParamType
| click_types.IntParamType,
):
return str(param.default)
raise NotImplementedError(
f"Serialization for parameter '{param.name}' of type "
f"'{type(param.type)}' is not implemented."
)
def _add_param_default_to_config(
self,
param: click.Parameter,
) -> bool:
if self.config.has_option(self.section_name, param.name):
return False
value = self._serialize_param_default(param)
self.config.set(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 is None:
return param.default
if value == "null":
return None
if not isinstance(param.type, click_types.ParamType):
raise NotImplementedError(
f"Parsing for parameter '{param.name}' of type "
f"'{type(param.type)}' is not implemented."
)
return param.type.convert(value, None, None)
def add_params_default_to_config(self) -> None:
has_changes = False
for param in self.click_context.command.params:
if param.name in EXCLUDED_CONFIG_FILE_PARAMS:
continue
has_changes = self._add_param_default_to_config(param) or has_changes
if has_changes:
self._write_config_file()
def cleanup_unknown_params(self) -> None:
param_names = {info.name for info in self.click_context.command.params}
has_changes = False
for key in list(self.config[self.section_name].keys()):
if key not in param_names:
self.config.remove_option(self.section_name, key)
has_changes = True
if has_changes:
self._write_config_file()
def update_params_from_config(self) -> None:
for param in self.click_context.command.params:
if (
self.click_context.get_parameter_source(param.name)
== click.core.ParameterSource.COMMANDLINE
):
continue
if self.config.has_option(self.section_name, param.name):
self.click_context.params[param.name] = self._parse_param_from_config(
param
)
def get_cli_config(self) -> CliConfig:
config_dict = {}
for param in self.click_context.command.params:
if param.name in {"help", "version"}:
continue
config_dict[param.name] = self.click_context.params.get(
param.name, param.default
)
return CliConfig(**config_dict)
def load(self) -> CliConfig:
self.cleanup_unknown_params()
self.add_params_default_to_config()
self.update_params_from_config()
return self.get_cli_config()
@staticmethod
def loader(func):
@wraps(func)
def wrapper(cli_config: CliConfig):
ctx = click.get_current_context()
config_path = ctx.params.get("config_path")
no_config_file = ctx.params.get("no_config_file")
if config_path and not no_config_file:
cli_config = ConfigFile(config_path).load()
return func(cli_config)
return wrapper
+9
View File
@@ -0,0 +1,9 @@
EXCLUDED_CONFIG_FILE_PARAMS = {
"urls",
"config_path",
"read_urls_as_txt",
"no_config_file",
"version",
"help",
}
X_NOT_IN_PATH = '{} was not found in PATH at "{}"'
+96
View File
@@ -0,0 +1,96 @@
import logging
from enum import Enum
from pathlib import Path
import click
class Csv(click.ParamType):
name = "csv"
def __init__(
self,
subtype: Enum,
) -> None:
self.subtype = subtype
def convert(
self,
value: str,
param: click.Parameter,
ctx: click.Context,
) -> list[Enum]:
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
class CustomLoggerFormatter(logging.Formatter):
base_format = "[%(levelname)-8s %(asctime)s]"
format_colors = {
logging.DEBUG: dict(dim=True),
logging.INFO: dict(fg="green"),
logging.WARNING: dict(fg="yellow"),
logging.ERROR: dict(fg="red"),
logging.CRITICAL: dict(fg="red", bold=True),
}
date_format = "%H:%M:%S"
def __init__(self, use_colors: bool = True) -> None:
super().__init__()
self.use_colors = use_colors
def format(self, record: logging.LogRecord) -> str:
return logging.Formatter(
(
click.style(self.base_format, **self.format_colors.get(record.levelno))
if self.use_colors
else self.base_format
)
+ " %(message)s",
datefmt=self.date_format,
).format(record)
def prompt_path(
input_path: str,
is_dir: bool = False,
) -> str:
path_validator = click.Path(
exists=True,
file_okay=not is_dir,
dir_okay=is_dir,
)
path_type = "directory" if is_dir else "file"
while True:
try:
result_path = path_validator.convert(input_path, None, None)
break
except click.BadParameter as e:
input_path = click.prompt(
(
f'{path_type.capitalize()} "{Path(input_path).absolute()}" does not exist. '
f"Create the {path_type} at the specified path, "
f"type a new path or drag and drop the {path_type} here. "
"Then, press enter to continue"
),
default=input_path,
show_default=False,
)
input_path = input_path.strip('"')
return result_path
-24
View File
@@ -1,24 +0,0 @@
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)
-541
View File
@@ -1,541 +0,0 @@
from __future__ import annotations
import base64
import datetime
import functools
import io
import re
import shutil
import subprocess
import typing
from pathlib import Path
import requests
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from mutagen.mp4 import MP4, MP4Cover
from PIL import Image
from pywidevine import PSSH, Cdm, Device
from yt_dlp import YoutubeDL
from .apple_music_api import AppleMusicApi
from .constants import IMAGE_FILE_EXTENSION_MAP, MP4_TAGS_MAP
from .enums import CoverFormat, DownloadMode, RemuxMode
from .hardcoded_wvd import HARDCODED_WVD
from .itunes_api import ItunesApi
from .models import DownloadQueue, UrlInfo
from .utils import raise_response_exception
class Downloader:
ILLEGAL_CHARS_RE = r'[\\/:*?"<>|;]'
ILLEGAL_CHAR_REPLACEMENT = "_"
VALID_URL_RE = r"/([a-z]{2})/(artist|album|playlist|song|music-video|post)/([^/]*)(?:/([^/?]*))?(?:\?i=)?([0-9a-z]*)?"
def __init__(
self,
apple_music_api: AppleMusicApi,
itunes_api: ItunesApi,
output_path: Path = Path("./Apple Music"),
temp_path: Path = Path("./temp"),
wvd_path: Path = None,
nm3u8dlre_path: str = "N_m3u8DL-RE",
mp4decrypt_path: str = "mp4decrypt",
ffmpeg_path: str = "ffmpeg",
mp4box_path: str = "MP4Box",
download_mode: DownloadMode = DownloadMode.YTDLP,
remux_mode: RemuxMode = RemuxMode.FFMPEG,
cover_format: CoverFormat = CoverFormat.JPG,
template_folder_album: str = "{album_artist}/{album}",
template_folder_compilation: str = "Compilations/{album}",
template_file_single_disc: str = "{track:02d} {title}",
template_file_multi_disc: str = "{disc}-{track:02d} {title}",
template_folder_no_album: str = "{artist}/Unknown Album",
template_file_no_album: str = "{title}",
template_file_playlist: str = "Playlists/{playlist_artist}/{playlist_title}",
template_date: str = "%Y-%m-%dT%H:%M:%SZ",
exclude_tags: str = None,
cover_size: int = 1200,
truncate: int = None,
silent: bool = False,
):
self.apple_music_api = apple_music_api
self.itunes_api = itunes_api
self.output_path = output_path
self.temp_path = temp_path
self.wvd_path = wvd_path
self.nm3u8dlre_path = nm3u8dlre_path
self.mp4decrypt_path = mp4decrypt_path
self.ffmpeg_path = ffmpeg_path
self.mp4box_path = mp4box_path
self.download_mode = download_mode
self.remux_mode = remux_mode
self.cover_format = cover_format
self.template_folder_album = template_folder_album
self.template_folder_compilation = template_folder_compilation
self.template_file_single_disc = template_file_single_disc
self.template_file_multi_disc = template_file_multi_disc
self.template_folder_no_album = template_folder_no_album
self.template_file_no_album = template_file_no_album
self.template_file_playlist = template_file_playlist
self.template_date = template_date
self.exclude_tags = exclude_tags
self.cover_size = cover_size
self.truncate = truncate
self.silent = silent
self._set_binaries_path_full()
self._set_exclude_tags_list()
self._set_truncate()
self._set_subprocess_additional_args()
def _set_binaries_path_full(self):
self.nm3u8dlre_path_full = shutil.which(self.nm3u8dlre_path)
self.ffmpeg_path_full = shutil.which(self.ffmpeg_path)
self.mp4box_path_full = shutil.which(self.mp4box_path)
self.mp4decrypt_path_full = shutil.which(self.mp4decrypt_path)
def _set_exclude_tags_list(self):
self.exclude_tags_list = (
[i.lower() for i in self.exclude_tags.split(",")]
if self.exclude_tags is not None
else []
)
def _set_truncate(self):
if self.truncate is not None:
self.truncate = None if self.truncate < 4 else self.truncate
def _set_subprocess_additional_args(self):
if self.silent:
self.subprocess_additional_args = {
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
}
else:
self.subprocess_additional_args = {}
def set_cdm(self):
if self.wvd_path:
self.cdm = Cdm.from_device(Device.load(self.wvd_path))
else:
self.cdm = Cdm.from_device(Device.loads(HARDCODED_WVD))
def get_url_info(self, url: str) -> UrlInfo:
url_info = UrlInfo()
url_regex_result = re.search(
self.VALID_URL_RE,
url,
)
url_info.storefront = url_regex_result.group(1)
url_info.type = (
"song" if url_regex_result.group(5) else url_regex_result.group(2)
)
url_info.id = (
url_regex_result.group(5)
or url_regex_result.group(4)
or url_regex_result.group(3)
)
return url_info
def get_download_queue(self, url_info: UrlInfo) -> DownloadQueue:
return self._get_download_queue(url_info.type, url_info.id)
def _get_download_queue(self, url_type: str, id: str) -> DownloadQueue:
download_queue = DownloadQueue()
if url_type == "artist":
artist = self.apple_music_api.get_artist(id)
download_queue.tracks_metadata = list(
self.get_download_queue_from_artist(artist)
)
elif url_type == "song":
download_queue.tracks_metadata = [self.apple_music_api.get_song(id)]
elif url_type == "album":
album = self.apple_music_api.get_album(id)
download_queue.tracks_metadata = [
track for track in album["relationships"]["tracks"]["data"]
]
elif url_type == "playlist":
playlist = self.apple_music_api.get_playlist(id)
download_queue.playlist_attributes = playlist["attributes"]
download_queue.tracks_metadata = [
track
for track in self.apple_music_api.get_playlist(id)["relationships"][
"tracks"
]["data"]
]
elif url_type == "music-video":
download_queue.tracks_metadata = [self.apple_music_api.get_music_video(id)]
elif url_type == "post":
download_queue.tracks_metadata = [self.apple_music_api.get_post(id)]
return download_queue
def get_download_queue_from_artist(
self,
artist: dict,
) -> typing.Generator[dict, None, None]:
media_type = inquirer.select(
message=f'Select which type to download for artist "{artist["attributes"]["name"]}":',
choices=[
Choice(name="Albums", value="albums"),
Choice(
name="Music Videos",
value="music-videos",
),
],
validate=lambda result: artist["relationships"].get(result, {}).get("data"),
invalid_message="The artist doesn't have any items of this type",
).execute()
if media_type == "albums":
yield from self.select_albums_from_artist(
artist["relationships"]["albums"]["data"]
)
elif media_type == "music-videos":
yield from self.select_music_videos_from_artist(
artist["relationships"]["music-videos"]["data"]
)
def select_albums_from_artist(
self,
albums: list[dict],
) -> typing.Generator[dict, None, None]:
choices = [
Choice(
name=" | ".join(
[
f'{album["attributes"]["trackCount"]:03d}',
f'{album["attributes"]["releaseDate"]:<10}',
f'{album["attributes"].get("contentRating", "None").title():<8}',
f'{album["attributes"]["name"]}',
]
),
value=album,
)
for album in albums
]
selected = inquirer.select(
message="Select which albums to download: (Track Count | Release Date | Rating | Title)",
choices=choices,
multiselect=True,
).execute()
for album in selected:
for track in self.apple_music_api.get_album(album["id"])["relationships"][
"tracks"
]["data"]:
yield track
def select_music_videos_from_artist(
self,
music_videos: list[dict],
) -> typing.Generator[dict, None, None]:
choices = [
Choice(
name=" | ".join(
[
self.millis_to_min_sec(
music_video["attributes"]["durationInMillis"]
),
f'{music_video["attributes"].get("contentRating", "None").title():<8}',
music_video["attributes"]["name"],
],
),
value=music_video,
)
for music_video in music_videos
]
selected = inquirer.select(
message="Select which music videos to download: (Duration | Rating | Title)",
choices=choices,
multiselect=True,
).execute()
for music_video in selected:
yield music_video
def get_playlist_tags(
self,
playlist_attributes: dict,
playlist_track: int,
) -> dict:
tags = {
"playlist_artist": playlist_attributes.get("curatorName", "Apple Music"),
"playlist_id": playlist_attributes["playParams"]["id"],
"playlist_title": playlist_attributes["name"],
"playlist_track": playlist_track,
}
return tags
def get_playlist_file_path(
self,
tags: dict,
):
template_file = self.template_file_playlist.split("/")
return Path(
self.output_path,
*[
self.get_sanitized_string(i.format(**tags), True)
for i in template_file[0:-1]
],
*[
self.get_sanitized_string(template_file[-1].format(**tags), False)
+ ".m3u8"
],
)
def update_playlist_file(
self,
playlist_file_path: Path,
final_path: Path,
playlist_track: int,
):
playlist_file_path.parent.mkdir(parents=True, exist_ok=True)
playlist_file_path_parent_parts_len = len(playlist_file_path.parent.parts)
output_path_parts_len = len(self.output_path.parts)
final_path_relative = Path(
("../" * (playlist_file_path_parent_parts_len - output_path_parts_len)),
*final_path.parts[output_path_parts_len:],
)
playlist_file_lines = (
playlist_file_path.open("r", encoding="utf8").readlines()
if playlist_file_path.exists()
else []
)
if len(playlist_file_lines) < playlist_track:
playlist_file_lines.extend(
"\n" for _ in range(playlist_track - len(playlist_file_lines))
)
playlist_file_lines[playlist_track - 1] = final_path_relative.as_posix() + "\n"
with playlist_file_path.open("w", encoding="utf8") as playlist_file:
playlist_file.writelines(playlist_file_lines)
@staticmethod
def millis_to_min_sec(millis) -> str:
minutes, seconds = divmod(millis // 1000, 60)
return f"{minutes:02d}:{seconds:02d}"
def sanitize_date(self, date: str) -> datetime.datetime:
return datetime.datetime.fromisoformat(date[:-1]).strftime(self.template_date)
def get_decryption_key(self, pssh: str, track_id: str) -> str:
try:
pssh_obj = PSSH(pssh.split(",")[-1])
cdm_session = self.cdm.open()
challenge = base64.b64encode(
self.cdm.get_license_challenge(cdm_session, pssh_obj)
).decode()
license = self.apple_music_api.get_widevine_license(
track_id,
pssh,
challenge,
)
self.cdm.parse_license(cdm_session, license)
decryption_key = next(
i for i in self.cdm.get_keys(cdm_session) if i.type == "CONTENT"
).key.hex()
finally:
self.cdm.close(cdm_session)
return decryption_key
def download(self, path: Path, stream_url: str):
if self.download_mode == DownloadMode.YTDLP:
self.download_ytdlp(path, stream_url)
elif self.download_mode == DownloadMode.NM3U8DLRE:
self.download_nm3u8dlre(path, stream_url)
def download_ytdlp(self, path: Path, stream_url: str):
with YoutubeDL(
{
"quiet": True,
"no_warnings": True,
"outtmpl": str(path),
"allow_unplayable_formats": True,
"fixup": "never",
"allowed_extractors": ["generic"],
"noprogress": self.silent,
}
) as ydl:
ydl.download(stream_url)
def download_nm3u8dlre(self, path: Path, stream_url: str):
path.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
[
self.nm3u8dlre_path_full,
stream_url,
"--binary-merge",
"--no-log",
"--log-level",
"off",
"--ffmpeg-binary-path",
self.ffmpeg_path_full,
"--save-name",
path.stem,
"--save-dir",
path.parent,
"--tmp-dir",
path.parent,
],
check=True,
**self.subprocess_additional_args,
)
def get_sanitized_string(self, dirty_string: str, is_folder: bool) -> str:
dirty_string = re.sub(
self.ILLEGAL_CHARS_RE,
self.ILLEGAL_CHAR_REPLACEMENT,
dirty_string,
)
if is_folder:
dirty_string = dirty_string[: self.truncate]
if dirty_string.endswith("."):
dirty_string = dirty_string[:-1] + self.ILLEGAL_CHAR_REPLACEMENT
else:
if self.truncate is not None:
dirty_string = dirty_string[: self.truncate - 4]
return dirty_string.strip()
def get_final_path(self, tags: dict, file_extension: str) -> Path:
if tags.get("album"):
template_folder = (
self.template_folder_compilation.split("/")
if tags.get("compilation")
else self.template_folder_album.split("/")
)
template_file = (
self.template_file_multi_disc.split("/")
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
return Path(
self.output_path,
*[
self.get_sanitized_string(i.format(**tags), True)
for i in template_final[0:-1]
],
(
self.get_sanitized_string(template_final[-1].format(**tags), False)
+ file_extension
),
)
def get_cover_file_extension(self, cover_url: str) -> str | None:
cover_bytes = self.get_url_response_bytes(cover_url)
if cover_bytes is None:
return None
image_obj = Image.open(io.BytesIO(self.get_url_response_bytes(cover_url)))
image_format = image_obj.format.lower()
return IMAGE_FILE_EXTENSION_MAP.get(image_format, f".{image_format}")
def get_cover_url(self, metadata: dict) -> str:
if self.cover_format == CoverFormat.RAW:
return self._get_raw_cover_url(metadata["attributes"]["artwork"]["url"])
return self._get_cover_url(metadata["attributes"]["artwork"]["url"])
def _get_raw_cover_url(self, cover_url_template: str) -> str:
return re.sub(
r"image/thumb/",
"",
re.sub(
r"is1-ssl",
"a1",
re.sub(
r"/\{w\}x\{h\}([a-z]{2})\.jpg",
"",
cover_url_template,
),
),
)
def _get_cover_url(self, cover_url_template: str) -> str:
return re.sub(
r"\{w\}x\{h\}([a-z]{2})\.jpg",
f"{self.cover_size}x{self.cover_size}bb.{self.cover_format.value}",
cover_url_template,
)
@staticmethod
@functools.lru_cache()
def get_url_response_bytes(url: str) -> bytes:
response = requests.get(url)
if response.status_code == 200:
return response.content
elif response.status_code == 404:
return None
else:
raise_response_exception(response)
return response.content
def apply_tags(
self,
path: Path,
tags: dict,
cover_url: str,
):
to_apply_tags = [
tag_name
for tag_name in tags.keys()
if tag_name not in self.exclude_tags_list
]
mp4_tags = {}
for tag_name in to_apply_tags:
if tag_name in ("disc", "disc_total"):
if mp4_tags.get("disk") is None:
mp4_tags["disk"] = [[0, 0]]
if tag_name == "disc":
mp4_tags["disk"][0][0] = tags[tag_name]
elif tag_name == "disc_total":
mp4_tags["disk"][0][1] = tags[tag_name]
elif tag_name in ("track", "track_total"):
if mp4_tags.get("trkn") is None:
mp4_tags["trkn"] = [[0, 0]]
if tag_name == "track":
mp4_tags["trkn"][0][0] = tags[tag_name]
elif tag_name == "track_total":
mp4_tags["trkn"][0][1] = tags[tag_name]
elif tag_name == "compilation":
mp4_tags["cpil"] = tags["compilation"]
elif tag_name == "gapless":
mp4_tags["pgap"] = tags["gapless"]
elif (
MP4_TAGS_MAP.get(tag_name) is not None
and tags.get(tag_name) is not None
):
mp4_tags[MP4_TAGS_MAP[tag_name]] = [tags[tag_name]]
if (
"cover" not in self.exclude_tags_list
and self.cover_format != CoverFormat.RAW
):
cover_bytes = self.get_url_response_bytes(cover_url)
if cover_bytes is not None:
mp4_tags["covr"] = [
MP4Cover(
self.get_url_response_bytes(cover_url),
imageformat=(
MP4Cover.FORMAT_JPEG
if self.cover_format == CoverFormat.JPG
else MP4Cover.FORMAT_PNG
),
)
]
mp4 = MP4(path)
mp4.clear()
mp4.update(mp4_tags)
mp4.save()
def move_to_output_path(
self,
remuxed_path: Path,
final_path: Path,
):
final_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(remuxed_path, final_path)
@functools.lru_cache()
def save_cover(self, cover_path: Path, cover_url: str):
cover_path.parent.mkdir(parents=True, exist_ok=True)
cover_path.write_bytes(self.get_url_response_bytes(cover_url))
def cleanup_temp_path(self):
shutil.rmtree(self.temp_path)
+8
View File
@@ -0,0 +1,8 @@
from .downloader import AppleMusicDownloader
from .downloader_base import AppleMusicBaseDownloader
from .downloader_music_video import AppleMusicMusicVideoDownloader
from .downloader_song import AppleMusicSongDownloader
from .downloader_uploaded_video import AppleMusicUploadedVideoDownloader
from .enums import *
from .exceptions import *
from .types import *
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
import re
TEMP_PATH_TEMPLATE = "gamdl_temp_{}"
ILLEGAL_CHARS_RE = r'[\\/:*?"<>|;]'
ILLEGAL_CHAR_REPLACEMENT = "_"
SONG_MEDIA_TYPE = {"song", "songs", "library-songs"}
ALBUM_MEDIA_TYPE = {"album", "albums", "library-albums"}
MUSIC_VIDEO_MEDIA_TYPE = {"music-video", "music-videos", "library-music-videos"}
ARTIST_MEDIA_TYPE = {"artist", "artists", "library-artists"}
UPLOADED_VIDEO_MEDIA_TYPE = {"post", "uploaded-videos"}
PLAYLIST_MEDIA_TYPE = {"playlist", "playlists", "library-playlists"}
VALID_URL_PATTERN = re.compile(
r"https://music\.apple\.com"
r"(?:"
r"/(?P<storefront>[a-z]{2})"
r"/(?P<type>artist|album|playlist|song|music-video|post)"
r"(?:/(?P<slug>[^\s/]+))?"
r"/(?P<id>[0-9]+|pl\.[0-9a-z]{32}|pl\.u-[a-zA-Z0-9]+)"
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]+|l\.[a-zA-Z0-9]+)"
r")"
)
+611
View File
@@ -0,0 +1,611 @@
import asyncio
import typing
from pathlib import Path
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from ..api.exceptions import ApiError
from ..interface import AppleMusicInterface
from ..utils import safe_gather
from .constants import (
ALBUM_MEDIA_TYPE,
ARTIST_MEDIA_TYPE,
MUSIC_VIDEO_MEDIA_TYPE,
PLAYLIST_MEDIA_TYPE,
SONG_MEDIA_TYPE,
UPLOADED_VIDEO_MEDIA_TYPE,
VALID_URL_PATTERN,
)
from .downloader_base import AppleMusicBaseDownloader
from .downloader_music_video import AppleMusicMusicVideoDownloader
from .downloader_song import AppleMusicSongDownloader
from .downloader_uploaded_video import AppleMusicUploadedVideoDownloader
from .enums import DownloadMode, RemuxMode
from .exceptions import (
ExecutableNotFound,
FormatNotAvailable,
MediaFileExists,
NotStreamable,
SyncedLyricsOnly,
UnsupportedMediaType,
)
from .types import DownloadItem, UrlInfo
class AppleMusicDownloader:
def __init__(
self,
interface: AppleMusicInterface,
base_downloader: AppleMusicBaseDownloader,
song_downloader: AppleMusicSongDownloader,
music_video_downloader: AppleMusicMusicVideoDownloader,
uploaded_video_downloader: AppleMusicUploadedVideoDownloader,
skip_music_videos: bool = False,
skip_processing: bool = False,
flat_filter: typing.Callable = None,
):
self.interface = interface
self.base_downloader = base_downloader
self.song_downloader = song_downloader
self.music_video_downloader = music_video_downloader
self.uploaded_video_downloader = uploaded_video_downloader
self.skip_music_videos = skip_music_videos
self.skip_processing = skip_processing
self.flat_filter = flat_filter
async def get_single_download_item(
self,
media_metadata: dict,
playlist_metadata: dict = None,
) -> DownloadItem:
if self.flat_filter:
flat_filter_result = self.flat_filter(media_metadata)
if asyncio.iscoroutine(flat_filter_result):
flat_filter_result = await flat_filter_result
if flat_filter_result:
return DownloadItem(
media_metadata=media_metadata,
playlist_metadata=playlist_metadata,
flat_filter_result=flat_filter_result,
)
return await self.get_single_download_item_no_filter(
media_metadata,
playlist_metadata,
)
async def get_single_download_item_no_filter(
self,
media_metadata: dict,
playlist_metadata: dict = None,
) -> DownloadItem:
try:
if not self.base_downloader.is_media_streamable(
media_metadata,
):
raise NotStreamable(media_metadata["id"])
if media_metadata["type"] in SONG_MEDIA_TYPE:
if not self.song_downloader:
raise UnsupportedMediaType(media_metadata["type"])
download_item = await self.song_downloader.get_download_item(
media_metadata,
playlist_metadata,
)
if media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE:
if not self.music_video_downloader:
raise UnsupportedMediaType(media_metadata["type"])
download_item = await self.music_video_downloader.get_download_item(
media_metadata,
playlist_metadata,
)
if media_metadata["type"] in UPLOADED_VIDEO_MEDIA_TYPE:
if not self.uploaded_video_downloader:
raise UnsupportedMediaType(media_metadata["type"])
download_item = await self.uploaded_video_downloader.get_download_item(
media_metadata,
)
except Exception as e:
download_item = DownloadItem(
media_metadata=media_metadata,
playlist_metadata=playlist_metadata,
error=e,
)
return download_item
async def get_collection_download_items(
self,
collection_metadata: dict,
) -> list[DownloadItem]:
tracks_metadata = collection_metadata["relationships"]["tracks"]["data"]
async for extended_data in self.interface.apple_music_api.extend_api_data(
collection_metadata["relationships"]["tracks"],
):
tracks_metadata.extend(extended_data["data"])
tasks = [
self.get_single_download_item(
media_metadata,
(
collection_metadata
if collection_metadata["type"] in PLAYLIST_MEDIA_TYPE
else None
),
)
for media_metadata in tracks_metadata
]
download_items = await safe_gather(*tasks)
return download_items
async def get_artist_download_items(
self,
artist_metadata: dict,
) -> list[DownloadItem]:
media_type = await inquirer.select(
message=f'Select which type to download for artist "{artist_metadata["attributes"]["name"]}":',
choices=[
Choice(
name="Main Albums",
value=["views", "full-albums"],
),
Choice(
name="Compilations Albums",
value=["views", "compilation-albums"],
),
Choice(
name="Live Albums",
value=["views", "live-albums"],
),
Choice(
name="Singles & EPs",
value=["views", "singles"],
),
Choice(
name="All Albums",
value=["relationships", "albums"],
),
Choice(
name="Top Songs",
value=["views", "top-songs"],
),
Choice(
name="Music Videos",
value=["relationships", "music-videos"],
),
],
validate=lambda result: artist_metadata.get(result[0], {})
.get(result[1], {})
.get("data"),
invalid_message="The artist doesn't have any items of this type",
).execute_async()
media_type, media_type_key = media_type
artist_metadata[media_type][media_type_key]["data"].extend(
[
extended_data
async for extended_data in self.interface.apple_music_api.extend_api_data(
artist_metadata[media_type][media_type_key],
)
]
)
selected_tracks = artist_metadata[media_type][media_type_key]["data"]
if media_type_key in {
"full-albums",
"compilation-albums",
"live-albums",
"singles",
"albums",
}:
return await self.get_artist_albums_download_items(selected_tracks)
elif media_type_key == "top-songs":
return await self.get_artist_songs_download_items(selected_tracks)
elif media_type_key == "music-videos":
return await self.get_artist_music_videos_download_items(selected_tracks)
async def get_artist_albums_download_items(
self,
albums_metadata: list[dict],
) -> list[DownloadItem]:
choices = [
Choice(
name=" | ".join(
[
f'{album["attributes"]["trackCount"]:03d}',
f'{album["attributes"]["releaseDate"]:<10}',
f'{album["attributes"].get("contentRating", "None").title():<8}',
f'{album["attributes"]["name"]}',
]
),
value=album,
)
for album in albums_metadata
if album.get("attributes")
]
selected = await inquirer.select(
message="Select which albums to download: (Track Count | Release Date | Rating | Title)",
choices=choices,
multiselect=True,
).execute_async()
download_items = []
album_tasks = [
self.interface.apple_music_api.get_album(album_metadata["id"])
for album_metadata in selected
]
album_responses = await safe_gather(*album_tasks)
track_tasks = [
self.get_collection_download_items(album_response["data"][0])
for album_response in album_responses
]
track_results = await safe_gather(*track_tasks)
for track_result in track_results:
download_items.extend(track_result)
return download_items
async def get_artist_music_videos_download_items(
self,
music_videos_metadata: list[dict],
) -> list[DownloadItem]:
choices = [
Choice(
name=" | ".join(
[
self.millis_to_min_sec(
music_video["attributes"]["durationInMillis"]
),
f'{music_video["attributes"].get("contentRating", "None").title():<8}',
music_video["attributes"]["name"],
],
),
value=music_video,
)
for music_video in music_videos_metadata
if music_video.get("attributes")
]
selected = await inquirer.select(
message="Select which music videos to download: (Duration | Rating | Title)",
choices=choices,
multiselect=True,
).execute_async()
music_video_tasks = [
self.get_single_download_item(
music_video_metadata,
)
for music_video_metadata in selected
]
download_items = await safe_gather(*music_video_tasks)
return download_items
async def get_artist_songs_download_items(
self,
songs_metadata: list[dict],
) -> list[DownloadItem]:
choices = [
Choice(
name=" | ".join(
[
self.millis_to_min_sec(song["attributes"]["durationInMillis"]),
f'{song["attributes"].get("contentRating", "None").title():<8}',
song["attributes"]["name"],
],
),
value=song,
)
for song in songs_metadata
if song.get("attributes")
]
selected = await inquirer.select(
message="Select which songs to download: (Duration | Rating | Title)",
choices=choices,
multiselect=True,
).execute_async()
song_tasks = [
self.get_single_download_item(
song_metadata,
)
for song_metadata in selected
]
download_items = await safe_gather(*song_tasks)
return download_items
def millis_to_min_sec(self, millis) -> str:
minutes, seconds = divmod(millis // 1000, 60)
return f"{minutes:02}:{seconds:02}"
def get_url_info(self, url: str) -> UrlInfo | None:
match = VALID_URL_PATTERN.match(url)
if not match:
return None
return UrlInfo(
**match.groupdict(),
)
async def get_download_queue(
self,
url_info: UrlInfo,
) -> list[DownloadItem] | None:
return await self._get_download_queue(
"song" if url_info.sub_id else url_info.type or url_info.library_type,
url_info.sub_id or url_info.id or url_info.library_id,
url_info.library_id is not None,
)
async def _get_download_queue(
self,
url_type: str,
id: str,
is_library: bool,
) -> list[DownloadItem] | None:
download_items = []
if url_type in ARTIST_MEDIA_TYPE:
try:
artist_response = await self.interface.apple_music_api.get_artist(
id,
)
except ApiError as e:
if e.status_code == 404:
return None
raise e
if artist_response is None:
return None
download_items = await self.get_artist_download_items(
artist_response["data"][0],
)
if url_type in SONG_MEDIA_TYPE:
try:
song_respose = await self.interface.apple_music_api.get_song(id)
except ApiError as e:
if e.status_code == 404:
return None
raise e
if song_respose is None:
return None
download_items.append(
await self.get_single_download_item(song_respose["data"][0])
)
if url_type in ALBUM_MEDIA_TYPE:
try:
if is_library:
album_response = (
await self.interface.apple_music_api.get_library_album(id)
)
else:
album_response = await self.interface.apple_music_api.get_album(id)
except ApiError as e:
if e.status_code == 404:
return None
raise e
if album_response is None:
return None
download_items = await self.get_collection_download_items(
album_response["data"][0],
)
if url_type in PLAYLIST_MEDIA_TYPE:
try:
if is_library:
playlist_response = (
await self.interface.apple_music_api.get_library_playlist(id)
)
else:
playlist_response = (
await self.interface.apple_music_api.get_playlist(id)
)
except ApiError as e:
if e.status_code == 404:
return None
raise e
if playlist_response is None:
return None
download_items = await self.get_collection_download_items(
playlist_response["data"][0],
)
if url_type in MUSIC_VIDEO_MEDIA_TYPE:
try:
music_video_response = (
await self.interface.apple_music_api.get_music_video(id)
)
except ApiError as e:
if e.status_code == 404:
return None
raise e
if music_video_response is None:
return None
download_items.append(
await self.get_single_download_item(music_video_response["data"][0])
)
if url_type in UPLOADED_VIDEO_MEDIA_TYPE:
try:
uploaded_video = (
await self.interface.apple_music_api.get_uploaded_video(id)
)
except ApiError as e:
if e.status_code == 404:
return None
raise e
if uploaded_video is None:
return None
download_items.append(
await self.get_single_download_item(uploaded_video["data"][0])
)
return download_items
async def download(
self,
download_item: DownloadItem,
) -> DownloadItem:
try:
if download_item.flat_filter_result:
download_item = await self.get_single_download_item_no_filter(
download_item.media_metadata,
download_item.playlist_metadata,
)
if download_item.error:
raise download_item.error
await self._initial_processing(download_item)
await self._download(download_item)
await self._final_processing(download_item)
return download_item
finally:
if isinstance(download_item, DownloadItem) and not self.skip_processing:
self.base_downloader.cleanup_temp(download_item.random_uuid)
async def _download(
self,
download_item: DownloadItem,
) -> None:
if (
self.song_downloader.synced_lyrics_only
and download_item.media_metadata["type"] not in SONG_MEDIA_TYPE
):
raise SyncedLyricsOnly()
if self.song_downloader.synced_lyrics_only:
return
if (
Path(download_item.final_path).exists()
and not self.base_downloader.overwrite
):
raise MediaFileExists(download_item.final_path)
if (
self.base_downloader.download_mode == DownloadMode.NM3U8DLRE
and not self.base_downloader.full_nm3u8dlre_path
):
raise ExecutableNotFound("N_m3u8DL-RE")
if download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE:
if (
self.music_video_downloader.remux_mode == RemuxMode.FFMPEG
and not self.base_downloader.full_ffmpeg_path
):
raise ExecutableNotFound("ffmpeg")
if (
self.music_video_downloader.remux_mode == RemuxMode.MP4BOX
and not self.base_downloader.full_mp4box_path
):
raise ExecutableNotFound("MP4Box")
if (
download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE
or self.music_video_downloader.remux_mode == RemuxMode.MP4BOX
) and not self.base_downloader.full_mp4decrypt_path:
raise ExecutableNotFound("mp4decrypt")
if (
not download_item.stream_info
or not download_item.stream_info.audio_track
or not download_item.stream_info.audio_track.stream_url
or (
(
not download_item.decryption_key
or not download_item.decryption_key.audio_track
or not download_item.decryption_key.audio_track.key
)
and not self.base_downloader.use_wrapper
)
):
raise FormatNotAvailable(download_item.media_metadata["id"])
if download_item.media_metadata["type"] in SONG_MEDIA_TYPE:
await self.song_downloader.download(download_item)
if download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE:
await self.music_video_downloader.download(download_item)
if download_item.media_metadata["type"] in UPLOADED_VIDEO_MEDIA_TYPE:
await self.uploaded_video_downloader.download(download_item)
async def _initial_processing(
self,
download_item: DownloadItem,
) -> None:
if self.skip_processing:
return
if download_item.cover_path and self.base_downloader.save_cover:
cover_bytes = await self.interface.get_cover_bytes(download_item.cover_url)
if cover_bytes and (
self.base_downloader.overwrite
or not Path(download_item.cover_path).exists()
):
self.base_downloader.write_cover_image(
cover_bytes,
download_item.cover_path,
)
if (
download_item.lyrics
and download_item.lyrics.synced
and not self.song_downloader.no_synced_lyrics
and (
self.base_downloader.overwrite
or not Path(download_item.synced_lyrics_path).exists()
)
):
self.song_downloader.write_synced_lyrics(
download_item.lyrics.synced,
download_item.synced_lyrics_path,
)
if download_item.playlist_tags and self.base_downloader.save_playlist:
self.base_downloader.update_playlist_file(
download_item.playlist_file_path,
download_item.final_path,
download_item.playlist_tags.playlist_track,
)
async def _final_processing(
self,
download_item: DownloadItem,
) -> None:
if self.skip_processing:
return
if download_item.staged_path and Path(download_item.staged_path).exists():
self.base_downloader.move_to_final_path(
download_item.staged_path,
download_item.final_path,
)
+428
View File
@@ -0,0 +1,428 @@
import asyncio
import re
import shutil
import uuid
from pathlib import Path
from mutagen.mp4 import MP4, MP4Cover
from pywidevine import Cdm, Device
from yt_dlp import YoutubeDL
from ..interface.enums import CoverFormat
from ..interface.types import MediaTags, PlaylistTags
from ..utils import CustomStringFormatter, async_subprocess
from .constants import ILLEGAL_CHAR_REPLACEMENT, ILLEGAL_CHARS_RE, TEMP_PATH_TEMPLATE
from .enums import DownloadMode
from .hardcoded_wvd import HARDCODED_WVD
class AppleMusicBaseDownloader:
def __init__(
self,
output_path: str = "./Apple Music",
temp_path: str = ".",
wvd_path: str = None,
overwrite: bool = False,
save_cover: bool = False,
save_playlist: bool = False,
nm3u8dlre_path: str = "N_m3u8DL-RE",
mp4decrypt_path: str = "mp4decrypt",
ffmpeg_path: str = "ffmpeg",
mp4box_path: str = "MP4Box",
use_wrapper: bool = False,
wrapper_decrypt_ip: str = "127.0.0.1:10020",
download_mode: DownloadMode = DownloadMode.YTDLP,
cover_format: CoverFormat = CoverFormat.JPG,
album_folder_template: str = "{album_artist}/{album}",
compilation_folder_template: str = "Compilations/{album}",
no_album_folder_template: str = "{artist}/Unknown Album",
single_disc_file_template: str = "{track:02d} {title}",
multi_disc_file_template: str = "{disc}-{track:02d} {title}",
no_album_file_template: str = "{title}",
playlist_file_template: str = "Playlists/{playlist_artist}/{playlist_title}",
date_tag_template: str = "%Y-%m-%dT%H:%M:%SZ",
exclude_tags: list[str] = None,
cover_size: int = 1200,
truncate: int = None,
silent: bool = False,
):
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.nm3u8dlre_path = nm3u8dlre_path
self.mp4decrypt_path = mp4decrypt_path
self.ffmpeg_path = ffmpeg_path
self.mp4box_path = mp4box_path
self.use_wrapper = use_wrapper
self.wrapper_decrypt_ip = wrapper_decrypt_ip
self.download_mode = download_mode
self.cover_format = cover_format
self.album_folder_template = album_folder_template
self.compilation_folder_template = compilation_folder_template
self.no_album_folder_template = no_album_folder_template
self.single_disc_file_template = single_disc_file_template
self.multi_disc_file_template = multi_disc_file_template
self.no_album_file_template = no_album_file_template
self.playlist_file_template = playlist_file_template
self.date_tag_template = date_tag_template
self.exclude_tags = exclude_tags
self.cover_size = cover_size
self.truncate = truncate
self.silent = silent
self.initialize()
def initialize(self):
self._initialize_binary_paths()
self._initialize_cdm()
def _initialize_binary_paths(self):
self.full_nm3u8dlre_path = shutil.which(self.nm3u8dlre_path)
self.full_mp4decrypt_path = shutil.which(self.mp4decrypt_path)
self.full_ffmpeg_path = shutil.which(self.ffmpeg_path)
self.full_mp4box_path = shutil.which(self.mp4box_path)
def _initialize_cdm(self):
if self.wvd_path:
self.cdm = Cdm.from_device(Device.load(self.wvd_path))
else:
self.cdm = Cdm.from_device(Device.loads(HARDCODED_WVD))
self.cdm.MAX_NUM_OF_SESSIONS = float("inf")
def get_random_uuid(self) -> str:
return uuid.uuid4().hex[:8]
def is_media_streamable(
self,
media_metadata: dict,
) -> bool:
return bool(media_metadata["attributes"].get("playParams"))
def get_playlist_tags(
self,
playlist_metadata: dict,
media_metadata: dict,
) -> PlaylistTags:
playlist_track = (
playlist_metadata["relationships"]["tracks"]["data"].index(media_metadata)
+ 1
)
return PlaylistTags(
playlist_artist=playlist_metadata["attributes"].get(
"curatorName", "Unknown"
),
playlist_id=playlist_metadata["attributes"]["playParams"]["id"],
playlist_title=playlist_metadata["attributes"]["name"],
playlist_track=playlist_track,
)
def get_temp_path(
self,
media_id: str,
folder_tag: str,
file_tag: str,
file_extension: str,
) -> str:
return str(
Path(self.temp_path)
/ TEMP_PATH_TEMPLATE.format(folder_tag)
/ (f"{media_id}_{file_tag}" + file_extension)
)
def sanitize_string(
self,
dirty_string: str,
file_ext: str = None,
) -> str:
sanitized_string = re.sub(
ILLEGAL_CHARS_RE,
ILLEGAL_CHAR_REPLACEMENT,
dirty_string,
)
if file_ext is None:
sanitized_string = sanitized_string[: self.truncate]
if sanitized_string.endswith("."):
sanitized_string = sanitized_string[:-1] + ILLEGAL_CHAR_REPLACEMENT
else:
if self.truncate is not None:
sanitized_string = sanitized_string[: self.truncate - len(file_ext)]
sanitized_string += file_ext
return sanitized_string.strip()
def get_final_path(
self,
tags: MediaTags,
file_extension: str,
playlist_tags: PlaylistTags | None,
) -> str:
if tags.album:
template_folder_parts = (
self.compilation_folder_template.split("/")
if tags.compilation
else self.album_folder_template.split("/")
)
else:
template_folder_parts = self.no_album_folder_template.split("/")
if tags.album:
template_file_parts = (
self.multi_disc_file_template.split("/")
if isinstance(tags.disc_total, int) and tags.disc_total > 1
else self.single_disc_file_template.split("/")
)
else:
template_file_parts = self.no_album_file_template.split("/")
template_parts = template_folder_parts + template_file_parts
formatted_parts = []
for i, part in enumerate(template_parts):
is_folder = i < len(template_parts) - 1
formatted_part = CustomStringFormatter().format(
part,
album=(tags.album, "Unknown Album"),
album_artist=(tags.album_artist, "Unknown Artist"),
album_id=(tags.album_id, "Unknown Album ID"),
artist=(tags.artist, "Unknown Artist"),
artist_id=(tags.artist_id, "Unknown Artist ID"),
composer=(tags.composer, "Unknown Composer"),
composer_id=(tags.composer_id, "Unknown Composer ID"),
date=(tags.date, "Unknown Date"),
disc=(tags.disc, ""),
disc_total=(tags.disc_total, ""),
media_type=(tags.media_type, "Unknown Media Type"),
playlist_artist=(
(playlist_tags.playlist_artist if playlist_tags else None),
"Unknown Playlist Artist",
),
playlist_id=(
(playlist_tags.playlist_id if playlist_tags else None),
"Unknown Playlist ID",
),
playlist_title=(
(playlist_tags.playlist_title if playlist_tags else None),
"Unknown Playlist Title",
),
playlist_track=(
(playlist_tags.playlist_track if playlist_tags else None),
"",
),
title=(tags.title, "Unknown Title"),
title_id=(tags.title_id, "Unknown Title ID"),
track=(tags.track, ""),
track_total=(tags.track_total, ""),
)
sanitized_formatted_part = self.sanitize_string(
formatted_part,
file_extension if not is_folder else None,
)
formatted_parts.append(sanitized_formatted_part)
return str(Path(self.output_path, *formatted_parts))
async def download_stream(self, stream_url: str, download_path: str):
if self.download_mode == DownloadMode.YTDLP:
await self.download_ytdlp(stream_url, download_path)
if self.download_mode == DownloadMode.NM3U8DLRE:
await self.download_nm3u8dlre(stream_url, download_path)
async def download_ytdlp(self, stream_url: str, download_path: str) -> None:
await asyncio.to_thread(
self._download_ytdlp,
stream_url,
download_path,
)
def _download_ytdlp(self, stream_url: str, download_path: str) -> None:
with YoutubeDL(
{
"quiet": True,
"no_warnings": True,
"outtmpl": download_path,
"allow_unplayable_formats": True,
"overwrites": True,
"fixup": "never",
"noprogress": self.silent,
"allowed_extractors": ["generic"],
}
) as ydl:
ydl.download(stream_url)
async def download_nm3u8dlre(self, stream_url: str, download_path: str):
download_path_obj = Path(download_path)
download_path_obj.parent.mkdir(parents=True, exist_ok=True)
await async_subprocess(
self.full_nm3u8dlre_path,
stream_url,
"--binary-merge",
"--no-log",
"--log-level",
"off",
"--ffmpeg-binary-path",
self.full_ffmpeg_path,
"--save-name",
download_path_obj.stem,
"--save-dir",
download_path_obj.parent,
"--tmp-dir",
download_path_obj.parent,
silent=self.silent,
)
async def apply_tags(
self,
media_path: Path,
tags: MediaTags,
cover_bytes: bytes | None,
extra_tags: dict | None = None,
):
exclude_tags = self.exclude_tags or []
filtered_tags = MediaTags(
**{
k: v
for k, v in tags.__dict__.items()
if v is not None and k not in exclude_tags
}
)
mp4_tags = filtered_tags.as_mp4_tags(self.date_tag_template)
skip_tagging = "all" in exclude_tags
await asyncio.to_thread(
self.apply_mp4_tags,
media_path,
mp4_tags,
cover_bytes,
skip_tagging,
extra_tags,
)
def apply_mp4_tags(
self,
media_path: Path,
tags: dict,
cover_bytes: bytes | None,
skip_tagging: bool,
extra_tags: dict | None,
):
mp4 = MP4(media_path)
mp4.clear()
if not skip_tagging:
if cover_bytes is not None:
mp4["covr"] = [
MP4Cover(
data=cover_bytes,
imageformat=(
MP4Cover.FORMAT_JPEG
if self.cover_format == CoverFormat.JPG
else MP4Cover.FORMAT_PNG
),
)
]
mp4.update(tags)
if extra_tags:
mp4.update(extra_tags)
mp4.save()
async def _apply_cover(
self,
mp4: MP4,
cover_bytes: bytes | None,
) -> None:
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_final_path(self, stage_path: str, final_path: str) -> None:
Path(final_path).parent.mkdir(parents=True, exist_ok=True)
shutil.move(stage_path, final_path)
def write_cover_image(
self,
cover_bytes: bytes,
cover_path: str,
) -> None:
Path(cover_path).parent.mkdir(parents=True, exist_ok=True)
Path(cover_path).write_bytes(cover_bytes)
def get_playlist_file_path(
self,
tags: PlaylistTags,
) -> str:
template_file_parts = self.playlist_file_template.split("/")
formatted_parts = []
for i, part in enumerate(template_file_parts):
is_folder = i < len(template_file_parts) - 1
formatted_part = CustomStringFormatter().format(
part,
playlist_artist=(tags.playlist_artist, "Unknown Playlist Artist"),
playlist_id=(tags.playlist_id, "Unknown Playlist ID"),
playlist_title=(tags.playlist_title, "Unknown Playlist Title"),
playlist_track=(tags.playlist_track, ""),
)
file_ext = None if is_folder else ".m3u8"
sanitized_formatted_part = self.sanitize_string(
formatted_part,
file_ext,
)
formatted_parts.append(sanitized_formatted_part)
return str(Path(self.output_path, *formatted_parts))
def update_playlist_file(
self,
playlist_file_path: str,
final_path: str,
playlist_track: int,
) -> None:
playlist_file_path_obj = Path(playlist_file_path)
final_path_obj = Path(final_path)
output_dir_obj = Path(self.output_path)
playlist_file_path_obj.parent.mkdir(parents=True, exist_ok=True)
playlist_file_path_parent_parts_len = len(playlist_file_path_obj.parent.parts)
output_path_parts_len = len(output_dir_obj.parts)
final_path_relative = Path(
("../" * (playlist_file_path_parent_parts_len - output_path_parts_len)),
*final_path_obj.parts[output_path_parts_len:],
)
playlist_file_lines = (
playlist_file_path_obj.open("r", encoding="utf8").readlines()
if playlist_file_path_obj.exists()
else []
)
if len(playlist_file_lines) < playlist_track:
playlist_file_lines.extend(
"\n" for _ in range(playlist_track - len(playlist_file_lines))
)
playlist_file_lines[playlist_track - 1] = final_path_relative.as_posix() + "\n"
with playlist_file_path_obj.open("w", encoding="utf8") as playlist_file:
playlist_file.writelines(playlist_file_lines)
def cleanup_temp(self, random_uuid: str) -> None:
temp_folder = Path(self.temp_path) / TEMP_PATH_TEMPLATE.format(random_uuid)
if temp_folder.exists():
shutil.rmtree(temp_folder)
+285
View File
@@ -0,0 +1,285 @@
from pathlib import Path
from ..interface.enums import CoverFormat, MusicVideoCodec, MusicVideoResolution
from ..interface.interface_music_video import AppleMusicMusicVideoInterface
from ..interface.types import DecryptionKeyAv
from ..utils import async_subprocess
from .downloader_base import AppleMusicBaseDownloader
from .enums import RemuxFormatMusicVideo, RemuxMode
from .types import DownloadItem
class AppleMusicMusicVideoDownloader(AppleMusicBaseDownloader):
def __init__(
self,
base_downloader: AppleMusicBaseDownloader,
interface: AppleMusicMusicVideoInterface,
codec_priority: list[MusicVideoCodec] = [
MusicVideoCodec.H264,
MusicVideoCodec.H265,
],
remux_mode: RemuxMode = RemuxMode.FFMPEG,
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
resolution: MusicVideoResolution = MusicVideoResolution.R1080P,
):
self.__dict__.update(base_downloader.__dict__)
self.interface = interface
self.codec_priority = codec_priority
self.remux_mode = remux_mode
self.remux_format = remux_format
self.resolution = resolution
async def remux_mp4box(
self,
input_path_video: str,
input_path_audio: str,
output_path: str,
):
await async_subprocess(
self.full_mp4box_path,
"-quiet",
"-add",
input_path_audio,
"-add",
input_path_video,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
output_path,
silent=self.silent,
)
async def remux_ffmpeg(
self,
input_path_video: str,
input_path_audio: str,
output_path: str,
decryption_key: str = None,
):
if decryption_key:
key = [
"-decryption_key",
decryption_key,
]
else:
key = []
await async_subprocess(
self.full_ffmpeg_path,
"-loglevel",
"error",
"-y",
*key,
"-i",
input_path_video,
"-i",
input_path_audio,
"-c",
"copy",
"-c:s",
"mov_text",
"-movflags",
"+faststart",
output_path,
silent=self.silent,
)
async def decrypt_mp4decrypt(
self,
input_path: str,
output_path: str,
decryption_key: str,
):
await async_subprocess(
self.full_mp4decrypt_path,
"--key",
f"1:{decryption_key}",
input_path,
output_path,
silent=self.silent,
)
async def stage(
self,
encrypted_path_video: str,
encrypted_path_audio: str,
decrypted_path_video: str,
decrypted_path_audio: str,
staged_path: str,
decryption_key: DecryptionKeyAv,
):
await self.decrypt_mp4decrypt(
encrypted_path_video,
decrypted_path_video,
decryption_key.video_track.key,
)
await self.decrypt_mp4decrypt(
encrypted_path_audio,
decrypted_path_audio,
decryption_key.audio_track.key,
)
if self.remux_mode == RemuxMode.MP4BOX:
await self.remux_mp4box(
decrypted_path_video,
decrypted_path_audio,
staged_path,
)
else:
await self.remux_ffmpeg(
decrypted_path_video,
decrypted_path_audio,
staged_path,
)
def get_cover_path(
self,
final_path: str,
file_extension: str,
) -> str:
return str(Path(final_path).with_suffix(file_extension))
async def get_download_item(
self,
music_video_metadata: dict,
playlist_metadata: dict = None,
) -> DownloadItem:
download_item = DownloadItem()
download_item.media_metadata = music_video_metadata
download_item.playlist_metadata = playlist_metadata
music_video_id = self.interface.get_media_id_of_library_media(
music_video_metadata,
)
itunes_page_metadata = await self.interface.get_itunes_page_metadata(
music_video_metadata,
)
download_item.media_tags = await self.interface.get_tags(
music_video_metadata,
itunes_page_metadata,
)
if playlist_metadata:
download_item.playlist_tags = self.get_playlist_tags(
playlist_metadata,
music_video_metadata,
)
download_item.playlist_file_path = self.get_playlist_file_path(
download_item.playlist_tags,
)
download_item.stream_info = await self.interface.get_stream_info(
music_video_metadata,
itunes_page_metadata,
self.codec_priority,
self.resolution,
)
download_item.decryption_key = await self.interface.get_decryption_key(
download_item.stream_info,
self.cdm,
)
download_item.random_uuid = self.get_random_uuid()
download_item.staged_path = self.get_temp_path(
music_video_id,
download_item.random_uuid,
"staged",
(
"."
+ (
"mp4"
if self.remux_format == RemuxFormatMusicVideo.MP4
else download_item.stream_info.file_format.value
)
),
)
download_item.final_path = self.get_final_path(
download_item.media_tags,
Path(download_item.staged_path).suffix,
playlist_metadata,
)
download_item.cover_url_template = self.interface.get_cover_url_template(
music_video_metadata,
self.cover_format,
)
download_item.cover_url = self.interface.get_cover_url(
download_item.cover_url_template,
self.cover_size,
self.cover_format,
)
cover_file_extension = await self.interface.get_cover_file_extension(
download_item.cover_url,
self.cover_format,
)
if cover_file_extension:
download_item.cover_path = self.get_cover_path(
download_item.final_path,
cover_file_extension,
)
return download_item
async def download(
self,
download_item: DownloadItem,
) -> None:
encrypted_path_video = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_uuid,
"encrypted_video",
".mp4",
)
encrypted_path_audio = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_uuid,
"encrypted_audio",
".m4a",
)
await self.download_stream(
download_item.stream_info.video_track.stream_url,
encrypted_path_video,
)
await self.download_stream(
download_item.stream_info.audio_track.stream_url,
encrypted_path_audio,
)
decrypted_path_video = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_uuid,
"decrypted_video",
".mp4",
)
decrypted_path_audio = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_uuid,
"decrypted_audio",
".m4a",
)
await self.stage(
encrypted_path_video,
encrypted_path_audio,
decrypted_path_video,
decrypted_path_audio,
download_item.staged_path,
download_item.decryption_key,
)
cover_bytes = (
await self.interface.get_cover_bytes(download_item.cover_url)
if self.cover_format != CoverFormat.RAW
else None
)
await self.apply_tags(
download_item.staged_path,
download_item.media_tags,
cover_bytes,
)
+250
View File
@@ -0,0 +1,250 @@
from pathlib import Path
from ..interface.enums import CoverFormat, SongCodec, SyncedLyricsFormat
from ..interface.interface_song import AppleMusicSongInterface
from ..interface.types import DecryptionKeyAv
from .amdecrypt import decrypt_file, decrypt_file_hex
from .downloader_base import AppleMusicBaseDownloader
from .types import DownloadItem
class AppleMusicSongDownloader(AppleMusicBaseDownloader):
def __init__(
self,
base_downloader: AppleMusicBaseDownloader,
interface: AppleMusicSongInterface,
codec: SongCodec = SongCodec.AAC_LEGACY,
synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC,
no_synced_lyrics: bool = False,
synced_lyrics_only: bool = False,
use_album_date: bool = False,
fetch_extra_tags: bool = False,
):
self.__dict__.update(base_downloader.__dict__)
self.interface = interface
self.codec = codec
self.synced_lyrics_format = synced_lyrics_format
self.no_synced_lyrics = no_synced_lyrics
self.synced_lyrics_only = synced_lyrics_only
self.use_album_date = use_album_date
self.fetch_extra_tags = fetch_extra_tags
async def get_download_item(
self,
song_metadata: dict,
playlist_metadata: dict = None,
) -> DownloadItem:
download_item = DownloadItem()
download_item.media_metadata = song_metadata
download_item.playlist_metadata = playlist_metadata
song_id = self.interface.get_media_id_of_library_media(song_metadata)
download_item.lyrics = await self.interface.get_lyrics(
song_metadata,
self.synced_lyrics_format,
)
webplayback = await self.interface.apple_music_api.get_webplayback(song_id)
download_item.media_tags = await self.interface.get_tags(
webplayback,
download_item.lyrics.unsynced if download_item.lyrics else None,
self.use_album_date,
)
if self.fetch_extra_tags:
download_item.extra_tags = await self.interface.get_extra_tags(
song_metadata,
)
if playlist_metadata:
download_item.playlist_tags = self.get_playlist_tags(
playlist_metadata,
song_metadata,
)
download_item.playlist_file_path = self.get_playlist_file_path(
download_item.playlist_tags,
)
download_item.final_path = self.get_final_path(
download_item.media_tags,
".m4a",
download_item.playlist_tags,
)
download_item.synced_lyrics_path = self.get_lyrics_synced_path(
download_item.final_path,
)
if self.synced_lyrics_only:
return download_item
if self.codec.is_legacy():
download_item.stream_info = await self.interface.get_stream_info_legacy(
webplayback,
self.codec,
)
download_item.decryption_key = (
await self.interface.get_decryption_key_legacy(
download_item.stream_info,
self.cdm,
)
)
else:
download_item.stream_info = await self.interface.get_stream_info(
song_metadata,
self.codec,
)
if (
not self.use_wrapper
and download_item.stream_info
and download_item.stream_info.audio_track.widevine_pssh
):
download_item.decryption_key = await self.interface.get_decryption_key(
download_item.stream_info,
self.cdm,
)
else:
download_item.decryption_key = None
download_item.cover_url_template = self.interface.get_cover_url_template(
song_metadata,
self.cover_format,
)
download_item.cover_url = self.interface.get_cover_url(
download_item.cover_url_template,
self.cover_size,
self.cover_format,
)
download_item.random_uuid = self.get_random_uuid()
if download_item.stream_info and download_item.stream_info.file_format:
download_item.staged_path = self.get_temp_path(
song_id,
download_item.random_uuid,
"staged",
"." + download_item.stream_info.file_format.value,
)
else:
download_item.staged_path = None
cover_file_extension = await self.interface.get_cover_file_extension(
download_item.cover_url,
self.cover_format,
)
if cover_file_extension:
download_item.cover_path = self.get_cover_path(
download_item.final_path,
cover_file_extension,
)
return download_item
async def decrypt_amdecrypt(
self,
input_path: str,
output_path: str,
media_id: str,
fairplay_key: str,
) -> None:
await decrypt_file(
self.wrapper_decrypt_ip,
media_id,
fairplay_key,
input_path,
output_path,
)
async def decrypt_amdecrypt_hex(
self,
input_path: str,
output_path: str,
decryption_key: str,
legacy: bool = False,
) -> None:
await decrypt_file_hex(
input_path,
output_path,
decryption_key,
legacy=legacy,
)
async def stage(
self,
encrypted_path: str,
staged_path: str,
decryption_key: DecryptionKeyAv,
codec: SongCodec,
media_id: str,
fairplay_key: str,
):
if self.use_wrapper:
await self.decrypt_amdecrypt(
encrypted_path,
staged_path,
media_id,
fairplay_key,
)
else:
await self.decrypt_amdecrypt_hex(
encrypted_path,
staged_path,
decryption_key.audio_track.key,
legacy=codec.is_legacy(),
)
def get_lyrics_synced_path(self, final_path: str) -> str:
return str(Path(final_path).with_suffix("." + self.synced_lyrics_format.value))
def get_cover_path(
self,
final_path: str,
file_extension: str,
) -> str:
return str(Path(final_path).parent / ("Cover" + file_extension))
def write_synced_lyrics(
self,
synced_lyrics: str,
lyrics_synced_path: str,
):
Path(lyrics_synced_path).parent.mkdir(parents=True, exist_ok=True)
Path(lyrics_synced_path).write_text(synced_lyrics, encoding="utf8")
async def download(
self,
download_item: DownloadItem,
) -> None:
if self.synced_lyrics_only:
return
encrypted_path = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_uuid,
"encrypted",
".m4a",
)
await self.download_stream(
download_item.stream_info.audio_track.stream_url,
encrypted_path,
)
await self.stage(
encrypted_path,
download_item.staged_path,
download_item.decryption_key,
self.codec,
download_item.media_metadata["id"],
download_item.stream_info.audio_track.fairplay_key,
)
cover_bytes = (
await self.interface.get_cover_bytes(download_item.cover_url)
if self.cover_format != CoverFormat.RAW
else None
)
await self.apply_tags(
download_item.staged_path,
download_item.media_tags,
cover_bytes,
download_item.extra_tags,
)
@@ -0,0 +1,107 @@
from pathlib import Path
from ..interface.enums import CoverFormat, UploadedVideoQuality
from ..interface.interface_uploaded_video import AppleMusicUploadedVideoInterface
from .downloader_base import AppleMusicBaseDownloader
from .types import DownloadItem
class AppleMusicUploadedVideoDownloader(AppleMusicBaseDownloader):
def __init__(
self,
base_downloader: AppleMusicBaseDownloader,
interface: AppleMusicUploadedVideoInterface,
quality: UploadedVideoQuality = UploadedVideoQuality.BEST,
):
self.__dict__.update(base_downloader.__dict__)
self.interface = interface
self.quality = quality
def get_cover_path(self, final_path: str, file_extension: str) -> str:
return str(Path(final_path).with_suffix(file_extension))
async def get_download_item(
self,
uploaded_video_metadata: dict,
) -> DownloadItem:
try:
return await self._get_download_item(
uploaded_video_metadata,
)
except Exception as e:
return DownloadItem(
media_metadata=uploaded_video_metadata,
error=e,
)
async def _get_download_item(
self,
uploaded_video_metadata: dict,
) -> DownloadItem:
download_item = DownloadItem()
download_item.media_metadata = uploaded_video_metadata
download_item.media_tags = self.interface.get_tags(
uploaded_video_metadata,
)
download_item.stream_info = await self.interface.get_stream_info(
uploaded_video_metadata,
self.quality,
)
download_item.random_uuid = self.get_random_uuid()
download_item.staged_path = self.get_temp_path(
uploaded_video_metadata["id"],
download_item.random_uuid,
"staged",
"." + download_item.stream_info.file_format.value,
)
download_item.final_path = self.get_final_path(
download_item.media_tags,
Path(download_item.staged_path).suffix,
None,
)
download_item.cover_url_template = self.interface.get_cover_url_template(
uploaded_video_metadata,
self.cover_format,
)
download_item.cover_url = self.interface.get_cover_url(
download_item.cover_url_template,
self.cover_size,
self.cover_format,
)
cover_file_extension = await self.interface.get_cover_file_extension(
download_item.cover_url,
self.cover_format,
)
if cover_file_extension:
download_item.cover_path = self.get_cover_path(
download_item.final_path,
cover_file_extension,
)
return download_item
async def download(
self,
download_item: DownloadItem,
) -> None:
await self.download_ytdlp(
download_item.stream_info.video_track.stream_url,
download_item.staged_path,
)
cover_bytes = (
await self.interface.get_cover_bytes(download_item.cover_url)
if self.cover_format != CoverFormat.RAW
else None
)
await self.apply_tags(
download_item.staged_path,
download_item.media_tags,
cover_bytes,
)
+16
View File
@@ -0,0 +1,16 @@
from enum import Enum
class DownloadMode(Enum):
YTDLP = "ytdlp"
NM3U8DLRE = "nm3u8dlre"
class RemuxMode(Enum):
FFMPEG = "ffmpeg"
MP4BOX = "mp4box"
class RemuxFormatMusicVideo(Enum):
M4V = "m4v"
MP4 = "mp4"
+31
View File
@@ -0,0 +1,31 @@
from ..utils import GamdlError
class MediaFileExists(GamdlError):
def __init__(self, media_path: str):
super().__init__(f"Media file already exists at path: {media_path}")
class NotStreamable(GamdlError):
def __init__(self, media_id: str):
super().__init__(f"Media ID is not streamable: {media_id}")
class FormatNotAvailable(GamdlError):
def __init__(self, media_id: str):
super().__init__(f"Requested format is not available for media ID: {media_id}")
class ExecutableNotFound(GamdlError):
def __init__(self, executable: str):
super().__init__(f"Executable not found: {executable}")
class SyncedLyricsOnly(GamdlError):
def __init__(self):
super().__init__("Only downloading synced lyrics is supported")
class UnsupportedMediaType(GamdlError):
def __init__(self, media_type: str):
super().__init__(f"Unsupported media type: {media_type}")
@@ -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=="""
+44
View File
@@ -0,0 +1,44 @@
from dataclasses import dataclass
from typing import Any
from ..interface.types import (
DecryptionKeyAv,
Lyrics,
MediaTags,
PlaylistTags,
StreamInfoAv,
)
@dataclass
class DownloadItem:
media_metadata: dict = None
playlist_metadata: dict = None
random_uuid: str = None
lyrics: Lyrics = None
media_tags: MediaTags = None
extra_tags: dict = None
playlist_tags: PlaylistTags = None
stream_info: StreamInfoAv = None
decryption_key: DecryptionKeyAv = None
cover_url_template: str = None
cover_url: str = None
staged_path: str = None
final_path: str = None
playlist_file_path: str = None
synced_lyrics_path: str = None
cover_path: str = None
flat_filter_result: Any = None
error: Exception = None
@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
-307
View File
@@ -1,307 +0,0 @@
from __future__ import annotations
import subprocess
import urllib.parse
from pathlib import Path
import m3u8
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from .constants import MUSIC_VIDEO_CODEC_MAP
from .downloader import Downloader
from .enums import MusicVideoCodec, RemuxMode
from .models import StreamInfo
class DownloaderMusicVideo:
MP4_FORMAT_CODECS = ["hvc1", "audio-atmos", "audio-ec3"]
def __init__(
self,
downloader: Downloader,
codec: MusicVideoCodec = MusicVideoCodec.H264,
):
self.downloader = downloader
self.codec = codec
def get_stream_url_from_webplayback(self, webplayback: dict) -> str:
return webplayback["hls-playlist-url"]
def get_stream_url_from_itunes_page(self, itunes_page: dict) -> dict:
stream_url = itunes_page["offers"][0]["assets"][0]["hlsUrl"]
url_parts = urllib.parse.urlparse(stream_url)
query = urllib.parse.parse_qs(url_parts.query, keep_blank_values=True)
query.update({"aec": "HD", "dsid": "1"})
return url_parts._replace(
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(
self,
playlists: list[dict],
) -> dict:
playlists_filtered = [
playlist
for playlist in playlists
if playlist["stream_info"]["codecs"].startswith(
MUSIC_VIDEO_CODEC_MAP[self.codec]
)
]
if not playlists_filtered:
playlists_filtered = [
playlist
for playlist in playlists
if playlist["stream_info"]["codecs"].startswith(
MUSIC_VIDEO_CODEC_MAP[MusicVideoCodec.H264]
)
]
playlists_filtered.sort(key=lambda x: x["stream_info"]["bandwidth"])
return playlists_filtered[-1]
def get_playlist_video_from_user(
self,
playlists: list[dict],
) -> dict:
choices = [
Choice(
name=" | ".join(
[
playlist["stream_info"]["codecs"][:4],
playlist["stream_info"]["resolution"],
str(playlist["stream_info"]["bandwidth"]),
]
),
value=playlist,
)
for playlist in playlists
]
selected = inquirer.select(
message="Select which video codec to download: (Codec | Resolution | Bitrate)",
choices=choices,
).execute()
return selected
def get_playlist_audio(
self,
playlists: list[dict],
) -> dict:
stream_url = next(
(
playlist
for playlist in playlists
if playlist["group_id"] == "audio-stereo-256"
),
None,
)
return stream_url
def get_playlist_audio_from_user(
self,
playlists: list[dict],
) -> dict:
choices = [
Choice(
name=playlist["group_id"],
value=playlist,
)
for playlist in playlists
if playlist.get("uri")
]
selected = inquirer.select(
message="Select which audio codec to download:",
choices=choices,
).execute()
return selected
def get_pssh(self, m3u8_data: dict):
return next(
(
key
for key in m3u8_data["keys"]
if key["keyformat"] == "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
),
None,
)["uri"]
def get_stream_info_video(self, m3u8_master_data: dict) -> StreamInfo:
stream_info = StreamInfo()
if self.codec != MusicVideoCodec.ASK:
playlist = self.get_playlist_video(m3u8_master_data["playlists"])
else:
playlist = self.get_playlist_video_from_user(m3u8_master_data["playlists"])
stream_info.stream_url = playlist["uri"]
stream_info.codec = playlist["stream_info"]["codecs"]
m3u8_data = m3u8.load(stream_info.stream_url).data
stream_info.widevine_pssh = self.get_pssh(m3u8_data)
return stream_info
def get_stream_info_audio(self, m3u8_master_data: dict) -> StreamInfo:
stream_info = StreamInfo()
if self.codec != MusicVideoCodec.ASK:
playlist = self.get_playlist_audio(m3u8_master_data["media"])
else:
playlist = self.get_playlist_audio_from_user(m3u8_master_data["media"])
stream_info.stream_url = playlist["uri"]
stream_info.codec = playlist["group_id"]
m3u8_data = m3u8.load(stream_info.stream_url).data
stream_info.widevine_pssh = self.get_pssh(m3u8_data)
return stream_info
def get_music_video_id_alt(self, metadata: dict) -> str:
return metadata["attributes"]["url"].split("/")[-1].split("?")[0]
def get_tags(
self,
id_alt: str,
itunes_page: dict,
metadata: dict,
):
metadata_itunes = self.downloader.itunes_api.get_resource(id_alt)
tags = {
"artist": metadata_itunes[0]["artistName"],
"artist_id": int(metadata_itunes[0]["artistId"]),
"copyright": itunes_page.get("copyright"),
"date": self.downloader.sanitize_date(metadata_itunes[0]["releaseDate"]),
"genre": metadata_itunes[0]["primaryGenreName"],
"genre_id": int(itunes_page["genres"][0]["genreId"]),
"media_type": 6,
"storefront": int(self.downloader.itunes_api.storefront_id.split("-")[0]),
"title": metadata_itunes[0]["trackCensoredName"],
"title_id": int(metadata["id"]),
}
if metadata_itunes[0]["trackExplicitness"] == "notExplicit":
tags["rating"] = 0
elif metadata_itunes[0]["trackExplicitness"] == "explicit":
tags["rating"] = 1
else:
tags["rating"] = 2
if len(metadata_itunes) > 1:
album = self.downloader.apple_music_api.get_album(
itunes_page["collectionId"]
)
tags["album"] = metadata_itunes[1]["collectionCensoredName"]
tags["album_artist"] = metadata_itunes[1]["artistName"]
tags["album_id"] = int(itunes_page["collectionId"])
tags["disc"] = metadata_itunes[0]["discNumber"]
tags["disc_total"] = metadata_itunes[0]["discCount"]
tags["compilation"] = album["attributes"]["isCompilation"]
tags["track"] = metadata_itunes[0]["trackNumber"]
tags["track_total"] = metadata_itunes[0]["trackCount"]
return tags
def get_encrypted_path_video(self, track_id: str) -> str:
return self.downloader.temp_path / f"encrypted_{track_id}.mp4"
def get_encrypted_path_audio(self, track_id: str) -> str:
return self.downloader.temp_path / f"encrypted_{track_id}.m4a"
def get_decrypted_path_video(self, track_id: str) -> str:
return self.downloader.temp_path / f"decrypted_{track_id}.mp4"
def get_decrypted_path_audio(self, track_id: str) -> str:
return self.downloader.temp_path / f"decrypted_{track_id}.m4a"
def get_remuxed_path(self, track_id: str) -> str:
return self.downloader.temp_path / f"remuxed_{track_id}.m4v"
def decrypt(self, encrypted_path: Path, decryption_key: str, decrypted_path: Path):
subprocess.run(
[
self.downloader.mp4decrypt_path_full,
encrypted_path,
"--key",
f"1:{decryption_key}",
decrypted_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_mp4box(
self,
decrypted_path_audio: Path,
decrypted_path_video: Path,
fixed_path: Path,
):
subprocess.run(
[
self.downloader.mp4box_path_full,
"-quiet",
"-add",
decrypted_path_audio,
"-add",
decrypted_path_video,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
fixed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_ffmpeg(
self,
decrypted_path_video: Path,
decrypte_path_audio: Path,
fixed_path: Path,
codec_video: str,
codec_audio: str,
):
use_mp4_flag = any(
codec_video.startswith(codec) for codec in self.MP4_FORMAT_CODECS
) or any(codec_audio.startswith(codec) for codec in self.MP4_FORMAT_CODECS)
subprocess.run(
[
self.downloader.ffmpeg_path_full,
"-loglevel",
"error",
"-y",
"-i",
decrypted_path_video,
"-i",
decrypte_path_audio,
"-movflags",
"+faststart",
"-f",
"mp4" if use_mp4_flag else "ipod",
"-c",
"copy",
"-c:s",
"mov_text",
fixed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux(
self,
decrypted_path_video: Path,
decrypted_path_audio: Path,
remuxed_path: Path,
codec_video: str,
codec_audio: str,
):
if self.downloader.remux_mode == RemuxMode.MP4BOX:
self.remux_mp4box(
decrypted_path_audio,
decrypted_path_video,
remuxed_path,
)
elif self.downloader.remux_mode == RemuxMode.FFMPEG:
self.remux_ffmpeg(
decrypted_path_video,
decrypted_path_audio,
remuxed_path,
codec_video,
codec_audio,
)
def get_cover_path(self, final_path: Path, file_extension: str) -> Path:
return final_path.with_suffix(file_extension)
-74
View File
@@ -1,74 +0,0 @@
from __future__ import annotations
from pathlib import Path
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from .downloader import Downloader
from .enums import PostQuality
class DownloaderPost:
QUALITY_RANK = [
"1080pHdVideo",
"720pHdVideo",
"sdVideoWithPlusAudio",
"sdVideo",
"sd480pVideo",
"provisionalUploadVideo",
]
def __init__(
self,
downloader: Downloader,
quality: PostQuality = PostQuality.BEST,
):
self.downloader = downloader
self.quality = quality
def get_stream_url_best(self, metadata: dict) -> str:
best_quality = next(
(
quality
for quality in self.QUALITY_RANK
if metadata["attributes"]["assetTokens"].get(quality)
),
None,
)
return metadata["attributes"]["assetTokens"][best_quality]
def get_stream_url_from_user(self, metadata: dict) -> str:
qualities = list(metadata["attributes"]["assetTokens"].keys())
choices = [
Choice(
name=quality,
value=quality,
)
for quality in qualities
]
selected = inquirer.select(
message="Select which quality to download:",
choices=choices,
).execute()
return metadata["attributes"]["assetTokens"][selected]
def get_stream_url(self, metadata: dict) -> str:
if self.quality == PostQuality.BEST:
stream_url = self.get_stream_url_best(metadata)
elif self.quality == PostQuality.ASK:
stream_url = self.get_stream_url_from_user(metadata)
return stream_url
def get_tags(self, metadata: dict) -> list:
attributes = metadata["attributes"]
return {
"artist": attributes["artistName"],
"date": self.downloader.sanitize_date(attributes["uploadDate"]),
"title": attributes["name"],
"title_id": int(metadata["id"]),
"storefront": int(self.downloader.itunes_api.storefront_id.split("-")[0]),
}
def get_post_temp_path(self, track_id: str) -> Path:
return self.downloader.temp_path / f"{track_id}_temp.m4v"
-405
View File
@@ -1,405 +0,0 @@
from __future__ import annotations
import base64
import datetime
import json
import re
import subprocess
from pathlib import Path
from xml.dom import minidom
from xml.etree import ElementTree
import m3u8
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from .constants import SONG_CODEC_REGEX_MAP, SYNCED_LYRICS_FILE_EXTENSION_MAP
from .downloader import Downloader
from .enums import RemuxMode, SongCodec, SyncedLyricsFormat
from .models import Lyrics, StreamInfo
class DownloaderSong:
DEFAULT_DECRYPTION_KEY = "32b8ade1769e26b1ffb8986352793fc6"
MP4_FORMAT_CODECS = ["ec-3"]
def __init__(
self,
downloader: Downloader,
codec: SongCodec = SongCodec.AAC_LEGACY,
synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC,
):
self.downloader = downloader
self.codec = codec
self.synced_lyrics_format = synced_lyrics_format
def get_drm_infos(self, m3u8_data: dict) -> dict:
drm_info_raw = next(
(
session_data
for session_data in m3u8_data["session_data"]
if session_data["data_id"] == "com.apple.hls.AudioSessionKeyInfo"
),
None,
)
if not drm_info_raw:
return None
return json.loads(base64.b64decode(drm_info_raw["value"]).decode("utf-8"))
def get_asset_infos(self, m3u8_data: dict) -> dict:
return json.loads(
base64.b64decode(
next(
session_data
for session_data in m3u8_data["session_data"]
if session_data["data_id"] == "com.apple.hls.audioAssetMetadata"
)["value"]
).decode("utf-8")
)
def get_playlist_from_codec(self, m3u8_data: dict) -> dict | None:
m3u8_master_playlists = [
playlist
for playlist in m3u8_data["playlists"]
if re.fullmatch(
SONG_CODEC_REGEX_MAP[self.codec], playlist["stream_info"]["audio"]
)
]
if not m3u8_master_playlists:
return None
m3u8_master_playlists.sort(key=lambda x: x["stream_info"]["average_bandwidth"])
return m3u8_master_playlists[-1]
def get_playlist_from_user(self, m3u8_data: dict) -> dict | None:
m3u8_master_playlists = [playlist for playlist in m3u8_data["playlists"]]
choices = [
Choice(
name=playlist["stream_info"]["audio"],
value=playlist,
)
for playlist in m3u8_master_playlists
]
selected = inquirer.select(
message="Select which codec to download:",
choices=choices,
).execute()
return selected
def _get_drm_data(
self,
drm_infos: dict,
drm_ids: list,
drm_key: str,
) -> str | None:
drm_info = next(
(
drm_infos[drm_id]
for drm_id in drm_ids
if drm_infos[drm_id].get(drm_key) and drm_id != "1"
),
None,
)
if not drm_info:
return None
return drm_info[drm_key]["URI"]
def get_widevine_pssh(
self,
drm_infos: dict,
drm_ids: list,
) -> str | None:
return self._get_drm_data(
drm_infos,
drm_ids,
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
)
def get_playready_pssh(self, drm_infos: dict, drm_ids: list) -> str | None:
return self._get_drm_data(
drm_infos,
drm_ids,
"com.microsoft.playready",
)
def get_fairplay_key(self, drm_infos: dict, drm_ids: list) -> str | None:
return self._get_drm_data(
drm_infos,
drm_ids,
"com.apple.streamingkeydelivery",
)
def get_stream_info(self, track_metadata: dict) -> StreamInfo:
m3u8_url = track_metadata["attributes"]["extendedAssetUrls"].get("enhancedHls")
if not m3u8_url:
return StreamInfo()
return self._get_stream_info(m3u8_url)
def _get_stream_info(self, m3u8_url: str) -> StreamInfo:
stream_info = StreamInfo()
m3u8_obj = m3u8.load(m3u8_url)
m3u8_data = m3u8_obj.data
drm_infos = self.get_drm_infos(m3u8_data)
if not drm_infos:
return stream_info
asset_infos = self.get_asset_infos(m3u8_data)
if self.codec == SongCodec.ASK:
playlist = self.get_playlist_from_user(m3u8_data)
else:
playlist = self.get_playlist_from_codec(m3u8_data)
if playlist is None:
return stream_info
stream_info.stream_url = m3u8_obj.base_uri + playlist["uri"]
variant_id = playlist["stream_info"]["stable_variant_id"]
drm_ids = asset_infos[variant_id]["AUDIO-SESSION-KEY-IDS"]
widevine_pssh, playready_pssh, fairplay_key = (
self.get_widevine_pssh(drm_infos, drm_ids),
self.get_playready_pssh(drm_infos, drm_ids),
self.get_fairplay_key(drm_infos, drm_ids),
)
stream_info.widevine_pssh = widevine_pssh
stream_info.playready_pssh = playready_pssh
stream_info.fairplay_key = fairplay_key
stream_info.codec = playlist["stream_info"]["codecs"]
return stream_info
@staticmethod
def parse_datetime_obj_from_timestamp_ttml(
timestamp_ttml: str,
) -> datetime.datetime:
mins_secs_ms = re.findall(r"\d+", timestamp_ttml)
ms, secs, mins = 0, 0, 0
if len(mins_secs_ms) == 2 and ":" in timestamp_ttml:
secs, mins = int(mins_secs_ms[-1]), int(mins_secs_ms[-2])
elif len(mins_secs_ms) == 1:
ms = int(mins_secs_ms[-1])
else:
secs = float(f"{mins_secs_ms[-2]}.{mins_secs_ms[-1]}")
if len(mins_secs_ms) > 2:
mins = int(mins_secs_ms[-3])
return datetime.datetime.fromtimestamp(
(mins * 60) + secs + (ms / 1000),
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)
ms_new = datetime_obj.strftime("%f")[:-3]
if int(ms_new[-1]) >= 5:
ms = int(f"{int(ms_new[:2]) + 1}") * 10
datetime_obj += datetime.timedelta(milliseconds=ms) - datetime.timedelta(
microseconds=datetime_obj.microsecond
)
return datetime_obj.strftime("%M:%S.%f")[:-4]
def get_lyrics_synced_timestamp_srt(self, timestamp_ttml: str) -> str:
datetime_obj = self.parse_datetime_obj_from_timestamp_ttml(timestamp_ttml)
return datetime_obj.strftime("00:%M:%S,%f")[:-3]
def get_lyrics_synced_line_lrc(self, timestamp_ttml: str, text: str) -> str:
return f"[{self.get_lyrics_synced_timestamp_lrc(timestamp_ttml)}]{text}"
def get_lyrics_synced_line_srt(
self,
index: int,
timestamp_ttml_start: str,
timestamp_ttml_end: str,
text: str,
) -> str:
timestamp_srt_start = self.get_lyrics_synced_timestamp_srt(timestamp_ttml_start)
timestamp_srt_end = self.get_lyrics_synced_timestamp_srt(timestamp_ttml_end)
return f"{index}\n{timestamp_srt_start} --> {timestamp_srt_end}\n{text}\n"
def get_lyrics(self, track_metadata: dict) -> Lyrics:
lyrics = Lyrics()
if not track_metadata["attributes"]["hasLyrics"]:
return lyrics
elif track_metadata.get("relationships") is None:
track_metadata = self.downloader.apple_music_api.get_song(
track_metadata["id"]
)
if (
track_metadata["relationships"].get("lyrics")
and track_metadata["relationships"]["lyrics"].get("data")
and track_metadata["relationships"]["lyrics"]["data"][0].get("attributes")
):
lyrics = self._get_lyrics(
track_metadata["relationships"]["lyrics"]["data"][0]["attributes"][
"ttml"
]
)
return lyrics
def _get_lyrics(self, lyrics_ttml: str) -> Lyrics:
lyrics = Lyrics("", "")
lyrics_ttml_et = ElementTree.fromstring(lyrics_ttml)
index = 1
for div in lyrics_ttml_et.iter("{http://www.w3.org/ns/ttml}div"):
for p in div.iter("{http://www.w3.org/ns/ttml}p"):
if p.text is not None:
lyrics.unsynced += p.text + "\n"
if p.attrib.get("begin"):
if self.synced_lyrics_format == SyncedLyricsFormat.LRC:
lyrics.synced += f"{self.get_lyrics_synced_line_lrc(p.attrib.get('begin'), p.text)}"
elif self.synced_lyrics_format == SyncedLyricsFormat.SRT:
lyrics.synced += f"{self.get_lyrics_synced_line_srt(index, p.attrib.get('begin'), p.attrib.get('end'), p.text)}"
elif self.synced_lyrics_format == SyncedLyricsFormat.TTML:
if not lyrics.synced:
lyrics.synced = minidom.parseString(
lyrics_ttml
).toprettyxml()
continue
lyrics.synced += "\n"
index += 1
lyrics.unsynced += "\n"
lyrics.unsynced = lyrics.unsynced[:-2]
return lyrics
def get_tags(self, webplayback: dict, lyrics_unsynced: str) -> dict:
tags_raw = webplayback["assets"][0]["metadata"]
tags = {
"album": tags_raw["playlistName"],
"album_artist": tags_raw["playlistArtistName"],
"album_id": int(tags_raw["playlistId"]),
"album_sort": tags_raw["sort-album"],
"artist": tags_raw["artistName"],
"artist_id": int(tags_raw["artistId"]),
"artist_sort": tags_raw["sort-artist"],
"comments": tags_raw.get("comments"),
"compilation": tags_raw["compilation"],
"composer": tags_raw.get("composerName"),
"composer_id": (
int(tags_raw.get("composerId")) if tags_raw.get("composerId") else None
),
"composer_sort": tags_raw.get("sort-composer"),
"copyright": tags_raw.get("copyright"),
"date": (
self.downloader.sanitize_date(tags_raw["releaseDate"])
if tags_raw.get("releaseDate")
else None
),
"disc": tags_raw["discNumber"],
"disc_total": tags_raw["discCount"],
"gapless": tags_raw["gapless"],
"genre": tags_raw.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"),
}
return tags
def get_encrypted_path(self, track_id: str) -> Path:
return self.downloader.temp_path / f"{track_id}_encrypted.m4a"
def get_decrypted_path(self, track_id: str) -> Path:
return self.downloader.temp_path / f"{track_id}_decrypted.m4a"
def get_remuxed_path(self, track_id: str) -> Path:
return self.downloader.temp_path / f"{track_id}_remuxed.m4a"
def fix_key_id(self, encrypted_path: Path):
count = 0
with open(encrypted_path, "rb+") as file:
while data := file.read(4096):
pos = file.tell()
i = 0
while tenc := max(0, data.find(b"tenc", i)):
kid = tenc + 12
file.seek(max(0, pos - 4096) + kid, 0)
file.write(bytes.fromhex(f"{count:032}"))
count += 1
i = kid + 1
file.seek(pos, 0)
def decrypt(
self,
encrypted_path: Path,
decrypted_path: Path,
decryption_key: str,
):
self.fix_key_id(encrypted_path)
subprocess.run(
[
self.downloader.mp4decrypt_path_full,
encrypted_path,
"--key",
f"00000000000000000000000000000001:{decryption_key}",
"--key",
f"00000000000000000000000000000000:{self.DEFAULT_DECRYPTION_KEY}",
decrypted_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux(self, decrypted_path: Path, remuxed_path: Path, codec: str):
if self.downloader.remux_mode == RemuxMode.MP4BOX:
self.remux_mp4box(decrypted_path, remuxed_path)
elif self.downloader.remux_mode == RemuxMode.FFMPEG:
self.remux_ffmpeg(decrypted_path, remuxed_path, codec)
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path):
subprocess.run(
[
self.downloader.mp4box_path_full,
"-quiet",
"-add",
decrypted_path,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
remuxed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_ffmpeg(
self,
decrypted_path: Path,
remuxed_path: Path,
codec: str,
):
use_mp4_format = any(
codec.startswith(possible_codec)
for possible_codec in self.MP4_FORMAT_CODECS
)
subprocess.run(
[
self.downloader.ffmpeg_path_full,
"-loglevel",
"error",
"-y",
"-i",
decrypted_path,
"-c",
"copy",
"-f",
"mp4" if use_mp4_format else "ipod",
"-movflags",
"+faststart",
remuxed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def get_lyrics_synced_path(self, final_path: Path) -> Path:
return final_path.with_suffix(
SYNCED_LYRICS_FILE_EXTENSION_MAP[self.synced_lyrics_format]
)
def get_cover_path(self, final_path: Path, file_extension: str) -> Path:
return final_path.parent / ("Cover" + file_extension)
def save_lyrics_synced(self, lyrics_synced_path: Path, lyrics_synced: str):
lyrics_synced_path.parent.mkdir(parents=True, exist_ok=True)
lyrics_synced_path.write_text(lyrics_synced, encoding="utf8")
-127
View File
@@ -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.widevine_pssh = m3u8_obj.keys[0].uri
return stream_info
def get_decryption_key(self, pssh: str, track_id: str) -> str:
try:
widevine_pssh_data = WidevinePsshData()
widevine_pssh_data.algorithm = 1
widevine_pssh_data.key_ids.append(base64.b64decode(pssh.split(",")[1]))
pssh_obj = PSSH(widevine_pssh_data.SerializeToString())
cdm_session = self.downloader.cdm.open()
challenge = base64.b64encode(
self.downloader.cdm.get_license_challenge(cdm_session, pssh_obj)
).decode()
license = self.downloader.apple_music_api.get_widevine_license(
track_id,
pssh,
challenge,
)
self.downloader.cdm.parse_license(cdm_session, license)
decryption_key = next(
i
for i in self.downloader.cdm.get_keys(cdm_session)
if i.type == "CONTENT"
).key.hex()
finally:
self.downloader.cdm.close(cdm_session)
return decryption_key
def decrypt(
self,
encrypted_path: Path,
decrypted_path: Path,
decryption_key: str,
):
subprocess.run(
[
self.downloader.mp4decrypt_path_full,
encrypted_path,
"--key",
f"1:{decryption_key}",
decrypted_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path):
subprocess.run(
[
self.downloader.mp4box_path_full,
"-quiet",
"-add",
decrypted_path,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
remuxed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_ffmpeg(
self,
decryption_key: str,
encrypted_path: Path,
remuxed_path: Path,
):
subprocess.run(
[
self.downloader.ffmpeg_path_full,
"-loglevel",
"error",
"-y",
"-decryption_key",
decryption_key,
"-i",
encrypted_path,
"-c",
"copy",
"-movflags",
"+faststart",
remuxed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux(
self,
encrypted_path: Path,
decrypted_path: Path,
remuxed_path: Path,
decryption_key: str,
):
if self.downloader.remux_mode == RemuxMode.FFMPEG:
self.remux_ffmpeg(decryption_key, encrypted_path, remuxed_path)
elif self.downloader.remux_mode == RemuxMode.MP4BOX:
self.decrypt(encrypted_path, decrypted_path, decryption_key)
self.remux_mp4box(decrypted_path, remuxed_path)
-49
View File
@@ -1,49 +0,0 @@
from enum import Enum
class DownloadMode(Enum):
YTDLP = "ytdlp"
NM3U8DLRE = "nm3u8dlre"
class RemuxMode(Enum):
FFMPEG = "ffmpeg"
MP4BOX = "mp4box"
class SongCodec(Enum):
AAC_LEGACY = "aac-legacy"
AAC_HE_LEGACY = "aac-he-legacy"
AAC = "aac"
AAC_HE = "aac-he"
AAC_BINAURAL = "aac-binaural"
AAC_DOWNMIX = "aac-downmix"
AAC_HE_BINAURAL = "aac-he-binaural"
AAC_HE_DOWNMIX = "aac-he-downmix"
ATMOS = "atmos"
AC3 = "ac3"
ALAC = "alac"
ASK = "ask"
class SyncedLyricsFormat(Enum):
LRC = "lrc"
SRT = "srt"
TTML = "ttml"
class MusicVideoCodec(Enum):
H264 = "h264"
H265 = "h265"
ASK = "ask"
class PostQuality(Enum):
BEST = "best"
ASK = "ask"
class CoverFormat(Enum):
JPG = "jpg"
PNG = "png"
RAW = "raw"
+6
View File
@@ -0,0 +1,6 @@
from .enums import *
from .interface import *
from .interface_music_video import *
from .interface_song import *
from .interface_uploaded_video import *
from .types import *
+62
View File
@@ -0,0 +1,62 @@
MEDIA_TYPE_STR_MAP = {
1: "Song",
6: "Music Video",
}
MEDIA_RATING_STR_MAP = {
0: "None",
1: "Explicit",
2: "Clean",
}
LEGACY_SONG_CODECS = {"aac-legacy", "aac-he-legacy"}
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",
}
MP4_FORMAT_CODECS = ["ec-3", "hvc1", "audio-atmos", "audio-ec3"]
SONG_CODEC_REGEX_MAP = {
"aac": r"audio-stereo-\d+",
"aac-he": r"audio-HE-stereo-\d+",
"aac-binaural": r"audio-stereo-\d+-binaural",
"aac-downmix": r"audio-stereo-\d+-downmix",
"aac-he-binaural": r"audio-HE-stereo-\d+-binaural",
"aac-he-downmix": r"audio-HE-stereo-\d+-downmix",
"atmos": r"audio-atmos-.*",
"ac3": r"audio-ac3-.*",
"alac": r"audio-alac-.*",
}
FOURCC_MAP = {
"h264": "avc1",
"h265": "hvc1",
}
UPLOADED_VIDEO_QUALITY_RANK = [
"1080pHdVideo",
"720pHdVideo",
"sdVideoWithPlusAudio",
"sdVideo",
"sd480pVideo",
"provisionalUploadVideo",
]
IMAGE_FILE_EXTENSION_MAP = {
"jpeg": ".jpg",
"tiff": ".tif",
}
+95
View File
@@ -0,0 +1,95 @@
from enum import Enum
from .constants import (
FOURCC_MAP,
LEGACY_SONG_CODECS,
MEDIA_RATING_STR_MAP,
MEDIA_TYPE_STR_MAP,
)
class SyncedLyricsFormat(Enum):
LRC = "lrc"
SRT = "srt"
TTML = "ttml"
class MediaType(Enum):
SONG = 1
MUSIC_VIDEO = 6
def __str__(self) -> str:
return MEDIA_TYPE_STR_MAP[self.value]
def __int__(self) -> int:
return self.value
class MediaRating(Enum):
NONE = 0
EXPLICIT = 1
CLEAN = 2
def __str__(self) -> str:
return MEDIA_RATING_STR_MAP[self.value]
def __int__(self) -> int:
return self.value
class MediaFileFormat(Enum):
MP4 = "mp4"
M4V = "m4v"
M4A = "m4a"
class SongCodec(Enum):
AAC_LEGACY = "aac-legacy"
AAC_HE_LEGACY = "aac-he-legacy"
AAC = "aac"
AAC_HE = "aac-he"
AAC_BINAURAL = "aac-binaural"
AAC_DOWNMIX = "aac-downmix"
AAC_HE_BINAURAL = "aac-he-binaural"
AAC_HE_DOWNMIX = "aac-he-downmix"
ATMOS = "atmos"
AC3 = "ac3"
ALAC = "alac"
ASK = "ask"
def is_legacy(self) -> bool:
return self.value in LEGACY_SONG_CODECS
class MusicVideoCodec(Enum):
H264 = "h264"
H265 = "h265"
ASK = "ask"
def fourcc(self) -> str:
return FOURCC_MAP[self.value]
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 UploadedVideoQuality(Enum):
BEST = "best"
ASK = "ask"
class CoverFormat(Enum):
JPG = "jpg"
PNG = "png"
RAW = "raw"
+161
View File
@@ -0,0 +1,161 @@
import asyncio
import base64
import datetime
import logging
import re
from io import BytesIO
from async_lru import alru_cache
from PIL import Image
from pywidevine import PSSH, Cdm
from ..api.apple_music_api import AppleMusicApi
from ..api.itunes_api import ItunesApi
from ..utils import get_response
from .constants import IMAGE_FILE_EXTENSION_MAP
from .enums import CoverFormat
from .types import DecryptionKey
logger = logging.getLogger(__name__)
class AppleMusicInterface:
def __init__(
self,
apple_music_api: AppleMusicApi,
itunes_api: ItunesApi,
) -> None:
self.apple_music_api = apple_music_api
self.itunes_api = itunes_api
@staticmethod
def get_media_id_of_library_media(library_media_metadata: dict) -> str:
play_params = library_media_metadata["attributes"].get("playParams", {})
return play_params.get("catalogId", library_media_metadata["id"])
@staticmethod
def parse_date(date: str) -> datetime.datetime:
return datetime.datetime.fromisoformat(date.split("Z")[0])
async def get_decryption_key(
self,
track_uri: str,
track_id: str,
cdm: Cdm,
) -> DecryptionKey:
try:
cdm_session = cdm.open()
pssh_obj = PSSH(track_uri.split(",")[-1])
challenge = base64.b64encode(
await asyncio.to_thread(
cdm.get_license_challenge, cdm_session, pssh_obj
)
).decode()
license = await self.apple_music_api.get_license_exchange(
track_id,
track_uri,
challenge,
)
await asyncio.to_thread(cdm.parse_license, cdm_session, license["license"])
decryption_key_info = next(
i for i in cdm.get_keys(cdm_session) if i.type == "CONTENT"
)
finally:
cdm.close(cdm_session)
decryption_key = DecryptionKey(
key=decryption_key_info.key.hex(),
kid=decryption_key_info.kid.hex,
)
logger.debug(f"Decryption key: {decryption_key}")
return decryption_key
def get_cover_url_template(self, metadata: dict, cover_format: CoverFormat) -> str:
if cover_format == CoverFormat.RAW:
cover_url_template = self._get_raw_cover_url(
metadata["attributes"]["artwork"]["url"]
)
else:
cover_url_template = metadata["attributes"]["artwork"]["url"]
logger.debug(f"Cover URL template: {cover_url_template}")
return cover_url_template
def _get_raw_cover_url(self, cover_url_template: str) -> str:
return re.sub(
r"image/thumb/",
"",
re.sub(
r"is1-ssl",
"a1",
cover_url_template,
),
)
def get_cover_url(
self,
cover_url_template: str,
cover_size: int,
cover_format: CoverFormat,
) -> str:
cover_url = re.sub(
r"/\{w\}x\{h\}([a-z]{2})\.jpg",
(
f"/{cover_size}x{cover_size}bb.{cover_format.value}"
if cover_format != CoverFormat.RAW
else ""
),
cover_url_template,
)
logger.debug(f"Cover URL: {cover_url}")
return cover_url
@alru_cache()
async def get_cover_file_extension(
self,
cover_url: str,
cover_format: CoverFormat,
) -> str | None:
if cover_format != CoverFormat.RAW:
return f".{cover_format.value}"
cover_bytes = await self.get_cover_bytes(cover_url)
if cover_bytes is None:
return None
image_obj = Image.open(BytesIO(await self.get_cover_bytes(cover_url)))
image_format = image_obj.format.lower()
return IMAGE_FILE_EXTENSION_MAP.get(
image_format,
f".{image_format.lower()}",
)
@alru_cache()
async def get_cover_bytes(self, cover_url: str) -> bytes | None:
response = await get_response(cover_url, {200, 404})
if response.status_code == 200:
return response.content
return None
@alru_cache()
async def get_media_date(
self,
media_id: str,
) -> datetime.datetime | None:
lookup_result = await self.itunes_api.get_lookup_result(media_id)
if not lookup_result["results"]:
return None
release_date = lookup_result["results"][0].get("releaseDate")
if not release_date:
return None
parsed_date = self.parse_date(release_date)
logger.debug(f"Parsed media date: {parsed_date}")
return parsed_date
+380
View File
@@ -0,0 +1,380 @@
import logging
import urllib.parse
import m3u8
from async_lru import alru_cache
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from pywidevine import Cdm
from ..utils import get_response
from .constants import MP4_FORMAT_CODECS
from .enums import MediaRating, MediaType, MusicVideoCodec, MusicVideoResolution
from .interface import AppleMusicInterface
from .types import DecryptionKeyAv, MediaFileFormat, MediaTags, StreamInfo, StreamInfoAv
logger = logging.getLogger(__name__)
class AppleMusicMusicVideoInterface(AppleMusicInterface):
def __init__(self, interface: AppleMusicInterface):
self.__dict__.update(interface.__dict__)
async def get_itunes_page_metadata(
self,
music_video_metadata: dict,
) -> dict:
alt_id = self.get_alt_id(music_video_metadata)
itunes_page = await self.itunes_api.get_itunes_page(
"music-video",
alt_id,
)
return itunes_page["storePlatformData"]["product-dv"]["results"][alt_id]
def get_m3u8_master_url_from_webplayback(self, webplayback: dict) -> str:
m3u8_master_url = webplayback["hls-playlist-url"]
return m3u8_master_url
def get_m3u8_master_url_from_itunes_page_metadata(
self,
itunes_page_metadata: dict,
) -> dict:
stream_url = itunes_page_metadata["offers"][0]["assets"][0]["hlsUrl"]
url_parts = urllib.parse.urlparse(stream_url)
query = urllib.parse.parse_qs(url_parts.query, keep_blank_values=True)
query.update({"aec": "HD", "dsid": "1"})
m3u8_master_url = url_parts._replace(
query=urllib.parse.urlencode(query, doseq=True)
).geturl()
return m3u8_master_url
def get_alt_id(self, metadata: dict) -> str | None:
music_video_url = metadata["attributes"].get("url")
if music_video_url is None:
return None
alt_id = music_video_url.split("/")[-1].split("?")[0]
logger.debug(f"Alt ID: {alt_id}")
return alt_id
@alru_cache()
async def get_album(
self,
collection_id: int,
) -> dict | None:
album_response = await self.apple_music_api.get_album(collection_id)
if not album_response:
return None
return album_response["data"][0]
async def get_tags(
self,
metadata: dict,
itunes_page_metadata: dict,
) -> MediaTags:
alt_id = self.get_alt_id(metadata)
lookup_metadata = (await self.itunes_api.get_lookup_result(alt_id))["results"]
explicitness = lookup_metadata[0]["trackExplicitness"]
if explicitness == "notExplicit":
rating = MediaRating.NONE
elif explicitness == "explicit":
rating = MediaRating.EXPLICIT
else:
rating = MediaRating.CLEAN
tags = MediaTags(
artist=lookup_metadata[0]["artistName"],
artist_id=int(lookup_metadata[0]["artistId"]),
copyright=itunes_page_metadata.get("copyright"),
date=self.parse_date(lookup_metadata[0]["releaseDate"]),
genre=lookup_metadata[0]["primaryGenreName"],
genre_id=int(itunes_page_metadata["genres"][0]["genreId"]),
media_type=MediaType.MUSIC_VIDEO,
storefront=int(self.itunes_api.storefront_id.split("-")[0]),
title=lookup_metadata[0]["trackCensoredName"],
title_id=int(metadata["id"]),
rating=rating,
)
if len(lookup_metadata) > 1:
album = await self.get_album(itunes_page_metadata["collectionId"])
if not album:
return tags
tags.album = lookup_metadata[1]["collectionCensoredName"]
tags.album_artist = lookup_metadata[1]["artistName"]
tags.album_id = int(itunes_page_metadata["collectionId"])
tags.disc = lookup_metadata[0]["discNumber"]
tags.disc_total = lookup_metadata[0]["discCount"]
tags.compilation = album["attributes"]["isCompilation"]
tags.track = lookup_metadata[0]["trackNumber"]
tags.track_total = lookup_metadata[0]["trackCount"]
logger.debug(f"Tags: {tags}")
return tags
async def get_stream_info(
self,
metadata: dict,
itunes_page_metadata: dict,
codec_priority: list[MusicVideoCodec],
resolution: MusicVideoResolution,
) -> StreamInfoAv:
alt_video_id = self.get_alt_id(metadata)
if alt_video_id == metadata["id"]:
m3u8_master_url = self.get_m3u8_master_url_from_itunes_page_metadata(
itunes_page_metadata,
)
else:
webplayback_response = await self.apple_music_api.get_webplayback(
metadata["id"]
)
m3u8_master_url = self.get_m3u8_master_url_from_webplayback(
webplayback_response["songList"][0],
)
playlist_master_m3u8_obj = m3u8.loads(
(await get_response(m3u8_master_url)).text
)
playlist_master_m3u8_obj.base_uri = m3u8_master_url.rpartition("/")[0]
stream_info_video = await self.get_stream_info_video(
playlist_master_m3u8_obj,
codec_priority,
resolution,
)
stream_info_audio = await self.get_stream_info_audio(
playlist_master_m3u8_obj.data,
codec_priority,
)
if not stream_info_video or not stream_info_audio:
return None
use_mp4 = any(
stream_info_video.codec.startswith(codec) for codec in MP4_FORMAT_CODECS
) or any(
stream_info_audio.codec.startswith(codec) for codec in MP4_FORMAT_CODECS
)
if use_mp4:
file_format = MediaFileFormat.MP4
else:
file_format = MediaFileFormat.M4V
stream_info = StreamInfoAv(
video_track=stream_info_video,
audio_track=stream_info_audio,
file_format=file_format,
)
logger.debug(f"Stream info: {stream_info}")
return stream_info
def get_video_playlist_from_resolution(
self,
video_playlists: list[m3u8.Playlist],
codec_priority: list[MusicVideoCodec],
resolution: MusicVideoResolution,
) -> m3u8.Playlist | None:
playlist_results = []
for codec_index, codec in enumerate(codec_priority):
for playlist in video_playlists:
if playlist.stream_info.codecs.startswith(codec.fourcc()):
playlist_results.append((codec_index, playlist))
if not playlist_results:
return None
def sort_key(
item: tuple[int, m3u8.Playlist],
) -> tuple[bool, int, int, int, int]:
codec_index, playlist = item
playlist_resolution = playlist.stream_info.resolution[-1]
bandwidth = playlist.stream_info.bandwidth
exceeds_resolution = playlist_resolution > int(resolution)
resolution_difference = abs(playlist_resolution - int(resolution))
return (
exceeds_resolution,
resolution_difference,
codec_index,
-playlist_resolution,
-bandwidth,
)
playlist_results.sort(key=sort_key)
return playlist_results[0][1]
def get_best_stereo_audio_playlist(
self,
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
async def get_video_playlist_from_user(
self,
video_playlists: list[m3u8.Playlist],
) -> m3u8.Playlist:
choices = [
Choice(
name=" | ".join(
[
playlist.stream_info.codecs[:4],
"x".join(str(v) for v in playlist.stream_info.resolution),
str(playlist.stream_info.bandwidth),
]
),
value=playlist,
)
for playlist in video_playlists
]
selected = await inquirer.select(
message="Select which video codec to download: (Codec | Resolution | Bitrate)",
choices=choices,
).execute_async()
return selected
async def get_audio_playlist_from_user(
self,
playlist_master_data: dict,
) -> dict:
choices = [
Choice(
name=playlist["group_id"],
value=playlist,
)
for playlist in playlist_master_data["media"]
if playlist.get("uri")
]
selected = await inquirer.select(
message="Select which audio codec to download:",
choices=choices,
).execute_async()
return selected
def _get_key_by_format(
self,
m3u8_obj: m3u8.M3U8,
key_format: str,
) -> str:
return next(
(key for key in m3u8_obj.keys if key.keyformat == key_format),
None,
).uri
def get_widevine_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
return self._get_key_by_format(
m3u8_obj,
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
)
def get_playready_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
return self._get_key_by_format(
m3u8_obj,
"com.microsoft.playready",
)
def get_fairplay_key(self, m3u8_obj: m3u8.M3U8) -> str:
return self._get_key_by_format(
m3u8_obj,
"com.apple.streamingkeydelivery",
)
async def get_stream_info_video(
self,
playlist_master_m3u8_obj: m3u8.M3U8,
codec_priority: list[MusicVideoCodec],
resolution: MusicVideoResolution,
) -> StreamInfo | None:
stream_info = StreamInfo()
if MusicVideoCodec.ASK not in codec_priority:
playlist = self.get_video_playlist_from_resolution(
playlist_master_m3u8_obj.playlists,
codec_priority,
resolution,
)
else:
playlist = await 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
stream_info.width, stream_info.height = playlist.stream_info.resolution
playlist_m3u8_obj = m3u8.loads(
(await get_response(stream_info.stream_url)).text
)
stream_info.widevine_pssh = self.get_widevine_pssh(playlist_m3u8_obj)
stream_info.fairplay_key = self.get_fairplay_key(playlist_m3u8_obj)
stream_info.playready_pssh = self.get_playready_pssh(playlist_m3u8_obj)
return stream_info
async def get_stream_info_audio(
self,
playlist_master_data: dict,
codec_priority: list[MusicVideoCodec],
) -> StreamInfo | None:
stream_info = StreamInfo()
if MusicVideoCodec.ASK not in codec_priority:
playlist = self.get_best_stereo_audio_playlist(playlist_master_data)
else:
playlist = await 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"]
playlist_m3u8_obj = m3u8.loads(
(await get_response(stream_info.stream_url)).text
)
stream_info.widevine_pssh = self.get_widevine_pssh(playlist_m3u8_obj)
stream_info.fairplay_key = self.get_fairplay_key(playlist_m3u8_obj)
stream_info.playready_pssh = self.get_playready_pssh(playlist_m3u8_obj)
return stream_info
async def get_decryption_key(
self,
stream_info: StreamInfoAv,
cdm: Cdm,
) -> DecryptionKeyAv:
decryption_key_video = await AppleMusicInterface.get_decryption_key(
self,
stream_info.video_track.widevine_pssh,
stream_info.media_id,
cdm,
)
decryption_key_audio = await AppleMusicInterface.get_decryption_key(
self,
stream_info.audio_track.widevine_pssh,
stream_info.media_id,
cdm,
)
return DecryptionKeyAv(
video_track=decryption_key_video,
audio_track=decryption_key_audio,
)
+489
View File
@@ -0,0 +1,489 @@
import asyncio
import base64
import datetime
import io
import json
import logging
import re
from xml.dom import minidom
from xml.etree import ElementTree
import m3u8
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from mutagen.mp4 import MP4
from pywidevine import PSSH, Cdm
from pywidevine.license_protocol_pb2 import WidevinePsshData
from ..utils import get_response
from .constants import DRM_DEFAULT_KEY_MAPPING, MP4_FORMAT_CODECS, SONG_CODEC_REGEX_MAP
from .enums import MediaRating, MediaType, SongCodec, SyncedLyricsFormat
from .interface import AppleMusicInterface
from .types import (
DecryptionKey,
DecryptionKeyAv,
Lyrics,
MediaFileFormat,
MediaTags,
StreamInfo,
StreamInfoAv,
)
logger = logging.getLogger(__name__)
class AppleMusicSongInterface(AppleMusicInterface):
def __init__(self, interface: AppleMusicInterface):
self.__dict__.update(interface.__dict__)
async def get_lyrics(
self,
song_metadata: dict,
synced_lyrics_format: SyncedLyricsFormat,
) -> Lyrics | None:
if not song_metadata["attributes"]["hasLyrics"]:
return None
if (
"relationships" not in song_metadata
or "lyrics" not in song_metadata["relationships"]
):
song_metadata = (
await self.apple_music_api.get_song(
self.get_media_id_of_library_media(song_metadata)
)
)["data"][0]
if (
"lyrics" in song_metadata["relationships"]
and "data" in song_metadata["relationships"]["lyrics"]
and len(song_metadata["relationships"]["lyrics"]["data"]) > 0
and "attributes" in song_metadata["relationships"]["lyrics"]["data"][0]
and song_metadata["relationships"]["lyrics"]["data"][0]["attributes"].get(
"ttml"
)
is not None
):
lyrics = self._get_lyrics(
song_metadata["relationships"]["lyrics"]["data"][0]["attributes"][
"ttml"
],
synced_lyrics_format,
)
logging.debug(f"Lyrics: {lyrics}")
return lyrics
def _get_lyrics(
self,
lyrics_ttml: str,
synced_lyrics_format: SyncedLyricsFormat,
) -> 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:
stanza.append(p.text)
if p.attrib.get("begin"):
if synced_lyrics_format == SyncedLyricsFormat.LRC:
synced_lyrics.append(self._get_lyrics_line_lrc(p))
if synced_lyrics_format == SyncedLyricsFormat.SRT:
synced_lyrics.append(self._get_lyrics_line_srt(index, p))
if 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"]) if synced_lyrics else None,
unsynced=(
"\n\n".join(["\n".join(lyric_group) for lyric_group in unsynced_lyrics])
if unsynced_lyrics
else None
),
)
def _parse_ttml_timestamp(
self,
timestamp_ttml: str,
) -> datetime.datetime:
mins_secs_ms = re.findall(r"\d+", timestamp_ttml)
ms, secs, mins = 0, 0, 0
if len(mins_secs_ms) == 2 and ":" in timestamp_ttml:
secs, mins = int(mins_secs_ms[-1]), int(mins_secs_ms[-2])
elif len(mins_secs_ms) == 1:
ms = int(mins_secs_ms[-1])
else:
secs = float(f"{mins_secs_ms[-2]}.{mins_secs_ms[-1]}")
if len(mins_secs_ms) > 2:
mins = int(mins_secs_ms[-3])
return datetime.datetime.fromtimestamp(
(mins * 60) + secs + (ms / 1000),
tz=datetime.timezone.utc,
)
def _get_lyrics_line_srt(self, index: int, element: ElementTree.Element) -> str:
timestamp_begin_ttml = element.attrib.get("begin")
timestamp_end_ttml = element.attrib.get("end")
text = element.text
timestamp_begin = self._parse_ttml_timestamp(timestamp_begin_ttml)
timestamp_end = self._parse_ttml_timestamp(timestamp_end_ttml)
return (
f"{index}\n"
f"{timestamp_begin.strftime('%H:%M:%S,%f')[:-3]} --> "
f"{timestamp_end.strftime('%H:%M:%S,%f')[:-3]}\n"
f"{text}\n"
)
def _get_lyrics_line_lrc(self, element: ElementTree.Element) -> str:
timestamp_ttml = element.attrib.get("begin")
text = element.text
timestamp = self._parse_ttml_timestamp(timestamp_ttml)
ms_new = timestamp.strftime("%f")[:-3]
if int(ms_new[-1]) >= 5:
ms = int(f"{int(ms_new[:2]) + 1}") * 10
timestamp += datetime.timedelta(milliseconds=ms) - datetime.timedelta(
microseconds=timestamp.microsecond
)
return f"[{timestamp.strftime('%M:%S.%f')[:-4]}]{text}"
async def get_tags(
self,
webplayback: dict,
lyrics: str | None = None,
use_album_date: bool = False,
) -> MediaTags:
webplayback_metadata = webplayback["songList"][0]["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
),
composer_sort=webplayback_metadata.get("sort-composer"),
copyright=webplayback_metadata.get("copyright"),
date=(
await self.get_media_date(webplayback_metadata["playlistId"])
if use_album_date
else (
self.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 if lyrics 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"),
)
logger.debug(f"Tags: {tags}")
return tags
async def get_stream_info(
self,
song_metadata: dict,
codec: SongCodec,
) -> StreamInfoAv | None:
if "extendedAssetUrls" not in song_metadata["attributes"]:
song_metadata = (
await self.apple_music_api.get_song(
self.get_media_id_of_library_media(song_metadata),
)
)["data"][0]
m3u8_master_url = song_metadata["attributes"]["extendedAssetUrls"].get(
"enhancedHls"
)
if not m3u8_master_url:
return None
m3u8_master_obj = m3u8.loads((await get_response(m3u8_master_url)).text)
m3u8_master_data = m3u8_master_obj.data
if codec == SongCodec.ASK:
playlist = await self._get_playlist_from_user(m3u8_master_data)
else:
playlist = self._get_playlist_from_codec(
m3u8_master_data,
codec,
)
if playlist is None:
return None
stream_info = StreamInfo()
stream_info.stream_url = (
f"{m3u8_master_url.rpartition('/')[0]}/{playlist['uri']}"
)
stream_info.codec = playlist["stream_info"]["codecs"]
is_mp4 = any(stream_info.codec.startswith(codec) for codec in 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 = self._get_drm_uri_from_session_key(
session_key_metadata,
drm_ids,
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
)
stream_info.playready_pssh = self._get_drm_uri_from_session_key(
session_key_metadata,
drm_ids,
"com.microsoft.playready",
)
stream_info.fairplay_key = self._get_drm_uri_from_session_key(
session_key_metadata,
drm_ids,
"com.apple.streamingkeydelivery",
)
else:
m3u8_obj = m3u8.loads((await get_response(stream_info.stream_url)).text)
stream_info.widevine_pssh = self._get_drm_uri_from_m3u8_keys(
m3u8_obj,
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
)
stream_info.playready_pssh = self._get_drm_uri_from_m3u8_keys(
m3u8_obj,
"com.microsoft.playready",
)
stream_info.fairplay_key = self._get_drm_uri_from_m3u8_keys(
m3u8_obj,
"com.apple.streamingkeydelivery",
)
stream_info_av = StreamInfoAv(
audio_track=stream_info,
file_format=MediaFileFormat.MP4 if is_mp4 else MediaFileFormat.M4A,
)
logger.debug(f"Stream info: {stream_info_av}")
return stream_info_av
def _get_m3u8_metadata(self, m3u8_data: dict, data_id: str) -> dict | None:
for session_data in m3u8_data.get("session_data", []):
if session_data["data_id"] == data_id:
return json.loads(
base64.b64decode(session_data["value"]).decode("utf-8")
)
return None
def _get_audio_session_key_metadata(self, m3u8_data: dict) -> dict | None:
return self._get_m3u8_metadata(
m3u8_data,
"com.apple.hls.AudioSessionKeyInfo",
)
def _get_asset_metadata(self, m3u8_data: dict) -> dict | None:
return self._get_m3u8_metadata(
m3u8_data,
"com.apple.hls.audioAssetMetadata",
)
def _get_playlist_from_codec(
self, m3u8_data: dict, codec: SongCodec
) -> dict | None:
matching_playlists = [
playlist
for playlist in m3u8_data["playlists"]
if re.fullmatch(
SONG_CODEC_REGEX_MAP[codec.value], playlist["stream_info"]["audio"]
)
]
if not matching_playlists:
return None
return max(
matching_playlists,
key=lambda x: x["stream_info"]["average_bandwidth"],
)
async def _get_playlist_from_user(self, m3u8_data: dict) -> dict | None:
choices = [
Choice(
name=playlist["stream_info"]["audio"],
value=playlist,
)
for playlist in m3u8_data["playlists"]
]
return await inquirer.select(
message="Select which codec to download:",
choices=choices,
).execute_async()
def _get_drm_uri_from_session_key(
self,
drm_infos: dict,
drm_ids: list,
drm_key: str,
) -> str | None:
for drm_id in drm_ids:
if drm_id != "1" and drm_key in drm_infos.get(drm_id, {}):
return drm_infos[drm_id][drm_key]["URI"]
return None
def _get_drm_uri_from_m3u8_keys(
self,
m3u8_obj: m3u8.M3U8,
drm_key: str,
) -> str | None:
default_uri = DRM_DEFAULT_KEY_MAPPING[drm_key]
for key in m3u8_obj.keys:
if key.keyformat == drm_key and key.uri != default_uri:
return key.uri
return None
async def get_stream_info_legacy(
self,
webplayback: dict,
codec: SongCodec,
) -> StreamInfoAv:
flavor = "32:ctrp64" if codec == SongCodec.AAC_HE_LEGACY else "28:ctrp256"
stream_info = StreamInfo()
stream_info.stream_url = next(
i for i in webplayback["songList"][0]["assets"] if i["flavor"] == flavor
)["URL"]
m3u8_obj = m3u8.loads((await get_response(stream_info.stream_url)).text)
stream_info.widevine_pssh = m3u8_obj.keys[0].uri
stream_info_av = StreamInfoAv(
media_id=webplayback["songList"][0]["songId"],
audio_track=stream_info,
file_format=MediaFileFormat.M4A,
)
logger.debug(f"Stream info legacy: {stream_info_av}")
return stream_info_av
async def get_decryption_key_legacy(
self,
stream_info: StreamInfoAv,
cdm: Cdm,
) -> DecryptionKeyAv:
stream_info_audio = stream_info.audio_track
try:
cdm_session = 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(
await asyncio.to_thread(
cdm.get_license_challenge, cdm_session, pssh_obj
)
).decode()
license_response = await self.apple_music_api.get_license_exchange(
stream_info.media_id,
stream_info.audio_track.widevine_pssh,
challenge,
)
await asyncio.to_thread(
cdm.parse_license, cdm_session, license_response["license"]
)
decryption_key = next(
i for i in cdm.get_keys(cdm_session) if i.type == "CONTENT"
)
finally:
cdm.close(cdm_session)
decryption_key = DecryptionKeyAv(
audio_track=DecryptionKey(
kid=decryption_key.kid.hex,
key=decryption_key.key.hex(),
)
)
logger.debug(f"Decryption key legacy: {decryption_key}")
return decryption_key
async def get_decryption_key(
self,
stream_info: StreamInfoAv,
cdm: Cdm,
) -> DecryptionKeyAv:
return DecryptionKeyAv(
audio_track=await AppleMusicInterface.get_decryption_key(
self,
stream_info.audio_track.widevine_pssh,
stream_info.media_id,
cdm,
)
)
async def get_extra_tags(
self,
song_metadata: dict,
) -> dict:
previews = song_metadata["attributes"].get("previews", [])
if not previews:
return {}
preview_url = previews[0]["url"]
preview_response = await get_response(preview_url)
preview_bytes = preview_response.content
preview_tags = dict(MP4(io.BytesIO(preview_bytes)).tags)
logger.debug(f"Extra tags: {preview_tags.keys()}")
return preview_tags
@@ -0,0 +1,86 @@
import logging
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from ..interface.enums import UploadedVideoQuality
from ..interface.types import MediaTags
from .constants import UPLOADED_VIDEO_QUALITY_RANK
from .interface import AppleMusicInterface
from .types import MediaFileFormat, StreamInfo, StreamInfoAv
logger = logging.getLogger(__name__)
class AppleMusicUploadedVideoInterface(AppleMusicInterface):
def __init__(self, interface: AppleMusicInterface):
self.__dict__.update(interface.__dict__)
def get_stream_url_best(self, metadata: dict) -> str:
best_quality = next(
(
quality
for quality in UPLOADED_VIDEO_QUALITY_RANK
if metadata["attributes"]["assetTokens"].get(quality)
),
None,
)
return metadata["attributes"]["assetTokens"][best_quality]
async def get_stream_url_from_user(self, metadata: dict) -> str:
qualities = list(metadata["attributes"]["assetTokens"].keys())
choices = [
Choice(
name=quality,
value=quality,
)
for quality in qualities
]
selected = await inquirer.select(
message="Select which quality to download:",
choices=choices,
).execute_async()
return metadata["attributes"]["assetTokens"][selected]
async def get_stream_url(
self, metadata: dict, quality: UploadedVideoQuality
) -> str:
if quality == UploadedVideoQuality.BEST:
stream_url = self.get_stream_url_best(metadata)
if quality == UploadedVideoQuality.ASK:
stream_url = await self.get_stream_url_from_user(metadata)
logger.debug(f"Stream URL: {stream_url}")
return stream_url
async def get_stream_info(
self,
metadata: dict,
quality: UploadedVideoQuality,
) -> StreamInfo:
stream_url = await self.get_stream_url(metadata, quality)
stream_info = StreamInfoAv(
file_format=MediaFileFormat.M4V,
video_track=StreamInfo(
stream_url=stream_url,
),
)
return stream_info
def get_tags(self, metadata: dict) -> MediaTags:
attributes = metadata["attributes"]
upload_date = attributes.get("uploadDate")
tags = MediaTags(
artist=attributes.get("artistName"),
date=self.parse_date(upload_date) if upload_date else None,
title=attributes.get("name"),
title_id=int(metadata["id"]),
storefront=int(self.itunes_api.storefront_id.split("-")[0]),
)
logger.debug(f"Tags: {tags}")
return tags
+143
View File
@@ -0,0 +1,143 @@
import datetime
from dataclasses import dataclass
from .enums import MediaFileFormat, MediaRating, MediaType
@dataclass
class Lyrics:
synced: str = None
unsynced: str = 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 as_mp4_tags(self, date_format: str = None) -> dict:
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 and disc_mp4[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 and track_mp4[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
else:
date_mp4 = None
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] if not isinstance(v, bool) else v)
for k, v in mp4_tags.items()
if v is not None
}
@dataclass
class PlaylistTags:
playlist_artist: str = None
playlist_id: int = None
playlist_title: str = None
playlist_track: int = None
@dataclass
class StreamInfo:
stream_url: str = None
widevine_pssh: str = None
playready_pssh: str = None
fairplay_key: str = None
codec: str = None
width: int = None
height: int = None
@dataclass
class StreamInfoAv:
media_id: str = None
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
-85
View File
@@ -1,85 +0,0 @@
from __future__ import annotations
import functools
import requests
from .constants import STOREFRONT_IDS
from .utils import raise_response_exception
class ItunesApi:
ITUNES_LOOKUP_API_URL = "https://itunes.apple.com/lookup"
ITUNES_PAGE_API_URL = "https://music.apple.com"
def __init__(
self,
storefront: str = "us",
language: str = "en-US",
):
self.storefront = storefront
self.language = language
self._setup_session()
def _setup_session(self):
try:
self.storefront_id = STOREFRONT_IDS[self.storefront.upper()]
except KeyError:
raise Exception(f"No storefront id for {self.storefront}")
self.session = requests.Session()
self.session.params = {
"country": self.storefront,
"lang": self.language,
}
self.session.headers = {
"X-Apple-Store-Front": f"{self.storefront_id} t:music31",
}
@functools.lru_cache()
def get_resource(
self,
resource_id: str,
entity: str = "album",
) -> dict:
response = self.session.get(
self.ITUNES_LOOKUP_API_URL,
params={
"id": resource_id,
"entity": entity,
},
)
try:
response.raise_for_status()
response_dict = response.json()
resource = response_dict.get("results")
assert resource
except (
requests.HTTPError,
requests.exceptions.JSONDecodeError,
AssertionError,
):
raise_response_exception(response)
return resource
def get_itunes_page(
self,
resource_type: str,
resource_id: str,
) -> dict:
response = self.session.get(
f"{self.ITUNES_PAGE_API_URL}/{resource_type}/{resource_id}"
)
try:
response.raise_for_status()
response_dict = response.json()
itunes_page = response_dict["storePlatformData"]["product-dv"][
"results"
].get(resource_id)
assert itunes_page
except (
requests.HTTPError,
requests.exceptions.JSONDecodeError,
AssertionError,
):
raise_response_exception(response)
return itunes_page
-31
View File
@@ -1,31 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class UrlInfo:
storefront: str = None
type: str = None
id: str = None
@dataclass
class DownloadQueue:
playlist_attributes: dict = None
tracks_metadata: list[dict] = None
@dataclass
class Lyrics:
synced: str = None
unsynced: str = None
@dataclass
class StreamInfo:
stream_url: str = None
widevine_pssh: str = None
playready_pssh: str = None
fairplay_key: str = None
codec: str = None
+93 -21
View File
@@ -1,29 +1,101 @@
from pathlib import Path
import asyncio
import json
import string
import subprocess
import typing
import click
import colorama
import requests
from .constants import X_NOT_FOUND_STRING
import httpx
def color_text(text: str, color) -> str:
return color + text + colorama.Style.RESET_ALL
def raise_for_status(httpx_response: httpx.Response, valid_responses: set[int] = {200}):
if httpx_response.status_code not in valid_responses:
raise httpx._exceptions.HTTPError(
f"HTTP error {httpx_response.status_code}: {httpx_response.text}"
)
def raise_response_exception(response: requests.Response):
raise Exception(
f"Request failed with status code {response.status_code}: {response.text}"
def safe_json(httpx_response: httpx.Response) -> dict | None:
try:
return httpx_response.json()
except (json.JSONDecodeError, UnicodeDecodeError):
return None
async def get_response(
url: str,
valid_responses: set[int] = {200},
) -> httpx.Response:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.get(url)
raise_for_status(response, valid_responses)
return response
async def async_subprocess(*args: str, silent: bool = False) -> None:
if silent:
additional_args = {
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
}
else:
additional_args = {}
proc = await asyncio.create_subprocess_exec(
*args,
**additional_args,
)
await proc.communicate()
if proc.returncode != 0:
raise Exception(f'"{args[0]}" exited with code {proc.returncode}')
async def safe_gather(
*tasks: typing.Awaitable[typing.Any],
limit: int = 10,
) -> list[typing.Any]:
semaphore = asyncio.Semaphore(limit)
async def bounded_task(task: typing.Awaitable[typing.Any]) -> typing.Any:
async with semaphore:
return await task
return await asyncio.gather(
*(bounded_task(task) for task in tasks),
return_exceptions=True,
)
def prompt_path(path_description: str, path_obj: Path) -> Path:
while not path_obj.exists():
path_obj_str = click.prompt(
X_NOT_FOUND_STRING.format(path_description, path_obj.absolute())
+ ". Move it to that location or drag and drop it here. Then, press enter to continue",
default=str(path_obj),
show_default=False,
)
path_obj = Path(path_obj_str.strip('"'))
return path_obj
async def sequential_gather(
*tasks: typing.Awaitable[typing.Any],
interval: float = 0.5,
) -> list[typing.Any]:
results = []
for i, task in enumerate(tasks):
try:
result = await task
results.append(result)
except Exception as e:
results.append(e)
if interval > 0 and i < len(tasks) - 1:
await asyncio.sleep(interval)
return results
class CustomStringFormatter(string.Formatter):
def format_field(self, value: typing.Any, format_spec: str) -> str:
if isinstance(value, tuple) and len(value) == 2:
actual_value, fallback_value = value
if actual_value is None:
return fallback_value
try:
return super().format_field(actual_value, format_spec)
except Exception:
return fallback_value
return super().format_field(value, format_spec)
class GamdlError(Exception):
pass
+19 -21
View File
@@ -1,28 +1,26 @@
[project]
name = "gamdl"
description = "A Python CLI app for downloading Apple Music songs, music videos and post videos."
requires-python = ">=3.9"
authors = [{ name = "glomatico" }]
dependencies = [
"click",
"colorama",
"inquirerpy",
"m3u8",
"mutagen",
"pillow",
"pywidevine",
"yt-dlp",
]
version = "2.9"
description = "A command-line app for downloading Apple Music songs, music videos and post videos."
readme = "README.md"
dynamic = ["version"]
license = "MIT"
requires-python = ">=3.10"
dependencies = [
"async-lru>=2.0.5",
"click>=8.3.0",
"colorama>=0.4.6",
"dataclass-click>=1.0.4",
"httpx>=0.28.1",
"inquirerpy>=0.3.4",
"m3u8>=6.0.0",
"mutagen>=1.47.0",
"pillow>=12.0.0",
"pywidevine>=1.8.0",
"yt-dlp>=2025.10.22",
]
[project.urls]
homepage = "https://github.com/glomatico/gamdl"
repository = "https://github.com/glomatico/gamdl"
[build-system]
requires = ["flit_core"]
build-backend = "flit_core.buildapi"
Repository = "https://github.com/glomatico/gamdl"
[project.scripts]
gamdl = "gamdl.cli:main"
gamdl = "gamdl.cli.cli:main"
-10
View File
@@ -1,10 +0,0 @@
click
colorama
inquirerpy
m3u8
mutagen
pillow
pywidevine
pyyaml
termcolor
yt-dlp
Generated
+657
View File
@@ -0,0 +1,657 @@
version = 1
revision = 3
requires-python = ">=3.10"
[[package]]
name = "anyio"
version = "4.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "idna" },
{ name = "sniffio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
]
[[package]]
name = "async-lru"
version = "2.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/4d/71ec4d3939dc755264f680f6c2b4906423a304c3d18e96853f0a595dfe97/async_lru-2.0.5.tar.gz", hash = "sha256:481d52ccdd27275f42c43a928b4a50c3bfb2d67af4e78b170e3e0bb39c66e5bb", size = 10380, upload-time = "2025-03-16T17:25:36.919Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/49/d10027df9fce941cb8184e78a02857af36360d33e1721df81c5ed2179a1a/async_lru-2.0.5-py3-none-any.whl", hash = "sha256:ab95404d8d2605310d345932697371a5f40def0487c03d6d0ad9138de52c9943", size = 6069, upload-time = "2025-03-16T17:25:35.422Z" },
]
[[package]]
name = "backports-datetime-fromisoformat"
version = "2.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/71/81/eff3184acb1d9dc3ce95a98b6f3c81a49b4be296e664db8e1c2eeabef3d9/backports_datetime_fromisoformat-2.0.3.tar.gz", hash = "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d", size = 23588, upload-time = "2024-12-28T20:18:15.017Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/4b/d6b051ca4b3d76f23c2c436a9669f3be616b8cf6461a7e8061c7c4269642/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f681f638f10588fa3c101ee9ae2b63d3734713202ddfcfb6ec6cea0778a29d4", size = 27561, upload-time = "2024-12-28T20:16:47.974Z" },
{ url = "https://files.pythonhosted.org/packages/6d/40/e39b0d471e55eb1b5c7c81edab605c02f71c786d59fb875f0a6f23318747/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:cd681460e9142f1249408e5aee6d178c6d89b49e06d44913c8fdfb6defda8d1c", size = 34448, upload-time = "2024-12-28T20:16:50.712Z" },
{ url = "https://files.pythonhosted.org/packages/f2/28/7a5c87c5561d14f1c9af979231fdf85d8f9fad7a95ff94e56d2205e2520a/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ee68bc8735ae5058695b76d3bb2aee1d137c052a11c8303f1e966aa23b72b65b", size = 27093, upload-time = "2024-12-28T20:16:52.994Z" },
{ url = "https://files.pythonhosted.org/packages/80/ba/f00296c5c4536967c7d1136107fdb91c48404fe769a4a6fd5ab045629af8/backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8273fe7932db65d952a43e238318966eab9e49e8dd546550a41df12175cc2be4", size = 52836, upload-time = "2024-12-28T20:16:55.283Z" },
{ url = "https://files.pythonhosted.org/packages/e3/92/bb1da57a069ddd601aee352a87262c7ae93467e66721d5762f59df5021a6/backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d57ea50aa5a524bb239688adc1d1d824c31b6094ebd39aa164d6cadb85de22", size = 52798, upload-time = "2024-12-28T20:16:56.64Z" },
{ url = "https://files.pythonhosted.org/packages/df/ef/b6cfd355982e817ccdb8d8d109f720cab6e06f900784b034b30efa8fa832/backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac6272f87693e78209dc72e84cf9ab58052027733cd0721c55356d3c881791cf", size = 52891, upload-time = "2024-12-28T20:16:58.887Z" },
{ url = "https://files.pythonhosted.org/packages/37/39/b13e3ae8a7c5d88b68a6e9248ffe7066534b0cfe504bf521963e61b6282d/backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:44c497a71f80cd2bcfc26faae8857cf8e79388e3d5fbf79d2354b8c360547d58", size = 52955, upload-time = "2024-12-28T20:17:00.028Z" },
{ url = "https://files.pythonhosted.org/packages/1e/e4/70cffa3ce1eb4f2ff0c0d6f5d56285aacead6bd3879b27a2ba57ab261172/backports_datetime_fromisoformat-2.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:6335a4c9e8af329cb1ded5ab41a666e1448116161905a94e054f205aa6d263bc", size = 29323, upload-time = "2024-12-28T20:17:01.125Z" },
{ url = "https://files.pythonhosted.org/packages/62/f5/5bc92030deadf34c365d908d4533709341fb05d0082db318774fdf1b2bcb/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2e4b66e017253cdbe5a1de49e0eecff3f66cd72bcb1229d7db6e6b1832c0443", size = 27626, upload-time = "2024-12-28T20:17:03.448Z" },
{ url = "https://files.pythonhosted.org/packages/28/45/5885737d51f81dfcd0911dd5c16b510b249d4c4cf6f4a991176e0358a42a/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:43e2d648e150777e13bbc2549cc960373e37bf65bd8a5d2e0cef40e16e5d8dd0", size = 34588, upload-time = "2024-12-28T20:17:04.459Z" },
{ url = "https://files.pythonhosted.org/packages/bc/6d/bd74de70953f5dd3e768c8fc774af942af0ce9f211e7c38dd478fa7ea910/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4ce6326fd86d5bae37813c7bf1543bae9e4c215ec6f5afe4c518be2635e2e005", size = 27162, upload-time = "2024-12-28T20:17:06.752Z" },
{ url = "https://files.pythonhosted.org/packages/47/ba/1d14b097f13cce45b2b35db9898957578b7fcc984e79af3b35189e0d332f/backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7c8fac333bf860208fd522a5394369ee3c790d0aa4311f515fcc4b6c5ef8d75", size = 54482, upload-time = "2024-12-28T20:17:08.15Z" },
{ url = "https://files.pythonhosted.org/packages/25/e9/a2a7927d053b6fa148b64b5e13ca741ca254c13edca99d8251e9a8a09cfe/backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4da5ab3aa0cc293dc0662a0c6d1da1a011dc1edcbc3122a288cfed13a0b45", size = 54362, upload-time = "2024-12-28T20:17:10.605Z" },
{ url = "https://files.pythonhosted.org/packages/c1/99/394fb5e80131a7d58c49b89e78a61733a9994885804a0bb582416dd10c6f/backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:58ea11e3bf912bd0a36b0519eae2c5b560b3cb972ea756e66b73fb9be460af01", size = 54162, upload-time = "2024-12-28T20:17:12.301Z" },
{ url = "https://files.pythonhosted.org/packages/88/25/1940369de573c752889646d70b3fe8645e77b9e17984e72a554b9b51ffc4/backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8a375c7dbee4734318714a799b6c697223e4bbb57232af37fbfff88fb48a14c6", size = 54118, upload-time = "2024-12-28T20:17:13.609Z" },
{ url = "https://files.pythonhosted.org/packages/b7/46/f275bf6c61683414acaf42b2df7286d68cfef03e98b45c168323d7707778/backports_datetime_fromisoformat-2.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:ac677b1664c4585c2e014739f6678137c8336815406052349c85898206ec7061", size = 29329, upload-time = "2024-12-28T20:17:16.124Z" },
{ url = "https://files.pythonhosted.org/packages/a2/0f/69bbdde2e1e57c09b5f01788804c50e68b29890aada999f2b1a40519def9/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147", size = 27630, upload-time = "2024-12-28T20:17:19.442Z" },
{ url = "https://files.pythonhosted.org/packages/d5/1d/1c84a50c673c87518b1adfeafcfd149991ed1f7aedc45d6e5eac2f7d19d7/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4", size = 34707, upload-time = "2024-12-28T20:17:21.79Z" },
{ url = "https://files.pythonhosted.org/packages/71/44/27eae384e7e045cda83f70b551d04b4a0b294f9822d32dea1cbf1592de59/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35", size = 27280, upload-time = "2024-12-28T20:17:24.503Z" },
{ url = "https://files.pythonhosted.org/packages/a7/7a/a4075187eb6bbb1ff6beb7229db5f66d1070e6968abeb61e056fa51afa5e/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1", size = 55094, upload-time = "2024-12-28T20:17:25.546Z" },
{ url = "https://files.pythonhosted.org/packages/71/03/3fced4230c10af14aacadc195fe58e2ced91d011217b450c2e16a09a98c8/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32", size = 55605, upload-time = "2024-12-28T20:17:29.208Z" },
{ url = "https://files.pythonhosted.org/packages/f6/0a/4b34a838c57bd16d3e5861ab963845e73a1041034651f7459e9935289cfd/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d", size = 55353, upload-time = "2024-12-28T20:17:32.433Z" },
{ url = "https://files.pythonhosted.org/packages/d9/68/07d13c6e98e1cad85606a876367ede2de46af859833a1da12c413c201d78/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7", size = 55298, upload-time = "2024-12-28T20:17:34.919Z" },
{ url = "https://files.pythonhosted.org/packages/60/33/45b4d5311f42360f9b900dea53ab2bb20a3d61d7f9b7c37ddfcb3962f86f/backports_datetime_fromisoformat-2.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d", size = 29375, upload-time = "2024-12-28T20:17:36.018Z" },
{ url = "https://files.pythonhosted.org/packages/be/03/7eaa9f9bf290395d57fd30d7f1f2f9dff60c06a31c237dc2beb477e8f899/backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90e202e72a3d5aae673fcc8c9a4267d56b2f532beeb9173361293625fe4d2039", size = 28980, upload-time = "2024-12-28T20:18:06.554Z" },
{ url = "https://files.pythonhosted.org/packages/47/80/a0ecf33446c7349e79f54cc532933780341d20cff0ee12b5bfdcaa47067e/backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2df98ef1b76f5a58bb493dda552259ba60c3a37557d848e039524203951c9f06", size = 28449, upload-time = "2024-12-28T20:18:07.77Z" },
]
[[package]]
name = "certifi"
version = "2025.10.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" },
{ url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" },
{ url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" },
{ url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" },
{ url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" },
{ url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" },
{ url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" },
{ url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" },
{ url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" },
{ url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" },
{ url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" },
{ url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" },
{ url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" },
{ url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" },
{ url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" },
{ url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" },
{ url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" },
{ url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" },
{ url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" },
{ url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" },
{ url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" },
{ url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" },
{ url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" },
{ url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" },
{ url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" },
{ url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" },
{ url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" },
{ url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" },
{ url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" },
{ url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" },
{ url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
{ url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
{ url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
{ url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
{ url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
{ url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
{ url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
{ url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
{ url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
{ url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
{ url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
{ url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
{ url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
{ url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
[[package]]
name = "click"
version = "8.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "construct"
version = "2.8.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/2c/66bab4fef920ef8caa3e180ea601475b2cbbe196255b18f1c58215940607/construct-2.8.8.tar.gz", hash = "sha256:1b84b8147f6fd15bcf64b737c3e8ac5100811ad80c830cb4b2545140511c4157", size = 717694, upload-time = "2016-10-20T22:29:12.563Z" }
[[package]]
name = "dataclass-click"
version = "1.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
]
sdist = { url = "https://files.pythonhosted.org/packages/89/82/5b6035efd90621771fa039960eab3e1ec7ff2a8625033272856843e8bd27/dataclass_click-1.0.4.tar.gz", hash = "sha256:10e7de638dd9e68ae9abd5086f61d8ddee42b1873a70f5fd9fd2167856afac11", size = 7580, upload-time = "2025-10-10T21:11:31.956Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/86/dc/38a94a2eb5f756724a6dc87a7aea38f7b747fe7b2e9daabc34a65e6cd9ac/dataclass_click-1.0.4-py3-none-any.whl", hash = "sha256:a225d30c04e4abbdba411cc3d5ec0a2ea829e1dca6500afe5f87cc243e5ead72", size = 8553, upload-time = "2025-10-10T21:11:30.514Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
]
[[package]]
name = "gamdl"
version = "2.9"
source = { virtual = "." }
dependencies = [
{ name = "async-lru" },
{ name = "click" },
{ name = "colorama" },
{ name = "dataclass-click" },
{ name = "httpx" },
{ name = "inquirerpy" },
{ name = "m3u8" },
{ name = "mutagen" },
{ name = "pillow" },
{ name = "pywidevine" },
{ name = "yt-dlp" },
]
[package.metadata]
requires-dist = [
{ name = "async-lru", specifier = ">=2.0.5" },
{ name = "click", specifier = ">=8.3.0" },
{ name = "colorama", specifier = ">=0.4.6" },
{ name = "dataclass-click", specifier = ">=1.0.4" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "inquirerpy", specifier = ">=0.3.4" },
{ name = "m3u8", specifier = ">=6.0.0" },
{ name = "mutagen", specifier = ">=1.47.0" },
{ name = "pillow", specifier = ">=12.0.0" },
{ name = "pywidevine", specifier = ">=1.8.0" },
{ name = "yt-dlp", specifier = ">=2025.10.22" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "inquirerpy"
version = "0.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pfzy" },
{ name = "prompt-toolkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/64/73/7570847b9da026e07053da3bbe2ac7ea6cde6bb2cbd3c7a5a950fa0ae40b/InquirerPy-0.3.4.tar.gz", hash = "sha256:89d2ada0111f337483cb41ae31073108b2ec1e618a49d7110b0d7ade89fc197e", size = 44431, upload-time = "2022-06-27T23:11:20.598Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/ff/3b59672c47c6284e8005b42e84ceba13864aa0f39f067c973d1af02f5d91/InquirerPy-0.3.4-py3-none-any.whl", hash = "sha256:c65fdfbac1fa00e3ee4fb10679f4d3ed7a012abf4833910e63c295827fe2a7d4", size = 67677, upload-time = "2022-06-27T23:11:17.723Z" },
]
[[package]]
name = "m3u8"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-datetime-fromisoformat", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/a5/73697aaa99bb32b610adc1f11d46a0c0c370351292e9b271755084a145e6/m3u8-6.0.0.tar.gz", hash = "sha256:7ade990a1667d7a653bcaf9413b16c3eb5cd618982ff46aaff57fe6d9fa9c0fd", size = 42720, upload-time = "2024-08-07T11:20:06.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/31/50f3c38b38ff28635ff9c4a4afefddccc5f1b57457b539bdbdf75ce18669/m3u8-6.0.0-py3-none-any.whl", hash = "sha256:566d0748739c552dad10f8c87150078de6a0ec25071fa48e6968e96fc6dcba5d", size = 24133, upload-time = "2024-08-07T11:20:03.96Z" },
]
[[package]]
name = "mutagen"
version = "1.47.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/81/e6/64bc71b74eef4b68e61eb921dcf72dabd9e4ec4af1e11891bbd312ccbb77/mutagen-1.47.0.tar.gz", hash = "sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99", size = 1274186, upload-time = "2023-09-03T16:33:33.411Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/7a/620f945b96be1f6ee357d211d5bf74ab1b7fe72a9f1525aafbfe3aee6875/mutagen-1.47.0-py3-none-any.whl", hash = "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719", size = 194391, upload-time = "2023-09-03T16:33:29.955Z" },
]
[[package]]
name = "pfzy"
version = "0.3.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d9/5a/32b50c077c86bfccc7bed4881c5a2b823518f5450a30e639db5d3711952e/pfzy-0.3.4.tar.gz", hash = "sha256:717ea765dd10b63618e7298b2d98efd819e0b30cd5905c9707223dceeb94b3f1", size = 8396, upload-time = "2022-01-28T02:26:17.946Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/d7/8ff98376b1acc4503253b685ea09981697385ce344d4e3935c2af49e044d/pfzy-0.3.4-py3-none-any.whl", hash = "sha256:5f50d5b2b3207fa72e7ec0ef08372ef652685470974a107d0d4999fc5a903a96", size = 8537, upload-time = "2022-01-28T02:26:16.047Z" },
]
[[package]]
name = "pillow"
version = "12.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/08/26e68b6b5da219c2a2cb7b563af008b53bb8e6b6fcb3fa40715fcdb2523a/pillow-12.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b", size = 5289809, upload-time = "2025-10-15T18:21:27.791Z" },
{ url = "https://files.pythonhosted.org/packages/cb/e9/4e58fb097fb74c7b4758a680aacd558810a417d1edaa7000142976ef9d2f/pillow-12.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1", size = 4650606, upload-time = "2025-10-15T18:21:29.823Z" },
{ url = "https://files.pythonhosted.org/packages/4b/e0/1fa492aa9f77b3bc6d471c468e62bfea1823056bf7e5e4f1914d7ab2565e/pillow-12.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363", size = 6221023, upload-time = "2025-10-15T18:21:31.415Z" },
{ url = "https://files.pythonhosted.org/packages/c1/09/4de7cd03e33734ccd0c876f0251401f1314e819cbfd89a0fcb6e77927cc6/pillow-12.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca", size = 8024937, upload-time = "2025-10-15T18:21:33.453Z" },
{ url = "https://files.pythonhosted.org/packages/2e/69/0688e7c1390666592876d9d474f5e135abb4acb39dcb583c4dc5490f1aff/pillow-12.0.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e", size = 6334139, upload-time = "2025-10-15T18:21:35.395Z" },
{ url = "https://files.pythonhosted.org/packages/ed/1c/880921e98f525b9b44ce747ad1ea8f73fd7e992bafe3ca5e5644bf433dea/pillow-12.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782", size = 7026074, upload-time = "2025-10-15T18:21:37.219Z" },
{ url = "https://files.pythonhosted.org/packages/28/03/96f718331b19b355610ef4ebdbbde3557c726513030665071fd025745671/pillow-12.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10", size = 6448852, upload-time = "2025-10-15T18:21:39.168Z" },
{ url = "https://files.pythonhosted.org/packages/3a/a0/6a193b3f0cc9437b122978d2c5cbce59510ccf9a5b48825096ed7472da2f/pillow-12.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa", size = 7117058, upload-time = "2025-10-15T18:21:40.997Z" },
{ url = "https://files.pythonhosted.org/packages/a7/c4/043192375eaa4463254e8e61f0e2ec9a846b983929a8d0a7122e0a6d6fff/pillow-12.0.0-cp310-cp310-win32.whl", hash = "sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275", size = 6295431, upload-time = "2025-10-15T18:21:42.518Z" },
{ url = "https://files.pythonhosted.org/packages/92/c6/c2f2fc7e56301c21827e689bb8b0b465f1b52878b57471a070678c0c33cd/pillow-12.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d", size = 7000412, upload-time = "2025-10-15T18:21:44.404Z" },
{ url = "https://files.pythonhosted.org/packages/b2/d2/5f675067ba82da7a1c238a73b32e3fd78d67f9d9f80fbadd33a40b9c0481/pillow-12.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7", size = 2435903, upload-time = "2025-10-15T18:21:46.29Z" },
{ url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" },
{ url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" },
{ url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" },
{ url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" },
{ url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" },
{ url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" },
{ url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" },
{ url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" },
{ url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" },
{ url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" },
{ url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" },
{ url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" },
{ url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" },
{ url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" },
{ url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" },
{ url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" },
{ url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" },
{ url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" },
{ url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" },
{ url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" },
{ url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" },
{ url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" },
{ url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" },
{ url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" },
{ url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" },
{ url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" },
{ url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" },
{ url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" },
{ url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" },
{ url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" },
{ url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" },
{ url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" },
{ url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" },
{ url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" },
{ url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" },
{ url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" },
{ url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" },
{ url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" },
{ url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" },
{ url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" },
{ url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" },
{ url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" },
{ url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" },
{ url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" },
{ url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" },
{ url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" },
{ url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" },
{ url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" },
{ url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" },
{ url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" },
{ url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" },
{ url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" },
{ url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" },
{ url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" },
{ url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" },
{ url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" },
{ url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" },
{ url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" },
{ url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" },
{ url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" },
{ url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" },
{ url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" },
{ url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" },
{ url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" },
{ url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" },
{ url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" },
{ url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" },
{ url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" },
{ url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" },
{ url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" },
{ url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" },
{ url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" },
{ url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" },
{ url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" },
]
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
]
[[package]]
name = "protobuf"
version = "4.25.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/01/34c8d2b6354906d728703cb9d546a0e534de479e25f1b581e4094c4a85cc/protobuf-4.25.8.tar.gz", hash = "sha256:6135cf8affe1fc6f76cced2641e4ea8d3e59518d1f24ae41ba97bcad82d397cd", size = 380920, upload-time = "2025-05-28T14:22:25.153Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/ff/05f34305fe6b85bbfbecbc559d423a5985605cad5eda4f47eae9e9c9c5c5/protobuf-4.25.8-cp310-abi3-win32.whl", hash = "sha256:504435d831565f7cfac9f0714440028907f1975e4bed228e58e72ecfff58a1e0", size = 392745, upload-time = "2025-05-28T14:22:10.524Z" },
{ url = "https://files.pythonhosted.org/packages/08/35/8b8a8405c564caf4ba835b1fdf554da869954712b26d8f2a98c0e434469b/protobuf-4.25.8-cp310-abi3-win_amd64.whl", hash = "sha256:bd551eb1fe1d7e92c1af1d75bdfa572eff1ab0e5bf1736716814cdccdb2360f9", size = 413736, upload-time = "2025-05-28T14:22:13.156Z" },
{ url = "https://files.pythonhosted.org/packages/28/d7/ab27049a035b258dab43445eb6ec84a26277b16105b277cbe0a7698bdc6c/protobuf-4.25.8-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:ca809b42f4444f144f2115c4c1a747b9a404d590f18f37e9402422033e464e0f", size = 394537, upload-time = "2025-05-28T14:22:14.768Z" },
{ url = "https://files.pythonhosted.org/packages/bd/6d/a4a198b61808dd3d1ee187082ccc21499bc949d639feb948961b48be9a7e/protobuf-4.25.8-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:9ad7ef62d92baf5a8654fbb88dac7fa5594cfa70fd3440488a5ca3bfc6d795a7", size = 294005, upload-time = "2025-05-28T14:22:16.052Z" },
{ url = "https://files.pythonhosted.org/packages/d6/c6/c9deaa6e789b6fc41b88ccbdfe7a42d2b82663248b715f55aa77fbc00724/protobuf-4.25.8-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:83e6e54e93d2b696a92cad6e6efc924f3850f82b52e1563778dfab8b355101b0", size = 294924, upload-time = "2025-05-28T14:22:17.105Z" },
{ url = "https://files.pythonhosted.org/packages/0c/c1/6aece0ab5209981a70cd186f164c133fdba2f51e124ff92b73de7fd24d78/protobuf-4.25.8-py3-none-any.whl", hash = "sha256:15a0af558aa3b13efef102ae6e4f3efac06f1eea11afb3a57db2901447d9fb59", size = 156757, upload-time = "2025-05-28T14:22:24.135Z" },
]
[[package]]
name = "pycryptodome"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
{ url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" },
{ url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" },
{ url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" },
{ url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" },
{ url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" },
]
[[package]]
name = "pymp4"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "construct" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a5/46/dfb3f5363fc71adaf419147fdcb93341029ca638634a5cc6f7e7446416b2/pymp4-1.4.0.tar.gz", hash = "sha256:bc9e77732a8a143d34c38aa862a54180716246938e4bf3e07585d19252b77bb5", size = 13018, upload-time = "2023-05-07T15:01:34.02Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/a2/27fea39af627c0ce5dbf6108bf969ea8f5fc9376d29f11282a80e3426f1d/pymp4-1.4.0-py3-none-any.whl", hash = "sha256:3401666c1e2a97ac94dffb18c5a5dcbd46d0a436da5272d378a6f9f6506dd12d", size = 14832, upload-time = "2023-05-07T15:01:32.293Z" },
]
[[package]]
name = "pywidevine"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "protobuf" },
{ name = "pycryptodome" },
{ name = "pymp4" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "unidecode" },
]
sdist = { url = "https://files.pythonhosted.org/packages/99/12/6ff0e6ffa2711187ee629392396d7c18ae6ca8e2e576dcef2d636316d667/pywidevine-1.8.0.tar.gz", hash = "sha256:c14f3fe2864473416b9caa73d9a21251a02d72138e6d54d8c1a3f44b7a6b05c9", size = 76406, upload-time = "2023-12-22T11:13:12.556Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/9f/60f8a4c8e7767a8c34f5c42428662e03fa3e38ad18ba41fcc5370ee43263/pywidevine-1.8.0-py3-none-any.whl", hash = "sha256:1ecf029ce562789b18bbbd64604596d15645aadf413b255cf0fafc8d8b06659d", size = 70476, upload-time = "2023-12-22T11:13:10.84Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "unidecode"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149, upload-time = "2025-04-24T08:45:03.798Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837, upload-time = "2025-04-24T08:45:01.609Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
[[package]]
name = "wcwidth"
version = "0.2.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" },
]
[[package]]
name = "yt-dlp"
version = "2025.10.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/08/70/cf4bd6c837ab0a709040888caa70d166aa2dfbb5018d1d5c983bf0b50254/yt_dlp-2025.10.22.tar.gz", hash = "sha256:db2d48133222b1d9508c6de757859c24b5cefb9568cf68ccad85dac20b07f77b", size = 3046863, upload-time = "2025-10-22T19:53:19.301Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/2a/fd184bf97d570841aa86b4aeb84aee93e7957a34059dafd4982157c10bff/yt_dlp-2025.10.22-py3-none-any.whl", hash = "sha256:9c803a9598859f91d0d5bd3337f1506ecb40bbe97f6efbe93bc4461fed344fb2", size = 3248983, upload-time = "2025-10-22T19:53:16.483Z" },
]