diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 675dd7d..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -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@v5 - 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 }} diff --git a/.gitignore b/.gitignore index ca5c9a9..e5fd28c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ __pycache__ !gamdl !.gitignore +!.python-version !pyproject.toml !README.md -!requirements.txt +!uv.lock diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/README.md b/README.md index 5123723..6427b1b 100644 --- a/README.md +++ b/README.md @@ -96,21 +96,19 @@ Config file values can be overridden using command-line arguments. | Command-line argument / Config file key | Description | Default value | | --------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------- | -| `--disable-music-video-skip` / `disable_music_video_skip` | Don't skip downloading music videos in albums/playlists. | `false` | | `--read-urls-as-txt`, `-r` / - | Interpret URLs as paths to text files containing URLs separated by newlines | `false` | | `--config-path` / - | Path to config file. | `/.gamdl/config.ini` | | `--log-level` / `log_level` | Log level. | `INFO` | +| `--log-file` / `log_file` | Path to log file. | `null` | | `--no-exceptions` / `no_exceptions` | Don't print exceptions. | `false` | | `--cookies-path`, `-c` / `cookies_path` | Path to .txt cookies file. | `./cookies.txt` | | `--language`, `-l` / `language` | Metadata language as an ISO-2A language code (don't always work for videos). | `en-US` | | `--output-path`, `-o` / `output_path` | Path to output directory. | `./Apple Music` | -| `--temp-path` / `temp_path` | Path to temporary directory. | `./temp` | +| `--temp-path` / `temp_path` | Path to temporary directory. | `.` | | `--wvd-path` / `wvd_path` | Path to .wvd file. | `null` | | `--overwrite` / `overwrite` | Overwrite existing files. | `false` | | `--save-cover`, `-s` / `save_cover` | Save cover as a separate file. | `false` | | `--save-playlist` / `save_playlist` | Save a M3U8 playlist file when downloading a playlist. | `false` | -| `--no-synced-lyrics` / `no_synced_lyrics` | Don't download the synced lyrics. | `false` | -| `--synced-lyrics-only` / `synced_lyrics_only` | Download only the synced lyrics. | `false` | | `--nm3u8dlre-path` / `nm3u8dlre_path` | Path to N_m3u8DL-RE binary. | `N_m3u8DL-RE` | | `--mp4decrypt-path` / `mp4decrypt_path` | Path to mp4decrypt binary. | `mp4decrypt` | | `--ffmpeg-path` / `ffmpeg_path` | Path to FFmpeg binary. | `ffmpeg` | @@ -118,24 +116,25 @@ Config file values can be overridden using command-line arguments. | `--download-mode` / `download_mode` | Download mode. | `ytdlp` | | `--remux-mode` / `remux_mode` | Remux mode. | `ffmpeg` | | `--cover-format` / `cover_format` | Cover format. | `jpg` | -| `--template-folder-album` / `template_folder_album` | Template folder for tracks that are part of an album. | `{album_artist}/{album}` | -| `--template-folder-compilation` / `template_folder_compilation` | Template folder for tracks that are part of a compilation album. | `Compilations/{album}` | -| `--template-file-single-disc` / `template_file_single_disc` | Template file for the tracks that are part of a single-disc album. | `{track:02d} {title}` | -| `--template-file-multi-disc` / `template_file_multi_disc` | Template file for the tracks that are part of a multi-disc album. | `{disc}-{track:02d} {title}` | -| `--template-folder-no-album` / `template_folder_no_album` | Template folder for the tracks that are not part of an album. | `{artist}/Unknown Album` | -| `--template-file-no-album` / `template_file_no_album` | Template file for the tracks that are not part of an album. | `{title}` | -| `--template-file-playlist` / `template_file_playlist` | Template file for the M3U8 playlist. | `Playlists/{playlist_artist}/{playlist_title}` | -| `--template-date` / `template_date` | Date tag template. | `%Y-%m-%dT%H:%M:%SZ` | +| `--album-folder-template` / `album_folder_template` | Template folder for tracks that are part of an album. | `{album_artist}/{album}` | +| `--compilation-folder-template` / `compilation_folder_template` | Template folder for tracks that are part of a compilation album. | `Compilations/{album}` | +| `--single-disc-folder-template` / `single_disc_folder_template` | Template file for the tracks that are part of a single-disc album. | `{track:02d} {title}` | +| `--multi-disc-folder-template` / `multi_disc_folder_template` | Template file for the tracks that are part of a multi-disc album. | `{disc}-{track:02d} {title}` | +| `--no-album-folder-template` / `no_album_folder_template` | Template folder for the tracks that are not part of an album. | `{artist}/Unknown Album` | +| `--no-album-file-template` / `no_album_file_template` | Template file for the tracks that are not part of an album. | `{title}` | +| `--playlist-file-template` / `playlist_file_template` | Template file for the M3U8 playlist. | `Playlists/{playlist_artist}/{playlist_title}` | +| `--date-tag-template` / `date_tag_template` | Date tag template. | `%Y-%m-%dT%H:%M:%SZ` | | `--exclude-tags` / `exclude_tags` | Comma-separated tags to exclude. | `null` | | `--cover-size` / `cover_size` | Cover size. | `1200` | | `--truncate` / `truncate` | Maximum length of the file/folder names. | `null` | -| `--database-path` / `database_path` | Path to the downloaded media database file. | `null` | | `--codec-song` / `codec_song` | Song codec. | `aac-legacy` | | `--synced-lyrics-format` / `synced_lyrics_format` | Synced lyrics format. | `lrc` | -| `--codec-music-video` / `codec_music_video` | Comma-separated music video codec priority. | `h264,h265` | -| `--remux-format-music-video` / `remux_format_music_video` | Music video remux format. | `m4v` | -| `--quality-post` / `quality_post` | Post video quality. | `best` | -| `--resolution` / `resolution` | Target video resolution for music videos. | `1080p` | +| `--no-synced-lyrics` / `no_synced_lyrics` | Don't download the synced lyrics. | `false` | +| `--synced-lyrics-only` / `synced_lyrics_only` | Download only the synced lyrics. | `false` | +| `--music-video-codec-priority` / `music_video_codec_priority` | Comma-separated music video codec priority. | `h265,h264` | +| `--music-video-remux-format` / `music_video_remux_format` | Music video remux format. | `m4v` | +| `--music-video-resolution` / `music_video_resolution` | Target video resolution for music videos. | `1080p` | +| `--uploaded-video-quality` / `uploaded_video_quality` | Upload videos quality. | `best` | | `--no-config-file`, `-n` / - | Do not use a config file. | `false` | ### Tags variables @@ -246,33 +245,63 @@ The following variables can be used in the template folders/files and/or in the - `png`: Lossless format. - `raw`: Raw cover without processing (requires `save_cover` to save separately). -### Database path - -You can specify any path for storing a database file of downloaded media. -This is useful if you want to avoid waiting for Gamdl to fetch metadata for checking if a media item has already been downloaded. - ## Embedding -Gamdl can be used as a library in Python scripts. Here's a basic example of downloading a song by its ID: +Gamdl can be used as an async library in Python scripts. Here's a basic example of downloading a song by its URL: ```python -from gamdl import AppleMusicApi, ItunesApi, Downloader, DownloaderSong +import asyncio - -apple_music_api = AppleMusicApi.from_netscape_cookies(cookies_path="cookies.txt") -itunes_api = ItunesApi( - storefront=apple_music_api.storefront, - language=apple_music_api.language, +from gamdl.api import AppleMusicApi +from gamdl.downloader import ( + AppleMusicBaseDownloader, + AppleMusicDownloader, + AppleMusicMusicVideoDownloader, + AppleMusicSongDownloader, + AppleMusicUploadedVideoDownloader, ) -downloader = Downloader( - apple_music_api=apple_music_api, - itunes_api=itunes_api, -) -downloader.set_cdm() -downloader_song = DownloaderSong(downloader=downloader) -for download_info in downloader_song.download(media_id="1624945512"): - # Process download_info as needed - pass +async def main(): + # Initialize the Apple Music API + api = AppleMusicApi.from_netscape_cookies(cookies_path="cookies.txt") + await api.setup() + + # Initialize the base downloader + base_downloader = AppleMusicBaseDownloader(apple_music_api=api) + base_downloader.setup() + + # Initialize the song downloader + song_downloader = AppleMusicSongDownloader(base_downloader) + song_downloader.setup() + + # Initialize the music video downloader + music_video_downloader = AppleMusicMusicVideoDownloader(base_downloader) + music_video_downloader.setup() + + # Initialize the uploaded video downloader + uploaded_video_downloader = AppleMusicUploadedVideoDownloader(base_downloader) + uploaded_video_downloader.setup() + + # Initialize the main downloader + downloader = AppleMusicDownloader( + base_downloader, + song_downloader, + music_video_downloader, + uploaded_video_downloader, + ) + + # Download a song by URL + url_info = downloader.get_url_info( + "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512" + ) + if url_info: + download_queue = await downloader.get_download_queue(url_info) + if download_queue: + for download_item in download_queue: + await downloader.download(download_item) + + +if __name__ == "__main__": + asyncio.run(main()) ``` diff --git a/gamdl/__init__.py b/gamdl/__init__.py index cc01d31..fe9bd36 100644 --- a/gamdl/__init__.py +++ b/gamdl/__init__.py @@ -1,8 +1 @@ -from .apple_music_api import AppleMusicApi -from .downloader import Downloader -from .downloader_music_video import DownloaderMusicVideo -from .downloader_post import DownloaderPost -from .downloader_song import DownloaderSong -from .itunes_api import ItunesApi - -__version__ = "2.6.5" +__version__ = "2.7" diff --git a/gamdl/__main__.py b/gamdl/__main__.py index 4e28416..34c69a1 100644 --- a/gamdl/__main__.py +++ b/gamdl/__main__.py @@ -1,3 +1,3 @@ -from .cli import main +from .cli.cli import main main() diff --git a/gamdl/api/__init__.py b/gamdl/api/__init__.py new file mode 100644 index 0000000..e498f3c --- /dev/null +++ b/gamdl/api/__init__.py @@ -0,0 +1,2 @@ +from .apple_music_api import AppleMusicApi +from .itunes_api import ItunesApi diff --git a/gamdl/api/apple_music_api.py b/gamdl/api/apple_music_api.py new file mode 100644 index 0000000..c749a3a --- /dev/null +++ b/gamdl/api/apple_music_api.py @@ -0,0 +1,448 @@ +import logging +import re +import typing +from http.cookiejar import MozillaCookieJar +from urllib.parse import parse_qs, urlparse + +import httpx + +from ..utils import raise_for_status, safe_json +from .constants import ( + AMP_API_URL, + APPLE_MUSIC_COOKIE_DOMAIN, + APPLE_MUSIC_HOMEPAGE_URL, + LICENSE_API_URL, + WEBPLAYBACK_API_URL, +) + +logger = logging.getLogger(__name__) + + +class AppleMusicApi: + def __init__( + self, + storefront: str = "us", + media_user_token: str | None = None, + language: str = "en-US", + ) -> None: + self.storefront = storefront + self.media_user_token = media_user_token + self.language = language + + @classmethod + def from_netscape_cookies( + cls, + cookies_path: str = "./cookies.txt", + language: str = "en-US", + ) -> "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 cls( + storefront=None, + media_user_token=media_user_token, + language=language, + ) + + async def setup(self) -> None: + await self._setup_client() + await self._setup_token() + await self._setup_account_info() + + async def _setup_client(self) -> None: + self.client = httpx.AsyncClient( + headers={ + "accept": "*/*", + "accept-language": "en-US", + "origin": APPLE_MUSIC_HOMEPAGE_URL, + "priority": "u=1, i", + "referer": APPLE_MUSIC_HOMEPAGE_URL, + "sec-ch-ua": '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-site", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", + }, + params={ + "l": self.language, + }, + follow_redirects=True, + transport=httpx.AsyncHTTPTransport(retries=3), + timeout=30.0, + ) + + async def _setup_token(self) -> None: + response = await self.client.get(APPLE_MUSIC_HOMEPAGE_URL) + raise_for_status(response) + home_page = response.text + + index_js_uri_match = re.search( + r"/(assets/index-legacy[~-][^/\"]+\.js)", + home_page, + ) + if not index_js_uri_match: + raise Exception("index.js URI not found in Apple Music homepage") + index_js_uri = index_js_uri_match.group(1) + + response = await self.client.get(f"{APPLE_MUSIC_HOMEPAGE_URL}/{index_js_uri}") + raise_for_status(response) + index_js_page = response.text + + token_match = re.search('(?=eyJh)(.*?)(?=")', index_js_page) + if not token_match: + raise Exception("Token not found in index.js page") + token = token_match.group(1) + + logger.debug(f"Token: {token}") + self.client.headers.update({"authorization": f"Bearer {token}"}) + + async def _setup_account_info(self) -> None: + if not self.media_user_token: + return + + self.client.cookies.update( + { + "media-user-token": self.media_user_token, + } + ) + + self.account_info = await self.get_account_info() + self.storefront = self.account_info["meta"]["subscription"]["storefront"] + + async def get_account_info(self, meta: str | None = "subscription") -> dict: + response = await self.client.get( + f"{AMP_API_URL}/v1/me/account", + params={ + **({"meta": meta} if meta else {}), + }, + ) + raise_for_status(response) + + account_info = safe_json(response) + if not "data" in account_info or (meta and "meta" not in account_info): + raise Exception("Error getting account info:", response.text) + logger.debug(f"Account info: {account_info}") + + return account_info + + async def get_song( + self, + song_id: str, + extend: str = "extendedAssetUrls", + include: str = "lyrics,albums", + ) -> dict | None: + response = await self.client.get( + f"{AMP_API_URL}/v1/catalog/{self.storefront}/songs/{song_id}", + params={ + "extend": extend, + "include": include, + }, + ) + raise_for_status(response, {200, 404}) + + if response.status_code == 404: + return None + + song = safe_json(response) + if not "data" in song: + raise Exception("Error getting song:", response.text) + logger.debug(f"Song: {song}") + + return song + + async def get_music_video( + self, + music_video_id: str, + include: str = "albums", + ) -> dict | None: + response = await self.client.get( + f"{AMP_API_URL}/v1/catalog/{self.storefront}/music-videos/{music_video_id}", + params={ + "include": include, + }, + ) + raise_for_status(response, {200, 404}) + + if response.status_code == 404: + return None + + music_video = safe_json(response) + if not "data" in music_video: + raise Exception("Error getting music video:", response.text) + logger.debug(f"Music video: {music_video}") + + return music_video + + async def get_uploaded_video( + self, + post_id: str, + ) -> dict | None: + response = await self.client.get( + f"{AMP_API_URL}/v1/catalog/{self.storefront}/uploaded-videos/{post_id}" + ) + raise_for_status(response, {200, 404}) + + if response.status_code == 404: + return None + + uploaded_video = safe_json(response) + if not "data" in uploaded_video: + raise Exception("Error getting uploaded video:", response.text) + logger.debug(f"Uploaded video: {uploaded_video}") + + return uploaded_video + + async def get_album( + self, + album_id: str, + extend: str = "extendedAssetUrls", + ) -> dict | None: + response = await self.client.get( + f"{AMP_API_URL}/v1/catalog/{self.storefront}/albums/{album_id}", + params={ + "extend": extend, + }, + ) + raise_for_status(response, {200, 404}) + + if response.status_code == 404: + return None + + album = safe_json(response) + if not "data" in album: + raise Exception("Error getting album:", response.text) + logger.debug(f"Album: {album}") + + return album + + async def get_playlist( + self, + playlist_id: str, + limit_tracks: int = 300, + extend: str = "extendedAssetUrls", + ) -> dict | None: + response = await self.client.get( + f"{AMP_API_URL}/v1/catalog/{self.storefront}/playlists/{playlist_id}", + params={ + "limit[tracks]": limit_tracks, + "extend": extend, + }, + ) + raise_for_status(response, {200, 404}) + + if response.status_code == 404: + return None + + playlist = safe_json(response) + if not "data" in playlist: + raise Exception("Error getting playlist:", response.text) + logger.debug(f"Playlist: {playlist}") + + return playlist + + async def get_artist( + self, + artist_id: str, + include: str = "albums,music-videos", + limit: int = 100, + ) -> dict | None: + response = await self.client.get( + f"{AMP_API_URL}/v1/catalog/{self.storefront}/artists/{artist_id}", + params={ + "include": include, + **{f"limit[{_include}]": limit for _include in include.split(",")}, + }, + ) + raise_for_status(response, {200, 404}) + + if response.status_code == 404: + return None + + artist = safe_json(response) + if not "data" in artist: + raise Exception("Error getting artist:", response.text) + logger.debug(f"Artist: {artist}") + + return artist + + async def get_library_album( + self, + album_id: str, + extend: str = "extendedAssetUrls", + ) -> dict | None: + response = await self.client.get( + f"{AMP_API_URL}/v1/me/library/albums/{album_id}", + params={ + "extend": extend, + }, + ) + raise_for_status(response, {200, 404}) + + if response.status_code == 404: + return None + + album = safe_json(response) + if not "data" in album: + raise Exception("Error getting library album:", response.text) + logger.debug(f"Library album: {album}") + + return album + + async def get_library_playlist( + self, + playlist_id: str, + include: str = "tracks", + limit: int = 100, + extend: str = "extendedAssetUrls", + ) -> dict | None: + response = await self.client.get( + f"{AMP_API_URL}/v1/me/library/playlists/{playlist_id}", + params={ + "include": include, + **{f"limit[{_include}]": limit for _include in include.split(",")}, + "extend": extend, + }, + ) + raise_for_status(response, {200, 404}) + + if response.status_code == 404: + return None + + playlist = safe_json(response) + if not "data" in playlist: + raise Exception("Error getting library playlist:", response.text) + + 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: + response = await self.client.get( + f"{AMP_API_URL}/v1/catalog/{self.storefront}/search", + params={ + "term": term, + "types": types, + "limit": limit, + "offset": offset, + }, + ) + raise_for_status(response) + + search_results = safe_json(response) + if not "results" in search_results: + raise Exception("Error searching:", response.text) + logger.debug(f"Search results: {search_results}") + + return search_results + + async def extend_api_data( + self, + api_response: dict, + extend: str = "extendedAssetUrls", + ) -> typing.AsyncGenerator[dict, None]: + next_uri = api_response.get("next") + if not next_uri: + return + + next_uri_params = parse_qs(urlparse(next_uri).query) + limit = int(next_uri_params["offset"][0]) + while next_uri: + extended_api_data = await self._get_extended_api_data( + next_uri, + limit, + extend, + ) + yield extended_api_data + next_uri = extended_api_data.get("next") + + async def _get_extended_api_data( + self, + next_uri: str, + limit: int, + extend: str, + ) -> dict: + response = await self.client.get( + AMP_API_URL + next_uri, + params={ + "limit": limit, + "extend": extend, + **parse_qs(urlparse(next_uri).query), + }, + ) + raise_for_status(response) + + extended_api_data = safe_json(response) + if not "data" in extended_api_data: + raise Exception("Error getting extended API data:", response.text) + logger.debug(f"Extended API data: {extended_api_data}") + + return extended_api_data + + async def get_webplayback( + self, + track_id: str, + ) -> dict: + response = await self.client.post( + WEBPLAYBACK_API_URL, + json={ + "salableAdamId": track_id, + "language": self.language, + }, + ) + raise_for_status(response) + + webplayback = safe_json(response) + if not "songList" in webplayback: + raise Exception("Error getting webplayback:", response.text) + logger.debug(f"Webplayback: {webplayback}") + + return webplayback + + async def get_license_exchange( + self, + track_id: str, + track_uri: str, + challenge: str, + key_system: str = "com.widevine.alpha", + ) -> dict: + response = await self.client.post( + LICENSE_API_URL, + json={ + "challenge": challenge, + "key-system": key_system, + "uri": track_uri, + "adamId": track_id, + "isLibrary": False, + "user-initiated": True, + }, + ) + raise_for_status(response) + + license_exchange = safe_json(response) + if not "license" in license_exchange: + raise Exception("Error getting license exchange:", response.text) + logger.debug(f"License exchange: {license_exchange}") + + return license_exchange diff --git a/gamdl/constants.py b/gamdl/api/constants.py similarity index 89% rename from gamdl/constants.py rename to gamdl/api/constants.py index 94d89c1..27180a1 100644 --- a/gamdl/constants.py +++ b/gamdl/api/constants.py @@ -1,3 +1,15 @@ +APPLE_MUSIC_HOMEPAGE_URL = "https://music.apple.com" +APPLE_MUSIC_COOKIE_DOMAIN = ".music.apple.com" +AMP_API_URL = "https://amp-api.music.apple.com" +WEBPLAYBACK_API_URL = ( + "https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback" +) +LICENSE_API_URL = ( + "https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/acquireWebPlaybackLicense" +) + +ITUNES_LOOKUP_API_URL = "https://itunes.apple.com/lookup" +ITUNES_PAGE_API_URL = "https://music.apple.com" STOREFRONT_IDS = { "AE": "143481-2,32", "AG": "143540-2,32", @@ -155,15 +167,3 @@ STOREFRONT_IDS = { "ZA": "143472-2,32", "ZW": "143605-2,32", } - - -EXCLUDED_CONFIG_FILE_PARAMS = ( - "urls", - "config_path", - "read_urls_as_txt", - "no_config_file", - "version", - "help", -) - -X_NOT_FOUND_STRING = '{} not found at "{}"' diff --git a/gamdl/api/itunes_api.py b/gamdl/api/itunes_api.py new file mode 100644 index 0000000..1adab15 --- /dev/null +++ b/gamdl/api/itunes_api.py @@ -0,0 +1,77 @@ +import logging + +import httpx + +from ..utils import raise_for_status, safe_json +from .constants import ITUNES_LOOKUP_API_URL, ITUNES_PAGE_API_URL, STOREFRONT_IDS + +logger = logging.getLogger(__name__) + + +class ItunesApi: + def __init__( + self, + storefront: str = "us", + language: str = "en-US", + ) -> None: + self.storefront = storefront + self.language = language + + def setup(self) -> None: + self._setup_storefront_id() + self._setup_session() + + def _setup_storefront_id(self) -> None: + try: + self.storefront_id = STOREFRONT_IDS[self.storefront.upper()] + except KeyError: + raise Exception(f"No storefront id for {self.storefront}") + + def _setup_session(self) -> None: + self.client = httpx.AsyncClient( + params={ + "country": self.storefront, + "lang": self.language, + }, + headers={ + "X-Apple-Store-Front": f"{self.storefront_id} t:music31", + }, + ) + + async def get_lookup_result( + self, + media_id: str, + entity: str = "album", + ) -> dict: + response = await self.client.get( + ITUNES_LOOKUP_API_URL, + params={ + "id": media_id, + "entity": entity, + }, + ) + raise_for_status(response) + + lookup_result = safe_json(response) + if "results" not in lookup_result: + raise Exception("Error getting lookup result:", response.text) + logger.debug(f"Lookup result: {lookup_result}") + + return lookup_result + + async def get_itunes_page( + self, + media_type: str, + media_id: str, + ) -> dict: + response = await self.client.get( + f"{ITUNES_PAGE_API_URL}/{media_type}/{media_id}" + ) + raise_for_status(response) + + itunes_page = safe_json(response) + if "storePlatformData" not in itunes_page: + raise Exception("Error getting iTunes page:", response.text) + logger.debug(f"iTunes page: {itunes_page}") + + return itunes_page diff --git a/gamdl/apple_music_api.py b/gamdl/apple_music_api.py deleted file mode 100644 index 89bca9a..0000000 --- a/gamdl/apple_music_api.py +++ /dev/null @@ -1,415 +0,0 @@ -from __future__ import annotations - -import functools -import re -import time -import typing -from http.cookiejar import MozillaCookieJar -from pathlib import Path -from urllib.parse import urlparse - -import requests - -from .utils import raise_response_exception - - -class AppleMusicApi: - APPLE_MUSIC_HOMEPAGE_URL = "https://music.apple.com" - AMP_API_URL = "https://amp-api.music.apple.com" - WEBPLAYBACK_API_URL = ( - "https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback" - ) - LICENSE_API_URL = "https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/acquireWebPlaybackLicense" - WAIT_TIME = 2 - - def __init__( - self, - storefront: str, - media_user_token: str | None = None, - language: str = "en-US", - ): - self.media_user_token = media_user_token - self.storefront = storefront - self.language = language - self._set_session() - - @classmethod - def from_netscape_cookies( - cls, - cookies_path: Path = Path("./cookies.txt"), - language: str = "en-US", - ) -> AppleMusicApi: - parse_cookie = lambda name: next( - ( - cookie.value - for cookie in cookies - if cookie.name == name - and cookie.domain.endswith( - urlparse(cls.APPLE_MUSIC_HOMEPAGE_URL).netloc - ) - ), - None, - ) - - cookies = MozillaCookieJar(cookies_path) - cookies.load(ignore_discard=True, ignore_expires=True) - - media_user_token = parse_cookie("media-user-token") - if not media_user_token: - raise ValueError( - '"media-user-token" cookie not found in cookies. ' - "Make sure you have exported the cookies from Apple Music webpage and are logged in " - "with an active subscription." - ) - - return cls( - storefront=None, - media_user_token=media_user_token, - language=language, - ) - - def _set_session(self): - self.session = requests.Session() - self.session.headers.update( - { - "accept": "*/*", - "accept-language": "en-US", - "origin": self.APPLE_MUSIC_HOMEPAGE_URL, - "priority": "u=1, i", - "referer": self.APPLE_MUSIC_HOMEPAGE_URL, - "sec-ch-ua": '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"', - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": '"Windows"', - "sec-fetch-dest": "empty", - "sec-fetch-mode": "cors", - "sec-fetch-site": "same-site", - "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", - } - ) - - home_page = self.session.get(self.APPLE_MUSIC_HOMEPAGE_URL).text - index_js_uri = re.search( - r"/(assets/index-legacy[~-][^/\"]+\.js)", - home_page, - ).group(1) - index_js_page = self.session.get( - f"{self.APPLE_MUSIC_HOMEPAGE_URL}/{index_js_uri}" - ).text - token = re.search('(?=eyJh)(.*?)(?=")', index_js_page).group(1) - - self.session.headers.update({"authorization": f"Bearer {token}"}) - self.session.params = {"l": self.language} - - if self.media_user_token: - self.session.cookies.update( - { - "media-user-token": self.media_user_token, - } - ) - self._set_account_info() - - def _set_account_info(self): - self.account_info = self.get_account_info() - self.storefront = self.account_info["meta"]["subscription"]["storefront"] - - def _check_amp_api_response(self, response: requests.Response) -> None: - try: - response.raise_for_status() - response_dict = response.json() - assert response_dict.get("data") or response_dict.get("results") is not None - except ( - requests.HTTPError, - requests.exceptions.JSONDecodeError, - AssertionError, - ): - raise_response_exception(response) - - def get_account_info(self, meta: str = "subscription") -> dict: - response = self.session.get( - f"{self.AMP_API_URL}/v1/me/account", - params={"meta": meta}, - ) - self._check_amp_api_response(response) - - return response.json() - - def get_artist( - self, - artist_id: str, - include: str = "albums,music-videos", - limit: int = 100, - fetch_all: bool = True, - ) -> dict | None: - response = self.session.get( - f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/artists/{artist_id}", - params={ - "include": include, - **{f"limit[{_include}]": limit for _include in include.split(",")}, - }, - ) - if response.status_code == 404: - return None - self._check_amp_api_response(response) - - artist = response.json()["data"][0] - if fetch_all: - for _include in include.split(","): - for additional_data in self._extend_api_data( - artist["relationships"][_include], - limit, - "", - ): - artist["relationships"][_include]["data"].extend(additional_data) - return artist - - def get_song( - self, - song_id: str, - extend: str = "extendedAssetUrls", - include: str = "lyrics,albums", - ) -> dict | None: - response = self.session.get( - f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/songs/{song_id}", - params={ - "include": include, - "extend": extend, - }, - ) - if response.status_code == 404: - return None - self._check_amp_api_response(response) - - return response.json()["data"][0] - - def get_music_video( - self, - music_video_id: str, - include: str = "albums", - ) -> dict | None: - response = self.session.get( - f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/music-videos/{music_video_id}", - params={ - "include": include, - }, - ) - if response.status_code == 404: - return None - self._check_amp_api_response(response) - - return response.json()["data"][0] - - def get_post( - self, - post_id: str, - ) -> dict | None: - response = self.session.get( - f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/uploaded-videos/{post_id}" - ) - if response.status_code == 404: - return None - self._check_amp_api_response(response) - - return response.json()["data"][0] - - @functools.lru_cache() - def get_album( - self, - album_id: str, - extend: str = "extendedAssetUrls", - ) -> dict | None: - response = self.session.get( - f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/albums/{album_id}", - params={ - "extend": extend, - }, - ) - if response.status_code == 404: - return None - self._check_amp_api_response(response) - - return response.json()["data"][0] - - def get_playlist( - self, - playlist_id: str, - limit_tracks: int = 300, - extend: str = "extendedAssetUrls", - fetch_all: bool = True, - ) -> dict | None: - response = self.session.get( - f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/playlists/{playlist_id}", - params={ - "extend": extend, - "limit[tracks]": limit_tracks, - }, - ) - if response.status_code == 404: - return None - self._check_amp_api_response(response) - - playlist = response.json()["data"][0] - if fetch_all: - for additional_data in self._extend_api_data( - playlist["relationships"]["tracks"], - limit_tracks, - extend, - ): - playlist["relationships"]["tracks"]["data"].extend(additional_data) - return playlist - - def search( - self, - term: str, - types: str = "songs,albums,artists,playlists", - limit: int = 25, - offset: int = 0, - ) -> dict | None: - response = self.session.get( - f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/search", - params={ - "term": term, - "types": types, - "limit": limit, - "offset": offset, - }, - ) - if response.status_code == 404: - return None - self._check_amp_api_response(response) - - return response.json()["results"] - - def get_library_album( - self, - album_id: str, - extend: str = "extendedAssetUrls", - ) -> dict | None: - response = self.session.get( - f"{self.AMP_API_URL}/v1/me/library/albums/{album_id}", - params={ - "extend": extend, - }, - ) - if response.status_code == 404: - return None - self._check_amp_api_response(response) - - return response.json()["data"][0] - - def get_library_playlist( - self, - playlist_id: str, - include: str = "tracks", - limit: int = 100, - extend: str = "extendedAssetUrls", - fetch_all: bool = True, - ) -> dict | None: - response = self.session.get( - f"{self.AMP_API_URL}/v1/me/library/playlists/{playlist_id}", - params={ - "include": include, - **{f"limit[{_include}]": limit for _include in include.split(",")}, - "extend": extend, - }, - ) - if response.status_code == 404: - return None - self._check_amp_api_response(response) - - playlist = response.json()["data"][0] - if fetch_all: - for additional_data in self._extend_api_data( - playlist["relationships"]["tracks"], - limit, - extend, - ): - playlist["relationships"]["tracks"]["data"].extend(additional_data) - return playlist - - def _extend_api_data( - self, - api_response: dict, - limit: int, - extend: str, - ) -> typing.Generator[list[dict], None, None]: - next_uri = api_response.get("next") - while next_uri: - playlist_next = self._get_next_uri_response(next_uri, limit, extend) - yield playlist_next["data"] - next_uri = playlist_next.get("next") - time.sleep(self.WAIT_TIME) - - def _get_next_uri_response( - self, - next_uri: str, - limit: int, - extend: str, - ) -> dict: - response = self.session.get( - self.AMP_API_URL + next_uri, - params={ - "limit": limit, - "extend": extend, - }, - ) - self._check_amp_api_response(response) - - return response.json() - - def get_webplayback( - self, - track_id: str, - ) -> dict: - response = self.session.post( - self.WEBPLAYBACK_API_URL, - json={ - "salableAdamId": track_id, - "language": self.language, - }, - ) - - try: - response.raise_for_status() - response_dict = response.json() - webplayback = response_dict.get("songList") - assert webplayback - except ( - requests.HTTPError, - requests.exceptions.JSONDecodeError, - AssertionError, - ): - raise_response_exception(response) - - return webplayback[0] - - def get_widevine_license( - self, - track_id: str, - track_uri: str, - challenge: str, - ) -> str: - response = self.session.post( - self.LICENSE_API_URL, - json={ - "challenge": challenge, - "key-system": "com.widevine.alpha", - "uri": track_uri, - "adamId": track_id, - "isLibrary": False, - "user-initiated": True, - }, - ) - - try: - response.raise_for_status() - response_dict = response.json() - widevine_license = response_dict.get("license") - assert widevine_license - except ( - requests.HTTPError, - requests.exceptions.JSONDecodeError, - AssertionError, - ): - raise_response_exception(response) - - return widevine_license diff --git a/gamdl/cli.py b/gamdl/cli.py deleted file mode 100644 index 1b2f35f..0000000 --- a/gamdl/cli.py +++ /dev/null @@ -1,653 +0,0 @@ -from __future__ import annotations - -import inspect -import logging -import typing -from pathlib import Path - -import click -import colorama - -from . import __version__ -from .apple_music_api import AppleMusicApi -from .config_file import ConfigFile -from .constants import * -from .custom_logger_formatter import CustomLoggerFormatter -from .downloader import Downloader -from .downloader_music_video import DownloaderMusicVideo -from .downloader_post import DownloaderPost -from .downloader_song import DownloaderSong -from .enums import ( - CoverFormat, - DownloadMode, - MusicVideoCodec, - MusicVideoResolution, - PostQuality, - RemuxFormatMusicVideo, - RemuxMode, - SongCodec, - SyncedLyricsFormat, -) -from .exceptions import * -from .itunes_api import ItunesApi -from .utils import color_text, prompt_path - -apple_music_api_from_netscape_cookies_sig = inspect.signature( - AppleMusicApi.from_netscape_cookies -) -downloader_sig = inspect.signature(Downloader.__init__) -downloader_song_sig = inspect.signature(DownloaderSong.__init__) -downloader_music_video_sig = inspect.signature(DownloaderMusicVideo.__init__) -downloader_post_sig = inspect.signature(DownloaderPost.__init__) - -logger = logging.getLogger("gamdl") - - -class Csv(click.ParamType): - name = "csv" - - def __init__( - self, - subtype: typing.Any, - ) -> None: - self.subtype = subtype - - def convert( - self, - value: str | typing.Any, - param: click.Parameter, - ctx: click.Context, - ) -> list[typing.Any]: - if not isinstance(value, str): - return value - items = [v.strip() for v in value.split(",") if v.strip()] - result = [] - for item in items: - try: - result.append(self.subtype(item)) - except ValueError as e: - self.fail( - f"'{item}' is not a valid value for {self.subtype.__name__}", - param, - ctx, - ) - return result - - -def load_config_file( - ctx: click.Context, - param: click.Parameter, - no_config_file: bool, -) -> click.Context: - if no_config_file: - return ctx - - filtered_params = [ - param - for param in ctx.command.params - if param.name not in EXCLUDED_CONFIG_FILE_PARAMS - ] - - config_file = ConfigFile(ctx.params["config_path"]) - config_file.add_params_default_to_config( - filtered_params, - ) - parsed_params = config_file.parse_params_from_config( - [ - param - for param in filtered_params - if ctx.get_parameter_source(param.name) - != click.core.ParameterSource.COMMANDLINE - ] - ) - ctx.params.update(parsed_params) - - return ctx - - -@click.command() -@click.help_option("-h", "--help") -@click.version_option(__version__, "-v", "--version") -# CLI specific options -@click.argument( - "urls", - nargs=-1, - type=str, - required=True, -) -@click.option( - "--disable-music-video-skip", - is_flag=True, - help="Don't skip downloading music videos in albums/playlists.", -) -@click.option( - "--read-urls-as-txt", - "-r", - is_flag=True, - help="Interpret URLs as paths to text files containing URLs separated by newlines", -) -@click.option( - "--config-path", - type=Path, - default=Path.home() / ".gamdl" / "config.ini", - help="Path to config file.", -) -@click.option( - "--log-level", - type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]), - default="INFO", - help="Log level.", -) -@click.option( - "--no-exceptions", - is_flag=True, - help="Don't print exceptions.", -) -# API specific options -@click.option( - "--cookies-path", - "-c", - type=Path, - default=apple_music_api_from_netscape_cookies_sig.parameters[ - "cookies_path" - ].default, - help="Path to .txt cookies file.", -) -@click.option( - "--language", - "-l", - type=str, - default=apple_music_api_from_netscape_cookies_sig.parameters["language"].default, - help="Metadata language as an ISO-2A language code (don't always work for videos).", -) -# Downloader specific options -@click.option( - "--output-path", - "-o", - type=Path, - default=downloader_sig.parameters["output_path"].default, - help="Path to output directory.", -) -@click.option( - "--temp-path", - type=Path, - default=downloader_sig.parameters["temp_path"].default, - help="Path to temporary directory.", -) -@click.option( - "--wvd-path", - type=Path, - default=downloader_sig.parameters["wvd_path"].default, - help="Path to .wvd file.", -) -@click.option( - "--overwrite", - is_flag=True, - help="Overwrite existing files.", - default=downloader_sig.parameters["overwrite"].default, -) -@click.option( - "--save-cover", - "-s", - is_flag=True, - help="Save cover as a separate file.", - default=downloader_sig.parameters["save_cover"].default, -) -@click.option( - "--save-playlist", - is_flag=True, - help="Save a M3U8 playlist file when downloading a playlist.", - default=downloader_sig.parameters["save_playlist"].default, -) -@click.option( - "--no-synced-lyrics", - is_flag=True, - help="Don't download the synced lyrics.", - default=downloader_sig.parameters["no_synced_lyrics"].default, -) -@click.option( - "--synced-lyrics-only", - is_flag=True, - help="Download only the synced lyrics.", - default=downloader_sig.parameters["synced_lyrics_only"].default, -) -@click.option( - "--nm3u8dlre-path", - type=str, - default=downloader_sig.parameters["nm3u8dlre_path"].default, - help="Path to N_m3u8DL-RE binary.", -) -@click.option( - "--mp4decrypt-path", - type=str, - default=downloader_sig.parameters["mp4decrypt_path"].default, - help="Path to mp4decrypt binary.", -) -@click.option( - "--ffmpeg-path", - type=str, - default=downloader_sig.parameters["ffmpeg_path"].default, - help="Path to FFmpeg binary.", -) -@click.option( - "--mp4box-path", - type=str, - default=downloader_sig.parameters["mp4box_path"].default, - help="Path to MP4Box binary.", -) -@click.option( - "--download-mode", - type=DownloadMode, - default=downloader_sig.parameters["download_mode"].default, - help="Download mode.", -) -@click.option( - "--remux-mode", - type=RemuxMode, - default=downloader_sig.parameters["remux_mode"].default, - help="Remux mode.", -) -@click.option( - "--cover-format", - type=CoverFormat, - default=downloader_sig.parameters["cover_format"].default, - help="Cover format.", -) -@click.option( - "--template-folder-album", - type=str, - default=downloader_sig.parameters["template_folder_album"].default, - help="Template folder for tracks that are part of an album.", -) -@click.option( - "--template-folder-compilation", - type=str, - default=downloader_sig.parameters["template_folder_compilation"].default, - help="Template folder for tracks that are part of a compilation album.", -) -@click.option( - "--template-file-single-disc", - type=str, - default=downloader_sig.parameters["template_file_single_disc"].default, - help="Template file for the tracks that are part of a single-disc album.", -) -@click.option( - "--template-file-multi-disc", - type=str, - default=downloader_sig.parameters["template_file_multi_disc"].default, - help="Template file for the tracks that are part of a multi-disc album.", -) -@click.option( - "--template-folder-no-album", - type=str, - default=downloader_sig.parameters["template_folder_no_album"].default, - help="Template folder for the tracks that are not part of an album.", -) -@click.option( - "--template-file-no-album", - type=str, - default=downloader_sig.parameters["template_file_no_album"].default, - help="Template file for the tracks that are not part of an album.", -) -@click.option( - "--template-file-playlist", - type=str, - default=downloader_sig.parameters["template_file_playlist"].default, - help="Template file for the M3U8 playlist.", -) -@click.option( - "--template-date", - type=str, - default=downloader_sig.parameters["template_date"].default, - help="Date tag template.", -) -@click.option( - "--exclude-tags", - type=Csv(str), - default=downloader_sig.parameters["exclude_tags"].default, - help="Comma-separated tags to exclude.", -) -@click.option( - "--cover-size", - type=int, - default=downloader_sig.parameters["cover_size"].default, - help="Cover size.", -) -@click.option( - "--truncate", - type=int, - default=downloader_sig.parameters["truncate"].default, - help="Maximum length of the file/folder names.", -) -@click.option( - "--database-path", - type=Path, - default=downloader_sig.parameters["database_path"].default, - help="Path to the downloaded media database file.", -) -# DownloaderSong specific options -@click.option( - "--codec-song", - type=SongCodec, - default=downloader_song_sig.parameters["codec"].default, - help="Song codec.", -) -@click.option( - "--synced-lyrics-format", - type=SyncedLyricsFormat, - default=downloader_song_sig.parameters["synced_lyrics_format"].default, - help="Synced lyrics format.", -) -# DownloaderMusicVideo specific options -@click.option( - "--codec-music-video", - type=Csv(MusicVideoCodec), - default=downloader_music_video_sig.parameters["codec"].default, - help="Comma-separated music video codec priority.", -) -@click.option( - "--remux-format-music-video", - type=RemuxFormatMusicVideo, - default=downloader_music_video_sig.parameters["remux_format"].default, - help="Music video remux format.", -) -@click.option( - "--resolution", - type=MusicVideoResolution, - default=downloader_music_video_sig.parameters["resolution"].default, - help="Target video resolution for music videos.", -) -# DownloaderPost specific options -@click.option( - "--quality-post", - type=PostQuality, - default=downloader_post_sig.parameters["quality"].default, - help="Post video quality.", -) -# This option should always be last -@click.option( - "--no-config-file", - "-n", - is_flag=True, - callback=load_config_file, - help="Do not use a config file.", -) -def main( - urls: list[str], - disable_music_video_skip: bool, - read_urls_as_txt: bool, - config_path: Path, - log_level: str, - no_exceptions: bool, - cookies_path: Path, - language: str, - output_path: Path, - temp_path: Path, - wvd_path: Path, - overwrite: bool, - save_cover: bool, - save_playlist: bool, - no_synced_lyrics: bool, - synced_lyrics_only: bool, - nm3u8dlre_path: str, - mp4decrypt_path: str, - ffmpeg_path: str, - mp4box_path: str, - download_mode: DownloadMode, - remux_mode: RemuxMode, - cover_format: CoverFormat, - template_folder_album: str, - template_folder_compilation: str, - template_file_single_disc: str, - template_file_multi_disc: str, - template_folder_no_album: str, - template_file_no_album: str, - template_file_playlist: str, - template_date: str, - exclude_tags: list[str], - cover_size: int, - truncate: int, - database_path: Path, - codec_song: SongCodec, - synced_lyrics_format: SyncedLyricsFormat, - codec_music_video: list[MusicVideoCodec], - remux_format_music_video: RemuxFormatMusicVideo, - resolution: MusicVideoResolution, - quality_post: PostQuality, - no_config_file: bool, -): - colorama.just_fix_windows_console() - - logger.setLevel(log_level) - stream_handler = logging.StreamHandler() - stream_handler.setFormatter(CustomLoggerFormatter()) - logger.addHandler(stream_handler) - - cookies_path = prompt_path(True, cookies_path, "Cookies file") - if wvd_path: - wvd_path = prompt_path(True, wvd_path, ".wvd file") - - logger.info("Starting Gamdl") - apple_music_api = AppleMusicApi.from_netscape_cookies( - cookies_path, - language, - ) - if not apple_music_api.account_info["meta"]["subscription"]["active"]: - logger.critical( - "No active Apple Music subscription found, you won't be able to download" - " anything" - ) - return - if apple_music_api.account_info["data"][0]["attributes"].get("restrictions"): - logger.warning( - "Your account has content restrictions enabled, some content may not be" - " downloadable" - ) - - itunes_api = ItunesApi( - apple_music_api.storefront, - apple_music_api.language, - ) - - downloader = Downloader( - apple_music_api, - itunes_api, - output_path, - temp_path, - wvd_path, - overwrite, - save_cover, - save_playlist, - no_synced_lyrics, - synced_lyrics_only, - nm3u8dlre_path, - mp4decrypt_path, - ffmpeg_path, - mp4box_path, - download_mode, - remux_mode, - cover_format, - template_folder_album, - template_folder_compilation, - template_file_single_disc, - template_file_multi_disc, - template_folder_no_album, - template_file_no_album, - template_file_playlist, - template_date, - exclude_tags, - cover_size, - truncate, - database_path, - log_level in ("WARNING", "ERROR"), - ) - - downloader_song = DownloaderSong( - downloader, - codec_song, - synced_lyrics_format, - ) - downloader_music_video = DownloaderMusicVideo( - downloader, - codec_music_video, - remux_format_music_video, - resolution, - ) - - downloader_post = DownloaderPost( - downloader, - quality_post, - ) - - skip_mv = False - - if not synced_lyrics_only: - logger.debug("Setting up CDM") - downloader.set_cdm() - - if not downloader.ffmpeg_path_full and ( - remux_mode == RemuxMode.FFMPEG or download_mode == DownloadMode.NM3U8DLRE - ): - logger.critical(X_NOT_FOUND_STRING.format("ffmpeg", ffmpeg_path)) - return - - if not downloader.mp4box_path_full and remux_mode == RemuxMode.MP4BOX: - logger.critical(X_NOT_FOUND_STRING.format("MP4Box", mp4box_path)) - return - - if ( - not downloader.mp4decrypt_path_full - and codec_song - not in ( - SongCodec.AAC_LEGACY, - SongCodec.AAC_HE_LEGACY, - ) - or (remux_mode == RemuxMode.MP4BOX and not downloader.mp4decrypt_path_full) - ): - logger.critical(X_NOT_FOUND_STRING.format("mp4decrypt", mp4decrypt_path)) - return - - if ( - download_mode == DownloadMode.NM3U8DLRE - and not downloader.nm3u8dlre_path_full - ): - logger.critical(X_NOT_FOUND_STRING.format("N_m3u8DL-RE", nm3u8dlre_path)) - return - - if not downloader.mp4decrypt_path_full: - logger.warning( - X_NOT_FOUND_STRING.format("mp4decrypt", mp4decrypt_path) - + ", music videos will not be downloaded" - ) - skip_mv = True - - if not codec_song.is_legacy(): - logger.warning( - "You have chosen an experimental song codec. " - "They're not guaranteed to work due to API limitations." - ) - - if read_urls_as_txt: - _urls = [] - for url in urls: - if Path(url).exists(): - _urls.extend(Path(url).read_text(encoding="utf-8").splitlines()) - urls = _urls - - error_count = 0 - - for url_index, url in enumerate(urls, start=1): - url_progress = color_text(f"URL {url_index}/{len(urls)}", colorama.Style.DIM) - try: - logger.info(f'({url_progress}) Processing "{url}"') - url_info = downloader.parse_url_info(url) - - if not url_info: - error_count += 1 - logger.error(f"({url_progress}) Invalid URL, skipping") - continue - - download_queue = downloader.get_download_queue(url_info) - - if not download_queue: - error_count += 1 - logger.error(f"({url_progress}) Media not found, skipping") - continue - - download_queue_medias_metadata = download_queue.medias_metadata - except Exception as e: - error_count += 1 - logger.error( - f'({url_progress}) Failed to process URL "{url}", skipping', - exc_info=not no_exceptions, - ) - continue - for download_index, media_metadata in enumerate( - download_queue_medias_metadata, - start=1, - ): - queue_progress = color_text( - f"Track {download_index}/{len(download_queue_medias_metadata)} from URL {url_index}/{len(urls)}", - colorama.Style.DIM, - ) - try: - logger.info( - f'({queue_progress}) "{media_metadata["attributes"]["name"]}"' - ) - - if ( - ( - synced_lyrics_only - and media_metadata["type"] not in {"songs", "library-songs"} - ) - or (media_metadata["type"] == "music-videos" and skip_mv) - or ( - media_metadata["type"] == "music-videos" - and url_info.type == "album" - and not disable_music_video_skip - ) - ): - logger.warning( - f"({queue_progress}) Track is not downloadable with current configuration, skipping" - ) - continue - - if media_metadata["type"] in {"songs", "library-songs"}: - for _ in downloader_song.download( - media_metadata=media_metadata, - playlist_attributes=download_queue.playlist_attributes, - playlist_track=download_index, - ): - pass - - if media_metadata["type"] in {"music-videos", "library-music-videos"}: - for _ in downloader_music_video.download( - media_metadata=media_metadata, - playlist_attributes=download_queue.playlist_attributes, - playlist_track=download_index, - ): - pass - - if media_metadata["type"] == "uploaded-videos": - for _ in downloader_post.download( - media_metadata=media_metadata, - ): - pass - except KeyboardInterrupt: - exit(0) - except ( - MediaNotStreamableException, - MediaFileAlreadyExistsException, - MediaFormatNotAvailableException, - ) as e: - logger.warning( - f"({queue_progress}) {e}, skipping", - ) - except Exception as e: - error_count += 1 - logger.error( - f'({queue_progress}) Failed to download "{media_metadata["attributes"]["name"]}"', - exc_info=not no_exceptions, - ) - - logger.info(f"Done, {error_count} error(s) occurred") diff --git a/gamdl/cli/__init__.py b/gamdl/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gamdl/cli/cli.py b/gamdl/cli/cli.py new file mode 100644 index 0000000..ef7bf12 --- /dev/null +++ b/gamdl/cli/cli.py @@ -0,0 +1,573 @@ +import inspect +import logging +from pathlib import Path + +import click + +from .. import __version__ +from ..api import AppleMusicApi +from ..downloader import ( + AppleMusicBaseDownloader, + AppleMusicDownloader, + AppleMusicMusicVideoDownloader, + AppleMusicSongDownloader, + AppleMusicUploadedVideoDownloader, + CoverFormat, + DownloadItem, + DownloadMode, + MediaDownloadConfigurationError, + MediaFormatNotAvailableError, + MediaNotStreamableError, + RemuxFormatMusicVideo, + RemuxMode, +) +from ..interface import ( + MusicVideoCodec, + MusicVideoResolution, + SongCodec, + SyncedLyricsFormat, + UploadedVideoQuality, +) +from .constants import X_NOT_IN_PATH +from .custom_logger_formatter import CustomLoggerFormatter +from .utils import Csv, PathPrompt, load_config_file, make_sync + +logger = logging.getLogger(__name__) + +api_sig = inspect.signature(AppleMusicApi.from_netscape_cookies) +base_downloader_sig = inspect.signature(AppleMusicBaseDownloader.__init__) +music_video_downloader_sig = inspect.signature(AppleMusicMusicVideoDownloader.__init__) +song_downloader_sig = inspect.signature(AppleMusicSongDownloader.__init__) +uploaded_video_downloader_sig = inspect.signature( + AppleMusicUploadedVideoDownloader.__init__ +) + + +@click.command() +@click.help_option("-h", "--help") +@click.version_option(__version__, "-v", "--version") +# CLI specific options +@click.argument( + "urls", + nargs=-1, + type=str, + required=True, +) +@click.option( + "--read-urls-as-txt", + "-r", + is_flag=True, + help="Interpret URLs as paths to text files containing URLs separated by newlines", +) +@click.option( + "--config-path", + type=click.Path(file_okay=True, dir_okay=False, writable=True, resolve_path=True), + default=str(Path.home() / ".gamdl" / "config.ini"), + help="Path to config file.", +) +@click.option( + "--log-level", + type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]), + default="INFO", + help="Log level.", +) +@click.option( + "--log-file", + type=click.Path(file_okay=True, dir_okay=False, writable=True, resolve_path=True), + default=None, + help="Path to log file.", +) +@click.option( + "--no-exceptions", + is_flag=True, + help="Don't print exceptions.", +) +# API specific options +@click.option( + "--cookies-path", + "-c", + type=PathPrompt(is_file=True), + default=api_sig.parameters["cookies_path"].default, + help="Path to .txt cookies file.", +) +@click.option( + "--language", + "-l", + type=str, + default=api_sig.parameters["language"].default, + help="Metadata language as an ISO-2A language code (don't always work for videos).", +) +# Base Downloader specific options +@click.option( + "--output-path", + "-o", + type=click.Path(file_okay=False, dir_okay=True, writable=True, resolve_path=True), + default=base_downloader_sig.parameters["output_path"].default, + help="Path to output directory.", +) +@click.option( + "--temp-path", + type=click.Path(file_okay=False, dir_okay=True, writable=True, resolve_path=True), + default=base_downloader_sig.parameters["temp_path"].default, + help="Path to temporary directory.", +) +@click.option( + "--wvd-path", + type=click.Path(file_okay=False, dir_okay=True, writable=True, resolve_path=True), + default=base_downloader_sig.parameters["wvd_path"].default, + help="Path to .wvd file.", +) +@click.option( + "--overwrite", + is_flag=True, + help="Overwrite existing files.", + default=base_downloader_sig.parameters["overwrite"].default, +) +@click.option( + "--save-cover", + "-s", + is_flag=True, + help="Save cover as a separate file.", + default=base_downloader_sig.parameters["save_cover"].default, +) +@click.option( + "--save-playlist", + is_flag=True, + help="Save a M3U8 playlist file when downloading a playlist.", + default=base_downloader_sig.parameters["save_playlist"].default, +) +@click.option( + "--nm3u8dlre-path", + type=str, + default=base_downloader_sig.parameters["nm3u8dlre_path"].default, + help="Path to N_m3u8DL-RE binary.", +) +@click.option( + "--mp4decrypt-path", + type=str, + default=base_downloader_sig.parameters["mp4decrypt_path"].default, + help="Path to mp4decrypt binary.", +) +@click.option( + "--ffmpeg-path", + type=str, + default=base_downloader_sig.parameters["ffmpeg_path"].default, + help="Path to FFmpeg binary.", +) +@click.option( + "--mp4box-path", + type=str, + default=base_downloader_sig.parameters["mp4box_path"].default, + help="Path to MP4Box binary.", +) +@click.option( + "--download-mode", + type=DownloadMode, + default=base_downloader_sig.parameters["download_mode"].default, + help="Download mode.", +) +@click.option( + "--remux-mode", + type=RemuxMode, + default=base_downloader_sig.parameters["remux_mode"].default, + help="Remux mode.", +) +@click.option( + "--cover-format", + type=CoverFormat, + default=base_downloader_sig.parameters["cover_format"].default, + help="Cover format.", +) +@click.option( + "--album-folder-template", + type=str, + default=base_downloader_sig.parameters["album_folder_template"].default, + help="Template folder for tracks that are part of an album.", +) +@click.option( + "--compilation-folder-template", + type=str, + default=base_downloader_sig.parameters["compilation_folder_template"].default, + help="Template folder for tracks that are part of a compilation album.", +) +@click.option( + "--single-disc-folder-template", + type=str, + default=base_downloader_sig.parameters["single_disc_folder_template"].default, + help="Template file for the tracks that are part of a single-disc album.", +) +@click.option( + "--multi-disc-folder-template", + type=str, + default=base_downloader_sig.parameters["multi_disc_folder_template"].default, + help="Template file for the tracks that are part of a multi-disc album.", +) +@click.option( + "--no-album-folder-template", + type=str, + default=base_downloader_sig.parameters["no_album_folder_template"].default, + help="Template folder for the tracks that are not part of an album.", +) +@click.option( + "--no-album-file-template", + type=str, + default=base_downloader_sig.parameters["no_album_file_template"].default, + help="Template file for the tracks that are not part of an album.", +) +@click.option( + "--playlist-file-template", + type=str, + default=base_downloader_sig.parameters["playlist_file_template"].default, + help="Template file for the M3U8 playlist.", +) +@click.option( + "--date-tag-template", + type=str, + default=base_downloader_sig.parameters["date_tag_template"].default, + help="Date tag template.", +) +@click.option( + "--exclude-tags", + type=Csv(str), + default=base_downloader_sig.parameters["exclude_tags"].default, + help="Comma-separated tags to exclude.", +) +@click.option( + "--cover-size", + type=int, + default=base_downloader_sig.parameters["cover_size"].default, + help="Cover size.", +) +@click.option( + "--truncate", + type=int, + default=base_downloader_sig.parameters["truncate"].default, + help="Maximum length of the file/folder names.", +) +# DownloaderSong specific options +@click.option( + "--codec-song", + type=SongCodec, + default=song_downloader_sig.parameters["codec"].default, + help="Song codec.", +) +@click.option( + "--synced-lyrics-format", + type=SyncedLyricsFormat, + default=song_downloader_sig.parameters["synced_lyrics_format"].default, + help="Synced lyrics format.", +) +@click.option( + "--no-synced-lyrics", + is_flag=True, + help="Don't download the synced lyrics.", + default=song_downloader_sig.parameters["no_synced_lyrics"].default, +) +@click.option( + "--synced-lyrics-only", + is_flag=True, + help="Download only the synced lyrics.", + default=song_downloader_sig.parameters["synced_lyrics_only"].default, +) +# DownloaderMusicVideo specific options +@click.option( + "--music-video-codec-priority", + type=Csv(MusicVideoCodec), + default=music_video_downloader_sig.parameters["codec_priority"].default, + help="Comma-separated music video codec priority.", +) +@click.option( + "--music-video-remux-format", + type=RemuxFormatMusicVideo, + default=music_video_downloader_sig.parameters["remux_format"].default, + help="Music video remux format.", +) +@click.option( + "--music-video-resolution", + type=MusicVideoResolution, + default=music_video_downloader_sig.parameters["resolution"].default, + help="Target video resolution for music videos.", +) +# DownloaderUploadedVideo specific options +@click.option( + "--uploaded-video-quality", + type=UploadedVideoQuality, + default=uploaded_video_downloader_sig.parameters["quality"].default, + help="Upload videos quality.", +) +# This option should always be last +@click.option( + "--no-config-file", + "-n", + is_flag=True, + callback=load_config_file, + help="Do not use a config file.", +) +@make_sync +async def main( + urls: list[str], + read_urls_as_txt: bool, + config_path: str, + log_level: str, + log_file: str, + no_exceptions: bool, + cookies_path: str, + language: str, + output_path: str, + temp_path: str, + wvd_path: str, + overwrite: bool, + save_cover: bool, + save_playlist: bool, + nm3u8dlre_path: str, + mp4decrypt_path: str, + ffmpeg_path: str, + mp4box_path: str, + download_mode: DownloadMode, + remux_mode: RemuxMode, + cover_format: CoverFormat, + album_folder_template: str, + compilation_folder_template: str, + single_disc_folder_template: str, + multi_disc_folder_template: str, + no_album_folder_template: str, + no_album_file_template: str, + playlist_file_template: str, + date_tag_template: str, + exclude_tags: list[str], + cover_size: int, + truncate: int, + codec_song: SongCodec, + synced_lyrics_format: SyncedLyricsFormat, + no_synced_lyrics: bool, + synced_lyrics_only: bool, + music_video_codec_priority: list[MusicVideoCodec], + music_video_remux_format: RemuxFormatMusicVideo, + music_video_resolution: MusicVideoResolution, + uploaded_video_quality: UploadedVideoQuality, + *args, + **kwargs, +): + root_logger = logging.getLogger(__name__.split(".")[0]) + root_logger.setLevel(log_level) + root_logger.propagate = False + + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(CustomLoggerFormatter()) + root_logger.addHandler(stream_handler) + + if log_file: + file_handler = logging.FileHandler(log_file, encoding="utf-8") + file_handler.setFormatter(CustomLoggerFormatter(use_colors=False)) + root_logger.addHandler(file_handler) + + logger.info(f"Starting Gamdl {__version__}") + + api = AppleMusicApi.from_netscape_cookies( + cookies_path=cookies_path, + language=language, + ) + await api.setup() + + if not api.account_info["meta"]["subscription"]["active"]: + logger.critical( + "No active Apple Music subscription found, you won't be able to download" + " anything" + ) + return + if api.account_info["data"][0]["attributes"].get("restrictions"): + logger.warning( + "Your account has content restrictions enabled, some content may not be" + " downloadable" + ) + + base_downloader = AppleMusicBaseDownloader( + apple_music_api=api, + output_path=output_path, + temp_path=temp_path, + wvd_path=wvd_path, + overwrite=overwrite, + save_cover=save_cover, + save_playlist=save_playlist, + nm3u8dlre_path=nm3u8dlre_path, + mp4decrypt_path=mp4decrypt_path, + ffmpeg_path=ffmpeg_path, + mp4box_path=mp4box_path, + download_mode=download_mode, + remux_mode=remux_mode, + cover_format=cover_format, + album_folder_template=album_folder_template, + compilation_folder_template=compilation_folder_template, + single_disc_folder_template=single_disc_folder_template, + multi_disc_folder_template=multi_disc_folder_template, + no_album_folder_template=no_album_folder_template, + no_album_file_template=no_album_file_template, + playlist_file_template=playlist_file_template, + date_tag_template=date_tag_template, + exclude_tags=exclude_tags, + cover_size=cover_size, + truncate=truncate, + ) + base_downloader.setup() + + song_downloader = AppleMusicSongDownloader( + base_downloader, + codec=codec_song, + synced_lyrics_format=synced_lyrics_format, + no_synced_lyrics=no_synced_lyrics, + synced_lyrics_only=synced_lyrics_only, + ) + song_downloader.setup() + + music_video_downloader = AppleMusicMusicVideoDownloader( + base_downloader, + codec_priority=music_video_codec_priority, + remux_format=music_video_remux_format, + resolution=music_video_resolution, + ) + music_video_downloader.setup() + + uploaded_video_downloader = AppleMusicUploadedVideoDownloader( + base_downloader, + quality=uploaded_video_quality, + ) + uploaded_video_downloader.setup() + + downloader = AppleMusicDownloader( + base_downloader, + song_downloader, + music_video_downloader, + uploaded_video_downloader, + ) + + if not synced_lyrics_only: + if not base_downloader.full_ffmpeg_path and ( + remux_mode == RemuxMode.FFMPEG or download_mode == DownloadMode.NM3U8DLRE + ): + logger.critical(X_NOT_IN_PATH.format("ffmpeg", ffmpeg_path)) + return + + if not base_downloader.full_mp4box_path and remux_mode == RemuxMode.MP4BOX: + logger.critical(X_NOT_IN_PATH.format("MP4Box", mp4box_path)) + return + + if ( + not base_downloader.full_mp4decrypt_path + and codec_song + not in ( + SongCodec.AAC_LEGACY, + SongCodec.AAC_HE_LEGACY, + ) + or ( + remux_mode == RemuxMode.MP4BOX + and not base_downloader.full_mp4decrypt_path + ) + ): + logger.critical(X_NOT_IN_PATH.format("mp4decrypt", mp4decrypt_path)) + return + + if ( + download_mode == DownloadMode.NM3U8DLRE + and not base_downloader.full_nm3u8dlre_path + ): + logger.critical(X_NOT_IN_PATH.format("N_m3u8DL-RE", nm3u8dlre_path)) + return + + if not base_downloader.full_mp4decrypt_path: + logger.warning( + X_NOT_IN_PATH.format("mp4decrypt", mp4decrypt_path) + + ", music videos will not be downloaded" + ) + downloader.skip_music_videos = True + + if not codec_song.is_legacy(): + logger.warning( + "You have chosen an experimental song codec. " + "They're not guaranteed to work due to API limitations." + ) + + if read_urls_as_txt: + urls_from_file = [] + for url in 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 + + error_count = 0 + for url_index, url in enumerate(urls, 1): + url_progress = click.style(f"[URL {url_index}/{len(urls)}]", dim=True) + logger.info(url_progress + f' Processing "{url}"') + download_queue = None + try: + url_info = downloader.get_url_info(url) + if not url_info: + logger.warning( + url_progress + f' Could not parse "{url}", skipping.', + ) + continue + + download_queue = await downloader.get_download_queue(url_info) + if not download_queue: + logger.warning( + url_progress + + f' No downloadable media found for "{url}", skipping.', + ) + continue + except KeyboardInterrupt: + exit(1) + except Exception as e: + error_count += 1 + logger.error( + url_progress + f' Error processing "{url}"', + exc_info=not no_exceptions, + ) + + if not download_queue: + continue + + for download_index, download_item in enumerate( + download_queue, + 1, + ): + download_queue_progress = click.style( + f"[Track {download_index}/{len(download_queue)}]", + dim=True, + ) + media_title = ( + download_item.media_metadata["attributes"]["name"] + if isinstance( + download_item, + DownloadItem, + ) + else "Unknown Title" + ) + logger.info(download_queue_progress + f' Downloading "{media_title}"') + + try: + await downloader.download(download_item) + except ( + FileExistsError, + MediaNotStreamableError, + MediaFormatNotAvailableError, + MediaDownloadConfigurationError, + ) as e: + logger.warning( + download_queue_progress + f' Skipping "{media_title}": {e}' + ) + continue + except KeyboardInterrupt: + exit(1) + except Exception as e: + error_count += 1 + logger.error( + download_queue_progress + f' Error downloading "{media_title}"', + exc_info=not no_exceptions, + ) + + logger.info(f"Finished with {error_count} error(s)") diff --git a/gamdl/config_file.py b/gamdl/cli/config_file.py similarity index 87% rename from gamdl/config_file.py rename to gamdl/cli/config_file.py index a2d2e39..9713687 100644 --- a/gamdl/config_file.py +++ b/gamdl/cli/config_file.py @@ -1,17 +1,17 @@ -from __future__ import annotations - import configparser +import typing from enum import Enum from pathlib import Path import click -import typing + +from .constants import EXCLUDED_CONFIG_FILE_PARAMS class ConfigFile: def __init__( self, - config_path: Path, + config_path: str, section_name: str = "gamdl", ) -> None: self.config_path = config_path @@ -22,16 +22,16 @@ class ConfigFile: def _read_config_file(self) -> None: self.config = configparser.ConfigParser(interpolation=None) - if self.config_path.exists(): + if Path(self.config_path).exists(): self.config.read(self.config_path, encoding="utf-8") else: - self.config_path.parent.mkdir(parents=True, exist_ok=True) + 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 self.config_path.open("w", encoding="utf-8") as config_file: + 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: @@ -84,6 +84,9 @@ class ConfigFile: has_changes = False for param in 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: diff --git a/gamdl/cli/constants.py b/gamdl/cli/constants.py new file mode 100644 index 0000000..a07301d --- /dev/null +++ b/gamdl/cli/constants.py @@ -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 "{}"' diff --git a/gamdl/cli/custom_logger_formatter.py b/gamdl/cli/custom_logger_formatter.py new file mode 100644 index 0000000..7b403b2 --- /dev/null +++ b/gamdl/cli/custom_logger_formatter.py @@ -0,0 +1,30 @@ +import logging + +import click + + +class CustomLoggerFormatter(logging.Formatter): + base_format = "[%(levelname)-8s %(asctime)s]" + format_colors = { + logging.DEBUG: dict(dim=True), + logging.INFO: dict(fg="green"), + logging.WARNING: dict(fg="yellow"), + logging.ERROR: dict(fg="red"), + logging.CRITICAL: dict(fg="red", bold=True), + } + date_format = "%H:%M:%S" + + def __init__(self, use_colors: bool = True) -> None: + super().__init__() + self.use_colors = use_colors + + def format(self, record: logging.LogRecord) -> str: + return logging.Formatter( + ( + click.style(self.base_format, **self.format_colors.get(record.levelno)) + if self.use_colors + else self.base_format + ) + + " %(message)s", + datefmt=self.date_format, + ).format(record) diff --git a/gamdl/cli/utils.py b/gamdl/cli/utils.py new file mode 100644 index 0000000..2e4b3ce --- /dev/null +++ b/gamdl/cli/utils.py @@ -0,0 +1,112 @@ +import asyncio +import typing +from functools import wraps +from pathlib import Path +import click +from .config_file import ConfigFile + + +class Csv(click.ParamType): + name = "csv" + + def __init__( + self, + subtype: typing.Any, + ) -> None: + self.subtype = subtype + + def convert( + self, + value: str | typing.Any, + param: click.Parameter, + ctx: click.Context, + ) -> list[typing.Any]: + if not isinstance(value, str): + return value + + items = [v.strip() for v in value.split(",") if v.strip()] + result = [] + + for item in items: + try: + result.append(self.subtype(item)) + except ValueError as e: + self.fail( + f"'{item}' is not a valid value for {self.subtype.__name__}", + param, + ctx, + ) + return result + + +class PathPrompt(click.ParamType): + name = "path" + + def __init__(self, is_file: bool = False) -> None: + self.is_file = is_file + + def convert( + self, + value: str | typing.Any, + param: click.Parameter, + ctx: click.Context, + ) -> str: + if not isinstance(value, str): + return value + + path_validator = click.Path( + exists=True, + file_okay=self.is_file, + dir_okay=not self.is_file, + ) + path_type = "file" if self.is_file else "directory" + while True: + try: + result = path_validator.convert(value, None, None) + break + except click.BadParameter as e: + value = click.prompt( + ( + f'{path_type.capitalize()} "{Path(value).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=value, + show_default=False, + ) + value = value.strip('"') + return result + + +def load_config_file( + ctx: click.Context, + param: click.Parameter, + no_config_file: bool, +) -> click.Context: + if no_config_file: + return ctx + + config_file = ConfigFile(ctx.params["config_path"]) + config_file.add_params_default_to_config( + ctx.command.params, + ) + parsed_params = config_file.parse_params_from_config( + [ + param + for param in ctx.command.params + if ctx.get_parameter_source(param.name) + != click.core.ParameterSource.COMMANDLINE + ] + ) + ctx.params.update(parsed_params) + + return ctx + + +def make_sync(func): + @wraps(func) + def wrapper(*args, **kwargs): + return asyncio.run(func(*args, **kwargs)) + + return wrapper diff --git a/gamdl/custom_logger_formatter.py b/gamdl/custom_logger_formatter.py deleted file mode 100644 index bfcd95c..0000000 --- a/gamdl/custom_logger_formatter.py +++ /dev/null @@ -1,24 +0,0 @@ -import logging - -import colorama - -from .utils import color_text - - -class CustomLoggerFormatter(logging.Formatter): - base_format = "[%(levelname)-8s %(asctime)s]" - format_colors = { - logging.DEBUG: colorama.Style.DIM, - logging.INFO: colorama.Fore.GREEN, - logging.WARNING: colorama.Fore.YELLOW, - logging.ERROR: colorama.Fore.RED, - logging.CRITICAL: colorama.Fore.RED, - } - date_format = "%H:%M:%S" - - def format(self, record: logging.LogRecord) -> str: - return logging.Formatter( - color_text(self.base_format, self.format_colors.get(record.levelno)) - + " %(message)s", - datefmt=self.date_format, - ).format(record) diff --git a/gamdl/database.py b/gamdl/database.py deleted file mode 100644 index 4f41343..0000000 --- a/gamdl/database.py +++ /dev/null @@ -1,50 +0,0 @@ -import sqlite3 -from pathlib import Path - - -class Database: - INITIAL_QUERY = """ - CREATE TABLE IF NOT EXISTS media ( - media_id TEXT PRIMARY KEY, - media_path TEXT NOT NULL - ) - """ - ADD_MEDIA_QUERY = """ - INSERT OR REPLACE INTO media (media_id, media_path) VALUES (?, ?) - """ - GET_MEDIA_QUERY = """ - SELECT media_path FROM media WHERE media_id = ? - """ - - def __init__(self, file_path: Path): - self.file_path = file_path - self._initialize_db() - - def _initialize_db(self): - self.file_path.parent.mkdir(parents=True, exist_ok=True) - - with sqlite3.connect(self.file_path) as conn: - conn.execute(self.INITIAL_QUERY) - conn.commit() - - def add_media(self, media_id: str, media_path: Path): - with sqlite3.connect(self.file_path) as conn: - conn.execute( - self.ADD_MEDIA_QUERY, - ( - media_id, - str(media_path.absolute()), - ), - ) - conn.commit() - - def get_media(self, media_id: str) -> Path | None: - with sqlite3.connect(self.file_path) as conn: - cursor = conn.execute( - self.GET_MEDIA_QUERY, - (media_id,), - ) - result = cursor.fetchone() - if result: - return Path(result[0]) - return None diff --git a/gamdl/downloader.py b/gamdl/downloader.py deleted file mode 100644 index ba4066b..0000000 --- a/gamdl/downloader.py +++ /dev/null @@ -1,818 +0,0 @@ -from __future__ import annotations - -import base64 -import datetime -import functools -import io -import logging -import re -import shutil -import subprocess -import typing -import urllib.parse -import uuid -from pathlib import Path - -import colorama -import requests -from InquirerPy import inquirer -from InquirerPy.base.control import Choice -from mutagen.mp4 import MP4, MP4Cover -from PIL import Image -from pywidevine import PSSH, Cdm, Device -from yt_dlp import YoutubeDL - -from .apple_music_api import AppleMusicApi -from .database import Database -from .enums import CoverFormat, DownloadMode, MediaFileFormat, RemuxMode -from .hardcoded_wvd import HARDCODED_WVD -from .itunes_api import ItunesApi -from .models import ( - DecryptionKey, - DownloadInfo, - DownloadQueue, - MediaTags, - PlaylistTags, - UrlInfo, -) -from .utils import color_text, raise_response_exception - -logger = logging.getLogger("gamdl") - - -class Downloader: - ILLEGAL_CHARS_RE = r'[\\/:*?"<>|;]' - ILLEGAL_CHAR_REPLACEMENT = "_" - VALID_URL_RE = ( - r"(" - r"/(?P[a-z]{2})" - r"/(?Partist|album|playlist|song|music-video|post)" - r"(?:/(?P[^\s/]+))?" - r"/(?P[0-9]+|pl\.[0-9a-z]{32}|pl\.u-[a-zA-Z0-9]+)" - r"(?:\?i=(?P[0-9]+))?" - r")|(" - r"(?:/(?P[a-z]{2}))?" - r"/library/(?P|playlist|albums)" - r"/(?Pp\.[a-zA-Z0-9]{15}|l\.[a-zA-Z0-9]{7})" - r")" - ) - IMAGE_FILE_EXTENSION_MAP = { - "jpeg": ".jpg", - "tiff": ".tif", - } - - def __init__( - self, - apple_music_api: AppleMusicApi, - itunes_api: ItunesApi, - output_path: Path = Path("./Apple Music"), - temp_path: Path = Path("."), - wvd_path: Path = None, - overwrite: bool = False, - save_cover: bool = False, - save_playlist: bool = False, - no_synced_lyrics: bool = False, - synced_lyrics_only: bool = False, - nm3u8dlre_path: str = "N_m3u8DL-RE", - mp4decrypt_path: str = "mp4decrypt", - ffmpeg_path: str = "ffmpeg", - mp4box_path: str = "MP4Box", - download_mode: DownloadMode = DownloadMode.YTDLP, - remux_mode: RemuxMode = RemuxMode.FFMPEG, - cover_format: CoverFormat = CoverFormat.JPG, - template_folder_album: str = "{album_artist}/{album}", - template_folder_compilation: str = "Compilations/{album}", - template_file_single_disc: str = "{track:02d} {title}", - template_file_multi_disc: str = "{disc}-{track:02d} {title}", - template_folder_no_album: str = "{artist}/Unknown Album", - template_file_no_album: str = "{title}", - template_file_playlist: str = "Playlists/{playlist_artist}/{playlist_title}", - template_date: str = "%Y-%m-%dT%H:%M:%SZ", - exclude_tags: list[str] = None, - cover_size: int = 1200, - truncate: int = None, - database_path: Path = None, - silent: bool = False, - skip_processing: bool = False, - ): - self.apple_music_api = apple_music_api - self.itunes_api = itunes_api - self.output_path = output_path - self.temp_path = temp_path - self.wvd_path = wvd_path - self.overwrite = overwrite - self.save_cover = save_cover - self.save_playlist = save_playlist - self.no_synced_lyrics = no_synced_lyrics - self.synced_lyrics_only = synced_lyrics_only - self.nm3u8dlre_path = nm3u8dlre_path - self.mp4decrypt_path = mp4decrypt_path - self.ffmpeg_path = ffmpeg_path - self.mp4box_path = mp4box_path - self.download_mode = download_mode - self.remux_mode = remux_mode - self.cover_format = cover_format - self.template_folder_album = template_folder_album - self.template_folder_compilation = template_folder_compilation - self.template_file_single_disc = template_file_single_disc - self.template_file_multi_disc = template_file_multi_disc - self.template_folder_no_album = template_folder_no_album - self.template_file_no_album = template_file_no_album - self.template_file_playlist = template_file_playlist - self.template_date = template_date - self.exclude_tags = exclude_tags - self.cover_size = cover_size - self.truncate = truncate - self.database_path = database_path - self.silent = silent - self.skip_processing = skip_processing - self._set_temp_path() - self._set_exclude_tags() - self._set_binaries_path_full() - self._set_truncate() - self._set_database() - self._set_subprocess_additional_args() - - def _set_temp_path(self): - random_suffix = uuid.uuid4().hex[:8] - self.temp_path_generated = self.temp_path / f"gamdl_temp_{random_suffix}" - - def _set_exclude_tags(self): - self.exclude_tags = self.exclude_tags if self.exclude_tags is not None else [] - - def _set_binaries_path_full(self): - self.nm3u8dlre_path_full = shutil.which(self.nm3u8dlre_path) - self.ffmpeg_path_full = shutil.which(self.ffmpeg_path) - self.mp4box_path_full = shutil.which(self.mp4box_path) - self.mp4decrypt_path_full = shutil.which(self.mp4decrypt_path) - - def _set_truncate(self): - if self.truncate is not None: - self.truncate = None if self.truncate < 4 else self.truncate - - def _set_database(self): - if self.database_path is not None: - self.database = Database(self.database_path) - else: - self.database = None - - def _set_subprocess_additional_args(self): - if self.silent: - self.subprocess_additional_args = { - "stdout": subprocess.DEVNULL, - "stderr": subprocess.DEVNULL, - } - else: - self.subprocess_additional_args = {} - - def set_cdm(self): - if self.wvd_path: - self.cdm = Cdm.from_device(Device.load(self.wvd_path)) - else: - self.cdm = Cdm.from_device(Device.loads(HARDCODED_WVD)) - - def parse_url_info(self, url: str) -> UrlInfo | None: - url = urllib.parse.unquote(url) - - url_regex_result = re.search( - self.VALID_URL_RE, - url, - ) - if not url_regex_result: - return None - - return UrlInfo( - **url_regex_result.groupdict(), - ) - - def get_download_queue(self, url_info: UrlInfo) -> DownloadQueue: - return self._get_download_queue( - "song" if url_info.sub_id else url_info.type, - url_info.sub_id or url_info.id or url_info.library_id, - url_info.library_id is not None, - ) - - def _get_download_queue( - self, - url_type: str, - id: str, - is_library: bool, - ) -> DownloadQueue | None: - download_queue = DownloadQueue() - - if url_type == "artist": - artist = self.apple_music_api.get_artist(id) - - if artist is None: - return None - - download_queue.medias_metadata = list( - self.get_download_queue_from_artist(artist) - ) - - if url_type == "song": - song = self.apple_music_api.get_song(id) - - if song is None: - return None - - download_queue.medias_metadata = [song] - - if url_type in {"album", "albums"}: - if is_library: - album = self.apple_music_api.get_library_album(id) - else: - album = self.apple_music_api.get_album(id) - - if album is None: - return None - - download_queue.medias_metadata = [ - track for track in album["relationships"]["tracks"]["data"] - ] - - if url_type == "playlist": - if is_library: - playlist = self.apple_music_api.get_library_playlist(id) - else: - playlist = self.apple_music_api.get_playlist(id) - - if playlist is None: - return None - - download_queue.medias_metadata = [ - track for track in playlist["relationships"]["tracks"]["data"] - ] - download_queue.playlist_attributes = playlist["attributes"] - - if url_type == "music-video": - music_video = self.apple_music_api.get_music_video(id) - - if music_video is None: - return None - - download_queue.medias_metadata = [music_video] - - if url_type == "post": - post = self.apple_music_api.get_post(id) - - if post is None: - return None - - download_queue.medias_metadata = [post] - - return download_queue - - def get_download_queue_from_artist( - self, - artist: dict, - ) -> typing.Generator[dict, None, None]: - media_type = inquirer.select( - message=f'Select which type to download for artist "{artist["attributes"]["name"]}":', - choices=[ - Choice(name="Albums", value="albums"), - Choice( - name="Music Videos", - value="music-videos", - ), - ], - validate=lambda result: artist["relationships"].get(result, {}).get("data"), - invalid_message="The artist doesn't have any items of this type", - ).execute() - if media_type == "albums": - yield from self.select_albums_from_artist( - artist["relationships"]["albums"]["data"] - ) - elif media_type == "music-videos": - yield from self.select_music_videos_from_artist( - artist["relationships"]["music-videos"]["data"] - ) - - def select_albums_from_artist( - self, - albums: list[dict], - ) -> typing.Generator[dict, None, None]: - choices = [ - Choice( - name=" | ".join( - [ - f'{album["attributes"]["trackCount"]:03d}', - f'{album["attributes"]["releaseDate"]:<10}', - f'{album["attributes"].get("contentRating", "None").title():<8}', - f'{album["attributes"]["name"]}', - ] - ), - value=album, - ) - for album in albums - ] - selected = inquirer.select( - message="Select which albums to download: (Track Count | Release Date | Rating | Title)", - choices=choices, - multiselect=True, - ).execute() - for album in selected: - for track in self.apple_music_api.get_album(album["id"])["relationships"][ - "tracks" - ]["data"]: - yield track - - def select_music_videos_from_artist( - self, - music_videos: list[dict], - ) -> typing.Generator[dict, None, None]: - choices = [ - Choice( - name=" | ".join( - [ - self.millis_to_min_sec( - music_video["attributes"]["durationInMillis"] - ), - f'{music_video["attributes"].get("contentRating", "None").title():<8}', - music_video["attributes"]["name"], - ], - ), - value=music_video, - ) - for music_video in music_videos - ] - selected = inquirer.select( - message="Select which music videos to download: (Duration | Rating | Title)", - choices=choices, - multiselect=True, - ).execute() - for music_video in selected: - yield music_video - - def get_media_id_of_library_media( - self, - library_media_metadata: dict, - ) -> str: - play_params = library_media_metadata["attributes"].get("playParams", {}) - return play_params.get("catalogId", library_media_metadata["id"]) - - def is_media_streamable( - self, - media_metadata: dict, - ) -> bool: - return bool(media_metadata["attributes"].get("playParams")) - - def get_database_final_path(self, media_id: str) -> Path | None: - if self.database is None: - return - - final_path_database = self.database.get_media(media_id) - if ( - final_path_database is not None - and final_path_database.exists() - and not self.overwrite - ): - return final_path_database - - def get_playlist_tags( - self, - playlist_attributes: dict, - playlist_track: int, - ) -> PlaylistTags: - return PlaylistTags( - playlist_artist=playlist_attributes.get("curatorName", "Unknown"), - playlist_id=playlist_attributes["playParams"]["id"], - playlist_title=playlist_attributes["name"], - playlist_track=playlist_track, - ) - - def get_playlist_file_path( - self, - tags: PlaylistTags, - ) -> Path: - template_file = self.template_file_playlist.split("/") - tags_dict = tags.__dict__.copy() - - return Path( - self.output_path, - *[ - self.get_sanitized_string(i.format(**tags_dict), True) - for i in template_file[0:-1] - ], - *[ - self.get_sanitized_string(template_file[-1].format(**tags_dict), False) - + ".m3u8" - ], - ) - - def update_playlist_file( - self, - playlist_file_path: Path, - final_path: Path, - playlist_track: int, - ): - playlist_file_path.parent.mkdir(parents=True, exist_ok=True) - playlist_file_path_parent_parts_len = len(playlist_file_path.parent.parts) - output_path_parts_len = len(self.output_path.parts) - final_path_relative = Path( - ("../" * (playlist_file_path_parent_parts_len - output_path_parts_len)), - *final_path.parts[output_path_parts_len:], - ) - playlist_file_lines = ( - playlist_file_path.open("r", encoding="utf8").readlines() - if playlist_file_path.exists() - else [] - ) - if len(playlist_file_lines) < playlist_track: - playlist_file_lines.extend( - "\n" for _ in range(playlist_track - len(playlist_file_lines)) - ) - playlist_file_lines[playlist_track - 1] = final_path_relative.as_posix() + "\n" - with playlist_file_path.open("w", encoding="utf8") as playlist_file: - playlist_file.writelines(playlist_file_lines) - - @staticmethod - def millis_to_min_sec(millis) -> str: - minutes, seconds = divmod(millis // 1000, 60) - return f"{minutes:02d}:{seconds:02d}" - - def parse_date(self, date: str) -> datetime.datetime: - return datetime.datetime.fromisoformat(date.split("Z")[0]) - - def get_decryption_key(self, pssh: str, track_id: str) -> DecryptionKey: - try: - cdm_session = self.cdm.open() - - pssh_obj = PSSH(pssh.split(",")[-1]) - - challenge = base64.b64encode( - self.cdm.get_license_challenge(cdm_session, pssh_obj) - ).decode() - license = self.apple_music_api.get_widevine_license( - track_id, - pssh, - challenge, - ) - - self.cdm.parse_license(cdm_session, license) - decryption_key_info = next( - i for i in self.cdm.get_keys(cdm_session) if i.type == "CONTENT" - ) - finally: - self.cdm.close(cdm_session) - return DecryptionKey( - key=decryption_key_info.key.hex(), - kid=decryption_key_info.kid.hex, - ) - - def download(self, path: Path, stream_url: str): - if self.download_mode == DownloadMode.YTDLP: - self.download_ytdlp(path, stream_url) - elif self.download_mode == DownloadMode.NM3U8DLRE: - self.download_nm3u8dlre(path, stream_url) - - def download_ytdlp(self, path: Path, stream_url: str): - with YoutubeDL( - { - "quiet": True, - "no_warnings": True, - "outtmpl": str(path), - "allow_unplayable_formats": True, - "fixup": "never", - "allowed_extractors": ["generic"], - "noprogress": self.silent, - } - ) as ydl: - ydl.download(stream_url) - - def download_nm3u8dlre(self, path: Path, stream_url: str): - path.parent.mkdir(parents=True, exist_ok=True) - subprocess.run( - [ - self.nm3u8dlre_path_full, - stream_url, - "--binary-merge", - "--no-log", - "--log-level", - "off", - "--ffmpeg-binary-path", - self.ffmpeg_path_full, - "--save-name", - path.stem, - "--save-dir", - path.parent, - "--tmp-dir", - path.parent, - ], - check=True, - **self.subprocess_additional_args, - ) - - def get_sanitized_string(self, dirty_string: str, is_folder: bool) -> str: - dirty_string = re.sub( - self.ILLEGAL_CHARS_RE, - self.ILLEGAL_CHAR_REPLACEMENT, - dirty_string, - ) - if is_folder: - dirty_string = dirty_string[: self.truncate] - if dirty_string.endswith("."): - dirty_string = dirty_string[:-1] + self.ILLEGAL_CHAR_REPLACEMENT - else: - if self.truncate is not None: - dirty_string = dirty_string[: self.truncate - 4] - return dirty_string.strip() - - def get_media_file_extension( - self, - media_file_format: MediaFileFormat, - ) -> str: - return "." + media_file_format.value - - def get_temp_path( - self, - media_id: str, - tag: str, - file_extension: str, - ): - temp_path = self.temp_path_generated / (f"{media_id}_{tag}" + file_extension) - return temp_path - - def get_final_path( - self, - tags: MediaTags, - file_extension: str, - playlist_tags: PlaylistTags, - ) -> Path: - if tags.album is not None: - template_folder = ( - self.template_folder_compilation.split("/") - if tags.compilation - else self.template_folder_album.split("/") - ) - template_file = ( - self.template_file_multi_disc.split("/") - if tags.disc_total > 1 - else self.template_file_single_disc.split("/") - ) - else: - template_folder = self.template_folder_no_album.split("/") - template_file = self.template_file_no_album.split("/") - - template_final = template_folder + template_file - - tags_dict = tags.__dict__.copy() - if playlist_tags: - tags_dict.update(playlist_tags.__dict__) - - return Path( - self.output_path, - *[ - self.get_sanitized_string(i.format(**tags_dict), True) - for i in template_final[0:-1] - ], - ( - self.get_sanitized_string(template_final[-1].format(**tags_dict), False) - + file_extension - ), - ) - - def get_cover_format(self, cover_url: str) -> str | None: - cover_bytes = self.get_cover_bytes(cover_url) - if cover_bytes is None: - return None - image_obj = Image.open(io.BytesIO(self.get_cover_bytes(cover_url))) - image_format = image_obj.format.lower() - return image_format - - def get_cover_file_extension(self, cover_format: str) -> str: - return self.IMAGE_FILE_EXTENSION_MAP.get( - cover_format, - f".{cover_format.lower()}", - ) - - def get_cover_url(self, metadata: dict) -> str: - if self.cover_format == CoverFormat.RAW: - return self._get_raw_cover_url(metadata["attributes"]["artwork"]["url"]) - return self._get_cover_url(metadata["attributes"]["artwork"]["url"]) - - def _get_raw_cover_url(self, cover_url_template: str) -> str: - return re.sub( - r"image/thumb/", - "", - re.sub( - r"is1-ssl", - "a1", - re.sub( - r"/\{w\}x\{h\}([a-z]{2})\.jpg", - "", - cover_url_template, - ), - ), - ) - - def _get_cover_url(self, cover_url_template: str) -> str: - return re.sub( - r"\{w\}x\{h\}([a-z]{2})\.jpg", - f"{self.cover_size}x{self.cover_size}bb.{self.cover_format.value}", - cover_url_template, - ) - - @staticmethod - @functools.lru_cache() - def get_cover_bytes(url: str) -> bytes | None: - response = requests.get(url) - if response.status_code == 200: - return response.content - elif response.status_code in (404, 400): - return None - else: - raise_response_exception(response) - return response.content - - def apply_tags( - self, - path: Path, - tags: MediaTags, - cover_url: str, - ): - filtered_tags = MediaTags( - **{ - k: v - for k, v in tags.__dict__.items() - if v is not None and k not in self.exclude_tags - } - ) - mp4_tags = filtered_tags.to_mp4_tags(self.template_date) - skip_tagging = "all" in self.exclude_tags - - mp4 = MP4(path) - mp4.clear() - if not skip_tagging: - if ( - "cover" not in self.exclude_tags - and self.cover_format != CoverFormat.RAW - ): - self._apply_cover(mp4, cover_url) - mp4.update(mp4_tags) - mp4.save() - - def _apply_cover( - self, - mp4: MP4, - cover_url: str, - ) -> None: - cover_bytes = self.get_cover_bytes(cover_url) - if cover_bytes is None: - return - mp4["covr"] = [ - MP4Cover( - data=cover_bytes, - imageformat=( - MP4Cover.FORMAT_JPEG - if self.cover_format == CoverFormat.JPG - else MP4Cover.FORMAT_PNG - ), - ) - ] - - def move_to_output_path( - self, - staged_path: Path, - final_path: Path, - ): - final_path.parent.mkdir(parents=True, exist_ok=True) - shutil.move(staged_path, final_path) - - @functools.lru_cache() - def write_cover(self, cover_path: Path, cover_url: str): - cover_path.parent.mkdir(parents=True, exist_ok=True) - cover_path.write_bytes(self.get_cover_bytes(cover_url)) - - def write_synced_lyrics( - self, - synced_lyrics_path: Path, - synced_lyrics: str, - ): - synced_lyrics_path.parent.mkdir(parents=True, exist_ok=True) - synced_lyrics_path.write_text( - synced_lyrics, - encoding="utf8", - ) - - def cleanup_temp_path(self) -> None: - if self.temp_path_generated.exists(): - shutil.rmtree(self.temp_path_generated) - - def _final_processing_wrapper( - self, - func, - *args, - **kwargs, - ) -> typing.Generator[DownloadInfo, None, None]: - exception = None - download_info = None - try: - for download_info in func(*args, **kwargs): - yield download_info - except Exception as e: - exception = e - finally: - if download_info is not None and isinstance(download_info, DownloadInfo): - self._final_processing( - download_info, - ) - - if exception is not None: - raise exception - - def _final_processing( - self, - download_info: DownloadInfo, - ) -> None: - if self.skip_processing: - return - - if download_info.media_id: - colored_media_id = color_text( - download_info.media_id, - colorama.Style.DIM, - ) - else: - colored_media_id = color_text( - "Unknown", - colorama.Style.DIM, - ) - - if download_info.staged_path: - logger.debug( - f'[{colored_media_id}] Applying tags to "{download_info.staged_path}"' - ) - self.apply_tags( - download_info.staged_path, - download_info.tags, - download_info.cover_url, - ) - logger.debug( - f'[{colored_media_id}] Moving "{download_info.staged_path}" to "{download_info.final_path}"' - ) - self.move_to_output_path( - download_info.staged_path, - download_info.final_path, - ) - logger.info(f"[{colored_media_id}] Download completed successfully") - - if self.database is not None: - logger.debug( - f'[{colored_media_id}] Adding entry to database at "{self.database_path}"' - ) - self.database.add_media( - download_info.media_id, - download_info.final_path, - ) - - if ( - download_info.cover_path and not self.save_cover - ) or not download_info.cover_path: - pass - elif download_info.cover_path.exists() and not self.overwrite: - logger.debug( - f'[{colored_media_id}] Cover already exists at "{download_info.cover_path}", skipping' - ) - else: - logger.debug( - f'[{colored_media_id}] Saving cover to "{download_info.cover_path}"' - ) - self.write_cover( - download_info.cover_path, - download_info.cover_url, - ) - - if ( - self.no_synced_lyrics - or not download_info.lyrics - or not download_info.lyrics.synced - ): - pass - elif download_info.synced_lyrics_path.exists() and not self.overwrite: - logger.debug( - f'[{colored_media_id}] Synced lyrics already exist at "{download_info.synced_lyrics_path}", skipping' - ) - else: - logger.debug( - f'[{colored_media_id}] Saving synced lyrics to "{download_info.synced_lyrics_path}"' - ) - self.write_synced_lyrics( - download_info.synced_lyrics_path, - download_info.lyrics.synced, - ) - - if download_info.playlist_tags and self.save_playlist: - playlist_file_path = self.get_playlist_file_path( - download_info.playlist_tags - ) - logger.debug( - f'[{colored_media_id}] Updating playlist file "{playlist_file_path}"' - ) - self.update_playlist_file( - playlist_file_path, - download_info.final_path, - download_info.playlist_tags.playlist_track, - ) - - self.cleanup_temp_path() diff --git a/gamdl/downloader/__init__.py b/gamdl/downloader/__init__.py new file mode 100644 index 0000000..8ca117e --- /dev/null +++ b/gamdl/downloader/__init__.py @@ -0,0 +1,8 @@ +from .downloader import AppleMusicDownloader +from .downloader_base import AppleMusicBaseDownloader +from .downloader_music_video import AppleMusicMusicVideoDownloader +from .downloader_song import AppleMusicSongDownloader +from .downloader_uploaded_video import AppleMusicUploadedVideoDownloader +from .enums import * +from .exceptions import * +from .types import * diff --git a/gamdl/downloader/constants.py b/gamdl/downloader/constants.py new file mode 100644 index 0000000..15bfa39 --- /dev/null +++ b/gamdl/downloader/constants.py @@ -0,0 +1,32 @@ +import re + +DEFAULT_SONG_DECRYPTION_KEY = "32b8ade1769e26b1ffb8986352793fc6" +IMAGE_FILE_EXTENSION_MAP = { + "jpeg": ".jpg", + "tiff": ".tif", +} +TEMP_PATH_TEMPLATE = "gamdl_temp_{}" +ILLEGAL_CHARS_RE = r'[\\/:*?"<>|;]' +ILLEGAL_CHAR_REPLACEMENT = "_" + +SONG_MEDIA_TYPE = {"song", "songs", "library-songs"} +ALBUM_MEDIA_TYPE = {"album", "albums", "library-albums"} +MUSIC_VIDEO_MEDIA_TYPE = {"music-video", "music-videos", "library-music-videos"} +ARTIST_MEDIA_TYPE = {"artist", "artists", "library-artists"} +UPLOADED_VIDEO_MEDIA_TYPE = {"post", "uploaded-videos"} +PLAYLIST_MEDIA_TYPE = {"playlist", "playlists", "library-playlists"} + +VALID_URL_PATTERN = re.compile( + r"https://music\.apple\.com" + r"(?:" + r"/(?P[a-z]{2})" + r"/(?Partist|album|playlist|song|music-video|post)" + r"(?:/(?P[^\s/]+))?" + r"/(?P[0-9]+|pl\.[0-9a-z]{32}|pl\.u-[a-zA-Z0-9]+)" + r"(?:\?i=(?P[0-9]+))?" + r"|" + r"(?:/(?P[a-z]{2}))?" + r"/library/(?Pplaylist|albums)" + r"/(?Pp\.[a-zA-Z0-9]+|l\.[a-zA-Z0-9]+)" + r")" +) diff --git a/gamdl/downloader/downloader.py b/gamdl/downloader/downloader.py new file mode 100644 index 0000000..48c5cb4 --- /dev/null +++ b/gamdl/downloader/downloader.py @@ -0,0 +1,444 @@ +import asyncio +from pathlib import Path + +from InquirerPy import inquirer +from InquirerPy.base.control import Choice + +from ..utils import safe_gather +from .constants import ( + ALBUM_MEDIA_TYPE, + ARTIST_MEDIA_TYPE, + MUSIC_VIDEO_MEDIA_TYPE, + PLAYLIST_MEDIA_TYPE, + SONG_MEDIA_TYPE, + UPLOADED_VIDEO_MEDIA_TYPE, + VALID_URL_PATTERN, +) +from .downloader_base import AppleMusicBaseDownloader +from .downloader_music_video import AppleMusicMusicVideoDownloader +from .downloader_song import AppleMusicSongDownloader +from .downloader_uploaded_video import AppleMusicUploadedVideoDownloader +from .exceptions import ( + MediaFormatNotAvailableError, + MediaNotStreamableError, + MediaDownloadConfigurationError, +) +from .types import DownloadItem, UrlInfo + + +class AppleMusicDownloader: + def __init__( + self, + base_downloader: AppleMusicBaseDownloader, + song_downloader: AppleMusicSongDownloader, + music_video_downloader: AppleMusicMusicVideoDownloader, + uploaded_video_downloader: AppleMusicUploadedVideoDownloader, + skip_music_videos: bool = False, + ): + self.base_downloader = base_downloader + self.song_downloader = song_downloader + self.music_video_downloader = music_video_downloader + self.uploaded_video_downloader = uploaded_video_downloader + self.skip_music_videos = skip_music_videos + + async def get_single_download_item( + self, + media_metadata: dict, + playlist_metadata: dict = None, + ) -> DownloadItem: + download_item = None + + if media_metadata["type"] in SONG_MEDIA_TYPE: + download_item = await self.song_downloader.get_download_item( + media_metadata, + playlist_metadata, + ) + + if media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE: + download_item = await self.music_video_downloader.get_download_item( + media_metadata, + playlist_metadata, + ) + + if media_metadata["type"] in UPLOADED_VIDEO_MEDIA_TYPE: + download_item = await self.uploaded_video_downloader.get_download_item( + media_metadata, + ) + + return download_item + + async def get_collection_download_items( + self, + collection_metadata: dict, + ) -> list[DownloadItem | Exception]: + collection_metadata["relationships"]["tracks"]["data"].extend( + [ + extended_data + async for extended_data in self.base_downloader.apple_music_api.extend_api_data( + collection_metadata["relationships"]["tracks"], + ) + ] + ) + + tasks = [ + asyncio.create_task( + self.get_single_download_item( + media_metadata, + ( + collection_metadata + if collection_metadata["type"] in PLAYLIST_MEDIA_TYPE + else None + ), + ) + ) + for media_metadata in collection_metadata["relationships"]["tracks"]["data"] + ] + + download_items = await safe_gather(*tasks) + return download_items + + async def get_artist_download_items( + self, + artist_metadata: dict, + ) -> list[DownloadItem | Exception]: + for relationship in artist_metadata["relationships"].keys(): + artist_metadata["relationships"][relationship]["data"].extend( + [ + extended_data + async for extended_data in self.base_downloader.apple_music_api.extend_api_data( + artist_metadata["relationships"][relationship], + ) + ] + ) + + media_type = await inquirer.select( + message=f'Select which type to download for artist "{artist_metadata["attributes"]["name"]}":', + choices=[ + Choice( + name="Albums", + value="albums", + ), + Choice( + name="Music Videos", + value="music-videos", + ), + ], + validate=lambda result: artist_metadata["relationships"] + .get(result, {}) + .get("data"), + invalid_message="The artist doesn't have any items of this type", + ).execute_async() + + if media_type == "albums": + return await self.get_artist_albums_download_items( + artist_metadata["relationships"]["albums"]["data"] + ) + if media_type == "music-videos": + return await self.get_artist_music_videos_download_items( + artist_metadata["relationships"]["music-videos"]["data"] + ) + + async def get_artist_albums_download_items( + self, + albums_metadata: list[dict], + ) -> list[DownloadItem | Exception]: + choices = [ + Choice( + name=" | ".join( + [ + f'{album["attributes"]["trackCount"]:03d}', + f'{album["attributes"]["releaseDate"]:<10}', + f'{album["attributes"].get("contentRating", "None").title():<8}', + f'{album["attributes"]["name"]}', + ] + ), + value=album, + ) + for album in albums_metadata + ] + selected = await inquirer.select( + message="Select which albums to download: (Track Count | Release Date | Rating | Title)", + choices=choices, + multiselect=True, + ).execute_async() + + download_items = [] + + album_tasks = [ + asyncio.create_task( + self.base_downloader.apple_music_api.get_album(album_metadata["id"]) + ) + for album_metadata in selected + ] + album_responses = await safe_gather(*album_tasks) + + track_tasks = [ + asyncio.create_task( + self.get_collection_download_items(album_response["data"][0]) + ) + for album_response in album_responses + ] + track_results = await safe_gather(*track_tasks) + + for track_result in track_results: + download_items.extend(track_result) + + return download_items + + async def get_artist_music_videos_download_items( + self, + music_videos_metadata: list[dict], + ) -> list[DownloadItem | Exception]: + choices = [ + Choice( + name=" | ".join( + [ + self.millis_to_min_sec( + music_video["attributes"]["durationInMillis"] + ), + f'{music_video["attributes"].get("contentRating", "None").title():<8}', + music_video["attributes"]["name"], + ], + ), + value=music_video, + ) + for music_video in music_videos_metadata + ] + selected = await inquirer.select( + message="Select which music videos to download: (Duration | Rating | Title)", + choices=choices, + multiselect=True, + ).execute_async() + + music_video_tasks = [ + asyncio.create_task( + self.get_single_download_item( + music_video_metadata, + ) + ) + for music_video_metadata in selected + ] + download_items = await safe_gather(*music_video_tasks) + + return download_items + + def millis_to_min_sec(self, millis) -> str: + minutes, seconds = divmod(millis // 1000, 60) + return f"{minutes:02}:{seconds:02}" + + def get_url_info(self, url: str) -> UrlInfo | None: + match = VALID_URL_PATTERN.match(url) + if not match: + return None + + return UrlInfo( + **match.groupdict(), + ) + + async def get_download_queue( + self, + url_info: UrlInfo, + ) -> list[DownloadItem | Exception] | None: + return await self._get_download_queue( + "song" if url_info.sub_id else url_info.type, + url_info.sub_id or url_info.id or url_info.library_id, + url_info.library_id is not None, + ) + + async def _get_download_queue( + self, + url_type: str, + id: str, + is_library: bool, + ) -> list[DownloadItem | Exception] | None: + download_items = [] + + if url_type in ARTIST_MEDIA_TYPE: + artist_response = await self.base_downloader.apple_music_api.get_artist( + id, + ) + + if artist_response is None: + return None + + download_items = await self.get_artist_download_items( + artist_response["data"][0], + ) + + if url_type in SONG_MEDIA_TYPE: + song_respose = await self.base_downloader.apple_music_api.get_song(id) + + if song_respose is None: + return None + + download_items.append( + await self.get_single_download_item(song_respose["data"][0]) + ) + + if url_type in ALBUM_MEDIA_TYPE: + if is_library: + album_response = ( + await self.base_downloader.apple_music_api.get_library_album(id) + ) + else: + album_response = await self.base_downloader.apple_music_api.get_album( + id + ) + + if album_response is None: + return None + + download_items = await self.get_collection_download_items( + album_response["data"][0], + ) + + if url_type in PLAYLIST_MEDIA_TYPE: + if is_library: + playlist_response = ( + await self.base_downloader.apple_music_api.get_library_playlist(id) + ) + else: + playlist_response = ( + await self.base_downloader.apple_music_api.get_playlist(id) + ) + + if playlist_response is None: + return None + + download_items = await self.get_collection_download_items( + playlist_response["data"][0], + ) + + if url_type in MUSIC_VIDEO_MEDIA_TYPE: + music_video_response = ( + await self.base_downloader.apple_music_api.get_music_video(id) + ) + + if music_video_response is None: + return None + + download_items.append( + await self.get_single_download_item(music_video_response["data"][0]) + ) + + if url_type in UPLOADED_VIDEO_MEDIA_TYPE: + uploaded_video = ( + await self.base_downloader.apple_music_api.get_uploaded_video(id) + ) + + if uploaded_video is None: + return None + + download_items.append( + await self.get_single_download_item(uploaded_video["data"][0]) + ) + + return download_items + + async def download(self, download_item: DownloadItem | Exception) -> None: + try: + if isinstance(download_item, Exception): + return download_item + + exception = await self._download(download_item) + await self._final_processing(download_item) + if exception: + raise exception + finally: + if isinstance(download_item, DownloadItem): + self.base_downloader.cleanup_temp(download_item.random_uuid) + + async def _download( + self, + download_item: DownloadItem, + ) -> Exception | None: + if self.song_downloader.synced_lyrics_only: + return + + if ( + Path(download_item.final_path).exists() + and not self.base_downloader.overwrite + ): + return FileExistsError( + f'Media file already exists at "{download_item.final_path}"' + ) + + if not self.base_downloader.is_media_streamable( + download_item.media_metadata, + ): + return MediaNotStreamableError( + download_item.media_metadata["id"], + ) + + if download_item.media_metadata["type"] in { + *SONG_MEDIA_TYPE, + *MUSIC_VIDEO_MEDIA_TYPE, + } and ( + not download_item.stream_info + or not download_item.stream_info.audio_track.widevine_pssh + ): + return MediaFormatNotAvailableError( + download_item.media_metadata["id"], + ) + + if download_item.media_metadata["type"] in SONG_MEDIA_TYPE: + await self.song_downloader.download(download_item) + + if download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE: + if self.skip_music_videos or self.song_downloader.synced_lyrics_only: + return MediaDownloadConfigurationError( + download_item.media_metadata["id"] + ) + await self.music_video_downloader.download(download_item) + + if download_item.media_metadata["type"] in UPLOADED_VIDEO_MEDIA_TYPE: + if self.song_downloader.synced_lyrics_only: + return MediaDownloadConfigurationError( + download_item.media_metadata["id"] + ) + await self.uploaded_video_downloader.download(download_item) + + async def _final_processing( + self, + download_item: DownloadItem, + ) -> None: + if download_item.staged_path and Path(download_item.staged_path).exists(): + self.base_downloader.move_to_final_path( + download_item.staged_path, + download_item.final_path, + ) + + if download_item.cover_path and self.base_downloader.save_cover: + cover_url = self.base_downloader.get_cover_url( + download_item.cover_url_template, + ) + cover_bytes = await self.base_downloader.get_cover_bytes(cover_url) + if cover_bytes and ( + self.base_downloader.overwrite + or not Path(download_item.cover_path).exists() + ): + self.base_downloader.write_cover_image( + cover_bytes, + download_item.cover_path, + ) + + if ( + download_item.lyrics + and download_item.lyrics.synced + and not self.song_downloader.no_synced_lyrics + and ( + self.base_downloader.overwrite + or not Path(download_item.synced_lyrics_path).exists() + ) + ): + self.song_downloader.write_synced_lyrics( + download_item.lyrics.synced, + download_item.synced_lyrics_path, + ) + + if download_item.playlist_tags and self.base_downloader.save_playlist: + self.base_downloader.update_playlist_file( + download_item.playlist_file_path, + download_item.final_path, + download_item.playlist_tags.playlist_track, + ) diff --git a/gamdl/downloader/downloader_base.py b/gamdl/downloader/downloader_base.py new file mode 100644 index 0000000..c64b9ea --- /dev/null +++ b/gamdl/downloader/downloader_base.py @@ -0,0 +1,449 @@ +import asyncio +import re +import shutil +import uuid +from io import BytesIO +from pathlib import Path + +import httpx +from async_lru import alru_cache +from mutagen.mp4 import MP4, MP4Cover +from PIL import Image +from pywidevine import Cdm, Device +from yt_dlp import YoutubeDL + +from ..api.apple_music_api import AppleMusicApi +from ..api.itunes_api import ItunesApi +from ..interface.interface import AppleMusicInterface +from ..interface.types import MediaTags, PlaylistTags +from ..utils import async_subprocess, raise_for_status +from .constants import ( + ILLEGAL_CHAR_REPLACEMENT, + ILLEGAL_CHARS_RE, + IMAGE_FILE_EXTENSION_MAP, + TEMP_PATH_TEMPLATE, +) +from .enums import CoverFormat, DownloadMode, RemuxMode +from .hardcoded_wvd import HARDCODED_WVD + + +class AppleMusicBaseDownloader: + def __init__( + self, + apple_music_api: AppleMusicApi, + output_path: str = "./Apple Music", + temp_path: str = ".", + wvd_path: str = None, + overwrite: bool = False, + save_cover: bool = False, + save_playlist: bool = False, + nm3u8dlre_path: str = "N_m3u8DL-RE", + mp4decrypt_path: str = "mp4decrypt", + ffmpeg_path: str = "ffmpeg", + mp4box_path: str = "MP4Box", + download_mode: DownloadMode = DownloadMode.YTDLP, + remux_mode: RemuxMode = RemuxMode.FFMPEG, + cover_format: CoverFormat = CoverFormat.JPG, + album_folder_template: str = "{album_artist}/{album}", + compilation_folder_template: str = "Compilations/{album}", + single_disc_folder_template: str = "{track:02d} {title}", + multi_disc_folder_template: str = "{disc}-{track:02d} {title}", + no_album_folder_template: str = "{artist}/Unknown Album", + no_album_file_template: str = "{title}", + playlist_file_template: str = "Playlists/{playlist_artist}/{playlist_title}", + date_tag_template: str = "%Y-%m-%dT%H:%M:%SZ", + exclude_tags: list[str] = None, + cover_size: int = 1200, + truncate: int = None, + silent: bool = False, + skip_processing: bool = False, + ): + self.apple_music_api = apple_music_api + self.output_path = output_path + self.temp_path = temp_path + self.wvd_path = wvd_path + self.overwrite = overwrite + self.save_cover = save_cover + self.save_playlist = save_playlist + self.nm3u8dlre_path = nm3u8dlre_path + self.mp4decrypt_path = mp4decrypt_path + self.ffmpeg_path = ffmpeg_path + self.mp4box_path = mp4box_path + self.download_mode = download_mode + self.remux_mode = remux_mode + self.cover_format = cover_format + self.album_folder_template = album_folder_template + self.compilation_folder_template = compilation_folder_template + self.single_disc_folder_template = single_disc_folder_template + self.multi_disc_folder_template = multi_disc_folder_template + self.no_album_folder_template = no_album_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.cover_size = cover_size + self.truncate = truncate + self.silent = silent + self.skip_processing = skip_processing + + def setup(self): + self._setup_binary_paths() + self._setup_cdm() + self._setup_interface() + + def _setup_binary_paths(self): + self.full_nm3u8dlre_path = shutil.which(self.nm3u8dlre_path) + self.full_mp4decrypt_path = shutil.which(self.mp4decrypt_path) + self.full_ffmpeg_path = shutil.which(self.ffmpeg_path) + self.full_mp4box_path = shutil.which(self.mp4box_path) + + def _setup_cdm(self): + if self.wvd_path: + self.cdm = Cdm.from_device(Device.load(self.wvd_path)) + else: + self.cdm = Cdm.from_device(Device.loads(HARDCODED_WVD)) + self.cdm.MAX_NUM_OF_SESSIONS = float("inf") + + def _setup_interface(self): + self.itunes_api = ItunesApi( + self.apple_music_api.storefront, + self.apple_music_api.language, + ) + self.itunes_api.setup() + self.interface = AppleMusicInterface(self.apple_music_api, self.itunes_api) + + def get_random_uuid(self) -> str: + return uuid.uuid4().hex[:8] + + def is_media_streamable( + self, + media_metadata: dict, + ) -> bool: + return bool(media_metadata["attributes"].get("playParams")) + + async def get_cover_file_extension(self, cover_url_template: str) -> str | None: + if self.cover_format != CoverFormat.RAW: + return f".{self.cover_format.value}" + + cover_url = self.get_cover_url(cover_url_template) + cover_bytes = await self.get_cover_bytes(cover_url) + if cover_bytes is None: + return None + + image_obj = Image.open(BytesIO(self.get_cover_bytes(cover_url))) + image_format = image_obj.format.lower() + return IMAGE_FILE_EXTENSION_MAP.get( + image_format, + f".{image_format.lower()}", + ) + + def get_playlist_tags( + self, + playlist_metadata: dict, + media_metadata: dict, + ) -> PlaylistTags: + playlist_track = ( + playlist_metadata["relationships"]["tracks"]["data"].index(media_metadata) + + 1 + ) + + return PlaylistTags( + playlist_artist=playlist_metadata["attributes"].get( + "curatorName", "Unknown" + ), + playlist_id=playlist_metadata["attributes"]["playParams"]["id"], + playlist_title=playlist_metadata["attributes"]["name"], + playlist_track=playlist_track, + ) + + def get_temp_path( + self, + media_id: str, + folder_tag: str, + file_tag: str, + file_extension: str, + ) -> str: + return str( + Path(self.temp_path) + / TEMP_PATH_TEMPLATE.format(folder_tag) + / (f"{media_id}_{file_tag}" + file_extension) + ) + + @alru_cache() + async def get_cover_bytes(self, cover_url: str) -> bytes | None: + async with httpx.AsyncClient() as client: + response = await client.get(cover_url) + raise_for_status(response, {200, 404}) + + if response.status_code == 200: + return response.content + return None + + def get_sanitized_string(self, dirty_string: str, is_folder: bool) -> str: + dirty_string = re.sub( + ILLEGAL_CHARS_RE, + ILLEGAL_CHAR_REPLACEMENT, + dirty_string, + ) + if is_folder: + dirty_string = dirty_string[: self.truncate] + if dirty_string.endswith("."): + dirty_string = dirty_string[:-1] + ILLEGAL_CHAR_REPLACEMENT + else: + if self.truncate is not None: + dirty_string = dirty_string[: self.truncate - 4] + return dirty_string.strip() + + def get_final_path( + self, + tags: MediaTags, + file_extension: str, + playlist_tags: PlaylistTags, + ) -> str: + if tags.album is not None: + template_folder = ( + self.compilation_folder_template.split("/") + if tags.compilation + else self.album_folder_template.split("/") + ) + template_file = ( + self.multi_disc_folder_template.split("/") + if tags.disc_total > 1 + else self.single_disc_folder_template.split("/") + ) + else: + template_folder = self.no_album_folder_template.split("/") + template_file = self.no_album_file_template.split("/") + + template_final = template_folder + template_file + + tags_dict = tags.__dict__.copy() + if playlist_tags: + tags_dict.update(playlist_tags.__dict__) + + return str( + Path( + self.output_path, + *[ + self.get_sanitized_string(i.format(**tags_dict), True) + for i in template_final[0:-1] + ], + ( + self.get_sanitized_string( + template_final[-1].format(**tags_dict), False + ) + + file_extension + ), + ) + ) + + def get_cover_url_template(self, metadata: dict) -> str: + if self.cover_format == CoverFormat.RAW: + return self._get_raw_cover_url(metadata["attributes"]["artwork"]["url"]) + return metadata["attributes"]["artwork"]["url"] + + def _get_raw_cover_url(self, cover_url_template: str) -> str: + return re.sub( + r"image/thumb/", + "", + re.sub( + r"is1-ssl", + "a1", + cover_url_template, + ), + ) + + def get_cover_url(self, cover_url_template: str) -> str: + return self.format_cover_url( + cover_url_template, + self.cover_size, + self.cover_format.value, + ) + + def format_cover_url( + self, + cover_url_template: str, + cover_size: int, + cover_format: str, + ) -> str: + return re.sub( + r"\{w\}x\{h\}([a-z]{2})\.jpg", + ( + f"{cover_size}x{cover_size}bb.{cover_format}" + if self.cover_format != CoverFormat.RAW + else "" + ), + cover_url_template, + ) + + async def download_stream(self, stream_url: str, download_path: str): + if self.download_mode == DownloadMode.YTDLP: + await self.download_ytdlp(stream_url, download_path) + + if self.download_mode == DownloadMode.NM3U8DLRE: + await self.download_nm3u8dlre(stream_url, download_path) + + async def download_ytdlp(self, stream_url: str, download_path: str) -> None: + await asyncio.to_thread( + self._download_ytdlp, + stream_url, + download_path, + ) + + def _download_ytdlp(self, stream_url: str, download_path: str) -> None: + with YoutubeDL( + { + "quiet": True, + "no_warnings": True, + "outtmpl": download_path, + "allow_unplayable_formats": True, + "overwrites": True, + "fixup": "never", + "noprogress": self.silent, + "allowed_extractors": ["generic"], + } + ) as ydl: + ydl.download(stream_url) + + async def download_nm3u8dlre(self, stream_url: str, download_path: str): + download_path_obj = Path(download_path) + + download_path_obj.parent.mkdir(parents=True, exist_ok=True) + await async_subprocess( + self.full_nm3u8dlre_path, + stream_url, + "--binary-merge", + "--no-log", + "--log-level", + "off", + "--ffmpeg-binary-path", + self.full_ffmpeg_path, + "--save-name", + download_path_obj.stem, + "--save-dir", + download_path_obj.parent, + "--tmp-dir", + download_path_obj.parent, + silent=self.silent, + ) + + async def apply_tags( + self, + media_path: Path, + tags: MediaTags, + cover_url_template: str, + ): + 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 + + mp4 = MP4(media_path) + mp4.clear() + + if not skip_tagging: + if "cover" not in exclude_tags and self.cover_format != CoverFormat.RAW: + await self._apply_cover(mp4, cover_url_template) + mp4.update(mp4_tags) + + mp4.save() + + async def _apply_cover( + self, + mp4: MP4, + cover_url_template: str, + ) -> None: + cover_url = self.get_cover_url(cover_url_template) + cover_bytes = await self.get_cover_bytes(cover_url) + if cover_bytes is None: + return + + mp4["covr"] = [ + MP4Cover( + data=cover_bytes, + imageformat=( + MP4Cover.FORMAT_JPEG + if self.cover_format == CoverFormat.JPG + else MP4Cover.FORMAT_PNG + ), + ) + ] + + def move_to_final_path(self, stage_path: str, final_path: str) -> None: + Path(final_path).parent.mkdir(parents=True, exist_ok=True) + shutil.move(stage_path, final_path) + + def write_cover_image( + self, + cover_bytes: bytes, + cover_path: str, + ) -> None: + Path(cover_path).parent.mkdir(parents=True, exist_ok=True) + Path(cover_path).write_bytes(cover_bytes) + + def get_playlist_file_path( + self, + tags: PlaylistTags, + ) -> str: + template_file = self.playlist_file_template.split("/") + tags_dict = tags.__dict__.copy() + + return str( + Path( + self.output_path, + *[ + self.get_sanitized_string(i.format(**tags_dict), True) + for i in template_file[0:-1] + ], + *[ + self.get_sanitized_string( + template_file[-1].format(**tags_dict), False + ) + + ".m3u8" + ], + ) + ) + + def update_playlist_file( + self, + playlist_file_path: str, + final_path: str, + playlist_track: int, + ) -> None: + playlist_file_path_obj = Path(playlist_file_path) + final_path_obj = Path(final_path) + output_dir_obj = Path(self.output_path) + + playlist_file_path_obj.parent.mkdir(parents=True, exist_ok=True) + playlist_file_path_parent_parts_len = len(playlist_file_path_obj.parent.parts) + output_path_parts_len = len(output_dir_obj.parts) + + final_path_relative = Path( + ("../" * (playlist_file_path_parent_parts_len - output_path_parts_len)), + *final_path_obj.parts[output_path_parts_len:], + ) + playlist_file_lines = ( + playlist_file_path_obj.open("r", encoding="utf8").readlines() + if playlist_file_path_obj.exists() + else [] + ) + if len(playlist_file_lines) < playlist_track: + playlist_file_lines.extend( + "\n" for _ in range(playlist_track - len(playlist_file_lines)) + ) + + playlist_file_lines[playlist_track - 1] = final_path_relative.as_posix() + "\n" + with playlist_file_path_obj.open("w", encoding="utf8") as playlist_file: + playlist_file.writelines(playlist_file_lines) + + def cleanup_temp(self, random_uuid: str) -> None: + temp_folder = Path(self.temp_path) / TEMP_PATH_TEMPLATE.format(random_uuid) + if temp_folder.exists(): + shutil.rmtree(temp_folder) diff --git a/gamdl/downloader/downloader_music_video.py b/gamdl/downloader/downloader_music_video.py new file mode 100644 index 0000000..1420e74 --- /dev/null +++ b/gamdl/downloader/downloader_music_video.py @@ -0,0 +1,279 @@ +from pathlib import Path + +from ..interface.enums import MusicVideoCodec, MusicVideoResolution +from ..interface.interface_music_video import AppleMusicMusicVideoInterface +from ..interface.types import DecryptionKeyAv +from ..utils import async_subprocess +from .downloader_base import AppleMusicBaseDownloader +from .enums import RemuxFormatMusicVideo, RemuxMode +from .types import DownloadItem + + +class AppleMusicMusicVideoDownloader: + def __init__( + self, + downloader: AppleMusicBaseDownloader, + codec_priority: list[MusicVideoCodec] = [ + MusicVideoCodec.H265, + MusicVideoCodec.H264, + ], + remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V, + resolution: MusicVideoResolution = MusicVideoResolution.R1080P, + ): + self.downloader = downloader + self.codec_priority = codec_priority + self.remux_format = remux_format + self.resolution = resolution + + def setup(self): + self._setup_interface() + + def _setup_interface(self): + self.music_video_interface = AppleMusicMusicVideoInterface( + self.downloader.interface, + ) + + async def remux_mp4box( + self, + input_path_video: str, + input_path_audio: str, + output_path: str, + ): + await async_subprocess( + self.downloader.full_mp4box_path, + "-quiet", + "-add", + input_path_audio, + "-add", + input_path_video, + "-itags", + "artist=placeholder", + "-keep-utc", + "-new", + output_path, + silent=self.downloader.silent, + ) + + async def remux_ffmpeg( + self, + input_path_video: str, + input_path_audio: str, + output_path: str, + decryption_key: str = None, + ): + if decryption_key: + key = [ + "-decryption_key", + decryption_key, + ] + else: + key = [] + + await async_subprocess( + self.downloader.full_ffmpeg_path, + "-loglevel", + "error", + "-y", + *key, + "-i", + input_path_video, + "-i", + input_path_audio, + "-c", + "copy", + "-c:s", + "mov_text", + "-movflags", + "+faststart", + output_path, + silent=self.downloader.silent, + ) + + async def decrypt_mp4decrypt( + self, + input_path: str, + output_path: str, + decryption_key: str, + ): + await async_subprocess( + self.downloader.full_mp4decrypt_path, + "--key", + f"1:{decryption_key}", + input_path, + output_path, + silent=self.downloader.silent, + ) + + async def stage( + self, + encrypted_path_video: str, + encrypted_path_audio: str, + decrypted_path_video: str, + decrypted_path_audio: str, + staged_path: str, + decryption_key: DecryptionKeyAv, + ): + await self.decrypt_mp4decrypt( + encrypted_path_video, + decrypted_path_video, + decryption_key.video_track.key, + ) + await self.decrypt_mp4decrypt( + encrypted_path_audio, + decrypted_path_audio, + decryption_key.audio_track.key, + ) + + if self.downloader.remux_mode == RemuxMode.MP4BOX: + await self.remux_mp4box( + decrypted_path_video, + decrypted_path_audio, + staged_path, + ) + else: + await self.remux_ffmpeg( + decrypted_path_video, + decrypted_path_audio, + staged_path, + ) + + def get_cover_path( + self, + final_path: str, + file_extension: str, + ) -> str: + return str(Path(final_path).parent / ("Cover" + file_extension)) + + async def get_download_item( + self, + music_video_metadata: dict, + playlist_metadata: dict = None, + ) -> DownloadItem: + download_item = DownloadItem() + + download_item.media_metadata = music_video_metadata + + music_video_id = self.downloader.interface.get_media_id_of_library_media( + music_video_metadata, + ) + + itunes_page_metadata = ( + await self.music_video_interface.get_itunes_page_metadata( + music_video_metadata, + ) + ) + download_item.media_tags = await self.music_video_interface.get_tags( + music_video_metadata, + itunes_page_metadata, + ) + + if playlist_metadata: + download_item.playlist_tags = self.downloader.get_playlist_tags( + playlist_metadata, + music_video_metadata, + ) + download_item.playlist_file_path = self.downloader.get_playlist_file_path( + download_item.playlist_tags, + ) + + stream_info = await self.music_video_interface.get_stream_info( + music_video_metadata, + itunes_page_metadata, + self.codec_priority, + self.resolution, + ) + download_item.stream_info = stream_info + + decryption_key = await self.music_video_interface.get_decryption_key( + stream_info, + self.downloader.cdm, + ) + download_item.decryption_key = decryption_key + + download_item.random_uuid = self.downloader.get_random_uuid() + download_item.staged_path = self.downloader.get_temp_path( + music_video_id, + download_item.random_uuid, + "staged", + ( + "." + + ( + "mp4" + if self.remux_format == RemuxFormatMusicVideo.MP4 + else download_item.stream_info.file_format.value + ) + ), + ) + download_item.final_path = self.downloader.get_final_path( + download_item.media_tags, + Path(download_item.staged_path).suffix, + playlist_metadata, + ) + + download_item.cover_url_template = self.downloader.get_cover_url_template( + music_video_metadata, + ) + cover_file_extension = await self.downloader.get_cover_file_extension( + download_item.cover_url_template, + ) + if cover_file_extension: + download_item.cover_path = self.get_cover_path( + download_item.final_path, + cover_file_extension, + ) + + return download_item + + async def download( + self, + download_item: DownloadItem, + ) -> None: + encrypted_path_video = self.downloader.get_temp_path( + download_item.media_metadata["id"], + download_item.random_uuid, + "encrypted_video", + ".mp4", + ) + encrypted_path_audio = self.downloader.get_temp_path( + download_item.media_metadata["id"], + download_item.random_uuid, + "encrypted_audio", + ".m4a", + ) + + await self.downloader.download_stream( + download_item.stream_info.video_track.stream_url, + encrypted_path_video, + ) + await self.downloader.download_stream( + download_item.stream_info.audio_track.stream_url, + encrypted_path_audio, + ) + + decrypted_path_video = self.downloader.get_temp_path( + download_item.media_metadata["id"], + download_item.random_uuid, + "decrypted_video", + ".mp4", + ) + decrypted_path_audio = self.downloader.get_temp_path( + download_item.media_metadata["id"], + download_item.random_uuid, + "decrypted_audio", + ".m4a", + ) + + await self.stage( + encrypted_path_video, + encrypted_path_audio, + decrypted_path_video, + decrypted_path_audio, + download_item.staged_path, + download_item.decryption_key, + ) + + await self.downloader.apply_tags( + download_item.staged_path, + download_item.media_tags, + download_item.cover_url_template, + ) diff --git a/gamdl/downloader/downloader_song.py b/gamdl/downloader/downloader_song.py new file mode 100644 index 0000000..cc45c5c --- /dev/null +++ b/gamdl/downloader/downloader_song.py @@ -0,0 +1,303 @@ +from pathlib import Path + +from ..interface.enums import SongCodec, SyncedLyricsFormat +from ..interface.interface_song import AppleMusicSongInterface +from ..interface.types import DecryptionKeyAv +from ..utils import async_subprocess +from .constants import DEFAULT_SONG_DECRYPTION_KEY +from .downloader_base import AppleMusicBaseDownloader +from .enums import RemuxMode +from .types import DownloadItem + + +class AppleMusicSongDownloader: + def __init__( + self, + downloader: AppleMusicBaseDownloader, + codec: SongCodec = SongCodec.AAC_LEGACY, + synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC, + no_synced_lyrics: bool = False, + synced_lyrics_only: bool = False, + ): + self.downloader = downloader + self.codec = codec + self.synced_lyrics_format = synced_lyrics_format + self.no_synced_lyrics = no_synced_lyrics + self.synced_lyrics_only = synced_lyrics_only + + def setup(self): + self._setup_interface() + + def _setup_interface(self): + self.song_interface = AppleMusicSongInterface(self.downloader.interface) + + async def get_download_item( + self, + song_metadata: dict, + playlist_metadata: dict = None, + ) -> DownloadItem: + download_item = DownloadItem() + + download_item.media_metadata = song_metadata + + song_id = self.downloader.interface.get_media_id_of_library_media(song_metadata) + + download_item.lyrics = await self.song_interface.get_lyrics( + song_metadata, + self.synced_lyrics_format, + ) + + webplayback = await self.downloader.apple_music_api.get_webplayback(song_id) + download_item.media_tags = self.song_interface.get_tags( + webplayback, + download_item.lyrics.unsynced if download_item.lyrics else None, + ) + + if playlist_metadata: + download_item.playlist_tags = self.downloader.get_playlist_tags( + playlist_metadata, + song_metadata, + ) + download_item.playlist_file_path = self.downloader.get_playlist_file_path( + download_item.playlist_tags, + ) + + download_item.final_path = self.downloader.get_final_path( + download_item.media_tags, + ".m4a", + download_item.playlist_tags, + ) + download_item.synced_lyrics_path = self.get_lyrics_synced_path( + download_item.final_path, + ) + + if self.synced_lyrics_only: + return download_item + + if self.codec.is_legacy(): + download_item.stream_info = ( + await self.song_interface.get_stream_info_legacy( + webplayback, + self.codec, + ) + ) + download_item.decryption_key = ( + await self.song_interface.get_decryption_key_legacy( + download_item.stream_info, + self.downloader.cdm, + ) + ) + else: + download_item.stream_info = await self.song_interface.get_stream_info( + song_metadata, + self.codec, + ) + if ( + download_item.stream_info + and download_item.stream_info.audio_track.widevine_pssh + ): + download_item.decryption_key = ( + await self.song_interface.get_decryption_key( + download_item.stream_info, + self.downloader.cdm, + ) + ) + else: + download_item.decryption_key = None + + download_item.cover_url_template = self.downloader.get_cover_url_template( + song_metadata + ) + + download_item.random_uuid = self.downloader.get_random_uuid() + download_item.staged_path = self.downloader.get_temp_path( + song_id, + download_item.random_uuid, + "staged", + "." + download_item.stream_info.file_format.value, + ) + cover_file_extension = await self.downloader.get_cover_file_extension( + download_item.cover_url_template, + ) + if cover_file_extension: + download_item.cover_path = self.get_cover_path( + download_item.final_path, + cover_file_extension, + ) + + return download_item + + def fix_key_id(self, input_path: str): + count = 0 + with open(input_path, "rb+") as file: + while data := file.read(4096): + pos = file.tell() + i = 0 + while tenc := max(0, data.find(b"tenc", i)): + kid = tenc + 12 + file.seek(max(0, pos - 4096) + kid, 0) + file.write(bytes.fromhex(f"{count:032}")) + count += 1 + i = kid + 1 + file.seek(pos, 0) + + async def remux_mp4box(self, input_path: str, output_path: str): + await async_subprocess( + self.downloader.full_mp4box_path, + "-quiet", + "-add", + input_path, + "-itags", + "artist=placeholder", + "-keep-utc", + "-new", + output_path, + silent=self.downloader.silent, + ) + + async def remux_ffmpeg( + self, + input_path: str, + output_path: str, + decryption_key: str = None, + ): + if decryption_key: + key = [ + "-decryption_key", + decryption_key, + ] + else: + key = [] + + await async_subprocess( + self.downloader.full_ffmpeg_path, + "-loglevel", + "error", + "-y", + *key, + "-i", + input_path, + "-c", + "copy", + "-movflags", + "+faststart", + output_path, + silent=self.downloader.silent, + ) + + async def decrypt_mp4decrypt( + self, + input_path: str, + output_path: str, + decryption_key: str, + legacy: bool, + ): + if legacy: + keys = [ + "--key", + f"1:{decryption_key}", + ] + else: + self.fix_key_id(input_path) + keys = [ + "--key", + "0" * 31 + "1" + f":{decryption_key}", + "--key", + "0" * 32 + f":{DEFAULT_SONG_DECRYPTION_KEY}", + ] + + await async_subprocess( + self.downloader.full_mp4decrypt_path, + *keys, + input_path, + output_path, + silent=self.downloader.silent, + ) + + async def stage( + self, + encrypted_path: str, + decrypted_path: str, + staged_path: str, + decryption_key: DecryptionKeyAv, + codec: SongCodec, + ): + if codec.is_legacy() and self.downloader.remux_mode == RemuxMode.FFMPEG: + await self.remux_ffmpeg( + encrypted_path, + staged_path, + decryption_key.audio_track.key, + ) + else: + await self.decrypt_mp4decrypt( + encrypted_path, + decrypted_path, + decryption_key.audio_track.key, + codec.is_legacy(), + ) + if self.downloader.remux_mode == RemuxMode.FFMPEG: + await self.remux_ffmpeg( + decrypted_path, + staged_path, + ) + else: + await self.remux_mp4box( + decrypted_path, + staged_path, + ) + + def get_lyrics_synced_path(self, final_path: str) -> str: + return str(Path(final_path).with_suffix("." + self.synced_lyrics_format.value)) + + def get_cover_path( + self, + final_path: str, + file_extension: str, + ) -> str: + return str(Path(final_path).parent / ("Cover" + file_extension)) + + def write_synced_lyrics( + self, + synced_lyrics: str, + lyrics_synced_path: str, + ): + Path(lyrics_synced_path).parent.mkdir(parents=True, exist_ok=True) + Path(lyrics_synced_path).write_text(synced_lyrics, encoding="utf8") + + async def download( + self, + download_item: DownloadItem, + ) -> None: + if self.synced_lyrics_only: + return + + encrypted_path = self.downloader.get_temp_path( + download_item.media_metadata["id"], + download_item.random_uuid, + "encrypted", + ".m4a", + ) + await self.downloader.download_stream( + download_item.stream_info.audio_track.stream_url, + encrypted_path, + ) + + decrypted_path = self.downloader.get_temp_path( + download_item.media_metadata["id"], + download_item.random_uuid, + "decrypted", + ".m4a", + ) + await self.stage( + encrypted_path, + decrypted_path, + download_item.staged_path, + download_item.decryption_key, + self.codec, + ) + + await self.downloader.apply_tags( + download_item.staged_path, + download_item.media_tags, + download_item.cover_url_template, + ) diff --git a/gamdl/downloader/downloader_uploaded_video.py b/gamdl/downloader/downloader_uploaded_video.py new file mode 100644 index 0000000..f4f26ff --- /dev/null +++ b/gamdl/downloader/downloader_uploaded_video.py @@ -0,0 +1,85 @@ +from pathlib import Path + +from ..interface.enums import UploadedVideoQuality +from ..interface.interface_uploaded_video import AppleMusicUploadedVideoInterface +from .downloader_base import AppleMusicBaseDownloader +from .types import DownloadItem + + +class AppleMusicUploadedVideoDownloader: + def __init__( + self, + downloader: AppleMusicBaseDownloader, + quality: UploadedVideoQuality = UploadedVideoQuality.BEST, + ): + self.downloader = downloader + self.quality = quality + + def setup(self): + self._setup_interface() + + def _setup_interface(self): + self.uploaded_video_interface = AppleMusicUploadedVideoInterface( + self.downloader.interface, + ) + + def get_cover_path(self, final_path: str, cover_file_extension: str) -> str: + return str(Path(final_path).with_suffix(cover_file_extension)) + + async def get_download_item( + self, + uploaded_video_metadata: dict, + ) -> DownloadItem: + download_item = DownloadItem() + + download_item.media_metadata = uploaded_video_metadata + + download_item.media_tags = self.uploaded_video_interface.get_tags( + uploaded_video_metadata, + ) + + download_item.stream_info = await self.uploaded_video_interface.get_stream_info( + uploaded_video_metadata, + self.quality, + ) + + download_item.random_uuid = self.downloader.get_random_uuid() + download_item.staged_path = self.downloader.get_temp_path( + uploaded_video_metadata["id"], + download_item.random_uuid, + "staged", + "." + download_item.stream_info.file_format.value, + ) + download_item.final_path = self.downloader.get_final_path( + download_item.media_tags, + Path(download_item.staged_path).suffix, + None, + ) + + download_item.cover_url_template = self.downloader.get_cover_url_template( + uploaded_video_metadata, + ) + cover_file_extension = await self.downloader.get_cover_file_extension( + download_item.cover_url_template, + ) + if cover_file_extension: + download_item.cover_path = self.get_cover_path( + download_item.final_path, + cover_file_extension, + ) + + return download_item + + async def download( + self, + download_item: DownloadItem, + ) -> None: + await self.downloader.download_ytdlp( + download_item.stream_info.video_track.stream_url, + download_item.staged_path, + ) + await self.downloader.apply_tags( + download_item.staged_path, + download_item.media_tags, + download_item.cover_url_template, + ) diff --git a/gamdl/downloader/enums.py b/gamdl/downloader/enums.py new file mode 100644 index 0000000..d58c243 --- /dev/null +++ b/gamdl/downloader/enums.py @@ -0,0 +1,22 @@ +from enum import Enum + + +class DownloadMode(Enum): + YTDLP = "ytdlp" + NM3U8DLRE = "nm3u8dlre" + + +class RemuxMode(Enum): + FFMPEG = "ffmpeg" + MP4BOX = "mp4box" + + +class CoverFormat(Enum): + JPG = "jpg" + PNG = "png" + RAW = "raw" + + +class RemuxFormatMusicVideo(Enum): + M4V = "m4v" + MP4 = "mp4" diff --git a/gamdl/downloader/exceptions.py b/gamdl/downloader/exceptions.py new file mode 100644 index 0000000..84b17f3 --- /dev/null +++ b/gamdl/downloader/exceptions.py @@ -0,0 +1,19 @@ +class MediaNotStreamableError(Exception): + def __init__(self, media_id: str): + super().__init__( + f'Media with ID "{media_id}" is not streamable'.format(media_id=media_id) + ) + + +class MediaFormatNotAvailableError(Exception): + def __init__(self, media_id: str): + super().__init__( + f'Media with ID "{media_id}" is not available in the requested format' + ) + + +class MediaDownloadConfigurationError(Exception): + def __init__(self, media_id: str): + super().__init__( + f'Media with ID "{media_id}" is not downloadable with the current configuration' + ) diff --git a/gamdl/hardcoded_wvd.py b/gamdl/downloader/hardcoded_wvd.py similarity index 100% rename from gamdl/hardcoded_wvd.py rename to gamdl/downloader/hardcoded_wvd.py diff --git a/gamdl/downloader/types.py b/gamdl/downloader/types.py new file mode 100644 index 0000000..bdc7bed --- /dev/null +++ b/gamdl/downloader/types.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass + +from ..interface.types import ( + DecryptionKeyAv, + Lyrics, + MediaTags, + PlaylistTags, + StreamInfoAv, +) + + +@dataclass +class DownloadItem: + media_metadata: dict = None + random_uuid: str = None + lyrics: Lyrics = None + media_tags: MediaTags = None + playlist_tags: PlaylistTags = None + stream_info: StreamInfoAv = None + decryption_key: DecryptionKeyAv = None + cover_url_template: str = None + staged_path: str = None + final_path: str = None + playlist_file_path: str = None + synced_lyrics_path: str = None + cover_path: str = None + + +@dataclass +class UrlInfo: + storefront: str = None + type: str = None + slug: str = None + id: str = None + sub_id: str = None + library_storefront: str = None + library_type: str = None + library_id: str = None diff --git a/gamdl/downloader_music_video.py b/gamdl/downloader_music_video.py deleted file mode 100644 index ccc4b9d..0000000 --- a/gamdl/downloader_music_video.py +++ /dev/null @@ -1,615 +0,0 @@ -from __future__ import annotations - -import logging -import subprocess -import urllib.parse -from pathlib import Path - -import colorama -import m3u8 -from InquirerPy import inquirer -from InquirerPy.base.control import Choice - -from .downloader import Downloader -from .enums import ( - MediaFileFormat, - MusicVideoCodec, - MusicVideoResolution, - RemuxFormatMusicVideo, - RemuxMode, -) -from .exceptions import * -from .models import ( - DecryptionKeyAv, - DownloadInfo, - MediaRating, - MediaTags, - MediaType, - StreamInfo, - StreamInfoAv, -) -from .utils import color_text - -logger = logging.getLogger("gamdl") - - -class DownloaderMusicVideo: - MP4_FORMAT_CODECS = ["hvc1", "audio-atmos", "audio-ec3"] - - def __init__( - self, - downloader: Downloader, - codec: list[MusicVideoCodec] = [MusicVideoCodec.H264, MusicVideoCodec.H265], - remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V, - resolution: MusicVideoResolution = MusicVideoResolution.R1080P, - ) -> None: - self.downloader = downloader - self.codec = codec - self.remux_format = remux_format - self.resolution = resolution - - def get_stream_url_from_webplayback(self, webplayback: dict) -> str: - return webplayback["hls-playlist-url"] - - def get_stream_url_from_itunes_page(self, itunes_page: dict) -> dict: - stream_url = itunes_page["offers"][0]["assets"][0]["hlsUrl"] - url_parts = urllib.parse.urlparse(stream_url) - query = urllib.parse.parse_qs(url_parts.query, keep_blank_values=True) - query.update({"aec": "HD", "dsid": "1"}) - return url_parts._replace( - query=urllib.parse.urlencode(query, doseq=True) - ).geturl() - - def get_video_playlist_from_resolution( - self, - playlists: list[m3u8.Playlist], - ) -> m3u8.Playlist | None: - playlists_filtered = set() - for playlist in playlists: - for codec in self.codec: - if playlist.stream_info.codecs.startswith(codec.fourcc()): - playlists_filtered.add(playlist) - - if not playlists_filtered: - return None - - playlists_filtered = list(playlists_filtered) - - def sort_key(playlist: m3u8.Playlist) -> tuple[int, int, int, int]: - playlist_resolution = playlist.stream_info.resolution[-1] - resolution_difference = abs(playlist_resolution - int(self.resolution)) - codec_preference = len(self.codec) - for i, preferred_codec in enumerate(self.codec): - if playlist.stream_info.codecs.startswith(preferred_codec.fourcc()): - codec_preference = i - break - bandwidth = playlist.stream_info.bandwidth - return ( - resolution_difference, - codec_preference, - -playlist_resolution, - -bandwidth, - ) - - playlists_filtered.sort(key=sort_key) - - return playlists_filtered[0] - - def get_best_stereo_audio_playlist( - self, - playlist_master_data: dict, - ) -> dict | None: - audio_playlist = next( - ( - media - for media in playlist_master_data["media"] - if media["group_id"] == "audio-stereo-256" - ), - None, - ) - return audio_playlist - - def get_video_playlist_from_user( - self, - playlists: list[m3u8.Playlist], - ) -> m3u8.Playlist: - choices = [ - Choice( - name=" | ".join( - [ - playlist.stream_info.codecs[:4], - "x".join(str(v) for v in playlist.stream_info.resolution), - str(playlist.stream_info.bandwidth), - ] - ), - value=playlist, - ) - for playlist in playlists - ] - selected = inquirer.select( - message="Select which video codec to download: (Codec | Resolution | Bitrate)", - choices=choices, - ).execute() - - return selected - - def get_audio_playlist_from_user( - self, - playlist_master_data: dict, - ) -> dict: - choices = [ - Choice( - name=playlist["group_id"], - value=playlist, - ) - for playlist in playlist_master_data["media"] - if playlist.get("uri") - ] - selected = inquirer.select( - message="Select which audio codec to download:", - choices=choices, - ).execute() - - return selected - - def get_pssh(self, m3u8_obj: m3u8.M3U8) -> str: - return next( - ( - key - for key in m3u8_obj.keys - if key.keyformat == "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed" - ), - None, - ).uri - - def get_stream_info_video( - self, playlist_master_m3u8_obj: m3u8.M3U8 - ) -> StreamInfo | None: - stream_info = StreamInfo() - - if MusicVideoCodec.ASK not in self.codec: - playlist = self.get_video_playlist_from_resolution( - playlist_master_m3u8_obj.playlists - ) - else: - playlist = self.get_video_playlist_from_user( - playlist_master_m3u8_obj.playlists - ) - if not playlist: - return None - - stream_info.stream_url = playlist.uri - stream_info.codec = playlist.stream_info.codecs - - playlist_m3u8_obj = m3u8.load(stream_info.stream_url) - stream_info.widevine_pssh = self.get_pssh(playlist_m3u8_obj) - - return stream_info - - def get_stream_info_audio(self, playlist_master_data: dict) -> StreamInfo | None: - stream_info = StreamInfo() - - if self.codec != MusicVideoCodec.ASK: - playlist = self.get_best_stereo_audio_playlist(playlist_master_data) - else: - playlist = self.get_audio_playlist_from_user(playlist_master_data) - if not playlist: - return None - - stream_info.stream_url = playlist["uri"] - stream_info.codec = playlist["group_id"] - - playlist_m3u8_obj = m3u8.load(stream_info.stream_url) - stream_info.widevine_pssh = self.get_pssh(playlist_m3u8_obj) - - return stream_info - - def _get_stream_info( - self, - stream_url: str, - ) -> StreamInfoAv | None: - playlist_master_m3u8_obj = m3u8.load(stream_url) - - stream_info_video = self.get_stream_info_video(playlist_master_m3u8_obj) - stream_info_audio = self.get_stream_info_audio(playlist_master_m3u8_obj.data) - if not stream_info_video or not stream_info_audio: - return None - - use_mp4 = ( - any( - stream_info_video.codec.startswith(codec) - for codec in self.MP4_FORMAT_CODECS - ) - or any( - stream_info_audio.codec.startswith(codec) - for codec in self.MP4_FORMAT_CODECS - ) - or self.remux_format == RemuxFormatMusicVideo.MP4 - ) - if use_mp4: - file_format = MediaFileFormat.MP4 - else: - file_format = MediaFileFormat.M4V - - return StreamInfoAv( - video_track=stream_info_video, - audio_track=stream_info_audio, - file_format=file_format, - ) - - def get_stream_info_from_webplayback( - self, - webplayback: dict, - ) -> StreamInfoAv | None: - return self._get_stream_info(self.get_stream_url_from_webplayback(webplayback)) - - def get_stream_info_from_itunes_page( - self, - itunes_page: dict, - ) -> StreamInfoAv | None: - return self._get_stream_info(self.get_stream_url_from_itunes_page(itunes_page)) - - def get_decryption_key( - self, - stream_info: StreamInfoAv, - media_id: str, - ) -> DecryptionKeyAv: - decryption_key_video = self.downloader.get_decryption_key( - stream_info.video_track.widevine_pssh, - media_id, - ) - decryption_key_audio = self.downloader.get_decryption_key( - stream_info.audio_track.widevine_pssh, - media_id, - ) - - return DecryptionKeyAv( - video_track=decryption_key_video, - audio_track=decryption_key_audio, - ) - - def get_music_video_id_alt(self, metadata: dict) -> str | None: - music_video_url = metadata["attributes"].get("url") - if music_video_url is None: - return None - return music_video_url.split("/")[-1].split("?")[0] - - def get_tags( - self, - id_alt: str, - itunes_page: dict, - metadata: dict, - ) -> MediaTags: - metadata_itunes = self.downloader.itunes_api.get_resource(id_alt) - - explicitness = metadata_itunes[0]["trackExplicitness"] - if explicitness == "notExplicit": - rating = MediaRating.NONE - elif explicitness == "explicit": - rating = MediaRating.EXPLICIT - else: - rating = MediaRating.CLEAN - - tags = MediaTags( - artist=metadata_itunes[0]["artistName"], - artist_id=int(metadata_itunes[0]["artistId"]), - copyright=itunes_page.get("copyright"), - date=self.downloader.parse_date(metadata_itunes[0]["releaseDate"]), - genre=metadata_itunes[0]["primaryGenreName"], - genre_id=int(itunes_page["genres"][0]["genreId"]), - media_type=MediaType.MUSIC_VIDEO, - storefront=int(self.downloader.itunes_api.storefront_id.split("-")[0]), - title=metadata_itunes[0]["trackCensoredName"], - title_id=int(metadata["id"]), - rating=rating, - ) - - if len(metadata_itunes) > 1: - album = self.downloader.apple_music_api.get_album( - itunes_page["collectionId"] - ) - if not album: - return tags - - tags.album = metadata_itunes[1]["collectionCensoredName"] - tags.album_artist = metadata_itunes[1]["artistName"] - tags.album_id = int(itunes_page["collectionId"]) - tags.disc = metadata_itunes[0]["discNumber"] - tags.disc_total = metadata_itunes[0]["discCount"] - tags.compilation = album["attributes"]["isCompilation"] - tags.track = metadata_itunes[0]["trackNumber"] - tags.track_total = metadata_itunes[0]["trackCount"] - - return tags - - def decrypt( - self, - encrypted_path: Path, - decryption_key: str, - decrypted_path: Path, - ) -> None: - subprocess.run( - [ - self.downloader.mp4decrypt_path_full, - encrypted_path, - "--key", - f"1:{decryption_key}", - decrypted_path, - ], - check=True, - **self.downloader.subprocess_additional_args, - ) - - def remux_mp4box( - self, - decrypted_path_audio: Path, - decrypted_path_video: Path, - fixed_path: Path, - ) -> None: - subprocess.run( - [ - self.downloader.mp4box_path_full, - "-quiet", - "-add", - decrypted_path_audio, - "-add", - decrypted_path_video, - "-itags", - "artist=placeholder", - "-keep-utc", - "-new", - fixed_path, - ], - check=True, - **self.downloader.subprocess_additional_args, - ) - - def remux_ffmpeg( - self, - decrypted_path_video: Path, - decrypte_path_audio: Path, - fixed_path: Path, - ) -> None: - subprocess.run( - [ - self.downloader.ffmpeg_path_full, - "-loglevel", - "error", - "-y", - "-i", - decrypted_path_video, - "-i", - decrypte_path_audio, - "-movflags", - "+faststart", - "-c", - "copy", - "-c:s", - "mov_text", - fixed_path, - ], - check=True, - **self.downloader.subprocess_additional_args, - ) - - def stage( - self, - encrypted_path_video: Path, - encrypted_path_audio: Path, - decrypted_path_video: Path, - decrypted_path_audio: Path, - staged_path: Path, - decryption_key: DecryptionKeyAv, - ) -> None: - self.decrypt( - encrypted_path_video, - decryption_key.video_track.key, - decrypted_path_video, - ) - self.decrypt( - encrypted_path_audio, - decryption_key.audio_track.key, - decrypted_path_audio, - ) - - if self.downloader.remux_mode == RemuxMode.MP4BOX: - self.remux_mp4box( - decrypted_path_audio, - decrypted_path_video, - staged_path, - ) - elif self.downloader.remux_mode == RemuxMode.FFMPEG: - self.remux_ffmpeg( - decrypted_path_video, - decrypted_path_audio, - staged_path, - ) - - def get_cover_path(self, final_path: Path, cover_format: str) -> Path: - return final_path.with_suffix( - self.downloader.get_cover_file_extension(cover_format) - ) - - import typing - - def download( - self, - media_id: str = None, - media_metadata: dict = None, - playlist_attributes: dict = None, - playlist_track: int = None, - ) -> typing.Generator[DownloadInfo, None, None]: - yield from self.downloader._final_processing_wrapper( - self._download, - media_id, - media_metadata, - playlist_attributes, - playlist_track, - ) - - def _download( - self, - media_id: str = None, - media_metadata: dict = None, - playlist_attributes: dict = None, - playlist_track: int = None, - ) -> typing.Generator[DownloadInfo, None, None]: - download_info = DownloadInfo() - yield download_info - - if playlist_track is None and playlist_attributes: - raise ValueError( - "playlist_track must be provided if playlist_attributes is provided" - ) - if playlist_attributes: - playlist_tags = self.downloader.get_playlist_tags( - playlist_attributes, - playlist_track, - ) - else: - playlist_tags = None - download_info.playlist_tags = playlist_tags - - if not media_id and not media_metadata: - raise ValueError("Either media_id or media_metadata must be provided") - - if media_metadata: - media_id = self.downloader.get_media_id_of_library_media(media_metadata) - download_info.media_id = media_id - colored_media_id = color_text(media_id, colorama.Style.DIM) - - database_final_path = self.downloader.get_database_final_path(media_id) - if database_final_path: - download_info.final_path = database_final_path - yield download_info - raise MediaFileAlreadyExistsException(database_final_path) - - if not media_metadata: - logger.debug(f"[{colored_media_id}] Getting Music Video metadata") - media_metadata = self.downloader.apple_music_api.get_music_video(media_id) - download_info.media_metadata = media_metadata - - if not self.downloader.is_media_streamable(media_metadata): - yield download_info - raise MediaNotStreamableException() - - alt_media_id = self.get_music_video_id_alt(media_metadata) or media_id - download_info.alt_media_id = alt_media_id - - logger.debug(f"[{colored_media_id}] Getting iTunes page") - itunes_page = self.downloader.itunes_api.get_itunes_page( - "music-video", - alt_media_id, - ) - - logger.debug(f"[{colored_media_id}] Getting tags") - tags = self.get_tags( - alt_media_id, - itunes_page, - media_metadata, - ) - download_info.tags = tags - - if alt_media_id == media_id: - logger.debug(f"[{colored_media_id}] Getting stream info") - stream_info = self.get_stream_info_from_itunes_page(itunes_page) - else: - logger.debug(f"[{colored_media_id}] Getting webplayback info") - webplayback = self.downloader.apple_music_api.get_webplayback(media_id) - logger.debug(f"[{colored_media_id}] Getting stream info") - stream_info = self.get_stream_info_from_webplayback(webplayback) - - if not stream_info: - yield download_info - raise MediaFormatNotAvailableException() - - download_info.stream_info = stream_info - - final_path = self.downloader.get_final_path( - tags, - self.downloader.get_media_file_extension(stream_info.file_format), - playlist_tags, - ) - download_info.final_path = final_path - - cover_url = self.downloader.get_cover_url(media_metadata) - cover_format = self.downloader.get_cover_format(cover_url) - if cover_format and self.downloader.save_cover: - cover_path = self.get_cover_path(final_path, cover_format) - else: - cover_path = None - download_info.cover_url = cover_url - download_info.cover_format = cover_format - download_info.cover_path = cover_path - - if final_path.exists() and not self.downloader.overwrite: - yield download_info - raise MediaFileAlreadyExistsException(final_path) - - logger.debug(f"[{colored_media_id}] Getting decryption key") - decryption_key = self.get_decryption_key( - stream_info, - media_id, - ) - - encrypted_path_video = self.downloader.get_temp_path( - media_id, - "encrypted_video", - ".mp4", - ) - encrypted_path_audio = self.downloader.get_temp_path( - media_id, - "encrypted_audio", - ".m4a", - ) - decrypted_path_video = self.downloader.get_temp_path( - media_id, - "decrypted_video", - ".mp4", - ) - decrypted_path_audio = self.downloader.get_temp_path( - media_id, - "decrypted_audio", - ".m4a", - ) - staged_path = self.downloader.get_temp_path( - media_id, - "staged", - self.downloader.get_media_file_extension(stream_info.file_format), - ) - - logger.info(f"[{colored_media_id}] Downloading Music Video") - - logger.debug( - f'[{colored_media_id}] Downloading video to "{encrypted_path_video}"' - ) - self.downloader.download( - encrypted_path_video, - stream_info.video_track.stream_url, - ) - - logger.debug( - f'[{colored_media_id}] Downloading audio to "{encrypted_path_audio}"' - ) - self.downloader.download( - encrypted_path_audio, - stream_info.audio_track.stream_url, - ) - - logger.debug( - f"[{colored_media_id}] " - "Decrypting video/audio to " - f'{decrypted_path_video}"/"{decrypted_path_audio}" ' - f'and remuxing to "{staged_path}"' - ) - self.stage( - encrypted_path_video, - encrypted_path_audio, - decrypted_path_video, - decrypted_path_audio, - staged_path, - decryption_key, - ) - download_info.staged_path = staged_path - - yield download_info diff --git a/gamdl/downloader_post.py b/gamdl/downloader_post.py deleted file mode 100644 index f76e4f6..0000000 --- a/gamdl/downloader_post.py +++ /dev/null @@ -1,168 +0,0 @@ -from __future__ import annotations - -import logging -import typing -from pathlib import Path - -import colorama -from InquirerPy import inquirer -from InquirerPy.base.control import Choice - -from .downloader import Downloader -from .enums import PostQuality -from .exceptions import MediaFileAlreadyExistsException, MediaNotStreamableException -from .models import DownloadInfo, MediaTags -from .utils import color_text - -logger = logging.getLogger("gamdl") - - -class DownloaderPost: - QUALITY_RANK = [ - "1080pHdVideo", - "720pHdVideo", - "sdVideoWithPlusAudio", - "sdVideo", - "sd480pVideo", - "provisionalUploadVideo", - ] - - def __init__( - self, - downloader: Downloader, - quality: PostQuality = PostQuality.BEST, - ): - self.downloader = downloader - self.quality = quality - - def get_stream_url_best(self, metadata: dict) -> str: - best_quality = next( - ( - quality - for quality in self.QUALITY_RANK - if metadata["attributes"]["assetTokens"].get(quality) - ), - None, - ) - return metadata["attributes"]["assetTokens"][best_quality] - - def get_stream_url_from_user(self, metadata: dict) -> str: - qualities = list(metadata["attributes"]["assetTokens"].keys()) - choices = [ - Choice( - name=quality, - value=quality, - ) - for quality in qualities - ] - selected = inquirer.select( - message="Select which quality to download:", - choices=choices, - ).execute() - return metadata["attributes"]["assetTokens"][selected] - - def get_stream_url(self, metadata: dict) -> str: - if self.quality == PostQuality.BEST: - stream_url = self.get_stream_url_best(metadata) - elif self.quality == PostQuality.ASK: - stream_url = self.get_stream_url_from_user(metadata) - return stream_url - - def get_tags(self, metadata: dict) -> MediaTags: - attributes = metadata["attributes"] - upload_date = attributes.get("uploadDate") - return MediaTags( - artist=attributes.get("artistName"), - date=self.downloader.parse_date(upload_date) if upload_date else None, - title=attributes.get("name"), - title_id=int(metadata["id"]), - storefront=int(self.downloader.itunes_api.storefront_id.split("-")[0]), - ) - - def get_cover_path(self, final_path: Path, cover_format: str) -> Path: - return final_path.with_suffix( - self.downloader.get_cover_file_extension(cover_format) - ) - - def download( - self, - media_id: str = None, - media_metadata: dict = None, - ) -> typing.Generator[DownloadInfo, None, None]: - yield from self.downloader._final_processing_wrapper( - self._download, - media_id, - media_metadata, - ) - - def _download( - self, - media_id: str = None, - media_metadata: dict = None, - ) -> typing.Generator[DownloadInfo, None, None]: - download_info = DownloadInfo() - yield download_info - - if not media_id and not media_metadata: - raise ValueError("Either media_id or media_metadata must be provided") - - if media_metadata: - media_id = media_metadata["id"] - download_info.media_id = media_id - colored_media_id = color_text(media_id, colorama.Style.DIM) - - database_final_path = self.downloader.get_database_final_path(media_id) - if database_final_path: - download_info.final_path = database_final_path - yield download_info - raise MediaFileAlreadyExistsException(database_final_path) - - if not media_metadata: - logger.debug(f"[{colored_media_id}] Getting Post Video metadata") - media_metadata = self.downloader.apple_music_api.get_post(media_id) - download_info.media_metadata = media_metadata - - if not self.downloader.is_media_streamable(media_metadata): - yield download_info - raise MediaNotStreamableException() - - tags = self.get_tags(media_metadata) - final_path = self.downloader.get_final_path( - tags, - ".m4v", - None, - ) - download_info.tags = tags - download_info.final_path = final_path - - if final_path.exists() and not self.downloader.overwrite: - yield download_info - raise MediaFileAlreadyExistsException(final_path) - - cover_url = self.downloader.get_cover_url(media_metadata) - cover_format = self.downloader.get_cover_format(cover_url) - if cover_format and self.downloader.save_cover: - cover_path = self.get_cover_path(final_path, cover_format) - else: - cover_path = None - download_info.cover_url = cover_url - download_info.cover_format = cover_format - download_info.cover_path = cover_path - - stream_url = self.get_stream_url(media_metadata) - staged_path = self.downloader.get_temp_path( - media_id, - "stage", - ".m4v", - ) - - logger.info(f"[{colored_media_id}] Downloading Post Video") - - logger.debug(f"[{colored_media_id}] Downloading to {staged_path}") - self.downloader.download_ytdlp( - staged_path, - stream_url, - ) - download_info.staged_path = staged_path - - yield download_info diff --git a/gamdl/downloader_song.py b/gamdl/downloader_song.py deleted file mode 100644 index 9062100..0000000 --- a/gamdl/downloader_song.py +++ /dev/null @@ -1,755 +0,0 @@ -from __future__ import annotations - -import base64 -import datetime -import json -import logging -import re -import subprocess -import typing -from pathlib import Path -from xml.dom import minidom -from xml.etree import ElementTree - -import colorama -import m3u8 -from InquirerPy import inquirer -from InquirerPy.base.control import Choice -from pywidevine import PSSH -from pywidevine.license_protocol_pb2 import WidevinePsshData - -from .downloader import Downloader -from .enums import MediaFileFormat, RemuxMode, SongCodec, SyncedLyricsFormat -from .exceptions import * -from .models import ( - DecryptionKey, - DecryptionKeyAv, - DownloadInfo, - Lyrics, - MediaRating, - MediaTags, - MediaType, - StreamInfo, - StreamInfoAv, -) -from .utils import color_text - -logger = logging.getLogger("gamdl") - - -class DownloaderSong: - DEFAULT_DECRYPTION_KEY = "32b8ade1769e26b1ffb8986352793fc6" - MP4_FORMAT_CODECS = ["ec-3"] - SONG_CODEC_REGEX_MAP = { - SongCodec.AAC: r"audio-stereo-\d+", - SongCodec.AAC_HE: r"audio-HE-stereo-\d+", - SongCodec.AAC_BINAURAL: r"audio-stereo-\d+-binaural", - SongCodec.AAC_DOWNMIX: r"audio-stereo-\d+-downmix", - SongCodec.AAC_HE_BINAURAL: r"audio-HE-stereo-\d+-binaural", - SongCodec.AAC_HE_DOWNMIX: r"audio-HE-stereo-\d+-downmix", - SongCodec.ATMOS: r"audio-atmos-.*", - SongCodec.AC3: r"audio-ac3-.*", - SongCodec.ALAC: r"audio-alac-.*", - } - DRM_DEFAULT_KEY_MAPPING = { - "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed": ( - "data:text/plain;base64,AAAAOHBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAABgSEAAAAAA" - "AAAAAczEvZTEgICBI88aJmwY=" - ), - "com.microsoft.playready": ( - "data:text/plain;charset=UTF-16;base64,vgEAAAEAAQC0ATwAVwBSAE0ASABFAEEARABF" - "AFIAIAB4AG0AbABuAHMAPQAiAGgAdAB0AHAAOgAvAC8AcwBjAGgAZQBtAGEAcwAuAG0AaQBjAH" - "IAbwBzAG8AZgB0AC4AYwBvAG0ALwBEAFIATQAvADIAMAAwADcALwAwADMALwBQAGwAYQB5AFIA" - "ZQBhAGQAeQBIAGUAYQBkAGUAcgAiACAAdgBlAHIAcwBpAG8AbgA9ACIANAAuADMALgAwAC4AMA" - "AiAD4APABEAEEAVABBAD4APABQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsASQBEAFMAPgA8" - "AEsASQBEACAAQQBMAEcASQBEAD0AIgBBAEUAUwBDAEIAQwAiACAAVgBBAEwAVQBFAD0AIgBBAE" - "EAQQBBAEEAQQBBAEEAQQBBAEIAegBNAFMAOQBsAE0AUwBBAGcASQBBAD0APQAiAD4APAAvAEsA" - "SQBEAD4APAAvAEsASQBEAFMAPgA8AC8AUABSAE8AVABFAEMAVABJAE4ARgBPAD4APAAvAEQAQQ" - "BUAEEAPgA8AC8AVwBSAE0ASABFAEEARABFAFIAPgA=" - ), - "com.apple.streamingkeydelivery": "skd://itunes.apple.com/P000000000/s1/e1", - } - - def __init__( - self, - downloader: Downloader, - codec: SongCodec = SongCodec.AAC_LEGACY, - synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC, - ): - self.downloader = downloader - self.codec = codec - self.synced_lyrics_format = synced_lyrics_format - - def _search_m3u8_metadata(self, m3u8_data: dict, data_id: str) -> dict: - searched = next( - ( - session_data - for session_data in m3u8_data["session_data"] - if session_data["data_id"] == data_id - ), - None, - ) - if not searched: - return None - return json.loads(base64.b64decode(searched["value"]).decode("utf-8")) - - def get_audio_session_key_metadata(self, m3u8_data: dict) -> dict: - return self._search_m3u8_metadata( - m3u8_data, - "com.apple.hls.AudioSessionKeyInfo", - ) - - def get_asset_metadata(self, m3u8_data: dict) -> dict: - return self._search_m3u8_metadata( - m3u8_data, - "com.apple.hls.audioAssetMetadata", - ) - - def get_playlist_from_codec(self, m3u8_data: dict) -> dict | None: - m3u8_master_playlists = [ - playlist - for playlist in m3u8_data["playlists"] - if re.fullmatch( - self.SONG_CODEC_REGEX_MAP[self.codec], playlist["stream_info"]["audio"] - ) - ] - if not m3u8_master_playlists: - return None - m3u8_master_playlists.sort(key=lambda x: x["stream_info"]["average_bandwidth"]) - return m3u8_master_playlists[-1] - - def get_playlist_from_user(self, m3u8_data: dict) -> dict | None: - m3u8_master_playlists = [playlist for playlist in m3u8_data["playlists"]] - choices = [ - Choice( - name=playlist["stream_info"]["audio"], - value=playlist, - ) - for playlist in m3u8_master_playlists - ] - selected = inquirer.select( - message="Select which codec to download:", - choices=choices, - ).execute() - return selected - - def _get_drm_uri_from_session_key( - self, - drm_infos: dict, - drm_ids: list, - drm_key: str, - ) -> str | None: - drm_info = next( - ( - drm_infos[drm_id] - for drm_id in drm_ids - if drm_infos[drm_id].get(drm_key) and drm_id != "1" - ), - None, - ) - if not drm_info: - return None - return drm_info[drm_key]["URI"] - - def _get_drm_uri_from_m3u8_keys( - self, - m3u8_obj: m3u8.M3U8, - drm_key: str, - ) -> str | None: - drm_uri = next( - ( - key - for key in m3u8_obj.keys - if key.keyformat == drm_key - and key.uri != self.DRM_DEFAULT_KEY_MAPPING[drm_key] - ), - None, - ) - if not drm_uri: - return None - return drm_uri.uri - - def _get_stream_info(self, m3u8_url: str) -> StreamInfoAv | None: - stream_info = StreamInfo() - m3u8_master_obj = m3u8.load(m3u8_url) - m3u8_master_data = m3u8_master_obj.data - - if self.codec == SongCodec.ASK: - playlist = self.get_playlist_from_user(m3u8_master_data) - else: - playlist = self.get_playlist_from_codec(m3u8_master_data) - if playlist is None: - return None - stream_info.stream_url = m3u8_master_obj.base_uri + playlist["uri"] - - stream_info.codec = playlist["stream_info"]["codecs"] - is_mp4 = any( - stream_info.codec.startswith(possible_codec) - for possible_codec in self.MP4_FORMAT_CODECS - ) - - session_key_metadata = self.get_audio_session_key_metadata(m3u8_master_data) - if session_key_metadata: - asset_metadata = self.get_asset_metadata(m3u8_master_data) - variant_id = playlist["stream_info"]["stable_variant_id"] - drm_ids = asset_metadata[variant_id]["AUDIO-SESSION-KEY-IDS"] - ( - stream_info.widevine_pssh, - stream_info.playready_pssh, - stream_info.fairplay_key, - ) = ( - self._get_drm_uri_from_session_key( - session_key_metadata, - drm_ids, - drm_key, - ) - for drm_key in self.DRM_DEFAULT_KEY_MAPPING.keys() - ) - else: - m3u8_obj = m3u8.load(stream_info.stream_url) - ( - stream_info.widevine_pssh, - stream_info.playready_pssh, - stream_info.fairplay_key, - ) = ( - self._get_drm_uri_from_m3u8_keys( - m3u8_obj, - drm_key, - ) - for drm_key in self.DRM_DEFAULT_KEY_MAPPING.keys() - ) - - return StreamInfoAv( - audio_track=stream_info, - file_format=MediaFileFormat.MP4 if is_mp4 else MediaFileFormat.M4A, - ) - - def get_stream_info(self, track_metadata: dict) -> StreamInfoAv | None: - m3u8_url = track_metadata["attributes"]["extendedAssetUrls"].get("enhancedHls") - if not m3u8_url: - return None - return self._get_stream_info(m3u8_url) - - def get_stream_info_legacy(self, webplayback: dict) -> StreamInfoAv: - flavor = "32:ctrp64" if self.codec == SongCodec.AAC_HE_LEGACY else "28:ctrp256" - - stream_info = StreamInfo() - stream_info.stream_url = next( - i for i in webplayback["assets"] if i["flavor"] == flavor - )["URL"] - - m3u8_obj = m3u8.load(stream_info.stream_url) - stream_info.widevine_pssh = m3u8_obj.keys[0].uri - - return StreamInfoAv( - audio_track=stream_info, - file_format=MediaFileFormat.M4A, - ) - - def get_decryption_key( - self, - stream_info: StreamInfoAv, - media_id: str, - ) -> DecryptionKeyAv: - decryption_key = self.downloader.get_decryption_key( - stream_info.audio_track.widevine_pssh, - media_id, - ) - return DecryptionKeyAv( - audio_track=decryption_key, - ) - - def get_decryption_key_legacy( - self, - stream_info: StreamInfoAv, - media_id: str, - ) -> DecryptionKeyAv: - stream_info_audio = stream_info.audio_track - - try: - cdm_session = self.downloader.cdm.open() - - widevine_pssh_data = WidevinePsshData() - widevine_pssh_data.algorithm = 1 - widevine_pssh_data.key_ids.append( - base64.b64decode(stream_info_audio.widevine_pssh.split(",")[1]) - ) - pssh_obj = PSSH(widevine_pssh_data.SerializeToString()) - - challenge = base64.b64encode( - self.downloader.cdm.get_license_challenge(cdm_session, pssh_obj) - ).decode() - license = self.downloader.apple_music_api.get_widevine_license( - media_id, - stream_info.audio_track.widevine_pssh, - challenge, - ) - - self.downloader.cdm.parse_license(cdm_session, license) - decryption_key = next( - i - for i in self.downloader.cdm.get_keys(cdm_session) - if i.type == "CONTENT" - ) - finally: - self.downloader.cdm.close(cdm_session) - return DecryptionKeyAv( - audio_track=DecryptionKey( - kid=decryption_key.kid.hex, - key=decryption_key.key.hex(), - ) - ) - - @staticmethod - def parse_datetime_obj_from_timestamp_ttml( - timestamp_ttml: str, - ) -> datetime.datetime: - mins_secs_ms = re.findall(r"\d+", timestamp_ttml) - ms, secs, mins = 0, 0, 0 - if len(mins_secs_ms) == 2 and ":" in timestamp_ttml: - secs, mins = int(mins_secs_ms[-1]), int(mins_secs_ms[-2]) - elif len(mins_secs_ms) == 1: - ms = int(mins_secs_ms[-1]) - else: - secs = float(f"{mins_secs_ms[-2]}.{mins_secs_ms[-1]}") - if len(mins_secs_ms) > 2: - mins = int(mins_secs_ms[-3]) - return datetime.datetime.fromtimestamp( - (mins * 60) + secs + (ms / 1000), - tz=datetime.timezone.utc, - ) - - def get_lyrics_synced_timestamp_lrc(self, timestamp_ttml: str) -> str: - datetime_obj = self.parse_datetime_obj_from_timestamp_ttml(timestamp_ttml) - ms_new = datetime_obj.strftime("%f")[:-3] - if int(ms_new[-1]) >= 5: - ms = int(f"{int(ms_new[:2]) + 1}") * 10 - datetime_obj += datetime.timedelta(milliseconds=ms) - datetime.timedelta( - microseconds=datetime_obj.microsecond - ) - return datetime_obj.strftime("%M:%S.%f")[:-4] - - def get_lyrics_synced_timestamp_srt(self, timestamp_ttml: str) -> str: - datetime_obj = self.parse_datetime_obj_from_timestamp_ttml(timestamp_ttml) - return datetime_obj.strftime("00:%M:%S,%f")[:-3] - - def get_lyrics_synced_line_lrc(self, timestamp_ttml: str, text: str) -> str: - return f"[{self.get_lyrics_synced_timestamp_lrc(timestamp_ttml)}]{text}" - - def get_lyrics_synced_line_srt( - self, - index: int, - timestamp_ttml_start: str, - timestamp_ttml_end: str, - text: str, - ) -> str: - timestamp_srt_start = self.get_lyrics_synced_timestamp_srt(timestamp_ttml_start) - timestamp_srt_end = self.get_lyrics_synced_timestamp_srt(timestamp_ttml_end) - return f"{index}\n{timestamp_srt_start} --> {timestamp_srt_end}\n{text}\n" - - def get_lyrics(self, track_metadata: dict) -> Lyrics | None: - lyrics = Lyrics() - if not track_metadata["attributes"]["hasLyrics"]: - return None - elif track_metadata.get("relationships") is None: - track_metadata = self.downloader.apple_music_api.get_song( - self.downloader.get_media_id_of_library_media(track_metadata) - ) - if ( - track_metadata["relationships"].get("lyrics") - and track_metadata["relationships"]["lyrics"].get("data") - and track_metadata["relationships"]["lyrics"]["data"][0].get("attributes") - ): - lyrics = self._get_lyrics( - track_metadata["relationships"]["lyrics"]["data"][0]["attributes"][ - "ttml" - ] - ) - return lyrics - - def _get_lyrics(self, lyrics_ttml: str) -> Lyrics: - lyrics_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( - f"{self.get_lyrics_synced_line_lrc(p.attrib.get('begin'), p.text)}" - ) - - if self.synced_lyrics_format == SyncedLyricsFormat.SRT: - synced_lyrics.append( - f"{self.get_lyrics_synced_line_srt(index, p.attrib.get('begin'), p.attrib.get('end'), p.text)}" - ) - - if self.synced_lyrics_format == SyncedLyricsFormat.TTML: - if not synced_lyrics: - synced_lyrics.append( - minidom.parseString(lyrics_ttml).toprettyxml() - ) - continue - - index += 1 - - return Lyrics( - synced="\n".join(synced_lyrics) + "\n", - unsynced="\n\n".join( - ["\n".join(lyric_group) for lyric_group in unsynced_lyrics] - ), - ) - - def get_tags(self, webplayback: dict, lyrics_unsynced: str) -> MediaTags: - webplayback_metadata = webplayback["assets"][0]["metadata"] - tags = MediaTags( - album=webplayback_metadata["playlistName"], - album_artist=webplayback_metadata["playlistArtistName"], - album_id=int(webplayback_metadata["playlistId"]), - album_sort=webplayback_metadata["sort-album"], - artist=webplayback_metadata["artistName"], - artist_id=int(webplayback_metadata["artistId"]), - artist_sort=webplayback_metadata["sort-artist"], - comment=webplayback_metadata.get("comments"), - compilation=webplayback_metadata["compilation"], - composer=webplayback_metadata.get("composerName"), - composer_id=( - int(webplayback_metadata.get("composerId")) - if webplayback_metadata.get("composerId") - else None - ), - composer_sort=webplayback_metadata.get("sort-composer"), - copyright=webplayback_metadata.get("copyright"), - date=( - self.downloader.parse_date(webplayback_metadata["releaseDate"]) - if webplayback_metadata.get("releaseDate") - else None - ), - disc=webplayback_metadata["discNumber"], - disc_total=webplayback_metadata["discCount"], - gapless=webplayback_metadata["gapless"], - genre=webplayback_metadata.get("genre"), - genre_id=int(webplayback_metadata["genreId"]), - lyrics=lyrics_unsynced if lyrics_unsynced else None, - media_type=MediaType.SONG, - rating=MediaRating(webplayback_metadata["explicit"]), - storefront=webplayback_metadata["s"], - title=webplayback_metadata["itemName"], - title_id=int(webplayback_metadata["itemId"]), - title_sort=webplayback_metadata["sort-name"], - track=webplayback_metadata["trackNumber"], - track_total=webplayback_metadata["trackCount"], - xid=webplayback_metadata.get("xid"), - ) - return tags - - def fix_key_id(self, encrypted_path: Path): - count = 0 - with open(encrypted_path, "rb+") as file: - while data := file.read(4096): - pos = file.tell() - i = 0 - while tenc := max(0, data.find(b"tenc", i)): - kid = tenc + 12 - file.seek(max(0, pos - 4096) + kid, 0) - file.write(bytes.fromhex(f"{count:032}")) - count += 1 - i = kid + 1 - file.seek(pos, 0) - - def decrypt( - self, - encrypted_path: Path, - decrypted_path: Path, - decryption_key: str, - codec: SongCodec, - ): - if codec.is_legacy(): - keys = [ - "--key", - f"1:{decryption_key}", - ] - else: - self.fix_key_id(encrypted_path) - keys = [ - "--key", - "0" * 31 + "1" + f":{decryption_key}", - "--key", - "0" * 32 + f":{self.DEFAULT_DECRYPTION_KEY}", - ] - subprocess.run( - [ - self.downloader.mp4decrypt_path_full, - *keys, - encrypted_path, - decrypted_path, - ], - check=True, - **self.downloader.subprocess_additional_args, - ) - - def stage( - self, - codec: SongCodec, - encrypted_path: Path, - decrypted_path: Path, - decryption_key: DecryptionKeyAv, - staged_path: Path, - ): - if codec.is_legacy() and self.downloader.remux_mode == RemuxMode.FFMPEG: - self.remux_ffmpeg( - encrypted_path, - staged_path, - decryption_key.audio_track.key, - ) - else: - self.decrypt( - encrypted_path, - decrypted_path, - decryption_key.audio_track.key, - codec, - ) - if self.downloader.remux_mode == RemuxMode.FFMPEG: - self.remux_ffmpeg( - decrypted_path, - staged_path, - ) - else: - self.remux_mp4box( - decrypted_path, - staged_path, - ) - - def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path): - subprocess.run( - [ - self.downloader.mp4box_path_full, - "-quiet", - "-add", - decrypted_path, - "-itags", - "artist=placeholder", - "-keep-utc", - "-new", - remuxed_path, - ], - check=True, - **self.downloader.subprocess_additional_args, - ) - - def remux_ffmpeg( - self, - decrypted_path: Path, - remuxed_path: Path, - decryption_key: str = None, - ): - if decryption_key: - decryption_key_arg = [ - "-decryption_key", - decryption_key, - ] - else: - decryption_key_arg = [] - subprocess.run( - [ - self.downloader.ffmpeg_path_full, - "-loglevel", - "error", - "-y", - *decryption_key_arg, - "-i", - decrypted_path, - "-c", - "copy", - "-movflags", - "+faststart", - remuxed_path, - ], - check=True, - **self.downloader.subprocess_additional_args, - ) - - def get_lyrics_synced_path(self, final_path: Path) -> Path: - return final_path.with_suffix("." + self.synced_lyrics_format.value) - - def get_cover_path(self, final_path: Path, cover_format: str) -> Path: - return final_path.parent / ( - "Cover" + self.downloader.get_cover_file_extension(cover_format) - ) - - def save_lyrics_synced(self, lyrics_synced_path: Path, lyrics_synced: str): - lyrics_synced_path.parent.mkdir(parents=True, exist_ok=True) - lyrics_synced_path.write_text(lyrics_synced, encoding="utf8") - - def download( - self, - media_id: str = None, - media_metadata: dict = None, - playlist_attributes: dict = None, - playlist_track: int = None, - ) -> typing.Generator[DownloadInfo, None, None]: - yield from self.downloader._final_processing_wrapper( - self._download, - media_id, - media_metadata, - playlist_attributes, - playlist_track, - ) - - def _download( - self, - media_id: str = None, - media_metadata: dict = None, - playlist_attributes: dict = None, - playlist_track: int = None, - ) -> typing.Generator[DownloadInfo, None, None]: - download_info = DownloadInfo() - yield download_info - - if playlist_track is None and playlist_attributes: - raise ValueError( - "playlist_track must be provided if playlist_attributes is provided" - ) - if playlist_attributes: - playlist_tags = self.downloader.get_playlist_tags( - playlist_attributes, - playlist_track, - ) - else: - playlist_tags = None - download_info.playlist_tags = playlist_tags - - if not media_id and not media_metadata: - raise ValueError("Either media_id or media_metadata must be provided") - - if media_metadata: - media_id = self.downloader.get_media_id_of_library_media(media_metadata) - download_info.media_id = media_id - colored_media_id = color_text(media_id, colorama.Style.DIM) - - database_final_path = self.downloader.get_database_final_path(media_id) - if database_final_path: - download_info.final_path = database_final_path - yield download_info - raise MediaFileAlreadyExistsException(database_final_path) - - if not media_metadata: - logger.debug(f"[{colored_media_id}] Getting Song metadata") - media_metadata = self.downloader.apple_music_api.get_song(media_id) - download_info.media_metadata = media_metadata - - if not self.downloader.is_media_streamable(media_metadata): - raise MediaNotStreamableException() - - logger.debug(f"[{colored_media_id}] Getting lyrics") - lyrics = self.get_lyrics(media_metadata) - download_info.lyrics = lyrics - - logger.debug(f"[{colored_media_id}] Getting webplayback info") - webplayback = self.downloader.apple_music_api.get_webplayback( - media_id, - ) - tags = self.get_tags( - webplayback, - lyrics.unsynced if lyrics else None, - ) - final_path = self.downloader.get_final_path(tags, ".m4a", playlist_tags) - download_info.tags = tags - download_info.final_path = final_path - - if lyrics and lyrics.synced: - synced_lyrics_path = self.get_lyrics_synced_path(final_path) - else: - synced_lyrics_path = None - download_info.synced_lyrics_path = synced_lyrics_path - - if self.downloader.synced_lyrics_only: - logger.info( - f"[{colored_media_id}] Downloading synced lyrics only, skipping song download" - ) - yield download_info - return - - cover_url = self.downloader.get_cover_url(media_metadata) - cover_format = self.downloader.get_cover_format(cover_url) - if cover_format: - cover_path = self.get_cover_path(final_path, cover_format) - else: - cover_path = None - download_info.cover_url = cover_url - download_info.cover_format = cover_format - download_info.cover_path = cover_path - - if final_path.exists() and not self.downloader.overwrite: - yield download_info - raise MediaFileAlreadyExistsException(final_path) - - logger.debug(f"[{colored_media_id}] Getting stream info") - if self.codec.is_legacy(): - stream_info = self.get_stream_info_legacy(webplayback) - logger.debug(f"[{colored_media_id}] Getting decryption key") - decryption_key = self.get_decryption_key_legacy( - stream_info, - media_id, - ) - download_info.stream_info = stream_info - download_info.decryption_key = decryption_key - else: - stream_info = self.get_stream_info(media_metadata) - - if not stream_info or not stream_info.audio_track.widevine_pssh: - yield download_info - raise MediaFormatNotAvailableException() - - logger.debug(f"[{colored_media_id}] Getting decryption key") - decryption_key = self.get_decryption_key( - stream_info, - media_id, - ) - download_info.stream_info = stream_info - download_info.decryption_key = decryption_key - - encrypted_path = self.downloader.get_temp_path( - media_id, - "encrypted", - ".m4a", - ) - decrypted_path = self.downloader.get_temp_path( - media_id, - "decrypted", - ".m4a", - ) - staged_path = self.downloader.get_temp_path( - media_id, - "staged", - self.downloader.get_media_file_extension(stream_info.file_format), - ) - - logger.info(f"[{colored_media_id}] Downloading song") - - logger.debug(f'[{colored_media_id}] Downloading to "{encrypted_path}"') - self.downloader.download( - encrypted_path, - download_info.stream_info.audio_track.stream_url, - ) - - logger.debug( - f'[{colored_media_id}] Decryping/remuxing to "{decrypted_path}"/"{staged_path}"' - ) - self.stage( - self.codec, - encrypted_path, - decrypted_path, - decryption_key, - staged_path, - ) - download_info.staged_path = staged_path - - yield download_info diff --git a/gamdl/exceptions.py b/gamdl/exceptions.py deleted file mode 100644 index 7484ccb..0000000 --- a/gamdl/exceptions.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - - -class MediaNotStreamableException(Exception): - DEFAULT_MESSAGE = "Media is not streamable" - - def __init__(self): - super().__init__(self.DEFAULT_MESSAGE) - - -class MediaFileAlreadyExistsException(Exception): - DEFAULT_MESSAGE = "Media file already exists at '{media_path}'" - - def __init__(self, media_path: Path): - super().__init__(self.DEFAULT_MESSAGE.format(media_path=media_path)) - - -class MediaFormatNotAvailableException(Exception): - DEFAULT_MESSAGE = "Requested media format or codec not available" - - def __init__(self): - super().__init__(self.DEFAULT_MESSAGE) diff --git a/gamdl/interface/__init__.py b/gamdl/interface/__init__.py new file mode 100644 index 0000000..c18a1f1 --- /dev/null +++ b/gamdl/interface/__init__.py @@ -0,0 +1,6 @@ +from .enums import * +from .interface import * +from .interface_music_video import * +from .interface_song import * +from .interface_uploaded_video import * +from .types import * diff --git a/gamdl/interface/constants.py b/gamdl/interface/constants.py new file mode 100644 index 0000000..b682635 --- /dev/null +++ b/gamdl/interface/constants.py @@ -0,0 +1,57 @@ +MEDIA_TYPE_STR_MAP = { + 1: "Song", + 6: "Music Video", +} + +MEDIA_RATING_STR_MAP = { + 0: "None", + 1: "Explicit", + 2: "Clean", +} + +LEGACY_SONG_CODECS = {"aac-legacy", "aac-he-legacy"} + +DRM_DEFAULT_KEY_MAPPING = { + "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed": ( + "data:text/plain;base64,AAAAOHBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAABgSEAAAAAA" + "AAAAAczEvZTEgICBI88aJmwY=" + ), + "com.microsoft.playready": ( + "data:text/plain;charset=UTF-16;base64,vgEAAAEAAQC0ATwAVwBSAE0ASABFAEEARABF" + "AFIAIAB4AG0AbABuAHMAPQAiAGgAdAB0AHAAOgAvAC8AcwBjAGgAZQBtAGEAcwAuAG0AaQBjAH" + "IAbwBzAG8AZgB0AC4AYwBvAG0ALwBEAFIATQAvADIAMAAwADcALwAwADMALwBQAGwAYQB5AFIA" + "ZQBhAGQAeQBIAGUAYQBkAGUAcgAiACAAdgBlAHIAcwBpAG8AbgA9ACIANAAuADMALgAwAC4AMA" + "AiAD4APABEAEEAVABBAD4APABQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsASQBEAFMAPgA8" + "AEsASQBEACAAQQBMAEcASQBEAD0AIgBBAEUAUwBDAEIAQwAiACAAVgBBAEwAVQBFAD0AIgBBAE" + "EAQQBBAEEAQQBBAEEAQQBBAEIAegBNAFMAOQBsAE0AUwBBAGcASQBBAD0APQAiAD4APAAvAEsA" + "SQBEAD4APAAvAEsASQBEAFMAPgA8AC8AUABSAE8AVABFAEMAVABJAE4ARgBPAD4APAAvAEQAQQ" + "BUAEEAPgA8AC8AVwBSAE0ASABFAEEARABFAFIAPgA=" + ), + "com.apple.streamingkeydelivery": "skd://itunes.apple.com/P000000000/s1/e1", +} +MP4_FORMAT_CODECS = ["ec-3", "hvc1", "audio-atmos", "audio-ec3"] +SONG_CODEC_REGEX_MAP = { + "aac": r"audio-stereo-\d+", + "aac-he": r"audio-HE-stereo-\d+", + "aac-binaural": r"audio-stereo-\d+-binaural", + "aac-downmix": r"audio-stereo-\d+-downmix", + "aac-he-binaural": r"audio-HE-stereo-\d+-binaural", + "aac-he-downmix": r"audio-HE-stereo-\d+-downmix", + "atmos": r"audio-atmos-.*", + "ac3": r"audio-ac3-.*", + "alac": r"audio-alac-.*", +} + +FOURCC_MAP = { + "h264": "avc1", + "h265": "hvc1", +} + +UPLOADED_VIDEO_QUALITY_RANK = [ + "1080pHdVideo", + "720pHdVideo", + "sdVideoWithPlusAudio", + "sdVideo", + "sd480pVideo", + "provisionalUploadVideo", +] diff --git a/gamdl/enums.py b/gamdl/interface/enums.py similarity index 62% rename from gamdl/enums.py rename to gamdl/interface/enums.py index 9058b83..1594c94 100644 --- a/gamdl/enums.py +++ b/gamdl/interface/enums.py @@ -1,14 +1,46 @@ from enum import Enum - -class DownloadMode(Enum): - YTDLP = "ytdlp" - NM3U8DLRE = "nm3u8dlre" +from .constants import ( + FOURCC_MAP, + LEGACY_SONG_CODECS, + MEDIA_RATING_STR_MAP, + MEDIA_TYPE_STR_MAP, +) -class RemuxMode(Enum): - FFMPEG = "ffmpeg" - MP4BOX = "mp4box" +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): @@ -26,13 +58,7 @@ class SongCodec(Enum): ASK = "ask" def is_legacy(self) -> bool: - return self in {SongCodec.AAC_LEGACY, SongCodec.AAC_HE_LEGACY} - - -class SyncedLyricsFormat(Enum): - LRC = "lrc" - SRT = "srt" - TTML = "ttml" + return self.value in LEGACY_SONG_CODECS class MusicVideoCodec(Enum): @@ -41,15 +67,7 @@ class MusicVideoCodec(Enum): ASK = "ask" def fourcc(self) -> str: - return { - MusicVideoCodec.H264: "avc1", - MusicVideoCodec.H265: "hvc1", - }.get(self) - - -class RemuxFormatMusicVideo(Enum): - M4V = "m4v" - MP4 = "mp4" + return FOURCC_MAP[self.value] class MusicVideoResolution(Enum): @@ -66,48 +84,6 @@ class MusicVideoResolution(Enum): return int(self.value[:-1]) -class MediaFileFormat(Enum): - M4A = "m4a" - MP4 = "mp4" - M4V = "m4v" - - -class PostQuality(Enum): +class UploadedVideoQuality(Enum): BEST = "best" ASK = "ask" - - -class CoverFormat(Enum): - JPG = "jpg" - PNG = "png" - RAW = "raw" - - -class MediaType(Enum): - SONG = 1 - MUSIC_VIDEO = 6 - - def __str__(self) -> str: - return { - MediaType.SONG: "Song", - MediaType.MUSIC_VIDEO: "Music Video", - }[self] - - def __int__(self) -> int: - return self.value - - -class MediaRating(Enum): - NONE = 0 - EXPLICIT = 1 - CLEAN = 2 - - def __str__(self) -> str: - return { - MediaRating.NONE: "None", - MediaRating.EXPLICIT: "Explicit", - MediaRating.CLEAN: "Clean", - }[self] - - def __int__(self) -> int: - return self.value diff --git a/gamdl/interface/interface.py b/gamdl/interface/interface.py new file mode 100644 index 0000000..926903b --- /dev/null +++ b/gamdl/interface/interface.py @@ -0,0 +1,65 @@ +import base64 +import datetime +import logging + +from pywidevine import PSSH, Cdm + +from ..api.apple_music_api import AppleMusicApi +from ..api.itunes_api import ItunesApi +from .types import DecryptionKey + +logger = logging.getLogger(__name__) + + +class AppleMusicInterface: + def __init__( + self, + apple_music_api: AppleMusicApi, + itunes_api: ItunesApi, + ) -> None: + self.apple_music_api = apple_music_api + self.itunes_api = itunes_api + + @staticmethod + def get_media_id_of_library_media(library_media_metadata: dict) -> str: + play_params = library_media_metadata["attributes"].get("playParams", {}) + return play_params.get("catalogId", library_media_metadata["id"]) + + @staticmethod + def parse_date(date: str) -> datetime.datetime: + return datetime.datetime.fromisoformat(date.split("Z")[0]) + + async def get_decryption_key( + self, + track_uri: str, + track_id: str, + cdm: Cdm, + ) -> DecryptionKey: + try: + cdm_session = cdm.open() + + pssh_obj = PSSH(track_uri.split(",")[-1]) + + challenge = base64.b64encode( + cdm.get_license_challenge(cdm_session, pssh_obj) + ).decode() + license = await self.apple_music_api.get_license_exchange( + track_id, + track_uri, + challenge, + ) + + cdm.parse_license(cdm_session, license["license"]) + decryption_key_info = next( + i for i in cdm.get_keys(cdm_session) if i.type == "CONTENT" + ) + finally: + cdm.close(cdm_session) + + decryption_key = DecryptionKey( + key=decryption_key_info.key.hex(), + kid=decryption_key_info.kid.hex, + ) + logger.debug(f"Decryption key: {decryption_key}") + + return decryption_key diff --git a/gamdl/interface/interface_music_video.py b/gamdl/interface/interface_music_video.py new file mode 100644 index 0000000..6fa9ad9 --- /dev/null +++ b/gamdl/interface/interface_music_video.py @@ -0,0 +1,343 @@ +import logging +import urllib.parse + +import m3u8 +from InquirerPy import inquirer +from InquirerPy.base.control import Choice +from pywidevine import Cdm + +from ..utils import get_response_text +from .constants import MP4_FORMAT_CODECS +from .enums import MediaRating, MediaType, MusicVideoCodec, MusicVideoResolution +from .interface import AppleMusicInterface +from .types import DecryptionKeyAv, MediaFileFormat, MediaTags, StreamInfo, StreamInfoAv + +logger = logging.getLogger(__name__) + + +class AppleMusicMusicVideoInterface: + def __init__( + self, + interface: AppleMusicInterface, + ): + self.interface = interface + + async def get_itunes_page_metadata( + self, + music_video_metadata: dict, + ) -> dict: + alt_id = self.get_alt_id(music_video_metadata) + itunes_page = await self.interface.itunes_api.get_itunes_page( + "music-video", + alt_id, + ) + return itunes_page["storePlatformData"]["product-dv"]["results"][alt_id] + + def get_m3u8_master_url_from_webplayback(self, webplayback: dict) -> str: + m3u8_master_url = webplayback["hls-playlist-url"] + return m3u8_master_url + + def get_m3u8_master_url_from_itunes_page_metadata( + self, + itunes_page_metadata: dict, + ) -> dict: + stream_url = itunes_page_metadata["offers"][0]["assets"][0]["hlsUrl"] + + url_parts = urllib.parse.urlparse(stream_url) + query = urllib.parse.parse_qs(url_parts.query, keep_blank_values=True) + query.update({"aec": "HD", "dsid": "1"}) + + m3u8_master_url = url_parts._replace( + query=urllib.parse.urlencode(query, doseq=True) + ).geturl() + + return m3u8_master_url + + def get_alt_id(self, metadata: dict) -> str | None: + music_video_url = metadata["attributes"].get("url") + if music_video_url is None: + return None + + alt_id = music_video_url.split("/")[-1].split("?")[0] + logger.debug(f"Alt ID: {alt_id}") + + return alt_id + + async def get_tags( + self, + metadata: dict, + itunes_page_metadata: dict, + ) -> MediaTags: + alt_id = self.get_alt_id(metadata) + lookup_metadata = (await self.interface.itunes_api.get_lookup_result(alt_id))[ + "results" + ] + + explicitness = lookup_metadata[0]["trackExplicitness"] + if explicitness == "notExplicit": + rating = MediaRating.NONE + elif explicitness == "explicit": + rating = MediaRating.EXPLICIT + else: + rating = MediaRating.CLEAN + + tags = MediaTags( + artist=lookup_metadata[0]["artistName"], + artist_id=int(lookup_metadata[0]["artistId"]), + copyright=itunes_page_metadata.get("copyright"), + date=self.interface.parse_date(lookup_metadata[0]["releaseDate"]), + genre=lookup_metadata[0]["primaryGenreName"], + genre_id=int(itunes_page_metadata["genres"][0]["genreId"]), + media_type=MediaType.MUSIC_VIDEO, + storefront=int(self.interface.itunes_api.storefront_id.split("-")[0]), + title=lookup_metadata[0]["trackCensoredName"], + title_id=int(metadata["id"]), + rating=rating, + ) + + if len(lookup_metadata) > 1: + album_response = await self.interface.apple_music_api.get_album( + itunes_page_metadata["collectionId"] + ) + if not album_response: + return tags + album = album_response["data"][0] + + tags.album = lookup_metadata[1]["collectionCensoredName"] + tags.album_artist = lookup_metadata[1]["artistName"] + tags.album_id = int(itunes_page_metadata["collectionId"]) + tags.disc = lookup_metadata[0]["discNumber"] + tags.disc_total = lookup_metadata[0]["discCount"] + tags.compilation = album["attributes"]["isCompilation"] + tags.track = lookup_metadata[0]["trackNumber"] + tags.track_total = lookup_metadata[0]["trackCount"] + + logger.debug(f"Tags: {tags}") + + return tags + + async def get_stream_info( + self, + metadata: dict, + itunes_page_metadata: dict, + codec_priority: list[MusicVideoCodec], + resolution: MusicVideoResolution, + ) -> StreamInfoAv: + alt_video_id = self.get_alt_id(metadata) + if alt_video_id == metadata["id"]: + m3u8_master_url = self.get_m3u8_master_url_from_itunes_page_metadata( + itunes_page_metadata, + ) + else: + webplayback_response = await self.interface.apple_music_api.get_webplayback( + metadata["id"] + ) + m3u8_master_url = self.get_m3u8_master_url_from_webplayback( + webplayback_response["songList"][0], + ) + + playlist_master_m3u8_obj = m3u8.loads(await get_response_text(m3u8_master_url)) + playlist_master_m3u8_obj.base_uri = m3u8_master_url.rpartition("/")[0] + stream_info_video = await self.get_stream_info_video( + playlist_master_m3u8_obj, + codec_priority, + resolution, + ) + stream_info_audio = await self.get_stream_info_audio( + playlist_master_m3u8_obj.data, + codec_priority, + ) + if not stream_info_video or not stream_info_audio: + return None + + use_mp4 = any( + stream_info_video.codec.startswith(codec) for codec in MP4_FORMAT_CODECS + ) or any( + stream_info_audio.codec.startswith(codec) for codec in MP4_FORMAT_CODECS + ) + if use_mp4: + file_format = MediaFileFormat.MP4 + else: + file_format = MediaFileFormat.M4V + + stream_info = StreamInfoAv( + video_track=stream_info_video, + audio_track=stream_info_audio, + file_format=file_format, + ) + logger.debug(f"Stream info: {stream_info}") + + return stream_info + + def get_video_playlist_from_resolution( + self, + video_playlists: list[m3u8.Playlist], + codec: MusicVideoCodec, + resolution: MusicVideoResolution, + ) -> m3u8.Playlist | None: + playlists_filtered = [ + playlist + for playlist in video_playlists + if playlist.stream_info.codecs.startswith(codec.fourcc()) + ] + if not playlists_filtered: + return None + + def sort_key(playlist: m3u8.Playlist) -> tuple[int, int, int, int]: + playlist_resolution = playlist.stream_info.resolution[-1] + resolution_difference = abs(playlist_resolution - int(resolution)) + bandwidth = playlist.stream_info.bandwidth + + return ( + resolution_difference, + -playlist_resolution, + -bandwidth, + ) + + playlists_filtered.sort(key=sort_key) + + return playlists_filtered[0] + + def get_best_stereo_audio_playlist( + self, + playlist_master_data: dict, + ) -> dict | None: + audio_playlist = next( + ( + media + for media in playlist_master_data["media"] + if media["group_id"] == "audio-stereo-256" + ), + None, + ) + return audio_playlist + + async def get_video_playlist_from_user( + self, + video_playlists: list[m3u8.Playlist], + ) -> m3u8.Playlist: + choices = [ + Choice( + name=" | ".join( + [ + playlist.stream_info.codecs[:4], + "x".join(str(v) for v in playlist.stream_info.resolution), + str(playlist.stream_info.bandwidth), + ] + ), + value=playlist, + ) + for playlist in video_playlists + ] + selected = await inquirer.select( + message="Select which video codec to download: (Codec | Resolution | Bitrate)", + choices=choices, + ).execute_async() + + return selected + + async def get_audio_playlist_from_user( + self, + playlist_master_data: dict, + ) -> dict: + choices = [ + Choice( + name=playlist["group_id"], + value=playlist, + ) + for playlist in playlist_master_data["media"] + if playlist.get("uri") + ] + selected = await inquirer.select( + message="Select which audio codec to download:", + choices=choices, + ).execute_async() + + return selected + + def get_pssh(self, m3u8_obj: m3u8.M3U8) -> str: + return next( + ( + key + for key in m3u8_obj.keys + if key.keyformat == "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed" + ), + None, + ).uri + + async def get_stream_info_video( + self, + playlist_master_m3u8_obj: m3u8.M3U8, + codec_priority: list[MusicVideoCodec], + resolution: MusicVideoResolution, + ) -> StreamInfo | None: + stream_info = StreamInfo() + + if MusicVideoCodec.ASK not in codec_priority: + for codec in codec_priority: + playlist = self.get_video_playlist_from_resolution( + playlist_master_m3u8_obj.playlists, + codec, + resolution, + ) + if playlist: + break + 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 + + playlist_m3u8_obj = m3u8.loads(await get_response_text(stream_info.stream_url)) + stream_info.widevine_pssh = self.get_pssh(playlist_m3u8_obj) + + return stream_info + + async def get_stream_info_audio( + self, + playlist_master_data: dict, + codec_priority: list[MusicVideoCodec], + ) -> StreamInfo | None: + stream_info = StreamInfo() + + if MusicVideoCodec.ASK not in codec_priority: + playlist = self.get_best_stereo_audio_playlist(playlist_master_data) + else: + playlist = await self.get_audio_playlist_from_user(playlist_master_data) + + if not playlist: + return None + + stream_info.stream_url = playlist["uri"] + stream_info.codec = playlist["group_id"] + + playlist_m3u8_obj = m3u8.loads(await get_response_text(stream_info.stream_url)) + stream_info.widevine_pssh = self.get_pssh(playlist_m3u8_obj) + + return stream_info + + async def get_decryption_key( + self, + stream_info: StreamInfoAv, + cdm: Cdm, + ) -> DecryptionKeyAv: + decryption_key_video = await self.interface.get_decryption_key( + stream_info.video_track.widevine_pssh, + stream_info.media_id, + cdm, + ) + decryption_key_audio = await self.interface.get_decryption_key( + stream_info.audio_track.widevine_pssh, + stream_info.media_id, + cdm, + ) + + return DecryptionKeyAv( + video_track=decryption_key_video, + audio_track=decryption_key_audio, + ) diff --git a/gamdl/interface/interface_song.py b/gamdl/interface/interface_song.py new file mode 100644 index 0000000..7c0e97c --- /dev/null +++ b/gamdl/interface/interface_song.py @@ -0,0 +1,456 @@ +import base64 +import datetime +import json +import logging +import re +from xml.dom import minidom +from xml.etree import ElementTree + +import m3u8 +from InquirerPy import inquirer +from InquirerPy.base.control import Choice +from pywidevine import PSSH, Cdm +from pywidevine.license_protocol_pb2 import WidevinePsshData + +from ..utils import get_response_text +from .constants import DRM_DEFAULT_KEY_MAPPING, MP4_FORMAT_CODECS, SONG_CODEC_REGEX_MAP +from .enums import MediaRating, MediaType, SongCodec, SyncedLyricsFormat +from .interface import AppleMusicInterface +from .types import ( + DecryptionKey, + DecryptionKeyAv, + Lyrics, + MediaFileFormat, + MediaTags, + StreamInfo, + StreamInfoAv, +) + +logger = logging.getLogger(__name__) + + +class AppleMusicSongInterface: + def __init__( + self, + interface: AppleMusicInterface, + ) -> None: + self.interface = interface + + async def get_lyrics( + self, + song_metadata: dict, + synced_lyrics_format: SyncedLyricsFormat, + ) -> Lyrics | None: + if not song_metadata["attributes"]["hasLyrics"]: + return None + + if ( + "relationships" not in song_metadata + or "lyrics" not in song_metadata["relationships"] + ): + song_metadata = ( + await self.interface.apple_music_api.get_song( + self.interface.get_media_id_of_library_media(song_metadata) + ) + )["data"][0] + + if ( + "lyrics" in song_metadata["relationships"] + and "data" in song_metadata["relationships"]["lyrics"] + and len(song_metadata["relationships"]["lyrics"]["data"]) > 0 + and "attributes" in song_metadata["relationships"]["lyrics"]["data"][0] + and song_metadata["relationships"]["lyrics"]["data"][0]["attributes"].get( + "ttml" + ) + is not None + ): + lyrics = self._get_lyrics( + song_metadata["relationships"]["lyrics"]["data"][0]["attributes"][ + "ttml" + ], + synced_lyrics_format, + ) + logging.debug(f"Lyrics: {lyrics}") + + return lyrics + + def _get_lyrics( + self, + lyrics_ttml: str, + synced_lyrics_format: SyncedLyricsFormat, + ) -> Lyrics: + lyrics_ttml_et = ElementTree.fromstring(lyrics_ttml) + unsynced_lyrics = [] + synced_lyrics = [] + index = 1 + + for div in lyrics_ttml_et.iter("{http://www.w3.org/ns/ttml}div"): + stanza = [] + unsynced_lyrics.append(stanza) + + for p in div.iter("{http://www.w3.org/ns/ttml}p"): + if p.text is not None: + stanza.append(p.text) + + if p.attrib.get("begin"): + if synced_lyrics_format == SyncedLyricsFormat.LRC: + synced_lyrics.append(self._get_lyrics_line_lrc(p)) + + if synced_lyrics_format == SyncedLyricsFormat.SRT: + synced_lyrics.append(self._get_lyrics_line_srt(index, p)) + + if synced_lyrics_format == SyncedLyricsFormat.TTML: + if not synced_lyrics: + synced_lyrics.append( + minidom.parseString(lyrics_ttml).toprettyxml() + ) + continue + + index += 1 + + return Lyrics( + synced="\n".join(synced_lyrics + ["\n"]), + unsynced="\n\n".join( + ["\n".join(lyric_group) for lyric_group in unsynced_lyrics] + ), + ) + + 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_tags( + self, + webplayback: dict, + lyrics: str | None = None, + ) -> MediaTags: + webplayback_metadata = webplayback["songList"][0]["assets"][0]["metadata"] + + tags = MediaTags( + album=webplayback_metadata["playlistName"], + album_artist=webplayback_metadata["playlistArtistName"], + album_id=int(webplayback_metadata["playlistId"]), + album_sort=webplayback_metadata["sort-album"], + artist=webplayback_metadata["artistName"], + artist_id=int(webplayback_metadata["artistId"]), + artist_sort=webplayback_metadata["sort-artist"], + comment=webplayback_metadata.get("comments"), + compilation=webplayback_metadata["compilation"], + composer=webplayback_metadata.get("composerName"), + composer_id=( + int(webplayback_metadata.get("composerId")) + if webplayback_metadata.get("composerId") + else None + ), + composer_sort=webplayback_metadata.get("sort-composer"), + copyright=webplayback_metadata.get("copyright"), + date=( + self.interface.parse_date(webplayback_metadata["releaseDate"]) + if webplayback_metadata.get("releaseDate") + else None + ), + disc=webplayback_metadata["discNumber"], + disc_total=webplayback_metadata["discCount"], + gapless=webplayback_metadata["gapless"], + genre=webplayback_metadata.get("genre"), + genre_id=int(webplayback_metadata["genreId"]), + lyrics=lyrics if lyrics else None, + media_type=MediaType.SONG, + rating=MediaRating(webplayback_metadata["explicit"]), + storefront=webplayback_metadata["s"], + title=webplayback_metadata["itemName"], + title_id=int(webplayback_metadata["itemId"]), + title_sort=webplayback_metadata["sort-name"], + track=webplayback_metadata["trackNumber"], + track_total=webplayback_metadata["trackCount"], + xid=webplayback_metadata.get("xid"), + ) + logger.debug(f"Tags: {tags}") + + return tags + + async def get_stream_info( + self, + song_metadata: dict, + codec: SongCodec, + ) -> StreamInfoAv | None: + m3u8_master_url = song_metadata["attributes"]["extendedAssetUrls"].get( + "enhancedHls" + ) + if not m3u8_master_url: + return None + + m3u8_master_obj = m3u8.loads(await get_response_text(m3u8_master_url)) + m3u8_master_data = m3u8_master_obj.data + + if codec == SongCodec.ASK: + playlist = await self._get_playlist_from_user(m3u8_master_data) + else: + playlist = self._get_playlist_from_codec( + m3u8_master_data, + codec, + ) + + if playlist is None: + return None + + stream_info = StreamInfo() + stream_info.stream_url = ( + f"{m3u8_master_url.rpartition('/')[0]}/{playlist['uri']}" + ) + stream_info.codec = playlist["stream_info"]["codecs"] + is_mp4 = any(stream_info.codec.startswith(codec) for codec in MP4_FORMAT_CODECS) + + session_key_metadata = self._get_audio_session_key_metadata(m3u8_master_data) + + if session_key_metadata: + asset_metadata = self._get_asset_metadata(m3u8_master_data) + variant_id = playlist["stream_info"]["stable_variant_id"] + drm_ids = asset_metadata[variant_id]["AUDIO-SESSION-KEY-IDS"] + + stream_info.widevine_pssh = self._get_drm_uri_from_session_key( + session_key_metadata, + drm_ids, + "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed", + ) + stream_info.playready_pssh = self._get_drm_uri_from_session_key( + session_key_metadata, + drm_ids, + "com.microsoft.playready", + ) + stream_info.fairplay_key = self._get_drm_uri_from_session_key( + session_key_metadata, + drm_ids, + "com.apple.streamingkeydelivery", + ) + else: + m3u8_obj = m3u8.loads(await get_response_text(stream_info.stream_url)) + + stream_info.widevine_pssh = self._get_drm_uri_from_m3u8_keys( + m3u8_obj, + "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed", + ) + stream_info.playready_pssh = self._get_drm_uri_from_m3u8_keys( + m3u8_obj, + "com.microsoft.playready", + ) + stream_info.fairplay_key = self._get_drm_uri_from_m3u8_keys( + m3u8_obj, + "com.apple.streamingkeydelivery", + ) + + stream_info_av = StreamInfoAv( + audio_track=stream_info, + file_format=MediaFileFormat.MP4 if is_mp4 else MediaFileFormat.M4A, + ) + logger.debug(f"Stream info: {stream_info_av}") + + return stream_info_av + + def _get_m3u8_metadata(self, m3u8_data: dict, data_id: str) -> dict | None: + for session_data in m3u8_data.get("session_data", []): + if session_data["data_id"] == data_id: + return json.loads( + base64.b64decode(session_data["value"]).decode("utf-8") + ) + return None + + def _get_audio_session_key_metadata(self, m3u8_data: dict) -> dict | None: + return self._get_m3u8_metadata( + m3u8_data, + "com.apple.hls.AudioSessionKeyInfo", + ) + + def _get_asset_metadata(self, m3u8_data: dict) -> dict | None: + return self._get_m3u8_metadata( + m3u8_data, + "com.apple.hls.audioAssetMetadata", + ) + + def _get_playlist_from_codec( + self, m3u8_data: dict, codec: SongCodec + ) -> dict | None: + matching_playlists = [ + playlist + for playlist in m3u8_data["playlists"] + if re.fullmatch( + SONG_CODEC_REGEX_MAP[codec.value], playlist["stream_info"]["audio"] + ) + ] + + if not matching_playlists: + return None + + return max( + matching_playlists, + key=lambda x: x["stream_info"]["average_bandwidth"], + ) + + async def _get_playlist_from_user(self, m3u8_data: dict) -> dict | None: + choices = [ + Choice( + name=playlist["stream_info"]["audio"], + value=playlist, + ) + for playlist in m3u8_data["playlists"] + ] + + return await inquirer.select( + message="Select which codec to download:", + choices=choices, + ).execute_async() + + def _get_drm_uri_from_session_key( + self, + drm_infos: dict, + drm_ids: list, + drm_key: str, + ) -> str | None: + for drm_id in drm_ids: + if drm_id != "1" and drm_key in drm_infos.get(drm_id, {}): + return drm_infos[drm_id][drm_key]["URI"] + return None + + def _get_drm_uri_from_m3u8_keys( + self, + m3u8_obj: m3u8.M3U8, + drm_key: str, + ) -> str | None: + default_uri = DRM_DEFAULT_KEY_MAPPING[drm_key] + + for key in m3u8_obj.keys: + if key.keyformat == drm_key and key.uri != default_uri: + return key.uri + return None + + async def get_stream_info_legacy( + self, + webplayback: dict, + codec: SongCodec, + ) -> StreamInfoAv: + flavor = "32:ctrp64" if codec == SongCodec.AAC_HE_LEGACY else "28:ctrp256" + + stream_info = StreamInfo() + stream_info.stream_url = next( + i for i in webplayback["songList"][0]["assets"] if i["flavor"] == flavor + )["URL"] + + m3u8_obj = m3u8.loads(await get_response_text(stream_info.stream_url)) + stream_info.widevine_pssh = m3u8_obj.keys[0].uri + + stream_info_av = StreamInfoAv( + media_id=webplayback["songList"][0]["songId"], + audio_track=stream_info, + file_format=MediaFileFormat.M4A, + ) + logger.debug(f"Stream info legacy: {stream_info_av}") + + return stream_info_av + + async def get_decryption_key_legacy( + self, + stream_info: StreamInfoAv, + cdm: Cdm, + ) -> DecryptionKeyAv: + stream_info_audio = stream_info.audio_track + + try: + cdm_session = cdm.open() + + widevine_pssh_data = WidevinePsshData() + widevine_pssh_data.algorithm = 1 + widevine_pssh_data.key_ids.append( + base64.b64decode(stream_info_audio.widevine_pssh.split(",")[1]) + ) + pssh_obj = PSSH(widevine_pssh_data.SerializeToString()) + + challenge = base64.b64encode( + cdm.get_license_challenge(cdm_session, pssh_obj) + ).decode() + license_response = ( + await self.interface.apple_music_api.get_license_exchange( + stream_info.media_id, + stream_info.audio_track.widevine_pssh, + challenge, + ) + ) + + cdm.parse_license(cdm_session, license_response["license"]) + + decryption_key = next( + i for i in cdm.get_keys(cdm_session) if i.type == "CONTENT" + ) + finally: + cdm.close(cdm_session) + + decryption_key = DecryptionKeyAv( + audio_track=DecryptionKey( + kid=decryption_key.kid.hex, + key=decryption_key.key.hex(), + ) + ) + logger.debug(f"Decryption key legacy: {decryption_key}") + + return decryption_key + + async def get_decryption_key( + self, + stream_info: StreamInfoAv, + cdm: Cdm, + ) -> DecryptionKeyAv: + return DecryptionKeyAv( + audio_track=await self.interface.get_decryption_key( + stream_info.audio_track.widevine_pssh, + stream_info.media_id, + cdm, + ) + ) diff --git a/gamdl/interface/interface_uploaded_video.py b/gamdl/interface/interface_uploaded_video.py new file mode 100644 index 0000000..c7e0f34 --- /dev/null +++ b/gamdl/interface/interface_uploaded_video.py @@ -0,0 +1,86 @@ +import logging + +from InquirerPy import inquirer +from InquirerPy.base.control import Choice + +from ..interface.enums import UploadedVideoQuality +from ..interface.types import MediaTags +from .constants import UPLOADED_VIDEO_QUALITY_RANK +from .interface import AppleMusicInterface +from .types import StreamInfo, StreamInfoAv, MediaFileFormat + +logger = logging.getLogger(__name__) + + +class AppleMusicUploadedVideoInterface: + def __init__(self, interface: AppleMusicInterface): + self.interface = interface + + def get_stream_url_best(self, metadata: dict) -> str: + best_quality = next( + ( + quality + for quality in UPLOADED_VIDEO_QUALITY_RANK + if metadata["attributes"]["assetTokens"].get(quality) + ), + None, + ) + return metadata["attributes"]["assetTokens"][best_quality] + + async def get_stream_url_from_user(self, metadata: dict) -> str: + qualities = list(metadata["attributes"]["assetTokens"].keys()) + choices = [ + Choice( + name=quality, + value=quality, + ) + for quality in qualities + ] + selected = await inquirer.select( + message="Select which quality to download:", + choices=choices, + ).execute_async() + + return metadata["attributes"]["assetTokens"][selected] + + async def get_stream_url( + self, metadata: dict, quality: UploadedVideoQuality + ) -> str: + if quality == UploadedVideoQuality.BEST: + stream_url = self.get_stream_url_best(metadata) + + if quality == UploadedVideoQuality.ASK: + stream_url = await self.get_stream_url_from_user(metadata) + + logger.debug(f"Stream URL: {stream_url}") + + return stream_url + + async def get_stream_info( + self, + metadata: dict, + quality: UploadedVideoQuality, + ) -> StreamInfo: + stream_url = await self.get_stream_url(metadata, quality) + stream_info = StreamInfoAv( + file_format=MediaFileFormat.M4V, + video_track=StreamInfo( + stream_url=stream_url, + ), + ) + return stream_info + + def get_tags(self, metadata: dict) -> MediaTags: + attributes = metadata["attributes"] + upload_date = attributes.get("uploadDate") + + tags = MediaTags( + artist=attributes.get("artistName"), + date=self.interface.parse_date(upload_date) if upload_date else None, + title=attributes.get("name"), + title_id=int(metadata["id"]), + storefront=int(self.interface.itunes_api.storefront_id.split("-")[0]), + ) + logger.debug(f"Tags: {tags}") + + return tags diff --git a/gamdl/models.py b/gamdl/interface/types.py similarity index 80% rename from gamdl/models.py rename to gamdl/interface/types.py index 9912243..bdf30db 100644 --- a/gamdl/models.py +++ b/gamdl/interface/types.py @@ -1,65 +1,15 @@ -from __future__ import annotations - import datetime -import typing from dataclasses import dataclass -from pathlib import Path from .enums import MediaFileFormat, MediaRating, MediaType -@dataclass -class UrlInfo: - storefront: str = None - type: str = None - slug: str = None - id: str = None - sub_id: str = None - library_storefront: str = None - library_type: str = None - library_id: str = None - - -@dataclass -class DownloadQueue: - playlist_attributes: dict = None - medias_metadata: list[dict] = None - - @dataclass class Lyrics: synced: str = None unsynced: str = None -@dataclass -class StreamInfo: - stream_url: str = None - widevine_pssh: str = None - playready_pssh: str = None - fairplay_key: str = None - codec: str = None - - -@dataclass -class StreamInfoAv: - video_track: StreamInfo = None - audio_track: StreamInfo = None - file_format: MediaFileFormat = None - - -@dataclass -class DecryptionKey: - kid: str = None - key: str = None - - -@dataclass -class DecryptionKeyAv: - video_track: DecryptionKey = None - audio_track: DecryptionKey = None - - @dataclass class MediaTags: album: str = None @@ -92,7 +42,7 @@ class MediaTags: track_total: int = None xid: str = None - def to_mp4_tags(self, date_format: str = None) -> dict[str, typing.Any]: + def as_mp4_tags(self, date_format: str = None) -> dict: disc_mp4 = [ [ self.disc if self.disc is not None else 0, @@ -162,18 +112,29 @@ class PlaylistTags: @dataclass -class DownloadInfo: - media_metadata: dict = None +class StreamInfo: + stream_url: str = None + widevine_pssh: str = None + playready_pssh: str = None + fairplay_key: str = None + codec: str = None + + +@dataclass +class StreamInfoAv: media_id: str = None - alt_media_id: str = None - playlist_tags: PlaylistTags = None - lyrics: Lyrics = None - tags: MediaTags = None - final_path: Path = None - cover_url: str = None - cover_format: str = None - cover_path: Path = None - stream_info: StreamInfoAv = None - decryption_key: DecryptionKeyAv = None - staged_path: Path = None - synced_lyrics_path: Path = None + 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 diff --git a/gamdl/itunes_api.py b/gamdl/itunes_api.py deleted file mode 100644 index d459cda..0000000 --- a/gamdl/itunes_api.py +++ /dev/null @@ -1,82 +0,0 @@ -from __future__ import annotations - -import functools - -import requests - -from .constants import STOREFRONT_IDS -from .utils import raise_response_exception - - -class ItunesApi: - ITUNES_LOOKUP_API_URL = "https://itunes.apple.com/lookup" - ITUNES_PAGE_API_URL = "https://music.apple.com" - - def __init__( - self, - storefront: str = "us", - language: str = "en-US", - ): - self.storefront = storefront - self.language = language - self._setup_session() - - def _setup_session(self): - try: - self.storefront_id = STOREFRONT_IDS[self.storefront.upper()] - except KeyError: - raise Exception(f"No storefront id for {self.storefront}") - self.session = requests.Session() - self.session.params = { - "country": self.storefront, - "lang": self.language, - } - self.session.headers = { - "X-Apple-Store-Front": f"{self.storefront_id} t:music31", - } - - @functools.lru_cache() - def get_resource( - self, - resource_id: str, - entity: str = "album", - ) -> dict | None: - response = self.session.get( - self.ITUNES_LOOKUP_API_URL, - params={ - "id": resource_id, - "entity": entity, - }, - ) - try: - response.raise_for_status() - response_dict = response.json() - except ( - requests.HTTPError, - requests.exceptions.JSONDecodeError, - ): - raise_response_exception(response) - if response_dict.get("results"): - return response_dict["results"] - return None - - def get_itunes_page( - self, - resource_type: str, - resource_id: str, - ) -> dict | None: - response = self.session.get( - f"{self.ITUNES_PAGE_API_URL}/{resource_type}/{resource_id}" - ) - try: - response.raise_for_status() - response_dict = response.json() - itunes_page = response_dict["storePlatformData"]["product-dv"][ - "results" - ].get(resource_id) - except ( - requests.HTTPError, - requests.exceptions.JSONDecodeError, - ): - raise_response_exception(response) - return itunes_page diff --git a/gamdl/utils.py b/gamdl/utils.py index 35dacf7..7a529bb 100644 --- a/gamdl/utils.py +++ b/gamdl/utils.py @@ -1,46 +1,71 @@ -from pathlib import Path +import json +import typing +import subprocess +import asyncio -import click -import colorama -import requests - -from .constants import X_NOT_FOUND_STRING +import httpx -def color_text(text: str, color) -> str: - return color + text + colorama.Style.RESET_ALL +def raise_for_status(httpx_response: httpx.Response, valid_responses: set[int] = {200}): + if httpx_response.status_code not in valid_responses: + raise httpx._exceptions.HTTPError( + f"HTTP error {httpx_response.status_code}: {httpx_response.text}" + ) -def raise_response_exception(response: requests.Response): - raise Exception( - f"Request failed with status code {response.status_code}: {response.text}" +def safe_json(httpx_response: httpx.Response) -> dict: + try: + return httpx_response.json() + except (json.JSONDecodeError, UnicodeDecodeError): + return {} + + +async def get_response_text(url: str) -> str: + async with httpx.AsyncClient() as client: + response = await client.get(url) + raise_for_status(response) + return response.text + + +async def async_subprocess(*args: str, silent: bool = False) -> None: + if silent: + additional_args = { + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + } + else: + additional_args = {} + + proc = await asyncio.create_subprocess_exec( + *args, + **additional_args, ) + await proc.communicate() + + if proc.returncode != 0: + raise Exception(f'"{args[0]}" exited with code {proc.returncode}') -def prompt_path(is_file: bool, initial_path: Path, description: str) -> Path: - path_validator = click.Path( - exists=True, - file_okay=is_file, - dir_okay=not is_file, - path_type=Path, +async def safe_gather( + *tasks: typing.Awaitable[typing.Any], + limit: int = 5, + retries: int = 3, +) -> list[typing.Any]: + semaphore = asyncio.Semaphore(limit) + + async def bounded_task(task: typing.Awaitable[typing.Any]) -> typing.Any: + async with semaphore: + last_exception = None + for attempt in range(retries + 1): + try: + return await task + except Exception as e: + last_exception = e + if attempt < retries: + await asyncio.sleep(2**attempt) + return last_exception + + return await asyncio.gather( + *(bounded_task(task) for task in tasks), + return_exceptions=True, ) - path_type = "file" if is_file else "folder" - while True: - try: - path_obj = path_validator.convert(initial_path, None, None) - break - except click.BadParameter as e: - path_str = click.prompt( - ( - f"{X_NOT_FOUND_STRING.format(description, initial_path.absolute())} or " - "the specified path is not valid. " - f"Move the {path_type} to that location, type a new path " - f"or drag and drop the {path_type} here. " - "Then, press enter to continue" - ), - default=str(initial_path), - show_default=False, - ) - path_str = path_str.strip('"') - initial_path = Path(path_str) - return path_obj diff --git a/pyproject.toml b/pyproject.toml index 443ae97..1745de5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,28 +1,21 @@ [project] name = "gamdl" +version = "2.7" description = "A command-line app for downloading Apple Music songs, music videos and post videos." -requires-python = ">=3.10" -authors = [{ name = "glomatico" }] -dependencies = [ - "click", - "colorama", - "inquirerpy", - "m3u8", - "mutagen", - "pillow", - "pywidevine", - "yt-dlp", -] readme = "README.md" -dynamic = ["version"] - -[project.urls] -homepage = "https://github.com/glomatico/gamdl" -repository = "https://github.com/glomatico/gamdl" - -[build-system] -requires = ["flit_core"] -build-backend = "flit_core.buildapi" +license = { file = "LICENSE" } +requires-python = ">=3.10" +dependencies = [ + "async-lru>=2.0.5", + "click>=8.3.0", + "httpx>=0.28.1", + "inquirerpy>=0.3.4", + "m3u8>=6.0.0", + "mutagen>=1.47.0", + "pillow>=12.0.0", + "pywidevine>=1.8.0", + "yt-dlp>=2025.10.22", +] [project.scripts] -gamdl = "gamdl.cli:main" +gamdl = "gamdl.cli.cli:main" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 2c05a99..0000000 --- a/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -click -colorama -inquirerpy -m3u8 -mutagen -pillow -pywidevine -pyyaml -termcolor -yt-dlp diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..47c267e --- /dev/null +++ b/uv.lock @@ -0,0 +1,641 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "async-lru" +version = "2.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/4d/71ec4d3939dc755264f680f6c2b4906423a304c3d18e96853f0a595dfe97/async_lru-2.0.5.tar.gz", hash = "sha256:481d52ccdd27275f42c43a928b4a50c3bfb2d67af4e78b170e3e0bb39c66e5bb", size = 10380, upload-time = "2025-03-16T17:25:36.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/49/d10027df9fce941cb8184e78a02857af36360d33e1721df81c5ed2179a1a/async_lru-2.0.5-py3-none-any.whl", hash = "sha256:ab95404d8d2605310d345932697371a5f40def0487c03d6d0ad9138de52c9943", size = 6069, upload-time = "2025-03-16T17:25:35.422Z" }, +] + +[[package]] +name = "backports-datetime-fromisoformat" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/81/eff3184acb1d9dc3ce95a98b6f3c81a49b4be296e664db8e1c2eeabef3d9/backports_datetime_fromisoformat-2.0.3.tar.gz", hash = "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d", size = 23588, upload-time = "2024-12-28T20:18:15.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/4b/d6b051ca4b3d76f23c2c436a9669f3be616b8cf6461a7e8061c7c4269642/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f681f638f10588fa3c101ee9ae2b63d3734713202ddfcfb6ec6cea0778a29d4", size = 27561, upload-time = "2024-12-28T20:16:47.974Z" }, + { url = "https://files.pythonhosted.org/packages/6d/40/e39b0d471e55eb1b5c7c81edab605c02f71c786d59fb875f0a6f23318747/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:cd681460e9142f1249408e5aee6d178c6d89b49e06d44913c8fdfb6defda8d1c", size = 34448, upload-time = "2024-12-28T20:16:50.712Z" }, + { url = "https://files.pythonhosted.org/packages/f2/28/7a5c87c5561d14f1c9af979231fdf85d8f9fad7a95ff94e56d2205e2520a/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ee68bc8735ae5058695b76d3bb2aee1d137c052a11c8303f1e966aa23b72b65b", size = 27093, upload-time = "2024-12-28T20:16:52.994Z" }, + { url = "https://files.pythonhosted.org/packages/80/ba/f00296c5c4536967c7d1136107fdb91c48404fe769a4a6fd5ab045629af8/backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8273fe7932db65d952a43e238318966eab9e49e8dd546550a41df12175cc2be4", size = 52836, upload-time = "2024-12-28T20:16:55.283Z" }, + { url = "https://files.pythonhosted.org/packages/e3/92/bb1da57a069ddd601aee352a87262c7ae93467e66721d5762f59df5021a6/backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d57ea50aa5a524bb239688adc1d1d824c31b6094ebd39aa164d6cadb85de22", size = 52798, upload-time = "2024-12-28T20:16:56.64Z" }, + { url = "https://files.pythonhosted.org/packages/df/ef/b6cfd355982e817ccdb8d8d109f720cab6e06f900784b034b30efa8fa832/backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac6272f87693e78209dc72e84cf9ab58052027733cd0721c55356d3c881791cf", size = 52891, upload-time = "2024-12-28T20:16:58.887Z" }, + { url = "https://files.pythonhosted.org/packages/37/39/b13e3ae8a7c5d88b68a6e9248ffe7066534b0cfe504bf521963e61b6282d/backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:44c497a71f80cd2bcfc26faae8857cf8e79388e3d5fbf79d2354b8c360547d58", size = 52955, upload-time = "2024-12-28T20:17:00.028Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e4/70cffa3ce1eb4f2ff0c0d6f5d56285aacead6bd3879b27a2ba57ab261172/backports_datetime_fromisoformat-2.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:6335a4c9e8af329cb1ded5ab41a666e1448116161905a94e054f205aa6d263bc", size = 29323, upload-time = "2024-12-28T20:17:01.125Z" }, + { url = "https://files.pythonhosted.org/packages/62/f5/5bc92030deadf34c365d908d4533709341fb05d0082db318774fdf1b2bcb/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2e4b66e017253cdbe5a1de49e0eecff3f66cd72bcb1229d7db6e6b1832c0443", size = 27626, upload-time = "2024-12-28T20:17:03.448Z" }, + { url = "https://files.pythonhosted.org/packages/28/45/5885737d51f81dfcd0911dd5c16b510b249d4c4cf6f4a991176e0358a42a/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:43e2d648e150777e13bbc2549cc960373e37bf65bd8a5d2e0cef40e16e5d8dd0", size = 34588, upload-time = "2024-12-28T20:17:04.459Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6d/bd74de70953f5dd3e768c8fc774af942af0ce9f211e7c38dd478fa7ea910/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4ce6326fd86d5bae37813c7bf1543bae9e4c215ec6f5afe4c518be2635e2e005", size = 27162, upload-time = "2024-12-28T20:17:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/47/ba/1d14b097f13cce45b2b35db9898957578b7fcc984e79af3b35189e0d332f/backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7c8fac333bf860208fd522a5394369ee3c790d0aa4311f515fcc4b6c5ef8d75", size = 54482, upload-time = "2024-12-28T20:17:08.15Z" }, + { url = "https://files.pythonhosted.org/packages/25/e9/a2a7927d053b6fa148b64b5e13ca741ca254c13edca99d8251e9a8a09cfe/backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4da5ab3aa0cc293dc0662a0c6d1da1a011dc1edcbc3122a288cfed13a0b45", size = 54362, upload-time = "2024-12-28T20:17:10.605Z" }, + { url = "https://files.pythonhosted.org/packages/c1/99/394fb5e80131a7d58c49b89e78a61733a9994885804a0bb582416dd10c6f/backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:58ea11e3bf912bd0a36b0519eae2c5b560b3cb972ea756e66b73fb9be460af01", size = 54162, upload-time = "2024-12-28T20:17:12.301Z" }, + { url = "https://files.pythonhosted.org/packages/88/25/1940369de573c752889646d70b3fe8645e77b9e17984e72a554b9b51ffc4/backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8a375c7dbee4734318714a799b6c697223e4bbb57232af37fbfff88fb48a14c6", size = 54118, upload-time = "2024-12-28T20:17:13.609Z" }, + { url = "https://files.pythonhosted.org/packages/b7/46/f275bf6c61683414acaf42b2df7286d68cfef03e98b45c168323d7707778/backports_datetime_fromisoformat-2.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:ac677b1664c4585c2e014739f6678137c8336815406052349c85898206ec7061", size = 29329, upload-time = "2024-12-28T20:17:16.124Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0f/69bbdde2e1e57c09b5f01788804c50e68b29890aada999f2b1a40519def9/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147", size = 27630, upload-time = "2024-12-28T20:17:19.442Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1d/1c84a50c673c87518b1adfeafcfd149991ed1f7aedc45d6e5eac2f7d19d7/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4", size = 34707, upload-time = "2024-12-28T20:17:21.79Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/27eae384e7e045cda83f70b551d04b4a0b294f9822d32dea1cbf1592de59/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35", size = 27280, upload-time = "2024-12-28T20:17:24.503Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7a/a4075187eb6bbb1ff6beb7229db5f66d1070e6968abeb61e056fa51afa5e/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1", size = 55094, upload-time = "2024-12-28T20:17:25.546Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/3fced4230c10af14aacadc195fe58e2ced91d011217b450c2e16a09a98c8/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32", size = 55605, upload-time = "2024-12-28T20:17:29.208Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0a/4b34a838c57bd16d3e5861ab963845e73a1041034651f7459e9935289cfd/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d", size = 55353, upload-time = "2024-12-28T20:17:32.433Z" }, + { url = "https://files.pythonhosted.org/packages/d9/68/07d13c6e98e1cad85606a876367ede2de46af859833a1da12c413c201d78/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7", size = 55298, upload-time = "2024-12-28T20:17:34.919Z" }, + { url = "https://files.pythonhosted.org/packages/60/33/45b4d5311f42360f9b900dea53ab2bb20a3d61d7f9b7c37ddfcb3962f86f/backports_datetime_fromisoformat-2.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d", size = 29375, upload-time = "2024-12-28T20:17:36.018Z" }, + { url = "https://files.pythonhosted.org/packages/be/03/7eaa9f9bf290395d57fd30d7f1f2f9dff60c06a31c237dc2beb477e8f899/backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90e202e72a3d5aae673fcc8c9a4267d56b2f532beeb9173361293625fe4d2039", size = 28980, upload-time = "2024-12-28T20:18:06.554Z" }, + { url = "https://files.pythonhosted.org/packages/47/80/a0ecf33446c7349e79f54cc532933780341d20cff0ee12b5bfdcaa47067e/backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2df98ef1b76f5a58bb493dda552259ba60c3a37557d848e039524203951c9f06", size = 28449, upload-time = "2024-12-28T20:18:07.77Z" }, +] + +[[package]] +name = "certifi" +version = "2025.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "construct" +version = "2.8.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/2c/66bab4fef920ef8caa3e180ea601475b2cbbe196255b18f1c58215940607/construct-2.8.8.tar.gz", hash = "sha256:1b84b8147f6fd15bcf64b737c3e8ac5100811ad80c830cb4b2545140511c4157", size = 717694, upload-time = "2016-10-20T22:29:12.563Z" } + +[[package]] +name = "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 = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "async-lru" }, + { name = "click" }, + { name = "httpx" }, + { name = "inquirerpy" }, + { name = "m3u8" }, + { name = "mutagen" }, + { name = "pillow" }, + { name = "pywidevine" }, + { name = "yt-dlp" }, +] + +[package.metadata] +requires-dist = [ + { name = "async-lru", specifier = ">=2.0.5" }, + { name = "click", specifier = ">=8.3.0" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "inquirerpy", specifier = ">=0.3.4" }, + { name = "m3u8", specifier = ">=6.0.0" }, + { name = "mutagen", specifier = ">=1.47.0" }, + { name = "pillow", specifier = ">=12.0.0" }, + { name = "pywidevine", specifier = ">=1.8.0" }, + { name = "yt-dlp", specifier = ">=2025.10.22" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "inquirerpy" +version = "0.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pfzy" }, + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/73/7570847b9da026e07053da3bbe2ac7ea6cde6bb2cbd3c7a5a950fa0ae40b/InquirerPy-0.3.4.tar.gz", hash = "sha256:89d2ada0111f337483cb41ae31073108b2ec1e618a49d7110b0d7ade89fc197e", size = 44431, upload-time = "2022-06-27T23:11:20.598Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/ff/3b59672c47c6284e8005b42e84ceba13864aa0f39f067c973d1af02f5d91/InquirerPy-0.3.4-py3-none-any.whl", hash = "sha256:c65fdfbac1fa00e3ee4fb10679f4d3ed7a012abf4833910e63c295827fe2a7d4", size = 67677, upload-time = "2022-06-27T23:11:17.723Z" }, +] + +[[package]] +name = "m3u8" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-datetime-fromisoformat", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/a5/73697aaa99bb32b610adc1f11d46a0c0c370351292e9b271755084a145e6/m3u8-6.0.0.tar.gz", hash = "sha256:7ade990a1667d7a653bcaf9413b16c3eb5cd618982ff46aaff57fe6d9fa9c0fd", size = 42720, upload-time = "2024-08-07T11:20:06.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/31/50f3c38b38ff28635ff9c4a4afefddccc5f1b57457b539bdbdf75ce18669/m3u8-6.0.0-py3-none-any.whl", hash = "sha256:566d0748739c552dad10f8c87150078de6a0ec25071fa48e6968e96fc6dcba5d", size = 24133, upload-time = "2024-08-07T11:20:03.96Z" }, +] + +[[package]] +name = "mutagen" +version = "1.47.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e6/64bc71b74eef4b68e61eb921dcf72dabd9e4ec4af1e11891bbd312ccbb77/mutagen-1.47.0.tar.gz", hash = "sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99", size = 1274186, upload-time = "2023-09-03T16:33:33.411Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7a/620f945b96be1f6ee357d211d5bf74ab1b7fe72a9f1525aafbfe3aee6875/mutagen-1.47.0-py3-none-any.whl", hash = "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719", size = 194391, upload-time = "2023-09-03T16:33:29.955Z" }, +] + +[[package]] +name = "pfzy" +version = "0.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/5a/32b50c077c86bfccc7bed4881c5a2b823518f5450a30e639db5d3711952e/pfzy-0.3.4.tar.gz", hash = "sha256:717ea765dd10b63618e7298b2d98efd819e0b30cd5905c9707223dceeb94b3f1", size = 8396, upload-time = "2022-01-28T02:26:17.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/d7/8ff98376b1acc4503253b685ea09981697385ce344d4e3935c2af49e044d/pfzy-0.3.4-py3-none-any.whl", hash = "sha256:5f50d5b2b3207fa72e7ec0ef08372ef652685470974a107d0d4999fc5a903a96", size = 8537, upload-time = "2022-01-28T02:26:16.047Z" }, +] + +[[package]] +name = "pillow" +version = "12.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/08/26e68b6b5da219c2a2cb7b563af008b53bb8e6b6fcb3fa40715fcdb2523a/pillow-12.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b", size = 5289809, upload-time = "2025-10-15T18:21:27.791Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/4e58fb097fb74c7b4758a680aacd558810a417d1edaa7000142976ef9d2f/pillow-12.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1", size = 4650606, upload-time = "2025-10-15T18:21:29.823Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/1fa492aa9f77b3bc6d471c468e62bfea1823056bf7e5e4f1914d7ab2565e/pillow-12.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363", size = 6221023, upload-time = "2025-10-15T18:21:31.415Z" }, + { url = "https://files.pythonhosted.org/packages/c1/09/4de7cd03e33734ccd0c876f0251401f1314e819cbfd89a0fcb6e77927cc6/pillow-12.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca", size = 8024937, upload-time = "2025-10-15T18:21:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/2e/69/0688e7c1390666592876d9d474f5e135abb4acb39dcb583c4dc5490f1aff/pillow-12.0.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e", size = 6334139, upload-time = "2025-10-15T18:21:35.395Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1c/880921e98f525b9b44ce747ad1ea8f73fd7e992bafe3ca5e5644bf433dea/pillow-12.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782", size = 7026074, upload-time = "2025-10-15T18:21:37.219Z" }, + { url = "https://files.pythonhosted.org/packages/28/03/96f718331b19b355610ef4ebdbbde3557c726513030665071fd025745671/pillow-12.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10", size = 6448852, upload-time = "2025-10-15T18:21:39.168Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a0/6a193b3f0cc9437b122978d2c5cbce59510ccf9a5b48825096ed7472da2f/pillow-12.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa", size = 7117058, upload-time = "2025-10-15T18:21:40.997Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c4/043192375eaa4463254e8e61f0e2ec9a846b983929a8d0a7122e0a6d6fff/pillow-12.0.0-cp310-cp310-win32.whl", hash = "sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275", size = 6295431, upload-time = "2025-10-15T18:21:42.518Z" }, + { url = "https://files.pythonhosted.org/packages/92/c6/c2f2fc7e56301c21827e689bb8b0b465f1b52878b57471a070678c0c33cd/pillow-12.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d", size = 7000412, upload-time = "2025-10-15T18:21:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d2/5f675067ba82da7a1c238a73b32e3fd78d67f9d9f80fbadd33a40b9c0481/pillow-12.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7", size = 2435903, upload-time = "2025-10-15T18:21:46.29Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" }, + { url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" }, + { url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" }, + { url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" }, + { url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" }, + { url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" }, + { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" }, + { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" }, + { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" }, + { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" }, + { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" }, + { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" }, + { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" }, + { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" }, + { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" }, + { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" }, + { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" }, + { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" }, + { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" }, + { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" }, + { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" }, + { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" }, + { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" }, + { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" }, + { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" }, + { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" }, + { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" }, + { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" }, + { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" }, + { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" }, + { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" }, + { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" }, + { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" }, + { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" }, + { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" }, + { url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" }, + { url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" }, + { url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" }, + { url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "protobuf" +version = "4.25.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/01/34c8d2b6354906d728703cb9d546a0e534de479e25f1b581e4094c4a85cc/protobuf-4.25.8.tar.gz", hash = "sha256:6135cf8affe1fc6f76cced2641e4ea8d3e59518d1f24ae41ba97bcad82d397cd", size = 380920, upload-time = "2025-05-28T14:22:25.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ff/05f34305fe6b85bbfbecbc559d423a5985605cad5eda4f47eae9e9c9c5c5/protobuf-4.25.8-cp310-abi3-win32.whl", hash = "sha256:504435d831565f7cfac9f0714440028907f1975e4bed228e58e72ecfff58a1e0", size = 392745, upload-time = "2025-05-28T14:22:10.524Z" }, + { url = "https://files.pythonhosted.org/packages/08/35/8b8a8405c564caf4ba835b1fdf554da869954712b26d8f2a98c0e434469b/protobuf-4.25.8-cp310-abi3-win_amd64.whl", hash = "sha256:bd551eb1fe1d7e92c1af1d75bdfa572eff1ab0e5bf1736716814cdccdb2360f9", size = 413736, upload-time = "2025-05-28T14:22:13.156Z" }, + { url = "https://files.pythonhosted.org/packages/28/d7/ab27049a035b258dab43445eb6ec84a26277b16105b277cbe0a7698bdc6c/protobuf-4.25.8-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:ca809b42f4444f144f2115c4c1a747b9a404d590f18f37e9402422033e464e0f", size = 394537, upload-time = "2025-05-28T14:22:14.768Z" }, + { url = "https://files.pythonhosted.org/packages/bd/6d/a4a198b61808dd3d1ee187082ccc21499bc949d639feb948961b48be9a7e/protobuf-4.25.8-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:9ad7ef62d92baf5a8654fbb88dac7fa5594cfa70fd3440488a5ca3bfc6d795a7", size = 294005, upload-time = "2025-05-28T14:22:16.052Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c6/c9deaa6e789b6fc41b88ccbdfe7a42d2b82663248b715f55aa77fbc00724/protobuf-4.25.8-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:83e6e54e93d2b696a92cad6e6efc924f3850f82b52e1563778dfab8b355101b0", size = 294924, upload-time = "2025-05-28T14:22:17.105Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c1/6aece0ab5209981a70cd186f164c133fdba2f51e124ff92b73de7fd24d78/protobuf-4.25.8-py3-none-any.whl", hash = "sha256:15a0af558aa3b13efef102ae6e4f3efac06f1eea11afb3a57db2901447d9fb59", size = 156757, upload-time = "2025-05-28T14:22:24.135Z" }, +] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" }, + { url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" }, + { url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" }, +] + +[[package]] +name = "pymp4" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "construct" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/46/dfb3f5363fc71adaf419147fdcb93341029ca638634a5cc6f7e7446416b2/pymp4-1.4.0.tar.gz", hash = "sha256:bc9e77732a8a143d34c38aa862a54180716246938e4bf3e07585d19252b77bb5", size = 13018, upload-time = "2023-05-07T15:01:34.02Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/a2/27fea39af627c0ce5dbf6108bf969ea8f5fc9376d29f11282a80e3426f1d/pymp4-1.4.0-py3-none-any.whl", hash = "sha256:3401666c1e2a97ac94dffb18c5a5dcbd46d0a436da5272d378a6f9f6506dd12d", size = 14832, upload-time = "2023-05-07T15:01:32.293Z" }, +] + +[[package]] +name = "pywidevine" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "protobuf" }, + { name = "pycryptodome" }, + { name = "pymp4" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/12/6ff0e6ffa2711187ee629392396d7c18ae6ca8e2e576dcef2d636316d667/pywidevine-1.8.0.tar.gz", hash = "sha256:c14f3fe2864473416b9caa73d9a21251a02d72138e6d54d8c1a3f44b7a6b05c9", size = 76406, upload-time = "2023-12-22T11:13:12.556Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/9f/60f8a4c8e7767a8c34f5c42428662e03fa3e38ad18ba41fcc5370ee43263/pywidevine-1.8.0-py3-none-any.whl", hash = "sha256:1ecf029ce562789b18bbbd64604596d15645aadf413b255cf0fafc8d8b06659d", size = 70476, upload-time = "2023-12-22T11:13:10.84Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "unidecode" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149, upload-time = "2025-04-24T08:45:03.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837, upload-time = "2025-04-24T08:45:01.609Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, +] + +[[package]] +name = "yt-dlp" +version = "2025.10.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/70/cf4bd6c837ab0a709040888caa70d166aa2dfbb5018d1d5c983bf0b50254/yt_dlp-2025.10.22.tar.gz", hash = "sha256:db2d48133222b1d9508c6de757859c24b5cefb9568cf68ccad85dac20b07f77b", size = 3046863, upload-time = "2025-10-22T19:53:19.301Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/2a/fd184bf97d570841aa86b4aeb84aee93e7957a34059dafd4982157c10bff/yt_dlp-2025.10.22-py3-none-any.whl", hash = "sha256:9c803a9598859f91d0d5bd3337f1506ecb40bbe97f6efbe93bc4461fed344fb2", size = 3248983, upload-time = "2025-10-22T19:53:16.483Z" }, +]