Compare commits

...

1184 Commits

Author SHA1 Message Date
Rafael Moraes ccd51d4dc1 Clarify README note about wrapper login 2026-05-20 17:59:54 -03:00
Rafael Moraes 35b3013b87 Refactor wrapper related methods to WrapperApi 2026-05-20 17:52:06 -03:00
Rafael Moraes 8aeda0abff Note that wrapper can skip cookies 2026-05-20 17:38:02 -03:00
Rafael Moraes 30aeee90b8 Add use_cenc and use_single_content_key to StreamInfo 2026-05-20 16:57:50 -03:00
Rafael Moraes 67bdfe8584 Add song codec flavor mappings and properties 2026-05-20 16:57:34 -03:00
Rafael Moraes 97086adfbe Add CENC and single content key support 2026-05-20 16:57:25 -03:00
Rafael Moraes da7346f704 Add use_single_content_key and use_cenc options 2026-05-20 16:57:15 -03:00
Rafael Moraes 3dd829b38c Bump version 2026-05-20 15:03:22 -03:00
Rafael Moraes c503d482a7 Bump version 2026-05-20 15:03:03 -03:00
Rafael Moraes 46df1672d9 README: add subscription note & update cookie text 2026-05-20 15:02:09 -03:00
Rafael Moraes d61e315362 Remove redundant Optional Dependencies note 2026-05-20 14:58:52 -03:00
Rafael Moraes b787e64820 Clarify README: optional deps, wrapper & codecs 2026-05-20 14:57:36 -03:00
Rafael Moraes 31d6ba7c93 Clarify wrapper CLI option help 2026-05-20 14:57:26 -03:00
Rafael Moraes 4841b953a7 Run yt-dlp in separate process 2026-05-20 14:46:35 -03:00
Rafael Moraes ada986573d Use codec.is_web property in codec check 2026-05-20 14:31:48 -03:00
Rafael Moraes 8ea1373c83 Use use_prefetch_key flag; update song staging 2026-05-20 14:31:37 -03:00
Rafael Moraes b7fdf7356f Support web AAC codecs and web stream handling 2026-05-20 14:31:22 -03:00
Rafael Moraes fba6a72747 Return stream_info in AppleMusicSongInterface 2026-05-20 08:27:10 -03:00
Rafael Moraes 48df71271b Add native music video muxing 2026-05-20 07:57:52 -03:00
Rafael Moraes cbd161038e Add logging to get_wrapper_playback 2026-05-20 06:44:37 -03:00
Rafael Moraes 66c3a0fcf1 Add media_id and raise on missing stream formats 2026-05-20 06:44:19 -03:00
Rafael Moraes b0b13e8367 Decrypt prefetch/default-key samples locally with DEFAULT_SONG_DECRYPTION_KEY 2026-05-18 22:05:20 -03:00
Rafael Moraes 7dab944908 Use /decrypt endpoint and cleanup formatting 2026-05-18 19:38:32 -03:00
Rafael Moraes ffeb3bcfec Adjust default wrapper decrypt endpoint 2026-05-18 18:30:41 -03:00
Rafael Moraes 6aae17c138 Use /sample/decrypt endpoint for decryption 2026-05-18 14:52:33 -03:00
Rafael Moraes 4cdad09372 Refactor amdecrypt for wrapper-v2 /decrypt/samples 2026-05-18 14:52:21 -03:00
Rafael Moraes 86bbb94274 Refactor music video to use tags from asset_info when using wrapper 2026-05-18 14:05:33 -03:00
Rafael Moraes e44b037414 Update to wrapper-v2 endpoints 2026-05-18 13:42:30 -03:00
Rafael Moraes 2205b76c07 Use wrapper-v2 HTTP decrypt for FairPlay CBCS.
Point amdecrypt at POST /decrypt with batched samples, robust moof/trun/senc parsing and CBCS subsample handling, and CLI/base defaults for the daemon URL. Update download/song and Apple Music paths to use the new flow; includes formatting and related API touch-ups.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 11:24:20 -03:00
Rafael Moraes 82e3cf20a0 Bump version to 3.5.2 2026-05-13 20:26:05 -03:00
Rafael Moraes bc4cdd181c Open file with UTF-8 encoding in add_file 2026-05-13 20:24:53 -03:00
Rafael Moraes dec4a22208 Bind logger and log m3u8 master URL extraction 2026-05-13 20:24:45 -03:00
Rafael Moraes b48dbeff8e Forward next_params (except limit) for pagination 2026-05-13 07:25:34 -03:00
Rafael Moraes 34a397eb18 Bump gamdl version to 3.5.1 2026-05-07 18:09:20 -03:00
Rafael Moraes 2c3abfd352 Bump version to 3.5.1 2026-05-07 18:08:26 -03:00
Rafael Moraes 1fc708177c Normalize Apple Music m3u8 master URL 2026-05-07 18:01:27 -03:00
Rafael Moraes f670fe8e95 Bump version to 3.5 2026-04-27 09:19:46 -03:00
Rafael Moraes 8f184fcb66 Remove '-28' from X-Apple-Store-Front header 2026-04-27 09:17:36 -03:00
Rafael Moraes 3765ef0df4 Set storefront_id None for non-US iTunes API 2026-04-27 08:56:43 -03:00
Rafael Moraes 4e28b7e9a3 Enable redirects and use correct storefront header 2026-04-27 08:54:22 -03:00
Rafael Moraes a009071a8d Bump version to 3.4 2026-04-27 06:35:39 -03:00
Rafael Moraes 64b1974232 Include filter result in exclusion error message 2026-04-27 06:35:00 -03:00
Rafael Moraes 37ede6572e Add overwrite flag to Database 2026-04-27 06:34:51 -03:00
Rafael Moraes 2e57216c3c Strip size suffix from Apple Music cover URLs 2026-04-27 06:25:55 -03:00
Rafael Moraes 5d242c89cd Remove 'level' and 'event' from event_dict 2026-04-26 11:41:47 -03:00
Rafael Moraes e5675f8874 Use CustomOutputWriter for structlog output 2026-04-26 00:38:08 -03:00
Rafael Moraes 716112c294 Use default_factory for DownloadItem uuid 2026-04-25 14:52:19 -03:00
Rafael Moraes 63ad0f2e07 Respect skip_cleanup when removing temp files 2026-04-25 14:28:09 -03:00
Rafael Moraes 939520b3f8 Stringify subprocess args in error message 2026-04-25 14:02:52 -03:00
Rafael Moraes df23276d3c Improve subprocess error message 2026-04-25 13:56:55 -03:00
Rafael Moraes a9227493ea Include subprocess output in async errors 2026-04-25 13:03:48 -03:00
Rafael Moraes 9375c2fccd Bump version to 3.3 2026-04-24 19:48:58 -03:00
Rafael Moraes c83e47df0c Remove total arg from media fetch calls 2026-04-24 19:48:27 -03:00
Rafael Moraes 715820e357 Bump version to 3.2 2026-04-24 16:17:49 -03:00
Rafael Moraes 137a739af2 Collect async generators for concurrency 2026-04-24 16:05:37 -03:00
Rafael Moraes 23220d1827 Limit download logging and use interface exception 2026-04-24 15:48:14 -03:00
Rafael Moraes 3c7ea272af Skip partial media; Remove flat filter exception 2026-04-24 15:44:40 -03:00
Rafael Moraes 34a92b6efc Refactor interface media fetching 2026-04-24 15:44:19 -03:00
Rafael Moraes 3a907cb76c Remove skip_decryption_key_non_legacy arg 2026-04-24 13:02:22 -03:00
Rafael Moraes 90646e7193 Use base.use_wrapper for decryption checks 2026-04-24 13:02:07 -03:00
Rafael Moraes 3b2875ccd1 Remove use_wrapper parameter and attribute 2026-04-24 12:59:01 -03:00
Rafael Moraes a989d9fefa Include index and total for music-video media fetch 2026-04-24 12:17:19 -03:00
Rafael Moraes fd3b6216c9 Use error() for URL parse errors 2026-04-24 12:08:24 -03:00
Rafael Moraes 84c21c0013 Pass total=1 when fetching single Apple Music song 2026-04-24 12:06:49 -03:00
Rafael Moraes aca3339b16 Remove string fallback for media_index 2026-04-24 12:04:52 -03:00
Rafael Moraes 6d6f9f4441 Provide index=0 to _get_song_media call 2026-04-24 12:01:51 -03:00
Rafael Moraes fe98bdb42c Process download items inline, remove queue 2026-04-24 11:55:35 -03:00
Rafael Moraes 7c8b20d8f3 Include track index/total in media objects 2026-04-24 11:55:11 -03:00
Rafael Moraes 6232493eed Add index and total fields to AppleMusicMedia 2026-04-24 11:54:57 -03:00
Rafael Moraes 09997bd6a1 Document --wrapper-m3u8-ip CLI option 2026-04-24 11:36:32 -03:00
Rafael Moraes 54c318908c Bump version to 3.1 2026-04-24 11:33:59 -03:00
Rafael Moraes dc6f2e8506 Use ExceptionPrettyPrinter and .exception logging 2026-04-24 11:26:21 -03:00
Rafael Moraes eff41a40f5 Await get_wrapper_m3u8 call 2026-04-24 11:22:33 -03:00
Rafael Moraes b00163a71c Add optional m3u8 wrapper support 2026-04-24 11:18:01 -03:00
Rafael Moraes 9f60043375 Add wrapper m3u8 IP and consolidate use_wrapper 2026-04-24 11:17:34 -03:00
Rafael Moraes 004ecd7c64 Guard against missing response on HTTP errors 2026-04-24 11:17:04 -03:00
Rafael Moraes 581bb7e094 Make GamdlApiResponseError.content optional 2026-04-24 11:15:57 -03:00
Rafael Moraes 5fd10d897e Extract cover URL formatting to helper 2026-04-23 11:45:57 -03:00
Rafael Moraes d7a83bab50 Use playlist_tags artist/title/track fields 2026-04-21 11:55:48 -03:00
Rafael Moraes 4aa70733d6 Handle URL parse errors and optional tracebacks 2026-04-21 11:50:55 -03:00
Rafael Moraes 7063900dd4 Check for stream_info before setting staged_path 2026-04-21 11:48:44 -03:00
Rafael Moraes ff5298c0ae Omit message in synced lyrics error 2026-04-21 11:44:17 -03:00
Rafael Moraes 3c54368f03 Refactor media parsing into helper 2026-04-21 11:43:13 -03:00
Rafael Moraes 905bbfd5ca Pass synced_lyrics_only to skip_stream_info 2026-04-21 11:33:17 -03:00
Rafael Moraes d84bc2c695 Add skip_stream_info option to SongInterface 2026-04-21 11:32:50 -03:00
Rafael Moraes 82ab9827eb Clarify yt-dlp usage in README 2026-04-21 11:26:42 -03:00
Rafael Moraes ff5dc4f20c Mention mp4decrypt in Music Videos entry 2026-04-21 10:51:11 -03:00
Rafael Moraes a99707666b Refactor README 2026-04-21 10:49:51 -03:00
Rafael Moraes 91db55adc3 Require mp4decrypt for music videos 2026-04-21 10:49:44 -03:00
Rafael Moraes ae8d4a27aa Remove ffmpeg decryption_key support in music_video 2026-04-21 10:48:41 -03:00
Rafael Moraes cfc4673082 Add SQLite database registry for downloaded media 2026-04-21 10:44:33 -03:00
Rafael Moraes 64a20f030a Fail on flat-filter excluded media
Introduce GamdlDownloaderFlatFilterExcludedError and raise it during AppleMusicDownloader processing when item.media.flat_filter_result is truthy. This aborts further processing/download for media excluded by the flat filter and includes the media id in the error message. Also import the new exception in the downloader module.
2026-04-21 10:36:08 -03:00
Rafael Moraes c4536963f8 Update README usage example for new API 2026-04-21 10:21:51 -03:00
Rafael Moraes 0b318156a4 Bump package version to 3.0 2026-04-21 10:19:09 -03:00
Rafael Moraes 30b3f36905 Refactor CLI module 2026-04-21 10:15:49 -03:00
Rafael Moraes 9b76ab90a7 Refine codec callback type hints 2026-04-21 10:14:33 -03:00
Rafael Moraes f3dfd3d9d8 Pass full playlist dict to ask_codec_function 2026-04-21 10:11:24 -03:00
Rafael Moraes 95c6e6dce7 Pass media metadata to artist selector 2026-04-21 10:03:58 -03:00
Rafael Moraes 2fd7ad9334 Support async and optional callbacks in interfaces 2026-04-21 09:00:41 -03:00
Rafael Moraes 97e8fd2223 Log cleanup success only when performed 2026-04-21 08:32:43 -03:00
Rafael Moraes 119a39c4fe Refactor imports in downloader.py 2026-04-20 11:57:32 -03:00
Rafael Moraes f9d62ee84b Refactor downloader module 2026-04-20 11:56:32 -03:00
Rafael Moraes 939e9459ef Replace _base with base in interfaces 2026-04-20 10:26:39 -03:00
Rafael Moraes de76ce898e Use _base.apple_music_api for AppleMusic calls 2026-04-20 10:23:27 -03:00
Rafael Moraes 5bbe87500a Use composition for AppleMusic interfaces 2026-04-20 10:22:56 -03:00
Rafael Moraes 61ea24bfdd Remove extra tags fetching and preview parsing 2026-04-20 09:55:57 -03:00
Rafael Moraes b5837bdca5 Fix ALAC duration and timescale handling 2026-04-20 09:53:38 -03:00
Rafael Moraes b21a9cc35b Add httpx-retries, structlog & dev deps 2026-04-20 09:49:19 -03:00
Rafael Moraes fe6fe54880 Merge pull request #289 from SiddharthManthan/media-length
fix (alac): resolution for incorrect duration tags in ALAC downloads
2026-04-20 09:33:15 -03:00
Rafael Moraes 56748797eb Re-export exceptions in api package 2026-04-19 19:08:42 -03:00
Rafael Moraes 9d504a34b0 Add exports for gamdl.interface package 2026-04-19 19:08:18 -03:00
Rafael Moraes b59d7b9a73 Refactor interface module 2026-04-19 17:09:52 -03:00
Rafael Moraes d3b13ebe26 Standardize log.debug messages to 'success' 2026-04-19 16:25:38 -03:00
Rafael Moraes c2bfe4f2f3 Standardize debug messages to 'success' 2026-04-19 16:21:30 -03:00
Rafael Moraes 178dc8822e Store storefront and language in ItunesApi 2026-04-19 16:14:33 -03:00
Rafael Moraes 2a966f178f Remove HTTP helpers and sequential_gather 2026-04-19 15:41:02 -03:00
Rafael Moraes 4cb771a925 Add retry transport to Apple Music HTTP client 2026-04-19 14:04:47 -03:00
Rafael Moraes 102dce2b75 Remove redundant debug log in apple_music.py 2026-04-14 07:49:00 -03:00
Rafael Moraes 27630b5657 Update API imports to new module names 2026-04-13 22:26:06 -03:00
Rafael Moraes 8335af0f79 Refactor API exception classes 2026-04-13 22:25:48 -03:00
Rafael Moraes e3ce405a41 Refactor Apple Music constants and add API URIs 2026-04-13 22:25:31 -03:00
Rafael Moraes c5e001fda5 Refactor iTunes API client 2026-04-13 22:25:09 -03:00
Rafael Moraes eba97c8344 Refactor Apple Music API client 2026-04-13 22:24:58 -03:00
Siddharth Manthan 0413d133b5 fix (alac): resolution for incorrect duration tags in ALAC downloads
- Updated amdecrypt.py to correctly patch both timescale and duration in mdhd boxes (support for v0 and v1)
- Added tag filtering in downloader_base.py and interface_song.py to prevent preview-related tags (e.g., ©dur, iTunSMPB) from overwriting full-track metadata
2026-04-10 22:45:11 +05:30
Rafael Moraes e330e11d82 Bump version to 2.9.3 2026-03-08 13:37:46 -03:00
Rafael Moraes bebfcb02d8 Use trex defaults for sample duration/size 2026-03-08 13:35:21 -03:00
Rafael Moraes 29f68f6bc4 Bump version to 2.9.2 2026-03-05 15:08:42 -03:00
Rafael Moraes e77c6b24b4 Merge pull request #277 from LiuqingDu/fix-all-albums
Fix KeyError during artist download pagination
2026-03-05 15:07:16 -03:00
Liuqing Du ba315dcb95 Fix KeyError during artist download pagination 2026-02-28 11:50:52 -06:00
Rafael Moraes 4187fad734 Bump version to 2.9.1 2026-02-25 19:13:13 -03:00
Rafael Moraes f36edf4bbd Add 'Apple Music Classical' to README 2026-02-25 19:12:29 -03:00
Rafael Moraes 50478d427e Add Artist Auto-Select options to README 2026-02-25 19:11:20 -03:00
Rafael Moraes 45461007a9 Add artist auto select flag; rename song codec flag 2026-02-25 19:07:33 -03:00
Rafael Moraes 79a03d4f4c Rename artist_selection to artist_auto_select in CLI 2026-02-25 19:05:07 -03:00
Rafael Moraes beb508529a Rename ArtistDownloadSelection to ArtistAutoSelect 2026-02-25 19:04:52 -03:00
Rafael Moraes 87cf8c7789 Add artist_selection CLI option 2026-02-25 19:01:57 -03:00
Rafael Moraes 9e3f740eec Add ArtistDownloadSelection and auto-select option 2026-02-25 19:01:37 -03:00
Rafael Moraes 7281f5c949 Support song codec priority list 2026-02-25 18:16:34 -03:00
Rafael Moraes d32781b23f Skip wrapper decryption for legacy codecs 2026-02-25 17:52:15 -03:00
Rafael Moraes 5f2c74399e Merge pull request #276 from symphoniacus/fix-classical-url-parsing
fix: add support for Apple Music Classical URLs
2026-02-25 17:48:15 -03:00
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
symphoniacus d11e937c6a fix: allow Apple Music Classical URLs (classical.music.apple.com) 2026-02-14 19:24:56 +01: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
Rafael Moraes 24de608bc8 bump version 2025-05-12 09:28:28 -03:00
Rafael Moraes d0e2e08748 use prompt_path function for wvd and cookies 2025-05-12 09:11:58 -03:00
Rafael Moraes 2223d36d5e added prompt_path function 2025-05-12 09:11:07 -03:00
Rafael Moraes 3077456ab7 update cover path retrieval to use downloader_song 2025-05-09 14:19:39 -03:00
Rafael Moraes bbd96cbe6b remove redundant cover path lines 2025-05-09 14:16:33 -03:00
Rafael Moraes ca16a208ba rename custom_formatter to custom_logger_formatter 2025-05-09 14:07:31 -03:00
Rafael Moraes c32c8622b7 add error handling for missing media-user-token in cookies 2025-05-09 14:04:24 -03:00
Rafael Moraes 132ae0ea56 improve storefront retrieval 2025-05-05 23:04:09 -03:00
Rafael Moraes 70238facac better handling for media that has no cover 2025-02-25 02:35:39 -03:00
Rafael Moraes 4fb1fb609b Update custom_formatter.py 2025-02-23 16:28:38 -03:00
Rafael Moraes f97b3dba14 bump version 2025-02-23 04:36:05 -03:00
Rafael Moraes 2da824ecbc add colorama to dependencies 2025-02-23 04:34:28 -03:00
Rafael Moraes 24810da4b6 replace inline response exception handling with utility function 2025-02-23 04:32:50 -03:00
Rafael Moraes f16a30549c refactor logging color handling to use colorama and add utility function for colored text 2025-02-23 04:30:11 -03:00
Rafael Moraes 2001b19d8f bump version 2025-02-23 03:50:23 -03:00
Rafael Moraes 14814dd2da small refactor on cookies user input 2025-02-23 03:16:03 -03:00
Rafael Moraes 6fad41467f add termcolor to dependencies 2025-02-23 03:14:31 -03:00
Rafael Moraes 0868f1c28c set timezone to utc on parse_datetime_obj_from_timestamp_ttml 2025-02-23 03:12:29 -03:00
Rafael Moraes a964011507 implement custom logging formatter with colored output 2025-02-23 03:11:23 -03:00
Rafael Moraes 3a943d0154 refactor media user token retrieval from cookies 2025-02-23 02:13:09 -03:00
Rafael Moraes 84bf0a3c2b prompt user for cookies file path if not found 2025-02-23 02:12:15 -03:00
Rafael Moraes 93dda6889c update Python version requirement to 3.9 2025-02-16 10:43:05 -03:00
Rafael Moraes d62a1377f8 Update README.md 2025-01-29 16:35:36 -03:00
Rafael Moraes 3a2d521352 bump version to 2.3.9 2025-01-29 16:31:23 -03:00
Rafael Moraes c8f45110bd replace deprecated logger.warn with logger.warning 2025-01-29 16:30:08 -03:00
Rafael Moraes 36925025b7 fix: improve formatting in README.md for download modes section 2025-01-29 16:28:50 -03:00
Rafael Moraes d8937d9805 bump required Python version to 3.9 in pyproject.toml 2025-01-29 16:19:25 -03:00
Rafael Moraes 513db83645 update project description for clarity and completeness 2025-01-29 16:17:53 -03:00
Rafael Moraes 11f9b5a75c Update README.md for improved clarity and formatting 2025-01-29 16:15:43 -03:00
Rafael Moraes 1dd01368c3 Update downloader_song.py 2025-01-27 22:47:39 -03:00
Rafael Moraes 4fc8887101 refactor DRM handling to use widevine_pssh and add support for playready and fairplay keys 2025-01-27 22:47:24 -03:00
Rafael Moraes 9169665579 Merge pull request #159 from shafreeck/main
Fix handling of 'Favorite Songs' playlist
2025-01-27 22:39:56 -03:00
Shafreeck d053db96e8 handle missing curatorName in 'Favorite Songs' playlist
'Favorite Songs' is an automatically generated playlist by
Apple Music that lacks the curatorName attribute. Set the
default curator to 'Apple Music' to maintain consistency
with other system playlists.
2025-01-06 07:19:56 +08:00
Rafael Moraes 1013bd20b9 add mutagen to depedencies 2024-11-23 17:05:39 -03:00
Rafael Moraes 2c1fa9d99b bump version 2024-11-23 17:05:28 -03:00
Rafael Moraes fc1c161e30 Update README.md 2024-11-02 03:54:37 -03:00
Rafael Moraes 2f87902163 Update README.md 2024-11-01 13:38:54 -03:00
Rafael Moraes 9f7bb0d404 Update README.md 2024-11-01 13:37:28 -03:00
Rafael Moraes c653db00cf bump version 2024-10-31 13:59:02 -03:00
Rafael Moraes cdd574a349 Update README.md 2024-10-31 13:57:27 -03:00
Rafael Moraes afbe65707a replace print_exceptions with no_exceptions 2024-10-31 13:47:35 -03:00
Rafael Moraes 3998b698e0 fix for songs that don't have genre 2024-10-31 13:45:08 -03:00
Rafael Moraes a67c81bd22 remove ciso8601 from requirements 2024-10-27 12:37:40 -03:00
Rafael Moraes 9b0a2acc6f bump version 2024-10-27 12:36:46 -03:00
Rafael Moraes 4d904e2e7c address old python compatibility when parsing date 2024-10-27 12:36:12 -03:00
Rafael Moraes 2d3b2b6b1f replace ciso8601 with python datetime 2024-10-27 12:32:38 -03:00
Rafael Moraes 1ee8e2aa13 Merge pull request #139 from glomatico/glomatico-patch-1
Create LICENSE
2024-09-22 00:00:37 -03:00
Rafael Moraes fd6d8a0689 Create LICENSE 2024-09-22 00:00:26 -03:00
Rafael Moraes 50904e9c08 bump version 2024-09-13 21:20:33 -03:00
Rafael Moraes 66556eac0a update update_playlist_file 2024-09-13 21:20:20 -03:00
Rafael Moraes d97445ec9e check for synced_lyrics_only before saving cover 2024-09-08 00:26:56 -03:00
Rafael Moraes d6f30aa0a2 bump version 2024-09-08 00:21:47 -03:00
Rafael Moraes 42a17ca90f optimize stream_url fetching for music videos 2024-09-08 00:21:35 -03:00
Rafael Moraes 3ee0d28727 add from __future__ import annotations 2024-09-08 00:09:30 -03:00
Rafael Moraes 7b8875250c refactor get_playlist_file_path 2024-09-08 00:08:19 -03:00
Rafael Moraes 16734b8b64 refactor update_playlist_file 2024-09-08 00:07:54 -03:00
Rafael Moraes 475bddb5f7 refactor get_final_path 2024-09-08 00:07:30 -03:00
Rafael Moraes 63ba4b0824 change default value for template_file_playlist 2024-09-08 00:07:07 -03:00
Rafael Moraes 9d67e8f0f0 bump version 2024-08-29 22:50:49 -03:00
Rafael Moraes fcbe596a80 refactor to use webplayback api to get the stream url instead of itunes page 2024-08-29 22:50:19 -03:00
Rafael Moraes acd5fefb76 bump version 2024-08-05 11:14:39 -03:00
Rafael Moraes ed584cc9b9 skip to next track when track is not downloadable 2024-08-04 20:04:52 -03:00
Rafael Moraes bac8eb9254 remove useless save cover lines in music video 2024-08-04 20:02:41 -03:00
Rafael Moraes 71ac17cce2 improve post video download logic 2024-08-03 17:53:25 -03:00
Rafael Moraes a35a3835aa create parent directory before saving cover image 2024-08-03 17:49:42 -03:00
Rafael Moraes 091ca3bf53 fix get_playlist_file_path 2024-08-03 14:15:14 -03:00
Rafael Moraes e4498e11c0 bump version 2024-08-03 14:13:55 -03:00
Rafael Moraes 5a8c5d2c25 fix get_final_path 2024-08-03 14:13:38 -03:00
Rafael Moraes c21d50479f adjust updating m3u8 log message 2024-08-03 02:21:34 -03:00
Rafael Moraes b6d1f36281 run update_playlist_file only when track is downloaded 2024-08-03 02:19:15 -03:00
Rafael Moraes c0d1ec2383 specify netscape format on cookies instructions 2024-08-03 02:02:43 -03:00
Rafael Moraes 8c1a3dbe7d set default truncate value to null for avoiding confusion 2024-08-03 02:00:23 -03:00
Rafael Moraes aa71239eba fix artist links 2024-08-03 01:55:48 -03:00
Rafael Moraes c890068eb7 create ILLEGAL_CHAR_REPLACEMENT var 2024-08-03 01:45:53 -03:00
Rafael Moraes e3f96d8684 add encoding="utf8" when saving playlist 2024-08-03 01:41:22 -03:00
Rafael Moraes 238a8377e0 bump version 2024-08-03 01:38:14 -03:00
Rafael Moraes 6c3dff566b adjust m3u8 playlist log message 2024-08-03 00:39:37 -03:00
Rafael Moraes 07c847a788 rename save_playlist_file to save_playlist 2024-08-03 00:37:45 -03:00
Rafael Moraes 0ca56d24d7 update default file for truncate on config section 2024-08-03 00:37:08 -03:00
Rafael Moraes 566a8aa498 add save_playlist and template_file_playlist to config section 2024-08-03 00:36:47 -03:00
Rafael Moraes a7af9e704f add playlist tags to tags variables 2024-08-03 00:31:55 -03:00
Rafael Moraes 540009fc1b improve update_playlist_file 2024-08-03 00:28:06 -03:00
Rafael Moraes 19fdd85c35 add save_playlist_file option 2024-08-03 00:11:11 -03:00
Rafael Moraes 0cd87254d3 rework DownloadQueue and add playlist tags support 2024-08-02 23:47:31 -03:00
Rafael Moraes 6593644c72 rename metadata attribute from models to track_metadata 2024-08-02 22:39:30 -03:00
Rafael Moraes 005af07fcc create VALID_URL_REGEX variable 2024-08-02 22:22:19 -03:00
Rafael Moraes adabfd95bc adjust default truncate value to 0 2024-08-02 22:21:12 -03:00
Rafael Moraes 564ece387c handle no search results 2024-07-31 07:11:58 -03:00
Rafael Moraes 328428a520 Merge pull request #131 from dracarys69/patch-2
Update apple_music_api.py
2024-07-30 21:03:01 -03:00
dracarys69 2c70a23e59 Update apple_music_api.py
added search
2024-07-27 15:10:24 +03:00
Rafael Moraes 52288bb7af Update encoding for reading URLs from file 2024-07-08 16:04:45 -03:00
Rafael Moraes 281a357863 Update README.md 2024-06-21 00:15:49 -03:00
Rafael Moraes 744300e36b Bump version 2024-06-03 01:53:40 -03:00
Rafael Moraes e86f990395 Add pillow library to dependencies 2024-06-03 01:52:54 -03:00
Rafael Moraes abc2f8f2f2 Refactor get_cover_file_extension method to use IMAGE_FILE_EXTENSION_MAP 2024-06-03 01:51:48 -03:00
Rafael Moraes 2dabb1c6fe Add IMAGE_FILE_EXTENSION_MAP 2024-06-03 01:51:21 -03:00
Rafael Moraes 8b80b0c6c5 Update raw cover format description in README.md 2024-06-03 00:50:20 -03:00
Rafael Moraes eef659bac8 Update raw cover format description in README.md 2024-06-03 00:49:16 -03:00
Rafael Moraes 0f7c3795a7 Refactor cover image handling for downloader module in apply_tags 2024-06-03 00:34:35 -03:00
Rafael Moraes c84b1137c2 chore: Update cover path generation in downloader modules 2024-06-03 00:21:01 -03:00
Rafael Moraes ebdc82d68b docs: Clarify description for raw cover format in README.md 2024-06-02 21:24:22 -03:00
Rafael Moraes 85c1fdbfbb chore: Refactor get_url_response_bytes method to handle HTTP errors 2024-06-02 21:20:38 -03:00
Rafael Moraes 5990e5f722 Bump version to 2.2.5 2024-06-02 21:15:19 -03:00
Rafael Moraes b8bd406d74 chore: Add support for fetching raw cover images 2024-06-02 21:15:03 -03:00
Rafael Moraes 57ee6e1db8 Update README.md 2024-05-25 01:49:38 -03:00
Rafael Moraes a20feb2aa7 Update README.md 2024-05-25 01:47:21 -03:00
Rafael Moraes 60db7e0339 Update README.md 2024-05-25 01:44:59 -03:00
glomatico 575d2ee154 Update README.md 2024-05-20 15:58:12 -03:00
glomatico f5bb56cab7 lol 2024-05-20 15:58:04 -03:00
glomatico ecc7979d7e adjust log message 2024-05-20 12:52:21 -03:00
glomatico d129551b55 adjust log message 2024-05-20 12:51:47 -03:00
glomatico 08a5ac00d8 adjust get_playlist 2024-05-20 12:50:15 -03:00
glomatico 628c9786d5 remove unused import 2024-05-20 04:36:53 -03:00
glomatico 7de12c3da7 add storefront tag for post videos 2024-05-20 04:21:17 -03:00
glomatico 39d724c488 Update cli.py 2024-05-20 04:17:25 -03:00
glomatico 79e00e5e19 adjust some log messages 2024-05-20 04:15:54 -03:00
glomatico e90fd24af0 adjust temp_path for posts 2024-05-20 04:13:32 -03:00
glomatico d68edd5393 Update README.md 2024-05-20 04:09:31 -03:00
glomatico 5acefd9a06 Update README.md 2024-05-20 04:08:22 -03:00
glomatico 93b62cdde9 update description 2024-05-20 04:08:19 -03:00
glomatico fc61a51da2 Update README.md 2024-05-20 04:06:37 -03:00
glomatico 81b44a808d Add missing storefront id tag for music videos 2024-05-20 04:02:07 -03:00
glomatico 24f3af1a5e Update README.md 2024-05-16 23:11:26 -03:00
glomatico 4a469d74d3 Update README.md 2024-05-10 19:33:11 -03:00
glomatico 6122835caa Update README.md 2024-05-10 19:24:01 -03:00
glomatico be597f0de4 bump version 2024-05-10 12:42:41 -03:00
glomatico b10ab5332d adjust default nm3u8dlre path 2024-05-10 12:42:22 -03:00
glomatico 080413b183 bump version 2024-05-09 20:17:15 -03:00
glomatico f6443081ae add try-finally block in get_decryption_key 2024-05-09 20:16:56 -03:00
glomatico 8dcf10c221 whoops 2024-05-05 13:33:35 -03:00
glomatico 6f5efd1779 Update README.md 2024-05-05 13:32:21 -03:00
alacleaker 06e43fdbbe bump version 2024-04-24 21:30:28 -03:00
alacleaker 646125b93f adjust get_tags for music videos 2024-04-24 21:29:35 -03:00
alacleaker ea281766ba check if playlist has uri in get_playlist_audio_from_user 2024-04-24 14:04:42 -03:00
alacleaker a1b0ad35ee bump version 2024-04-24 14:01:29 -03:00
alacleaker 2f715b3d9d adjust MP4_FORMAT_CODECS and stream info audio codec 2024-04-24 13:58:22 -03:00
alacleaker 461fcedf30 remove fix key id 2024-04-23 15:50:28 -03:00
alacleaker 6d7cb3ada4 update description 2024-04-23 00:32:22 -03:00
alacleaker b87d406ffa update requirements 2024-04-23 00:31:40 -03:00
alacleaker f6efdb3332 Update README.md 2024-04-23 00:19:59 -03:00
alacleaker 29a006c304 update --read-urls-as-txt help 2024-04-23 00:19:16 -03:00
alacleaker a49a9c90cc read all url arguments instead of just the first when -r 2024-04-23 00:16:27 -03:00
alacleaker 43fd1dd2e3 Update README.md 2024-04-23 00:01:27 -03:00
alacleaker 2ed6ac05ba Update README.md 2024-04-23 00:00:25 -03:00
alacleaker 0f0e17f4cd adjust inquirer message 2024-04-22 23:56:36 -03:00
alacleaker 8c4d2713f7 switch from fstring to string 2024-04-22 23:37:42 -03:00
alacleaker 1baca4151b switch to InquirerPy in get_stream_url_from_user 2024-04-22 23:36:50 -03:00
alacleaker 6f08a4b2f9 adjust select_albums_from_artist 2024-04-22 23:12:56 -03:00
alacleaker 38f708e2e9 add validation to get_download_queue_from_artist 2024-04-22 23:11:25 -03:00
alacleaker f27adf98df add column indication when listing artist mvs/albums 2024-04-22 23:01:52 -03:00
alacleaker 9f0b25e1d1 add rating info when listing artist mvs 2024-04-22 22:57:27 -03:00
alacleaker 3590d99063 add rating info when listing artist albums 2024-04-22 22:55:18 -03:00
alacleaker b200dade5a change checking url log from debug to info 2024-04-22 21:12:23 -03:00
alacleaker 118d23e9db remove _best prefix from music_video_codec enum 2024-04-22 20:41:39 -03:00
alacleaker 7bc8c6668f bump version 2024-04-22 20:37:42 -03:00
alacleaker 0e9fb3702d adjust getting streaminfo log 2024-04-22 20:26:21 -03:00
alacleaker e706f0fa82 switch from tabulate to InquirerPy for selecting codecs 2024-04-22 20:24:37 -03:00
alacleaker 3014fb112d add support for artist urls 2024-04-22 20:05:15 -03:00
alacleaker d7f17b8b6f add debug message for url checking 2024-04-22 20:04:50 -03:00
alacleaker 947e2df81a rename some variables 2024-04-22 17:02:48 -03:00
alacleaker c8fe96b31d add get_artist 2024-04-22 13:01:38 -03:00
alacleaker 83a3efc1fa add _extend_api_data and _get_next_uri_response methods 2024-04-22 12:54:28 -03:00
alacleaker 345afbf174 adjust lyrics checking 2024-04-16 21:19:45 -03:00
alacleaker c35051a7ec bump version 2024-04-16 21:13:48 -03:00
alacleaker b286ee84e2 add from __future__ import annotations 2024-04-16 21:12:29 -03:00
alacleaker 9094f2c7b4 remove -> None 2024-04-16 21:09:04 -03:00
alacleaker 5feb5b274a remove "-isma" 2024-04-16 21:07:08 -03:00
alacleaker 1375af929c add keep utc to remux mp4box 2024-04-16 00:37:54 -03:00
alacleaker d280f1fad2 Merge branch 'main' of https://github.com/glomatico/gamdl 2024-04-15 10:43:45 -03:00
alacleaker 3a04d7927e adjust get_lyrics 2024-04-15 10:43:37 -03:00
Rafael Moraes 942a812308 Update README.md 2024-04-14 13:42:48 -03:00
Rafael Moraes 66e01293e6 Update README.md 2024-04-14 13:26:03 -03:00
alacleaker c0561da592 Update README.md 2024-04-13 02:58:04 -03:00
alacleaker 56d238fb1b update description 2024-04-13 02:58:01 -03:00
alacleaker d3a53bf93b Update README.md 2024-04-12 18:15:56 -03:00
alacleaker bb7a3ff77e Update README.md 2024-04-12 18:14:55 -03:00
alacleaker bf6293a0a0 Update README.md 2024-04-12 18:12:05 -03:00
alacleaker c421b3e855 fix incorrect spacing on synced lyrics 2024-04-12 18:05:04 -03:00
alacleaker 7e495300f9 bump version 2024-04-12 08:30:09 -03:00
alacleaker ccef00e39f add additional lyrics presence check 2024-04-12 07:14:53 -03:00
alacleaker 94cdba313c add missing subprocess_additional_args 2024-04-11 07:08:47 -03:00
alacleaker d7b19e8c67 add cookies file not found error 2024-04-11 07:07:31 -03:00
alacleaker a6809df2ef update description 2024-04-11 07:01:37 -03:00
alacleaker 40f3616bc3 Update README.md 2024-04-11 06:59:14 -03:00
alacleaker 104100e091 Update README.md 2024-04-11 06:58:36 -03:00
alacleaker ce4a7d7880 Update README.md 2024-04-11 06:52:15 -03:00
alacleaker ec09bacd39 Update README.md 2024-04-11 06:49:32 -03:00
alacleaker e0d3f46159 Update README.md 2024-04-10 19:10:27 -03:00
alacleaker 0bfb4d80b8 Update README.md 2024-04-10 19:06:03 -03:00
alacleaker 54d6d93967 Update README.md 2024-04-10 19:03:15 -03:00
alacleaker c13160b999 Update README.md 2024-04-10 19:02:34 -03:00
alacleaker ea4d574810 Update README.md 2024-04-10 18:57:20 -03:00
alacleaker 9da35c3f57 update --language help 2024-04-10 18:57:17 -03:00
alacleaker ebb7ec1da7 Update README.md 2024-04-10 18:55:27 -03:00
alacleaker d155a42e3a update --language help 2024-04-10 18:54:20 -03:00
alacleaker 8f18562e1c bump version 2024-04-10 17:07:50 -03:00
alacleaker 25ed506b82 add language option 2024-04-10 17:07:17 -03:00
alacleaker ba76241032 add silent and subprocess_additional_args 2024-04-10 16:51:15 -03:00
alacleaker c6f7e99135 add ac3 2024-04-09 14:43:35 -03:00
alacleaker dbaa1faa6b Merge branch 'main' of https://github.com/glomatico/gamdl 2024-04-09 14:40:32 -03:00
alacleaker a61a9c4975 catch abort exception when getting codec from user 2024-04-09 14:40:30 -03:00
Rafael Moraes 8ffe5c86ca Update FUNDING.yml 2024-04-09 13:20:03 -03:00
alacleaker 88bdf64825 Merge branch 'main' of https://github.com/glomatico/gamdl 2024-04-09 13:16:54 -03:00
alacleaker 3849df9adb add missing sanitize_date 2024-04-09 13:16:46 -03:00
Rafael Moraes a989ff6c34 Merge pull request #96 from glomatico/glomatico-patch-1
Create FUNDING.yml
2024-04-09 13:12:05 -03:00
Rafael Moraes f3d583aab2 Create FUNDING.yml 2024-04-09 13:11:53 -03:00
alacleaker 7ac3d3e400 bump version 2024-04-09 12:47:25 -03:00
alacleaker 085e8f1b5d check if m3u8 url exists 2024-04-09 12:47:11 -03:00
alacleaker 4df36e60d9 add from __future__ import annotations 2024-04-09 12:27:40 -03:00
alacleaker f6d726e466 bump version 2024-04-09 07:16:16 -03:00
alacleaker 61b1bf1e55 fix song not downloadable detection 2024-04-09 07:16:07 -03:00
alacleaker 3ae6709ccb bump version 2024-04-09 06:54:07 -03:00
alacleaker 1f00e4fb9f adjust non-legacy message 2024-04-09 06:53:53 -03:00
alacleaker 714d47bb13 Update README.md 2024-04-09 06:52:01 -03:00
alacleaker 46e3a92d4f adjust non-legacy warn 2024-04-09 06:51:56 -03:00
alacleaker 42b536d271 warn about non-legacy 2024-04-09 06:49:43 -03:00
alacleaker dac8d5eed9 add LEGACY_CODECS 2024-04-09 06:49:32 -03:00
alacleaker 2956f20dfa Update README.md 2024-04-08 21:18:53 -03:00
alacleaker 8f76743a3b Update README.md 2024-04-08 21:13:17 -03:00
alacleaker 3096bbc79d adjust song is not available message 2024-04-08 21:06:57 -03:00
alacleaker ed49d7bd5f adjust song is not available message 2024-04-08 21:06:47 -03:00
alacleaker 0ea72d0b78 adjust song is not available message 2024-04-08 21:06:37 -03:00
alacleaker ae490320ad bump version 2024-04-08 21:04:57 -03:00
alacleaker e40668e6ec adjust song is not available message 2024-04-08 21:04:22 -03:00
alacleaker 62c695b5ff bump version 2024-04-08 19:50:50 -03:00
alacleaker 5d9c8c1f0b add from __future__ import annotations 2024-04-08 19:50:39 -03:00
alacleaker 54d640230a bump version 2024-04-08 18:25:10 -03:00
alacleaker 3d272a6891 use m4v with ffmpeg when compatible codecs 2024-04-08 17:29:45 -03:00
alacleaker e99ed0eb5a fix wrong format and rename to use_mp4_format 2024-04-08 17:00:20 -03:00
alacleaker 86b5029773 add stream info codec to remux 2024-04-08 16:59:44 -03:00
alacleaker 3df0a91d3f adjust mp4 flag codecs 2024-04-08 16:54:17 -03:00
alacleaker d356596cf4 add codec to StreamInfo 2024-04-08 16:54:03 -03:00
alacleaker cbd2df79b7 add -f mp4 for atmos 2024-04-08 16:46:36 -03:00
Rafael Moraes 80c2afde63 Merge pull request #91 from glomatico/dev
Dev
2024-04-08 15:34:29 -03:00
alacleaker e43169b9d3 Update README.md 2024-04-08 15:26:14 -03:00
alacleaker 3c97dfdb1f Update README.md 2024-04-08 15:26:05 -03:00
alacleaker 72e6d624ed Update README.md 2024-04-08 15:22:22 -03:00
alacleaker 1c93c6b7ce Update README.md 2024-04-08 15:20:56 -03:00
alacleaker 3f61a907ff Update README.md 2024-04-08 15:09:20 -03:00
alacleaker c4a38c5c17 Update README.md 2024-04-08 15:08:11 -03:00
alacleaker a49066d30d bump to 2.0 owo 2024-04-08 15:08:08 -03:00
alacleaker 2cb5eb9b2f Update README.md 2024-04-08 14:59:19 -03:00
alacleaker 237fc0c3d1 Update README.md 2024-04-08 14:59:02 -03:00
alacleaker 7b767adc05 move no_config_file to end 2024-04-08 14:34:13 -03:00
alacleaker 2baf05ebc7 change config_location to config_path 2024-04-08 14:28:01 -03:00
alacleaker c3eb66ef0e adjust variable name 2024-04-08 14:21:19 -03:00
alacleaker cf8d22b7dc synced lyrics format 2024-04-08 14:13:44 -03:00
alacleaker 2dccfaef9e adjust lrc references to synced lyrics 2024-04-08 14:13:35 -03:00
alacleaker 6c4c3cdd25 add SYNCED_LYRICS_FILE_EXTENSION_MAP 2024-04-08 14:13:15 -03:00
alacleaker 7cd3d4b705 add SyncedLyricsFormat 2024-04-08 14:13:07 -03:00
alacleaker 65bcb41f83 adjust get_download_queue 2024-04-08 13:06:29 -03:00
alacleaker 0ded3e1b50 fix get_cover_url 2024-04-08 12:49:10 -03:00
alacleaker 2a69969c97 change artwork to cover 2024-04-08 12:38:13 -03:00
alacleaker ab782c98c9 adjust imageformat on apply_tags 2024-04-08 12:35:37 -03:00
alacleaker 9cc1b8a800 Update downloader.py 2024-04-08 12:35:06 -03:00
alacleaker d60e26d4d1 adjust get_cover_url 2024-04-08 12:34:59 -03:00
alacleaker 5d2f50d315 fix get_cover_path 2024-04-08 08:42:34 -03:00
alacleaker fe44cd0d8e adjust for MUSIC_VIDEO_CODEC_MAP 2024-04-08 07:58:50 -03:00
alacleaker 9d3865e7d0 add MUSIC_VIDEO_CODEC_MAP and adjust EXCLUDED_CONFIG_FILE_PARAMS 2024-04-08 07:57:43 -03:00
alacleaker 4636d96dbf Update README.md 2024-04-08 07:12:12 -03:00
alacleaker b4424a110e Update README.md 2024-04-08 07:11:22 -03:00
alacleaker 2773f20931 Update README.md 2024-04-08 07:09:57 -03:00
alacleaker f8005beac9 check if N_m3u8DL-RE is on path 2024-04-08 07:09:49 -03:00
alacleaker b1c02cffef update lrc_only check 2024-04-08 07:08:11 -03:00
alacleaker 89080a0f7f skip unplayable tracks 2024-04-08 07:07:00 -03:00
alacleaker 0bfc702533 add disable_music_video_skip 2024-04-08 07:04:39 -03:00
alacleaker aa4a391309 warn when mp4decrypt is missing 2024-04-08 07:00:31 -03:00
alacleaker a2932b637a update requirements & required version 2024-04-08 06:54:51 -03:00
alacleaker 5b909b9abc remove unused import 2024-04-07 20:09:02 -03:00
alacleaker 617605495e Create downloader_post.py 2024-04-07 20:08:56 -03:00
alacleaker 4a373bbf99 add post download support 2024-04-07 20:08:52 -03:00
alacleaker b93b1a8135 add post quality 2024-04-07 20:08:36 -03:00
alacleaker 1ec3aed080 add post download support 2024-04-07 19:39:22 -03:00
alacleaker 406edc06bc add get_post 2024-04-07 19:18:29 -03:00
alacleaker 1e311aa84e Update cli.py 2024-04-07 19:18:22 -03:00
alacleaker 739debcfb9 fix hvc1 2024-04-07 19:06:19 -03:00
alacleaker b3edae7d91 adjust get_m3u8_master_data 2024-04-07 18:57:23 -03:00
alacleaker 7281a9b8bb rename music-video-codec to codec-music-video 2024-04-07 18:57:17 -03:00
alacleaker a62a52bfa0 Create downloader_music_video.py 2024-04-07 17:24:19 -03:00
alacleaker e78f02812a add lru cache to get_album 2024-04-07 17:24:16 -03:00
alacleaker f500488f13 fix get_cover_path not using specified format 2024-04-07 17:24:02 -03:00
alacleaker 76eb8e62f9 add lru cache decoration to get_resource 2024-04-07 17:23:44 -03:00
alacleaker f46387dacf update music video codec 2024-04-07 15:41:42 -03:00
alacleaker e92a8890f9 new version 2024-04-07 14:45:40 -03:00
alacleaker 48c7d08609 Create downloader_song.py 2024-04-07 14:45:33 -03:00
alacleaker b6e2761af4 Create downloader_song_legacy.py 2024-04-07 14:45:30 -03:00
alacleaker 129ce5d20d add remux mode and ask in song codec 2024-04-07 14:45:26 -03:00
alacleaker 57209cf071 rename cookies_location to cookies_path 2024-04-07 11:56:11 -03:00
alacleaker c7aaca1f29 add SONG_CODEC_REGEX_MAP 2024-04-06 19:22:22 -03:00
alacleaker eb323f2faf fix enums import 2024-04-06 19:22:08 -03:00
alacleaker f28b75b66d add more formats 2024-04-06 19:09:38 -03:00
alacleaker 14d54a7dd8 changed jpeg to jpg 2024-04-06 18:00:19 -03:00
alacleaker 57494e8e0a Create enums.py 2024-04-06 17:59:52 -03:00
alacleaker e54b6b3fc4 Create models.py 2024-04-06 17:59:50 -03:00
alacleaker 61d1876029 Create itunes_api.py 2024-04-06 17:59:48 -03:00
alacleaker dc3e0422bd Create hardcoded_wvd.py 2024-04-06 17:59:44 -03:00
alacleaker c77dc4adba Create apple_music_api.py 2024-04-06 17:59:39 -03:00
Rafael Moraes e76803add1 Merge pull request #86 from Cronocide/main
Added support for configuration from ENV
2024-03-21 13:56:49 -03:00
Cronocide 6551722ef3 Evaluate env in no_config_callback 2024-03-19 14:20:12 -06:00
Cronocide 90d17fb206 Evaluate env in no_config_callback 2024-03-19 14:15:13 -06:00
Cronocide cb09033cca Removed explicit env documentation from README 2024-03-12 09:23:38 -06:00
Cronocide 733a4bb2c6 Added support for configuration from ENV 2024-03-12 00:36:56 -06:00
Rafael Moraes 56f01dc433 Bump version 2024-02-22 14:39:51 -03:00
Rafael Moraes d99cfec349 Bump version 2024-02-22 14:39:40 -03:00
Rafael Moraes 4ecc76ebe4 Fix missing date 2024-02-22 14:39:26 -03:00
Rafael Moraes 2678950fcd Merge pull request #76 from florisdipt/main
Add support for downloading playlists with more than 300 songs
2024-02-17 02:02:53 -03:00
FlorisDipt c0eae562e7 Replace hardcoded API hostname with a constant 2024-02-17 00:21:41 +01:00
FlorisDipt b2b11f3680 Seperated getting additional playlist tracks into its own function 2024-02-17 00:15:06 +01:00
FlorisDipt d2dd8673ca Added support for large playlist downloading.
Tested initial version, not happy the code but happy with the functionality. Pre refactor commit
2024-02-16 23:48:57 +01:00
Rafael Moraes da3ec2b4e7 Merge pull request #64 from glomatico/dev
Dev
2023-11-27 16:12:06 -03:00
R. M bb7579870e bump version 2023-11-27 16:11:45 -03:00
R. M 6c459b566e adjust url regex result 2023-11-27 16:11:29 -03:00
Rafael Moraes 4172ccaf37 Merge pull request #62 from glomatico/dev
Dev
2023-11-21 14:41:50 -03:00
R. M 01c46d588e bump version 2023-11-21 14:41:31 -03:00
R. M f14fd2f42f fix WidevinePsshData import 2023-11-21 14:38:59 -03:00
Rafael Moraes 6febfe9c3d Merge pull request #59 from glomatico/dev
Dev
2023-11-14 22:42:14 -03:00
R. M b648c295b9 bump version 2023-11-14 22:41:40 -03:00
R. M dd96fbb134 adjust apply_tags 2023-11-14 22:41:00 -03:00
Rafael Moraes 72aae6c147 Merge pull request #58 from glomatico/dev
fix array
2023-11-14 22:21:38 -03:00
R. M 5564105d9c fix array 2023-11-14 22:18:12 -03:00
Rafael Moraes 934cb4db93 Merge pull request #57 from glomatico/dev
Dev
2023-11-14 22:09:39 -03:00
R. M 1a86f23187 bump version 2023-11-14 21:22:26 -03:00
R. M 5bf0e6dc09 Update README.md 2023-11-14 21:21:24 -03:00
R. M c98d779c1b make prefer_account_language as downloader attribute and minor improvements 2023-11-14 21:21:13 -03:00
R. M 47a9cfe3ef Update README.md 2023-11-14 18:27:22 -03:00
R. M 743f5bedb9 adjust sanitize_date 2023-11-14 18:27:19 -03:00
R. M eb041894cf adjust template date arg default value and help 2023-11-14 18:27:01 -03:00
Rafael Moraes d5f5398fe1 Merge pull request #56 from reaitten/dev
Add some fixes and improvements
2023-11-14 18:10:22 -03:00
reaitten c55a894212 Add custom date template option & fix README 2023-11-13 21:26:49 -05:00
reaitten 8a3e857cb3 Strip time and timezone from date in tagged metadata 2023-11-13 20:18:31 -05:00
reaitten 71e31d7832 Add option to prefer original language 2023-11-13 20:17:47 -05:00
Rafael Moraes abef15789d Merge pull request #55 from glomatico/dev
Dev
2023-11-09 15:26:26 -03:00
R. M 41a8584023 bump version 2023-11-09 15:25:54 -03:00
R. M 025a5a22cf Fix album download and playlist download 2023-11-09 15:25:39 -03:00
Rafael Moraes 936b878474 Merge pull request #54 from glomatico/dev
Dev
2023-11-09 10:00:24 -03:00
R. M d76d574da9 bump version 2023-11-09 09:59:50 -03:00
R. M 92f65d5c10 Enhance exception verbose 2023-11-08 17:05:14 -03:00
R. M d96a28f5fb Fix download_type check 2023-11-08 16:18:32 -03:00
R. M 4064ebcec5 Create get_song, music_video, album and playlist methods 2023-11-08 16:18:04 -03:00
Rafael Moraes b70792e9a7 Merge pull request #53 from glomatico/dev
adjust get_lyrics_synced_timestamp_lrc
2023-11-02 16:37:19 -03:00
R. M 68ba1556f5 adjust get_lyrics_synced_timestamp_lrc 2023-11-02 16:26:54 -03:00
Rafael Moraes 6fdca2fe2e Merge pull request #52 from glomatico/dev
Dev
2023-10-25 23:29:44 -03:00
R. M 7709a37b88 bump version 2023-10-25 23:29:22 -03:00
R. M 9c4d2d1a13 adjust lyrics ms parsing 2023-10-25 23:28:27 -03:00
Rafael Moraes 6a4b9d8eb1 Merge pull request #51 from glomatico/dev
Dev
2023-10-21 22:08:28 -03:00
R. M 3be4a4bbbc bump version 2023-10-21 22:08:00 -03:00
R. M 70b595b323 add missing subprocess checks 2023-10-21 22:07:43 -03:00
Rafael Moraes 6e3824f448 Merge pull request #50 from glomatico/dev
Dev
2023-10-21 17:54:11 -03:00
R. M b0f98e21ec adjust get_lyrics_synced_timestamp_lrc 2023-10-21 17:50:50 -03:00
R. M 46918ff4a0 bump version 2023-10-21 17:20:04 -03:00
R. M dc25b8a7be Update README.md 2023-10-21 15:59:45 -03:00
R. M 6516499df2 make getting music video stream_urls faster 2023-10-21 15:54:30 -03:00
R. M c550ae3c20 adjust ttml lrc timestamp conversion 2023-10-21 15:32:48 -03:00
Rafael Moraes 908dcf794b Merge pull request #48 from glomatico/dev
Dev
2023-10-18 10:08:37 -03:00
R. M 4dd8df7e70 bump version 2023-10-18 10:04:23 -03:00
R. M 8e6623cb93 lyrics attributes check 2023-10-16 06:37:48 -03:00
Rafael Moraes aabe0aa7ea Merge pull request #46 from glomatico/dev
Dev
2023-10-13 22:43:57 -03:00
R. M 7d122844e2 bump version 2023-10-13 22:43:20 -03:00
R. M 5bb34caf09 adjust has lyrics 2023-10-13 19:42:38 -03:00
R. M d615842c60 has lyrics check 2023-10-10 14:42:45 -03:00
Rafael Moraes 1a5dd94c1c Merge pull request #45 from glomatico/dev
Dev
2023-10-03 14:16:21 -03:00
R. M 17e930cfbd Update README.md 2023-10-02 16:56:26 -03:00
R. M df6b138d30 adjust get_stream_url_music_video 2023-09-30 14:10:05 -03:00
R. M c5271f6d99 Update README.md 2023-09-30 14:00:05 -03:00
R. M 4bc0938eea Update README.md 2023-09-30 01:31:51 -03:00
R. M c3285ac3cd rename x not found string 2023-09-30 01:16:52 -03:00
R. M b0e2e15702 bump version 2023-09-30 01:16:31 -03:00
R. M 1c94d04066 adjust log messages 2023-09-30 01:14:45 -03:00
R. M 13bf369d26 Update README.md 2023-09-30 01:14:32 -03:00
R. M 91e8111d72 add missing return types 2023-09-30 00:13:56 -03:00
R. M af1a356322 rename make_lrc to save_lrc 2023-09-30 00:12:19 -03:00
R. M 8ad83e1f82 Update downloader.py 2023-09-30 00:07:09 -03:00
R. M 0f539115ad rename cleanup to cleanup_temp_path 2023-09-30 00:06:26 -03:00
R. M 70139e9eaa create cleanup function 2023-09-30 00:03:38 -03:00
R. M 56c77a0447 adjust version argument and don't setup cdm with lrc only 2023-09-29 23:42:06 -03:00
R. M d9d7ddf3d7 fix old python version compatibility 2023-09-19 06:58:16 -03:00
Rafael Moraes cd929a0626 Merge pull request #42 from glomatico/dev
Dev
2023-09-09 01:47:07 -03:00
R. M 366698fa86 bump version 2023-09-09 01:24:38 -03:00
R. M 99e8c30826 adjust failed to check url log message 2023-09-09 01:07:59 -03:00
R. M 20f306b026 rename dl to downloader 2023-09-09 01:04:23 -03:00
R. M 014f55a994 lrc first then cover 2023-09-09 01:00:08 -03:00
R. M 813b119e5a adjust get download queue 2023-09-09 00:23:45 -03:00
R. M 186209f99a adjust failed to download log 2023-09-09 00:14:38 -03:00
R. M beb2e10349 remove some log messages 2023-09-09 00:05:42 -03:00
R. M 0203ec4535 adjust download mode choice 2023-09-08 23:40:25 -03:00
R. M 6cdea55eb1 Update README.md 2023-09-08 23:39:26 -03:00
R. M efc5387785 adjust remux mode help 2023-09-08 23:38:52 -03:00
R. M b52b2f050c rename yt-dlp to ytdlp 2023-09-08 23:38:27 -03:00
R. M 8926052751 save cover lru cache 2023-09-08 23:32:41 -03:00
R. M 72df094194 remove failed to setup x constant 2023-09-07 19:42:05 -03:00
R. M 8bc369481c fix line too long 2023-09-07 19:06:29 -03:00
R. M b1f04ab0ce fix failed to download track error log 2023-09-07 19:03:29 -03:00
R. M 2e34754303 return types 2023-09-07 16:40:59 -03:00
R. M a464de8f18 Update README.md 2023-09-07 16:28:30 -03:00
R. M 7e7fbc5a38 rename disable_music_video_skip option 2023-09-07 16:28:27 -03:00
R. M 06ffa8a2ca add return types and rename lyrics variable 2023-09-07 16:20:33 -03:00
R. M 12cf08d489 add starting downloader log 2023-09-07 16:03:13 -03:00
R. M 4ecf5fc6f4 Update README.md 2023-09-07 15:33:13 -03:00
R. M 44de089522 Update README.md 2023-09-07 15:27:38 -03:00
R. M 9a4fdf1874 closed captions with ffmpeg 2023-09-07 15:26:39 -03:00
R. M 8180a850fd adjust log messages 2023-09-07 15:13:55 -03:00
R. M 2b2fe89dcc more debug logs and fix download mode in music video 2023-09-05 17:00:44 -03:00
R. M fca7ee5c3b split download function 2023-09-04 23:22:56 -03:00
R. M 2d510a7703 cover_url 2023-09-04 23:17:58 -03:00
R. M 1cd5221304 tags optimization 2023-09-04 22:27:51 -03:00
R. M 788270c2ab rename constants variables 2023-09-04 22:16:38 -03:00
R. M 3dc9132a60 download queue optimizations 2023-09-04 22:10:37 -03:00
R. M f5a8180fcc constants file 2023-09-04 17:26:15 -03:00
R. M ebc07082d5 rename dl to downloader 2023-09-04 17:17:25 -03:00
R. M 6f96166cf0 start session and start cdm 2023-09-04 17:12:36 -03:00
R. M 9358a8b760 binaries location adjust 2023-09-04 16:57:01 -03:00
Rafael Moraes bea54a73cb Merge pull request #41 from glomatico/dev
Dev
2023-08-27 20:14:30 -03:00
R. M 85f930b298 Bump version 2023-08-27 20:02:38 -03:00
R. M 44cfa36f9a Update README.md 2023-08-27 20:02:24 -03:00
R. M fd60260e6c Update README.md 2023-08-27 20:02:04 -03:00
R. M 919c9a66ec Change songs aac help 2023-08-27 19:59:43 -03:00
R. M f1d609e8d2 Update README.md 2023-08-27 19:59:18 -03:00
R. M e91fa202f0 Add missing rating tag 2023-08-27 11:44:38 -03:00
Rafael Moraes a181f53f48 Merge pull request #39 from glomatico/dev
Dev
2023-08-27 04:00:11 -03:00
R. M f4cb9a9c4e Update .gitignore 2023-08-27 03:50:58 -03:00
R. M b3a328f1a2 Return when ffmpeg is not on path 2023-08-27 02:33:50 -03:00
R. M 18bbb3418c Adjust binaries 2023-08-27 02:32:27 -03:00
R. M d3629a6846 Binaries location type to str 2023-08-27 02:27:31 -03:00
R. M 8a4d6b33ec Fix nm3u8dlre ffmpeg issue 2023-08-27 02:15:43 -03:00
R. M 24858f4ca8 Update README.md 2023-08-27 01:51:05 -03:00
R. M 1b0b00cf08 Add ffmpeg binay path option in nm3u8dlre 2023-08-27 01:47:40 -03:00
R. M 8b6b81466b Update README.md 2023-08-27 01:44:00 -03:00
R. M 752681d3ff Update README.md 2023-08-27 01:43:07 -03:00
R. M 4dc9a25753 Update README.md 2023-08-27 01:42:10 -03:00
R. M 9303722eab Update README.md 2023-08-27 01:39:23 -03:00
R. M 153727c625 Update README.md 2023-08-27 01:37:30 -03:00
R. M aa3a61bca3 Fix spell mistake in get_sanitized_string 2023-08-27 01:14:32 -03:00
R. M aaf2e9181f Update pyproject.toml 2023-08-27 01:07:08 -03:00
R. M 6d598cc294 Update README.md 2023-08-27 00:48:02 -03:00
R. M 0a6c7d78c8 Bump version 2023-08-27 00:47:59 -03:00
R. M cdde615186 Invalid cookies file error 2023-08-27 00:33:22 -03:00
R. M 631650824f Update x not found string 2023-08-26 23:39:52 -03:00
R. M a11a937b89 Update if media is album on get final location 2023-08-26 15:54:29 -03:00
R. M 688816125b Change get_cover arguments 2023-08-25 17:30:31 -03:00
R. M 36395f226f Fix music video skip lrc only 2023-08-25 16:33:00 -03:00
R. M 15b5342707 Lrc only music video skip 2023-08-25 16:31:01 -03:00
R. M c8bda637fe Changed file template argument help 2023-08-25 13:12:03 -03:00
R. M 774df56091 Add click 2023-08-25 12:27:23 -03:00
R. M 157e331a8a Fix unresolved attribute for class dl 2023-08-25 12:27:14 -03:00
R. M f0e1d672a5 Fix lyrics timestamp 2023-08-25 03:46:53 -03:00
R. M ee434e767e Fix skip music video when mp4decrypt is not present 2023-08-25 03:44:36 -03:00
R. M 04719f7587 Skip music videos when mp4decrypt is not present 2023-08-25 03:43:23 -03:00
R. M dc3c673985 Cookies file not found error 2023-08-25 03:05:43 -03:00
R. M f912a19fed Skip remux and download_mode check in lrc_only mode 2023-08-25 03:05:27 -03:00
R. M bd3553048f Remove cover quality 2023-08-25 02:58:08 -03:00
R. M e6cd1a11d5 New lrc timestamp method, heaac and remove unused cover quality argument 2023-08-25 02:54:10 -03:00
R. M dd9968387e Change log level argument position 2023-08-25 01:17:42 -03:00
R. M 10122146f1 Remove old cli 2023-08-25 00:58:24 -03:00
R. M 3d17b3f363 New cli 2023-08-25 00:58:16 -03:00
R. M 4356519dce Create cli.py 2023-08-25 00:57:41 -03:00
R. M 24bac438b7 Create dl.py 2023-08-25 00:57:36 -03:00
R. M 9fb05ef89c Delete gamdl.py 2023-08-25 00:57:13 -03:00
R. M f0966e7b39 Rename to storefronts 2023-08-23 03:06:59 -03:00
R. M ae4c2abfe4 Changed version 2023-06-11 01:09:24 -03:00
R. M 37554e8f49 Added missing line 2023-06-11 01:09:12 -03:00
R. M aee494f464 Changed version 2023-06-11 00:17:05 -03:00
R. M eddd6f9053 Update __init__.py 2023-06-11 00:15:47 -03:00
R. M 24d9fdf9ee Increase video cover size 2023-06-10 12:20:35 -03:00
R. M a0fef9944e Changed URL argument behavior 2023-06-10 12:20:00 -03:00
R. M e37038e67b Remove skip cleanup 2023-06-10 12:15:53 -03:00
R. M 52c59f9a17 Update gamdl.py 2023-06-10 12:09:37 -03:00
R. M 2f80b9dc65 Update __main__.py 2023-06-10 12:09:35 -03:00
R. M caed322fd0 Update __init__.py 2023-06-10 12:09:33 -03:00
R. M 76396f3fed Update README.md 2023-05-20 14:21:10 -03:00
R. M b64cc06641 Version bump 2023-05-20 14:15:22 -03:00
R. M 4e2c54934a LRC only mode 2023-05-20 14:13:26 -03:00
R. M e76c79d9b4 Remove Print Video M3U8 URL 2023-05-20 13:14:32 -03:00
R. M 6dd730c368 Increased cover size 2023-05-20 13:12:14 -03:00
R. M 2a2403c130 Update gamdl.py 2023-05-20 13:10:11 -03:00
R. M fd47acab4f Remove HE-AAC 2023-05-20 13:09:50 -03:00
R. M 66d8211a16 Update get_sanizated_string 2023-05-20 13:05:26 -03:00
R. M 158f0e9f27 Update gamdl.py 2023-05-20 13:04:59 -03:00
R. M 0d9b225fdc Version bump 2023-04-19 22:47:02 -03:00
R. M 254147096a Fix index_js_uri 2023-04-19 22:46:31 -03:00
R. M 3a10069c76 heaac 2023-04-05 23:43:46 -03:00
R. M 9e07aee4e6 Update __init__.py 2023-04-04 16:13:30 -03:00
R. M 2c18a285a0 Update gamdl.py 2023-04-04 16:13:21 -03:00
R. M c854af5b2c Changed version 2023-04-04 16:08:22 -03:00
R. M f10a4a731b Gapless tag 2023-04-04 16:07:47 -03:00
R. M 527dd9935a Update __init__.py 2023-03-28 01:30:37 -03:00
R. M 06d5c10725 Update gamdl.py 2023-03-28 01:30:20 -03:00
R. M 58a8e3944d Update gamdl.py 2023-03-28 01:15:23 -03:00
R. M 4c7e563d4c Remove useless if 2023-03-28 01:13:02 -03:00
R. M f05dace5c1 Changed version 2023-03-28 01:10:11 -03:00
R. M eb81728475 Remove unused methods 2023-03-28 01:08:41 -03:00
R. M 96c90e1716 Better synced lyrics time format 2023-03-28 01:08:09 -03:00
R. M 7459d95df0 Added missing SD quality 2023-03-27 08:40:10 -03:00
R. M b2521e2933 Changed video get stream url method 2023-03-27 08:39:09 -03:00
R. M f87bee7732 Changed version 2023-02-22 12:11:36 -03:00
R. M 3aa36c1323 Minor change 2023-02-22 12:10:54 -03:00
R. M 7c60b2cd31 Added -new on MP4Box fixup 2023-02-22 12:06:20 -03:00
R. M 68ff155a9e Changed pywidevine import 2023-02-22 12:04:24 -03:00
R. M 0d41ef0895 Minor change 2023-02-22 12:02:30 -03:00
R. M 575f652813 Fixed music video copyright tag error 2023-02-22 11:54:09 -03:00
R. M 11db7154a1 Changed URL check error message 2023-02-22 11:51:36 -03:00
51 changed files with 10827 additions and 885 deletions
+1
View File
@@ -0,0 +1 @@
ko_fi: glomatico
-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/
+4 -1
View File
@@ -1,5 +1,8 @@
/*
__pycache__
!gamdl
!requirements.txt
!.gitignore
!.python-version
!pyproject.toml
!README.md
!uv.lock
+1
View File
@@ -0,0 +1 @@
3.10
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Glomatico
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+376 -80
View File
@@ -1,91 +1,387 @@
# Glomatico's Apple Music Downloader
A Python script to download Apple Music songs/music videos/albums/playlists.
# Gamdl (Glomatico's Apple Music Downloader)
![Windows CMD usage example](https://i.imgur.com/18Azlg4.png)
[![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/)
This is a rework of https://github.com/loveyoursupport/AppleMusic-Downloader/tree/661a274d62586b521feec5a7de6bee0e230fdb7d.
A command-line app for downloading Apple Music songs, music videos and post videos.
Some new features that I added:
* MP4Box for muxing
* Tags for music videos
* Multiple URLs input
* iTunes folder structure
* Embedded lyrics and .lrc file
* Auto set region
* Playlist support
* And much more!
**Join our Discord Server:** <https://discord.gg/aBjMEZ9tnq>
## Setup
1. Install Python 3.7 or newer
2. Install gamdl with pip
```
pip install gamdl
```
3. Add MP4Box and mp4decrypt to your PATH
* You can get them from here:
* MP4Box: https://gpac.wp.imt.fr/downloads/
* mp4decrypt: https://www.bento4.com/downloads/
4. Export your Apple Music cookies as `cookies.txt` to the same folder that you will run the script
* You can export your cookies by using this Google Chrome extension on Apple Music website: https://chrome.google.com/webstore/detail/open-cookiestxt/gdocmgbfkjnnpapoeobnolbbkoibbcif. Make sure to be logged in.
5. Put your Widevine Device file (.wvd) in the same folder that you will run the script
* You can use Dumper to dump your phone's L3 CDM: https://github.com/Diazole/dumper. Once you have the L3 CDM, you can use pywidevine to create the .wvd file from it.
1. Install pywidevine with pip
```
pip install pywidevine pyyaml
```
2. Create the .wvd file
```
pywidevine create-device -t ANDROID -l 3 -k private_key.pem -c client_id.bin -o .
```
6. (optional) Add aria2c to your PATH for faster downloads
* You can get it from here: https://github.com/aria2/aria2/releases.
## ✨ Features
## Usage
```
usage: gamdl [-h] [-u [URLS_TXT]] [-w WVD_LOCATION] [-f FINAL_PATH] [-t TEMP_PATH] [-c COOKIES_LOCATION] [-m] [-p]
[-o] [-n] [-s] [-e] [-i] [-v]
[url ...]
- 🎵 **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
Download Apple Music songs/music videos/albums/playlists
## 📋 Prerequisites
positional arguments:
url Apple Music song/music video/album/playlist URL(s) (default: None)
### Required
options:
-h, --help show this help message and exit
-u [URLS_TXT], --urls-txt [URLS_TXT]
Read URLs from a text file (default: None)
-w WVD_LOCATION, --wvd-location WVD_LOCATION
.wvd file location (default: *.wvd)
-f FINAL_PATH, --final-path FINAL_PATH
Final Path (default: Apple Music)
-t TEMP_PATH, --temp-path TEMP_PATH
Temp Path (default: temp)
-c COOKIES_LOCATION, --cookies-location COOKIES_LOCATION
Cookies location (default: cookies.txt)
-m, --disable-music-video-skip
Disable music video skip on playlists/albums (default: False)
-p, --prefer-hevc Prefer HEVC over AVC (default: False)
-o, --overwrite Overwrite existing files (default: False)
-n, --no-lrc Don't create .lrc file (default: False)
-s, --skip-cleanup Skip cleanup (default: False)
-e, --print-exceptions
Print execeptions (default: False)
-i, --print-video-m3u8-url
Print Video M3U8 URL (default: False)
-v, --version show program's version number and exit
- **Python 3.10 or higher**
- **Active Apple Music subscription**
- **Apple Music Cookies** - export your browser cookies in Netscape format while logged in at [Apple Music](https://music.apple.com):
- **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 Dependencies
#### Wrapper
Run the [Wrapper v2](https://github.com/glomatico/wrapper-v2) server for wrapper-backed account, playback, and decryption requests. Enable it with `--use-wrapper` or `use_wrapper = true`, and configure the base URL with `--wrapper-url` or `wrapper_url`.
The wrapper is recommended when using these non-web song codecs:
- `aac`
- `aac-he`
- `aac-binaural`
- `aac-downmix`
- `aac-he-binaural`
- `aac-he-downmix`
- `atmos`
- `ac3`
- `alac`
**Note:**
- When using the Wrapper, you'll be asked to insert your credentials to login if you haven't already.
- Web song codecs such as `aac-web` and `aac-he-web` do not require the wrapper.
- Cookies can be skipped when using the wrapper.
#### N_m3u8DL-RE
Use [N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest) as a faster download alternative to the default yt-dlp download mode. Enable it with `--download-mode nm3u8dlre` or `download_mode = nm3u8dlre`.
If the executable is not available in your system PATH, set its location with `--nm3u8dlre-path` or `nm3u8dlre_path`.
## 📦 Installation
1. **Install Gamdl via pip:**
```bash
pip install gamdl
```
2. **Set up the cookies file:**
- Place the cookies file in the working directory as `cookies.txt`, or
- Specify the path using `--cookies-path` or in the config file
3. **Optional: Set up dependencies** (only if you need the functionality)
See the [Optional Dependencies](#optional-dependencies) section to determine which optional tools you need.
## 🚀 Usage
```bash
gamdl [OPTIONS] URLS...
```
## Songs/Music Videos quality
* Songs:
* 256kbps AAC
* Music Videos (varies depending on the video):
* 4K HEVC 20mbps / AAC 256kbps
* 4K HEVC 12mbps / AAC 256kbps
* 1080p AVC 10mbps / AAC 256kbps
* 1080p AVC 6.5bps / AAC 256kbps
* 720p AVC 4mbps / AAC 256kbps
* 480p AVC 1.5mbps / AAC 256kbps
* 360p AVC 1mbps / AAC 256kbps
### Supported URL Types
Some videos may include EIA-608 closed captions.
- Songs
- Albums (Public/Library)
- Playlists (Public/Library)
- Music Videos
- Artists
- Post Videos
- Apple Music Classical
### 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:**
| 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` |
| `--artist-auto-select` | Automatically select artist content to download (artist URLs) | - |
| `--database-path` | Path to the SQLite database file for registering downloaded media | - |
| `--no-config-file`, `-n` | Don't use a config file | `false` |
| **Apple Music Options** | | |
| `--cookies-path`, `-c` | Cookies file path | `./cookies.txt` |
| `--wrapper-url` | Wrapper base URL | `http://127.0.0.1` |
| `--language`, `-l` | Metadata language | `en-US` |
| **Interface Options** | | |
| `--cover-format` | Cover format | `jpg` |
| `--cover-size` | Cover size in pixels | `1200` |
| `--wvd-path` | .wvd file path | - |
| `--use-wrapper` | Use wrapper for account, playback, and decryption requests | `false` |
| **Song Options** | | |
| `--synced-lyrics-format` | Synced lyrics format | `lrc` |
| `--song-codec-priority` | Comma-separated codec priority | `aac-web` |
| `--use-album-date` | Use album release date for songs | `false` |
| `--no-synced-lyrics` | Don't download synced lyrics | `false` |
| `--synced-lyrics-only` | Download only synced lyrics | `false` |
| **Music Video Options** | | |
| `--music-video-resolution` | Max music video resolution | `1080p` |
| `--music-video-codec-priority` | Comma-separated codec priority | `h264,h265` |
| `--music-video-remux-format` | Music video remux format | `m4v` |
| **Post Video Options** | | |
| `--uploaded-video-quality` | Post video quality | `best` |
| **Download & Path Options** | | |
| `--output-path`, `-o` | Output directory path | `./Apple Music` |
| `--temp-path` | Temporary directory path | `.` |
| `--nm3u8dlre-path` | N_m3u8DL-RE executable path | `N_m3u8DL-RE` |
| `--download-mode` | Download mode | `ytdlp` |
| **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` |
| `--playlist-folder-template` | Playlist folder template | `Playlists/{playlist_artist}` |
| `--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 | `{playlist_title}` |
| `--date-tag-template` | Date tag template | `%Y-%m-%dT%H:%M:%SZ` |
| `--exclude-tags` | Comma-separated tags to exclude | - |
| `--truncate` | Max filename length | - |
| **File Output Options** | | |
| `--overwrite` | Overwrite existing files | `false` |
| `--save-cover`, `-s` | Save cover as separate file | `false` |
| `--save-playlist` | Save M3U8 playlist file | `false` |
### Template Variables
**Tags for templates and exclude-tags:**
- `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`
> [!NOTE]
>
> - **yt-dlp is only used as a file download library**. Media is still fetched directly from Apple Music's servers, and yt-dlp is only responsible for handling the file download process.
### 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
**Web:**
- `aac-web` - AAC 256kbps 44.1kHz
- `aac-he-web` - AAC-HE 64kbps 44.1kHz
**Non-web** (wrapper recommended; may not work without wrapper 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
- `ask` - Interactive 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
### Artist Auto-Select Options
- `main-albums`
- `compilation-albums`
- `live-albums`
- `singles-eps`
- `all-albums`
- `top-songs`
- `music-videos`
## 🐍 Embedding
Use Gamdl as a library in your Python projects:
```python
import asyncio
from gamdl.api import AppleMusicApi
from gamdl.downloader import (
AppleMusicBaseDownloader,
AppleMusicDownloader,
AppleMusicMusicVideoDownloader,
AppleMusicSongDownloader,
AppleMusicUploadedVideoDownloader,
)
from gamdl.interface import (
AppleMusicBaseInterface,
AppleMusicInterface,
AppleMusicMusicVideoInterface,
AppleMusicSongInterface,
AppleMusicUploadedVideoInterface,
)
async def main():
# Create AppleMusicApi instance from cookies
apple_music_api = await AppleMusicApi.create_from_netscape_cookies(
cookies_path="cookies.txt",
)
# Check subscription
if not apple_music_api.active_subscription:
print("No active Apple Music subscription")
return
# Create base interface
base_interface = await AppleMusicBaseInterface.create(
apple_music_api=apple_music_api,
)
# Create specialized interfaces
song_interface = AppleMusicSongInterface(
base=base_interface,
)
music_video_interface = AppleMusicMusicVideoInterface(
base=base_interface,
)
uploaded_video_interface = AppleMusicUploadedVideoInterface(
base=base_interface,
)
# Create main interface
interface = AppleMusicInterface(
song=song_interface,
music_video=music_video_interface,
uploaded_video=uploaded_video_interface,
)
# Create base downloader
base_downloader = AppleMusicBaseDownloader(
interface=interface,
)
# Create specialized downloaders
song_downloader = AppleMusicSongDownloader(base=base_downloader)
music_video_downloader = AppleMusicMusicVideoDownloader(
base=base_downloader,
)
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(base=base_downloader)
# Create main downloader
downloader = AppleMusicDownloader(
song=song_downloader,
music_video=music_video_downloader,
uploaded_video=uploaded_video_downloader,
)
# Download from URL
url = "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
download_queue = []
async for media in downloader.get_download_item_from_url(url):
download_queue.append(media)
for download_item in download_queue:
try:
await downloader.download(download_item)
except Exception as e:
print(f"Error downloading: {e}")
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 -183
View File
@@ -1,183 +1 @@
import shutil
import argparse
import traceback
from .gamdl import Gamdl
__version__ = '1.2'
def main():
if not shutil.which('mp4decrypt'):
raise Exception('mp4decrypt is not on PATH')
if not shutil.which('MP4Box'):
raise Exception('MP4Box is not on PATH')
parser = argparse.ArgumentParser(
description = 'Download Apple Music songs/music videos/albums/playlists',
formatter_class = argparse.ArgumentDefaultsHelpFormatter
)
parser.add_argument(
'url',
help = 'Apple Music song/music video/album/playlist URL(s)',
nargs = '*'
)
parser.add_argument(
'-u',
'--urls-txt',
help = 'Read URLs from a text file',
nargs = '?'
)
parser.add_argument(
'-w',
'--wvd-location',
default = '*.wvd',
help = '.wvd file location'
)
parser.add_argument(
'-f',
'--final-path',
default = 'Apple Music',
help = 'Final Path'
)
parser.add_argument(
'-t',
'--temp-path',
default = 'temp',
help = 'Temp Path'
)
parser.add_argument(
'-c',
'--cookies-location',
default = 'cookies.txt',
help = 'Cookies location'
)
parser.add_argument(
'-m',
'--disable-music-video-skip',
action = 'store_true',
help = 'Disable music video skip on playlists/albums'
)
parser.add_argument(
'-p',
'--prefer-hevc',
action = 'store_true',
help = 'Prefer HEVC over AVC'
)
parser.add_argument(
'-o',
'--overwrite',
action = 'store_true',
help = 'Overwrite existing files'
)
parser.add_argument(
'-n',
'--no-lrc',
action = 'store_true',
help = "Don't create .lrc file"
)
parser.add_argument(
'-s',
'--skip-cleanup',
action = 'store_true',
help = 'Skip cleanup'
)
parser.add_argument(
'-e',
'--print-exceptions',
action = 'store_true',
help = 'Print execeptions'
)
parser.add_argument(
'-i',
'--print-video-m3u8-url',
action = 'store_true',
help = 'Print Video M3U8 URL'
)
parser.add_argument(
'-v',
'--version',
action = 'version',
version = f'%(prog)s {__version__}'
)
args = parser.parse_args()
if not args.url and not args.urls_txt:
parser.error('you must specify an url or a text file using -u/--urls-txt')
if args.urls_txt:
with open(args.urls_txt, 'r', encoding = 'utf8') as f:
args.url = f.read().splitlines()
dl = Gamdl(
args.wvd_location,
args.cookies_location,
args.disable_music_video_skip,
args.prefer_hevc,
args.temp_path,
args.final_path,
args.no_lrc,
args.overwrite,
args.skip_cleanup
)
error_count = 0
download_queue = []
for i, url in enumerate(args.url):
try:
download_queue.append(dl.get_download_queue(url.strip()))
except KeyboardInterrupt:
exit(1)
except:
error_count += 1
print(f'* Failed to check URL {i + 1}.')
if args.print_exceptions:
traceback.print_exc()
for i, url in enumerate(download_queue):
for j, track in enumerate(url):
print(f'Downloading "{track["attributes"]["name"]}" (track {j + 1}/{len(url)} from URL {i + 1}/{len(download_queue)})')
track_id = track['id']
try:
webplayback = dl.get_webplayback(track_id)
if track['type'] == 'music-videos':
if args.print_video_m3u8_url:
print(webplayback['hls-playlist-url'])
tags = dl.get_tags_music_video(track['attributes']['url'].split('/')[-1].split('?')[0])
final_location = dl.get_final_location('.m4v', tags)
if dl.check_exists(final_location) and not args.overwrite:
continue
playlist = dl.get_playlist_music_video(webplayback)
stream_url_audio = dl.get_stream_url_music_video_audio(playlist)
decryption_keys_audio = dl.get_decryption_keys_music_video(stream_url_audio, track_id)
encrypted_location_audio = dl.get_encrypted_location_audio(track_id)
dl.download(encrypted_location_audio, stream_url_audio)
decrypted_location_audio = dl.get_decrypted_location_audio(track_id)
dl.decrypt(encrypted_location_audio, decrypted_location_audio, decryption_keys_audio)
stream_url_video = dl.get_stream_url_music_video_video(playlist)
decryption_keys_video = dl.get_decryption_keys_music_video(stream_url_video, track_id)
encrypted_location_video = dl.get_encrypted_location_video(track_id)
dl.download(encrypted_location_video, stream_url_video)
decrypted_location_video = dl.get_decrypted_location_video(track_id)
dl.decrypt(encrypted_location_video, decrypted_location_video, decryption_keys_video)
fixed_location = dl.get_fixed_location(track_id, '.m4v')
dl.fixup_music_video(decrypted_location_audio, decrypted_location_video, fixed_location)
dl.make_final(final_location, fixed_location, tags)
else:
unsynced_lyrics, synced_lyrics = dl.get_lyrics(track_id)
tags = dl.get_tags_song(webplayback, unsynced_lyrics)
final_location = dl.get_final_location('.m4a', tags)
if dl.check_exists(final_location) and not args.overwrite:
continue
stream_url = dl.get_stream_url_song(webplayback)
decryption_keys = dl.get_decryption_keys_song(stream_url, track_id)
encrypted_location = dl.get_encrypted_location_audio(track_id)
dl.download(encrypted_location, stream_url)
decrypted_location = dl.get_decrypted_location_audio(track_id)
dl.decrypt(encrypted_location, decrypted_location, decryption_keys)
fixed_location = dl.get_fixed_location(track_id, '.m4a')
dl.fixup_song(decrypted_location, fixed_location)
dl.make_final(final_location, fixed_location, tags)
dl.make_lrc(final_location, synced_lyrics)
except KeyboardInterrupt:
exit(1)
except:
error_count += 1
print(f'Failed to download "{track["attributes"]["name"]}" (track {j + 1}/{len(url)} from URL {i + 1}/{len(download_queue)})')
if args.print_exceptions:
traceback.print_exc()
dl.cleanup()
print(f'Done ({error_count} error(s))')
__version__ = "3.6"
+2 -3
View File
@@ -1,4 +1,3 @@
import gamdl
from .cli.cli import main
if __name__ == "__main__":
gamdl.main()
main()
+4
View File
@@ -0,0 +1,4 @@
from .apple_music import AppleMusicApi
from .exceptions import *
from .itunes import ItunesApi
from .wrapper import WrapperApi
+606
View File
@@ -0,0 +1,606 @@
import re
from http.cookiejar import MozillaCookieJar
from urllib.parse import parse_qs, urlparse
import httpx
import structlog
from httpx_retries import Retry, RetryTransport
from .constants import (
APPLE_MUSIC_ACCOUNT_INFO_API_URI,
APPLE_MUSIC_ALBUM_API_URI,
APPLE_MUSIC_AMP_API_URL,
APPLE_MUSIC_ARTIST_API_URI,
APPLE_MUSIC_COOKIE_DOMAIN,
APPLE_MUSIC_HOMEPAGE_URL,
APPLE_MUSIC_LIBRARY_ALBUM_API_URI,
APPLE_MUSIC_LIBRARY_PLAYLIST_API_URI,
APPLE_MUSIC_LICENSE_API_URL,
APPLE_MUSIC_MUSIC_VIDEO_API_URI,
APPLE_MUSIC_PLAYLIST_API_URI,
APPLE_MUSIC_SEARCH_API_URI,
APPLE_MUSIC_SONG_API_URI,
APPLE_MUSIC_UPLOADED_VIDEO_API_URL,
APPLE_MUSIC_WEBPLAYBACK_API_URL,
)
from .exceptions import GamdlApiResponseError
from .wrapper import WrapperApi
logger = structlog.get_logger(__name__)
class AppleMusicApi:
def __init__(
self,
client: httpx.AsyncClient,
token: str,
storefront: str,
language: str,
media_user_token: str | None = None,
account_info: dict | None = None,
) -> None:
self.token = token
self.storefront = storefront
self.language = language
self.media_user_token = media_user_token
self.account_info = account_info
self.client = client
@property
def active_subscription(self) -> bool:
if not self.account_info:
return False
return (
self.account_info.get("meta", {})
.get("subscription", {})
.get("active", False)
)
@property
def account_restrictions(self) -> dict | None:
if not self.account_info:
return None
data = self.account_info.get("data", [])
if not data:
return None
return data[0].get("attributes", {}).get("restrictions")
@staticmethod
async def get_token() -> str:
log = logger.bind(action="get_token")
response = None
async with httpx.AsyncClient() as client:
try:
response = await client.get(
APPLE_MUSIC_HOMEPAGE_URL,
follow_redirects=True,
)
response.raise_for_status()
home_page = response.text
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching Apple Music homepage",
status_code=response.status_code if response is not None else None,
)
index_js_uri_match = re.search(
r"/(assets/index-legacy[~-][^/\"]+\.js)",
home_page,
)
if not index_js_uri_match:
raise GamdlApiResponseError(
"Error finding index.js URI in Apple Music homepage"
)
index_js_uri = index_js_uri_match.group(1)
response = None
async with httpx.AsyncClient(follow_redirects=True) as client:
try:
response = await client.get(
f"{APPLE_MUSIC_HOMEPAGE_URL}/{index_js_uri}"
)
response.raise_for_status()
index_js_page = response.text
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching index.js page",
status_code=response.status_code if response is not None else None,
)
token_match = re.search('(?=eyJh)(.*?)(?=")', index_js_page)
if not token_match:
raise GamdlApiResponseError("Error finding token in index.js page")
token = token_match.group(1)
log.debug("success")
return token
@staticmethod
async def get_account_info(
token: str,
media_user_token: str,
meta: str = "subscription",
) -> dict:
log = logger.bind(action="get_account_info", meta=meta)
response = None
async with httpx.AsyncClient() as client:
try:
response = await client.get(
APPLE_MUSIC_AMP_API_URL + APPLE_MUSIC_ACCOUNT_INFO_API_URI,
params={
"meta": meta,
},
headers={
"authorization": f"Bearer {token}",
"origin": APPLE_MUSIC_HOMEPAGE_URL,
"cookie": f"media-user-token={media_user_token}",
},
)
response.raise_for_status()
account_info = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching account info",
status_code=response.status_code if response is not None else None,
)
log.debug("success", account_info=account_info)
return account_info
@classmethod
async def create(
cls,
storefront: str | None = "us",
language: str = "en-US",
token: str | None = None,
media_user_token: str | None = None,
) -> "AppleMusicApi":
token = token or await cls.get_token()
account_info = (
await cls.get_account_info(token, media_user_token)
if media_user_token
else None
)
storefront = (
account_info["meta"]["subscription"]["storefront"]
if account_info
else storefront
)
if not storefront:
raise ValueError(
"Storefront must be provided if it cannot be determined from account info"
)
client = httpx.AsyncClient(
headers={
"authorization": f"Bearer {token}",
"origin": APPLE_MUSIC_HOMEPAGE_URL,
},
transport=RetryTransport(
retry=Retry(
total=6,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
)
),
)
if media_user_token:
client.headers.update(
{
"cookie": f"media-user-token={media_user_token}",
}
)
api = cls(
client=client,
token=token,
storefront=storefront,
language=language,
media_user_token=media_user_token,
account_info=account_info,
)
return api
@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(
media_user_token=media_user_token,
*args,
**kwargs,
)
@classmethod
async def create_from_wrapper(
cls,
wrapper_api: WrapperApi,
*args,
**kwargs,
) -> "AppleMusicApi":
auth = wrapper_api.me.get("auth", {})
media_user_token = auth.get("music_user_token")
token = auth.get("dev_token")
if not media_user_token or not token:
raise GamdlApiResponseError(
"Wrapper account info is missing auth tokens",
status_code=None,
)
return await cls.create(
media_user_token=media_user_token,
token=token,
*args,
**kwargs,
)
async def _amp_request(
self,
uri: str,
params: dict | None = None,
) -> dict:
response = None
try:
response = await self.client.get(
APPLE_MUSIC_AMP_API_URL + uri,
params=params,
)
response.raise_for_status()
response_json = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching from AMP API",
content=response.text if response is not None else None,
status_code=response.status_code if response is not None else None,
)
if "errors" in response_json:
raise GamdlApiResponseError(
"Error fetching from AMP API",
content=response_json["errors"],
)
return response_json
async def get_song(
self,
song_id: str,
extend: str = "extendedAssetUrls",
include: str = "lyrics,albums",
) -> dict:
log = logger.bind(action="get_song", song_id=song_id)
song = await self._amp_request(
APPLE_MUSIC_SONG_API_URI.format(
storefront=self.storefront,
song_id=song_id,
),
{
"extend": extend,
"include": include,
},
)
log.debug("success", song=song)
return song
async def get_music_video(
self,
music_video_id: str,
include: str = "albums",
) -> dict:
log = logger.bind(action="get_music_video", music_video_id=music_video_id)
music_video = await self._amp_request(
APPLE_MUSIC_MUSIC_VIDEO_API_URI.format(
storefront=self.storefront,
music_video_id=music_video_id,
),
{
"include": include,
},
)
log.debug("success", music_video=music_video)
return music_video
async def get_uploaded_video(
self,
uploaded_video_id: str,
) -> dict:
log = logger.bind(
action="get_uploaded_video", uploaded_video_id=uploaded_video_id
)
uploaded_video = await self._amp_request(
APPLE_MUSIC_UPLOADED_VIDEO_API_URL.format(
storefront=self.storefront,
uploaded_video_id=uploaded_video_id,
)
)
log.debug("success", uploaded_video=uploaded_video)
return uploaded_video
async def get_album(
self,
album_id: str,
extend: str = "extendedAssetUrls",
) -> dict:
log = logger.bind(action="get_album", album_id=album_id)
album = await self._amp_request(
APPLE_MUSIC_ALBUM_API_URI.format(
storefront=self.storefront,
album_id=album_id,
),
{
"extend": extend,
},
)
log.debug("success", album=album)
return album
async def get_playlist(
self,
playlist_id: str,
limit_tracks: int = 300,
extend: str = "extendedAssetUrls",
) -> dict:
log = logger.bind(action="get_playlist", playlist_id=playlist_id)
playlist = await self._amp_request(
APPLE_MUSIC_PLAYLIST_API_URI.format(
storefront=self.storefront,
playlist_id=playlist_id,
),
{
"limit[tracks]": limit_tracks,
"extend": extend,
},
)
log.debug("success", 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:
log = logger.bind(action="get_artist", artist_id=artist_id)
artist = await self._amp_request(
APPLE_MUSIC_ARTIST_API_URI.format(
storefront=self.storefront,
artist_id=artist_id,
),
{
"include": include,
"views": views,
**{
f"limit[{_include}]": limit
for _include in [*include.split(","), *views.split(",")]
},
},
)
log.debug("success", artist=artist)
return artist
async def get_library_album(
self,
album_id: str,
extend: str = "extendedAssetUrls",
) -> dict:
log = logger.bind(action="get_library_album", album_id=album_id)
album = await self._amp_request(
APPLE_MUSIC_LIBRARY_ALBUM_API_URI.format(
album_id=album_id,
),
{
"extend": extend,
},
)
log.debug("success", album=album)
return album
async def get_library_playlist(
self,
playlist_id: str,
include: str = "tracks",
limit: int = 100,
extend: str = "extendedAssetUrls",
) -> dict:
log = logger.bind(action="get_library_playlist", playlist_id=playlist_id)
playlist = await self._amp_request(
APPLE_MUSIC_LIBRARY_PLAYLIST_API_URI.format(
playlist_id=playlist_id,
),
{
"include": include,
**{f"limit[{_include}]": limit for _include in include.split(",")},
"extend": extend,
},
)
log.debug("success", 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:
log = logger.bind(action="get_search_results", term=term, types=types)
search_results = await self._amp_request(
APPLE_MUSIC_SEARCH_API_URI.format(
storefront=self.storefront,
),
{
"term": term,
"types": types,
"limit": limit,
"offset": offset,
},
)
log.debug("success", search_results=search_results)
return search_results
async def get_extended_api_data(
self,
next_uri: str | None,
href_uri: str,
) -> dict:
log = logger.bind(
action="extend_api_data", next_uri=next_uri, href_uri=href_uri
)
if not next_uri:
log.debug("no_next_uri")
return
href_params = parse_qs(urlparse(href_uri).query)
next_params = parse_qs(urlparse(next_uri).query)
if href_params.get("limit"):
limit = int(href_params["limit"][0])
else:
limit = None
extended_data = await self._amp_request(
urlparse(next_uri).path,
{
**({"limit": limit} if limit else {}),
**{k: v for k, v in next_params.items() if k not in ["limit"]},
},
)
log.debug("success", extended_data=extended_data)
return extended_data
async def get_webplayback(
self,
track_id: str,
) -> dict:
log = logger.bind(action="get_webplayback", track_id=track_id)
response = None
try:
response = await self.client.post(
APPLE_MUSIC_WEBPLAYBACK_API_URL,
json={
"salableAdamId": track_id,
"language": self.language,
},
)
response.raise_for_status()
webplayback = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching webplayback data",
content=response.text if response is not None else None,
status_code=response.status_code if response is not None else None,
)
if "dialog" in webplayback:
raise GamdlApiResponseError(
"Error fetching webplayback data",
content=webplayback["dialog"],
)
log.debug("success", webplayback=webplayback)
return webplayback
async def get_license_exchange(
self,
track_id: str,
track_uri: str,
challenge: str,
key_system: str = "com.widevine.alpha",
is_library: bool = False,
) -> dict:
log = logger.bind(action="get_license_exchange", track_id=track_id)
response = None
try:
response = await self.client.post(
APPLE_MUSIC_LICENSE_API_URL,
json={
"challenge": challenge,
"key-system": key_system,
"uri": track_uri,
"adamId": track_id,
"isLibrary": is_library,
"user-initiated": True,
},
)
response.raise_for_status()
license_exchange = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching license exchange data",
content=response.text if response is not None else None,
status_code=response.status_code if response is not None else None,
)
if license_exchange.get("status") != 0:
raise GamdlApiResponseError(
"Error fetching license exchange data",
content=response.text,
status_code=response.status_code,
)
log.debug("success", license_exchange=license_exchange)
return license_exchange
+34
View File
@@ -0,0 +1,34 @@
APPLE_MUSIC_HOMEPAGE_URL = "https://music.apple.com"
APPLE_MUSIC_COOKIE_DOMAIN = ".music.apple.com"
APPLE_MUSIC_AMP_API_URL = "https://amp-api.music.apple.com"
APPLE_MUSIC_ACCOUNT_INFO_API_URI = "/v1/me/account"
APPLE_MUSIC_SONG_API_URI = "/v1/catalog/{storefront}/songs/{song_id}"
APPLE_MUSIC_MUSIC_VIDEO_API_URI = (
"/v1/catalog/{storefront}/music-videos/{music_video_id}"
)
APPLE_MUSIC_UPLOADED_VIDEO_API_URL = (
"/v1/catalog/{storefront}/uploaded-videos/{uploaded_video_id}"
)
APPLE_MUSIC_ALBUM_API_URI = "/v1/catalog/{storefront}/albums/{album_id}"
APPLE_MUSIC_PLAYLIST_API_URI = "/v1/catalog/{storefront}/playlists/{playlist_id}"
APPLE_MUSIC_ARTIST_API_URI = "/v1/catalog/{storefront}/artists/{artist_id}"
APPLE_MUSIC_LIBRARY_ALBUM_API_URI = "/v1/me/library/albums/{album_id}"
APPLE_MUSIC_LIBRARY_PLAYLIST_API_URI = "/v1/me/library/playlists/{playlist_id}"
APPLE_MUSIC_SEARCH_API_URI = "/v1/catalog/{storefront}/search"
APPLE_MUSIC_WEBPLAYBACK_API_URL = (
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback"
)
APPLE_MUSIC_LICENSE_API_URL = (
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/acquireWebPlaybackLicense"
)
APPLE_MUSIC_MUSIC_KIT_URL = (
"https://music.apple.com/includes/js-cdn/musickit/v3/amp/musickit.js"
)
ITUNES_LOOKUP_API_URL = "https://itunes.apple.com/lookup"
ITUNES_PAGE_API_URL = "https://music.apple.com/{media_type}/{media_id}"
+25
View File
@@ -0,0 +1,25 @@
from ..utils import GamdlError
class GamdlApiError(GamdlError):
pass
class GamdlApiResponseError(GamdlApiError):
def __init__(
self,
message: str,
content: str | None = None,
status_code: int | None = None,
):
self.message = message
self.content = content
self.status_code = status_code
if status_code is not None:
message = f"{message} (Status code: {status_code})"
if content:
message += f": {content}"
super().__init__(message)
+151
View File
@@ -0,0 +1,151 @@
import re
import httpx
import structlog
from .constants import (
APPLE_MUSIC_MUSIC_KIT_URL,
ITUNES_LOOKUP_API_URL,
ITUNES_PAGE_API_URL,
)
from .exceptions import GamdlApiResponseError
logger = structlog.get_logger(__name__)
class ItunesApi:
def __init__(
self,
client: httpx.AsyncClient,
storefront: str,
language: str,
storefront_id: int,
) -> None:
self.client = client
self.storefront = storefront
self.language = language
self.storefront_id = storefront_id
@staticmethod
async def get_storefront_id(storefront: str) -> int:
log = logger.bind(action="get_storefront_id", storefront=storefront)
response = None
async with httpx.AsyncClient() as client:
try:
response = await client.get(APPLE_MUSIC_MUSIC_KIT_URL)
response.raise_for_status()
music_kit_content = response.text
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching MusicKit content",
status_code=response.status_code if response is not None else None,
)
normalized_storefront = storefront.upper()
country_code_pattern = f'{normalized_storefront}:"([A-Z]{{3}})"'
country_code_match = re.search(country_code_pattern, music_kit_content)
if not country_code_match:
raise GamdlApiResponseError(
f"Country code {storefront} not found in MusicKit content"
)
three_letter_code = country_code_match.group(1)
storefront_pattern = f'{three_letter_code}:"(\\d+)"'
storefront_match = re.search(storefront_pattern, music_kit_content)
if not storefront_match:
raise GamdlApiResponseError(
f"Storefront ID not found for country code {storefront}"
)
storefront_id = int(storefront_match.group(1))
log.debug("success", storefront_id=storefront_id)
return storefront_id
@classmethod
async def create(
cls,
storefront: str = "us",
storefront_id: int | None = 143441,
language: str = "en-US",
) -> "ItunesApi":
storefront_id = storefront_id or await cls.get_storefront_id(storefront)
client = httpx.AsyncClient(
timeout=60.0,
follow_redirects=True,
)
return cls(
client=client,
storefront=storefront,
language=language,
storefront_id=storefront_id,
)
async def get_lookup_result(
self,
media_id: str,
entity: str = "album",
) -> dict:
log = logger.bind(action="get_lookup_result", media_id=media_id, entity=entity)
response = None
try:
response = await self.client.get(
ITUNES_LOOKUP_API_URL,
params={
"id": media_id,
"entity": entity,
"country": self.storefront,
"lang": self.language,
},
)
response.raise_for_status()
lookup_result = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching iTunes lookup result",
content=response.text if response is not None else None,
status_code=response.status_code if response is not None else None,
)
log.debug("success", lookup_result=lookup_result)
return lookup_result
async def get_itunes_page(
self,
media_type: str,
media_id: str,
) -> dict:
log = logger.bind(
action="get_itunes_page",
media_type=media_type,
media_id=media_id,
)
response = None
try:
response = await self.client.get(
ITUNES_PAGE_API_URL.format(media_type=media_type, media_id=media_id),
headers={
"X-Apple-Store-Front": f"{self.storefront_id},32 t:music31",
},
)
response.raise_for_status()
itunes_page = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching iTunes page",
content=response.text if response is not None else None,
status_code=response.status_code if response is not None else None,
)
log.debug("success", itunes_page=itunes_page)
return itunes_page
+272
View File
@@ -0,0 +1,272 @@
from __future__ import annotations
import inspect
import struct
from collections.abc import Awaitable, Callable
from typing import TypeVar
import httpx
import structlog
from .exceptions import GamdlApiResponseError
logger = structlog.get_logger(__name__)
T = TypeVar("T")
CredentialsFunc = (
Callable[[], tuple[str, str]] | Callable[[], Awaitable[tuple[str, str]]]
)
TwoFactorCodeFunc = Callable[[], str] | Callable[[], Awaitable[str]]
async def _invoke(func: Callable[[], T | Awaitable[T]]) -> T:
result = func()
if inspect.isawaitable(result):
return await result
return result
class WrapperApi:
def __init__(
self,
base_url: str,
client: httpx.AsyncClient,
me: dict,
):
self.base_url = base_url
self.client = client
self.me = me
@staticmethod
def build_decrypt_sample_frame(
adam_id: str,
skd_uri: str,
ciphertexts: list[bytes],
) -> bytes:
"""Build wrapper-v2 /decrypt binary request frame."""
adam_id_bytes = adam_id.encode("utf-8")
skd_uri_bytes = skd_uri.encode("utf-8")
if not adam_id_bytes:
raise ValueError("wrapper-v2: adam_id must not be empty")
if not skd_uri_bytes:
raise ValueError("wrapper-v2: skd_uri must not be empty")
if not ciphertexts:
raise ValueError("wrapper-v2: ciphertext batch must not be empty")
frame = bytearray()
frame += struct.pack(
">III",
len(adam_id_bytes),
len(skd_uri_bytes),
len(ciphertexts),
)
for ciphertext in ciphertexts:
frame += struct.pack(">I", len(ciphertext))
frame += adam_id_bytes
frame += skd_uri_bytes
for ciphertext in ciphertexts:
frame += ciphertext
return bytes(frame)
@staticmethod
def parse_decrypt_sample_frame(data: bytes, expected_count: int) -> list[bytes]:
"""Parse wrapper-v2 /decrypt binary response frame."""
if len(data) < 4:
raise IOError("wrapper-v2: POST /decrypt returned a truncated response")
(sample_count,) = struct.unpack_from(">I", data, 0)
if sample_count != expected_count:
raise IOError(
f"wrapper-v2: expected {expected_count} samples in response, "
f"got {sample_count}"
)
table_end = 4 + sample_count * 4
if len(data) < table_end:
raise IOError("wrapper-v2: POST /decrypt returned a truncated length table")
lengths = [
struct.unpack_from(">I", data, 4 + i * 4)[0] for i in range(sample_count)
]
offset = table_end
out: list[bytes] = []
for i, length in enumerate(lengths):
end = offset + length
if end > len(data):
raise IOError(
f"wrapper-v2: POST /decrypt returned truncated sample {i}"
)
out.append(data[offset:end])
offset = end
if offset != len(data):
raise IOError("wrapper-v2: POST /decrypt returned trailing bytes")
return out
@classmethod
async def create(
cls,
base_url: str = "http://127.0.0.1",
get_credentials_func: CredentialsFunc | None = None,
get_2fa_code: TwoFactorCodeFunc | None = None,
) -> WrapperApi:
client = httpx.AsyncClient(
timeout=httpx.Timeout(600.0, connect=30.0),
)
base_url = base_url.rstrip("/")
me = await cls.get_me(client, base_url)
if get_credentials_func is not None and me["auth"]["state"] == "logged_out":
username, password = await _invoke(get_credentials_func)
await cls.login(
client,
base_url,
username,
password,
get_2fa_code,
)
me = await cls.get_me(client, base_url)
if me.get("auth", {}).get("state") == "logged_out":
raise GamdlApiResponseError(
"Wrapper is not authenticated. "
"Provide get_credentials_func or log in via the wrapper.",
)
return cls(base_url, client, me)
@staticmethod
async def login(
client: httpx.AsyncClient,
base_url: str,
username: str,
password: str,
get_2fa_code: TwoFactorCodeFunc | None = None,
) -> None:
base_url = base_url.rstrip("/")
response = await client.post(
f"{base_url}/login",
json={"username": username, "password": password},
)
if response.status_code == 200:
return
if response.status_code == 202:
if get_2fa_code is None:
raise GamdlApiResponseError(
"Wrapper login requires 2FA; provide get_2fa_code",
status_code=202,
)
code = await _invoke(get_2fa_code)
tfa_response = await client.post(
f"{base_url}/login/2fa",
json={"code": code},
)
if tfa_response.is_error:
raise GamdlApiResponseError(
"Wrapper 2FA login failed",
content=tfa_response.text,
status_code=tfa_response.status_code,
)
return
raise GamdlApiResponseError(
"Wrapper login failed",
content=response.text,
status_code=response.status_code,
)
@staticmethod
async def get_me(client: httpx.AsyncClient, base_url: str) -> dict:
log = logger.bind(action="wrapper_get_me")
response = None
try:
response = await client.get(f"{base_url}/me")
response.raise_for_status()
account_info = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching wrapper account info",
content=getattr(response, "text", None),
status_code=getattr(response, "status_code", None),
)
log.debug("success", account_info=account_info)
return account_info
async def get_playback(self, media_id: str) -> dict:
log = logger.bind(action="wrapper_get_playback", media_id=media_id)
response = None
try:
response = await self.client.get(
f"{self.base_url}/playback",
params={"adam_id": media_id},
)
response.raise_for_status()
playback = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching wrapper playback",
content=getattr(response, "text", None),
status_code=getattr(response, "status_code", None),
)
log.debug("success", playback=playback)
return playback
async def decrypt(
self,
adam_id: str,
skd_uri: str,
ciphertexts: list[bytes],
) -> list[bytes]:
"""Decrypt one POST /decrypt batch; plaintexts match ciphertext order."""
log = logger.bind(
action="wrapper_decrypt",
adam_id=adam_id,
sample_count=len(ciphertexts),
)
frame = self.build_decrypt_sample_frame(adam_id, skd_uri, ciphertexts)
response = await self.client.post(
f"{self.base_url}/decrypt",
content=frame,
headers={
"content-type": "application/octet-stream",
"accept": "application/octet-stream",
},
)
if response.status_code == 401:
raise IOError(
"wrapper-v2: POST /decrypt returned 401 — log in with POST /login "
"or restore a session on the daemon first"
)
if response.status_code == 503:
raise IOError(
"wrapper-v2: decrypt unavailable (503) — check daemon logs /health "
"for playback_ready and Apple lib init"
)
if response.status_code != 200:
detail = ""
try:
j = response.json()
detail = (j.get("detail") or j.get("error") or str(j)) or ""
except Exception:
detail = (response.text or "")[:500]
raise IOError(
f"wrapper-v2: POST /decrypt failed HTTP {response.status_code}: {detail}"
)
plaintexts = self.parse_decrypt_sample_frame(
response.content,
len(ciphertexts),
)
log.debug("success")
return plaintexts
View File
+304
View File
@@ -0,0 +1,304 @@
import asyncio
from functools import wraps
from pathlib import Path
import click
import colorama
import structlog
from dataclass_click import dataclass_click
from httpx import ConnectError
from .. import __version__
from ..api import AppleMusicApi
from ..api.wrapper import WrapperApi
from ..downloader import (
AppleMusicBaseDownloader,
AppleMusicDownloader,
AppleMusicMusicVideoDownloader,
AppleMusicSongDownloader,
AppleMusicUploadedVideoDownloader,
GamdlDownloaderDependencyNotFoundError,
GamdlDownloaderMediaFileExistsError,
GamdlDownloaderSyncedLyricsOnlyError,
)
from ..interface import (
AppleMusicBaseInterface,
AppleMusicInterface,
AppleMusicMusicVideoInterface,
AppleMusicSongInterface,
AppleMusicUploadedVideoInterface,
GamdlInterfaceArtistMediaTypeError,
GamdlInterfaceDecryptionNotAvailableError,
GamdlInterfaceFlatFilterExcludedError,
GamdlInterfaceFormatNotAvailableError,
GamdlInterfaceMediaNotStreamableError,
GamdlInterfaceUrlParseError,
)
from .cli_config import CliConfig
from .config_file import ConfigFile
from .database import Database
from .interactive_prompts import InteractivePrompts
from .utils import CustomOutputWriter, custom_structlog_formatter, prompt_path
logger = structlog.get_logger(__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()
log_output = CustomOutputWriter()
if config.log_file:
log_output.add_file(config.log_file)
structlog.configure(
processors=[
structlog.processors.add_log_level,
structlog.processors.ExceptionPrettyPrinter(),
custom_structlog_formatter,
],
logger_factory=structlog.PrintLoggerFactory(file=log_output),
wrapper_class=structlog.make_filtering_bound_logger(config.log_level),
)
logger.info(f"Starting Gamdl {__version__}")
interactive_prompts = InteractivePrompts(
artist_auto_select=config.artist_auto_select,
)
if config.use_wrapper:
try:
wrapper_api = await WrapperApi.create(
base_url=config.wrapper_url,
get_credentials_func=InteractivePrompts.get_wrapper_credentials,
get_2fa_code=InteractivePrompts.get_wrapper_2fa_code,
)
apple_music_api = await AppleMusicApi.create_from_wrapper(
wrapper_api=wrapper_api,
language=config.language,
)
except Exception as e:
logger.exception(f"Error: {e}")
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,
)
wrapper_api = None
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"
)
if (
any(not codec.is_web for codec in config.song_codec_piority)
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.database_path:
database = Database(config.database_path, config.overwrite)
flat_filter = database.flat_filter
else:
database = None
flat_filter = None
base_interface = await AppleMusicBaseInterface.create(
apple_music_api=apple_music_api,
cover_format=config.cover_format,
cover_size=config.cover_size,
wvd_path=config.wvd_path,
wrapper_api=wrapper_api,
)
song_interface = AppleMusicSongInterface(
base=base_interface,
synced_lyrics_format=config.synced_lyrics_format,
codec_priority=config.song_codec_piority,
use_album_date=config.use_album_date,
skip_stream_info=config.synced_lyrics_only,
ask_codec_function=interactive_prompts.ask_song_codec,
)
music_video_interface = AppleMusicMusicVideoInterface(
base=base_interface,
resolution=config.music_video_resolution,
codec_priority=config.music_video_codec_priority,
ask_video_codec_function=interactive_prompts.ask_music_video_video_codec_function,
ask_audio_codec_function=interactive_prompts.ask_music_video_audio_codec_function,
)
uploaded_video_interface = AppleMusicUploadedVideoInterface(
base=base_interface,
quality=config.uploaded_video_quality,
ask_quality_function=interactive_prompts.ask_uploaded_video_quality_function,
)
interface = AppleMusicInterface(
song=song_interface,
music_video=music_video_interface,
uploaded_video=uploaded_video_interface,
artist_select_media_type_function=interactive_prompts.ask_artist_media_type,
artist_select_items_function=interactive_prompts.ask_artist_select_items,
flat_filter_function=flat_filter,
)
base_downloader = AppleMusicBaseDownloader(
interface=interface,
output_path=config.output_path,
temp_path=config.temp_path,
nm3u8dlre_path=config.nm3u8dlre_path,
download_mode=config.download_mode,
album_folder_template=config.album_folder_template,
compilation_folder_template=config.compilation_folder_template,
no_album_folder_template=config.no_album_folder_template,
playlist_folder_template=config.playlist_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,
truncate=config.truncate,
)
song_downloader = AppleMusicSongDownloader(
base=base_downloader,
)
music_video_downloader = AppleMusicMusicVideoDownloader(
base=base_downloader,
remux_format=config.music_video_remux_format,
)
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(
base=base_downloader,
)
downloader = AppleMusicDownloader(
song=song_downloader,
music_video=music_video_downloader,
uploaded_video=uploaded_video_downloader,
overwrite=config.overwrite,
save_cover=config.save_cover,
save_playlist=config.save_playlist,
no_synced_lyrics=config.no_synced_lyrics,
synced_lyrics_only=config.synced_lyrics_only,
)
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_log = logger.bind(action=f"URL {url_index:>3}/{len(urls):<3}")
url_log.info(f'Processing "{url}"')
try:
async for download_item in downloader.get_download_item_from_url(url):
media_index = download_item.media.index + 1
media_total = download_item.media.total or "-"
track_log = logger.bind(
action=f"Track {media_index:>3}/{media_total:<3}"
)
media_title = (
download_item.media.media_metadata["attributes"]["name"]
if download_item.media.media_metadata
and download_item.media.media_metadata.get("attributes", {}).get(
"name"
)
else "Unknown Title"
)
media_type = (
download_item.media.media_metadata["type"]
if download_item.media.media_metadata
else None
)
if download_item.media.partial and media_type in {
None,
"songs",
"library-songs",
"music-videos",
"library-music-videos",
"uploaded-videos",
}:
track_log.info(f'Downloading "{media_title}"')
try:
await downloader.download(download_item)
except (
GamdlInterfaceMediaNotStreamableError,
GamdlInterfaceFormatNotAvailableError,
GamdlInterfaceDecryptionNotAvailableError,
GamdlInterfaceArtistMediaTypeError,
GamdlDownloaderSyncedLyricsOnlyError,
GamdlDownloaderMediaFileExistsError,
GamdlDownloaderDependencyNotFoundError,
GamdlInterfaceFlatFilterExcludedError,
) as e:
track_log.warning(f'Skipping "{media_title}": {e}')
continue
except Exception as e:
error_count += 1
track_log.exception(f'Error downloading "{media_title}"')
if (
database
and download_item.media.media_metadata
and download_item.final_path
):
database.add(
download_item.media.media_metadata["id"],
download_item.final_path,
)
except GamdlInterfaceUrlParseError as e:
url_log.error(f"{e}")
continue
except Exception as e:
url_log.exception(f'Error processing "{url}": {e}')
error_count += 1
continue
logger.info(f"Finished with {error_count} error(s)")
+467
View File
@@ -0,0 +1,467 @@
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, WrapperApi
from ..downloader import (
AppleMusicBaseDownloader,
AppleMusicDownloader,
AppleMusicMusicVideoDownloader,
DownloadMode,
RemuxFormatMusicVideo,
RemuxMode,
)
from ..interface import (
AppleMusicBaseInterface,
AppleMusicInterface,
AppleMusicMusicVideoInterface,
AppleMusicSongInterface,
AppleMusicUploadedVideoInterface,
ArtistMediaType,
CoverFormat,
MusicVideoCodec,
MusicVideoResolution,
SongCodec,
SyncedLyricsFormat,
UploadedVideoQuality,
)
from .utils import Csv
api_from_cookies_sig = inspect.signature(AppleMusicApi.create_from_netscape_cookies)
wrapper_api_create_sig = inspect.signature(WrapperApi.create)
api_create_sig = inspect.signature(AppleMusicApi.create)
base_interface_create_sig = inspect.signature(AppleMusicBaseInterface.create)
song_interface_sig = inspect.signature(AppleMusicSongInterface.__init__)
music_video_interface_sig = inspect.signature(AppleMusicMusicVideoInterface.__init__)
uploaded_video_interface_sig = inspect.signature(
AppleMusicUploadedVideoInterface.__init__
)
interface_create_sig = inspect.signature(AppleMusicInterface)
base_downloader_sig = inspect.signature(AppleMusicBaseDownloader.__init__)
music_video_downloader_sig = inspect.signature(AppleMusicMusicVideoDownloader.__init__)
downloader_sig = inspect.signature(AppleMusicDownloader.__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,
),
]
artist_auto_select: Annotated[
ArtistMediaType | None,
option(
"--artist-auto-select",
help="Automatically select artist content to download (only for artist URLs)",
default=None,
type=ArtistMediaType,
),
]
database_path: Annotated[
str,
option(
"--database-path",
help="Path to the SQLite database file for registering downloaded media",
default=None,
type=click.Path(
file_okay=True,
dir_okay=False,
writable=True,
resolve_path=True,
),
),
]
no_config_file: Annotated[
bool,
option(
"--no-config-file",
"-n",
help="Don't use a config file",
is_flag=True,
),
]
# Wrapper specific options
wrapper_url: Annotated[
str,
option(
"--wrapper-url",
help="Wrapper base URL",
default=wrapper_api_create_sig.parameters["base_url"].default,
),
]
# 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,
),
),
]
language: Annotated[
str,
option(
"--language",
"-l",
help="Metadata language",
default=api_create_sig.parameters["language"].default,
),
]
# Base Interface specific options
cover_format: Annotated[
CoverFormat,
option(
"--cover-format",
help="Cover format",
default=base_interface_create_sig.parameters["cover_format"].default,
type=CoverFormat,
),
]
cover_size: Annotated[
int,
option(
"--cover-size",
help="Cover size in pixels",
default=base_interface_create_sig.parameters["cover_size"].default,
),
]
wvd_path: Annotated[
str | None,
option(
"--wvd-path",
help=".wvd file path",
default=base_interface_create_sig.parameters["wvd_path"].default,
type=click.Path(
file_okay=True,
dir_okay=False,
writable=False,
resolve_path=True,
),
),
]
use_wrapper: Annotated[
bool,
option(
"--use-wrapper",
help="Use wrapper for account, playback, and decryption requests",
is_flag=True,
),
]
# Song Interface Options
synced_lyrics_format: Annotated[
SyncedLyricsFormat,
option(
"--synced-lyrics-format",
help="Synced lyrics format",
default=song_interface_sig.parameters["synced_lyrics_format"].default,
type=SyncedLyricsFormat,
),
]
song_codec_piority: Annotated[
list[SongCodec],
option(
"--song-codec-priority",
help="Comma-separated codec priority",
default=song_interface_sig.parameters["codec_priority"].default,
type=Csv(SongCodec),
),
]
use_album_date: Annotated[
bool,
option(
"--use-album-date",
help="Use album release date for songs",
is_flag=True,
),
]
# Music Video Interface Options
music_video_resolution: Annotated[
MusicVideoResolution,
option(
"--music-video-resolution",
help="Max music video resolution",
default=music_video_interface_sig.parameters["resolution"].default,
type=MusicVideoResolution,
),
]
music_video_codec_priority: Annotated[
list[MusicVideoCodec],
option(
"--music-video-codec-priority",
help="Comma-separated codec priority",
default=music_video_interface_sig.parameters["codec_priority"].default,
type=Csv(MusicVideoCodec),
),
]
# Uploaded Video Interface Options
uploaded_video_quality: Annotated[
UploadedVideoQuality,
option(
"--uploaded-video-quality",
help="Post video quality",
default=uploaded_video_interface_sig.parameters["quality"].default,
type=UploadedVideoQuality,
),
]
# 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,
),
),
]
nm3u8dlre_path: Annotated[
str,
option(
"--nm3u8dlre-path",
help="N_m3u8DL-RE executable path",
default=base_downloader_sig.parameters["nm3u8dlre_path"].default,
),
]
download_mode: Annotated[
DownloadMode,
option(
"--download-mode",
help="Download mode",
default=base_downloader_sig.parameters["download_mode"].default,
type=DownloadMode,
),
]
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,
),
]
playlist_folder_template: Annotated[
str,
option(
"--playlist-folder-template",
help="Playlist folder template",
default=base_downloader_sig.parameters["playlist_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),
),
]
truncate: Annotated[
int,
option(
"--truncate",
help="Max filename length",
default=base_downloader_sig.parameters["truncate"].default,
),
]
# DownloaderMusicVideo specific options
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,
),
]
# Downloader specific options
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,
),
]
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,
),
]
+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 "{}"'
+58
View File
@@ -0,0 +1,58 @@
import sqlite3
from pathlib import Path
class Database:
def __init__(
self,
path: Path,
overwrite: bool,
):
self.overwrite = overwrite
self.connection = sqlite3.connect(path)
self.cursor = self.connection.cursor()
self._create_tables()
def _create_tables(self) -> None:
self.cursor.execute(
"""
CREATE TABLE IF NOT EXISTS media (
id TEXT PRIMARY KEY,
path TEXT NOT NULL
)
"""
)
self.connection.commit()
def get(self, media_id: str) -> str | None:
self.cursor.execute("SELECT path FROM media WHERE id = ?", (media_id,))
row = self.cursor.fetchone()
return row[0] if row else None
def add(self, media_id: str, path: str) -> None:
self.cursor.execute(
"INSERT OR REPLACE INTO media (id, path) VALUES (?, ?)",
(media_id, str(Path(path).absolute())),
)
self.connection.commit()
def remove(self, media_id: str) -> None:
self.cursor.execute("DELETE FROM media WHERE id = ?", (media_id,))
self.connection.commit()
def close(self) -> None:
self.connection.close()
def flat_filter(self, media_metadata: dict) -> str | None:
media_id = media_metadata["id"]
result = self.get(media_id)
if not result:
return None
return (
"Registered in database"
if Path(result).exists() and not self.overwrite
else None
)
+248
View File
@@ -0,0 +1,248 @@
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
import m3u8
from ..interface import ArtistMediaType
class InteractivePrompts:
def __init__(
self,
artist_auto_select: ArtistMediaType | None = None,
):
self.artist_auto_select = artist_auto_select
@staticmethod
def millis_to_min_sec(millis) -> str:
minutes, seconds = divmod(millis // 1000, 60)
return f"{minutes:02}:{seconds:02}"
@staticmethod
async def get_wrapper_credentials() -> tuple[str, str]:
username = await inquirer.text(
message="Apple ID:",
).execute_async()
password = await inquirer.secret(
message="Password:",
).execute_async()
return username, password
@staticmethod
async def get_wrapper_2fa_code() -> str:
return await inquirer.text(
message="Two-factor authentication code:",
).execute_async()
@staticmethod
async def ask_song_codec(
playlists: list[dict],
) -> dict:
choices = [
Choice(
name=playlist["stream_info"]["audio"],
value=playlist,
)
for playlist in playlists
]
return await inquirer.select(
message="Select which codec to download:",
choices=choices,
).execute_async()
@staticmethod
async def ask_music_video_video_codec_function(
playlists: list[m3u8.Playlist],
) -> dict:
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 playlists
]
return await inquirer.select(
message="Select which video codec to download: (Codec | Resolution | Bitrate)",
choices=choices,
).execute_async()
@staticmethod
async def ask_music_video_audio_codec_function(
playlists: list[dict],
) -> dict:
choices = [
Choice(
name=playlist["group_id"],
value=playlist,
)
for playlist in playlists
]
selected = await inquirer.select(
message="Select which audio codec to download:",
choices=choices,
).execute_async()
return selected
@staticmethod
async def ask_uploaded_video_quality_function(
available_qualities: dict[str, str],
) -> str:
qualities = list(available_qualities.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 available_qualities[selected]
async def ask_artist_media_type(
self,
media_types: list[ArtistMediaType],
artist_metadata: dict,
) -> ArtistMediaType:
if self.artist_auto_select:
return self.artist_auto_select
available_choices = []
for media_types in media_types:
available_choices.append(
Choice(
name=str(media_types),
value=(media_types,),
),
)
(media_type,) = await inquirer.select(
message=f'Select which type to download for artist "{artist_metadata["attributes"]["name"]}":',
choices=available_choices,
validate=lambda result: artist_metadata.get(result[0].path_key[0], {})
.get(result[0].path_key[1], {})
.get("data"),
).execute_async()
return media_type
async def ask_artist_select_items(
self,
media_type: ArtistMediaType,
items: list[dict],
) -> list[dict]:
if media_type in {
ArtistMediaType.MAIN_ALBUMS,
ArtistMediaType.COMPILATION_ALBUMS,
ArtistMediaType.LIVE_ALBUMS,
ArtistMediaType.SINGLES_EPS,
ArtistMediaType.ALL_ALBUMS,
}:
return await self._ask_artist_select_albums(items)
elif media_type == ArtistMediaType.TOP_SONGS:
return await self._ask_artist_select_songs(
items,
)
elif media_type == ArtistMediaType.MUSIC_VIDEOS:
return await self._ask_artist_select_music_videos(items)
async def _ask_artist_select_albums(
self,
albums: list[dict],
) -> list[dict]:
if self.artist_auto_select:
return albums
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
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()
return selected
async def _ask_artist_select_songs(
self,
songs: list[dict],
) -> list[dict]:
if self.artist_auto_select:
return songs
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
if song.get("attributes")
]
selected = await inquirer.select(
message="Select which songs to download: (Duration | Rating | Title)",
choices=choices,
multiselect=True,
).execute_async()
return selected
async def _ask_artist_select_music_videos(
self,
music_videos: list[dict],
) -> list[dict]:
if self.artist_auto_select:
return music_videos
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
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()
return selected
+123
View File
@@ -0,0 +1,123 @@
import atexit
import sys
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Any
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 CustomOutputWriter:
def __init__(
self,
streams: list[Any] = [sys.stdout],
):
self.streams = streams
def add_file(self, path: str):
file_stream = open(path, "a", encoding="utf-8")
atexit.register(file_stream.close)
self.streams.append(file_stream)
def write(self, message: str):
for stream in self.streams:
stream.write(message)
def flush(self):
for stream in self.streams:
stream.flush()
def custom_structlog_formatter(
logger: Any,
name: str,
event_dict: dict[str, Any],
) -> str:
level = event_dict.pop("level", "INFO").upper()
timestamp = datetime.now().strftime("%H:%M:%S")
level_colors = {
"DEBUG": "cyan",
"INFO": "green",
"WARNING": "yellow",
"ERROR": "red",
"CRITICAL": "red",
}
color = level_colors.get(level, "white")
prefix = click.style(f"[{level:<8} {timestamp}]", fg=color)
action = event_dict.pop("action", None)
if action:
prefix += click.style(f" [{action}]", dim=True)
if level in {"INFO", "WARNING", "ERROR", "CRITICAL"}:
message = event_dict.pop("event", "")
return f"{prefix} {message}"
else:
return f"{prefix} {event_dict}"
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
+9
View File
@@ -0,0 +1,9 @@
from .amdecrypt import decrypt_file_hex, decrypt_wrapper, write_decrypted_media
from .base import AppleMusicBaseDownloader
from .downloader import AppleMusicDownloader
from .enums import *
from .exceptions import *
from .music_video import AppleMusicMusicVideoDownloader
from .song import AppleMusicSongDownloader
from .types import *
from .uploaded_video import AppleMusicUploadedVideoDownloader
File diff suppressed because it is too large Load Diff
+392
View File
@@ -0,0 +1,392 @@
import asyncio
import multiprocessing
import queue
import re
import shutil
import traceback
from pathlib import Path
import structlog
from mutagen.mp4 import MP4, MP4Cover
from yt_dlp import YoutubeDL
from ..interface.enums import CoverFormat
from ..interface.interface import AppleMusicInterface
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
logger = structlog.get_logger(__name__)
def _download_ytdlp_process(
stream_url: str,
download_path: str,
silent: bool,
result_queue,
) -> None:
try:
with YoutubeDL(
{
"quiet": True,
"no_warnings": True,
"outtmpl": download_path,
"allow_unplayable_formats": True,
"overwrites": True,
"fixup": "never",
"noprogress": silent,
"allowed_extractors": ["generic"],
}
) as ydl:
ydl.download(stream_url)
except Exception as e:
result_queue.put(("error", repr(e), traceback.format_exc()))
class AppleMusicBaseDownloader:
def __init__(
self,
interface: AppleMusicInterface,
output_path: str = "./Apple Music",
temp_path: str = ".",
nm3u8dlre_path: str = "N_m3u8DL-RE",
download_mode: DownloadMode = DownloadMode.YTDLP,
album_folder_template: str = "{album_artist}/{album}",
compilation_folder_template: str = "Compilations/{album}",
no_album_folder_template: str = "{artist}/Unknown Album",
playlist_folder_template: str = "Playlists/{playlist_artist}",
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 = "{playlist_title}",
date_tag_template: str = "%Y-%m-%dT%H:%M:%SZ",
exclude_tags: list[str] = None,
truncate: int = None,
silent: bool = False,
):
self.interface = interface
self.output_path = output_path
self.temp_path = temp_path
self.nm3u8dlre_path = nm3u8dlre_path
self.download_mode = download_mode
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.playlist_folder_template = playlist_folder_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.truncate = truncate
self.silent = silent
self._initialize_binary_paths()
def _initialize_binary_paths(self):
log = logger.bind(action="initialize_binary_paths")
self.full_nm3u8dlre_path = shutil.which(self.nm3u8dlre_path)
log = log.debug(
"success",
full_nm3u8dlre_path=self.full_nm3u8dlre_path,
)
def get_temp_path(
self,
media_id: str,
folder_tag: str,
file_tag: str,
file_extension: str,
) -> str:
log = logger.bind(action="get_temp_path")
temp_path = str(
Path(self.temp_path)
/ TEMP_PATH_TEMPLATE.format(folder_tag)
/ (f"{media_id}_{file_tag}" + file_extension)
)
log.debug("success", temp_path=temp_path)
return temp_path
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:
log = logger.bind(action="get_final_path")
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.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.title if playlist_tags else None),
"Unknown Playlist Title",
),
playlist_track=(
(playlist_tags.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)
final_path = str(Path(self.output_path, *formatted_parts))
log.debug("success", final_path=final_path)
return final_path
async def download_stream(self, stream_url: str, download_path: str):
log = logger.bind(
action="download_stream", stream_url=stream_url, download_path=download_path
)
if self.download_mode == DownloadMode.YTDLP:
await self._download_ytdlp_async(stream_url, download_path)
if self.download_mode == DownloadMode.NM3U8DLRE:
await self._download_nm3u8dlre(stream_url, download_path)
log.debug("success")
async def _download_ytdlp_async(self, stream_url: str, download_path: str) -> None:
ctx = multiprocessing.get_context()
result_queue = ctx.Queue()
process = ctx.Process(
target=_download_ytdlp_process,
args=(stream_url, download_path, self.silent, result_queue),
)
process.start()
try:
while process.is_alive():
await asyncio.sleep(0.1)
process.join()
try:
status, error_repr, error_traceback = result_queue.get_nowait()
except queue.Empty:
status = None
if status == "error":
raise RuntimeError(
f"yt-dlp failed: {error_repr}\n{error_traceback}"
) from None
if process.exitcode != 0:
raise RuntimeError(f"yt-dlp exited with code {process.exitcode}")
finally:
if process.is_alive():
process.terminate()
await asyncio.to_thread(process.join, 5)
if process.is_alive():
process.kill()
await asyncio.to_thread(process.join)
process.close()
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",
"--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: str,
tags: MediaTags,
cover_bytes: bytes | None,
):
log = logger.bind(action="apply_tags", media_path=media_path)
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,
)
log.debug("success")
def _apply_mp4_tags(
self,
media_path: str,
tags: dict,
cover_bytes: bytes | None,
skip_tagging: bool,
):
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.interface.base.cover_format == CoverFormat.JPG
else MP4Cover.FORMAT_PNG
),
)
]
mp4.update(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.interface.base.cover_format == CoverFormat.JPG
else MP4Cover.FORMAT_PNG
),
)
]
def get_playlist_file_path(
self,
tags: PlaylistTags,
) -> str:
log = logger.bind(action="get_playlist_file_path")
template_folder_parts = self.playlist_folder_template.split("/")
template_file_parts = self.playlist_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,
playlist_artist=(tags.artist, "Unknown Playlist Artist"),
playlist_id=(tags.playlist_id, "Unknown Playlist ID"),
playlist_title=(tags.title, "Unknown Playlist Title"),
playlist_track=(tags.track, ""),
)
file_ext = None if is_folder else ".m3u"
sanitized_formatted_part = self._sanitize_string(
formatted_part,
file_ext,
)
formatted_parts.append(sanitized_formatted_part)
final_path = str(Path(self.output_path, *formatted_parts))
log.debug("success", playlist_file_path=final_path)
return final_path
+3
View File
@@ -0,0 +1,3 @@
TEMP_PATH_TEMPLATE = "gamdl_temp_{}"
ILLEGAL_CHARS_RE = r'[\\/:*?"<>|;]'
ILLEGAL_CHAR_REPLACEMENT = "_"
+254
View File
@@ -0,0 +1,254 @@
import shutil
from pathlib import Path
from typing import AsyncGenerator
import structlog
from ..interface.types import AppleMusicMedia
from .constants import TEMP_PATH_TEMPLATE
from .enums import DownloadMode
from .exceptions import (
GamdlDownloaderDependencyNotFoundError,
GamdlDownloaderMediaFileExistsError,
GamdlDownloaderSyncedLyricsOnlyError,
)
from .music_video import AppleMusicMusicVideoDownloader
from .song import AppleMusicSongDownloader
from .types import DownloadItem
from .uploaded_video import AppleMusicUploadedVideoDownloader
logger = structlog.get_logger(__name__)
class AppleMusicDownloader:
def __init__(
self,
song: AppleMusicSongDownloader,
music_video: AppleMusicMusicVideoDownloader,
uploaded_video: AppleMusicUploadedVideoDownloader,
overwrite: bool = False,
save_cover: bool = False,
save_playlist: bool = False,
no_synced_lyrics: bool = False,
synced_lyrics_only: bool = False,
skip_cleanup: bool = False,
skip_processing: bool = False,
):
self.song = song
self.music_video = music_video
self.uploaded_video = uploaded_video
self.overwrite = overwrite
self.save_cover = save_cover
self.save_playlist = save_playlist
self.no_synced_lyrics = no_synced_lyrics
self.synced_lyrics_only = synced_lyrics_only
self.skip_cleanup = skip_cleanup
self.skip_processing = skip_processing
self.base = song.base
async def get_download_item_from_url(
self,
url: str,
) -> AsyncGenerator[DownloadItem, None]:
async for media in self.base.interface.get_media_from_url(url):
yield await self.parse_download_item(media)
async def parse_download_item(
self,
media: AppleMusicMedia,
) -> DownloadItem:
if media.error:
return DownloadItem(media)
if media.partial:
return DownloadItem(media)
elif media.media_metadata["type"] in {"songs", "library-songs"}:
return await self.song.get_download_item(media)
elif media.media_metadata["type"] in {
"music-videos",
"library-music-videos",
}:
return await self.music_video.get_download_item(media)
elif media.media_metadata["type"] in {"uploaded-videos"}:
return await self.uploaded_video.get_download_item(media)
async def download(self, item: DownloadItem) -> None:
try:
if item.media.error:
raise item.media.error
if item.media.partial:
return
await self._initial_processing(item)
await self._download(item)
await self._final_processing(item)
finally:
if not self.skip_cleanup:
self._cleanup_temp(item.uuid_)
def _update_playlist_file(
self,
playlist_file_path: str,
final_path: str,
playlist_track: int,
) -> None:
log = logger.bind(
action="update_playlist_file",
playlist_file_path=playlist_file_path,
final_path=final_path,
playlist_track=playlist_track,
)
playlist_file_path_obj = Path(playlist_file_path)
final_path_obj = Path(final_path)
output_dir_obj = Path(self.base.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)
log.debug("success")
def _write_cover(self, cover_path: str, cover_bytes: bytes) -> None:
log = logger.bind(action="write_cover_file", cover_path=cover_path)
Path(cover_path).parent.mkdir(parents=True, exist_ok=True)
with open(cover_path, "wb") as f:
f.write(cover_bytes)
log.debug("success")
def _write_synced_lyrics(self, synced_lyrics_path: str, lyrics: str) -> None:
log = logger.bind(
action="write_synced_lyrics",
synced_lyrics_path=synced_lyrics_path,
)
Path(synced_lyrics_path).parent.mkdir(parents=True, exist_ok=True)
with open(synced_lyrics_path, "w", encoding="utf-8") as f:
f.write(lyrics)
log.debug("success")
async def _initial_processing(self, item: DownloadItem) -> None:
if self.skip_processing:
return
if item.playlist_file_path and item.final_path and self.save_playlist:
self._update_playlist_file(
item.playlist_file_path,
item.final_path,
item.media.playlist_tags.track,
)
if item.cover_path and self.save_cover and item.media.cover.url:
cover_bytes = await self.base.interface.base.get_cover_bytes(
item.media.cover.url,
)
if cover_bytes and (self.overwrite or not Path(item.cover_path).exists()):
self._write_cover(
item.cover_path,
cover_bytes,
)
if (
item.synced_lyrics_path
and not self.no_synced_lyrics
and item.media.lyrics
and item.media.lyrics.synced
and (self.overwrite or not Path(item.synced_lyrics_path).exists())
):
self._write_synced_lyrics(
item.synced_lyrics_path,
item.media.lyrics.synced,
)
async def _download(self, item: DownloadItem) -> None:
if item.media.error:
raise item.media.error
if self.synced_lyrics_only:
raise GamdlDownloaderSyncedLyricsOnlyError()
if Path(item.final_path).exists() and not self.overwrite:
raise GamdlDownloaderMediaFileExistsError(item.final_path)
if item.media.media_metadata["type"] in {
"music-videos",
"library-music-videos",
"songs",
"library-songs",
}:
if (
self.base.download_mode == DownloadMode.NM3U8DLRE
and not self.base.full_nm3u8dlre_path
):
raise GamdlDownloaderDependencyNotFoundError("N_m3u8DL-RE")
if item.media.media_metadata["type"] in {"songs", "library-songs"}:
await self.song.download(item)
elif item.media.media_metadata["type"] in {
"music-videos",
"library-music-videos",
}:
await self.music_video.download(item)
elif item.media.media_metadata["type"] in {"uploaded-videos"}:
await self.uploaded_video.download(item)
def _move_to_final_path(self, staged_path: str, final_path: str) -> None:
log = logger.bind(
action="move_to_final_path",
staged_path=staged_path,
final_path=final_path,
)
Path(final_path).parent.mkdir(parents=True, exist_ok=True)
shutil.move(staged_path, final_path)
log.debug("success")
async def _final_processing(
self,
item: DownloadItem,
) -> None:
if self.skip_processing:
return
if Path(item.staged_path).exists():
self._move_to_final_path(
item.staged_path,
item.final_path,
)
def _cleanup_temp(self, folder_tag: str) -> None:
log = logger.bind(action="cleanup_temp", folder_tag=folder_tag)
temp_path = Path(self.base.temp_path) / TEMP_PATH_TEMPLATE.format(folder_tag)
if temp_path.exists() and temp_path.is_dir():
shutil.rmtree(temp_path, ignore_errors=True)
log.debug("success")
+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"
+20
View File
@@ -0,0 +1,20 @@
from ..utils import GamdlError
class GamdlDownloaderError(GamdlError):
pass
class GamdlDownloaderSyncedLyricsOnlyError(GamdlDownloaderError):
def __init__(self) -> None:
super().__init__("Download mode is set to synced lyrics only")
class GamdlDownloaderMediaFileExistsError(GamdlDownloaderError):
def __init__(self, file_path: str) -> None:
super().__init__(f"Media file already exists: {file_path}")
class GamdlDownloaderDependencyNotFoundError(GamdlDownloaderError):
def __init__(self, dependency_name: str) -> None:
super().__init__(f"Required dependency not found: {dependency_name}")
+123
View File
@@ -0,0 +1,123 @@
from pathlib import Path
from ..interface.enums import CoverFormat
from ..interface.types import AppleMusicMedia, DecryptionKeyAv
from .amdecrypt import decrypt_file_hex, write_decrypted_media
from .base import AppleMusicBaseDownloader
from .enums import RemuxFormatMusicVideo, RemuxMode
from .types import DownloadItem
class AppleMusicMusicVideoDownloader:
def __init__(
self,
base: AppleMusicBaseDownloader,
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
):
self.base = base
self.remux_format = remux_format
async def stage(
self,
encrypted_path_video: str,
encrypted_path_audio: str,
staged_path: str,
decryption_key: DecryptionKeyAv,
is_m4v: bool = False,
):
decrypted_media = await decrypt_file_hex(
decryption_key.audio_track.key,
encrypted_path_audio,
decryption_key.video_track.key,
encrypted_path_video,
)
await write_decrypted_media(
decrypted_media,
staged_path,
m4v_brand=is_m4v,
)
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,
media: AppleMusicMedia,
) -> DownloadItem:
download_item = DownloadItem(media)
download_item.staged_path = self.base.get_temp_path(
media.media_metadata["id"],
download_item.uuid_,
"staged",
"." + media.stream_info.file_format.value,
)
download_item.final_path = self.base.get_final_path(
media.tags,
"." + media.stream_info.file_format.value,
media.playlist_tags,
)
if media.playlist_tags:
download_item.playlist_file_path = self.base.get_playlist_file_path(
media.playlist_tags,
)
download_item.cover_path = self.get_cover_path(
download_item.final_path,
media.cover.file_extension,
)
return download_item
async def download(
self,
download_item: DownloadItem,
) -> None:
encrypted_path_video = self.base.get_temp_path(
download_item.media.media_metadata["id"],
download_item.uuid_,
"encrypted_video",
".mp4",
)
encrypted_path_audio = self.base.get_temp_path(
download_item.media.media_metadata["id"],
download_item.uuid_,
"encrypted_audio",
".m4a",
)
await self.base.download_stream(
download_item.media.stream_info.video_track.stream_url,
encrypted_path_video,
)
await self.base.download_stream(
download_item.media.stream_info.audio_track.stream_url,
encrypted_path_audio,
)
await self.stage(
encrypted_path_video,
encrypted_path_audio,
download_item.staged_path,
download_item.media.decryption_key,
download_item.staged_path.endswith(".m4v"),
)
cover_bytes = (
await self.base.interface.base.get_cover_bytes(
download_item.media.cover.url
)
if self.base.interface.base.cover_format != CoverFormat.RAW
else None
)
await self.base.apply_tags(
download_item.staged_path,
download_item.media.tags,
cover_bytes,
)
+194
View File
@@ -0,0 +1,194 @@
from pathlib import Path
import structlog
from ..interface.enums import CoverFormat
from ..interface.types import AppleMusicMedia, DecryptionKeyAv
from .amdecrypt import decrypt_file_hex, decrypt_wrapper, write_decrypted_media
from .base import AppleMusicBaseDownloader
from .types import DownloadItem
logger = structlog.get_logger(__name__)
class AppleMusicSongDownloader:
def __init__(
self,
base: AppleMusicBaseDownloader,
):
self.base = base
async def get_download_item(self, media: AppleMusicMedia) -> DownloadItem:
download_item = DownloadItem(media)
if media.stream_info:
download_item.staged_path = self.base.get_temp_path(
media.media_metadata["id"],
download_item.uuid_,
"staged",
"." + media.stream_info.file_format.value,
)
download_item.final_path = self.base.get_final_path(
media.tags,
".m4a",
media.playlist_tags,
)
if media.playlist_tags:
download_item.playlist_file_path = self.base.get_playlist_file_path(
media.playlist_tags,
)
download_item.synced_lyrics_path = self.get_synced_lyrics_path(
download_item.final_path
)
download_item.cover_path = self.get_cover_path(
download_item.final_path,
media.cover.file_extension,
)
return download_item
async def _decrypt_amdecrypt(
self,
input_path: str,
output_path: str,
media_id: str,
fairplay_key: str,
use_single_content_key: bool = False,
) -> None:
wrapper_api = self.base.interface.base.wrapper_api
if wrapper_api is None:
raise ValueError("wrapper_api is required for FairPlay decrypt")
decrypted_media = await decrypt_wrapper(
wrapper_api,
media_id,
input_path,
fairplay_key_audio=fairplay_key,
use_single_content_key=use_single_content_key,
)
await write_decrypted_media(decrypted_media, output_path)
async def _decrypt_amdecrypt_hex(
self,
input_path: str,
output_path: str,
decryption_key: str,
*,
use_cenc: bool = False,
use_single_content_key: bool = False,
) -> None:
decrypted_media = await decrypt_file_hex(
decryption_key,
input_path,
use_cenc=use_cenc,
use_single_content_key=use_single_content_key,
)
await write_decrypted_media(decrypted_media, output_path)
async def stage(
self,
encrypted_path: str,
staged_path: str,
media_id: str,
decryption_key: DecryptionKeyAv | None = None,
fairplay_key: str = None,
use_cenc: bool = False,
use_single_content_key: bool = False,
):
log = logger.bind(
action="stage_song",
media_id=media_id,
encrypted_path=encrypted_path,
staged_path=staged_path,
)
if decryption_key:
await self._decrypt_amdecrypt_hex(
encrypted_path,
staged_path,
decryption_key.audio_track.key,
use_cenc=use_cenc,
use_single_content_key=use_single_content_key,
)
else:
await self._decrypt_amdecrypt(
encrypted_path,
staged_path,
media_id,
fairplay_key,
use_single_content_key=use_single_content_key,
)
log.debug("success")
def get_synced_lyrics_path(self, final_path: str) -> str:
log = logger.bind(action="get_synced_lyrics_path", final_path=final_path)
synced_lyrics_path = str(
Path(final_path).with_suffix(
"." + self.base.interface.song.synced_lyrics_format.value
)
)
log.debug("success", synced_lyrics_path=synced_lyrics_path)
return synced_lyrics_path
def get_cover_path(
self,
final_path: str,
file_extension: str,
) -> str:
log = logger.bind(
action="get_song_cover_path",
final_path=final_path,
file_extension=file_extension,
)
cover_path = str(Path(final_path).parent / ("Cover" + file_extension))
log.debug("success", cover_path=cover_path)
return cover_path
async def download(
self,
download_item: DownloadItem,
) -> None:
encrypted_path = self.base.get_temp_path(
download_item.media.media_metadata["id"],
download_item.uuid_,
"encrypted",
".m4a",
)
await self.base.download_stream(
download_item.media.stream_info.audio_track.stream_url,
encrypted_path,
)
await self.stage(
encrypted_path,
download_item.staged_path,
download_item.media.media_id,
download_item.media.decryption_key,
download_item.media.stream_info.audio_track.fairplay_key,
download_item.media.stream_info.audio_track.use_cenc,
download_item.media.stream_info.audio_track.use_single_content_key,
)
cover_bytes = (
await self.base.interface.base.get_cover_bytes(
download_item.media.cover.url
)
if self.base.interface.base.cover_format != CoverFormat.RAW
else None
)
await self.base.apply_tags(
download_item.staged_path,
download_item.media.tags,
cover_bytes,
)
+15
View File
@@ -0,0 +1,15 @@
import uuid
from dataclasses import dataclass, field
from ..interface.types import AppleMusicMedia
@dataclass
class DownloadItem:
media: AppleMusicMedia
uuid_: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
staged_path: str = None
final_path: str = None
playlist_file_path: str = None
synced_lyrics_path: str = None
cover_path: str = None
+65
View File
@@ -0,0 +1,65 @@
from pathlib import Path
from ..interface.enums import CoverFormat
from ..interface.types import AppleMusicMedia
from .base import AppleMusicBaseDownloader
from .types import DownloadItem
class AppleMusicUploadedVideoDownloader:
def __init__(
self,
base: AppleMusicBaseDownloader,
):
self.base = base
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,
media: AppleMusicMedia,
) -> DownloadItem:
download_item = DownloadItem(media)
download_item.staged_path = self.base.get_temp_path(
media.media_metadata["id"],
download_item.uuid_,
"staged",
"." + media.stream_info.file_format.value,
)
download_item.final_path = self.base.get_final_path(
media.tags,
"." + media.stream_info.file_format.value,
media.playlist_tags,
)
download_item.cover_path = self.get_cover_path(
download_item.final_path,
media.cover.file_extension,
)
return download_item
async def download(
self,
download_item: DownloadItem,
) -> None:
await self.base._download_ytdlp_async(
download_item.media.stream_info.video_track.stream_url,
download_item.staged_path,
)
cover_bytes = (
await self.base.interface.base.get_cover_bytes(
download_item.media.cover.url
)
if self.base.interface.base.cover_format != CoverFormat.RAW
else None
)
await self.base.apply_tags(
download_item.staged_path,
download_item.media.tags,
cover_bytes,
)
-404
View File
@@ -1,404 +0,0 @@
from pathlib import Path
import glob
from http.cookiejar import MozillaCookieJar
import re
import base64
import datetime
from xml.etree import ElementTree
import functools
import subprocess
import shutil
import gamdl.storefront_ids
from pywidevine import Cdm
from pywidevine import Device
import requests
import m3u8
from yt_dlp import YoutubeDL
from pywidevine.pssh import PSSH
from pywidevine.license_protocol_pb2 import WidevinePsshData
from mutagen.mp4 import MP4, MP4Cover
class Gamdl:
def __init__(self, wvd_location, cookies_location, disable_music_video_skip, prefer_hevc, temp_path, final_path, no_lrc, overwrite, skip_cleanup):
self.disable_music_video_skip = disable_music_video_skip
self.prefer_hevc = prefer_hevc
self.temp_path = Path(temp_path)
self.final_path = Path(final_path)
self.no_lrc = no_lrc
self.overwrite = overwrite
self.skip_cleanup = skip_cleanup
wvd_location = glob.glob(wvd_location)
if not wvd_location:
raise Exception('.wvd file not found')
self.cdm = Cdm.from_device(Device.load(Path(wvd_location[0])))
self.cdm_session = self.cdm.open()
cookies = MozillaCookieJar(Path(cookies_location))
cookies.load(ignore_discard = True, ignore_expires = True)
self.session = requests.Session()
self.session.cookies.update(cookies)
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0',
'Accept': 'application/json',
'Accept-Language': 'en-US,en;q=0.5',
'Accept-Encoding': 'gzip, deflate, br',
'content-type': 'application/json',
'Media-User-Token': self.session.cookies.get_dict()['media-user-token'],
'x-apple-renewal': 'true',
'DNT': '1',
'Connection': 'keep-alive',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-site',
'origin': 'https://beta.music.apple.com'
})
web_page = self.session.get('https://beta.music.apple.com').text
index_js_uri = re.search('(?<=index\.)(.*?)(?=\.js")', web_page).group(1)
index_js_page = self.session.get(f'https://beta.music.apple.com/assets/index.{index_js_uri}.js').text
token = re.search('(?=eyJh)(.*?)(?=")', index_js_page).group(1)
self.session.headers.update({"authorization": f'Bearer {token}'})
self.country = self.session.cookies.get_dict()['itua']
self.storefront = getattr(gamdl.storefront_ids, self.country.upper())
def get_download_queue(self, url):
download_queue = []
product_id = url.split('/')[-1].split('i=')[-1].split('&')[0].split('?')[0]
response = self.session.get(f'https://amp-api.music.apple.com/v1/catalog/{self.country}/?ids[songs]={product_id}&ids[albums]={product_id}&ids[playlists]={product_id}&ids[music-videos]={product_id}').json()['data'][0]
if response['type'] in ('songs', 'music-videos') and 'playParams' in response['attributes']:
download_queue.append(response)
if response['type'] == 'albums' or response['type'] == 'playlists':
for track in response['relationships']['tracks']['data']:
if 'playParams' in track['attributes']:
if track['type'] == 'music-videos' and self.disable_music_video_skip:
download_queue.append(track)
if track['type'] == 'songs':
download_queue.append(track)
if not download_queue:
raise Exception('Criteria not met')
return download_queue
def get_webplayback(self, track_id):
response = self.session.post(
'https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback',
json = {
'salableAdamId': track_id,
'language': 'en-US'
}
).json()["songList"][0]
return response
def get_playlist_music_video(self, webplayback):
return m3u8.load(webplayback['hls-playlist-url'])
def get_stream_url_song(self, webplayback):
return next(i for i in webplayback["assets"] if i["flavor"] == "28:ctrp256")['URL']
def get_stream_url_music_video_audio(self, playlist):
return [i for i in playlist.media if i.type == "AUDIO"][-1].uri
def get_stream_url_music_video_video(self, playlist):
if self.prefer_hevc:
return playlist.playlists[-1].uri
else:
return [i for i in playlist.playlists if 'avc' in i.stream_info.codecs][-1].uri
def check_exists(self, final_location):
return Path(final_location).exists()
def get_encrypted_location_video(self, track_id):
return self.temp_path / f'{track_id}_encrypted_video.mp4'
def get_encrypted_location_audio(self, track_id):
return self.temp_path / f'{track_id}_encrypted_audio.mp4'
def get_decrypted_location_video(self, track_id):
return self.temp_path / f'{track_id}_decrypted_video.mp4'
def get_decrypted_location_audio(self, track_id):
return self.temp_path / f'{track_id}_decrypted_audio.mp4'
def get_fixed_location(self, track_id, file_extension):
return self.temp_path / f'{track_id}_fixed{file_extension}'
def download(self, encrypted_location, stream_url):
with YoutubeDL({
'quiet': True,
'no_warnings': True,
'outtmpl': str(encrypted_location),
'allow_unplayable_formats': True,
'fixup': 'never',
'overwrites': self.overwrite,
'external_downloader': 'aria2c'
}) as ydl:
ydl.download(stream_url)
def get_license_b64(self, challenge, track_uri, track_id):
return self.session.post(
'https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/acquireWebPlaybackLicense',
json = {
'challenge': challenge,
'key-system': 'com.widevine.alpha',
'uri': track_uri,
'adamId': track_id,
'isLibrary': False,
'user-initiated': True
}
).json()['license']
def get_decryption_keys_music_video(self, stream_url, track_id):
playlist = m3u8.load(stream_url)
track_uri = next(i for i in playlist.keys if i.keyformat == "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed").uri
pssh = PSSH(track_uri.split(',')[1])
challenge = base64.b64encode(self.cdm.get_license_challenge(self.cdm_session, pssh)).decode('utf-8')
license_b64 = self.get_license_b64(challenge, track_uri, track_id)
self.cdm.parse_license(self.cdm_session, license_b64)
return f'1:{next(i for i in self.cdm.get_keys(self.cdm_session) if i.type == "CONTENT").key.hex()}'
def get_decryption_keys_song(self, stream_url, track_id):
track_uri = m3u8.load(stream_url).keys[0].uri
widevine_pssh_data = WidevinePsshData()
widevine_pssh_data.algorithm = 1
widevine_pssh_data.key_ids.append(base64.b64decode(track_uri.split(",")[1]))
pssh = PSSH(base64.b64encode(widevine_pssh_data.SerializeToString()).decode('utf-8'))
challenge = base64.b64encode(self.cdm.get_license_challenge(self.cdm_session, pssh)).decode('utf-8')
license_b64 = self.get_license_b64(challenge, track_uri, track_id)
self.cdm.parse_license(self.cdm_session, license_b64)
return f'1:{next(i for i in self.cdm.get_keys(self.cdm_session) if i.type == "CONTENT").key.hex()}'
def decrypt(self, encrypted_location, decrypted_location, decryption_keys):
subprocess.run(
[
'mp4decrypt',
encrypted_location,
'--key',
decryption_keys,
decrypted_location
],
check = True
)
def get_synced_lyrics_formated_time(self, unformatted_time):
if 's' in unformatted_time:
unformatted_time = unformatted_time.replace('s', '')
if '.' not in unformatted_time:
unformatted_time += '.0'
s = int(unformatted_time.split('.')[-2].split(':')[-1]) * 1000
try:
m = int(unformatted_time.split('.')[-2].split(':')[-2]) * 60000
except:
m = 0
ms = f'{int(unformatted_time.split(".")[-1]):03d}'
if int(ms[2]) >= 5:
ms = int(f'{int(ms[:2]) + 1}') * 10
else:
ms = int(ms)
formated_time = datetime.datetime.fromtimestamp((s + m + ms)/1000.0)
return formated_time.strftime('%M:%S.%f')[:-4]
def get_lyrics(self, track_id):
try:
lyrics_ttml = ElementTree.fromstring(self.session.get(f'https://amp-api.music.apple.com/v1/catalog/{self.country}/songs/{track_id}/lyrics').json()['data'][0]['attributes']['ttml'])
except:
return None, None
unsynced_lyrics = ''
synced_lyrics = ''
for div in lyrics_ttml.iter('{http://www.w3.org/ns/ttml}div'):
for p in div.iter('{http://www.w3.org/ns/ttml}p'):
if p.attrib.get('begin'):
synced_lyrics += f'[{self.get_synced_lyrics_formated_time(p.attrib.get("begin"))}]{p.text}\n'
if p.text is not None:
unsynced_lyrics += p.text + '\n'
unsynced_lyrics += '\n'
return unsynced_lyrics[:-2], synced_lyrics
@functools.lru_cache()
def get_cover(self, url):
return requests.get(url).content
def get_tags_song(self, webplayback, unsynced_lyrics):
metadata = next(i for i in webplayback["assets"] if i["flavor"] == "28:ctrp256")['metadata']
cover_url = next(i for i in webplayback["assets"] if i["flavor"] == "28:ctrp256")['artworkURL']
tags = {
'\xa9nam': [metadata['itemName']],
'\xa9gen': [metadata['genre']],
'aART': [metadata['playlistArtistName']],
'\xa9alb': [metadata['playlistName']],
'soar': [metadata['sort-artist']],
'soal': [metadata['sort-album']],
'sonm': [metadata['sort-name']],
'\xa9ART': [metadata['artistName']],
'geID': [metadata['genreId']],
'atID': [int(metadata['artistId'])],
'plID': [int(metadata['playlistId'])],
'cnID': [int(metadata['itemId'])],
'sfID': [metadata['s']],
'rtng': [metadata['explicit']],
'pgap': metadata['gapless'],
'cpil': metadata['compilation'],
'disk': [(metadata['discNumber'], metadata['discCount'])],
'trkn': [(metadata['trackNumber'], metadata['trackCount'])],
'covr': [MP4Cover(self.get_cover(cover_url), MP4Cover.FORMAT_JPEG)],
'stik': [1]
}
if 'copyright' in metadata:
tags['cprt'] = [metadata['copyright']]
if 'releaseDate' in metadata:
tags['\xa9day'] = [metadata['releaseDate']]
if 'comments' in metadata:
tags['\xa9cmt'] = [metadata['comments']]
if 'xid' in metadata:
tags['xid '] = [metadata['xid']]
if 'composerId' in metadata:
tags['cmID'] = [int(metadata['composerId'])]
tags['\xa9wrt'] = [metadata['composerName']]
tags['soco'] = [metadata['sort-composer']]
if unsynced_lyrics:
tags['\xa9lyr'] = [unsynced_lyrics]
return tags
def get_tags_music_video(self, track_id):
metadata = requests.get(f'https://itunes.apple.com/lookup?id={track_id}&entity=album&limit=200&country={self.country}&lang=en_US').json()['results']
extra_metadata = requests.get(f'https://music.apple.com/music-video/{metadata[0]["trackId"]}', headers = {'X-Apple-Store-Front': f'{self.storefront} t:music31'}).json()['storePlatformData']['product-dv']['results'][str(metadata[0]['trackId'])]
tags = {
'\xa9ART': [metadata[0]["artistName"]],
'\xa9nam': [metadata[0]["trackCensoredName"]],
'\xa9day': [metadata[0]["releaseDate"]],
'cprt': [extra_metadata['copyright']],
'\xa9gen': [metadata[0]['primaryGenreName']],
'stik': [6],
'atID': [metadata[0]['artistId']],
'cnID': [metadata[0]["trackId"]],
'geID': [int(extra_metadata['genres'][0]['genreId'])],
'sfID': [int(self.storefront.split('-')[0])],
'covr': [MP4Cover(self.get_cover(metadata[0]["artworkUrl30"].replace('30x30bb.jpg', '600x600bb.jpg')), MP4Cover.FORMAT_JPEG)]
}
if metadata[0]['trackExplicitness'] == 'notExplicit':
tags['rtng'] = [0]
elif metadata[0]['trackExplicitness'] == 'explicit':
tags['rtng'] = [1]
else:
tags['rtng'] = [2]
if len(metadata) > 1:
tags['\xa9alb'] = [metadata[1]["collectionCensoredName"]]
tags['aART'] = [metadata[1]["artistName"]]
tags['plID'] = [metadata[1]["collectionId"]]
tags['disk'] = [(metadata[0]["discNumber"], metadata[0]["discCount"])]
tags['trkn'] = [(metadata[0]["trackNumber"], metadata[0]["trackCount"])]
return tags
def get_sanizated_string(self, dirty_string, is_folder):
for character in ['\\', '/', ':', '*', '?', '"', '<', '>', '|', ';']:
dirty_string = dirty_string.replace(character, '_')
if is_folder:
dirty_string = dirty_string[:40]
if dirty_string[-1:] == '.':
dirty_string = dirty_string[:-1] + '_'
else:
dirty_string = dirty_string[:36]
return dirty_string.strip()
def get_final_location_overwrite_prevented_music_video(self, final_location):
count = 1
while True:
if final_location.with_name(f'{final_location.stem} {count}.m4v').exists():
count += 1
else:
return final_location.with_name(f'{final_location.stem} {count}.m4v')
def get_final_location(self, file_extension, tags):
final_location = self.final_path
if 'plID' in tags:
if tags['disk'][0][1] > 1:
file_name = self.get_sanizated_string(f'{tags["disk"][0][0]}-{tags["trkn"][0][0]:02d} {tags["©nam"][0]}', False)
else:
file_name = self.get_sanizated_string(f'{tags["trkn"][0][0]:02d} {tags["©nam"][0]}', False)
if 'cpil' in tags and tags['cpil']:
final_location /= f'Compilations/{self.get_sanizated_string(tags["©alb"][0], True)}'
else:
final_location /= f'{self.get_sanizated_string(tags["aART"][0], True)}/{self.get_sanizated_string(tags["©alb"][0], True)}'
else:
file_name = self.get_sanizated_string(tags["©nam"][0], False)
final_location /= f'{self.get_sanizated_string(tags["©ART"][0], True)}/Unknown Album/'
final_location /= f'{file_name}{file_extension}'
try:
if file_extension == '.m4v' and final_location.exists() and MP4(final_location).tags['cnID'][0] != tags['cnID'][0]:
final_location = self.get_final_location_overwrite_prevented_music_video(final_location)
except:
pass
return final_location
def fixup_music_video(self, decrypted_location_audio, decrypted_location_video, fixed_location):
subprocess.run(
[
'MP4Box',
'-quiet',
'-add',
decrypted_location_audio,
'-add',
decrypted_location_video,
'-itags',
'artist=placeholder',
fixed_location
],
check = True
)
def fixup_song(self, decrypted_location, fixed_location):
subprocess.run(
[
'MP4Box',
'-quiet',
'-add',
decrypted_location,
'-itags',
'artist=placeholder',
fixed_location
],
check = True
)
def make_lrc(self, final_location, synced_lyrics):
if synced_lyrics and not self.no_lrc:
with open(final_location.with_suffix('.lrc'), 'w', encoding = 'utf8') as f:
f.write(synced_lyrics)
def make_final(self, final_location, fixed_location, tags):
final_location.parent.mkdir(parents = True, exist_ok = True)
shutil.copy(fixed_location, final_location)
file = MP4(final_location)
file.update(tags)
file.save()
def cleanup(self):
if self.temp_path.exists() and not self.skip_cleanup:
shutil.rmtree(self.temp_path)
+8
View File
@@ -0,0 +1,8 @@
from .base import AppleMusicBaseInterface
from .enums import *
from .exceptions import *
from .interface import AppleMusicInterface
from .music_video import AppleMusicMusicVideoInterface
from .song import AppleMusicSongInterface
from .types import *
from .uploaded_video import AppleMusicUploadedVideoInterface
+399
View File
@@ -0,0 +1,399 @@
import asyncio
import base64
import datetime
import re
from io import BytesIO
import httpx
import structlog
from async_lru import alru_cache
from PIL import Image
from pywidevine import PSSH, Cdm, Device
from pywidevine.license_protocol_pb2 import WidevinePsshData
from gamdl.interface.wvd import WVD
from ..api.apple_music import AppleMusicApi
from ..api.itunes import ItunesApi
from ..api.wrapper import WrapperApi
from .constants import IMAGE_FILE_EXTENSION_MAP
from .enums import CoverFormat
from .types import Cover, DecryptionKey, MediaRating, MediaTags, MediaType, PlaylistTags
logger = structlog.get_logger(__name__)
class AppleMusicBaseInterface:
def __init__(
self,
apple_music_api: AppleMusicApi,
itunes_api: ItunesApi,
wrapper_api: WrapperApi | None,
cover_format: CoverFormat,
cover_size: int,
cdm: Cdm,
) -> None:
self.apple_music_api = apple_music_api
self.itunes_api = itunes_api
self.cover_format = cover_format
self.cover_size = cover_size
self.cdm = cdm
self.wrapper_api = wrapper_api
@staticmethod
def create_cdm(wvd_path: str | None = None) -> Cdm:
if wvd_path:
cdm = Cdm.from_device(Device.load(wvd_path))
else:
cdm = Cdm.from_device(Device.loads(WVD))
cdm.MAX_NUM_OF_SESSIONS = float("inf")
return cdm
@staticmethod
def is_media_streamable(
media_metadata: dict,
) -> bool:
return bool(media_metadata["attributes"].get("playParams"))
@staticmethod
def parse_catalog_media_id(media_metadata: dict) -> str:
play_params = media_metadata["attributes"].get("playParams", {})
return play_params.get("catalogId", media_metadata["id"])
@staticmethod
def parse_media_id_from_url(media_metadata: dict) -> str | None:
media_url = media_metadata["attributes"].get("url")
if media_url is None:
return None
url_media_id = media_url.split("/")[-1].split("?")[0]
return url_media_id
@staticmethod
def parse_date(date: str) -> datetime.datetime:
return datetime.datetime.fromisoformat(date.split("Z")[0])
@staticmethod
def reconstruct_pssh(pssh: str) -> bytes:
pssh = pssh.split(",")[-1]
decoded_pssh = base64.b64decode(pssh)
if len(decoded_pssh) > 30:
return pssh
widevine_pssh_data = WidevinePsshData(
algorithm=1,
key_ids=[decoded_pssh],
)
return widevine_pssh_data.SerializeToString()
@staticmethod
async def get_response(
url: str,
valid_responses: list[int] = [200],
) -> httpx.Response:
async with httpx.AsyncClient(timeout=60.0) as client:
try:
response = await client.get(url)
response.raise_for_status()
except httpx.HTTPStatusError as e:
if e.response.status_code in valid_responses:
return e.response
raise e
return response
@staticmethod
def format_cover(
template_cover_url: str,
cover_size: int,
cover_format: CoverFormat,
) -> str:
return re.sub(
r"/\{w\}x\{h\}([a-z]{2})\.jpg",
f"/{cover_size}x{cover_size}bb.{cover_format.value}",
template_cover_url,
)
@classmethod
async def create(
cls,
apple_music_api: AppleMusicApi,
cover_format: CoverFormat = CoverFormat.JPG,
cover_size: int = 1200,
wvd_path: str | None = None,
itunes_api: ItunesApi | None = None,
wrapper_api: WrapperApi | None = None,
):
itunes_api = itunes_api or await ItunesApi.create(
storefront=apple_music_api.storefront,
language=apple_music_api.language,
**(
{"storefront_id": None}
if apple_music_api.storefront.lower() != "us"
else {}
),
)
cdm = cls.create_cdm(wvd_path)
base = cls(
apple_music_api=apple_music_api,
itunes_api=itunes_api,
cover_format=cover_format,
cover_size=cover_size,
cdm=cdm,
wrapper_api=wrapper_api,
)
return base
@alru_cache()
async def get_album_cached(
self,
album_id: int,
) -> dict | None:
return (await self.apple_music_api.get_album(album_id))["data"][0]
async def get_decryption_key(
self,
pssh: str,
track_id: str,
) -> DecryptionKey:
log = logger.bind(action="get_decryption_key", track_id=track_id)
reconstructed_pssh = self.reconstruct_pssh(pssh)
cdm_session = self.cdm.open()
try:
pssh_obj = PSSH(reconstructed_pssh)
challenge = base64.b64encode(
await asyncio.to_thread(
self.cdm.get_license_challenge, cdm_session, pssh_obj
)
).decode()
license = await self.apple_music_api.get_license_exchange(
track_id,
pssh,
challenge,
)
await asyncio.to_thread(
self.cdm.parse_license, cdm_session, license["license"]
)
decryption_key_info = next(
i for i in self.cdm.get_keys(cdm_session) if i.type == "CONTENT"
)
finally:
self.cdm.close(cdm_session)
decryption_key = DecryptionKey(
key=decryption_key_info.key.hex(),
kid=decryption_key_info.kid.hex,
)
log.debug("success", decryption_key=decryption_key)
return decryption_key
@alru_cache()
async def get_cover_bytes(self, cover_url: str) -> bytes | None:
log = logger.bind(action="get_cover_bytes", cover_url=cover_url)
async with httpx.AsyncClient() as client:
response = await client.get(cover_url)
if response.status_code == 404:
log.debug("cover_not_found")
return None
response.raise_for_status()
return response.content
def _get_cover_template_url(self, metadata: dict) -> str:
if self.cover_format == CoverFormat.RAW:
cover_template_url = self._get_raw_cover_url(
metadata["attributes"]["artwork"]["url"]
)
else:
cover_template_url = metadata["attributes"]["artwork"]["url"]
return cover_template_url
def _get_raw_cover_url(self, cover_url_template: str) -> str:
return re.sub(
r"/\{w\}x\{h\}bb\.jpg",
"",
re.sub(
r"image/thumb/",
"",
re.sub(
r"is1-ssl",
"a1",
cover_url_template,
),
),
)
@alru_cache()
async def _get_cover_file_extension(
self,
cover_url: str,
) -> str | None:
log = logger.bind(action="get_cover_file_extension", cover_url=cover_url)
if self.cover_format != CoverFormat.RAW:
return f".{self.cover_format.value}"
cover_bytes = await self.get_cover_bytes(cover_url)
if cover_bytes is None:
log.debug("cover_bytes_empty")
return None
image_obj = Image.open(BytesIO(cover_bytes))
image_format = image_obj.format.lower()
return IMAGE_FILE_EXTENSION_MAP.get(
image_format,
f".{image_format.lower()}",
)
async def get_cover(
self,
metadata: dict,
) -> str:
log = logger.bind(
action="get_cover", media_id=self.parse_catalog_media_id(metadata)
)
template_url = self._get_cover_template_url(metadata)
if self.cover_format == CoverFormat.RAW:
cover_url = template_url
else:
cover_url = self.format_cover(
template_url,
self.cover_size,
self.cover_format,
)
cover_file_extension = await self._get_cover_file_extension(cover_url)
cover = Cover(
template_url=template_url,
url=cover_url,
file_extension=cover_file_extension,
)
log.debug("success", cover=cover)
return cover
@alru_cache()
async def get_media_date(
self,
media_id: str,
) -> datetime.datetime | None:
log = logger.bind(action="get_media_date", media_id=media_id)
lookup_result = await self.itunes_api.get_lookup_result(media_id)
if not lookup_result["results"]:
log.debug("no_media_id")
return None
release_date = lookup_result["results"][0].get("releaseDate")
if not release_date:
log.debug("no_release_date")
return None
parsed_date = self.parse_date(release_date)
log.debug("success", release_date=parsed_date)
return parsed_date
def get_playlist_tags(
self,
playlist_metadata: dict,
playlist_track: int,
) -> PlaylistTags:
log = logger.bind(
action="get_playlist_tags",
playlist_id=playlist_metadata["id"],
)
playlist_tags = PlaylistTags(
artist=playlist_metadata["attributes"].get("curatorName", "Unknown"),
playlist_id=playlist_metadata["attributes"]["playParams"]["id"],
title=playlist_metadata["attributes"]["name"],
track=playlist_track,
)
log.debug("success", playlist_tags=playlist_tags)
return playlist_tags
async def get_tags_from_asset_info(
self,
asset_data: dict,
lyrics: str | None = None,
use_album_date: bool = False,
) -> MediaTags:
log = logger.bind(
action="get_tags_from_asset_info", asset_id=asset_data["itemId"]
)
tags = MediaTags(
album=asset_data.get("playlistName"),
album_artist=asset_data.get("playlistArtistName"),
album_id=(
int(asset_data["playlistId"]) if asset_data.get("playlistId") else None
),
album_sort=asset_data.get("sort-album"),
artist=asset_data["artistName"],
artist_id=int(asset_data["artistId"]),
artist_sort=asset_data["sort-artist"],
comment=asset_data.get("comments"),
compilation=asset_data.get("compilation"),
composer=asset_data.get("composerName"),
composer_id=(
int(asset_data.get("composerId"))
if asset_data.get("composerId")
else None
),
composer_sort=asset_data.get("sort-composer"),
copyright=asset_data.get("copyright"),
date=(
await self.get_media_date(asset_data["playlistId"])
if use_album_date
else (
self.parse_date(asset_data["releaseDate"])
if asset_data.get("releaseDate")
else None
)
),
disc=asset_data.get("discNumber"),
disc_total=asset_data.get("discCount"),
gapless=asset_data.get("gapless"),
genre=asset_data.get("genre"),
genre_id=int(asset_data["genreId"]),
lyrics=lyrics if lyrics else None,
media_type=(
MediaType.SONG
if asset_data["kind"] == "song"
else MediaType.MUSIC_VIDEO
),
rating=MediaRating(asset_data["explicit"]),
storefront=asset_data["s"],
title=asset_data["itemName"],
title_id=int(asset_data["itemId"]),
title_sort=asset_data["sort-name"],
track=asset_data.get("trackNumber"),
track_total=asset_data.get("trackCount"),
xid=asset_data.get("xid"),
)
log.debug("success", tags=tags)
return tags
+103
View File
@@ -0,0 +1,103 @@
import re
MEDIA_TYPE_STR_MAP = {
1: "Song",
6: "Music Video",
}
MEDIA_RATING_STR_MAP = {
0: "None",
1: "Explicit",
2: "Clean",
}
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",
}
VALID_URL_PATTERN = re.compile(
r"https://(?:classical\.)?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")"
)
ARTIST_AUTO_SELECT_KEY_MAP = {
"main-albums": ("views", "full-albums"),
"compilation-albums": ("views", "compilation-albums"),
"live-albums": ("views", "live-albums"),
"singles-eps": ("views", "singles"),
"all-albums": ("relationships", "albums"),
"top-songs": ("views", "top-songs"),
"music-videos": ("relationships", "music-videos"),
}
ARTIST_AUTO_SELECT_STR_MAP = {
"main-albums": "Main Albums",
"compilation-albums": "Compilation Albums",
"live-albums": "Live Albums",
"singles-eps": "Singles & EPs",
"all-albums": "All Albums",
"top-songs": "Top Songs",
"music-videos": "Music Videos",
}
SONG_CODEC_FLAVOR_MAP = {
"aac-web": "28:ctrp256",
"aac-he-web": "32:ctrp64",
"aac-fps-web": "30:cbcp256",
"aac-he-fps-web": "34:cbcp64",
}
+126
View File
@@ -0,0 +1,126 @@
from enum import Enum
from .constants import (
ARTIST_AUTO_SELECT_KEY_MAP,
ARTIST_AUTO_SELECT_STR_MAP,
FOURCC_MAP,
MEDIA_RATING_STR_MAP,
MEDIA_TYPE_STR_MAP,
SONG_CODEC_FLAVOR_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_WEB = "aac-web"
AAC_HE_WEB = "aac-he-web"
# doesnt work with wrapper, gives ckc error
# AAC_FPS_WEB = "aac-fps-web"
# AAC_HE_FPS_WEB = "aac-he-fps-web"
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"
@property
def is_web(self) -> bool:
return self.value.endswith("-web")
@property
def flavor(self) -> str | None:
return SONG_CODEC_FLAVOR_MAP.get(self.value)
@property
def is_cenc(self) -> bool:
return self.flavor is not None and "ctrp" in self.flavor
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"
class ArtistMediaType(Enum):
MAIN_ALBUMS = "main-albums"
COMPILATION_ALBUMS = "compilation-albums"
LIVE_ALBUMS = "live-albums"
SINGLES_EPS = "singles-eps"
ALL_ALBUMS = "all-albums"
TOP_SONGS = "top-songs"
MUSIC_VIDEOS = "music-videos"
@property
def path_key(self) -> tuple[str, str]:
return ARTIST_AUTO_SELECT_KEY_MAP[self.value]
def __str__(self) -> str:
return ARTIST_AUTO_SELECT_STR_MAP[self.value]
+53
View File
@@ -0,0 +1,53 @@
from ..utils import GamdlError
from typing import Any
class GamdlInterfaceError(GamdlError):
pass
class GamdlInterfaceMediaNotStreamableError(GamdlInterfaceError):
def __init__(self, media_id: str):
super().__init__(f"Media is not streamable: {media_id}")
class GamdlInterfaceFormatNotAvailableError(GamdlInterfaceError):
def __init__(self, media_id: str, codec: Any | None = None):
super().__init__(
f"Requested format is not available (media ID: {media_id}): {codec}"
)
class GamdlInterfaceDecryptionNotAvailableError(GamdlInterfaceError):
def __init__(self, media_id: str):
super().__init__(f"Decryption is not available for media ID: {media_id}")
class GamdlInterfaceMediaNotAllowedError(GamdlInterfaceError):
def __init__(self, media_type: str, media_id: str | None = None):
message = "Media type is disallowed"
if media_id:
message += f" (media ID: {media_id})"
super().__init__(f"{message}: {media_type}")
class GamdlInterfaceUrlParseError(GamdlInterfaceError):
def __init__(self, url: str):
super().__init__(f"URL is not valid or supported: {url}")
class GamdlInterfaceArtistMediaTypeError(GamdlInterfaceError):
def __init__(self, media_id: str, media_type: str):
super().__init__(
f"Artist has no media of type (media ID: {media_id}): {media_type}"
)
class GamdlInterfaceFlatFilterExcludedError(GamdlInterfaceError):
def __init__(self, media_id: str, result: Any):
super().__init__(
f"Media excluded by flat filter (media ID: {media_id}): {result}"
)
self.result = result
+468
View File
@@ -0,0 +1,468 @@
import asyncio
from typing import Any, AsyncGenerator, Callable
import structlog
from ..utils import safe_gather
from .constants import VALID_URL_PATTERN
from .enums import ArtistMediaType
from .exceptions import (
GamdlInterfaceMediaNotAllowedError,
GamdlInterfaceUrlParseError,
GamdlInterfaceArtistMediaTypeError,
GamdlInterfaceFlatFilterExcludedError,
)
from .music_video import AppleMusicMusicVideoInterface
from .song import AppleMusicSongInterface
from .types import AppleMusicMedia, AppleMusicUrlInfo
from .uploaded_video import AppleMusicUploadedVideoInterface
logger = structlog.get_logger(__name__)
class AppleMusicInterface:
def __init__(
self,
song: AppleMusicSongInterface,
music_video: AppleMusicMusicVideoInterface,
uploaded_video: AppleMusicUploadedVideoInterface,
artist_select_media_type_function: (
Callable[[list[ArtistMediaType], dict], ArtistMediaType | None] | None
) = None,
artist_select_items_function: (
Callable[[ArtistMediaType, list[dict]], list[dict] | None] | None
) = None,
flat_filter_function: Callable[[dict], Any] | None = None,
concurrency: int = 1,
disallowed_media_types: list[str] | None = None,
) -> None:
self.song = song
self.music_video = music_video
self.uploaded_video = uploaded_video
self.artist_select_media_type_function = artist_select_media_type_function
self.artist_select_items_function = artist_select_items_function
self.flat_filter_function = flat_filter_function
self.concurrency = concurrency
self.disallowed_media_types = disallowed_media_types
self.base = song.base
@staticmethod
def get_url_info(url: str) -> AppleMusicUrlInfo | None:
log = logger.bind(action="get_url_info", url=url)
match = VALID_URL_PATTERN.match(url)
if not match:
log.debug("invalid_url_pattern")
return None
url_match = AppleMusicUrlInfo(
**match.groupdict(),
)
log.debug("success", url_info=url_match)
return url_match
async def _run_flat_filter(self, media: AppleMusicMedia) -> None:
if not self.flat_filter_function or not media.partial:
return
result = self.flat_filter_function(media.media_metadata)
if asyncio.iscoroutine(result):
result = await result
if result:
raise GamdlInterfaceFlatFilterExcludedError(media.media_id, result)
def _run_media_type_filter(self, media: AppleMusicMedia) -> None:
if not self.disallowed_media_types or not media.partial:
return
if media.media_metadata["type"] in self.disallowed_media_types:
raise GamdlInterfaceMediaNotAllowedError(
media.media_metadata["type"],
media.media_id,
)
async def _collect_generator(
self, generator_or_coroutine: AsyncGenerator[AppleMusicMedia, None]
) -> list[AppleMusicMedia]:
results = []
async for result in generator_or_coroutine:
results.append(result)
return results
async def _get_song_media(
self,
media_id: str,
index: int | None = None,
total: int | None = None,
media_metadata: dict | None = None,
playlist_metadata: dict | None = None,
) -> AsyncGenerator[AppleMusicMedia, None]:
media = AppleMusicMedia(
media_id=media_id,
)
if index is not None:
media.index = index
if total is not None:
media.total = total
media.media_metadata = media_metadata
media.playlist_metadata = playlist_metadata
try:
async for media in self.song.get_media(media):
yield media
self._run_media_type_filter(media)
await self._run_flat_filter(media)
except Exception as e:
media.partial = False
media.error = e
yield media
return
async def _get_music_video_media(
self,
media_id: str,
index: int | None = None,
total: int | None = None,
media_metadata: dict | None = None,
playlist_metadata: dict | None = None,
) -> AsyncGenerator[AppleMusicMedia, None]:
media = AppleMusicMedia(
media_id=media_id,
)
if index is not None:
media.index = index
if total is not None:
media.total = total
media.media_metadata = media_metadata
media.playlist_metadata = playlist_metadata
try:
async for media in self.music_video.get_media(media):
yield media
self._run_media_type_filter(media)
await self._run_flat_filter(media)
except Exception as e:
media.partial = False
media.error = e
yield media
return
async def _get_uploaded_video_media(
self,
media_id: str,
) -> AsyncGenerator[AppleMusicMedia, None]:
media = AppleMusicMedia(
media_id=media_id,
)
try:
async for media in self.music_video.get_media(media):
yield
self._run_media_type_filter(media)
await self._run_flat_filter(media)
except Exception as e:
media.partial = False
media.error = e
yield media
return
async def _get_album_media(
self,
media_id: str,
is_library: bool = False,
) -> AsyncGenerator[AppleMusicMedia, None]:
base_media = AppleMusicMedia(media_id)
try:
base_media.media_metadata = (
await self.base.apple_music_api.get_library_album(
media_id,
)
if is_library
else await self.base.apple_music_api.get_album(
media_id,
)
)["data"][0]
self._run_media_type_filter(base_media)
await self._run_flat_filter(base_media)
except Exception as e:
base_media.partial = False
base_media.error = e
yield base_media
return
yield base_media
tracks = base_media.media_metadata["relationships"]["tracks"]["data"]
tasks = [
(
self._get_song_media(
media_id=track["id"],
index=index,
total=base_media.media_metadata["attributes"]["trackCount"],
media_metadata=track,
)
if track["type"] in {"songs", "library-songs"}
else self._get_music_video_media(
media_id=track["id"],
index=index,
total=base_media.media_metadata["attributes"]["trackCount"],
media_metadata=track,
)
)
for index, track in enumerate(tracks)
]
if self.concurrency == 1:
for task in tasks:
async for media in task:
yield media
else:
collected_tasks = [self._collect_generator(task) for task in tasks]
batches = await safe_gather(*collected_tasks, limit=self.concurrency)
for batch in batches:
for media in batch:
yield media
async def _get_playlist_media(
self,
media_id: str,
is_library: bool = False,
) -> AsyncGenerator[AppleMusicMedia, None]:
base_media = AppleMusicMedia(media_id)
try:
base_media.media_metadata = (
await self.base.apple_music_api.get_library_playlist(
media_id,
)
if is_library
else await self.base.apple_music_api.get_playlist(
media_id,
)
)["data"][0]
self._run_media_type_filter(base_media)
await self._run_flat_filter(base_media)
tracks = base_media.media_metadata["relationships"]["tracks"]["data"]
next_uri = base_media.media_metadata["relationships"]["tracks"].get("next")
href_uri = base_media.media_metadata["relationships"]["tracks"].get("href")
while next_uri:
extended_data = await self.base.apple_music_api.get_extended_api_data(
next_uri,
href_uri,
)
tracks.extend(extended_data["data"])
next_uri = extended_data.get("next")
except Exception as e:
base_media.partial = False
base_media.error = e
yield base_media
return
yield base_media
tasks = [
(
self._get_song_media(
media_id=track["id"],
index=index,
media_metadata=track,
playlist_metadata=base_media.media_metadata,
)
if track["type"] in {"songs", "library-songs"}
else self._get_music_video_media(
media_id=track["id"],
index=index,
media_metadata=track,
playlist_metadata=base_media.media_metadata,
)
)
for index, track in enumerate(tracks)
]
if self.concurrency == 1:
for task in tasks:
async for media in task:
yield media
else:
collected_tasks = [self._collect_generator(task) for task in tasks]
batches = await safe_gather(*collected_tasks, limit=self.concurrency)
for batch in batches:
for media in batch:
yield media
async def _get_artist_media(
self,
media_id: str,
) -> AsyncGenerator[AppleMusicMedia, None]:
base_media = AppleMusicMedia(media_id)
try:
base_media.media_metadata = (
await self.base.apple_music_api.get_artist(
media_id,
)
)["data"][0]
self._run_media_type_filter(base_media)
await self._run_flat_filter(base_media)
if self.artist_select_media_type_function:
artist_media_type = self.artist_select_media_type_function(
list(ArtistMediaType),
base_media.media_metadata,
)
if asyncio.iscoroutine(artist_media_type):
artist_media_type = await artist_media_type
else:
artist_media_type = list(ArtistMediaType)[0]
relation_key, type_key = artist_media_type.path_key
items_relation = base_media.media_metadata.get(relation_key, {}).get(
type_key, {}
)
items = items_relation.get("data", [])
if not items:
raise GamdlInterfaceArtistMediaTypeError(
base_media.media_id,
str(artist_media_type),
)
next_uri = items_relation.get("next")
href_uri = items_relation.get("href")
while next_uri:
extended_data = await self.base.apple_music_api.get_extended_api_data(
next_uri,
href_uri,
)
items.extend(extended_data.get("data", []))
next_uri = extended_data.get("next")
except Exception as e:
yield AppleMusicMedia(
media_id=media_id,
media_metadata=None,
error=e,
)
return
yield base_media
if self.artist_select_items_function:
selected_items = self.artist_select_items_function(
artist_media_type,
items,
)
if asyncio.iscoroutine(selected_items):
selected_items = await selected_items
else:
selected_items = items[:1]
tasks = []
for index, item in enumerate(selected_items):
if item["type"] in {"songs", "library-songs"}:
tasks.append(
self._get_song_media(
media_id=item["id"],
index=index,
total=len(selected_items),
media_metadata=item,
)
)
elif item["type"] in {"albums", "library-albums"}:
tasks.append(
self._get_album_media(
media_id=item["id"],
)
)
else:
tasks.append(
self._get_music_video_media(
media_id=item["id"],
index=index,
total=len(selected_items),
media_metadata=item,
)
)
if self.concurrency == 1:
for task in tasks:
async for media in task:
yield media
else:
collected_tasks = [self._collect_generator(task) for task in tasks]
batches = await safe_gather(*collected_tasks, limit=self.concurrency)
for batch in batches:
for media in batch:
yield media
async def get_media_from_url(
self,
url: str,
) -> AsyncGenerator[AppleMusicMedia, None]:
url_info = self.get_url_info(url)
if not url_info:
raise GamdlInterfaceUrlParseError(url)
if self.disallowed_media_types and url_info.type in self.disallowed_media_types:
raise GamdlInterfaceMediaNotAllowedError(
url_info.type,
)
if url_info.type == "song" or url_info.sub_id:
async for media in self._get_song_media(
media_id=url_info.sub_id or url_info.id,
index=0,
total=1,
):
yield media
elif url_info.type == "music-video":
async for media in self._get_music_video_media(
media_id=url_info.id,
index=0,
total=1,
):
yield media
elif url_info.type == "album" or url_info.library_type == "albums":
async for media in self._get_album_media(
media_id=url_info.library_id or url_info.id,
is_library=bool(url_info.library_type),
):
yield media
elif url_info.type == "playlist" or url_info.library_type == "playlist":
async for media in self._get_playlist_media(
media_id=url_info.library_id or url_info.id,
is_library=bool(url_info.library_type),
):
yield media
elif url_info.type == "post":
async for media in self._get_uploaded_video_media(
media_id=url_info.id,
):
yield media
elif url_info.type == "artist":
async for media in self._get_artist_media(
media_id=url_info.id,
):
yield media
+448
View File
@@ -0,0 +1,448 @@
import asyncio
import urllib.parse
from typing import AsyncGenerator, Callable
import m3u8
import structlog
from .base import AppleMusicBaseInterface
from .constants import MP4_FORMAT_CODECS
from .enums import MediaRating, MediaType, MusicVideoCodec, MusicVideoResolution
from .exceptions import (
GamdlInterfaceDecryptionNotAvailableError,
GamdlInterfaceFormatNotAvailableError,
GamdlInterfaceMediaNotStreamableError,
)
from .types import (
AppleMusicMedia,
DecryptionKeyAv,
MediaFileFormat,
MediaTags,
StreamInfo,
StreamInfoAv,
)
logger = structlog.get_logger(__name__)
class AppleMusicMusicVideoInterface:
def __init__(
self,
base: AppleMusicBaseInterface,
resolution: MusicVideoResolution = MusicVideoResolution.R1080P,
codec_priority: list[MusicVideoCodec] = [
MusicVideoCodec.H264,
MusicVideoCodec.H265,
],
ask_video_codec_function: (
Callable[[list[m3u8.Playlist]], dict | None] | None
) = None,
ask_audio_codec_function: Callable[[list[dict]], dict | None] | None = None,
):
self.base = base
self.resolution = resolution
self.codec_priority = codec_priority
self.ask_video_codec_function = ask_video_codec_function
self.ask_audio_codec_function = ask_audio_codec_function
async def get_itunes_page_metadata(
self,
music_video_metadata: dict,
) -> dict:
url_media_id = self.base.parse_media_id_from_url(music_video_metadata)
itunes_page = await self.base.itunes_api.get_itunes_page(
"music-video",
url_media_id,
)
return itunes_page["storePlatformData"]["product-dv"]["results"][url_media_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,
) -> str | None:
log = logger.bind(action="get_m3u8_master_url_from_itunes_page_metadata")
stream_url = itunes_page_metadata["offers"][0]["assets"][0].get("hlsUrl")
if not stream_url:
return None
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()
m3u8_master_url = m3u8_master_url.replace(
"play-edge.itunes.apple.com",
"play.itunes.apple.com",
).replace(
"MZPlayLocal.woa",
"MZPlay.woa",
)
log.debug("success", m3u8_master_url=m3u8_master_url)
return m3u8_master_url
async def get_tags(
self,
metadata: dict,
itunes_page_metadata: dict,
) -> MediaTags:
log = logger.bind(
action="get_music_video_tags",
media_id=self.base.parse_catalog_media_id(metadata),
)
url_media_id = self.base.parse_media_id_from_url(metadata)
lookup_metadata = (await self.base.itunes_api.get_lookup_result(url_media_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.base.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=self.base.itunes_api.storefront_id,
title=lookup_metadata[0]["trackCensoredName"],
title_id=int(metadata["id"]),
rating=rating,
)
if len(lookup_metadata) > 1:
album = await self.base.get_album_cached(
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"]
log.debug("success", tags=tags)
return tags
async def get_stream_info(
self,
metadata: dict,
itunes_page_metadata: dict,
) -> StreamInfoAv | None:
log = logger.bind(
action="get_music_video_stream_info",
media_id=self.base.parse_catalog_media_id(metadata),
)
url_media_id = self.base.parse_media_id_from_url(metadata)
m3u8_master_url = None
if url_media_id == metadata["id"]:
m3u8_master_url = self._get_m3u8_master_url_from_itunes_page_metadata(
itunes_page_metadata,
)
if not m3u8_master_url:
webplayback_response = await self.base.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 self.base.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)
stream_info_audio = await self._get_stream_info_audio(
playlist_master_m3u8_obj.data,
)
if not stream_info_video or not stream_info_audio:
return None
use_mp4 = any(
stream_info_video.codec.startswith(codec) for codec in 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,
)
log.debug("success", stream_info=stream_info)
return stream_info
def _get_video_playlist_from_resolution(
self,
video_playlists: list[m3u8.Playlist],
) -> m3u8.Playlist | None:
playlist_results = []
for codec_index, codec in enumerate(self.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(self.resolution)
resolution_difference = abs(playlist_resolution - int(self.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 | None:
if self.ask_video_codec_function:
video_playlist = self.ask_video_codec_function(video_playlists)
if asyncio.iscoroutine(video_playlist):
video_playlist = await video_playlist
return video_playlist
return None
async def _get_audio_playlist_from_user(
self,
playlist_master_data: dict,
) -> dict | None:
if self.ask_audio_codec_function:
audio_playlist = self.ask_audio_codec_function(
[
playlist
for playlist in playlist_master_data["media"]
if playlist.get("uri")
]
)
if asyncio.iscoroutine(audio_playlist):
audio_playlist = await audio_playlist
return audio_playlist
return None
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,
) -> StreamInfo | None:
stream_info = StreamInfo()
if MusicVideoCodec.ASK not in self.codec_priority:
playlist = self._get_video_playlist_from_resolution(
playlist_master_m3u8_obj.playlists,
)
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 self.base.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,
) -> StreamInfo | None:
stream_info = StreamInfo()
if MusicVideoCodec.ASK not in self.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 self.base.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,
) -> DecryptionKeyAv:
decryption_key_video, decryption_key_audio = await asyncio.gather(
self.base.get_decryption_key(
stream_info.video_track.widevine_pssh,
stream_info.media_id,
),
self.base.get_decryption_key(
stream_info.audio_track.widevine_pssh,
stream_info.media_id,
),
)
return DecryptionKeyAv(
video_track=decryption_key_video,
audio_track=decryption_key_audio,
)
async def get_media(
self,
media: AppleMusicMedia,
) -> AsyncGenerator[AppleMusicMedia, None]:
if not media.media_metadata:
media.media_metadata = (
await self.base.apple_music_api.get_music_video(media.media_id)
)["data"][0]
media.media_id = self.base.parse_catalog_media_id(media.media_metadata)
yield media
if not self.base.is_media_streamable(media.media_metadata):
raise GamdlInterfaceMediaNotStreamableError(media.media_id)
if media.playlist_metadata:
media.playlist_tags = self.base.get_playlist_tags(
media.playlist_metadata,
media.index,
)
media.cover = await self.base.get_cover(media.media_metadata)
itunes_page_metadata = await self.get_itunes_page_metadata(media.media_metadata)
if self.base.wrapper_api:
playback = await self.base.wrapper_api.get_playback(media.media_id)
media.tags = await self.base.get_tags_from_asset_info(
playback["songList"][0]["assets"][0]["metadata"],
)
else:
media.tags = await self.get_tags(
media.media_metadata,
itunes_page_metadata,
)
media.stream_info = await self.get_stream_info(
media.media_metadata,
itunes_page_metadata,
)
if not media.stream_info:
raise GamdlInterfaceFormatNotAvailableError(
media.media_id,
self.codec_priority,
)
if (
not media.stream_info.video_track.widevine_pssh
or not media.stream_info.audio_track.widevine_pssh
):
raise GamdlInterfaceDecryptionNotAvailableError(media.media_id)
media.decryption_key = await self.get_decryption_key(media.stream_info)
media.partial = False
yield media
+571
View File
@@ -0,0 +1,571 @@
import asyncio
import base64
import datetime
import json
import re
import struct
from typing import AsyncGenerator, Callable
from xml.dom import minidom
from xml.etree import ElementTree
import m3u8
import structlog
from .base import AppleMusicBaseInterface
from .constants import DRM_DEFAULT_KEY_MAPPING, MP4_FORMAT_CODECS, SONG_CODEC_REGEX_MAP
from .enums import MediaRating, MediaType, SongCodec, SyncedLyricsFormat
from .exceptions import (
GamdlInterfaceDecryptionNotAvailableError,
GamdlInterfaceFormatNotAvailableError,
GamdlInterfaceMediaNotStreamableError,
)
from .types import (
AppleMusicMedia,
DecryptionKeyAv,
Lyrics,
MediaFileFormat,
MediaTags,
StreamInfo,
StreamInfoAv,
)
import httpx
logger = structlog.get_logger(__name__)
class AppleMusicSongInterface:
def __init__(
self,
base: AppleMusicBaseInterface,
synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC,
codec_priority: list[SongCodec] = [SongCodec.AAC_WEB],
use_album_date: bool = False,
skip_stream_info: bool = False,
ask_codec_function: Callable[[list[dict]], dict | None] | None = None,
):
self.base = base
self.synced_lyrics_format = synced_lyrics_format
self.codec_priority = codec_priority
self.use_album_date = use_album_date
self.skip_stream_info = skip_stream_info
self.ask_codec_function = ask_codec_function
async def get_lyrics(
self,
song_metadata: dict,
) -> Lyrics | None:
log = logger.bind(
action="get_lyrics",
song_id=self.base.parse_catalog_media_id(song_metadata),
)
if not song_metadata["attributes"]["hasLyrics"]:
log.debug("no_lyrics")
return None
if (
"relationships" not in song_metadata
or "lyrics" not in song_metadata["relationships"]
):
song_metadata = (
await self.base.apple_music_api.get_song(
self.base.parse_catalog_media_id(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"
],
)
log.debug("success", lyrics=lyrics)
return lyrics
else:
log.debug("no_lyrics_data")
def _get_lyrics(
self,
lyrics_ttml: str,
) -> 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 self.synced_lyrics_format == SyncedLyricsFormat.LRC:
synced_lyrics.append(self._get_lyrics_line_lrc(p))
if self.synced_lyrics_format == SyncedLyricsFormat.SRT:
synced_lyrics.append(self._get_lyrics_line_srt(index, p))
if self.synced_lyrics_format == SyncedLyricsFormat.TTML:
if not synced_lyrics:
synced_lyrics.append(
minidom.parseString(lyrics_ttml).toprettyxml()
)
continue
index += 1
return Lyrics(
synced="\n".join(synced_lyrics + ["\n"]) 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}"
def _get_m3u8_from_playback(self, playback: dict) -> str | None:
return playback["songList"][0].get("hls-playlist-url")
async def get_tags(
self,
asset_data: dict,
lyrics: str | None = None,
) -> MediaTags:
log = logger.bind(action="get_song_tags")
tags = MediaTags(
album=asset_data["playlistName"],
album_artist=asset_data["playlistArtistName"],
album_id=int(asset_data["playlistId"]),
album_sort=asset_data["sort-album"],
artist=asset_data["artistName"],
artist_id=int(asset_data["artistId"]),
artist_sort=asset_data["sort-artist"],
comment=asset_data.get("comments"),
compilation=asset_data["compilation"],
composer=asset_data.get("composerName"),
composer_id=(
int(asset_data.get("composerId"))
if asset_data.get("composerId")
else None
),
composer_sort=asset_data.get("sort-composer"),
copyright=asset_data.get("copyright"),
date=(
await self.base.get_media_date(asset_data["playlistId"])
if self.use_album_date
else (
self.base.parse_date(asset_data["releaseDate"])
if asset_data.get("releaseDate")
else None
)
),
disc=asset_data["discNumber"],
disc_total=asset_data["discCount"],
gapless=asset_data["gapless"],
genre=asset_data.get("genre"),
genre_id=int(asset_data["genreId"]),
lyrics=lyrics if lyrics else None,
media_type=MediaType.SONG,
rating=MediaRating(asset_data["explicit"]),
storefront=asset_data["s"],
title=asset_data["itemName"],
title_id=int(asset_data["itemId"]),
title_sort=asset_data["sort-name"],
track=asset_data["trackNumber"],
track_total=asset_data["trackCount"],
xid=asset_data.get("xid"),
)
log.debug("success", tags=tags)
return tags
async def _get_m3u8_from_metadata(self, song_metadata: dict) -> str | None:
if "extendedAssetUrls" not in song_metadata["attributes"]:
song_metadata = (
await self.base.apple_music_api.get_song(
self.base.parse_catalog_media_id(song_metadata),
)
)["data"][0]
return song_metadata["attributes"]["extendedAssetUrls"].get("enhancedHls")
async def get_stream_info(
self,
media_id: str,
m3u8_master_url: str | None = None,
webplayback: dict | None = None,
) -> StreamInfoAv:
stream_info = None
for codec in self.codec_priority:
if codec.is_web:
stream_info = await self._get_web_stream_info(webplayback, codec)
else:
stream_info = await self._get_stream_info(m3u8_master_url, codec)
if stream_info:
break
if not stream_info:
raise GamdlInterfaceFormatNotAvailableError(
media_id=media_id,
formats=[codec.value for codec in self.codec_priority],
)
return stream_info
async def _get_stream_info(
self,
m3u8_master_url: str | None,
codec: SongCodec,
) -> StreamInfoAv | None:
log = logger.bind(action="get_song_stream_info")
if not m3u8_master_url:
log.debug("no_m3u8_master_url")
return None
m3u8_master_obj = m3u8.loads(
(await self.base.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:
log.debug("no_matching_playlist", codec=codec.value)
return None
stream_info = StreamInfo(use_single_content_key=False)
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 self.base.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,
)
log.debug("success", 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:
if self.ask_codec_function:
playlist = self.ask_codec_function(
[playlist for playlist in m3u8_data["playlists"]]
)
if asyncio.iscoroutine(playlist):
playlist = await playlist
return playlist
return None
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_web_stream_info(
self,
webplayback: dict,
codec: SongCodec,
) -> StreamInfoAv:
log = logger.bind(action="get_web_song_stream_info")
flavor = codec.flavor
stream_info = StreamInfo(
use_cenc=codec.is_cenc,
)
asset = next(
(i for i in webplayback["songList"][0]["assets"] if i["flavor"] == flavor),
None,
)
if not asset:
log.debug("no_matching_asset", codec=codec.value, flavor=flavor)
return None
stream_info.stream_url = asset["URL"]
m3u8_obj = m3u8.loads(
(await self.base.get_response(stream_info.stream_url)).text
)
if stream_info.use_cenc:
stream_info.widevine_pssh = m3u8_obj.keys[0].uri
else:
stream_info.fairplay_key = m3u8_obj.keys[0].uri
stream_info_av = StreamInfoAv(
media_id=webplayback["songList"][0]["songId"],
audio_track=stream_info,
file_format=MediaFileFormat.M4A,
)
log.debug("success", stream_info=stream_info_av)
return stream_info_av
async def get_media(
self,
media: AppleMusicMedia,
) -> AsyncGenerator[AppleMusicMedia, None]:
if not media.media_metadata:
media.media_metadata = (
await self.base.apple_music_api.get_song(media.media_id)
)["data"][0]
media.media_id = self.base.parse_catalog_media_id(media.media_metadata)
yield media
if not self.base.is_media_streamable(media.media_metadata):
raise GamdlInterfaceMediaNotStreamableError(
media_id=media.media_id,
)
if media.playlist_metadata:
media.playlist_tags = self.base.get_playlist_tags(
media.playlist_metadata,
media.index,
)
media.cover = await self.base.get_cover(media.media_metadata)
media.lyrics = await self.get_lyrics(media.media_metadata)
if self.base.wrapper_api:
playback = await self.base.wrapper_api.get_playback(media.media_id)
media.tags = await self.base.get_tags_from_asset_info(
playback["songList"][0]["assets"][0]["metadata"],
media.lyrics.unsynced if media.lyrics else None,
self.use_album_date,
)
if not self.skip_stream_info:
m3u8_master_url = self._get_m3u8_from_playback(playback)
webplayback = (
await self.base.apple_music_api.get_webplayback(media.media_id)
if any(codec.is_web for codec in self.codec_priority)
else None
)
media.stream_info = await self.get_stream_info(
media.media_id,
m3u8_master_url,
webplayback,
)
else:
webplayback = await self.base.apple_music_api.get_webplayback(
media.media_id
)
media.tags = await self.base.get_tags_from_asset_info(
webplayback["songList"][0]["assets"][0]["metadata"],
media.lyrics.unsynced if media.lyrics else None,
self.use_album_date,
)
if not self.skip_stream_info:
m3u8_master_url = await self._get_m3u8_from_metadata(
media.media_metadata
)
media.stream_info = await self.get_stream_info(
media.media_id,
m3u8_master_url,
webplayback,
)
if media.stream_info:
if (
not self.base.wrapper_api
and not media.stream_info.audio_track.widevine_pssh
) or (
self.base.wrapper_api
and not media.stream_info.audio_track.fairplay_key
and not media.stream_info.audio_track.use_cenc
):
raise GamdlInterfaceDecryptionNotAvailableError(media_id=media.media_id)
elif media.stream_info.audio_track.widevine_pssh:
media.decryption_key = DecryptionKeyAv(
audio_track=await self.base.get_decryption_key(
media.stream_info.audio_track.widevine_pssh,
media.media_id,
)
)
media.partial = False
yield media
+183
View File
@@ -0,0 +1,183 @@
import datetime
from dataclasses import dataclass
from typing import Any
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:
artist: str = None
playlist_id: int = None
title: str = None
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
use_cenc: bool = False
use_single_content_key: bool = True
@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
@dataclass
class Cover:
template_url: str = None
file_extension: str = None
url: str = None
@dataclass
class AppleMusicMedia:
media_id: str
index: int = 0
total: int = 0
partial: bool = True
media_metadata: dict | None = None
error: BaseException | None = None
playlist_metadata: dict | None = None
playlist_tags: PlaylistTags | None = None
extra_tags: dict | None = None
cover: Cover | None = None
lyrics: Lyrics | None = None
tags: MediaTags | None = None
stream_info: StreamInfoAv | None = None
decryption_key: DecryptionKeyAv | None = None
@dataclass
class AppleMusicUrlInfo:
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
+133
View File
@@ -0,0 +1,133 @@
import asyncio
from collections.abc import Callable
from typing import AsyncGenerator
import structlog
from .base import AppleMusicBaseInterface
from .constants import UPLOADED_VIDEO_QUALITY_RANK
from .enums import UploadedVideoQuality
from .exceptions import (
GamdlInterfaceFormatNotAvailableError,
GamdlInterfaceMediaNotStreamableError,
)
from .types import AppleMusicMedia, MediaFileFormat, MediaTags, StreamInfo, StreamInfoAv
logger = structlog.get_logger(__name__)
class AppleMusicUploadedVideoInterface:
def __init__(
self,
base: AppleMusicBaseInterface,
quality: UploadedVideoQuality = UploadedVideoQuality.BEST,
ask_quality_function: Callable[[dict], dict | None] | None = None,
):
self.base = base
self.quality = quality
self.ask_quality_function = ask_quality_function
def _get_best_stream_url(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 | None:
if self.ask_quality_function:
selected_quality = self.ask_quality_function(
metadata["attributes"]["assetTokens"]
)
if asyncio.iscoroutine(selected_quality):
selected_quality = await selected_quality
return selected_quality
return None
async def _get_stream_url(
self,
metadata: dict,
) -> str | None:
if self.quality == UploadedVideoQuality.BEST:
stream_url = self._get_best_stream_url(metadata)
if self.quality == UploadedVideoQuality.ASK:
stream_url = await self._get_stream_url_from_user(metadata)
return stream_url
async def get_stream_info(
self,
metadata: dict,
) -> StreamInfo | None:
log = logger.bind(
action="get_uploaded_video_stream_info", media_id=metadata["id"]
)
stream_url = await self._get_stream_url(metadata)
if not stream_url:
log.debug("no_stream_url_available")
return None
stream_info = StreamInfoAv(
file_format=MediaFileFormat.M4V,
video_track=StreamInfo(
stream_url=stream_url,
),
)
log.debug("success", stream_info=stream_info)
return stream_info
def get_tags(self, metadata: dict) -> MediaTags:
log = logger.bind(action="get_uploaded_video_tags", media_id=metadata["id"])
attributes = metadata["attributes"]
upload_date = attributes.get("uploadDate")
tags = MediaTags(
artist=attributes.get("artistName"),
date=self.base.parse_date(upload_date) if upload_date else None,
title=attributes.get("name"),
title_id=int(metadata["id"]),
storefront=self.base.itunes_api.storefront_id,
)
log.debug("success", tags=tags)
return tags
async def get_media(
self,
media: AppleMusicMedia,
) -> AsyncGenerator[AppleMusicMedia, None]:
if not media.media_metadata:
media.media_metadata = (
await self.base.apple_music_api.get_uploaded_video(media.media_id)
)["data"][0]
media.media_id = self.base.parse_catalog_media_id(media.media_metadata)
yield media
if not self.base.is_media_streamable(media.media_metadata):
raise GamdlInterfaceMediaNotStreamableError(media.media_id)
media.cover = await self.base.get_cover(media.media_metadata)
media.stream_info = await self.get_stream_info(media.media_metadata)
if not media.stream_info:
raise GamdlInterfaceFormatNotAvailableError(media.media_id)
media.tags = self.get_tags(media.media_metadata)
media.partial = False
yield media
+3
View File
@@ -0,0 +1,3 @@
# Dumped from Android Studio Virtual Device running Android 9
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=="""
-155
View File
@@ -1,155 +0,0 @@
AE = "143481-2,32"
AG = "143540-2,32"
AI = "143538-2,32"
AL = "143575-2,32"
AM = "143524-2,32"
AO = "143564-2,32"
AR = "143505-28,32"
AT = "143445-4,32"
AU = "143460-27,32"
AZ = "143568-2,32"
BB = "143541-2,32"
BE = "143446-2,32"
BF = "143578-2,32"
BG = "143526-2,32"
BH = "143559-2,32"
BJ = "143576-2,32"
BM = "143542-2,32"
BN = "143560-2,32"
BO = "143556-28,32"
BR = "143503-15,32"
BS = "143539-2,32"
BT = "143577-2,32"
BW = "143525-2,32"
BY = "143565-2,32"
BZ = "143555-2,32"
CA = "143455-6,32"
CG = "143582-2,32"
CH = "143459-57,32"
CL = "143483-28,32"
CN = "143465-19,32"
CO = "143501-28,32"
CR = "143495-28,32"
CV = "143580-2,32"
CY = "143557-2,32"
CZ = "143489-2,32"
DE = "143443-4,32"
DK = "143458-2,32"
DM = "143545-2,32"
DO = "143508-28,32"
DZ = "143563-2,32"
EC = "143509-28,32"
EE = "143518-2,32"
EG = "143516-2,32"
ES = "143454-8,32"
FI = "143447-2,32"
FJ = "143583-2,32"
FM = "143591-2,32"
FR = "143442-3,32"
GB = "143444-2,32"
GD = "143546-2,32"
GH = "143573-2,32"
GM = "143584-2,32"
GR = "143448-2,32"
GT = "143504-28,32"
GW = "143585-2,32"
GY = "143553-2,32"
HK = "143463-45,32"
HN = "143510-28,32"
HR = "143494-2,32"
HU = "143482-2,32"
ID = "143476-2,32"
IE = "143449-2,32"
IL = "143491-2,32"
IN = "143467-2,32"
IS = "143558-2,32"
IT = "143450-7,32"
JM = "143511-2,32"
JO = "143528-2,32"
JP = "143462-9,32"
KE = "143529-2,32"
KG = "143586-2,32"
KH = "143579-2,32"
KN = "143548-2,32"
KR = "143466-13,32"
KW = "143493-2,32"
KY = "143544-2,32"
KZ = "143517-2,32"
LA = "143587-2,32"
LB = "143497-2,32"
LC = "143549-2,32"
LK = "143486-2,32"
LR = "143588-2,32"
LT = "143520-2,32"
LU = "143451-2,32"
LV = "143519-2,32"
MD = "143523-2,32"
MG = "143531-2,32"
MK = "143530-2,32"
ML = "143532-2,32"
MN = "143592-2,32"
MO = "143515-45,32"
MR = "143590-2,32"
MS = "143547-2,32"
MT = "143521-2,32"
MU = "143533-2,32"
MW = "143589-2,32"
MX = "143468-28,32"
MY = "143473-2,32"
MZ = "143593-2,32"
NA = "143594-2,32"
NE = "143534-2,32"
NG = "143561-2,32"
NI = "143512-28,32"
NL = "143452-10,32"
NO = "143457-2,32"
NP = "143484-2,32"
NZ = "143461-27,32"
OM = "143562-2,32"
PA = "143485-28,32"
PE = "143507-28,32"
PG = "143597-2,32"
PH = "143474-2,32"
PK = "143477-2,32"
PL = "143478-2,32"
PT = "143453-24,32"
PW = "143595-2,32"
PY = "143513-28,32"
QA = "143498-2,32"
RO = "143487-2,32"
RU = "143469-16,32"
SA = "143479-2,32"
SB = "143601-2,32"
SC = "143599-2,32"
SE = "143456-17,32"
SG = "143464-19,32"
SI = "143499-2,32"
SK = "143496-2,32"
SL = "143600-2,32"
SN = "143535-2,32"
SR = "143554-2,32"
ST = "143598-2,32"
SV = "143506-28,32"
SZ = "143602-2,32"
TC = "143552-2,32"
TD = "143581-2,32"
TH = "143475-2,32"
TJ = "143603-2,32"
TM = "143604-2,32"
TN = "143536-2,32"
TR = "143480-2,32"
TT = "143551-2,32"
TW = "143470-18,32"
TZ = "143572-2,32"
UA = "143492-2,32"
UG = "143537-2,32"
US = "143441-1,32"
UY = "143514-2,32"
UZ = "143566-2,32"
VC = "143550-2,32"
VE = "143502-28,32"
VG = "143543-2,32"
VN = "143471-2,32"
YE = "143571-2,32"
ZA = "143472-2,32"
ZW = "143605-2,32"
+67
View File
@@ -0,0 +1,67 @@
import asyncio
import string
import typing
async def async_subprocess(*args: str, silent: bool = False) -> None:
if silent:
additional_args = {
"stdout": asyncio.subprocess.PIPE,
"stderr": asyncio.subprocess.PIPE,
}
else:
additional_args = {}
proc = await asyncio.create_subprocess_exec(
*args,
**additional_args,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
msg = (
f"Exited with code {proc.returncode}: {' '.join(str(arg) for arg in args)}"
)
if stdout:
msg += f"\nstdout:\n{stdout.decode()}"
if stderr:
msg += f"\nstderr:\n{stderr.decode()}"
raise Exception(msg)
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,
)
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
+27 -17
View File
@@ -1,24 +1,34 @@
[project]
name = "gamdl"
description = "Download Apple Music songs/music videos/albums/playlists"
requires-python = ">=3.7"
authors = [{name = "glomatico"}]
dependencies = [
"pywidevine",
"pyyaml",
"m3u8",
"yt-dlp"
]
version = "3.6"
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",
"httpx-retries>=0.4.6",
"inquirerpy>=0.3.4",
"m3u8>=6.0.0",
"mutagen>=1.47.0",
"pillow>=12.0.0",
"pywidevine>=1.8.0",
"structlog>=25.5.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:main"
gamdl = "gamdl.cli.cli:main"
[dependency-groups]
dev = [
"pytest>=9.0.3",
"pytest-asyncio>=1.3.0",
]
-4
View File
@@ -1,4 +0,0 @@
pywidevine
pyyaml
m3u8
yt-dlp
Generated
+828
View File
@@ -0,0 +1,828 @@
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-asyncio-runner"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
]
[[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 = "3.6"
source = { virtual = "." }
dependencies = [
{ name = "async-lru" },
{ name = "click" },
{ name = "colorama" },
{ name = "dataclass-click" },
{ name = "httpx" },
{ name = "httpx-retries" },
{ name = "inquirerpy" },
{ name = "m3u8" },
{ name = "mutagen" },
{ name = "pillow" },
{ name = "pywidevine" },
{ name = "structlog" },
{ name = "yt-dlp" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[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 = "httpx-retries", specifier = ">=0.4.6" },
{ 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 = "structlog", specifier = ">=25.5.0" },
{ name = "yt-dlp", specifier = ">=2025.10.22" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
]
[[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 = "httpx-retries"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a4/13/5eac2df576c02280f79e4639a6d4c93a25cfe94458275f5aa55f5e6c8ea0/httpx_retries-0.4.6.tar.gz", hash = "sha256:a076d8a5ede5d5794e9c241da17b15b393b482129ddd2fdf1fa56a3fa1f28a7f", size = 13466, upload-time = "2026-02-17T16:16:05.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/97/63f56da4400034adde22adfe7524635dba068f17d6858f92ecd96f55b53e/httpx_retries-0.4.6-py3-none-any.whl", hash = "sha256:d66d912173b844e065ffb109345a453b922f4c2cd9c9e11139304cb33e7a1ee1", size = 8490, upload-time = "2026-02-17T16:16:04.137Z" },
]
[[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 = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[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 = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[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 = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[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 = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[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 = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[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 = "structlog"
version = "25.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" },
]
[[package]]
name = "tomli"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[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" },
]