From 965482a1e410fa4418275318800d774e3c0def70 Mon Sep 17 00:00:00 2001 From: Andy Date: Tue, 25 Nov 2025 20:14:48 +0000 Subject: [PATCH 01/38] feat: merge upstream dev branch - Add Gluetun dynamic VPN-to-HTTP proxy provider - Add remote services and authentication system - Add country code utilities - Add Docker binary detection - Update proxy providers --- CONFIG.md | 956 ++++++++++- docs/GLUETUN.md | 159 ++ pyproject.toml | 1 + unshackle/commands/dl.py | 26 +- unshackle/commands/env.py | 1 + unshackle/commands/remote_auth.py | 225 +++ unshackle/commands/search.py | 9 +- unshackle/commands/serve.py | 58 +- unshackle/core/api/api_keys.py | 137 ++ unshackle/core/api/remote_handlers.py | 1879 ++++++++++++++++++++++ unshackle/core/api/routes.py | 20 + unshackle/core/api/session_serializer.py | 236 +++ unshackle/core/binaries.py | 2 + unshackle/core/config.py | 2 + unshackle/core/local_session_cache.py | 274 ++++ unshackle/core/proxies/__init__.py | 3 +- unshackle/core/proxies/gluetun.py | 1261 +++++++++++++++ unshackle/core/proxies/nordvpn.py | 70 +- unshackle/core/proxies/surfsharkvpn.py | 64 +- unshackle/core/remote_auth.py | 279 ++++ unshackle/core/remote_service.py | 593 +++++++ unshackle/core/remote_services.py | 245 +++ unshackle/core/service.py | 47 + unshackle/core/services.py | 54 +- unshackle/core/utilities.py | 75 + unshackle/unshackle-example.yaml | 89 + uv.lock | 11 + 27 files changed, 6678 insertions(+), 98 deletions(-) create mode 100644 docs/GLUETUN.md create mode 100644 unshackle/commands/remote_auth.py create mode 100644 unshackle/core/api/api_keys.py create mode 100644 unshackle/core/api/remote_handlers.py create mode 100644 unshackle/core/api/session_serializer.py create mode 100644 unshackle/core/local_session_cache.py create mode 100644 unshackle/core/proxies/gluetun.py create mode 100644 unshackle/core/remote_auth.py create mode 100644 unshackle/core/remote_service.py create mode 100644 unshackle/core/remote_services.py diff --git a/CONFIG.md b/CONFIG.md index 15eef05..f777f2d 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -31,10 +31,11 @@ which does not keep comments. ## cdm (dict) -Pre-define which Widevine or PlayReady device to use for each Service by Service Tag as Key (case-sensitive). -The value should be a WVD or PRD filename without the file extension. When -loading the device, unshackle will look in both the `WVDs` and `PRDs` directories -for a matching file. +Pre-define which Widevine or PlayReady device to use for each Service by Service Tag as Key (case-sensitive). +The value should be a WVD or PRD filename without the file extension, or a remote CDM name defined in `remote_cdm`. +When loading a local device, unshackle will look in both the `WVDs` and `PRDs` directories for a matching file. + +### Basic CDM Selection For example, @@ -43,6 +44,8 @@ AMZN: chromecdm_903_l3 NF: nexus_6_l1 ``` +### Profile-Based CDM Selection + You may also specify this device based on the profile used. For example, @@ -55,7 +58,9 @@ DSNP: jane_uhd: nexus_5_l1 ``` -You can also specify a fallback value to predefine if a match was not made. +### Default Fallback + +You can also specify a fallback value to predefine if a match was not made. This can be done using `default` key. This can help reduce redundancy in your specifications. For example, the following has the same result as the previous example, as well as all other @@ -68,6 +73,256 @@ DSNP: default: chromecdm_903_l3 ``` +### Quality-Based CDM Selection + +**NEW:** You can now select different CDMs based on video resolution (quality). This allows you to use local CDMs +for lower qualities and automatically switch to remote CDMs for higher qualities that require L1/L2 security levels. + +unshackle automatically detects the highest quality video track and selects the appropriate CDM before downloading. + +#### Supported Quality Operators + +- **Exact match**: `"480"`, `"720"`, `"1080"`, `"2160"` - Matches exact resolution +- **Greater than or equal**: `">=1080"` - Matches 1080p and above (1440p, 2160p, etc.) +- **Greater than**: `">720"` - Matches above 720p (1080p, 1440p, 2160p, etc.) +- **Less than or equal**: `"<=1080"` - Matches 1080p and below +- **Less than**: `"<1080"` - Matches below 1080p + +**Note**: Quality keys must be quoted strings to preserve operators in YAML. + +#### Example: Local for SD/HD, Remote for 4K + +```yaml +cdm: + NETFLIX: + "<=1080": local_l3 # Use local CDM for 1080p and below + ">=1440": remote_l1 # Use remote L1 CDM for 1440p and above + default: local_l3 # Fallback if no quality match + + DISNEY: + "480": local_l3_mobile # Use mobile L3 for 480p + "720": local_l3 # Use local L3 for 720p + "1080": local_l3_hd # Use local L3 for 1080p + ">1080": remote_l1 # Use remote L1 for above 1080p +``` + +#### Example: Mixed Profile and Quality Selection + +```yaml +cdm: + AMAZON: + # Profile-based selection + john_account: johns_local_l3 + jane_account: janes_remote_l1 + + # Quality-based selection (for default profile) + "<=720": local_l3 + ">=1080": remote_l1 + + default: local_l3 +``` + +#### Example: Switching Between Widevine and PlayReady Based on Quality + +Some services may use different DRM systems for different quality levels. For example, AMAZON might use +Widevine (ChromeCDM) for SD/HD content but require PlayReady (SL3) for UHD content: + +```yaml +cdm: + AMAZON: + # Use local/remote Widevine ChromeCDM for 1080p and below + "<=1080": local_chromecdm + + # Use remote PlayReady SL3 for above 1080p (1440p, 2160p) + ">1080": remote_sl3 + + default: local_chromecdm + +# If using local CDMs, place chromecdm.wvd in your WVDs directory +# If using remote CDMs, configure them below: +remote_cdm: + - name: remote_chromecdm + type: decrypt_labs # Or custom_api + device_name: ChromeCDM + host: https://your-cdm-api.com + secret: YOUR_API_KEY + + - name: remote_sl3 + type: decrypt_labs # Or custom_api + device_name: SL3 + device_type: PLAYREADY + host: https://your-cdm-api.com + secret: YOUR_API_KEY +``` + +**How it works:** +- When downloading 720p or 1080p content → Uses `local_chromecdm` (local Widevine L3) +- When downloading 1440p or 2160p content → Uses `remote_sl3` (remote PlayReady SL3) +- unshackle automatically detects the video quality and selects the appropriate CDM +- The DRM type is verified against the content's actual DRM system + +**Note:** This configuration assumes the service uses different DRM systems for different qualities. +Most services use a single DRM system across all qualities, but some (like AMAZON) may vary by region or quality tier. + +### DRM-Specific CDM Selection (Widevine/PlayReady) + +For services that support multiple DRM systems, you can specify different CDMs based on the DRM type. +unshackle automatically detects the DRM system used by content and switches to the appropriate CDM. + +#### Example: Separate Widevine and PlayReady CDMs + +```yaml +cdm: + DISNEY: + widevine: + default: local_wv # Local Widevine CDM + ">=2160": remote_l1 # Remote L1 for 4K Widevine + + playready: + default: local_pr # Local PlayReady CDM + ">=1080": remote_sl2 # Remote SL2 for HD+ PlayReady +``` + +#### Example: AMAZON - Quality-Based with DRM Type Override + +For AMAZON, you might want to use ChromeCDM (Widevine) for SD/HD content and PlayReady SL3 for UHD content. +Here's a more explicit configuration using DRM-specific overrides: + +```yaml +cdm: + AMAZON: + # DRM-specific configuration with quality-based selection + widevine: + "<=1080": local_chromecdm # Local ChromeCDM for 1080p and below + default: local_chromecdm + + playready: + ">1080": remote_sl3 # Remote PlayReady SL3 for above 1080p + "<=1080": local_pr # Optional: Local PlayReady for lower quality + default: remote_sl3 + + # Fallback for unknown DRM types + default: local_chromecdm + +# Define remote CDMs (if using remote for high quality) +remote_cdm: + - name: remote_sl3 + type: decrypt_labs # Or custom_api + device_name: SL3 + device_type: PLAYREADY + host: https://your-cdm-api.com + secret: YOUR_API_KEY + + - name: remote_chromecdm + type: decrypt_labs # Or custom_api + device_name: ChromeCDM + host: https://your-cdm-api.com + secret: YOUR_API_KEY +``` + +**How it works:** +- If content uses **Widevine** → Uses `local_chromecdm` for all qualities up to 1080p +- If content uses **PlayReady** and quality > 1080p → Uses `remote_sl3` (remote SL3) +- If content uses **PlayReady** and quality ≤ 1080p → Uses `local_pr` (local, optional) +- Fallback for unknown DRM → Uses `local_chromecdm` + +**Alternative: Simple quality-based approach** (when DRM type varies by quality): + +```yaml +cdm: + AMAZON: + "<=1080": local_chromecdm # Local Widevine for SD/HD + ">1080": remote_sl3 # Remote PlayReady for UHD + default: local_chromecdm +``` + +This simpler approach works when the service consistently uses Widevine for SD/HD and PlayReady for UHD. + +### How Automatic DRM Switching Works + +When downloading content, unshackle: + +1. **Detects video quality** - Analyzes all video tracks and determines the highest resolution +2. **Applies quality rules** - Matches resolution against your quality-based CDM configuration +3. **Detects DRM type** - Identifies whether content uses Widevine or PlayReady +4. **Switches CDM automatically** - Loads the appropriate CDM based on DRM type and quality +5. **Falls back if needed** - Uses local CDM if remote CDM is unavailable + +For example, if you download 4K content that uses Widevine: +- System detects 2160p resolution +- Matches `">=2160": remote_l1` rule +- Detects Widevine DRM +- Automatically loads `remote_l1` remote CDM +- If remote CDM fails, falls back to local CDM (if available) + +### Local to Remote CDM Fallback + +When you configure both local and remote CDMs, unshackle follows this priority order: + +1. **Remote CDM** (if defined in `remote_cdm` and matched by quality/DRM rules) +2. **Local PlayReady** (.prd files in `PRDs` directory) +3. **Local Widevine** (.wvd files in `WVDs` directory) + +This ensures that if a remote CDM API is unavailable, unshackle can still use local devices as fallback. + +#### Example: Complete Configuration with Fallback + +```yaml +cdm: + NETFLIX: + # Use local for low quality, remote for high quality + "<=720": local_l3_sd # Local WVD file + "1080": local_l3_hd # Local WVD file + ">=1440": remote_l1 # Remote L1 API + default: local_l3_sd + +# Define remote CDMs +remote_cdm: + - name: remote_l1 + type: decrypt_labs # Or custom_api + device_name: L1 + host: https://your-cdm-api.com + secret: YOUR_API_KEY + + - name: remote_sl2 + type: decrypt_labs # Or custom_api + device_name: SL2 # PlayReady SL2000 + device_type: PLAYREADY + host: https://your-cdm-api.com + secret: YOUR_API_KEY +``` + +**Result:** +- **480p/720p content** → Uses `local_l3_sd` (local .wvd file) +- **1080p content** → Uses `local_l3_hd` (local .wvd file) +- **1440p/2160p content** → Uses `remote_l1` (remote API) +- **If remote API fails** → Falls back to local .wvd files if available + +### Advanced: Service Certificate Configuration + +Some services require L1/L2 security levels for high-quality content. When using remote L1/L2 CDMs, +you may need to configure the service certificate in the `services` section. See the [services](#services-dict) +section for certificate configuration details. + +### Configuration Priority Order + +When multiple configuration types are defined, unshackle follows this selection hierarchy: + +1. **Profile-specific** (if `-p/--profile` specified on command line) +2. **DRM-specific** (widevine/playready keys) +3. **Quality-based** (resolution with operators: >=, >, <=, <, exact) +4. **Service-level default** (default key under service) +5. **Global default** (top-level default key) + +### Summary + +- **Basic**: Simple service → CDM mapping +- **Profile**: Different CDMs per user profile +- **Quality**: Automatic CDM selection based on video resolution +- **DRM Type**: Separate CDMs for Widevine vs PlayReady +- **Fallback**: Local CDM fallback if remote CDM unavailable +- **Automatic**: Zero manual intervention - unshackle handles all switching + ## chapter_fallback_name (str) The Chapter Name to use when exporting a Chapter without a Name. @@ -111,18 +366,49 @@ Please be aware that this information is sensitive and to keep it safe. Do not s ## curl_impersonate (dict) -- `browser` - The Browser to impersonate as. A list of available Browsers and Versions are listed here: - +Configuration for curl_cffi browser impersonation and custom fingerprinting. + +- `browser` - The Browser to impersonate as OR a fingerprint preset name. A list of available Browsers and Versions + are listed here: Default: `"chrome124"` -For example, +### Available Fingerprint Presets + +- `okhttp4` - Android TV OkHttp 4.x fingerprint preset (for better Android TV compatibility) +- `okhttp5` - Android TV OkHttp 5.x fingerprint preset (for better Android TV compatibility) + +### Custom Fingerprinting + +For advanced users, you can specify custom TLS and HTTP/2 fingerprints: + +- `ja3` (str): Custom JA3 TLS fingerprint string (format: "SSLVersion,Ciphers,Extensions,Curves,PointFormats") +- `akamai` (str): Custom Akamai HTTP/2 fingerprint string (format: "SETTINGS|WINDOW_UPDATE|PRIORITY|PSEUDO_HEADERS") +- `extra_fp` (dict): Additional fingerprint parameters for advanced customization + +For example, using a browser preset: ```yaml curl_impersonate: browser: "chrome120" ``` +Using an Android TV preset: + +```yaml +curl_impersonate: + browser: "okhttp4" +``` + +Using custom fingerprints: + +```yaml +curl_impersonate: + browser: "chrome120" + ja3: "custom_ja3_fingerprint_string" + akamai: "custom_akamai_fingerprint_string" +``` + ## directories (dict) Override the default directories used across unshackle. @@ -158,12 +444,14 @@ There are directories not listed that cannot be modified as they are crucial to ## dl (dict) -Pre-define default options and switches of the `dl` command. +Pre-define default options and switches of the `dl` command. The values will be ignored if explicitly set in the CLI call. -The Key must be the same value Python click would resolve it to as an argument. +The Key must be the same value Python click would resolve it to as an argument. E.g., `@click.option("-r", "--range", "range_", type=...` actually resolves as `range_` variable. +### Common Options + For example to set the default primary language to download to German, ```yaml @@ -199,6 +487,29 @@ or to change the output subtitle format from the default (original format) to We sub_format: vtt ``` +### Additional Available Options + +The following additional flags can be pre-configured as defaults: + +- `latest_episode` (bool): Download only the most recent episode (corresponds to `--latest-episode` / `-le` flag) +- `no_video` (bool): Skip downloading video tracks (corresponds to `--no-video` / `-nv` flag) +- `audio_description` (bool): Download audio description tracks (corresponds to `--audio-description` / `-ad` flag) +- `forced_subs` (bool): Include forced subtitle tracks (corresponds to `--forced-subs` / `-fs` flag) +- `no_cache` (bool): Bypass title cache (corresponds to `--no-cache` flag) +- `reset_cache` (bool): Clear title cache before fetching (corresponds to `--reset-cache` flag) +- `best_available` (bool): Continue with best quality if requested unavailable (corresponds to `--best-available` flag) + +For example, + +```yaml +dl: + latest_episode: true # Always download only the latest episode + audio_description: true # Include audio description tracks by default + best_available: true # Use best available quality as fallback +``` + +**Note**: These options can also be set per-service by nesting them under a service tag. + ## downloader (str | dict) Choose what software to use to download data throughout unshackle where needed. @@ -226,6 +537,51 @@ downloader: The `default` entry is optional. If omitted, `requests` will be used for services not listed. +## debug (bool) + +Enable comprehensive JSON-based debug logging for troubleshooting and service development. +When enabled, creates JSON Lines (`.jsonl`) log files with complete debugging context. + +Default: `false` + +When enabled (via `--debug` flag or `debug: true` in config): + +- Creates structured JSON Lines log files: `logs/unshackle_debug_{service}_{timestamp}.jsonl` +- Logs session info, CLI parameters, service configuration, CDM details, authentication status +- Logs title/track metadata, DRM operations, vault queries +- Logs errors with full stack traces +- Also creates text log: `logs/unshackle_root_{timestamp}.log` + +For example, + +```yaml +debug: true +``` + +**Security Note**: Passwords, tokens, cookies, and session tokens are ALWAYS redacted regardless of this setting. + +## debug_keys (bool) + +Control whether actual decryption keys (CEKs) are logged in debug logs. + +Default: `false` + +When set to `true`, includes actual content encryption keys in debug logs. This is useful for debugging +key retrieval and decryption issues. + +For example, + +```yaml +debug_keys: true +``` + +**Security Notes**: + +- Only affects content_key and key fields (the actual CEKs) +- Key metadata (kid, keys_count, key_id) is always logged regardless of this setting +- Passwords, tokens, cookies, and session tokens remain redacted even when this is enabled +- Use with caution and ensure debug logs are stored securely + ## decryption (str | dict) Choose what software to use to decrypt DRM-protected content throughout unshackle where needed. @@ -257,14 +613,31 @@ Simple configuration (single method for all services): decryption: mp4decrypt ``` +## decrypt_labs_api_key (str) + +API key for DecryptLabs CDM service integration. + +When set, enables the use of DecryptLabs remote CDM services in your `remote_cdm` configuration. +This is used specifically for `type: "decrypt_labs"` entries in the remote CDM list. + +For example, + +```yaml +decrypt_labs_api_key: "your_api_key_here" +``` + +**Note**: This is different from the per-CDM `secret` field in `remote_cdm` entries. This provides a global +API key that can be referenced across multiple DecryptLabs CDM configurations. + ## filenames (dict) -Override the default filenames used across unshackle. +Override the default filenames used across unshackle. The filenames use various variables that are replaced during runtime. The following filenames are available and may be overridden: - `log` - Log filenames. Uses `{name}` and `{time}` variables. +- `debug_log` - Debug log filenames in JSON Lines format. Uses `{service}` and `{time}` variables. - `config` - Service configuration filenames. - `root_config` - Root configuration filename. - `chapters` - Chapter export filenames. Uses `{title}` and `{random}` variables. @@ -275,6 +648,7 @@ For example, ```yaml filenames: log: "unshackle_{name}_{time}.log" + debug_log: "unshackle_debug_{service}_{time}.jsonl" config: "config.yaml" root_config: "unshackle.yaml" chapters: "Chapters_{title}_{random}.txt" @@ -408,31 +782,6 @@ n_m3u8dl_re: use_proxy: true ``` -## nordvpn (dict) - -**Legacy configuration. Use `proxy_providers.nordvpn` instead.** - -Set your NordVPN Service credentials with `username` and `password` keys to automate the use of NordVPN as a Proxy -system where required. - -You can also specify specific servers to use per-region with the `server_map` key. -Sometimes a specific server works best for a service than others, so hard-coding one for a day or two helps. - -For example, - -```yaml -nordvpn: - username: zxqsR7C5CyGwmGb6KSvk8qsZ # example of the login format - password: wXVHmht22hhRKUEQ32PQVjCZ - server_map: - us: 12 # force US server #12 for US proxies -``` - -The username and password should NOT be your normal NordVPN Account Credentials. -They should be the `Service credentials` which can be found on your Nord Account Dashboard. - -Note that `gb` is used instead of `uk` to be more consistent across regional systems. - ## proxy_providers (dict) Enable external proxy provider services. These proxies will be used automatically where needed as defined by the @@ -486,7 +835,7 @@ Note that `gb` is used instead of `uk` to be more consistent across regional sys ### surfsharkvpn (dict) -Enable Surfshark VPN proxy service using Surfshark Service credentials (not your login password). +Enable Surfshark VPN proxy service using Surfshark Service credentials (not your login password). You may pin specific server IDs per region using `server_map`. ```yaml @@ -498,9 +847,32 @@ server_map: au: 4621 # force AU server #4621 ``` +### windscribevpn (dict) + +Enable Windscribe VPN proxy service using Windscribe Service credentials (not your login password). +You may pin specific server hostnames per region using `server_map`. + +```yaml +username: your_windscribe_username # From https://windscribe.com/getconfig/openvpn +password: your_windscribe_password # Service credentials (not your login password) +server_map: + us: "us-central-096.totallyacdn.com" # Force specific US server + gb: "uk-london-055.totallyacdn.com" # Force specific GB server + de: "de-frankfurt-001.totallyacdn.com" # Force specific DE server +``` + +**Note**: The username and password should be your Windscribe OpenVPN credentials, which can be obtained from +the Windscribe configuration generator. The `server_map` uses full server hostnames (not just numbers like NordVPN). + +You can use Windscribe proxies by specifying `--proxy=windscribevpn:us` or such. Server selection works similar +to other providers - use `--proxy=windscribevpn:us` for automatic server or specify the full hostname if needed. + ### hola (dict) -Enable Hola VPN proxy service. This is a simple provider that doesn't require configuration. +Enable Hola VPN proxy service for datacenter and residential proxies. + +This provider uses the open-source `hola-proxy` tool and requires no account credentials. +Simply include an empty configuration to enable it. For example, @@ -509,28 +881,332 @@ proxy_providers: hola: {} ``` -Note: Hola VPN is automatically enabled when proxy_providers is configured, no additional setup is required. +**Requirements**: The `hola-proxy` binary must be installed and available in your system PATH or in the +unshackle binaries directory. + +**Note**: Hola uses a peer-to-peer VPN network. Consider the privacy implications before using this provider. + +### gluetun (dict) + +Enable Gluetun VPN proxy service, which creates Docker containers running Gluetun to bridge VPN connections +to HTTP proxies. This supports 50+ VPN providers through a single, unified interface. + +**Requirements**: Docker must be installed and running. Check with `unshackle env check`. + +```yaml +gluetun: + base_port: 8888 # Starting port for HTTP proxies + auto_cleanup: true # Remove containers when done + container_prefix: "unshackle-gluetun" # Docker container name prefix + verify_ip: true # Verify VPN IP matches expected region + + providers: + windscribe: + vpn_type: wireguard + credentials: + private_key: "YOUR_WIREGUARD_PRIVATE_KEY" + addresses: "YOUR_WIREGUARD_ADDRESS" # e.g., "10.x.x.x/32" + server_countries: + us: US + uk: GB + ca: CA +``` + +**Usage**: Use the format `--proxy gluetun::`, e.g.: +- `--proxy gluetun:windscribe:us` - Connect via Windscribe to US +- `--proxy gluetun:nordvpn:de` - Connect via NordVPN to Germany + +**Supported VPN Types**: +- `wireguard` - For providers like Windscribe, NordVPN, Surfshark (recommended) +- `openvpn` - For providers like ExpressVPN, PIA + +See the example config file for more provider configurations. ## remote_cdm (list\[dict]) -Use [pywidevine] Serve-compliant Remote CDMs in unshackle as if it was a local widevine device file. -The name of each defined device maps as if it was a local device and should be used like a local device. +Configure remote CDM (Content Decryption Module) APIs to use for decrypting DRM-protected content. +Remote CDMs allow you to use high-security CDMs (L1/L2 for Widevine, SL2000/SL3000 for PlayReady) without +having the physical device files locally. + +unshackle supports multiple types of remote CDM providers: + +1. **DecryptLabs CDM** - Official DecryptLabs KeyXtractor API with intelligent caching +2. **Custom API CDM** - Highly configurable adapter for any third-party CDM API +3. **Legacy PyWidevine Serve** - Standard pywidevine serve-compliant APIs + +The name of each defined remote CDM can be referenced in the `cdm` configuration as if it was a local device file. + +### DecryptLabs Remote CDM + +DecryptLabs provides a professional CDM API service with support for multiple device types and intelligent key caching. + +**Supported Devices:** +- **Widevine**: `ChromeCDM` (L3), `L1` (Security Level 1), `L2` (Security Level 2) +- **PlayReady**: `SL2` (SL2000), `SL3` (SL3000) + +**Configuration:** + +```yaml +remote_cdm: + # Widevine L1 Device + - name: decrypt_labs_l1 + type: decrypt_labs # Required: identifies as DecryptLabs CDM + device_name: L1 # Required: must match exactly (L1, L2, ChromeCDM, SL2, SL3) + host: https://keyxtractor.decryptlabs.com + secret: YOUR_API_KEY # Your DecryptLabs API key + + # Widevine L2 Device + - name: decrypt_labs_l2 + type: decrypt_labs + device_name: L2 + host: https://keyxtractor.decryptlabs.com + secret: YOUR_API_KEY + + # Chrome CDM (L3) + - name: decrypt_labs_chrome + type: decrypt_labs + device_name: ChromeCDM + host: https://keyxtractor.decryptlabs.com + secret: YOUR_API_KEY + + # PlayReady SL2000 + - name: decrypt_labs_playready_sl2 + type: decrypt_labs + device_name: SL2 + device_type: PLAYREADY # Required for PlayReady + host: https://keyxtractor.decryptlabs.com + secret: YOUR_API_KEY + + # PlayReady SL3000 + - name: decrypt_labs_playready_sl3 + type: decrypt_labs + device_name: SL3 + device_type: PLAYREADY + host: https://keyxtractor.decryptlabs.com + secret: YOUR_API_KEY +``` + +**Features:** +- Intelligent key caching system (reduces API calls) +- Automatic integration with unshackle's vault system +- Support for both Widevine and PlayReady +- Multiple security levels (L1, L2, L3, SL2000, SL3000) + +**Note:** The `device_type` and `security_level` fields are optional metadata. They don't affect API communication +but are used for internal device identification. + +### Custom API Remote CDM + +A highly configurable CDM adapter that can work with virtually any third-party CDM API through YAML configuration. +This allows you to integrate custom CDM services without writing code. + +**Configuration Philosophy:** +- **90%** of new CDM providers: Only YAML config needed +- **9%** of cases: Add new transform type +- **1%** of cases: Add new auth strategy + +**Basic Example:** + +```yaml +remote_cdm: + - name: custom_chrome_cdm + type: custom_api # Required: identifies as Custom API CDM + host: https://your-cdm-api.com + timeout: 30 # Optional: request timeout in seconds + + device: + name: ChromeCDM + type: CHROME # CHROME, ANDROID, PLAYREADY + system_id: 27175 + security_level: 3 + + auth: + type: bearer # bearer, header, basic, body + key: YOUR_API_TOKEN + + endpoints: + get_request: + path: /get-challenge + method: POST + decrypt_response: + path: /get-keys + method: POST + + caching: + enabled: true # Enable key caching + use_vaults: true # Integrate with vault system +``` + +**Advanced Example with Field Mapping:** + +```yaml +remote_cdm: + - name: advanced_custom_api + type: custom_api + host: https://api.example.com + device: + name: L1 + type: ANDROID + security_level: 1 + + # Authentication configuration + auth: + type: header + header_name: X-API-Key + key: YOUR_SECRET_KEY + custom_headers: + User-Agent: Unshackle/2.0.0 + X-Client-Version: "1.0" + + # Endpoint configuration + endpoints: + get_request: + path: /v2/challenge + method: POST + timeout: 30 + decrypt_response: + path: /v2/decrypt + method: POST + timeout: 30 + + # Request parameter mapping + request_mapping: + get_request: + param_names: + init_data: pssh # Rename 'init_data' to 'pssh' + scheme: device_type # Rename 'scheme' to 'device_type' + static_params: + api_version: "2.0" # Add static parameter + decrypt_response: + param_names: + license_request: challenge + license_response: license + + # Response field mapping + response_mapping: + get_request: + fields: + challenge: data.challenge # Deep field access + session_id: session.id + success_conditions: + - status == 'ok' # Validate response + decrypt_response: + fields: + keys: data.keys + key_fields: + kid: key_id # Map 'kid' field + key: content_key # Map 'key' field + + caching: + enabled: true + use_vaults: true + check_cached_first: true # Check cache before API calls +``` + +**Supported Authentication Types:** +- `bearer` - Bearer token authentication +- `header` - Custom header authentication +- `basic` - HTTP Basic authentication +- `body` - Credentials in request body + +### Legacy PyWidevine Serve Format + +Standard pywidevine serve-compliant remote CDM configuration (backwards compatibility). + +```yaml +remote_cdm: + - name: legacy_chrome_cdm + device_name: chrome + device_type: CHROME + system_id: 27175 + security_level: 3 + host: https://domain.com/api + secret: secret_key +``` + +**Note:** If `type` is not specified, unshackle assumes legacy format. For DecryptLabs or Custom API, +always specify `type: decrypt_labs` or `type: custom_api`. + +### Integration with Quality-Based CDM Selection + +Remote CDMs can be used in quality-based and DRM-specific CDM configurations: + +```yaml +cdm: + NETFLIX: + "<=1080": local_l3 # Local for SD/HD + ">=1440": remote_l1 # Remote for 4K+ + + widevine: + ">=2160": remote_l1 # Remote L1 for 4K Widevine + default: local_wv + + playready: + ">=1080": remote_sl2 # Remote SL2 for HD+ PlayReady + default: local_pr + +remote_cdm: + - name: remote_l1 + type: decrypt_labs # Or custom_api + device_name: L1 + host: https://your-cdm-api.com + secret: YOUR_API_KEY + + - name: remote_sl2 + type: decrypt_labs # Or custom_api + device_name: SL2 + device_type: PLAYREADY + host: https://your-cdm-api.com + secret: YOUR_API_KEY +``` + +### Key Features + +**Intelligent Caching:** +- Remote CDMs integrate with unshackle's vault system +- Keys are cached locally to reduce API calls +- Cached keys are checked before making license requests +- Multiple vault sources supported (SQLite, MySQL, API) + +**Automatic Fallback:** +- If remote CDM fails, unshackle falls back to local devices (if available) +- Priority: Remote CDM → Local PRD → Local WVD + +**DRM Type Detection:** +- Automatically switches between Widevine and PlayReady remote CDMs +- Based on content DRM system detection + +**Quality-Based Selection:** +- Use different remote CDMs based on video resolution +- Combine with local CDMs for cost-effective downloads + +[pywidevine]: https://github.com/rlaphoenix/pywidevine + +## remote_services (list\[dict]) + +Configure connections to remote unshackle REST API servers to access services running on other instances. +This allows you to use services from remote unshackle installations as if they were local. + +Each entry requires: + +- `url` (str): The base URL of the remote unshackle REST API server +- `api_key` (str): API key for authenticating with the remote server +- `name` (str, optional): Friendly name for the remote service (for logging/display purposes) For example, ```yaml -- name: chromecdm_903_l3 # name must be unique for each remote CDM - # the device type, system id and security level must match the values of the device on the API - # if any of the information is wrong, it will raise an error, if you do not know it ask the API owner - device_type: CHROME - system_id: 1234 - security_level: 3 - host: "http://xxxxxxxxxxxxxxxx/the_cdm_endpoint" - secret: "secret/api key" - device_name: "remote device to use" # the device name from the API, usually a wvd filename +remote_services: + - url: "https://remote-unshackle.example.com" + api_key: "your_api_key_here" + name: "Remote US Server" + - url: "https://remote-unshackle-eu.example.com" + api_key: "another_api_key" + name: "Remote EU Server" ``` -[pywidevine]: https://github.com/rlaphoenix/pywidevine +**Note**: The remote unshackle instances must have the REST API enabled and running. Services from all +configured remote servers will be available alongside your local services. ## scene_naming (bool) @@ -577,22 +1253,120 @@ users: Configuration data for each Service. The Service will have the data within this section merged into the `config.yaml` before provided to the Service class. -Think of this config to be used for more sensitive configuration data, like user or device-specific API keys, IDs, -device attributes, and so on. A `config.yaml` file is typically shared and not meant to be modified, so use this for -any sensitive configuration data. +This configuration serves two purposes: -The Key is the Service Tag, but can take any arbitrary form for its value. It's expected to begin as either a list or -a dictionary. +1. **Service-specific data**: Sensitive configuration like user or device-specific API keys, IDs, device attributes, and + so on. A `config.yaml` file is typically shared and not meant to be modified, so use this for any sensitive data. + +2. **Per-service configuration overrides**: Override any global configuration option on a per-service basis for fine-tuned + control. This allows you to customize behavior for services with special requirements. + +The Key is the Service Tag, and the value can take any form (typically a dictionary or list). + +### Basic Service Configuration For example, ```yaml -NOW: - client: - auth_scheme: MESSO - # ... more sensitive data +services: + NOW: + client: + auth_scheme: MESSO + # ... more sensitive data ``` +### Service-Specific Configuration Overrides + +**New in v2.0.0**: You can override ANY global configuration option on a per-service basis. Supported overrides include: + +- `dl` - Download command defaults +- `aria2c` - aria2c downloader settings +- `n_m3u8dl_re` - N_m3u8DL-RE downloader settings +- `curl_impersonate` - Browser impersonation settings +- `subtitle` - Subtitle processing options +- `muxing` - Muxing behavior +- `headers` - HTTP headers +- And more... + +### Comprehensive Example + +```yaml +services: + EXAMPLE: + # Standard service configuration + api_key: "service_api_key" + + # Service certificate for Widevine L1/L2 (base64 encoded) + certificate: | + CAUSwwUKvQIIAxIQ5US6QAvBDzfTtjb4tU/7QxiH8c+TBSKOAjCCAQoCggEBAObzvlu2hZRs... + # (full base64 certificate) + + # Profile-specific configurations + profiles: + john_sd: + device: + app_name: "AIV" + device_model: "SHIELD Android TV" + jane_uhd: + device: + app_name: "AIV" + device_model: "Fire TV Stick 4K" + + # Override dl command defaults for this service + dl: + downloads: 4 # Limit concurrent track downloads + workers: 8 # Reduce workers per track + lang: ["en", "es-419"] # Different language priority + sub_format: srt # Force SRT subtitle format + + # Override n_m3u8dl_re downloader settings + n_m3u8dl_re: + thread_count: 8 # Lower thread count for rate-limited service + use_proxy: true # Force proxy usage + retry_count: 10 # More retries for unstable connections + + # Override aria2c downloader settings + aria2c: + max_concurrent_downloads: 2 + max_connection_per_server: 1 + split: 3 + + # Override subtitle processing + subtitle: + conversion_method: pycaption + sdh_method: auto + + # Service-specific headers + headers: + User-Agent: "Service-specific user agent string" + Accept-Language: "en-US,en;q=0.9" + + # Override muxing options + muxing: + set_title: true + + # Example: Rate-limited service requiring conservative settings + RATE_LIMITED_SERVICE: + dl: + downloads: 2 + workers: 4 + n_m3u8dl_re: + thread_count: 4 + retry_count: 20 + aria2c: + max_concurrent_downloads: 1 + max_connection_per_server: 1 +``` + +### Important Notes + +- Overrides are merged with global config, not replaced +- Only specified keys are overridden; others use global defaults +- Reserved keys (`profiles`, `api_key`, `certificate`, etc.) are NOT treated as overrides +- Any dict-type config option can be overridden +- CLI arguments always take priority over service-specific config +- This feature enables fine-tuned control without modifying global settings + ## set_terminal_bg (bool) Controls whether unshackle should set the terminal background color. Default: `false` @@ -603,6 +1377,26 @@ For example, set_terminal_bg: true ``` +## simkl_client_id (str) + +Client ID for SIMKL API integration. SIMKL is used as a metadata source for improved title matching and tagging, +especially when a TMDB API key is not configured. + +To obtain a SIMKL Client ID: + +1. Create an account at +2. Go to +3. Register a new application to receive your Client ID + +For example, + +```yaml +simkl_client_id: "your_client_id_here" +``` + +**Note**: While optional, having a SIMKL Client ID improves metadata lookup reliability and reduces the chance +of rate limiting. SIMKL serves as an alternative or fallback metadata source to TMDB. + ## tag (str) Group or Username to postfix to the end of all download filenames following a dash. @@ -651,28 +1445,58 @@ tmdb_api_key: cf66bf18956kca5311ada3bebb84eb9a # Not a real key ## subtitle (dict) -Control subtitle conversion and SDH (hearing-impaired) stripping behavior. +Control subtitle conversion, SDH (hearing-impaired) stripping behavior, and formatting preservation. + +### Conversion and Processing Options - `conversion_method`: How to convert subtitles between formats. Default: `auto`. - - `auto`: Use subby for WebVTT/SAMI, standard for others. - - `subby`: Always use subby with CommonIssuesFixer. + - `auto`: Smart routing - use subby for WebVTT/SAMI, pycaption for others. + - `subby`: Always use subby with CommonIssuesFixer for advanced processing. - `subtitleedit`: Prefer SubtitleEdit when available; otherwise fallback to standard conversion. - `pycaption`: Use only the pycaption library (no SubtitleEdit, no subby). - `pysubs2`: Use pysubs2 library (supports SRT, SSA, ASS, WebVTT, TTML, SAMI, MicroDVD, MPL2, TMP formats). - `sdh_method`: How to strip SDH cues. Default: `auto`. - - `auto`: Try subby for SRT first, then SubtitleEdit, then filter-subs. + + - `auto`: Try subby for SRT first, then SubtitleEdit, then subtitle-filter. - `subby`: Use subby's SDHStripper (SRT only). - `subtitleedit`: Use SubtitleEdit's RemoveTextForHI when available. - - `filter-subs`: Use the subtitle-filter library. + - `filter-subs`: Use the subtitle-filter library directly. -Example: +- `strip_sdh`: Automatically create stripped (non-SDH) versions of SDH subtitles. Default: `true`. + + Set to `false` to disable automatic SDH stripping entirely. When `true`, unshackle will automatically + detect SDH subtitles and create clean versions alongside the originals. + +- `convert_before_strip`: Auto-convert VTT/other formats to SRT before using subtitle-filter. Default: `true`. + + This ensures compatibility when subtitle-filter is used as the fallback SDH stripping method, as + subtitle-filter works best with SRT format. + +- `preserve_formatting`: Preserve original subtitle formatting (tags, positioning, styling). Default: `true`. + + When `true`, skips pycaption processing for WebVTT files to keep tags like ``, ``, positioning, + and other formatting intact. Combined with no `sub_format` setting, ensures subtitles remain in their + original format. + +### Example Configuration ```yaml subtitle: conversion_method: auto sdh_method: auto + strip_sdh: true + convert_before_strip: true + preserve_formatting: true +``` + +### Minimal Configuration (Disable Processing) + +```yaml +subtitle: + strip_sdh: false # Don't strip SDH + preserve_formatting: true # Keep all formatting intact ``` ## update_checks (bool) diff --git a/docs/GLUETUN.md b/docs/GLUETUN.md new file mode 100644 index 0000000..1787c30 --- /dev/null +++ b/docs/GLUETUN.md @@ -0,0 +1,159 @@ +# Gluetun VPN Proxy + +Gluetun provides Docker-managed VPN proxies supporting 50+ VPN providers. + +## Prerequisites + +**Docker must be installed and running.** + +```bash +# Linux +curl -fsSL https://get.docker.com | sh +sudo usermod -aG docker $USER # Then log out/in + +# Windows/Mac +# Install Docker Desktop: https://www.docker.com/products/docker-desktop/ +``` + +## Quick Start + +### 1. Configuration + +Add to `~/.config/unshackle/unshackle.yaml`: + +```yaml +proxy_providers: + gluetun: + providers: + nordvpn: + vpn_type: wireguard + credentials: + private_key: YOUR_PRIVATE_KEY +``` + +### 2. Usage + +Use 2-letter country codes directly: + +```bash +uv run unshackle dl SERVICE CONTENT --proxy gluetun:nordvpn:us +uv run unshackle dl SERVICE CONTENT --proxy gluetun:nordvpn:uk +``` + +Format: `gluetun:provider:region` + +## Provider Credential Requirements + +Each provider has different credential requirements. See the [Gluetun Wiki](https://github.com/qdm12/gluetun-wiki/tree/main/setup/providers) for complete details. + +| Provider | VPN Type | Required Credentials | +|----------|----------|---------------------| +| NordVPN | WireGuard | `private_key` only | +| ProtonVPN | WireGuard | `private_key` only | +| Windscribe | WireGuard | `private_key`, `addresses`, `preshared_key` (all required) | +| Surfshark | WireGuard | `private_key`, `addresses` | +| Mullvad | WireGuard | `private_key`, `addresses` | +| IVPN | WireGuard | `private_key`, `addresses` | +| ExpressVPN | OpenVPN | `username`, `password` (no WireGuard support) | +| Any | OpenVPN | `username`, `password` | + +### Configuration Examples + +**NordVPN/ProtonVPN** (only private_key needed): +```yaml +providers: + nordvpn: + vpn_type: wireguard + credentials: + private_key: YOUR_PRIVATE_KEY +``` + +**Windscribe** (all three credentials required): +```yaml +providers: + windscribe: + vpn_type: wireguard + credentials: + private_key: YOUR_PRIVATE_KEY + addresses: 10.x.x.x/32 + preshared_key: YOUR_PRESHARED_KEY # Required, can be empty string +``` + +**OpenVPN** (any provider): +```yaml +providers: + expressvpn: + vpn_type: openvpn + credentials: + username: YOUR_USERNAME + password: YOUR_PASSWORD +``` + +## Server Selection + +Most providers use `SERVER_COUNTRIES`, but some use `SERVER_REGIONS`: + +| Variable | Providers | +|----------|-----------| +| `SERVER_COUNTRIES` | NordVPN, ProtonVPN, Surfshark, Mullvad, ExpressVPN, and most others | +| `SERVER_REGIONS` | Windscribe, VyprVPN, VPN Secure | + +Unshackle handles this automatically - just use 2-letter country codes. + +## Global Settings + +```yaml +proxy_providers: + gluetun: + providers: {...} + base_port: 8888 # Starting port (default: 8888) + auto_cleanup: true # Remove containers on exit (default: true) + verify_ip: true # Verify IP matches region (default: true) + container_prefix: "unshackle-gluetun" + auth_user: username # Proxy auth (optional) + auth_password: password +``` + +## Features + +- **Container Reuse**: First request takes 10-30s; subsequent requests are instant +- **IP Verification**: Automatically verifies VPN exit IP matches requested region +- **Concurrent Sessions**: Multiple downloads share the same container +- **Specific Servers**: Use `--proxy gluetun:nordvpn:us1239` for specific server selection + +## Container Management + +```bash +# View containers +docker ps | grep unshackle-gluetun + +# Check logs +docker logs unshackle-gluetun-nordvpn-us + +# Remove all containers +docker ps -a | grep unshackle-gluetun | awk '{print $1}' | xargs docker rm -f +``` + +## Troubleshooting + +### Docker Permission Denied (Linux) +```bash +sudo usermod -aG docker $USER +# Then log out and log back in +``` + +### VPN Connection Failed +Check container logs for specific errors: +```bash +docker logs unshackle-gluetun-nordvpn-us +``` + +Common issues: +- Invalid/missing credentials +- Windscribe requires `preshared_key` (can be empty string) +- VPN provider server issues + +## Resources + +- [Gluetun Wiki](https://github.com/qdm12/gluetun-wiki) - Official provider documentation +- [Gluetun GitHub](https://github.com/qdm12/gluetun) diff --git a/pyproject.toml b/pyproject.toml index 96f9cdc..43eafd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ dependencies = [ "aiohttp-swagger3>=0.9.0,<1", "pysubs2>=1.7.0,<2", "PyExecJS>=1.5.1,<2", + "pycountry>=24.6.1", ] [project.urls] diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 5a38b20..535b1f0 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -48,7 +48,7 @@ from unshackle.core.constants import DOWNLOAD_LICENCE_ONLY, AnyTrack, context_se from unshackle.core.credential import Credential from unshackle.core.drm import DRM_T, PlayReady, Widevine from unshackle.core.events import events -from unshackle.core.proxies import Basic, Hola, NordVPN, SurfsharkVPN, WindscribeVPN +from unshackle.core.proxies import Basic, Gluetun, Hola, NordVPN, SurfsharkVPN, WindscribeVPN from unshackle.core.service import Service from unshackle.core.services import Services from unshackle.core.title_cacher import get_account_hash @@ -261,13 +261,6 @@ class dl: default=None, help="Wanted episodes, e.g. `S01-S05,S07`, `S01E01-S02E03`, `S02-S02E03`, e.t.c, defaults to all.", ) - @click.option( - "-le", - "--latest-episode", - is_flag=True, - default=False, - help="Download only the single most recent episode available.", - ) @click.option( "-l", "--lang", @@ -275,6 +268,12 @@ class dl: default="orig", help="Language wanted for Video and Audio. Use 'orig' to select the original language, e.g. 'orig,en' for both original and English.", ) + @click.option( + "--latest-episode", + is_flag=True, + default=False, + help="Download only the single most recent episode available.", + ) @click.option( "-vl", "--v-lang", @@ -665,6 +664,8 @@ class dl: self.proxy_providers.append(SurfsharkVPN(**config.proxy_providers["surfsharkvpn"])) if config.proxy_providers.get("windscribevpn"): self.proxy_providers.append(WindscribeVPN(**config.proxy_providers["windscribevpn"])) + if config.proxy_providers.get("gluetun"): + self.proxy_providers.append(Gluetun(**config.proxy_providers["gluetun"])) if binaries.HolaProxy: self.proxy_providers.append(Hola()) for proxy_provider in self.proxy_providers: @@ -675,7 +676,8 @@ class dl: if re.match(r"^[a-z]+:.+$", proxy, re.IGNORECASE): # requesting proxy from a specific proxy provider requested_provider, proxy = proxy.split(":", maxsplit=1) - if re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE): + # Match simple region codes (us, ca, uk1) or provider:region format (nordvpn:ca, windscribe:us) + if re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE) or re.match(r"^[a-z]+:[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE): proxy = proxy.lower() with console.status(f"Getting a Proxy to {proxy}...", spinner="dots"): if requested_provider: @@ -699,8 +701,14 @@ class dl: proxy = ctx.params["proxy"] = proxy_uri self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}") break + # Store proxy query info for service-specific overrides + ctx.params["proxy_query"] = proxy + ctx.params["proxy_provider"] = requested_provider else: self.log.info(f"Using explicit Proxy: {proxy}") + # For explicit proxies, store None for query/provider + ctx.params["proxy_query"] = None + ctx.params["proxy_provider"] = None ctx.obj = ContextData( config=self.service_config, cdm=self.cdm, proxy_providers=self.proxy_providers, profile=self.profile diff --git a/unshackle/commands/env.py b/unshackle/commands/env.py index 504cbf6..94d9a0b 100644 --- a/unshackle/commands/env.py +++ b/unshackle/commands/env.py @@ -97,6 +97,7 @@ def check() -> None: "cat": "Network", }, {"name": "Caddy", "binary": binaries.Caddy, "required": False, "desc": "Web server", "cat": "Network"}, + {"name": "Docker", "binary": binaries.Docker, "required": False, "desc": "Gluetun VPN", "cat": "Network"}, ] # Track overall status diff --git a/unshackle/commands/remote_auth.py b/unshackle/commands/remote_auth.py new file mode 100644 index 0000000..23b6de2 --- /dev/null +++ b/unshackle/commands/remote_auth.py @@ -0,0 +1,225 @@ +"""CLI command for authenticating remote services.""" + +from typing import Optional + +import click +from rich.table import Table + +from unshackle.core.config import config +from unshackle.core.console import console +from unshackle.core.constants import context_settings +from unshackle.core.remote_auth import RemoteAuthenticator + + +@click.group(short_help="Manage remote service authentication.", context_settings=context_settings) +def remote_auth() -> None: + """Authenticate and manage sessions for remote services.""" + pass + + +@remote_auth.command(name="authenticate") +@click.argument("service", type=str) +@click.option( + "-r", "--remote", type=str, help="Remote server name or URL (from config)", required=False +) +@click.option("-p", "--profile", type=str, help="Profile to use for authentication") +def authenticate_command(service: str, remote: Optional[str], profile: Optional[str]) -> None: + """ + Authenticate a service locally and upload session to remote server. + + This command: + 1. Authenticates the service locally (shows browser, handles 2FA, etc.) + 2. Extracts the authenticated session + 3. Uploads the session to the remote server + + The server will use this pre-authenticated session for all requests. + + Examples: + unshackle remote-auth authenticate DSNP + unshackle remote-auth authenticate NF --profile john + unshackle remote-auth auth AMZN --remote my-server + """ + # Get remote server config + remote_config = _get_remote_config(remote) + if not remote_config: + return + + remote_url = remote_config["url"] + api_key = remote_config["api_key"] + server_name = remote_config.get("name", remote_url) + + console.print(f"\n[bold cyan]Authenticating {service} for remote server:[/bold cyan] {server_name}") + console.print(f"[dim]Server: {remote_url}[/dim]\n") + + # Create authenticator + authenticator = RemoteAuthenticator(remote_url, api_key) + + # Authenticate and save locally + success = authenticator.authenticate_and_save(service, profile) + + if success: + console.print(f"\n[bold green]✓ Success![/bold green] Session saved locally. You can now use remote_{service} service.") + else: + console.print(f"\n[bold red]✗ Failed to authenticate {service}[/bold red]") + raise click.Abort() + + +@remote_auth.command(name="status") +@click.option( + "-r", "--remote", type=str, help="Remote server name or URL (from config)", required=False +) +def status_command(remote: Optional[str]) -> None: + """ + Show status of all authenticated sessions in local cache. + + Examples: + unshackle remote-auth status + unshackle remote-auth status --remote my-server + """ + import datetime + + from unshackle.core.local_session_cache import get_local_session_cache + + # Get local session cache + cache = get_local_session_cache() + + # Get remote server config (optional filter) + remote_url = None + if remote: + remote_config = _get_remote_config(remote) + if remote_config: + remote_url = remote_config["url"] + server_name = remote_config.get("name", remote_url) + else: + server_name = "All Remotes" + + # Get sessions (filtered by remote if specified) + sessions = cache.list_sessions(remote_url) + + if not sessions: + if remote_url: + console.print(f"\n[yellow]No authenticated sessions for {server_name}[/yellow]") + else: + console.print("\n[yellow]No authenticated sessions in local cache[/yellow]") + console.print("\nUse [cyan]unshackle remote-auth authenticate [/cyan] to add sessions") + return + + # Display sessions in table + table = Table(title=f"Local Authenticated Sessions - {server_name}") + table.add_column("Remote", style="magenta") + table.add_column("Service", style="cyan") + table.add_column("Profile", style="green") + table.add_column("Cached", style="dim") + table.add_column("Age", style="yellow") + table.add_column("Status", style="bold") + + for session in sessions: + cached_time = datetime.datetime.fromtimestamp(session["cached_at"]).strftime("%Y-%m-%d %H:%M") + + # Format age + age_seconds = session["age_seconds"] + if age_seconds < 3600: + age_str = f"{age_seconds // 60}m" + elif age_seconds < 86400: + age_str = f"{age_seconds // 3600}h" + else: + age_str = f"{age_seconds // 86400}d" + + # Status + status = "[red]Expired" if session["expired"] else "[green]Valid" + + # Short remote URL for display + remote_display = session["remote_url"].replace("https://", "").replace("http://", "") + if len(remote_display) > 30: + remote_display = remote_display[:27] + "..." + + table.add_row( + remote_display, + session["service_tag"], + session["profile"], + cached_time, + age_str, + status + ) + + console.print() + console.print(table) + console.print("\n[dim]Sessions are stored locally and expire after 24 hours[/dim]") + console.print() + + +@remote_auth.command(name="delete") +@click.argument("service", type=str) +@click.option( + "-r", "--remote", type=str, help="Remote server name or URL (from config)", required=False +) +@click.option("-p", "--profile", type=str, default="default", help="Profile name") +def delete_command(service: str, remote: Optional[str], profile: str) -> None: + """ + Delete an authenticated session from local cache. + + Examples: + unshackle remote-auth delete DSNP + unshackle remote-auth delete NF --profile john + """ + from unshackle.core.local_session_cache import get_local_session_cache + + # Get remote server config + remote_config = _get_remote_config(remote) + if not remote_config: + return + + remote_url = remote_config["url"] + + cache = get_local_session_cache() + + console.print(f"\n[yellow]Deleting local session for {service} (profile: {profile})...[/yellow]") + + deleted = cache.delete_session(remote_url, service, profile) + + if deleted: + console.print("[green]✓ Session deleted from local cache[/green]") + else: + console.print(f"[red]✗ No session found for {service} (profile: {profile})[/red]") + + +def _get_remote_config(remote: Optional[str]) -> Optional[dict]: + """ + Get remote server configuration. + + Args: + remote: Remote server name or URL, or None for first configured remote + + Returns: + Remote config dict or None + """ + if not config.remote_services: + console.print("[red]No remote services configured in unshackle.yaml[/red]") + console.print("\nAdd a remote service to your config:") + console.print("[dim]remote_services:") + console.print(" - url: https://your-server.com") + console.print(" api_key: your-api-key") + console.print(" name: my-server[/dim]") + return None + + # If no remote specified, use the first one + if not remote: + return config.remote_services[0] + + # Check if remote is a name + for remote_config in config.remote_services: + if remote_config.get("name") == remote: + return remote_config + + # Check if remote is a URL + for remote_config in config.remote_services: + if remote_config.get("url") == remote: + return remote_config + + console.print(f"[red]Remote server '{remote}' not found in config[/red]") + console.print("\nAvailable remotes:") + for remote_config in config.remote_services: + name = remote_config.get("name", remote_config.get("url")) + console.print(f" - {name}") + + return None diff --git a/unshackle/commands/search.py b/unshackle/commands/search.py index a6d63bb..450f263 100644 --- a/unshackle/commands/search.py +++ b/unshackle/commands/search.py @@ -16,7 +16,7 @@ from unshackle.core import binaries from unshackle.core.config import config from unshackle.core.console import console from unshackle.core.constants import context_settings -from unshackle.core.proxies import Basic, Hola, NordVPN, SurfsharkVPN +from unshackle.core.proxies import Basic, Gluetun, Hola, NordVPN, SurfsharkVPN, WindscribeVPN from unshackle.core.service import Service from unshackle.core.services import Services from unshackle.core.utils.click_types import ContextData @@ -71,6 +71,10 @@ def search(ctx: click.Context, no_proxy: bool, profile: Optional[str] = None, pr proxy_providers.append(NordVPN(**config.proxy_providers["nordvpn"])) if config.proxy_providers.get("surfsharkvpn"): proxy_providers.append(SurfsharkVPN(**config.proxy_providers["surfsharkvpn"])) + if config.proxy_providers.get("windscribevpn"): + proxy_providers.append(WindscribeVPN(**config.proxy_providers["windscribevpn"])) + if config.proxy_providers.get("gluetun"): + proxy_providers.append(Gluetun(**config.proxy_providers["gluetun"])) if binaries.HolaProxy: proxy_providers.append(Hola()) for proxy_provider in proxy_providers: @@ -81,7 +85,8 @@ def search(ctx: click.Context, no_proxy: bool, profile: Optional[str] = None, pr if re.match(r"^[a-z]+:.+$", proxy, re.IGNORECASE): # requesting proxy from a specific proxy provider requested_provider, proxy = proxy.split(":", maxsplit=1) - if re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE): + # Match simple region codes (us, ca, uk1) or provider:region format (nordvpn:ca, windscribe:us) + if re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE) or re.match(r"^[a-z]+:[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE): proxy = proxy.lower() with console.status(f"Getting a Proxy to {proxy}...", spinner="dots"): if requested_provider: diff --git a/unshackle/commands/serve.py b/unshackle/commands/serve.py index a28d633..692a82b 100644 --- a/unshackle/commands/serve.py +++ b/unshackle/commands/serve.py @@ -24,7 +24,13 @@ from unshackle.core.constants import context_settings default=False, help="Include technical debug information (tracebacks, stderr) in API error responses.", ) -def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug_api: bool) -> None: +@click.option( + "--debug", + is_flag=True, + default=False, + help="Enable debug logging for API operations.", +) +def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug_api: bool, debug: bool) -> None: """ Serve your Local Widevine Devices and REST API for Remote Access. @@ -39,12 +45,60 @@ def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug \b The REST API provides programmatic access to unshackle functionality. - Configure authentication in your config under serve.users and serve.api_secret. + Configure authentication in your config under serve.api_secret and serve.api_keys. + + \b + API KEY TIERS: + Premium API keys can use server-side CDM for decryption. Configure in unshackle.yaml: + + \b + serve: + api_secret: "your-api-secret" + api_keys: + - key: "basic-user-key" + tier: "basic" + allowed_cdms: [] + - key: "premium-user-key" + tier: "premium" + default_cdm: "chromecdm_2101" + allowed_cdms: ["*"] # or list specific CDMs: ["chromecdm_2101", "chromecdm_2202"] + + \b + REMOTE SERVICES: + The server exposes endpoints that allow remote unshackle clients to use + your configured services without needing the service implementations. + Remote clients can authenticate, get titles/tracks, and receive session data + for downloading. Configure remote clients in unshackle.yaml: + + \b + remote_services: + - url: "http://your-server:8786" + api_key: "your-api-key" + name: "my-server" + + \b + Available remote endpoints: + - GET /api/remote/services - List available services + - POST /api/remote/{service}/search - Search for content + - POST /api/remote/{service}/titles - Get titles + - POST /api/remote/{service}/tracks - Get tracks + - POST /api/remote/{service}/chapters - Get chapters + - POST /api/remote/{service}/license - Get DRM license (uses client CDM) + - POST /api/remote/{service}/decrypt - Decrypt using server CDM (premium only) """ from pywidevine import serve as pywidevine_serve log = logging.getLogger("serve") + # Configure logging level based on --debug flag + if debug: + logging.basicConfig(level=logging.DEBUG, format="%(name)s - %(levelname)s - %(message)s") + log.info("Debug logging enabled for API operations") + else: + # Set API loggers to WARNING to reduce noise unless --debug is used + logging.getLogger("api").setLevel(logging.WARNING) + logging.getLogger("api.remote").setLevel(logging.WARNING) + # Validate API secret for REST API routes (unless --no-key is used) if not no_key: api_secret = config.serve.get("api_secret") diff --git a/unshackle/core/api/api_keys.py b/unshackle/core/api/api_keys.py new file mode 100644 index 0000000..255a45c --- /dev/null +++ b/unshackle/core/api/api_keys.py @@ -0,0 +1,137 @@ +"""API key tier management for remote services.""" + +import logging +from typing import Any, Dict, List, Optional + +from aiohttp import web + +log = logging.getLogger("api.keys") + + +def get_api_key_from_request(request: web.Request) -> Optional[str]: + """ + Extract API key from request headers. + + Args: + request: aiohttp request object + + Returns: + API key string or None + """ + return request.headers.get("X-API-Key") or request.headers.get("Authorization", "").replace("Bearer ", "") + + +def get_api_key_config(app: web.Application, api_key: str) -> Optional[Dict[str, Any]]: + """ + Get configuration for a specific API key. + + Args: + app: aiohttp application + api_key: API key to look up + + Returns: + API key configuration dict or None if not found + """ + config = app.get("config", {}) + + # Check new-style tiered API keys + api_keys = config.get("api_keys", []) + for key_config in api_keys: + if isinstance(key_config, dict) and key_config.get("key") == api_key: + return key_config + + # Check legacy users list (backward compatibility) + users = config.get("users", []) + if api_key in users: + return { + "key": api_key, + "tier": "basic", + "allowed_cdms": [] + } + + return None + + +def is_premium_user(app: web.Application, api_key: str) -> bool: + """ + Check if an API key belongs to a premium user. + + Premium users can use server-side CDM for decryption. + + Args: + app: aiohttp application + api_key: API key to check + + Returns: + True if premium user, False otherwise + """ + key_config = get_api_key_config(app, api_key) + if not key_config: + return False + + tier = key_config.get("tier", "basic") + return tier == "premium" + + +def get_allowed_cdms(app: web.Application, api_key: str) -> List[str]: + """ + Get list of CDMs that an API key is allowed to use. + + Args: + app: aiohttp application + api_key: API key to check + + Returns: + List of allowed CDM names, or empty list if not premium + """ + key_config = get_api_key_config(app, api_key) + if not key_config: + return [] + + allowed_cdms = key_config.get("allowed_cdms", []) + + # Handle wildcard + if allowed_cdms == "*" or allowed_cdms == ["*"]: + return ["*"] + + return allowed_cdms if isinstance(allowed_cdms, list) else [] + + +def get_default_cdm(app: web.Application, api_key: str) -> Optional[str]: + """ + Get default CDM for an API key. + + Args: + app: aiohttp application + api_key: API key to check + + Returns: + Default CDM name or None + """ + key_config = get_api_key_config(app, api_key) + if not key_config: + return None + + return key_config.get("default_cdm") + + +def can_use_cdm(app: web.Application, api_key: str, cdm_name: str) -> bool: + """ + Check if an API key can use a specific CDM. + + Args: + app: aiohttp application + api_key: API key to check + cdm_name: CDM name to check access for + + Returns: + True if allowed, False otherwise + """ + allowed_cdms = get_allowed_cdms(app, api_key) + + # Wildcard access + if "*" in allowed_cdms: + return True + + # Specific CDM access + return cdm_name in allowed_cdms diff --git a/unshackle/core/api/remote_handlers.py b/unshackle/core/api/remote_handlers.py new file mode 100644 index 0000000..b4a8cc3 --- /dev/null +++ b/unshackle/core/api/remote_handlers.py @@ -0,0 +1,1879 @@ +"""API handlers for remote service functionality.""" + +import http.cookiejar +import inspect +import logging +import tempfile +import time +from pathlib import Path +from typing import Any, Dict, Optional + +import click +import yaml +from aiohttp import web + +from unshackle.commands.dl import dl +from unshackle.core.api.api_keys import can_use_cdm, get_api_key_from_request, get_default_cdm, is_premium_user +from unshackle.core.api.handlers import (serialize_audio_track, serialize_subtitle_track, serialize_title, + serialize_video_track, validate_service) +from unshackle.core.api.session_serializer import deserialize_session, serialize_session +from unshackle.core.config import config +from unshackle.core.credential import Credential +from unshackle.core.search_result import SearchResult +from unshackle.core.services import Services +from unshackle.core.titles import Episode +from unshackle.core.utils.click_types import ContextData +from unshackle.core.utils.collections import merge_dict + +log = logging.getLogger("api.remote") + +# Session expiry time in seconds (24 hours) +SESSION_EXPIRY_TIME = 86400 + + +def load_cookies_from_content(cookies_content: Optional[str]) -> Optional[http.cookiejar.MozillaCookieJar]: + """ + Load cookies from raw cookie file content. + + Args: + cookies_content: Raw content of a Netscape/Mozilla format cookie file + + Returns: + MozillaCookieJar object or None + """ + if not cookies_content: + return None + + # Write to temporary file + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + f.write(cookies_content) + temp_path = f.name + + try: + # Load using standard cookie jar + cookie_jar = http.cookiejar.MozillaCookieJar(temp_path) + cookie_jar.load(ignore_discard=True, ignore_expires=True) + return cookie_jar + finally: + # Clean up temp file + Path(temp_path).unlink(missing_ok=True) + + +def create_credential_from_dict(cred_data: Optional[Dict[str, str]]) -> Optional[Credential]: + """ + Create a Credential object from dictionary. + + Args: + cred_data: Dictionary with 'username' and 'password' keys + + Returns: + Credential object or None + """ + if not cred_data or "username" not in cred_data or "password" not in cred_data: + return None + + return Credential(username=cred_data["username"], password=cred_data["password"]) + + +def validate_session_expiry(session_data: Dict[str, Any]) -> Optional[str]: + """ + Validate if a session is expired. + + Args: + session_data: Session data with cached_at timestamp + + Returns: + Error code if session is expired, None if valid + """ + if not session_data: + return None + + cached_at = session_data.get("cached_at") + if not cached_at: + # No timestamp - assume valid (backward compatibility) + return None + + age = time.time() - cached_at + if age > SESSION_EXPIRY_TIME: + log.warning(f"Session expired (age: {age:.0f}s, limit: {SESSION_EXPIRY_TIME}s)") + return "SESSION_EXPIRED" + + # Warn if session is close to expiry (within 1 hour) + if age > (SESSION_EXPIRY_TIME - 3600): + remaining = SESSION_EXPIRY_TIME - age + log.info(f"Session expires soon (remaining: {remaining:.0f}s)") + + return None + + +def get_auth_from_request(data: Dict[str, Any], service_tag: str, profile: Optional[str] = None): + """ + Get authentication from request data or fallback to server config. + + Server is STATELESS - it never stores sessions. + Client sends pre-authenticated session with each request. + + Priority order: + 1. Pre-authenticated session from client (sent with request) + 2. Client-provided credentials/cookies in request + 3. Server-side credentials/cookies from config (fallback) + + Args: + data: Request data + service_tag: Service tag + profile: Profile name + + Returns: + Tuple of (cookies, credential, pre_authenticated_session, session_error) + where session_error is an error code if session is expired + """ + # First priority: Check for pre-authenticated session sent by client + pre_authenticated_session = data.get("pre_authenticated_session") + + if pre_authenticated_session: + log.info(f"Using client's pre-authenticated session for {service_tag}") + + # Validate session expiry + session_error = validate_session_expiry(pre_authenticated_session) + if session_error: + log.warning(f"Session validation failed: {session_error}") + return None, None, None, session_error + + # Return None, None to indicate we'll use the pre-authenticated session + return None, None, pre_authenticated_session, None + + # Second priority: Try to get from client request + cookies_content = data.get("cookies") + credential_data = data.get("credential") + + if cookies_content: + cookies = load_cookies_from_content(cookies_content) + else: + # Fallback to server-side cookies if not provided by client + cookies = dl.get_cookie_jar(service_tag, profile) + + if credential_data: + credential = create_credential_from_dict(credential_data) + else: + # Fallback to server-side credentials if not provided by client + credential = dl.get_credentials(service_tag, profile) + + return cookies, credential, None, None + + +async def remote_list_services(request: web.Request) -> web.Response: + """ + List all available services on this remote server. + --- + summary: List remote services + description: Get all available services that can be accessed remotely + responses: + '200': + description: List of available services + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: success + services: + type: array + items: + type: object + properties: + tag: + type: string + aliases: + type: array + items: + type: string + geofence: + type: array + items: + type: string + help: + type: string + '500': + description: Server error + """ + try: + service_tags = Services.get_tags() + services_info = [] + + for tag in service_tags: + service_data = { + "tag": tag, + "aliases": [], + "geofence": [], + "help": None, + } + + try: + service_module = Services.load(tag) + + if hasattr(service_module, "ALIASES"): + service_data["aliases"] = list(service_module.ALIASES) + + if hasattr(service_module, "GEOFENCE"): + service_data["geofence"] = list(service_module.GEOFENCE) + + if service_module.__doc__: + service_data["help"] = service_module.__doc__.strip() + + except Exception as e: + log.warning(f"Could not load details for service {tag}: {e}") + + services_info.append(service_data) + + return web.json_response({"status": "success", "services": services_info}) + + except Exception: + log.exception("Error listing remote services") + return web.json_response({"status": "error", "message": "Internal server error while listing services"}, status=500) + + +async def remote_search(request: web.Request) -> web.Response: + """ + Search for content on a remote service. + --- + summary: Search remote service + description: Search for content using a remote service + parameters: + - name: service + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - query + properties: + query: + type: string + description: Search query + profile: + type: string + description: Profile to use for credentials + responses: + '200': + description: Search results + '400': + description: Invalid request + '500': + description: Server error + """ + service_tag = request.match_info.get("service") + + try: + data = await request.json() + except Exception: + return web.json_response({"status": "error", "message": "Invalid JSON request body"}, status=400) + + query = data.get("query") + if not query: + return web.json_response({"status": "error", "message": "Missing required parameter: query"}, status=400) + + normalized_service = validate_service(service_tag) + if not normalized_service: + return web.json_response( + {"status": "error", "message": f"Invalid or unavailable service: {service_tag}"}, status=400 + ) + + try: + profile = data.get("profile") + + service_config_path = Services.get_path(normalized_service) / config.filenames.config + if service_config_path.exists(): + service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8")) + else: + service_config = {} + merge_dict(config.services.get(normalized_service), service_config) + + @click.command() + @click.pass_context + def dummy_service(ctx: click.Context) -> None: + pass + + # Handle proxy configuration + # Client MUST send resolved proxy with credentials (e.g., http://user:pass@host:port) + # Server does NOT resolve proxy providers - client must do that + proxy_param = data.get("proxy") + no_proxy = data.get("no_proxy", False) + + if proxy_param and not no_proxy: + import re + + # Validate that client sent a fully resolved proxy URL + if re.match(r"^https?://", proxy_param): + log.info("Using client-resolved proxy with credentials") + else: + # Reject unresolved proxy parameters + log.error(f"[SECURITY] Client sent unresolved proxy parameter: {proxy_param}") + return web.json_response({ + "status": "error", + "error_code": "INVALID_PROXY", + "message": f"Proxy must be a fully resolved URL (http://... or https://...). " + f"Cannot use proxy provider shortcuts like '{proxy_param}'. " + f"Please resolve the proxy on the client side before sending to server." + }, status=400) + + ctx = click.Context(dummy_service) + ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=[], profile=profile) + ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy} + + service_module = Services.load(normalized_service) + + dummy_service.name = normalized_service + ctx.invoked_subcommand = normalized_service + + service_ctx = click.Context(dummy_service, parent=ctx) + service_ctx.obj = ctx.obj + + # Get service initialization parameters + service_init_params = inspect.signature(service_module.__init__).parameters + service_kwargs = {} + + # Extract defaults from click command + if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"): + for param in service_module.cli.params: + if hasattr(param, "name") and param.name not in service_kwargs: + if hasattr(param, "default") and param.default is not None: + service_kwargs[param.name] = param.default + + # Add query parameter + if "query" in service_init_params: + service_kwargs["query"] = query + + # Filter to only valid parameters + filtered_kwargs = {k: v for k, v in service_kwargs.items() if k in service_init_params} + + service_instance = service_module(service_ctx, **filtered_kwargs) + + # Authenticate with client-provided or server-side auth + cookies, credential, pre_authenticated_session, session_error = get_auth_from_request(data, normalized_service, profile) + + # Check for session expiry + if session_error == "SESSION_EXPIRED": + return web.json_response({ + "status": "error", + "error_code": "SESSION_EXPIRED", + "message": f"Session expired for {normalized_service}. Please re-authenticate." + }, status=401) + + try: + if pre_authenticated_session: + # Use pre-authenticated session sent by client (server is stateless) + deserialize_session(pre_authenticated_session, service_instance.session) + else: + # Authenticate with credentials/cookies + if not cookies and not credential: + # No auth data available - tell client to authenticate + return web.json_response({ + "status": "error", + "error_code": "AUTH_REQUIRED", + "message": f"Authentication required for {normalized_service}. No credentials or session available." + }, status=401) + + service_instance.authenticate(cookies, credential) + except Exception as auth_error: + # Authentication failed - tell client to re-authenticate + log.warning(f"Authentication failed for {normalized_service}: {auth_error}") + return web.json_response({ + "status": "error", + "error_code": "AUTH_REQUIRED", + "message": f"Authentication failed for {normalized_service}. Please authenticate locally." + }, status=401) + + # Perform search + search_results = [] + if hasattr(service_instance, "search"): + for result in service_instance.search(): + if isinstance(result, SearchResult): + search_results.append( + { + "id": str(result.id_), + "title": result.title, + "description": result.description, + "label": result.label, + "url": result.url, + } + ) + + # Serialize session data + session_data = serialize_session(service_instance.session) + + return web.json_response({"status": "success", "results": search_results, "session": session_data}) + + except Exception: + log.exception("Error performing remote search") + return web.json_response({"status": "error", "message": "Internal server error while performing search"}, status=500) + + +async def remote_get_titles(request: web.Request) -> web.Response: + """ + Get titles from a remote service. + --- + summary: Get titles from remote service + description: Get available titles for content from a remote service + parameters: + - name: service + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - title + properties: + title: + type: string + description: Title identifier, URL, or any format accepted by the service + profile: + type: string + description: Profile to use for credentials + proxy: + type: string + description: Proxy region code (e.g., "ca", "us") or full proxy URL - uses server's proxy configuration + no_proxy: + type: boolean + description: Disable proxy usage + cookies: + type: string + description: Raw Netscape/Mozilla format cookie file content (optional - uses server cookies if not provided) + credential: + type: object + description: Credentials object with username and password (optional - uses server credentials if not provided) + properties: + username: + type: string + password: + type: string + responses: + '200': + description: Titles and session data + '400': + description: Invalid request + '500': + description: Server error + """ + service_tag = request.match_info.get("service") + + try: + data = await request.json() + except Exception: + return web.json_response({"status": "error", "message": "Invalid JSON request body"}, status=400) + + # Accept 'title', 'title_id', or 'url' for flexibility + title = data.get("title") or data.get("title_id") or data.get("url") + if not title: + return web.json_response( + { + "status": "error", + "message": "Missing required parameter: title (can be URL, ID, or any format accepted by the service)", + }, + status=400, + ) + + normalized_service = validate_service(service_tag) + if not normalized_service: + return web.json_response( + {"status": "error", "message": f"Invalid or unavailable service: {service_tag}"}, status=400 + ) + + try: + profile = data.get("profile") + + service_config_path = Services.get_path(normalized_service) / config.filenames.config + if service_config_path.exists(): + service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8")) + else: + service_config = {} + merge_dict(config.services.get(normalized_service), service_config) + + @click.command() + @click.pass_context + def dummy_service(ctx: click.Context) -> None: + pass + + # Handle proxy configuration + # Client MUST send resolved proxy with credentials (e.g., http://user:pass@host:port) + # Server does NOT resolve proxy providers - client must do that + proxy_param = data.get("proxy") + no_proxy = data.get("no_proxy", False) + + if proxy_param and not no_proxy: + import re + + # Validate that client sent a fully resolved proxy URL + if re.match(r"^https?://", proxy_param): + log.info("Using client-resolved proxy with credentials") + else: + # Reject unresolved proxy parameters + log.error(f"[SECURITY] Client sent unresolved proxy parameter: {proxy_param}") + return web.json_response({ + "status": "error", + "error_code": "INVALID_PROXY", + "message": f"Proxy must be a fully resolved URL (http://... or https://...). " + f"Cannot use proxy provider shortcuts like '{proxy_param}'. " + f"Please resolve the proxy on the client side before sending to server." + }, status=400) + + ctx = click.Context(dummy_service) + ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=[], profile=profile) + ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy} + + service_module = Services.load(normalized_service) + + dummy_service.name = normalized_service + dummy_service.params = [click.Argument([title], type=str)] + ctx.invoked_subcommand = normalized_service + + service_ctx = click.Context(dummy_service, parent=ctx) + service_ctx.obj = ctx.obj + + service_kwargs = {"title": title} + + # Add additional parameters from request data + for key, value in data.items(): + if key not in ["title", "title_id", "url", "profile", "proxy", "no_proxy"]: + service_kwargs[key] = value + + # Get service parameter info and click command defaults + service_init_params = inspect.signature(service_module.__init__).parameters + + # Extract default values from the click command + if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"): + for param in service_module.cli.params: + if hasattr(param, "name") and param.name not in service_kwargs: + if hasattr(param, "default") and param.default is not None: + service_kwargs[param.name] = param.default + + # Handle required parameters + for param_name, param_info in service_init_params.items(): + if param_name not in service_kwargs and param_name not in ["self", "ctx"]: + if param_info.default is inspect.Parameter.empty: + if param_name == "meta_lang": + service_kwargs[param_name] = None + elif param_name == "movie": + service_kwargs[param_name] = False + + # Filter to only valid parameters + filtered_kwargs = {k: v for k, v in service_kwargs.items() if k in service_init_params} + + service_instance = service_module(service_ctx, **filtered_kwargs) + + # Authenticate with client-provided or server-side auth + cookies, credential, pre_authenticated_session, session_error = get_auth_from_request(data, normalized_service, profile) + + # Check for session expiry + if session_error == "SESSION_EXPIRED": + return web.json_response({ + "status": "error", + "error_code": "SESSION_EXPIRED", + "message": f"Session expired for {normalized_service}. Please re-authenticate." + }, status=401) + + try: + if pre_authenticated_session: + # Use pre-authenticated session sent by client (server is stateless) + deserialize_session(pre_authenticated_session, service_instance.session) + else: + # Authenticate with credentials/cookies + if not cookies and not credential: + # No auth data available - tell client to authenticate + return web.json_response({ + "status": "error", + "error_code": "AUTH_REQUIRED", + "message": f"Authentication required for {normalized_service}. No credentials or session available." + }, status=401) + + service_instance.authenticate(cookies, credential) + except Exception as auth_error: + # Authentication failed - tell client to re-authenticate + log.warning(f"Authentication failed for {normalized_service}: {auth_error}") + return web.json_response({ + "status": "error", + "error_code": "AUTH_REQUIRED", + "message": f"Authentication failed for {normalized_service}. Please authenticate locally." + }, status=401) + + # Get titles + titles = service_instance.get_titles() + + if hasattr(titles, "__iter__") and not isinstance(titles, str): + title_list = [serialize_title(t) for t in titles] + else: + title_list = [serialize_title(titles)] + + # Serialize session data + session_data = serialize_session(service_instance.session) + + # Include geofence info so client knows to activate VPN + geofence = [] + if hasattr(service_module, "GEOFENCE"): + geofence = list(service_module.GEOFENCE) + + return web.json_response({ + "status": "success", + "titles": title_list, + "session": session_data, + "geofence": geofence + }) + + except Exception: + log.exception("Error getting remote titles") + return web.json_response({"status": "error", "message": "Internal server error while getting titles"}, status=500) + + +async def remote_get_tracks(request: web.Request) -> web.Response: + """ + Get tracks from a remote service. + --- + summary: Get tracks from remote service + description: Get available tracks for a title from a remote service + parameters: + - name: service + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - title + properties: + title: + type: string + description: Title identifier, URL, or any format accepted by the service + wanted: + type: string + description: Specific episodes/seasons + profile: + type: string + description: Profile to use for credentials + proxy: + type: string + description: Proxy region code (e.g., "ca", "us") or full proxy URL - uses server's proxy configuration + no_proxy: + type: boolean + description: Disable proxy usage + cookies: + type: string + description: Raw Netscape/Mozilla format cookie file content (optional - uses server cookies if not provided) + credential: + type: object + description: Credentials object with username and password (optional - uses server credentials if not provided) + properties: + username: + type: string + password: + type: string + responses: + '200': + description: Tracks and session data + '400': + description: Invalid request + '500': + description: Server error + """ + service_tag = request.match_info.get("service") + + try: + data = await request.json() + except Exception: + return web.json_response({"status": "error", "message": "Invalid JSON request body"}, status=400) + + # Accept 'title', 'title_id', or 'url' for flexibility + title = data.get("title") or data.get("title_id") or data.get("url") + if not title: + return web.json_response( + { + "status": "error", + "message": "Missing required parameter: title (can be URL, ID, or any format accepted by the service)", + }, + status=400, + ) + + normalized_service = validate_service(service_tag) + if not normalized_service: + return web.json_response( + {"status": "error", "message": f"Invalid or unavailable service: {service_tag}"}, status=400 + ) + + try: + profile = data.get("profile") + + service_config_path = Services.get_path(normalized_service) / config.filenames.config + if service_config_path.exists(): + service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8")) + else: + service_config = {} + merge_dict(config.services.get(normalized_service), service_config) + + @click.command() + @click.pass_context + def dummy_service(ctx: click.Context) -> None: + pass + + # Handle proxy configuration + # Client MUST send resolved proxy with credentials (e.g., http://user:pass@host:port) + # Server does NOT resolve proxy providers - client must do that + proxy_param = data.get("proxy") + no_proxy = data.get("no_proxy", False) + + if proxy_param and not no_proxy: + import re + + # Validate that client sent a fully resolved proxy URL + if re.match(r"^https?://", proxy_param): + log.info("Using client-resolved proxy with credentials") + else: + # Reject unresolved proxy parameters + log.error(f"[SECURITY] Client sent unresolved proxy parameter: {proxy_param}") + return web.json_response({ + "status": "error", + "error_code": "INVALID_PROXY", + "message": f"Proxy must be a fully resolved URL (http://... or https://...). " + f"Cannot use proxy provider shortcuts like '{proxy_param}'. " + f"Please resolve the proxy on the client side before sending to server." + }, status=400) + + ctx = click.Context(dummy_service) + ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=[], profile=profile) + ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy} + + service_module = Services.load(normalized_service) + + dummy_service.name = normalized_service + dummy_service.params = [click.Argument([title], type=str)] + ctx.invoked_subcommand = normalized_service + + service_ctx = click.Context(dummy_service, parent=ctx) + service_ctx.obj = ctx.obj + + service_kwargs = {"title": title} + + # Add additional parameters + for key, value in data.items(): + if key not in ["title", "title_id", "url", "profile", "wanted", "season", "episode", "proxy", "no_proxy"]: + service_kwargs[key] = value + + # Get service parameters + service_init_params = inspect.signature(service_module.__init__).parameters + + # Extract defaults from click command + if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"): + for param in service_module.cli.params: + if hasattr(param, "name") and param.name not in service_kwargs: + if hasattr(param, "default") and param.default is not None: + service_kwargs[param.name] = param.default + + # Handle required parameters + for param_name, param_info in service_init_params.items(): + if param_name not in service_kwargs and param_name not in ["self", "ctx"]: + if param_info.default is inspect.Parameter.empty: + if param_name == "meta_lang": + service_kwargs[param_name] = None + elif param_name == "movie": + service_kwargs[param_name] = False + + # Filter to valid parameters + filtered_kwargs = {k: v for k, v in service_kwargs.items() if k in service_init_params} + + service_instance = service_module(service_ctx, **filtered_kwargs) + + # Authenticate with client-provided or server-side auth + cookies, credential, pre_authenticated_session, session_error = get_auth_from_request(data, normalized_service, profile) + + # Check for session expiry + if session_error == "SESSION_EXPIRED": + return web.json_response({ + "status": "error", + "error_code": "SESSION_EXPIRED", + "message": f"Session expired for {normalized_service}. Please re-authenticate." + }, status=401) + + try: + if pre_authenticated_session: + # Use pre-authenticated session sent by client (server is stateless) + deserialize_session(pre_authenticated_session, service_instance.session) + else: + # Authenticate with credentials/cookies + if not cookies and not credential: + # No auth data available - tell client to authenticate + return web.json_response({ + "status": "error", + "error_code": "AUTH_REQUIRED", + "message": f"Authentication required for {normalized_service}. No credentials or session available." + }, status=401) + + service_instance.authenticate(cookies, credential) + except Exception as auth_error: + # Authentication failed - tell client to re-authenticate + log.warning(f"Authentication failed for {normalized_service}: {auth_error}") + return web.json_response({ + "status": "error", + "error_code": "AUTH_REQUIRED", + "message": f"Authentication failed for {normalized_service}. Please authenticate locally." + }, status=401) + + # Get titles + titles = service_instance.get_titles() + + wanted_param = data.get("wanted") + season = data.get("season") + episode = data.get("episode") + + if hasattr(titles, "__iter__") and not isinstance(titles, str): + titles_list = list(titles) + + wanted = None + if wanted_param: + from unshackle.core.utils.click_types import SeasonRange + + try: + season_range = SeasonRange() + wanted = season_range.parse_tokens(wanted_param) + except Exception as e: + return web.json_response( + {"status": "error", "message": f"Invalid wanted parameter: {e}"}, status=400 + ) + elif season is not None and episode is not None: + wanted = [f"{season}x{episode}"] + + if wanted: + matching_titles = [] + for title in titles_list: + if isinstance(title, Episode): + episode_key = f"{title.season}x{title.number}" + if episode_key in wanted: + matching_titles.append(title) + else: + matching_titles.append(title) + + if not matching_titles: + return web.json_response( + {"status": "error", "message": "No episodes found matching wanted criteria"}, status=404 + ) + + # Handle multiple episodes + if len(matching_titles) > 1 and all(isinstance(t, Episode) for t in matching_titles): + episodes_data = [] + failed_episodes = [] + + sorted_titles = sorted(matching_titles, key=lambda t: (t.season, t.number)) + + for title in sorted_titles: + try: + tracks = service_instance.get_tracks(title) + video_tracks = sorted(tracks.videos, key=lambda t: t.bitrate or 0, reverse=True) + audio_tracks = sorted(tracks.audio, key=lambda t: t.bitrate or 0, reverse=True) + + episode_data = { + "title": serialize_title(title), + "video": [serialize_video_track(t) for t in video_tracks], + "audio": [serialize_audio_track(t) for t in audio_tracks], + "subtitles": [serialize_subtitle_track(t) for t in tracks.subtitles], + } + episodes_data.append(episode_data) + except (SystemExit, Exception): + failed_episodes.append(f"S{title.season}E{title.number:02d}") + continue + + if episodes_data: + session_data = serialize_session(service_instance.session) + + # Include geofence info + geofence = [] + if hasattr(service_module, "GEOFENCE"): + geofence = list(service_module.GEOFENCE) + + response = { + "status": "success", + "episodes": episodes_data, + "session": session_data, + "geofence": geofence + } + if failed_episodes: + response["unavailable_episodes"] = failed_episodes + return web.json_response(response) + else: + return web.json_response( + { + "status": "error", + "message": f"No available episodes. Unavailable: {', '.join(failed_episodes)}", + }, + status=404, + ) + else: + first_title = matching_titles[0] + else: + first_title = titles_list[0] + else: + first_title = titles + + # Get tracks for single title + tracks = service_instance.get_tracks(first_title) + + video_tracks = sorted(tracks.videos, key=lambda t: t.bitrate or 0, reverse=True) + audio_tracks = sorted(tracks.audio, key=lambda t: t.bitrate or 0, reverse=True) + + # Serialize session data + session_data = serialize_session(service_instance.session) + + # Include geofence info + geofence = [] + if hasattr(service_module, "GEOFENCE"): + geofence = list(service_module.GEOFENCE) + + response_data = { + "status": "success", + "title": serialize_title(first_title), + "video": [serialize_video_track(t) for t in video_tracks], + "audio": [serialize_audio_track(t) for t in audio_tracks], + "subtitles": [serialize_subtitle_track(t) for t in tracks.subtitles], + "session": session_data, + "geofence": geofence + } + + return web.json_response(response_data) + + except Exception: + log.exception("Error getting remote tracks") + return web.json_response({"status": "error", "message": "Internal server error while getting tracks"}, status=500) + + +async def remote_get_chapters(request: web.Request) -> web.Response: + """ + Get chapters from a remote service. + --- + summary: Get chapters from remote service + description: Get available chapters for a title from a remote service + parameters: + - name: service + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - title + properties: + title: + type: string + description: Title identifier, URL, or any format accepted by the service + profile: + type: string + description: Profile to use for credentials + proxy: + type: string + description: Proxy region code (e.g., "ca", "us") or full proxy URL - uses server's proxy configuration + no_proxy: + type: boolean + description: Disable proxy usage + cookies: + type: string + description: Raw Netscape/Mozilla format cookie file content (optional - uses server cookies if not provided) + credential: + type: object + description: Credentials object with username and password (optional - uses server credentials if not provided) + properties: + username: + type: string + password: + type: string + responses: + '200': + description: Chapters and session data + '400': + description: Invalid request + '500': + description: Server error + """ + service_tag = request.match_info.get("service") + + try: + data = await request.json() + except Exception: + return web.json_response({"status": "error", "message": "Invalid JSON request body"}, status=400) + + # Accept 'title', 'title_id', or 'url' for flexibility + title = data.get("title") or data.get("title_id") or data.get("url") + if not title: + return web.json_response( + { + "status": "error", + "message": "Missing required parameter: title (can be URL, ID, or any format accepted by the service)", + }, + status=400, + ) + + normalized_service = validate_service(service_tag) + if not normalized_service: + return web.json_response( + {"status": "error", "message": f"Invalid or unavailable service: {service_tag}"}, status=400 + ) + + try: + profile = data.get("profile") + + service_config_path = Services.get_path(normalized_service) / config.filenames.config + if service_config_path.exists(): + service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8")) + else: + service_config = {} + merge_dict(config.services.get(normalized_service), service_config) + + @click.command() + @click.pass_context + def dummy_service(ctx: click.Context) -> None: + pass + + # Handle proxy configuration + # Client MUST send resolved proxy with credentials (e.g., http://user:pass@host:port) + # Server does NOT resolve proxy providers - client must do that + proxy_param = data.get("proxy") + no_proxy = data.get("no_proxy", False) + + if proxy_param and not no_proxy: + import re + + # Validate that client sent a fully resolved proxy URL + if re.match(r"^https?://", proxy_param): + log.info("Using client-resolved proxy with credentials") + else: + # Reject unresolved proxy parameters + log.error(f"[SECURITY] Client sent unresolved proxy parameter: {proxy_param}") + return web.json_response({ + "status": "error", + "error_code": "INVALID_PROXY", + "message": f"Proxy must be a fully resolved URL (http://... or https://...). " + f"Cannot use proxy provider shortcuts like '{proxy_param}'. " + f"Please resolve the proxy on the client side before sending to server." + }, status=400) + + ctx = click.Context(dummy_service) + ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=[], profile=profile) + ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy} + + service_module = Services.load(normalized_service) + + dummy_service.name = normalized_service + dummy_service.params = [click.Argument([title], type=str)] + ctx.invoked_subcommand = normalized_service + + service_ctx = click.Context(dummy_service, parent=ctx) + service_ctx.obj = ctx.obj + + service_kwargs = {"title": title} + + # Add additional parameters + for key, value in data.items(): + if key not in ["title", "title_id", "url", "profile", "proxy", "no_proxy"]: + service_kwargs[key] = value + + # Get service parameters + service_init_params = inspect.signature(service_module.__init__).parameters + + # Extract defaults + if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"): + for param in service_module.cli.params: + if hasattr(param, "name") and param.name not in service_kwargs: + if hasattr(param, "default") and param.default is not None: + service_kwargs[param.name] = param.default + + # Handle required parameters + for param_name, param_info in service_init_params.items(): + if param_name not in service_kwargs and param_name not in ["self", "ctx"]: + if param_info.default is inspect.Parameter.empty: + if param_name == "meta_lang": + service_kwargs[param_name] = None + elif param_name == "movie": + service_kwargs[param_name] = False + + # Filter to valid parameters + filtered_kwargs = {k: v for k, v in service_kwargs.items() if k in service_init_params} + + service_instance = service_module(service_ctx, **filtered_kwargs) + + # Authenticate with client-provided or server-side auth + cookies, credential, pre_authenticated_session, session_error = get_auth_from_request(data, normalized_service, profile) + + # Check for session expiry + if session_error == "SESSION_EXPIRED": + return web.json_response({ + "status": "error", + "error_code": "SESSION_EXPIRED", + "message": f"Session expired for {normalized_service}. Please re-authenticate." + }, status=401) + + try: + if pre_authenticated_session: + # Use pre-authenticated session sent by client (server is stateless) + deserialize_session(pre_authenticated_session, service_instance.session) + else: + # Authenticate with credentials/cookies + if not cookies and not credential: + # No auth data available - tell client to authenticate + return web.json_response({ + "status": "error", + "error_code": "AUTH_REQUIRED", + "message": f"Authentication required for {normalized_service}. No credentials or session available." + }, status=401) + + service_instance.authenticate(cookies, credential) + except Exception as auth_error: + # Authentication failed - tell client to re-authenticate + log.warning(f"Authentication failed for {normalized_service}: {auth_error}") + return web.json_response({ + "status": "error", + "error_code": "AUTH_REQUIRED", + "message": f"Authentication failed for {normalized_service}. Please authenticate locally." + }, status=401) + + # Get titles + titles = service_instance.get_titles() + + if hasattr(titles, "__iter__") and not isinstance(titles, str): + first_title = list(titles)[0] + else: + first_title = titles + + # Get chapters if service supports it + chapters_data = [] + if hasattr(service_instance, "get_chapters"): + chapters = service_instance.get_chapters(first_title) + if chapters: + for chapter in chapters: + chapters_data.append( + { + "timestamp": chapter.timestamp, + "name": chapter.name if hasattr(chapter, "name") else None, + } + ) + + # Serialize session data + session_data = serialize_session(service_instance.session) + + return web.json_response({"status": "success", "chapters": chapters_data, "session": session_data}) + + except Exception: + log.exception("Error getting remote chapters") + return web.json_response({"status": "error", "message": "Internal server error while getting chapters"}, status=500) + + +async def remote_get_license(request: web.Request) -> web.Response: + """ + Get DRM license from a remote service using client's CDM. + + The server does NOT need a CDM - it just facilitates the license request + using the client's pre-authenticated session. The client decrypts using + their own CDM. + --- + summary: Get DRM license from remote service + description: Request license acquisition using client session (server does not need CDM) + parameters: + - name: service + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - title + - track_id + - challenge + properties: + title: + type: string + description: Title identifier + track_id: + type: string + description: Track ID for license + challenge: + type: string + description: Base64-encoded license challenge from client's CDM + session: + type: integer + description: CDM session ID + profile: + type: string + description: Profile to use + pre_authenticated_session: + type: object + description: Client's pre-authenticated session + responses: + '200': + description: License response + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: success + license: + type: string + description: Base64-encoded license response + session: + type: object + description: Updated session data + '400': + description: Invalid request + '401': + description: Authentication required + '500': + description: Server error + """ + service_tag = request.match_info.get("service") + + try: + data = await request.json() + except Exception: + return web.json_response({"status": "error", "message": "Invalid JSON request body"}, status=400) + + # Validate required parameters + title = data.get("title") + track_id = data.get("track_id") + challenge = data.get("challenge") + + if not all([title, track_id, challenge]): + return web.json_response( + { + "status": "error", + "message": "Missing required parameters: title, track_id, challenge" + }, + status=400 + ) + + normalized_service = validate_service(service_tag) + if not normalized_service: + return web.json_response( + {"status": "error", "message": f"Invalid or unavailable service: {service_tag}"}, + status=400 + ) + + try: + profile = data.get("profile") + + service_config_path = Services.get_path(normalized_service) / config.filenames.config + if service_config_path.exists(): + service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8")) + else: + service_config = {} + merge_dict(config.services.get(normalized_service), service_config) + + @click.command() + @click.pass_context + def dummy_service(ctx: click.Context) -> None: + pass + + # Handle proxy configuration + # Client MUST send resolved proxy with credentials (e.g., http://user:pass@host:port) + # Server does NOT resolve proxy providers - client must do that + proxy_param = data.get("proxy") + no_proxy = data.get("no_proxy", False) + + if proxy_param and not no_proxy: + import re + + # Validate that client sent a fully resolved proxy URL + if re.match(r"^https?://", proxy_param): + log.info("Using client-resolved proxy with credentials") + else: + # Reject unresolved proxy parameters + log.error(f"[SECURITY] Client sent unresolved proxy parameter: {proxy_param}") + return web.json_response({ + "status": "error", + "error_code": "INVALID_PROXY", + "message": f"Proxy must be a fully resolved URL (http://... or https://...). " + f"Cannot use proxy provider shortcuts like '{proxy_param}'. " + f"Please resolve the proxy on the client side before sending to server." + }, status=400) + + ctx = click.Context(dummy_service) + ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=[], profile=profile) + ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy} + + service_module = Services.load(normalized_service) + + dummy_service.name = normalized_service + ctx.invoked_subcommand = normalized_service + + service_ctx = click.Context(dummy_service, parent=ctx) + service_ctx.obj = ctx.obj + + service_kwargs = {"title": title} + + # Add additional parameters + for key, value in data.items(): + if key not in ["title", "track_id", "challenge", "session", "profile", "proxy", "no_proxy", "pre_authenticated_session", "credential", "cookies"]: + service_kwargs[key] = value + + # Get service parameters + service_init_params = inspect.signature(service_module.__init__).parameters + + # Extract defaults + if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"): + for param in service_module.cli.params: + if hasattr(param, "name") and param.name not in service_kwargs: + if hasattr(param, "default") and param.default is not None: + service_kwargs[param.name] = param.default + + # Handle required parameters + for param_name, param_info in service_init_params.items(): + if param_name not in service_kwargs and param_name not in ["self", "ctx"]: + if param_info.default is inspect.Parameter.empty: + if param_name == "meta_lang": + service_kwargs[param_name] = None + elif param_name == "movie": + service_kwargs[param_name] = False + + # Filter to valid parameters + filtered_kwargs = {k: v for k, v in service_kwargs.items() if k in service_init_params} + + service_instance = service_module(service_ctx, **filtered_kwargs) + + # Authenticate with client-provided or server-side auth + cookies, credential, pre_authenticated_session, session_error = get_auth_from_request(data, normalized_service, profile) + + # Check for session expiry + if session_error == "SESSION_EXPIRED": + return web.json_response({ + "status": "error", + "error_code": "SESSION_EXPIRED", + "message": f"Session expired for {normalized_service}. Please re-authenticate." + }, status=401) + + try: + if pre_authenticated_session: + # Use pre-authenticated session sent by client (server is stateless) + deserialize_session(pre_authenticated_session, service_instance.session) + else: + # Authenticate with credentials/cookies + if not cookies and not credential: + # No auth data available - tell client to authenticate + return web.json_response({ + "status": "error", + "error_code": "AUTH_REQUIRED", + "message": f"Authentication required for {normalized_service}. No credentials or session available." + }, status=401) + + service_instance.authenticate(cookies, credential) + except Exception as auth_error: + # Authentication failed - tell client to re-authenticate + log.warning(f"Authentication failed for {normalized_service}: {auth_error}") + return web.json_response({ + "status": "error", + "error_code": "AUTH_REQUIRED", + "message": f"Authentication failed for {normalized_service}. Please authenticate locally." + }, status=401) + + # Get titles to find the correct one + titles = service_instance.get_titles() + if hasattr(titles, "__iter__") and not isinstance(titles, str): + first_title = list(titles)[0] + else: + first_title = titles + + # Get tracks to find license URL + tracks = service_instance.get_tracks(first_title) + + # Find the track with the matching ID + target_track = None + for track in tracks.videos + tracks.audio: + if str(track.id) == str(track_id) or track.id == track_id: + target_track = track + break + + if not target_track: + return web.json_response({ + "status": "error", + "message": f"Track {track_id} not found" + }, status=404) + + # Get license URL and headers from track + if not hasattr(target_track, "drm") or not target_track.drm: + return web.json_response({ + "status": "error", + "message": f"Track {track_id} is not DRM-protected" + }, status=400) + + # Extract license information + license_url = None + license_headers = {} + + # Try to get license URL from DRM info + for drm_info in target_track.drm: + if hasattr(drm_info, "license_url"): + license_url = drm_info.license_url + if hasattr(drm_info, "license_headers"): + license_headers = drm_info.license_headers or {} + break + + if not license_url: + return web.json_response({ + "status": "error", + "message": "No license URL found for track" + }, status=400) + + # Make license request using service session + import base64 + challenge_data = base64.b64decode(challenge) + + license_response = service_instance.session.post( + license_url, + data=challenge_data, + headers=license_headers + ) + + if license_response.status_code != 200: + return web.json_response({ + "status": "error", + "message": f"License request failed: {license_response.status_code}" + }, status=500) + + # Return base64-encoded license + license_b64 = base64.b64encode(license_response.content).decode("utf-8") + + # Serialize session data + session_data = serialize_session(service_instance.session) + + return web.json_response({ + "status": "success", + "license": license_b64, + "session": session_data + }) + + except Exception: + log.exception("Error getting remote license") + return web.json_response({"status": "error", "message": "Internal server error while getting license"}, status=500) + + +async def remote_decrypt(request: web.Request) -> web.Response: + """ + Decrypt DRM content using server's CDM (premium users only). + + This endpoint is for premium API key holders who can use the server's + CDM infrastructure. Regular users must use their own CDM with the + license endpoint. + + --- + summary: Decrypt DRM content using server CDM + description: Use server's CDM to decrypt content (premium tier only) + parameters: + - name: service + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - title + - track_id + - pssh + properties: + title: + type: string + description: Title identifier + track_id: + type: string + description: Track ID for decryption + pssh: + type: string + description: Base64-encoded PSSH box + cdm: + type: string + description: Specific CDM to use (optional, uses default if not specified) + license_url: + type: string + description: License server URL (optional, extracted from track if not provided) + profile: + type: string + description: Profile to use + pre_authenticated_session: + type: object + description: Client's pre-authenticated session + responses: + '200': + description: Decryption keys + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: success + keys: + type: array + items: + type: object + properties: + kid: + type: string + key: + type: string + type: + type: string + session: + type: object + description: Updated session data + '400': + description: Invalid request + '401': + description: Authentication required + '403': + description: Not authorized for premium features + '500': + description: Server error + """ + service_tag = request.match_info.get("service") + + # Check if user is premium + api_key = get_api_key_from_request(request) + if not api_key: + return web.json_response({ + "status": "error", + "error_code": "NO_API_KEY", + "message": "API key required" + }, status=401) + + if not is_premium_user(request.app, api_key): + return web.json_response({ + "status": "error", + "error_code": "PREMIUM_REQUIRED", + "message": "This endpoint requires a premium API key. Use /api/remote/{service}/license with your own CDM instead." + }, status=403) + + try: + data = await request.json() + except Exception: + return web.json_response({"status": "error", "message": "Invalid JSON request body"}, status=400) + + # Validate required parameters + title = data.get("title") + track_id = data.get("track_id") + pssh = data.get("pssh") + + if not all([title, track_id, pssh]): + return web.json_response( + { + "status": "error", + "message": "Missing required parameters: title, track_id, pssh" + }, + status=400 + ) + + # Determine which CDM to use + requested_cdm = data.get("cdm") + if not requested_cdm: + # Use default CDM for this API key + requested_cdm = get_default_cdm(request.app, api_key) + + if not requested_cdm: + return web.json_response({ + "status": "error", + "message": "No CDM specified and no default CDM configured for your API key" + }, status=400) + + # Check if user can use this CDM + if not can_use_cdm(request.app, api_key, requested_cdm): + return web.json_response({ + "status": "error", + "error_code": "CDM_NOT_ALLOWED", + "message": f"Your API key is not authorized to use CDM: {requested_cdm}" + }, status=403) + + normalized_service = validate_service(service_tag) + if not normalized_service: + return web.json_response( + {"status": "error", "message": f"Invalid or unavailable service: {service_tag}"}, + status=400 + ) + + try: + from pywidevine.cdm import Cdm as WidevineCdm + from pywidevine.device import Device + + # Load the requested CDM + log.info(f"Premium user using server CDM: {requested_cdm}") + + # Get CDM device path + cdm_device_path = None + if requested_cdm.endswith(".wvd"): + # Direct path to WVD file + cdm_device_path = Path(requested_cdm) + else: + # Look in configured CDM directory + cdm_dir = config.directories.wvds + potential_path = cdm_dir / f"{requested_cdm}.wvd" + if potential_path.exists(): + cdm_device_path = potential_path + + if not cdm_device_path or not cdm_device_path.exists(): + return web.json_response({ + "status": "error", + "message": f"CDM device not found: {requested_cdm}" + }, status=404) + + # Initialize CDM + device = Device.load(cdm_device_path) + cdm = WidevineCdm.from_device(device) + + # Open CDM session + session_id = cdm.open() + + # Parse PSSH + import base64 + pssh_data = base64.b64decode(pssh) + + # Set service certificate if needed (some services require it) + # This would be service-specific + + # Get challenge + challenge = cdm.get_license_challenge(session_id, pssh_data) + + # Get license URL + license_url = data.get("license_url") + + # If no license URL provided, get it from track + if not license_url: + profile = data.get("profile") + + service_config_path = Services.get_path(normalized_service) / config.filenames.config + if service_config_path.exists(): + service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8")) + else: + service_config = {} + merge_dict(config.services.get(normalized_service), service_config) + + @click.command() + @click.pass_context + def dummy_service(ctx: click.Context) -> None: + pass + + # Handle proxy configuration + # Client MUST send resolved proxy with credentials + # Server does NOT resolve proxy providers - client must do that + proxy_param = data.get("proxy") + no_proxy = data.get("no_proxy", False) + + if proxy_param and not no_proxy: + import re + + # Validate that client sent a fully resolved proxy URL + if re.match(r"^https?://", proxy_param): + log.info("Using client-resolved proxy with credentials") + else: + # Reject unresolved proxy parameters + log.error(f"[SECURITY] Client sent unresolved proxy parameter: {proxy_param}") + cdm.close(session_id) + return web.json_response({ + "status": "error", + "error_code": "INVALID_PROXY", + "message": f"Proxy must be a fully resolved URL (http://... or https://...). " + f"Cannot use proxy provider shortcuts like '{proxy_param}'. " + f"Please resolve the proxy on the client side before sending to server." + }, status=400) + + ctx = click.Context(dummy_service) + ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=[], profile=profile) + ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy} + + service_module = Services.load(normalized_service) + dummy_service.name = normalized_service + ctx.invoked_subcommand = normalized_service + + service_ctx = click.Context(dummy_service, parent=ctx) + service_ctx.obj = ctx.obj + + service_kwargs = {"title": title} + + # Get service parameters + service_init_params = inspect.signature(service_module.__init__).parameters + + # Extract defaults + if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"): + for param in service_module.cli.params: + if hasattr(param, "name") and param.name not in service_kwargs: + if hasattr(param, "default") and param.default is not None: + service_kwargs[param.name] = param.default + + # Handle required parameters + for param_name, param_info in service_init_params.items(): + if param_name not in service_kwargs and param_name not in ["self", "ctx"]: + if param_info.default is inspect.Parameter.empty: + if param_name == "meta_lang": + service_kwargs[param_name] = None + elif param_name == "movie": + service_kwargs[param_name] = False + + # Filter to valid parameters + filtered_kwargs = {k: v for k, v in service_kwargs.items() if k in service_init_params} + + service_instance = service_module(service_ctx, **filtered_kwargs) + + # Authenticate + cookies, credential, pre_authenticated_session, session_error = get_auth_from_request(data, normalized_service, profile) + + if session_error == "SESSION_EXPIRED": + cdm.close(session_id) + return web.json_response({ + "status": "error", + "error_code": "SESSION_EXPIRED", + "message": f"Session expired for {normalized_service}. Please re-authenticate." + }, status=401) + + try: + if pre_authenticated_session: + deserialize_session(pre_authenticated_session, service_instance.session) + else: + if not cookies and not credential: + cdm.close(session_id) + return web.json_response({ + "status": "error", + "error_code": "AUTH_REQUIRED", + "message": f"Authentication required for {normalized_service}." + }, status=401) + service_instance.authenticate(cookies, credential) + except Exception as auth_error: + cdm.close(session_id) + log.warning(f"Authentication failed for {normalized_service}: {auth_error}") + return web.json_response({ + "status": "error", + "error_code": "AUTH_REQUIRED", + "message": f"Authentication failed for {normalized_service}.", + "details": str(auth_error) + }, status=401) + + # Get titles and tracks to find license URL + titles = service_instance.get_titles() + if hasattr(titles, "__iter__") and not isinstance(titles, str): + first_title = list(titles)[0] + else: + first_title = titles + + tracks = service_instance.get_tracks(first_title) + + # Find the track + target_track = None + for track in tracks.videos + tracks.audio: + if str(track.id) == str(track_id) or track.id == track_id: + target_track = track + break + + if not target_track: + cdm.close(session_id) + return web.json_response({ + "status": "error", + "message": f"Track {track_id} not found" + }, status=404) + + if not hasattr(target_track, "drm") or not target_track.drm: + cdm.close(session_id) + return web.json_response({ + "status": "error", + "message": f"Track {track_id} is not DRM-protected" + }, status=400) + + # Extract license URL + license_headers = {} + for drm_info in target_track.drm: + if hasattr(drm_info, "license_url"): + license_url = drm_info.license_url + if hasattr(drm_info, "license_headers"): + license_headers = drm_info.license_headers or {} + break + + if not license_url: + cdm.close(session_id) + return web.json_response({ + "status": "error", + "message": "No license URL found for track" + }, status=400) + + # Make license request + license_response = service_instance.session.post( + license_url, + data=challenge, + headers=license_headers + ) + + if license_response.status_code != 200: + cdm.close(session_id) + return web.json_response({ + "status": "error", + "message": f"License request failed: {license_response.status_code}" + }, status=500) + + # Parse license + cdm.parse_license(session_id, license_response.content) + + # Get keys + keys = [] + for key in cdm.get_keys(session_id): + if key.type == "CONTENT": + keys.append({ + "kid": key.kid.hex(), + "key": key.key.hex(), + "type": key.type + }) + + # Close CDM session + cdm.close(session_id) + + # Serialize session + session_data = serialize_session(service_instance.session) + + return web.json_response({ + "status": "success", + "keys": keys, + "session": session_data, + "cdm_used": requested_cdm + }) + + else: + # License URL provided directly + # Make license request (need to provide session for this) + cdm.close(session_id) + return web.json_response({ + "status": "error", + "message": "Direct license URL not yet supported, omit license_url to auto-detect from service" + }, status=400) + + except Exception: + log.exception("Error in server-side decryption") + return web.json_response({"status": "error", "message": "Internal server error during decryption"}, status=500) diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index a5202c5..a458dd6 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -8,6 +8,9 @@ from unshackle.core import __version__ from unshackle.core.api.errors import APIError, APIErrorCode, build_error_response, handle_api_exception from unshackle.core.api.handlers import (cancel_download_job_handler, download_handler, get_download_job_handler, list_download_jobs_handler, list_titles_handler, list_tracks_handler) +from unshackle.core.api.remote_handlers import (remote_decrypt, remote_get_chapters, remote_get_license, + remote_get_titles, remote_get_tracks, remote_list_services, + remote_search) from unshackle.core.services import Services from unshackle.core.update_checker import UpdateChecker @@ -730,6 +733,15 @@ def setup_routes(app: web.Application) -> None: app.router.add_get("/api/download/jobs/{job_id}", download_job_detail) app.router.add_delete("/api/download/jobs/{job_id}", cancel_download_job) + # Remote service endpoints + app.router.add_get("/api/remote/services", remote_list_services) + app.router.add_post("/api/remote/{service}/search", remote_search) + app.router.add_post("/api/remote/{service}/titles", remote_get_titles) + app.router.add_post("/api/remote/{service}/tracks", remote_get_tracks) + app.router.add_post("/api/remote/{service}/chapters", remote_get_chapters) + app.router.add_post("/api/remote/{service}/license", remote_get_license) + app.router.add_post("/api/remote/{service}/decrypt", remote_decrypt) + def setup_swagger(app: web.Application) -> None: """Setup Swagger UI documentation.""" @@ -754,5 +766,13 @@ def setup_swagger(app: web.Application) -> None: web.get("/api/download/jobs", download_jobs), web.get("/api/download/jobs/{job_id}", download_job_detail), web.delete("/api/download/jobs/{job_id}", cancel_download_job), + # Remote service routes + web.get("/api/remote/services", remote_list_services), + web.post("/api/remote/{service}/search", remote_search), + web.post("/api/remote/{service}/titles", remote_get_titles), + web.post("/api/remote/{service}/tracks", remote_get_tracks), + web.post("/api/remote/{service}/chapters", remote_get_chapters), + web.post("/api/remote/{service}/license", remote_get_license), + web.post("/api/remote/{service}/decrypt", remote_decrypt), ] ) diff --git a/unshackle/core/api/session_serializer.py b/unshackle/core/api/session_serializer.py new file mode 100644 index 0000000..733b179 --- /dev/null +++ b/unshackle/core/api/session_serializer.py @@ -0,0 +1,236 @@ +"""Session serialization helpers for remote services.""" + +from http.cookiejar import CookieJar +from typing import Any, Dict, Optional + +import requests + +from unshackle.core.credential import Credential + + +def serialize_session(session: requests.Session) -> Dict[str, Any]: + """ + Serialize a requests.Session into a JSON-serializable dictionary. + + Extracts cookies, headers, and other session data that can be + transferred to a remote client for downloading. + + Args: + session: The requests.Session to serialize + + Returns: + Dictionary containing serialized session data + """ + session_data = { + "cookies": {}, + "headers": {}, + "proxies": session.proxies.copy() if session.proxies else {}, + } + + # Serialize cookies + if session.cookies: + for cookie in session.cookies: + session_data["cookies"][cookie.name] = { + "value": cookie.value, + "domain": cookie.domain, + "path": cookie.path, + "secure": cookie.secure, + "expires": cookie.expires, + } + + # Serialize headers (exclude proxy-authorization for security) + if session.headers: + for key, value in session.headers.items(): + # Skip proxy-related headers as they're server-specific + if key.lower() not in ["proxy-authorization"]: + session_data["headers"][key] = value + + return session_data + + +def deserialize_session( + session_data: Dict[str, Any], target_session: Optional[requests.Session] = None +) -> requests.Session: + """ + Deserialize session data into a requests.Session. + + Applies cookies, headers, and other session data from a remote server + to a local session for downloading. + + Args: + session_data: Dictionary containing serialized session data + target_session: Optional existing session to update (creates new if None) + + Returns: + requests.Session with applied session data + """ + if target_session is None: + target_session = requests.Session() + + # Apply cookies + if "cookies" in session_data: + for cookie_name, cookie_data in session_data["cookies"].items(): + target_session.cookies.set( + name=cookie_name, + value=cookie_data["value"], + domain=cookie_data.get("domain"), + path=cookie_data.get("path", "/"), + secure=cookie_data.get("secure", False), + expires=cookie_data.get("expires"), + ) + + # Apply headers + if "headers" in session_data: + target_session.headers.update(session_data["headers"]) + + # Note: We don't apply proxies from remote as the local client + # should use its own proxy configuration + + return target_session + + +def extract_session_tokens(session: requests.Session) -> Dict[str, Any]: + """ + Extract authentication tokens and similar data from a session. + + Looks for common authentication patterns like Bearer tokens, + API keys in headers, etc. + + Args: + session: The requests.Session to extract tokens from + + Returns: + Dictionary containing extracted tokens + """ + tokens = {} + + # Check for Authorization header + if "Authorization" in session.headers: + tokens["authorization"] = session.headers["Authorization"] + + # Check for common API key headers + for key in ["X-API-Key", "Api-Key", "X-Auth-Token"]: + if key in session.headers: + tokens[key.lower().replace("-", "_")] = session.headers[key] + + return tokens + + +def apply_session_tokens(tokens: Dict[str, Any], target_session: requests.Session) -> None: + """ + Apply authentication tokens to a session. + + Args: + tokens: Dictionary containing tokens to apply + target_session: Session to apply tokens to + """ + # Apply Authorization header + if "authorization" in tokens: + target_session.headers["Authorization"] = tokens["authorization"] + + # Apply other token headers + token_header_map = { + "x_api_key": "X-API-Key", + "api_key": "Api-Key", + "x_auth_token": "X-Auth-Token", + } + + for token_key, header_name in token_header_map.items(): + if token_key in tokens: + target_session.headers[header_name] = tokens[token_key] + + +def serialize_cookies(cookie_jar: Optional[CookieJar]) -> Dict[str, Any]: + """ + Serialize a CookieJar into a JSON-serializable dictionary. + + Args: + cookie_jar: The CookieJar to serialize + + Returns: + Dictionary containing serialized cookies + """ + if not cookie_jar: + return {} + + cookies = {} + for cookie in cookie_jar: + cookies[cookie.name] = { + "value": cookie.value, + "domain": cookie.domain, + "path": cookie.path, + "secure": cookie.secure, + "expires": cookie.expires, + } + + return cookies + + +def deserialize_cookies(cookies_data: Dict[str, Any]) -> CookieJar: + """ + Deserialize cookies into a CookieJar. + + Args: + cookies_data: Dictionary containing serialized cookies + + Returns: + CookieJar with cookies + """ + import http.cookiejar + + cookie_jar = http.cookiejar.CookieJar() + + for cookie_name, cookie_data in cookies_data.items(): + cookie = http.cookiejar.Cookie( + version=0, + name=cookie_name, + value=cookie_data["value"], + port=None, + port_specified=False, + domain=cookie_data.get("domain", ""), + domain_specified=bool(cookie_data.get("domain")), + domain_initial_dot=cookie_data.get("domain", "").startswith("."), + path=cookie_data.get("path", "/"), + path_specified=True, + secure=cookie_data.get("secure", False), + expires=cookie_data.get("expires"), + discard=False, + comment=None, + comment_url=None, + rest={}, + ) + cookie_jar.set_cookie(cookie) + + return cookie_jar + + +def serialize_credential(credential: Optional[Credential]) -> Optional[Dict[str, str]]: + """ + Serialize a Credential into a JSON-serializable dictionary. + + Args: + credential: The Credential to serialize + + Returns: + Dictionary containing username and password, or None + """ + if not credential: + return None + + return {"username": credential.username, "password": credential.password} + + +def deserialize_credential(credential_data: Optional[Dict[str, str]]) -> Optional[Credential]: + """ + Deserialize credential data into a Credential object. + + Args: + credential_data: Dictionary containing username and password + + Returns: + Credential object or None + """ + if not credential_data: + return None + + return Credential(username=credential_data["username"], password=credential_data["password"]) diff --git a/unshackle/core/binaries.py b/unshackle/core/binaries.py index f846256..1b56061 100644 --- a/unshackle/core/binaries.py +++ b/unshackle/core/binaries.py @@ -52,6 +52,7 @@ Mkvpropedit = find("mkvpropedit") DoviTool = find("dovi_tool") HDR10PlusTool = find("hdr10plus_tool", "HDR10Plus_tool") Mp4decrypt = find("mp4decrypt") +Docker = find("docker") __all__ = ( @@ -71,5 +72,6 @@ __all__ = ( "DoviTool", "HDR10PlusTool", "Mp4decrypt", + "Docker", "find", ) diff --git a/unshackle/core/config.py b/unshackle/core/config.py index 6eb7b26..242a0b0 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -103,6 +103,8 @@ class Config: self.debug: bool = kwargs.get("debug", False) self.debug_keys: bool = kwargs.get("debug_keys", False) + self.remote_services: list[dict] = kwargs.get("remote_services") or [] + @classmethod def from_yaml(cls, path: Path) -> Config: if not path.exists(): diff --git a/unshackle/core/local_session_cache.py b/unshackle/core/local_session_cache.py new file mode 100644 index 0000000..ae54ade --- /dev/null +++ b/unshackle/core/local_session_cache.py @@ -0,0 +1,274 @@ +"""Local client-side session cache for remote services. + +Sessions are stored ONLY on the client machine, never on the server. +The server is completely stateless and receives session data with each request. +""" + +import json +import logging +import time +from pathlib import Path +from typing import Any, Dict, Optional + +log = logging.getLogger("LocalSessionCache") + + +class LocalSessionCache: + """ + Client-side session cache. + + Stores authenticated sessions locally (similar to cookies/cache). + Server never stores sessions - client sends session with each request. + """ + + def __init__(self, cache_dir: Path): + """ + Initialize local session cache. + + Args: + cache_dir: Directory to store session cache files + """ + self.cache_dir = cache_dir + self.cache_dir.mkdir(parents=True, exist_ok=True) + self.sessions_file = cache_dir / "remote_sessions.json" + + # Load existing sessions + self.sessions: Dict[str, Dict[str, Dict[str, Any]]] = self._load_sessions() + + def _load_sessions(self) -> Dict[str, Dict[str, Dict[str, Any]]]: + """Load sessions from cache file.""" + if not self.sessions_file.exists(): + return {} + + try: + data = json.loads(self.sessions_file.read_text(encoding="utf-8")) + log.debug(f"Loaded {len(data)} remote sessions from cache") + return data + except Exception as e: + log.error(f"Failed to load sessions cache: {e}") + return {} + + def _save_sessions(self) -> None: + """Save sessions to cache file.""" + try: + self.sessions_file.write_text( + json.dumps(self.sessions, indent=2, ensure_ascii=False), + encoding="utf-8" + ) + log.debug(f"Saved {len(self.sessions)} remote sessions to cache") + except Exception as e: + log.error(f"Failed to save sessions cache: {e}") + + def store_session( + self, + remote_url: str, + service_tag: str, + profile: str, + session_data: Dict[str, Any] + ) -> None: + """ + Store an authenticated session locally. + + Args: + remote_url: Remote server URL (as key) + service_tag: Service tag + profile: Profile name + session_data: Authenticated session data + """ + # Create nested structure + if remote_url not in self.sessions: + self.sessions[remote_url] = {} + if service_tag not in self.sessions[remote_url]: + self.sessions[remote_url][service_tag] = {} + + # Store session with metadata + self.sessions[remote_url][service_tag][profile] = { + "session_data": session_data, + "cached_at": time.time(), + "service_tag": service_tag, + "profile": profile, + } + + self._save_sessions() + log.info(f"Cached session for {service_tag} (profile: {profile}, remote: {remote_url})") + + def get_session( + self, + remote_url: str, + service_tag: str, + profile: str + ) -> Optional[Dict[str, Any]]: + """ + Retrieve a cached session. + + Args: + remote_url: Remote server URL + service_tag: Service tag + profile: Profile name + + Returns: + Session data or None if not found/expired + """ + try: + session_entry = self.sessions[remote_url][service_tag][profile] + + # Check if expired (24 hours) + age = time.time() - session_entry["cached_at"] + if age > 86400: # 24 hours + log.info(f"Session expired for {service_tag} (age: {age:.0f}s)") + self.delete_session(remote_url, service_tag, profile) + return None + + log.debug(f"Using cached session for {service_tag} (profile: {profile})") + return session_entry["session_data"] + + except KeyError: + log.debug(f"No cached session for {service_tag} (profile: {profile})") + return None + + def has_session( + self, + remote_url: str, + service_tag: str, + profile: str + ) -> bool: + """ + Check if a valid session exists. + + Args: + remote_url: Remote server URL + service_tag: Service tag + profile: Profile name + + Returns: + True if valid session exists + """ + session = self.get_session(remote_url, service_tag, profile) + return session is not None + + def delete_session( + self, + remote_url: str, + service_tag: str, + profile: str + ) -> bool: + """ + Delete a cached session. + + Args: + remote_url: Remote server URL + service_tag: Service tag + profile: Profile name + + Returns: + True if session was deleted + """ + try: + del self.sessions[remote_url][service_tag][profile] + + # Clean up empty nested dicts + if not self.sessions[remote_url][service_tag]: + del self.sessions[remote_url][service_tag] + if not self.sessions[remote_url]: + del self.sessions[remote_url] + + self._save_sessions() + log.info(f"Deleted cached session for {service_tag} (profile: {profile})") + return True + + except KeyError: + return False + + def list_sessions(self, remote_url: Optional[str] = None) -> list[Dict[str, Any]]: + """ + List all cached sessions. + + Args: + remote_url: Optional filter by remote URL + + Returns: + List of session metadata + """ + sessions = [] + + remotes = [remote_url] if remote_url else self.sessions.keys() + + for remote in remotes: + if remote not in self.sessions: + continue + + for service_tag, profiles in self.sessions[remote].items(): + for profile, entry in profiles.items(): + age = time.time() - entry["cached_at"] + + sessions.append({ + "remote_url": remote, + "service_tag": service_tag, + "profile": profile, + "cached_at": entry["cached_at"], + "age_seconds": int(age), + "expired": age > 86400, + "has_cookies": bool(entry["session_data"].get("cookies")), + "has_headers": bool(entry["session_data"].get("headers")), + }) + + return sessions + + def cleanup_expired(self) -> int: + """ + Remove expired sessions (older than 24 hours). + + Returns: + Number of sessions removed + """ + removed = 0 + current_time = time.time() + + for remote_url in list(self.sessions.keys()): + for service_tag in list(self.sessions[remote_url].keys()): + for profile in list(self.sessions[remote_url][service_tag].keys()): + entry = self.sessions[remote_url][service_tag][profile] + age = current_time - entry["cached_at"] + + if age > 86400: # 24 hours + del self.sessions[remote_url][service_tag][profile] + removed += 1 + log.info(f"Removed expired session for {service_tag} (age: {age:.0f}s)") + + # Clean up empty dicts + if not self.sessions[remote_url][service_tag]: + del self.sessions[remote_url][service_tag] + if not self.sessions[remote_url]: + del self.sessions[remote_url] + + if removed > 0: + self._save_sessions() + + return removed + + +# Global instance +_local_session_cache: Optional[LocalSessionCache] = None + + +def get_local_session_cache() -> LocalSessionCache: + """ + Get the global local session cache instance. + + Returns: + LocalSessionCache instance + """ + global _local_session_cache + + if _local_session_cache is None: + from unshackle.core.config import config + cache_dir = config.directories.cache / "remote_sessions" + _local_session_cache = LocalSessionCache(cache_dir) + + # Clean up expired sessions on init + _local_session_cache.cleanup_expired() + + return _local_session_cache + + +__all__ = ["LocalSessionCache", "get_local_session_cache"] diff --git a/unshackle/core/proxies/__init__.py b/unshackle/core/proxies/__init__.py index ecb97de..4a53298 100644 --- a/unshackle/core/proxies/__init__.py +++ b/unshackle/core/proxies/__init__.py @@ -1,7 +1,8 @@ from .basic import Basic +from .gluetun import Gluetun from .hola import Hola from .nordvpn import NordVPN from .surfsharkvpn import SurfsharkVPN from .windscribevpn import WindscribeVPN -__all__ = ("Basic", "Hola", "NordVPN", "SurfsharkVPN", "WindscribeVPN") +__all__ = ("Basic", "Gluetun", "Hola", "NordVPN", "SurfsharkVPN", "WindscribeVPN") diff --git a/unshackle/core/proxies/gluetun.py b/unshackle/core/proxies/gluetun.py new file mode 100644 index 0000000..83986d6 --- /dev/null +++ b/unshackle/core/proxies/gluetun.py @@ -0,0 +1,1261 @@ +import atexit +import logging +import os +import re +import subprocess +import threading +import time +from typing import Optional + +import requests + +from unshackle.core import binaries +from unshackle.core.proxies.proxy import Proxy +from unshackle.core.utilities import get_country_code, get_country_name, get_debug_logger, get_ip_info + +# Global registry for cleanup on exit +_gluetun_instances: list["Gluetun"] = [] +_cleanup_lock = threading.Lock() +_cleanup_registered = False + + +def _cleanup_all_gluetun_containers(): + """Cleanup all Gluetun containers on exit.""" + # Get instances without holding the lock during cleanup + with _cleanup_lock: + instances = list(_gluetun_instances) + _gluetun_instances.clear() + + # Cleanup each instance (no lock held, so no deadlock possible) + for instance in instances: + try: + instance.cleanup() + except Exception: + pass + + +def _register_cleanup(): + """Register cleanup handlers (only once).""" + global _cleanup_registered + with _cleanup_lock: + if not _cleanup_registered: + # Only use atexit for cleanup - don't override signal handlers + # This allows Ctrl+C to work normally while still cleaning up on exit + atexit.register(_cleanup_all_gluetun_containers) + _cleanup_registered = True + + +class Gluetun(Proxy): + """ + Dynamic Gluetun VPN-to-HTTP Proxy Provider with multi-provider support. + + Automatically manages Docker containers running Gluetun for WireGuard/OpenVPN VPN connections. + Supports multiple VPN providers in a single configuration using query format: provider:region + + Supported VPN providers: windscribe, expressvpn, nordvpn, surfshark, protonvpn, mullvad, + privateinternetaccess, cyberghost, vyprvpn, torguard, and 50+ more. + + Configuration example in unshackle.yaml: + proxy_providers: + gluetun: + providers: + windscribe: + vpn_type: wireguard + credentials: + private_key: YOUR_KEY + addresses: YOUR_ADDRESS + server_countries: + us: US + uk: GB + nordvpn: + vpn_type: wireguard + credentials: + private_key: YOUR_KEY + addresses: YOUR_ADDRESS + server_countries: + us: US + de: DE + # Global settings (optional) + base_port: 8888 + auto_cleanup: true + container_prefix: "unshackle-gluetun" + + Usage: + --proxy gluetun:windscribe:us + --proxy gluetun:nordvpn:de + """ + + # Mapping of common VPN provider names to Gluetun identifiers + PROVIDER_MAPPING = { + "windscribe": "windscribe", + "expressvpn": "expressvpn", + "nordvpn": "nordvpn", + "surfshark": "surfshark", + "protonvpn": "protonvpn", + "mullvad": "mullvad", + "pia": "private internet access", + "privateinternetaccess": "private internet access", + "cyberghost": "cyberghost", + "vyprvpn": "vyprvpn", + "torguard": "torguard", + "ipvanish": "ipvanish", + "purevpn": "purevpn", + } + + def __init__( + self, + providers: Optional[dict] = None, + base_port: int = 8888, + auto_cleanup: bool = True, + container_prefix: str = "unshackle-gluetun", + auth_user: Optional[str] = None, + auth_password: Optional[str] = None, + verify_ip: bool = True, + **kwargs, + ): + """ + Initialize Gluetun proxy provider with multi-provider support. + + Args: + providers: Dict of VPN provider configurations + Format: { + "windscribe": { + "vpn_type": "wireguard", + "credentials": {"private_key": "...", "addresses": "..."}, + "server_countries": {"us": "US", "uk": "GB"} + }, + "nordvpn": {...} + } + base_port: Starting port for HTTP proxies (default: 8888) + auto_cleanup: Automatically remove stopped containers (default: True) + container_prefix: Docker container name prefix (default: "unshackle-gluetun") + auth_user: Optional HTTP proxy authentication username + auth_password: Optional HTTP proxy authentication password + verify_ip: Automatically verify IP and region after connection (default: True) + """ + # Check Docker availability using binaries module + if not binaries.Docker: + raise RuntimeError( + "Docker is not available. Please install Docker to use Gluetun proxy.\n" + "Visit: https://docs.docker.com/engine/install/" + ) + + self.providers = providers or {} + self.base_port = base_port + self.auto_cleanup = auto_cleanup + self.container_prefix = container_prefix + self.auth_user = auth_user + self.auth_password = auth_password + self.verify_ip = verify_ip + + # Track active containers: {query_key: {"container_name": ..., "port": ..., ...}} + self.active_containers = {} + + # Lock for thread-safe port allocation + self._port_lock = threading.Lock() + + # Validate provider configurations + for provider_name, config in self.providers.items(): + self._validate_provider_config(provider_name, config) + + # Register this instance for cleanup on exit + _register_cleanup() + with _cleanup_lock: + _gluetun_instances.append(self) + + # Log initialization + debug_logger = get_debug_logger() + if debug_logger: + debug_logger.log( + level="INFO", + operation="gluetun_init", + message=f"Gluetun proxy provider initialized with {len(self.providers)} provider(s)", + context={ + "providers": list(self.providers.keys()), + "base_port": base_port, + "auto_cleanup": auto_cleanup, + "verify_ip": verify_ip, + "container_prefix": container_prefix, + }, + ) + + def __repr__(self) -> str: + provider_count = len(self.providers) + return f"Gluetun ({provider_count} provider{['s', ''][provider_count == 1]})" + + def get_proxy(self, query: str) -> Optional[str]: + """ + Get an HTTP proxy URI for a Gluetun VPN connection. + + Args: + query: Query format: "provider:region" (e.g., "windscribe:us", "nordvpn:uk") + + Returns: + HTTP proxy URI or None if unavailable + """ + # Parse query + parts = query.split(":") + if len(parts) != 2: + raise ValueError( + f"Invalid query format: '{query}'. Expected 'provider:region' (e.g., 'windscribe:us')" + ) + + provider_name = parts[0].lower() + region = parts[1].lower() + + # Check if provider is configured + if provider_name not in self.providers: + available = ", ".join(self.providers.keys()) + raise ValueError( + f"VPN provider '{provider_name}' not configured. Available providers: {available}" + ) + + # Create query key for tracking + query_key = f"{provider_name}:{region}" + container_name = f"{self.container_prefix}-{provider_name}-{region}" + + debug_logger = get_debug_logger() + + # Check if container already exists (in memory OR in Docker) + # This handles multiple concurrent Unshackle sessions + if query_key in self.active_containers: + container = self.active_containers[query_key] + if self._is_container_running(container["container_name"]): + if debug_logger: + debug_logger.log( + level="DEBUG", + operation="gluetun_container_reuse", + message=f"Reusing existing container (in-memory): {query_key}", + context={ + "query_key": query_key, + "container_name": container["container_name"], + "port": container["port"], + }, + ) + # Re-verify if needed + if self.verify_ip: + self._verify_container(query_key) + return self._build_proxy_uri(container["port"]) + else: + # Not in memory, but might exist in Docker (from another session) + existing_info = self._get_existing_container_info(container_name) + if existing_info: + # Container exists in Docker, reuse it + self.active_containers[query_key] = existing_info + if debug_logger: + debug_logger.log( + level="INFO", + operation="gluetun_container_reuse_docker", + message=f"Reusing existing Docker container: {query_key}", + context={ + "query_key": query_key, + "container_name": container_name, + "port": existing_info["port"], + }, + ) + # Re-verify if needed + if self.verify_ip: + self._verify_container(query_key) + return self._build_proxy_uri(existing_info["port"]) + + # Get provider configuration + provider_config = self.providers[provider_name] + + # Determine server location + server_countries = provider_config.get("server_countries", {}) + server_cities = provider_config.get("server_cities", {}) + server_hostnames = provider_config.get("server_hostnames", {}) + + country = server_countries.get(region) + city = server_cities.get(region) + hostname = server_hostnames.get(region) + + # Check if region is a specific server pattern (e.g., us1239, uk5678) + # Format: 2-letter country code + number + specific_server_match = re.match(r"^([a-z]{2})(\d+)$", region, re.IGNORECASE) + + if specific_server_match and not country and not city and not hostname: + # Specific server requested (e.g., us1239) + country_code = specific_server_match.group(1).upper() + server_num = specific_server_match.group(2) + + # Build hostname based on provider + hostname = self._build_server_hostname(provider_name, country_code, server_num) + country = country_code # Set country for verification + + # If not explicitly mapped and not a specific server, try to use query as country code + elif not country and not city and not hostname: + if re.match(r"^[a-z]{2}$", region): + # Convert country code to full name for Gluetun + country = get_country_name(region) + if not country: + raise ValueError( + f"Country code '{region}' not recognized. " + f"Configure it in server_countries or use a valid ISO 3166-1 alpha-2 code." + ) + else: + raise ValueError( + f"Region '{region}' not recognized for provider '{provider_name}'. " + f"Configure it in server_countries or server_cities, or use a 2-letter country code." + ) + + # Remove any stopped container with the same name + self._remove_stopped_container(container_name) + + # Find available port + port = self._get_available_port() + + # Create container (name already set above) + try: + self._create_container( + container_name=container_name, + port=port, + provider_name=provider_name, + provider_config=provider_config, + country=country, + city=city, + hostname=hostname, + ) + + # Store container info + self.active_containers[query_key] = { + "container_name": container_name, + "port": port, + "provider": provider_name, + "region": region, + "country": country, + "city": city, + "hostname": hostname, + } + + # Wait for container to be ready (60s timeout for VPN connection) + if not self._wait_for_container(container_name, timeout=60): + # Get container logs for better error message + logs = self._get_container_logs(container_name, tail=30) + error_msg = f"Gluetun container '{container_name}' failed to start" + if hasattr(self, '_last_wait_error') and self._last_wait_error: + error_msg += f": {self._last_wait_error}" + if logs: + # Extract last few relevant lines + log_lines = [line for line in logs.strip().split('\n') if line.strip()][-5:] + error_msg += "\nRecent logs:\n" + "\n".join(log_lines) + raise RuntimeError(error_msg) + + # Verify IP and region if enabled + if self.verify_ip: + self._verify_container(query_key) + + return self._build_proxy_uri(port) + + except Exception as e: + # Cleanup on failure + self._remove_container(container_name) + if query_key in self.active_containers: + del self.active_containers[query_key] + raise RuntimeError(f"Failed to create Gluetun container: {e}") + + def cleanup(self): + """Stop and remove all managed Gluetun containers.""" + debug_logger = get_debug_logger() + container_count = len(self.active_containers) + + if container_count > 0 and debug_logger: + debug_logger.log( + level="DEBUG", + operation="gluetun_cleanup_start", + message=f"Cleaning up {container_count} Gluetun container(s)", + context={ + "container_count": container_count, + "containers": list(self.active_containers.keys()), + }, + ) + + for query_key, container_info in list(self.active_containers.items()): + container_name = container_info["container_name"] + self._remove_container(container_name) + + if debug_logger: + debug_logger.log( + level="DEBUG", + operation="gluetun_container_removed", + message=f"Removed Gluetun container: {container_name}", + context={ + "query_key": query_key, + "container_name": container_name, + }, + ) + + self.active_containers.clear() + + if container_count > 0 and debug_logger: + debug_logger.log( + level="INFO", + operation="gluetun_cleanup_complete", + message=f"Cleanup complete: removed {container_count} container(s)", + context={"container_count": container_count}, + success=True, + ) + + def _validate_provider_config(self, provider_name: str, config: dict): + """Validate a provider's configuration.""" + vpn_type = config.get("vpn_type", "wireguard").lower() + credentials = config.get("credentials", {}) + + if vpn_type not in ["wireguard", "openvpn"]: + raise ValueError( + f"Provider '{provider_name}': Invalid vpn_type '{vpn_type}'. Use 'wireguard' or 'openvpn'" + ) + + if vpn_type == "wireguard": + # private_key is always required for WireGuard + if "private_key" not in credentials: + raise ValueError( + f"Provider '{provider_name}': WireGuard requires 'private_key' in credentials" + ) + + # Provider-specific WireGuard requirements based on Gluetun wiki: + # - NordVPN, ProtonVPN: only private_key required + # - Windscribe: private_key, addresses, AND preshared_key required (preshared_key MUST be set) + # - Surfshark, Mullvad, IVPN: private_key AND addresses required + provider_lower = provider_name.lower() + + # Windscribe requires preshared_key (can be empty string, but must be set) + if provider_lower == "windscribe": + if "preshared_key" not in credentials: + raise ValueError( + f"Provider '{provider_name}': Windscribe WireGuard requires 'preshared_key' in credentials " + "(can be empty string, but must be set). Get it from windscribe.com/getconfig/wireguard" + ) + if "addresses" not in credentials: + raise ValueError( + f"Provider '{provider_name}': Windscribe WireGuard requires 'addresses' in credentials. " + "Get it from windscribe.com/getconfig/wireguard" + ) + + # Providers that require addresses (but not preshared_key) + elif provider_lower in ["surfshark", "mullvad", "ivpn"]: + if "addresses" not in credentials: + raise ValueError( + f"Provider '{provider_name}': WireGuard requires 'addresses' in credentials" + ) + + elif vpn_type == "openvpn": + if "username" not in credentials or "password" not in credentials: + raise ValueError( + f"Provider '{provider_name}': OpenVPN requires 'username' and 'password' in credentials" + ) + + def _get_available_port(self) -> int: + """Find an available port starting from base_port (thread-safe).""" + with self._port_lock: + used_ports = {info["port"] for info in self.active_containers.values()} + port = self.base_port + while port in used_ports or self._is_port_in_use(port): + port += 1 + return port + + def _is_port_in_use(self, port: int) -> bool: + """Check if a port is in use on the system or by any Docker container.""" + import socket + + # First check if the port is available on the system + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", port)) + except OSError: + # Port is in use by something on the system + return True + + # Also check Docker containers (in case of port forwarding) + try: + result = subprocess.run( + ["docker", "ps", "--format", "{{.Ports}}"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + return f":{port}->" in result.stdout or f"0.0.0.0:{port}" in result.stdout + return False + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + + def _build_server_hostname(self, provider_name: str, country_code: str, server_num: str) -> str: + """ + Build a server hostname for specific server selection. + + Args: + provider_name: VPN provider name (e.g., "nordvpn") + country_code: 2-letter country code (e.g., "US") + server_num: Server number (e.g., "1239") + + Returns: + Server hostname (e.g., "us1239.nordvpn.com") + """ + # Convert to lowercase for hostname + country_lower = country_code.lower() + + # Provider-specific hostname formats + hostname_formats = { + "nordvpn": f"{country_lower}{server_num}.nordvpn.com", + "surfshark": f"{country_lower}-{server_num}.prod.surfshark.com", + "expressvpn": f"{country_lower}-{server_num}.expressvpn.com", + "cyberghost": f"{country_lower}-s{server_num}.cg-dialup.net", + # Generic fallback for other providers + } + + # Get provider-specific format or use generic + if provider_name in hostname_formats: + return hostname_formats[provider_name] + else: + # Generic format: country_code + server_num + return f"{country_lower}{server_num}" + + def _ensure_image_available(self, image: str = "qmcgaw/gluetun:latest") -> bool: + """ + Ensure the Gluetun Docker image is available locally. + + If the image is not present, it will be pulled. This prevents + the container creation from timing out during the first run. + + Args: + image: Docker image name with tag + + Returns: + True if image is available, False otherwise + """ + log = logging.getLogger("Gluetun") + + # Check if image exists locally + try: + result = subprocess.run( + ["docker", "image", "inspect", image], + capture_output=True, + text=True, + timeout=10, + encoding="utf-8", + errors="replace", + ) + if result.returncode == 0: + return True + log.debug(f"Image inspect failed: {result.stderr}") + except subprocess.TimeoutExpired: + log.warning("Docker image inspect timed out") + except FileNotFoundError: + log.error("Docker command not found - is Docker installed and in PATH?") + return False + + # Image not found, pull it + log.info(f"Pulling Docker image {image}...") + try: + result = subprocess.run( + ["docker", "pull", image], + capture_output=True, + text=True, + timeout=300, # 5 minutes for pull + encoding="utf-8", + errors="replace", + ) + if result.returncode == 0: + return True + log.error(f"Docker pull failed: {result.stderr}") + return False + except subprocess.TimeoutExpired: + raise RuntimeError(f"Timed out pulling Docker image '{image}'") + + def _create_container( + self, + container_name: str, + port: int, + provider_name: str, + provider_config: dict, + country: Optional[str] = None, + city: Optional[str] = None, + hostname: Optional[str] = None, + ): + """Create and start a Gluetun Docker container.""" + debug_logger = get_debug_logger() + start_time = time.time() + + if debug_logger: + debug_logger.log( + level="DEBUG", + operation="gluetun_container_create_start", + message=f"Creating Gluetun container: {container_name}", + context={ + "container_name": container_name, + "port": port, + "provider": provider_name, + "country": country, + "city": city, + "hostname": hostname, + }, + ) + + # Ensure the Gluetun image is available (pulls if needed) + gluetun_image = "qmcgaw/gluetun:latest" + if not self._ensure_image_available(gluetun_image): + if debug_logger: + debug_logger.log( + level="ERROR", + operation="gluetun_image_pull_failed", + message=f"Failed to pull Docker image: {gluetun_image}", + success=False, + ) + raise RuntimeError(f"Failed to ensure Gluetun Docker image '{gluetun_image}' is available") + + vpn_type = provider_config.get("vpn_type", "wireguard").lower() + credentials = provider_config.get("credentials", {}) + extra_env = provider_config.get("extra_env", {}) + + # Normalize provider name + gluetun_provider = self.PROVIDER_MAPPING.get(provider_name.lower(), provider_name.lower()) + + # Build environment variables + env_vars = { + "VPN_SERVICE_PROVIDER": gluetun_provider, + "VPN_TYPE": vpn_type, + "HTTPPROXY": "on", + "HTTPPROXY_LISTENING_ADDRESS": ":8888", + "HTTPPROXY_LOG": "on", + "TZ": os.environ.get("TZ", "UTC"), + "LOG_LEVEL": "info", + } + + # Add credentials + if vpn_type == "wireguard": + env_vars["WIREGUARD_PRIVATE_KEY"] = credentials["private_key"] + # addresses is optional - not needed for some providers like NordVPN + if "addresses" in credentials: + env_vars["WIREGUARD_ADDRESSES"] = credentials["addresses"] + # preshared_key is required for Windscribe, optional for others + if "preshared_key" in credentials: + env_vars["WIREGUARD_PRESHARED_KEY"] = credentials["preshared_key"] + elif vpn_type == "openvpn": + env_vars["OPENVPN_USER"] = credentials.get("username", "") + env_vars["OPENVPN_PASSWORD"] = credentials.get("password", "") + + # Add server location + # Priority: hostname > country + city > country only + # Note: Different providers support different server selection variables + # - Most providers: SERVER_COUNTRIES, SERVER_CITIES + # - Windscribe, VyprVPN, VPN Secure: SERVER_REGIONS, SERVER_CITIES (no SERVER_COUNTRIES) + if hostname: + # Specific server hostname requested (e.g., us1239.nordvpn.com) + env_vars["SERVER_HOSTNAMES"] = hostname + else: + # Providers that use SERVER_REGIONS instead of SERVER_COUNTRIES + region_only_providers = {"windscribe", "vyprvpn", "vpn secure"} + uses_regions = gluetun_provider in region_only_providers + + # Use country/city selection + if country: + if uses_regions: + env_vars["SERVER_REGIONS"] = country + else: + env_vars["SERVER_COUNTRIES"] = country + if city: + env_vars["SERVER_CITIES"] = city + + # Add authentication if configured + if self.auth_user: + env_vars["HTTPPROXY_USER"] = self.auth_user + if self.auth_password: + env_vars["HTTPPROXY_PASSWORD"] = self.auth_password + + # Merge extra environment variables + env_vars.update(extra_env) + + # Build docker run command + cmd = [ + "docker", + "run", + "-d", + "--name", + container_name, + "--cap-add=NET_ADMIN", + "--device=/dev/net/tun", + "-p", + f"127.0.0.1:{port}:8888/tcp", + ] + + # Add environment variables + for key, value in env_vars.items(): + cmd.extend(["-e", f"{key}={value}"]) + + # Add Gluetun image + cmd.append("qmcgaw/gluetun:latest") + + # Execute docker run + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + encoding="utf-8", + errors="replace", + ) + + if result.returncode != 0: + error_msg = result.stderr or "unknown error" + if debug_logger: + debug_logger.log( + level="ERROR", + operation="gluetun_container_create_failed", + message=f"Docker run failed for {container_name}", + context={ + "container_name": container_name, + "return_code": result.returncode, + "stderr": error_msg, + }, + success=False, + duration_ms=(time.time() - start_time) * 1000, + ) + raise RuntimeError(f"Docker run failed: {error_msg}") + + # Log successful container creation + if debug_logger: + duration_ms = (time.time() - start_time) * 1000 + debug_logger.log( + level="INFO", + operation="gluetun_container_created", + message=f"Gluetun container created: {container_name}", + context={ + "container_name": container_name, + "port": port, + "provider": provider_name, + "vpn_type": vpn_type, + "country": country, + "city": city, + "hostname": hostname, + "container_id": result.stdout.strip()[:12] if result.stdout else None, + }, + success=True, + duration_ms=duration_ms, + ) + + except subprocess.TimeoutExpired: + if debug_logger: + debug_logger.log( + level="ERROR", + operation="gluetun_container_create_timeout", + message=f"Docker run timed out for {container_name}", + context={"container_name": container_name}, + success=False, + duration_ms=(time.time() - start_time) * 1000, + ) + raise RuntimeError("Docker run command timed out") + + def _is_container_running(self, container_name: str) -> bool: + """Check if a Docker container is running.""" + try: + result = subprocess.run( + ["docker", "ps", "--filter", f"name={container_name}", "--format", "{{.Names}}"], + capture_output=True, + text=True, + timeout=5, + ) + return result.returncode == 0 and container_name in result.stdout + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + + def _get_existing_container_info(self, container_name: str) -> Optional[dict]: + """ + Check if a container exists in Docker and get its info. + + This handles multiple Unshackle sessions - if another session already + created the container, we'll reuse it instead of trying to create a duplicate. + + Args: + container_name: Name of the container to check + + Returns: + Dict with container info if exists and running, None otherwise + """ + try: + # Check if container is running + if not self._is_container_running(container_name): + return None + + # Get container port mapping + # Format: "127.0.0.1:8888->8888/tcp" + result = subprocess.run( + ["docker", "inspect", container_name, "--format", "{{.NetworkSettings.Ports}}"], + capture_output=True, + text=True, + timeout=5, + ) + + if result.returncode != 0: + return None + + # Parse port from output like "map[8888/tcp:[{127.0.0.1 8888}]]" + port_match = re.search(r'127\.0\.0\.1\s+(\d+)', result.stdout) + if not port_match: + return None + + port = int(port_match.group(1)) + + # Extract provider and region from container name + # Format: unshackle-gluetun-provider-region + name_pattern = f"{self.container_prefix}-(.+)-([^-]+)$" + name_match = re.match(name_pattern, container_name) + if not name_match: + return None + + provider_name = name_match.group(1) + region = name_match.group(2) + + # Get expected country and hostname from config (if available) + country = None + hostname = None + + # Check if region is a specific server (e.g., us1239) + specific_server_match = re.match(r"^([a-z]{2})(\d+)$", region, re.IGNORECASE) + if specific_server_match: + country_code = specific_server_match.group(1).upper() + server_num = specific_server_match.group(2) + hostname = self._build_server_hostname(provider_name, country_code, server_num) + country = country_code + + # Otherwise check config + elif provider_name in self.providers: + provider_config = self.providers[provider_name] + server_countries = provider_config.get("server_countries", {}) + country = server_countries.get(region) + + if not country and re.match(r"^[a-z]{2}$", region): + country = region.upper() + + return { + "container_name": container_name, + "port": port, + "provider": provider_name, + "region": region, + "country": country, + "city": None, + "hostname": hostname, + } + + except (subprocess.TimeoutExpired, FileNotFoundError, ValueError): + return None + + def _wait_for_container(self, container_name: str, timeout: int = 60) -> bool: + """ + Wait for Gluetun container to be ready by checking logs for proxy readiness. + + Gluetun logs "http proxy listening" when the HTTP proxy is ready to accept connections. + + Args: + container_name: Name of the container to wait for + timeout: Maximum time to wait in seconds (default: 60) + + Returns: + True if container is ready, False if it failed or timed out + """ + log = logging.getLogger("Gluetun") + debug_logger = get_debug_logger() + start_time = time.time() + last_error = None + last_status = None + + if debug_logger: + debug_logger.log( + level="DEBUG", + operation="gluetun_container_wait_start", + message=f"Waiting for container to be ready: {container_name}", + context={"container_name": container_name, "timeout": timeout}, + ) + + while time.time() - start_time < timeout: + try: + # First check if container is still running + if not self._is_container_running(container_name): + # Container may have exited - check if it crashed + exit_info = self._get_container_exit_info(container_name) + if exit_info: + last_error = f"Container exited with code {exit_info.get('exit_code', 'unknown')}" + time.sleep(1) + continue + + # Check logs for readiness indicators + result = subprocess.run( + ["docker", "logs", container_name, "--tail", "100"], + capture_output=True, + text=True, + timeout=5, + encoding="utf-8", + errors="replace", + ) + + if result.returncode == 0: + # Combine stdout and stderr for checking (handle None values) + stdout = result.stdout or "" + stderr = result.stderr or "" + all_logs = (stdout + stderr).lower() + + # Gluetun needs both proxy listening AND VPN connected + # The proxy starts before VPN is ready, so we need to wait for VPN + proxy_ready = "[http proxy] listening" in all_logs + vpn_ready = "initialization sequence completed" in all_logs + + # Log status changes to help debug slow connections + current_status = None + if vpn_ready: + current_status = "VPN connected" + elif "peer connection initiated" in all_logs: + current_status = "VPN connecting..." + elif "[openvpn]" in all_logs or "[wireguard]" in all_logs: + current_status = "Starting VPN..." + elif "[firewall]" in all_logs: + current_status = "Configuring firewall..." + + if current_status and current_status != last_status: + log.info(current_status) + last_status = current_status + + if proxy_ready and vpn_ready: + # Give a brief moment for the proxy to fully initialize + time.sleep(1) + duration_ms = (time.time() - start_time) * 1000 + if debug_logger: + debug_logger.log( + level="INFO", + operation="gluetun_container_ready", + message=f"Gluetun container is ready: {container_name}", + context={ + "container_name": container_name, + "proxy_ready": proxy_ready, + "vpn_ready": vpn_ready, + }, + success=True, + duration_ms=duration_ms, + ) + return True + + # Check for fatal errors that indicate VPN connection failure + error_indicators = [ + "fatal", + "cannot connect", + "authentication failed", + "invalid credentials", + "connection refused", + "no valid servers", + ] + + for error in error_indicators: + if error in all_logs: + # Extract the error line for better messaging + for line in (stdout + stderr).split('\n'): + if error in line.lower(): + last_error = line.strip() + break + # Fatal errors mean we should stop waiting + if "fatal" in all_logs or "invalid credentials" in all_logs: + return False + + except subprocess.TimeoutExpired: + pass + + time.sleep(2) + + # Store the last error for potential logging + if last_error: + self._last_wait_error = last_error + + # Log timeout/failure + duration_ms = (time.time() - start_time) * 1000 + if debug_logger: + debug_logger.log( + level="ERROR", + operation="gluetun_container_wait_timeout", + message=f"Gluetun container failed to become ready: {container_name}", + context={ + "container_name": container_name, + "timeout": timeout, + "last_error": last_error, + "last_status": last_status, + }, + success=False, + duration_ms=duration_ms, + ) + return False + + def _get_container_exit_info(self, container_name: str) -> Optional[dict]: + """Get exit information for a stopped container.""" + try: + result = subprocess.run( + [ + "docker", "inspect", container_name, + "--format", "{{.State.ExitCode}}:{{.State.Error}}" + ], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + parts = result.stdout.strip().split(":", 1) + return { + "exit_code": int(parts[0]) if parts[0].isdigit() else -1, + "error": parts[1] if len(parts) > 1 else "" + } + return None + except (subprocess.TimeoutExpired, FileNotFoundError, ValueError): + return None + + def _get_container_logs(self, container_name: str, tail: int = 50) -> str: + """Get recent logs from a container for error reporting.""" + try: + result = subprocess.run( + ["docker", "logs", container_name, "--tail", str(tail)], + capture_output=True, + text=True, + timeout=10, + encoding="utf-8", + errors="replace", + ) + return (result.stdout or "") + (result.stderr or "") + except (subprocess.TimeoutExpired, FileNotFoundError): + return "" + + def _verify_container(self, query_key: str, max_retries: int = 3): + """ + Verify container's VPN IP and region using ipinfo.io lookup. + + Uses the shared get_ip_info function with a session configured to use + the Gluetun proxy. Retries with exponential backoff if the network + isn't ready immediately after the VPN connects. + + Args: + query_key: The container query key (provider:region) + max_retries: Maximum number of retry attempts (default: 3) + + Raises: + RuntimeError: If verification fails after all retries + """ + debug_logger = get_debug_logger() + start_time = time.time() + + if query_key not in self.active_containers: + return + + container = self.active_containers[query_key] + proxy_url = self._build_proxy_uri(container["port"]) + expected_country = container.get("country", "").upper() + + if debug_logger: + debug_logger.log( + level="DEBUG", + operation="gluetun_verify_start", + message=f"Verifying VPN IP for: {query_key}", + context={ + "query_key": query_key, + "container_name": container.get("container_name"), + "expected_country": expected_country, + "max_retries": max_retries, + }, + ) + + last_error = None + + # Create a session with the proxy configured + session = requests.Session() + session.proxies = {"http": proxy_url, "https": proxy_url} + + # Retry with exponential backoff + for attempt in range(max_retries): + try: + # Get external IP through the proxy using shared utility + ip_info = get_ip_info(session) + + if ip_info: + actual_country = ip_info.get("country", "").upper() + + # Check if country matches (if we have an expected country) + # ipinfo.io returns country codes (CA), but we may have full names (Canada) + # Normalize both to country codes for comparison using shared utility + if expected_country: + # Convert expected country name to code if it's a full name + expected_code = get_country_code(expected_country) or expected_country + expected_code = expected_code.upper() + + if actual_country != expected_code: + duration_ms = (time.time() - start_time) * 1000 + if debug_logger: + debug_logger.log( + level="ERROR", + operation="gluetun_verify_mismatch", + message=f"Region mismatch for {query_key}", + context={ + "query_key": query_key, + "expected_country": expected_code, + "actual_country": actual_country, + "ip": ip_info.get("ip"), + "city": ip_info.get("city"), + "org": ip_info.get("org"), + }, + success=False, + duration_ms=duration_ms, + ) + raise RuntimeError( + f"Region mismatch for {container['provider']}:{container['region']}: " + f"Expected '{expected_code}' but got '{actual_country}' " + f"(IP: {ip_info.get('ip')}, City: {ip_info.get('city')})" + ) + + # Verification successful + duration_ms = (time.time() - start_time) * 1000 + if debug_logger: + debug_logger.log( + level="INFO", + operation="gluetun_verify_success", + message=f"VPN IP verified for: {query_key}", + context={ + "query_key": query_key, + "ip": ip_info.get("ip"), + "country": actual_country, + "city": ip_info.get("city"), + "org": ip_info.get("org"), + "attempts": attempt + 1, + }, + success=True, + duration_ms=duration_ms, + ) + return + + # ip_info was None, retry + last_error = "Failed to get IP info from ipinfo.io" + + except RuntimeError: + raise # Re-raise region mismatch errors immediately + except Exception as e: + last_error = str(e) + if debug_logger: + debug_logger.log( + level="DEBUG", + operation="gluetun_verify_retry", + message=f"Verification attempt {attempt + 1} failed, retrying", + context={ + "query_key": query_key, + "attempt": attempt + 1, + "error": last_error, + }, + ) + + # Wait before retry (exponential backoff) + if attempt < max_retries - 1: + wait_time = 2 ** attempt # 1, 2, 4 seconds + time.sleep(wait_time) + + # All retries exhausted + duration_ms = (time.time() - start_time) * 1000 + if debug_logger: + debug_logger.log( + level="ERROR", + operation="gluetun_verify_failed", + message=f"VPN verification failed after {max_retries} attempts", + context={ + "query_key": query_key, + "max_retries": max_retries, + "last_error": last_error, + }, + success=False, + duration_ms=duration_ms, + ) + raise RuntimeError( + f"Failed to verify VPN IP for {container['provider']}:{container['region']} " + f"after {max_retries} attempts. Last error: {last_error}" + ) + + def _remove_stopped_container(self, container_name: str) -> bool: + """ + Remove a stopped container with the given name if it exists. + + This prevents "container name already in use" errors when a previous + container wasn't properly cleaned up. + + Args: + container_name: Name of the container to check and remove + + Returns: + True if a container was removed, False otherwise + """ + try: + # Check if container exists (running or stopped) + result = subprocess.run( + ["docker", "ps", "-a", "--filter", f"name=^{container_name}$", "--format", "{{.Names}}:{{.Status}}"], + capture_output=True, + text=True, + timeout=5, + ) + + if result.returncode != 0 or not result.stdout.strip(): + return False + + # Parse status - format is "name:Up 2 hours" or "name:Exited (0) 2 hours ago" + output = result.stdout.strip() + if container_name not in output: + return False + + # Check if container is stopped (not running) + if "Exited" in output or "Created" in output or "Dead" in output: + # Container exists but is stopped - remove it + subprocess.run( + ["docker", "rm", "-f", container_name], + capture_output=True, + text=True, + timeout=10, + ) + return True + + return False + + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + + def _remove_container(self, container_name: str): + """Stop and remove a Docker container.""" + try: + if self.auto_cleanup: + # Use docker rm -f to force remove (stops and removes in one command) + subprocess.run( + ["docker", "rm", "-f", container_name], + capture_output=True, + text=True, + timeout=10, + ) + else: + # Just stop the container + subprocess.run( + ["docker", "stop", container_name], + capture_output=True, + text=True, + timeout=10, + ) + except subprocess.TimeoutExpired: + # Force kill if timeout + try: + subprocess.run( + ["docker", "rm", "-f", container_name], + capture_output=True, + text=True, + timeout=5, + ) + except subprocess.TimeoutExpired: + pass + + def _build_proxy_uri(self, port: int) -> str: + """Build HTTP proxy URI.""" + if self.auth_user and self.auth_password: + return f"http://{self.auth_user}:{self.auth_password}@localhost:{port}" + return f"http://localhost:{port}" + + def __del__(self): + """Cleanup containers on object destruction.""" + if hasattr(self, 'auto_cleanup') and self.auto_cleanup: + try: + if hasattr(self, 'active_containers') and self.active_containers: + self.cleanup() + except Exception: + pass diff --git a/unshackle/core/proxies/nordvpn.py b/unshackle/core/proxies/nordvpn.py index a50891d..33418ff 100644 --- a/unshackle/core/proxies/nordvpn.py +++ b/unshackle/core/proxies/nordvpn.py @@ -1,4 +1,5 @@ import json +import random import re from typing import Optional @@ -46,8 +47,21 @@ class NordVPN(Proxy): HTTP proxies under port 80 were disabled on the 15th of Feb, 2021: https://nordvpn.com/blog/removing-http-proxies + + Supports: + - Country code: "us", "ca", "gb" + - Country ID: "228" + - Specific server: "us1234" + - City selection: "us:seattle", "ca:calgary" """ query = query.lower() + city = None + + # Check if query includes city specification (e.g., "ca:calgary") + if ":" in query: + query, city = query.split(":", maxsplit=1) + city = city.strip() + if re.match(r"^[a-z]{2}\d+$", query): # country and nordvpn server id, e.g., us1, fr1234 hostname = f"{query}.nordvpn.com" @@ -64,7 +78,12 @@ class NordVPN(Proxy): # NordVPN doesnt have servers in this region return - server_mapping = self.server_map.get(country["code"].lower()) + # Check server_map for pinned servers (can include city) + server_map_key = f"{country['code'].lower()}:{city}" if city else country["code"].lower() + server_mapping = self.server_map.get(server_map_key) or ( + self.server_map.get(country["code"].lower()) if not city else None + ) + if server_mapping: # country was set to a specific server ID in config hostname = f"{country['code'].lower()}{server_mapping}.nordvpn.com" @@ -76,7 +95,19 @@ class NordVPN(Proxy): f"The NordVPN Country {query} currently has no recommended servers. " "Try again later. If the issue persists, double-check the query." ) - hostname = recommended_servers[0]["hostname"] + + # Filter by city if specified + if city: + city_servers = self.filter_servers_by_city(recommended_servers, city) + if not city_servers: + raise ValueError( + f"No servers found in city '{city}' for country '{country['name']}'. " + "Try a different city or check the city name spelling." + ) + recommended_servers = city_servers + + # Pick a random server from the filtered list + hostname = random.choice(recommended_servers)["hostname"] if hostname.startswith("gb"): # NordVPN uses the alpha2 of 'GB' in API responses, but 'UK' in the hostname @@ -95,6 +126,41 @@ class NordVPN(Proxy): ): return country + @staticmethod + def filter_servers_by_city(servers: list[dict], city: str) -> list[dict]: + """ + Filter servers by city name. + + The API returns servers with location data that includes city information. + This method filters servers to only those in the specified city. + + Args: + servers: List of server dictionaries from the NordVPN API + city: City name to filter by (case-insensitive) + + Returns: + List of servers in the specified city + """ + city_lower = city.lower() + filtered = [] + + for server in servers: + # Each server has a 'locations' list with location data + locations = server.get("locations", []) + for location in locations: + # City data can be in different formats: + # - {"city": {"name": "Seattle", ...}} + # - {"city": "Seattle"} + city_data = location.get("city") + if city_data: + # Handle both dict and string formats + city_name = city_data.get("name") if isinstance(city_data, dict) else city_data + if city_name and city_name.lower() == city_lower: + filtered.append(server) + break # Found a match, no need to check other locations for this server + + return filtered + @staticmethod def get_recommended_servers(country_id: int) -> list[dict]: """ diff --git a/unshackle/core/proxies/surfsharkvpn.py b/unshackle/core/proxies/surfsharkvpn.py index 32bf518..491906d 100644 --- a/unshackle/core/proxies/surfsharkvpn.py +++ b/unshackle/core/proxies/surfsharkvpn.py @@ -44,8 +44,21 @@ class SurfsharkVPN(Proxy): def get_proxy(self, query: str) -> Optional[str]: """ Get an HTTP(SSL) proxy URI for a SurfsharkVPN server. + + Supports: + - Country code: "us", "ca", "gb" + - Country ID: "228" + - Specific server: "us-bos" (Boston) + - City selection: "us:seattle", "ca:toronto" """ query = query.lower() + city = None + + # Check if query includes city specification (e.g., "us:seattle") + if ":" in query: + query, city = query.split(":", maxsplit=1) + city = city.strip() + if re.match(r"^[a-z]{2}\d+$", query): # country and surfsharkvpn server id, e.g., au-per, be-anr, us-bos hostname = f"{query}.prod.surfshark.com" @@ -62,13 +75,18 @@ class SurfsharkVPN(Proxy): # SurfsharkVPN doesnt have servers in this region return - server_mapping = self.server_map.get(country["countryCode"].lower()) + # Check server_map for pinned servers (can include city) + server_map_key = f"{country['countryCode'].lower()}:{city}" if city else country["countryCode"].lower() + server_mapping = self.server_map.get(server_map_key) or ( + self.server_map.get(country["countryCode"].lower()) if not city else None + ) + if server_mapping: # country was set to a specific server ID in config hostname = f"{country['code'].lower()}{server_mapping}.prod.surfshark.com" else: # get the random server ID - random_server = self.get_random_server(country["countryCode"]) + random_server = self.get_random_server(country["countryCode"], city) if not random_server: raise ValueError( f"The SurfsharkVPN Country {query} currently has no random servers. " @@ -92,18 +110,44 @@ class SurfsharkVPN(Proxy): ): return country - def get_random_server(self, country_id: str): + def get_random_server(self, country_id: str, city: Optional[str] = None): """ - Get the list of random Server for a Country. + Get a random server for a Country, optionally filtered by city. - Note: There may not always be more than one recommended server. + Args: + country_id: The country code (e.g., "US", "CA") + city: Optional city name to filter by (case-insensitive) + + Note: The API may include a 'location' field with city information. + If not available, this will return any server from the country. """ - country = [x["connectionName"] for x in self.countries if x["countryCode"].lower() == country_id.lower()] + servers = [x for x in self.countries if x["countryCode"].lower() == country_id.lower()] + + # Filter by city if specified + if city: + city_lower = city.lower() + # Check if servers have a 'location' field for city filtering + city_servers = [ + x + for x in servers + if x.get("location", "").lower() == city_lower or x.get("city", "").lower() == city_lower + ] + + if city_servers: + servers = city_servers + else: + raise ValueError( + f"No servers found in city '{city}' for country '{country_id}'. " + "Try a different city or check the city name spelling." + ) + + # Get connection names from filtered servers + connection_names = [x["connectionName"] for x in servers] + try: - country = random.choice(country) - return country - except Exception: - raise ValueError("Could not get random countrycode from the countries list.") + return random.choice(connection_names) + except (IndexError, KeyError): + raise ValueError(f"Could not get random server for country '{country_id}'.") @staticmethod def get_countries() -> list[dict]: diff --git a/unshackle/core/remote_auth.py b/unshackle/core/remote_auth.py new file mode 100644 index 0000000..3b2a947 --- /dev/null +++ b/unshackle/core/remote_auth.py @@ -0,0 +1,279 @@ +"""Client-side authentication for remote services. + +This module handles authenticating services locally on the client side, +then sending the authenticated session to the remote server. + +This approach allows: +- Interactive browser-based logins +- 2FA/CAPTCHA handling +- OAuth flows +- Any authentication that requires user interaction + +The server NEVER sees credentials - only authenticated sessions. +""" + +import logging +from typing import Any, Dict, Optional + +import click +import requests +import yaml + +from unshackle.core.api.session_serializer import serialize_session +from unshackle.core.config import config +from unshackle.core.console import console +from unshackle.core.credential import Credential +from unshackle.core.local_session_cache import get_local_session_cache +from unshackle.core.services import Services +from unshackle.core.utils.click_types import ContextData +from unshackle.core.utils.collections import merge_dict + +log = logging.getLogger("RemoteAuth") + + +class RemoteAuthenticator: + """ + Handles client-side authentication for remote services. + + Workflow: + 1. Load service locally + 2. Authenticate using local credentials/cookies (can show browser, handle 2FA) + 3. Extract authenticated session + 4. Upload session to remote server + 5. Server uses the pre-authenticated session + """ + + def __init__(self, remote_url: str, api_key: str): + """ + Initialize remote authenticator. + + Args: + remote_url: Base URL of remote server + api_key: API key for remote server + """ + self.remote_url = remote_url.rstrip("/") + self.api_key = api_key + self.session = requests.Session() + self.session.headers.update({"X-API-Key": self.api_key, "Content-Type": "application/json"}) + + def authenticate_service_locally( + self, service_tag: str, profile: Optional[str] = None, force_reauth: bool = False + ) -> Dict[str, Any]: + """ + Authenticate a service locally and extract the session. + + This runs the service authentication on the CLIENT side where browsers, + 2FA, and interactive prompts can work. + + Args: + service_tag: Service to authenticate (e.g., "DSNP", "NF") + profile: Optional profile to use for credentials + force_reauth: Force re-authentication even if session exists + + Returns: + Serialized session data + + Raises: + ValueError: If service not found or authentication fails + """ + console.print(f"[cyan]Authenticating {service_tag} locally...[/cyan]") + + # Validate service exists + if service_tag not in Services.get_tags(): + raise ValueError(f"Service {service_tag} not found locally") + + # Load service + service_module = Services.load(service_tag) + + # Load service config + service_config_path = Services.get_path(service_tag) / config.filenames.config + if service_config_path.exists(): + service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8")) + else: + service_config = {} + merge_dict(config.services.get(service_tag), service_config) + + # Create Click context + @click.command() + @click.pass_context + def dummy_command(ctx: click.Context) -> None: + pass + + ctx = click.Context(dummy_command) + ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=[], profile=profile) + + # Create service instance + try: + # Get service initialization parameters + import inspect + + service_init_params = inspect.signature(service_module.__init__).parameters + service_kwargs = {} + + # Extract defaults from click command + if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"): + for param in service_module.cli.params: + if hasattr(param, "name") and param.name not in service_kwargs: + if hasattr(param, "default") and param.default is not None: + service_kwargs[param.name] = param.default + + # Filter to only valid parameters + filtered_kwargs = {k: v for k, v in service_kwargs.items() if k in service_init_params} + + # Create service instance + service_instance = service_module(ctx, **filtered_kwargs) + + # Get credentials and cookies + cookies = self._get_cookie_jar(service_tag, profile) + credential = self._get_credentials(service_tag, profile) + + # Authenticate the service + console.print("[yellow]Authenticating... (this may show browser or prompts)[/yellow]") + service_instance.authenticate(cookies=cookies, credential=credential) + + # Serialize the authenticated session + session_data = serialize_session(service_instance.session) + + # Add metadata + session_data["service_tag"] = service_tag + session_data["profile"] = profile + session_data["authenticated"] = True + + console.print(f"[green]✓ {service_tag} authenticated successfully![/green]") + log.info(f"Authenticated {service_tag} (profile: {profile or 'default'})") + + return session_data + + except Exception as e: + console.print(f"[red]✗ Authentication failed: {e}[/red]") + log.error(f"Failed to authenticate {service_tag}: {e}") + raise ValueError(f"Authentication failed for {service_tag}: {e}") + + def save_session_locally(self, session_data: Dict[str, Any]) -> bool: + """ + Save authenticated session to local cache. + + The session is stored only on the client machine, never on the server. + The server is completely stateless. + + Args: + session_data: Serialized session data + + Returns: + True if save successful + """ + service_tag = session_data.get("service_tag") + profile = session_data.get("profile", "default") + + console.print("[cyan]Saving session to local cache...[/cyan]") + + try: + # Get local session cache + cache = get_local_session_cache() + + # Store session locally + cache.store_session( + remote_url=self.remote_url, + service_tag=service_tag, + profile=profile, + session_data=session_data + ) + + console.print("[green]✓ Session saved locally![/green]") + log.info(f"Saved session for {service_tag} (profile: {profile}) to local cache") + return True + + except Exception as e: + console.print(f"[red]✗ Save failed: {e}[/red]") + log.error(f"Failed to save session locally: {e}") + return False + + def authenticate_and_save(self, service_tag: str, profile: Optional[str] = None) -> bool: + """ + Authenticate locally and save session to local cache in one step. + + Args: + service_tag: Service to authenticate + profile: Optional profile + + Returns: + True if successful + """ + try: + # Authenticate locally + session_data = self.authenticate_service_locally(service_tag, profile) + + # Save to local cache + return self.save_session_locally(session_data) + + except Exception as e: + console.print(f"[red]Authentication and save failed: {e}[/red]") + return False + + def check_local_session_status(self, service_tag: str, profile: Optional[str] = None) -> Dict[str, Any]: + """ + Check if a session exists in local cache. + + Args: + service_tag: Service tag + profile: Optional profile + + Returns: + Session status info + """ + try: + cache = get_local_session_cache() + session_data = cache.get_session(self.remote_url, service_tag, profile or "default") + + if session_data: + # Get metadata + sessions = cache.list_sessions(self.remote_url) + for session in sessions: + if session["service_tag"] == service_tag and session["profile"] == (profile or "default"): + return { + "status": "success", + "exists": True, + "session_info": session + } + + return { + "status": "success", + "exists": False, + "message": f"No session found for {service_tag} (profile: {profile or 'default'})" + } + + except Exception as e: + log.error(f"Failed to check session status: {e}") + return {"status": "error", "message": "Failed to check session status"} + + def _get_cookie_jar(self, service_tag: str, profile: Optional[str]): + """Get cookie jar for service and profile.""" + from unshackle.commands.dl import dl + + return dl.get_cookie_jar(service_tag, profile) + + def _get_credentials(self, service_tag: str, profile: Optional[str]) -> Optional[Credential]: + """Get credentials for service and profile.""" + from unshackle.commands.dl import dl + + return dl.get_credentials(service_tag, profile) + + +def authenticate_remote_service(remote_url: str, api_key: str, service_tag: str, profile: Optional[str] = None) -> bool: + """ + Helper function to authenticate a remote service. + + Args: + remote_url: Remote server URL + api_key: API key + service_tag: Service to authenticate + profile: Optional profile + + Returns: + True if successful + """ + authenticator = RemoteAuthenticator(remote_url, api_key) + return authenticator.authenticate_and_save(service_tag, profile) + + +__all__ = ["RemoteAuthenticator", "authenticate_remote_service"] diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py new file mode 100644 index 0000000..23d178c --- /dev/null +++ b/unshackle/core/remote_service.py @@ -0,0 +1,593 @@ +"""Remote service implementation for connecting to remote unshackle servers.""" + +import logging +import time +from collections.abc import Generator +from http.cookiejar import CookieJar +from typing import Any, Dict, Optional, Union + +import click +import requests +from rich.padding import Padding +from rich.rule import Rule + +from unshackle.core.api.session_serializer import deserialize_session +from unshackle.core.console import console +from unshackle.core.credential import Credential +from unshackle.core.local_session_cache import get_local_session_cache +from unshackle.core.search_result import SearchResult +from unshackle.core.titles import Episode, Movie, Movies, Series +from unshackle.core.tracks import Chapter, Chapters, Tracks +from unshackle.core.tracks.audio import Audio +from unshackle.core.tracks.subtitle import Subtitle +from unshackle.core.tracks.video import Video + + +class RemoteService: + """ + Remote Service wrapper that connects to a remote unshackle server. + + This class mimics the Service interface but delegates all operations + to a remote unshackle server via API calls. It receives session data + from the remote server which is then used locally for downloading. + """ + + ALIASES: tuple[str, ...] = () + GEOFENCE: tuple[str, ...] = () + + def __init__( + self, + ctx: click.Context, + remote_url: str, + api_key: str, + service_tag: str, + service_metadata: Dict[str, Any], + **kwargs, + ): + """ + Initialize remote service. + + Args: + ctx: Click context + remote_url: Base URL of the remote unshackle server + api_key: API key for authentication + service_tag: The service tag on the remote server (e.g., "DSNP") + service_metadata: Metadata about the service from remote discovery + **kwargs: Additional service-specific parameters + """ + console.print(Padding(Rule(f"[rule.text]Remote Service: {service_tag}"), (1, 2))) + + self.log = logging.getLogger(f"RemoteService.{service_tag}") + self.remote_url = remote_url.rstrip("/") + self.api_key = api_key + self.service_tag = service_tag + self.service_metadata = service_metadata + self.ctx = ctx + self.kwargs = kwargs + + # Set GEOFENCE and ALIASES from metadata + if "geofence" in service_metadata: + self.GEOFENCE = tuple(service_metadata["geofence"]) + if "aliases" in service_metadata: + self.ALIASES = tuple(service_metadata["aliases"]) + + # Create a session for API calls to the remote server + self.api_session = requests.Session() + self.api_session.headers.update({"X-API-Key": self.api_key, "Content-Type": "application/json"}) + + # This session will receive data from remote for actual downloading + self.session = requests.Session() + + # Store authentication state + self.authenticated = False + self.credential = None + self.cookies_content = None # Raw cookie file content to send to remote + + # Get profile from context if available + self.profile = "default" + if hasattr(ctx, "obj") and hasattr(ctx.obj, "profile"): + self.profile = ctx.obj.profile or "default" + + # Initialize proxy providers for resolving proxy credentials + self._proxy_providers = None + if hasattr(ctx, "obj") and hasattr(ctx.obj, "proxy_providers"): + self._proxy_providers = ctx.obj.proxy_providers + + def _resolve_proxy_locally(self, proxy: str) -> Optional[str]: + """ + Resolve proxy parameter locally using client's proxy providers. + + This allows the client to resolve proxy providers (like NordVPN) and + send the full proxy URI with credentials to the server. + + Args: + proxy: Proxy parameter (e.g., "nordvpn:ca1066", "us2104", or full URI) + + Returns: + Resolved proxy URI with credentials, or None if no_proxy + """ + if not proxy: + return None + + import re + + # If already a full URI, return as-is + if re.match(r"^https?://", proxy): + self.log.debug(f"Using explicit proxy URI: {proxy}") + return proxy + + # Try to resolve using local proxy providers + if self._proxy_providers: + try: + from unshackle.core.api.handlers import resolve_proxy + + resolved = resolve_proxy(proxy, self._proxy_providers) + self.log.info(f"Resolved proxy '{proxy}' to: {resolved}") + return resolved + except Exception as e: + self.log.warning(f"Failed to resolve proxy locally: {e}") + # Fall back to sending proxy parameter as-is for server to resolve + return proxy + else: + self.log.debug(f"No proxy providers available, sending proxy as-is: {proxy}") + return proxy + + def _add_proxy_to_request(self, data: Dict[str, Any]) -> None: + """ + Add resolved proxy information to request data. + + Resolves proxy using local proxy providers and adds to request. + Server will use the resolved proxy URI (with credentials). + + Args: + data: Request data dictionary to modify + """ + if hasattr(self.ctx, "params"): + no_proxy = self.ctx.params.get("no_proxy", False) + proxy_param = self.ctx.params.get("proxy") + + if no_proxy: + data["no_proxy"] = True + elif proxy_param: + # Resolve proxy locally to get credentials + resolved_proxy = self._resolve_proxy_locally(proxy_param) + if resolved_proxy: + data["proxy"] = resolved_proxy + self.log.debug(f"Sending resolved proxy to server: {resolved_proxy}") + + def _make_request(self, endpoint: str, data: Optional[Dict[str, Any]] = None, retry_count: int = 0) -> Dict[str, Any]: + """ + Make an API request to the remote server with retry logic. + + Automatically handles authentication: + 1. Check for cached session - send with request if found + 2. If session expired, re-authenticate automatically + 3. If no session, send credentials (server tries to auth) + 4. If server returns AUTH_REQUIRED, authenticate locally + 5. Retry request with new session + + Args: + endpoint: API endpoint path (e.g., "/api/remote/DSNP/titles") + data: Optional JSON data to send + retry_count: Current retry attempt (for internal use) + + Returns: + Response JSON data + + Raises: + ConnectionError: If the request fails after all retries + """ + url = f"{self.remote_url}{endpoint}" + max_retries = 3 # Max network retries + retry_delays = [2, 4, 8] # Exponential backoff in seconds + + # Ensure data is a dictionary + if data is None: + data = {} + + # Priority 1: Check for pre-authenticated session in local cache + cache = get_local_session_cache() + cached_session = cache.get_session(self.remote_url, self.service_tag, self.profile) + + if cached_session: + # Send pre-authenticated session data (server never stores it) + self.log.debug(f"Using cached session for {self.service_tag}") + data["pre_authenticated_session"] = cached_session + else: + # Priority 2: Fallback to credentials/cookies (old behavior) + # This allows server to authenticate if no local session exists + if self.cookies_content: + data["cookies"] = self.cookies_content + + if self.credential: + data["credential"] = {"username": self.credential.username, "password": self.credential.password} + + try: + if data: + response = self.api_session.post(url, json=data) + else: + response = self.api_session.get(url) + + response.raise_for_status() + result = response.json() + + # Check if session expired - re-authenticate automatically + if result.get("error_code") == "SESSION_EXPIRED": + console.print(f"[yellow]Session expired for {self.service_tag}[/yellow]") + console.print("[cyan]Re-authenticating...[/cyan]") + + # Delete expired session from cache + cache.delete_session(self.remote_url, self.service_tag, self.profile) + + # Perform local authentication + session_data = self._authenticate_locally() + + if session_data: + # Save to cache for future requests + cache.store_session( + remote_url=self.remote_url, + service_tag=self.service_tag, + profile=self.profile, + session_data=session_data + ) + + # Retry request with new session + data["pre_authenticated_session"] = session_data + # Remove old auth data + data.pop("cookies", None) + data.pop("credential", None) + + # Retry the request + response = self.api_session.post(url, json=data) + response.raise_for_status() + result = response.json() + + # Check if server requires authentication + elif result.get("error_code") == "AUTH_REQUIRED" and not cached_session: + console.print(f"[yellow]Authentication required for {self.service_tag}[/yellow]") + console.print("[cyan]Authenticating locally...[/cyan]") + + # Perform local authentication + session_data = self._authenticate_locally() + + if session_data: + # Save to cache for future requests + cache.store_session( + remote_url=self.remote_url, + service_tag=self.service_tag, + profile=self.profile, + session_data=session_data + ) + + # Retry request with authenticated session + data["pre_authenticated_session"] = session_data + # Remove old auth data + data.pop("cookies", None) + data.pop("credential", None) + + # Retry the request + response = self.api_session.post(url, json=data) + response.raise_for_status() + result = response.json() + + # Apply session data if present + if "session" in result: + deserialize_session(result["session"], self.session) + + return result + + except requests.RequestException as e: + # Retry on network errors with exponential backoff + if retry_count < max_retries: + delay = retry_delays[retry_count] + self.log.warning(f"Request failed (attempt {retry_count + 1}/{max_retries + 1}): {e}") + self.log.info(f"Retrying in {delay} seconds...") + time.sleep(delay) + return self._make_request(endpoint, data, retry_count + 1) + else: + self.log.error(f"Remote API request failed after {max_retries + 1} attempts: {e}") + raise ConnectionError(f"Failed to communicate with remote server after {max_retries + 1} attempts: {e}") + + def _authenticate_locally(self) -> Optional[Dict[str, Any]]: + """ + Authenticate the service locally when server requires it. + + This performs interactive authentication (browser, 2FA, etc.) + and returns the authenticated session. + + Returns: + Serialized session data or None if authentication fails + """ + from unshackle.core.remote_auth import RemoteAuthenticator + + try: + authenticator = RemoteAuthenticator(self.remote_url, self.api_key) + session_data = authenticator.authenticate_service_locally(self.service_tag, self.profile) + console.print("[green]✓ Authentication successful![/green]") + return session_data + + except Exception as e: + console.print(f"[red]✗ Authentication failed: {e}[/red]") + self.log.error(f"Local authentication failed: {e}") + return None + + def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: + """ + Prepare authentication data to send to remote service. + + Stores cookies and credentials to send with each API request. + The remote server will use these for authentication. + + Args: + cookies: Cookie jar from local configuration + credential: Credentials from local configuration + """ + self.log.info("Preparing authentication for remote server...") + self.credential = credential + + # Read cookies file content if cookies provided + if cookies and hasattr(cookies, "filename") and cookies.filename: + try: + from pathlib import Path + + cookie_file = Path(cookies.filename) + if cookie_file.exists(): + self.cookies_content = cookie_file.read_text() + self.log.info(f"Loaded cookies from {cookie_file}") + except Exception as e: + self.log.warning(f"Could not read cookie file: {e}") + + self.authenticated = True + self.log.info("Authentication data ready for remote server") + + def search(self, query: Optional[str] = None) -> Generator[SearchResult, None, None]: + """ + Search for content on the remote service. + + Args: + query: Search query string + + Yields: + SearchResult objects + """ + if query is None: + query = self.kwargs.get("query", "") + + self.log.info(f"Searching remote service for: {query}") + + data = {"query": query} + + # Add proxy information (resolved locally with credentials) + self._add_proxy_to_request(data) + + response = self._make_request(f"/api/remote/{self.service_tag}/search", data) + + if response.get("status") == "success" and "results" in response: + for result in response["results"]: + yield SearchResult( + id_=result["id"], + title=result["title"], + description=result.get("description"), + label=result.get("label"), + url=result.get("url"), + ) + + def get_titles(self) -> Union[Movies, Series]: + """ + Get titles from the remote service. + + Returns: + Movies or Series object containing title information + """ + title = self.kwargs.get("title") + + if not title: + raise ValueError("No title provided") + + self.log.info(f"Getting titles from remote service for: {title}") + + data = {"title": title} + + # Add additional parameters + for key, value in self.kwargs.items(): + if key not in ["title"]: + data[key] = value + + # Add proxy information (resolved locally with credentials) + self._add_proxy_to_request(data) + + response = self._make_request(f"/api/remote/{self.service_tag}/titles", data) + + if response.get("status") != "success" or "titles" not in response: + raise ValueError(f"Failed to get titles from remote: {response.get('message', 'Unknown error')}") + + titles_data = response["titles"] + + # Deserialize titles + titles = [] + for title_info in titles_data: + if title_info["type"] == "movie": + titles.append( + Movie( + id_=title_info.get("id", title), + service=self.__class__, + name=title_info["name"], + year=title_info.get("year"), + data=title_info, + ) + ) + elif title_info["type"] == "episode": + titles.append( + Episode( + id_=title_info.get("id", title), + service=self.__class__, + title=title_info.get("series_title", title_info["name"]), + season=title_info.get("season", 0), + number=title_info.get("number", 0), + name=title_info.get("name"), + year=title_info.get("year"), + data=title_info, + ) + ) + + # Return appropriate container + if titles and isinstance(titles[0], Episode): + return Series(titles) + else: + return Movies(titles) + + def get_tracks(self, title: Union[Movie, Episode]) -> Tracks: + """ + Get tracks from the remote service. + + Args: + title: Title object to get tracks for + + Returns: + Tracks object containing video, audio, and subtitle tracks + """ + self.log.info(f"Getting tracks from remote service for: {title}") + + title_input = self.kwargs.get("title") + data = {"title": title_input} + + # Add episode information if applicable + if isinstance(title, Episode): + data["season"] = title.season + data["episode"] = title.number + + # Add additional parameters + for key, value in self.kwargs.items(): + if key not in ["title"]: + data[key] = value + + # Add proxy information (resolved locally with credentials) + self._add_proxy_to_request(data) + + response = self._make_request(f"/api/remote/{self.service_tag}/tracks", data) + + if response.get("status") != "success": + raise ValueError(f"Failed to get tracks from remote: {response.get('message', 'Unknown error')}") + + # Handle multiple episodes response + if "episodes" in response: + # For multiple episodes, return tracks for the matching title + for episode_data in response["episodes"]: + episode_title = episode_data["title"] + if ( + isinstance(title, Episode) + and episode_title.get("season") == title.season + and episode_title.get("number") == title.number + ): + return self._deserialize_tracks(episode_data, title) + + raise ValueError(f"Could not find tracks for {title.season}x{title.number} in remote response") + + # Single title response + return self._deserialize_tracks(response, title) + + def _deserialize_tracks(self, data: Dict[str, Any], title: Union[Movie, Episode]) -> Tracks: + """ + Deserialize tracks from API response. + + Args: + data: Track data from API + title: Title object these tracks belong to + + Returns: + Tracks object + """ + tracks = Tracks() + + # Deserialize video tracks + for video_data in data.get("video", []): + video = Video( + id_=video_data["id"], + url="", # URL will be populated during download from manifests + codec=Video.Codec[video_data["codec"]], + bitrate=video_data.get("bitrate", 0) * 1000 if video_data.get("bitrate") else None, + width=video_data.get("width"), + height=video_data.get("height"), + fps=video_data.get("fps"), + range_=Video.Range[video_data["range"]] if video_data.get("range") else None, + language=video_data.get("language"), + drm=video_data.get("drm"), + ) + tracks.add(video) + + # Deserialize audio tracks + for audio_data in data.get("audio", []): + audio = Audio( + id_=audio_data["id"], + url="", # URL will be populated during download + codec=Audio.Codec[audio_data["codec"]], + bitrate=audio_data.get("bitrate", 0) * 1000 if audio_data.get("bitrate") else None, + channels=audio_data.get("channels"), + language=audio_data.get("language"), + descriptive=audio_data.get("descriptive", False), + drm=audio_data.get("drm"), + ) + if audio_data.get("atmos"): + audio.atmos = True + tracks.add(audio) + + # Deserialize subtitle tracks + for subtitle_data in data.get("subtitles", []): + subtitle = Subtitle( + id_=subtitle_data["id"], + url="", # URL will be populated during download + codec=Subtitle.Codec[subtitle_data["codec"]], + language=subtitle_data.get("language"), + forced=subtitle_data.get("forced", False), + sdh=subtitle_data.get("sdh", False), + cc=subtitle_data.get("cc", False), + ) + tracks.add(subtitle) + + return tracks + + def get_chapters(self, title: Union[Movie, Episode]) -> Chapters: + """ + Get chapters from the remote service. + + Args: + title: Title object to get chapters for + + Returns: + Chapters object + """ + self.log.info(f"Getting chapters from remote service for: {title}") + + title_input = self.kwargs.get("title") + data = {"title": title_input} + + # Add episode information if applicable + if isinstance(title, Episode): + data["season"] = title.season + data["episode"] = title.number + + # Add proxy information (resolved locally with credentials) + self._add_proxy_to_request(data) + + response = self._make_request(f"/api/remote/{self.service_tag}/chapters", data) + + if response.get("status") != "success": + self.log.warning(f"Failed to get chapters from remote: {response.get('message', 'Unknown error')}") + return Chapters() + + chapters = Chapters() + for chapter_data in response.get("chapters", []): + chapters.add(Chapter(timestamp=chapter_data["timestamp"], name=chapter_data.get("name"))) + + return chapters + + @staticmethod + def get_session() -> requests.Session: + """ + Create a session for the remote service. + + Returns: + A requests.Session object + """ + session = requests.Session() + return session diff --git a/unshackle/core/remote_services.py b/unshackle/core/remote_services.py new file mode 100644 index 0000000..cca45a3 --- /dev/null +++ b/unshackle/core/remote_services.py @@ -0,0 +1,245 @@ +"""Remote service discovery and management.""" + +import logging +from pathlib import Path +from typing import Any, Dict, List, Optional + +import requests + +from unshackle.core.config import config +from unshackle.core.remote_service import RemoteService + +log = logging.getLogger("RemoteServices") + + +class RemoteServiceManager: + """ + Manages discovery and registration of remote services. + + This class connects to configured remote unshackle servers, + discovers available services, and creates RemoteService instances + that can be used like local services. + """ + + def __init__(self): + """Initialize the remote service manager.""" + self.remote_services: Dict[str, type] = {} + self.remote_configs: List[Dict[str, Any]] = [] + + def discover_services(self) -> None: + """ + Discover services from all configured remote servers. + + Reads the remote_services configuration, connects to each server, + retrieves available services, and creates RemoteService classes + for each discovered service. + """ + if not config.remote_services: + log.debug("No remote services configured") + return + + log.info(f"Discovering services from {len(config.remote_services)} remote server(s)...") + + for remote_config in config.remote_services: + try: + self._discover_from_server(remote_config) + except Exception as e: + log.error(f"Failed to discover services from {remote_config.get('url')}: {e}") + continue + + log.info(f"Discovered {len(self.remote_services)} remote service(s)") + + def _discover_from_server(self, remote_config: Dict[str, Any]) -> None: + """ + Discover services from a single remote server. + + Args: + remote_config: Configuration for the remote server + (must contain 'url' and 'api_key') + """ + url = remote_config.get("url", "").rstrip("/") + api_key = remote_config.get("api_key", "") + server_name = remote_config.get("name", url) + + if not url: + log.warning("Remote service configuration missing 'url', skipping") + return + + if not api_key: + log.warning(f"Remote service {url} missing 'api_key', skipping") + return + + log.info(f"Connecting to remote server: {server_name}") + + try: + # Query the remote server for available services + response = requests.get( + f"{url}/api/remote/services", + headers={"X-API-Key": api_key, "Content-Type": "application/json"}, + timeout=10, + ) + + response.raise_for_status() + data = response.json() + + if data.get("status") != "success" or "services" not in data: + log.error(f"Invalid response from {url}: {data}") + return + + services = data["services"] + log.info(f"Found {len(services)} service(s) on {server_name}") + + # Create RemoteService classes for each service + for service_info in services: + self._register_remote_service(url, api_key, service_info, server_name) + + except requests.RequestException as e: + log.error(f"Failed to connect to remote server {url}: {e}") + raise + + def _register_remote_service( + self, remote_url: str, api_key: str, service_info: Dict[str, Any], server_name: str + ) -> None: + """ + Register a remote service as a local service class. + + Args: + remote_url: Base URL of the remote server + api_key: API key for authentication + service_info: Service metadata from the remote server + server_name: Friendly name of the remote server + """ + service_tag = service_info.get("tag") + if not service_tag: + log.warning(f"Service info missing 'tag': {service_info}") + return + + # Create a unique tag for the remote service + # Use "remote_" prefix to distinguish from local services + remote_tag = f"remote_{service_tag}" + + # Check if this remote service is already registered + if remote_tag in self.remote_services: + log.debug(f"Remote service {remote_tag} already registered, skipping") + return + + log.info(f"Registering remote service: {remote_tag} from {server_name}") + + # Create a dynamic class that inherits from RemoteService + # This allows us to create instances with the cli() method for Click integration + class DynamicRemoteService(RemoteService): + """Dynamically created remote service class.""" + + def __init__(self, ctx, **kwargs): + super().__init__( + ctx=ctx, + remote_url=remote_url, + api_key=api_key, + service_tag=service_tag, + service_metadata=service_info, + **kwargs, + ) + + @staticmethod + def cli(): + """CLI method for Click integration.""" + import click + + # Create a dynamic Click command for this service + @click.command( + name=remote_tag, + short_help=f"Remote: {service_info.get('help', service_tag)}", + help=service_info.get("help", f"Remote service for {service_tag}"), + ) + @click.argument("title", type=str, required=False) + @click.option("-q", "--query", type=str, help="Search query") + @click.pass_context + def remote_service_cli(ctx, title=None, query=None, **kwargs): + # Combine title and kwargs + params = {**kwargs} + if title: + params["title"] = title + if query: + params["query"] = query + + return DynamicRemoteService(ctx, **params) + + return remote_service_cli + + # Set class name for better debugging + DynamicRemoteService.__name__ = remote_tag + DynamicRemoteService.__module__ = "unshackle.remote_services" + + # Set GEOFENCE and ALIASES + if "geofence" in service_info: + DynamicRemoteService.GEOFENCE = tuple(service_info["geofence"]) + if "aliases" in service_info: + # Add "remote_" prefix to aliases too + DynamicRemoteService.ALIASES = tuple(f"remote_{alias}" for alias in service_info["aliases"]) + + # Register the service + self.remote_services[remote_tag] = DynamicRemoteService + + def get_service(self, tag: str) -> Optional[type]: + """ + Get a remote service class by tag. + + Args: + tag: Service tag (e.g., "remote_DSNP") + + Returns: + RemoteService class or None if not found + """ + return self.remote_services.get(tag) + + def get_all_services(self) -> Dict[str, type]: + """ + Get all registered remote services. + + Returns: + Dictionary mapping service tags to RemoteService classes + """ + return self.remote_services.copy() + + def get_service_path(self, tag: str) -> Optional[Path]: + """ + Get the path for a remote service. + + Remote services don't have local paths, so this returns None. + This method exists for compatibility with the Services interface. + + Args: + tag: Service tag + + Returns: + None (remote services have no local path) + """ + return None + + +# Global instance +_remote_service_manager: Optional[RemoteServiceManager] = None + + +def get_remote_service_manager() -> RemoteServiceManager: + """ + Get the global RemoteServiceManager instance. + + Creates the instance on first call and discovers services. + + Returns: + RemoteServiceManager instance + """ + global _remote_service_manager + + if _remote_service_manager is None: + _remote_service_manager = RemoteServiceManager() + try: + _remote_service_manager.discover_services() + except Exception as e: + log.error(f"Failed to discover remote services: {e}") + + return _remote_service_manager + + +__all__ = ("RemoteServiceManager", "get_remote_service_manager") diff --git a/unshackle/core/service.py b/unshackle/core/service.py index dd748ad..d39cb55 100644 --- a/unshackle/core/service.py +++ b/unshackle/core/service.py @@ -53,8 +53,55 @@ class Service(metaclass=ABCMeta): if not ctx.parent or not ctx.parent.params.get("no_proxy"): if ctx.parent: proxy = ctx.parent.params["proxy"] + proxy_query = ctx.parent.params.get("proxy_query") + proxy_provider_name = ctx.parent.params.get("proxy_provider") else: proxy = None + proxy_query = None + proxy_provider_name = None + + # Check for service-specific proxy mapping + service_name = self.__class__.__name__ + service_config_dict = config.services.get(service_name, {}) + proxy_map = service_config_dict.get("proxy_map", {}) + + if proxy_map and proxy_query: + # Build the full proxy query key (e.g., "nordvpn:ca" or "us") + if proxy_provider_name: + full_proxy_key = f"{proxy_provider_name}:{proxy_query}" + else: + full_proxy_key = proxy_query + + # Check if there's a mapping for this query + mapped_value = proxy_map.get(full_proxy_key) + if mapped_value: + self.log.info(f"Found service-specific proxy mapping: {full_proxy_key} -> {mapped_value}") + # Query the proxy provider with the mapped value + if proxy_provider_name: + # Specific provider requested + proxy_provider = next( + (x for x in ctx.obj.proxy_providers if x.__class__.__name__.lower() == proxy_provider_name), + None, + ) + if proxy_provider: + mapped_proxy_uri = proxy_provider.get_proxy(mapped_value) + if mapped_proxy_uri: + proxy = mapped_proxy_uri + self.log.info(f"Using mapped proxy from {proxy_provider.__class__.__name__}: {proxy}") + else: + self.log.warning(f"Failed to get proxy for mapped value '{mapped_value}', using default") + else: + self.log.warning(f"Proxy provider '{proxy_provider_name}' not found, using default proxy") + else: + # No specific provider, try all providers + for proxy_provider in ctx.obj.proxy_providers: + mapped_proxy_uri = proxy_provider.get_proxy(mapped_value) + if mapped_proxy_uri: + proxy = mapped_proxy_uri + self.log.info(f"Using mapped proxy from {proxy_provider.__class__.__name__}: {proxy}") + break + else: + self.log.warning(f"No provider could resolve mapped value '{mapped_value}', using default") if not proxy: # don't override the explicit proxy set by the user, even if they may be geoblocked diff --git a/unshackle/core/services.py b/unshackle/core/services.py index 0ba317f..97f64bf 100644 --- a/unshackle/core/services.py +++ b/unshackle/core/services.py @@ -25,6 +25,17 @@ class Services(click.MultiCommand): # Click-specific methods + @staticmethod + def _get_remote_services(): + """Get remote services from the manager (lazy import to avoid circular dependency).""" + try: + from unshackle.core.remote_services import get_remote_service_manager + + manager = get_remote_service_manager() + return manager.get_all_services() + except Exception: + return {} + def list_commands(self, ctx: click.Context) -> list[str]: """Returns a list of all available Services as command names for Click.""" return Services.get_tags() @@ -51,13 +62,25 @@ class Services(click.MultiCommand): @staticmethod def get_tags() -> list[str]: - """Returns a list of service tags from all available Services.""" - return [x.parent.stem for x in _SERVICES] + """Returns a list of service tags from all available Services (local + remote).""" + local_tags = [x.parent.stem for x in _SERVICES] + remote_services = Services._get_remote_services() + remote_tags = list(remote_services.keys()) + return local_tags + remote_tags @staticmethod def get_path(name: str) -> Path: """Get the directory path of a command.""" tag = Services.get_tag(name) + + # Check if it's a remote service + remote_services = Services._get_remote_services() + if tag in remote_services: + # Remote services don't have local paths + # Return a dummy path or raise an appropriate error + # For now, we'll raise KeyError to indicate no path exists + raise KeyError(f"Remote service '{tag}' has no local path") + for service in _SERVICES: if service.parent.stem == tag: return service.parent @@ -72,19 +95,38 @@ class Services(click.MultiCommand): """ original_value = value value = value.lower() + + # Check local services for path in _SERVICES: tag = path.parent.stem if value in (tag.lower(), *_ALIASES.get(tag, [])): return tag + + # Check remote services + remote_services = Services._get_remote_services() + for tag, service_class in remote_services.items(): + if value == tag.lower(): + return tag + if hasattr(service_class, "ALIASES"): + if value in (alias.lower() for alias in service_class.ALIASES): + return tag + return original_value @staticmethod def load(tag: str) -> Service: - """Load a Service module by Service tag.""" + """Load a Service module by Service tag (local or remote).""" + # Check local services first module = _MODULES.get(tag) - if not module: - raise KeyError(f"There is no Service added by the Tag '{tag}'") - return module + if module: + return module + + # Check remote services + remote_services = Services._get_remote_services() + if tag in remote_services: + return remote_services[tag] + + raise KeyError(f"There is no Service added by the Tag '{tag}'") __all__ = ("Services",) diff --git a/unshackle/core/utilities.py b/unshackle/core/utilities.py index 5aaf6f0..69322e5 100644 --- a/unshackle/core/utilities.py +++ b/unshackle/core/utilities.py @@ -19,6 +19,7 @@ from urllib.parse import ParseResult, urlparse from uuid import uuid4 import chardet +import pycountry import requests from construct import ValidationError from fontTools import ttLib @@ -272,6 +273,80 @@ def ap_case(text: str, keep_spaces: bool = False, stop_words: tuple[str] = None) ) +# Common country code aliases that differ from ISO 3166-1 alpha-2 +COUNTRY_CODE_ALIASES = { + "uk": "gb", # United Kingdom -> Great Britain +} + + +def get_country_name(code: str) -> Optional[str]: + """ + Convert a 2-letter country code to full country name. + + Args: + code: ISO 3166-1 alpha-2 country code (e.g., 'ca', 'us', 'gb', 'uk') + + Returns: + Full country name (e.g., 'Canada', 'United States', 'United Kingdom') or None if not found + + Examples: + >>> get_country_name('ca') + 'Canada' + >>> get_country_name('US') + 'United States' + >>> get_country_name('uk') + 'United Kingdom' + """ + # Handle common aliases + code = COUNTRY_CODE_ALIASES.get(code.lower(), code.lower()) + + try: + country = pycountry.countries.get(alpha_2=code.upper()) + if country: + return country.name + except (KeyError, LookupError): + pass + return None + + +def get_country_code(name: str) -> Optional[str]: + """ + Convert a country name to its 2-letter ISO 3166-1 alpha-2 code. + + Args: + name: Full country name (e.g., 'Canada', 'United States', 'United Kingdom') + + Returns: + 2-letter country code in uppercase (e.g., 'CA', 'US', 'GB') or None if not found + + Examples: + >>> get_country_code('Canada') + 'CA' + >>> get_country_code('united states') + 'US' + >>> get_country_code('United Kingdom') + 'GB' + """ + try: + # Try exact name match first + country = pycountry.countries.get(name=name.title()) + if country: + return country.alpha_2.upper() + + # Try common name (e.g., "Bolivia" vs "Bolivia, Plurinational State of") + country = pycountry.countries.get(common_name=name.title()) + if country: + return country.alpha_2.upper() + + # Try fuzzy search as fallback + results = pycountry.countries.search_fuzzy(name) + if results: + return results[0].alpha_2.upper() + except (KeyError, LookupError): + pass + return None + + def get_ip_info(session: Optional[requests.Session] = None) -> dict: """ Use ipinfo.io to get IP location information. diff --git a/unshackle/unshackle-example.yaml b/unshackle/unshackle-example.yaml index 36e7c2c..0e25aa6 100644 --- a/unshackle/unshackle-example.yaml +++ b/unshackle/unshackle-example.yaml @@ -408,6 +408,19 @@ services: app_name: "AIV" device_model: "Fire TV Stick 4K" + # Service-specific proxy mappings + # Override global proxy selection with specific servers for this service + # When --proxy matches a key in proxy_map, the mapped server will be used + # instead of the default/random server selection + proxy_map: + nordvpn:ca: ca1577 # Use ca1577 when --proxy nordvpn:ca is specified + nordvpn:us: us9842 # Use us9842 when --proxy nordvpn:us is specified + us: 123 # Use server 123 (from any provider) when --proxy us is specified + gb: 456 # Use server 456 (from any provider) when --proxy gb is specified + # Without this service, --proxy nordvpn:ca picks a random CA server + # With this config, --proxy nordvpn:ca EXAMPLE uses ca1577 specifically + # Other services or no service specified will still use random selection + # NEW: Configuration overrides (can be combined with profiles and certificates) # Override dl command defaults for this service dl: @@ -478,8 +491,15 @@ proxy_providers: nordvpn: username: username_from_service_credentials password: password_from_service_credentials + # server_map: global mapping that applies to ALL services + # Difference from service-specific proxy_map: + # - server_map: applies to ALL services when --proxy nordvpn:us is used + # - proxy_map: only applies to the specific service configured (see services: EXAMPLE: proxy_map above) + # - proxy_map takes precedence over server_map for that service server_map: us: 12 # force US server #12 for US proxies + ca:calgary: 2534 # force CA server #2534 for Calgary proxies + us:seattle: 7890 # force US server #7890 for Seattle proxies surfsharkvpn: username: your_surfshark_service_username # Service credentials from https://my.surfshark.com/vpn/manual-setup/main/openvpn password: your_surfshark_service_password # Service credentials (not your login password) @@ -487,12 +507,81 @@ proxy_providers: us: 3844 # force US server #3844 for US proxies gb: 2697 # force GB server #2697 for GB proxies au: 4621 # force AU server #4621 for AU proxies + us:seattle: 5678 # force US server #5678 for Seattle proxies + ca:toronto: 1234 # force CA server #1234 for Toronto proxies windscribevpn: username: your_windscribe_username # Service credentials from https://windscribe.com/getconfig/openvpn password: your_windscribe_password # Service credentials (not your login password) server_map: us: "us-central-096.totallyacdn.com" # force US server gb: "uk-london-055.totallyacdn.com" # force GB server + us:seattle: "us-west-011.totallyacdn.com" # force US Seattle server + ca:toronto: "ca-toronto-012.totallyacdn.com" # force CA Toronto server + + # Gluetun: Dynamic Docker-based VPN proxy (supports 50+ VPN providers) + # Creates Docker containers running Gluetun to bridge VPN connections to HTTP proxies + # Requires Docker to be installed and running + # Usage: --proxy gluetun:windscribe:us or --proxy gluetun:nordvpn:de + gluetun: + # Global settings + base_port: 8888 # Starting port for HTTP proxies (increments for each container) + auto_cleanup: true # Automatically remove containers when done + container_prefix: "unshackle-gluetun" # Docker container name prefix + verify_ip: true # Verify VPN IP matches expected region + # Optional HTTP proxy authentication (for the proxy itself, not VPN) + # auth_user: proxy_user + # auth_password: proxy_password + + # VPN provider configurations + providers: + # Windscribe (WireGuard) - Get credentials from https://windscribe.com/getconfig/wireguard + windscribe: + vpn_type: wireguard + credentials: + private_key: "YOUR_WIREGUARD_PRIVATE_KEY" + addresses: "YOUR_WIREGUARD_ADDRESS" # e.g., "10.x.x.x/32" + # Map friendly names to country codes + server_countries: + us: US + uk: GB + ca: CA + de: DE + + # NordVPN (OpenVPN) - Get service credentials from https://my.nordaccount.com/dashboard/nordvpn/manual-configuration/ + # Note: Service credentials are NOT your email+password - generate them from the link above + # nordvpn: + # vpn_type: openvpn + # credentials: + # username: "YOUR_NORDVPN_SERVICE_USERNAME" + # password: "YOUR_NORDVPN_SERVICE_PASSWORD" + # server_countries: + # us: US + # uk: GB + + # ExpressVPN (OpenVPN) - Get credentials from ExpressVPN setup page + # expressvpn: + # vpn_type: openvpn + # credentials: + # username: "YOUR_EXPRESSVPN_USERNAME" + # password: "YOUR_EXPRESSVPN_PASSWORD" + # server_countries: + # us: US + # uk: GB + + # Surfshark (WireGuard) - Get credentials from https://my.surfshark.com/vpn/manual-setup/main/wireguard + # surfshark: + # vpn_type: wireguard + # credentials: + # private_key: "YOUR_SURFSHARK_PRIVATE_KEY" + # addresses: "YOUR_SURFSHARK_ADDRESS" + # server_countries: + # us: US + # uk: GB + + # Specific server selection: Use format like "us1239" to select specific servers + # Example: --proxy gluetun:nordvpn:us1239 connects to us1239.nordvpn.com + # Supported providers: nordvpn, surfshark, expressvpn, cyberghost + basic: GB: - "socks5://username:password@bhx.socks.ipvanish.com:1080" # 1 (Birmingham) diff --git a/uv.lock b/uv.lock index c426e06..b1fa8a6 100644 --- a/uv.lock +++ b/uv.lock @@ -1070,6 +1070,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/c7/d13c57e5a3408df2e5d910853e957ec8e253b41ba531e0f32036c8321240/pycaption-2.2.19-py3-none-any.whl", hash = "sha256:7eb84a05d40bb80400689f9431d05d8b77dec6535938b419ebed2c9d67283a4f", size = 124970, upload-time = "2025-09-30T07:15:21.945Z" }, ] +[[package]] +name = "pycountry" +version = "24.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/57/c389fa68c50590881a75b7883eeb3dc15e9e73a0fdc001cdd45c13290c92/pycountry-24.6.1.tar.gz", hash = "sha256:b61b3faccea67f87d10c1f2b0fc0be714409e8fcdcc1315613174f6466c10221", size = 6043910, upload-time = "2024-06-01T04:12:15.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/ec/1fb891d8a2660716aadb2143235481d15ed1cbfe3ad669194690b0604492/pycountry-24.6.1-py3-none-any.whl", hash = "sha256:f1a4fb391cd7214f8eefd39556d740adcc233c778a27f8942c8dca351d6ce06f", size = 6335189, upload-time = "2024-06-01T04:11:49.711Z" }, +] + [[package]] name = "pycparser" version = "2.22" @@ -1585,6 +1594,7 @@ dependencies = [ { name = "pproxy" }, { name = "protobuf" }, { name = "pycaption" }, + { name = "pycountry" }, { name = "pycryptodomex" }, { name = "pyexecjs" }, { name = "pyjwt" }, @@ -1638,6 +1648,7 @@ requires-dist = [ { name = "pproxy", specifier = ">=2.7.9,<3" }, { name = "protobuf", specifier = ">=4.25.3,<7" }, { name = "pycaption", specifier = ">=2.2.6,<3" }, + { name = "pycountry", specifier = ">=24.6.1" }, { name = "pycryptodomex", specifier = ">=3.20.0,<4" }, { name = "pyexecjs", specifier = ">=1.5.1,<2" }, { name = "pyjwt", specifier = ">=2.8.0,<3" }, From 8aec80246a1af23e79919a7c217567956dc484af Mon Sep 17 00:00:00 2001 From: MasterOfKay <144215097+MasterOfKay@users.noreply.github.com> Date: Fri, 28 Nov 2025 16:27:26 +0100 Subject: [PATCH 02/38] added config.py changes --- unshackle/core/config.py | 1 + unshackle/core/titles/episode.py | 32 +++++++++++++++++++++++++------- unshackle/core/titles/movie.py | 2 ++ 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/unshackle/core/config.py b/unshackle/core/config.py index 6eb7b26..26c6400 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -94,6 +94,7 @@ class Config: self.update_checks: bool = kwargs.get("update_checks", True) self.update_check_interval: int = kwargs.get("update_check_interval", 24) self.scene_naming: bool = kwargs.get("scene_naming", True) + self.dash_naming: bool = kwargs.get("dash_naming", False) self.series_year: bool = kwargs.get("series_year", True) self.title_cache_time: int = kwargs.get("title_cache_time", 1800) # 30 minutes default diff --git a/unshackle/core/titles/episode.py b/unshackle/core/titles/episode.py index 6592b60..549d54f 100644 --- a/unshackle/core/titles/episode.py +++ b/unshackle/core/titles/episode.py @@ -109,13 +109,31 @@ class Episode(Title): name += f" {self.year}" name += f" S{self.season:02}" else: - name = "{title}{year} S{season:02}E{number:02} {name}".format( - title=self.title.replace("$", "S"), # e.g., Arli$$ - year=f" {self.year}" if self.year and config.series_year else "", - season=self.season, - number=self.number, - name=self.name or "", - ).strip() + if config.dash_naming: + # Format: Title - SXXEXX - Episode Name + name = self.title.replace("$", "S") # e.g., Arli$$ + + # Add year if configured + if self.year and config.series_year: + name += f" {self.year}" + + # Add season and episode + name += f" - S{self.season:02}E{self.number:02}" + + # Add episode name with dash separator + if self.name: + name += f" - {self.name}" + + name = name.strip() + else: + # Standard format without extra dashes + name = "{title}{year} S{season:02}E{number:02} {name}".format( + title=self.title.replace("$", "S"), # e.g., Arli$$ + year=f" {self.year}" if self.year and config.series_year else "", + season=self.season, + number=self.number, + name=self.name or "", + ).strip() if config.scene_naming: # Resolution diff --git a/unshackle/core/titles/movie.py b/unshackle/core/titles/movie.py index 1545b18..457e233 100644 --- a/unshackle/core/titles/movie.py +++ b/unshackle/core/titles/movie.py @@ -47,6 +47,8 @@ class Movie(Title): def __str__(self) -> str: if self.year: + if config.dash_naming: + return f"{self.name} - {self.year}" return f"{self.name} ({self.year})" return self.name From 197fe76f7a887371dd42689393280f00bbca0cd9 Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Wed, 31 Dec 2025 19:27:10 +0900 Subject: [PATCH 03/38] Replace ffmpeg string with FFMPEG variable --- unshackle/core/tracks/hybrid.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/unshackle/core/tracks/hybrid.py b/unshackle/core/tracks/hybrid.py index f6631fb..7e7936b 100644 --- a/unshackle/core/tracks/hybrid.py +++ b/unshackle/core/tracks/hybrid.py @@ -8,7 +8,7 @@ from pathlib import Path from rich.padding import Padding from rich.rule import Rule -from unshackle.core.binaries import DoviTool, HDR10PlusTool +from unshackle.core.binaries import FFMPEG, DoviTool, HDR10PlusTool from unshackle.core.config import config from unshackle.core.console import console @@ -109,7 +109,7 @@ class Hybrid: """Simple ffmpeg execution without progress tracking""" p = subprocess.run( [ - "ffmpeg", + str(FFMPEG), "-nostdin", "-i", str(save_path), From 450cde7c80707c6a6842f7a8e63bf373ac233ebf Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Wed, 31 Dec 2025 19:27:39 +0900 Subject: [PATCH 04/38] Troubleshooting some string overlap --- unshackle/core/downloaders/n_m3u8dl_re.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/unshackle/core/downloaders/n_m3u8dl_re.py b/unshackle/core/downloaders/n_m3u8dl_re.py index 7472c59..a929e05 100644 --- a/unshackle/core/downloaders/n_m3u8dl_re.py +++ b/unshackle/core/downloaders/n_m3u8dl_re.py @@ -18,7 +18,7 @@ PERCENT_RE = re.compile(r"(\d+\.\d+%)") SPEED_RE = re.compile(r"(\d+\.\d+(?:MB|KB)ps)") SIZE_RE = re.compile(r"(\d+\.\d+(?:MB|GB|KB)/\d+\.\d+(?:MB|GB|KB))") WARN_RE = re.compile(r"(WARN : Response.*|WARN : One or more errors occurred.*)") -ERROR_RE = re.compile(r"(ERROR.*)") +ERROR_RE = re.compile(r"(ERROR.*|error.*|Error.*|FAILED.*|Failed.*|Exception.*)") DECRYPTION_ENGINE = { "shaka": "SHAKA_PACKAGER", @@ -67,7 +67,7 @@ def get_track_selection_args(track: Any) -> list[str]: if track_type == "Audio": if track_id := representation.get("id") or adaptation_set.get("audioTrackId"): - parts.append(rf'"id=\b{track_id}\b"') + parts.append(rf"id={track_id}") else: if codecs := representation.get("codecs"): parts.append(f"codecs={codecs}") @@ -83,7 +83,7 @@ def get_track_selection_args(track: Any) -> list[str]: if track_type == "Video": if track_id := representation.get("id"): - parts.append(rf'"id=\b{track_id}\b"') + parts.append(rf"id={track_id}") else: if width := representation.get("width"): parts.append(f"res={width}*") @@ -96,7 +96,7 @@ def get_track_selection_args(track: Any) -> list[str]: if track_type == "Subtitle": if track_id := representation.get("id"): - parts.append(rf'"id=\b{track_id}\b"') + parts.append(rf"id={track_id}") else: if lang := representation.get("lang"): parts.append(f"lang={lang}") @@ -109,7 +109,7 @@ def get_track_selection_args(track: Any) -> list[str]: if track_type == "Audio": if name := stream_index.get("Name") or quality_level.get("Index"): - parts.append(rf'"id=\b{name}\b"') + parts.append(rf"id={name}") else: if codecs := quality_level.get("FourCC"): parts.append(f"codecs={codecs}") @@ -122,7 +122,7 @@ def get_track_selection_args(track: Any) -> list[str]: if track_type == "Video": if name := stream_index.get("Name") or quality_level.get("Index"): - parts.append(rf'"id=\b{name}\b"') + parts.append(rf"id={name}") else: if width := quality_level.get("MaxWidth"): parts.append(f"res={width}*") @@ -136,7 +136,7 @@ def get_track_selection_args(track: Any) -> list[str]: # I've yet to encounter a subtitle track in ISM manifests, so this is mostly theoretical. if track_type == "Subtitle": if name := stream_index.get("Name") or quality_level.get("Index"): - parts.append(rf'"id=\b{name}\b"') + parts.append(rf"id={name}") else: if lang := stream_index.get("Language"): parts.append(f"lang={lang}") From 9cd67568d2cd6dc28143716860de00b02d5a0ca9 Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Thu, 22 Jan 2026 02:34:34 +0900 Subject: [PATCH 05/38] Fix --- unshackle/core/downloaders/n_m3u8dl_re.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/unshackle/core/downloaders/n_m3u8dl_re.py b/unshackle/core/downloaders/n_m3u8dl_re.py index ddb8138..84e8f5a 100644 --- a/unshackle/core/downloaders/n_m3u8dl_re.py +++ b/unshackle/core/downloaders/n_m3u8dl_re.py @@ -90,7 +90,7 @@ def get_track_selection_args(track: Any) -> list[str]: if track_type == "Video": if track_id := representation.get("id"): - parts.append(rf"id={track_id}") + parts.append(rf'"id=\b{track_id}\b"') else: if width := representation.get("width"): parts.append(f"res={width}*") @@ -103,7 +103,7 @@ def get_track_selection_args(track: Any) -> list[str]: if track_type == "Subtitle": if track_id := representation.get("id"): - parts.append(rf"id={track_id}") + parts.append(rf'"id=\b{track_id}\b"') else: if lang := representation.get("lang"): parts.append(f"lang={lang}") @@ -116,7 +116,7 @@ def get_track_selection_args(track: Any) -> list[str]: if track_type == "Audio": if name := stream_index.get("Name") or quality_level.get("Index"): - parts.append(rf"id={name}") + parts.append(rf'"id=\b{name}\b"') else: if codecs := quality_level.get("FourCC"): parts.append(f"codecs={codecs}") @@ -129,7 +129,7 @@ def get_track_selection_args(track: Any) -> list[str]: if track_type == "Video": if name := stream_index.get("Name") or quality_level.get("Index"): - parts.append(rf"id={name}") + parts.append(rf'"id=\b{name}\b"') else: if width := quality_level.get("MaxWidth"): parts.append(f"res={width}*") @@ -143,7 +143,7 @@ def get_track_selection_args(track: Any) -> list[str]: # I've yet to encounter a subtitle track in ISM manifests, so this is mostly theoretical. if track_type == "Subtitle": if name := stream_index.get("Name") or quality_level.get("Index"): - parts.append(rf"id={name}") + parts.append(rf'"id=\b{name}\b"') else: if lang := stream_index.get("Language"): parts.append(f"lang={lang}") @@ -374,16 +374,12 @@ def download( continue last_line = output - if ERROR_RE.search(output): - console.log(f"[N_m3u8DL-RE]: {output}") - if warn_match := WARN_RE.search(output): console.log(f"{track_type} {warn_match.group(1)}") continue if speed_match := SPEED_RE.search(output): - size_match = SIZE_RE.search(output) - size = size_match.group(1) if size_match else "" + size = size_match.group(1) if (size_match := SIZE_RE.search(output)) else "" yield {"downloaded": f"{speed_match.group(1)} {size}"} if percent_match := PERCENT_RE.search(output): @@ -522,4 +518,4 @@ def n_m3u8dl_re( ) -__all__ = ("n_m3u8dl_re",) \ No newline at end of file +__all__ = ("n_m3u8dl_re",) From b384139b418825970d25006d7d78ee0d16f3eafd Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Thu, 22 Jan 2026 02:41:21 +0900 Subject: [PATCH 06/38] Fix 2 --- unshackle/core/downloaders/n_m3u8dl_re.py | 1 - 1 file changed, 1 deletion(-) diff --git a/unshackle/core/downloaders/n_m3u8dl_re.py b/unshackle/core/downloaders/n_m3u8dl_re.py index 84e8f5a..9512692 100644 --- a/unshackle/core/downloaders/n_m3u8dl_re.py +++ b/unshackle/core/downloaders/n_m3u8dl_re.py @@ -183,7 +183,6 @@ def build_download_args( "--thread-count": thread_count, "--download-retry-count": retry_count, "--write-meta-json": False, - "--no-log": True, } if FFMPEG: args["--ffmpeg-binary-path"] = str(FFMPEG) From f3cc1d080e214a696ae64b04488de2a09c9cdd7d Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Thu, 22 Jan 2026 15:27:57 +0900 Subject: [PATCH 07/38] Fix Hybrid --- unshackle/core/tracks/hybrid.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unshackle/core/tracks/hybrid.py b/unshackle/core/tracks/hybrid.py index 7e7936b..316db97 100644 --- a/unshackle/core/tracks/hybrid.py +++ b/unshackle/core/tracks/hybrid.py @@ -109,7 +109,7 @@ class Hybrid: """Simple ffmpeg execution without progress tracking""" p = subprocess.run( [ - str(FFMPEG), + str(FFMPEG) if FFMPEG else "ffmpeg", "-nostdin", "-i", str(save_path), From 3c049a1fc02c786e0a02ef70700e3e4d7c7fd624 Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Thu, 22 Jan 2026 15:28:24 +0900 Subject: [PATCH 08/38] Fix n_m3u8dl_re --- unshackle/core/downloaders/n_m3u8dl_re.py | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/unshackle/core/downloaders/n_m3u8dl_re.py b/unshackle/core/downloaders/n_m3u8dl_re.py index 9512692..976b72f 100644 --- a/unshackle/core/downloaders/n_m3u8dl_re.py +++ b/unshackle/core/downloaders/n_m3u8dl_re.py @@ -20,7 +20,7 @@ PERCENT_RE = re.compile(r"(\d+\.\d+%)") SPEED_RE = re.compile(r"(\d+\.\d+(?:MB|KB)ps)") SIZE_RE = re.compile(r"(\d+\.\d+(?:MB|GB|KB)/\d+\.\d+(?:MB|GB|KB))") WARN_RE = re.compile(r"(WARN : Response.*|WARN : One or more errors occurred.*)") -ERROR_RE = re.compile(r"(ERROR.*|error.*|Error.*|FAILED.*|Failed.*|Exception.*)") +ERROR_RE = re.compile(r"(\bERROR\b.*|\bFAILED\b.*|\bException\b.*)") DECRYPTION_ENGINE = { "shaka": "SHAKA_PACKAGER", @@ -275,18 +275,6 @@ def download( if not binaries.N_m3u8DL_RE: raise EnvironmentError("N_m3u8DL-RE executable not found...") - - decryption_engine = config.decryption.lower() - binary_path = None - - if content_keys: - if decryption_engine == "shaka": - binary_path = binaries.ShakaPackager - elif decryption_engine == "mp4decrypt": - binary_path = binaries.Mp4decrypt - - if binary_path: - binary_path = Path(binary_path) effective_max_workers = max_workers or min(32, (os.cpu_count() or 1) + 4) @@ -349,12 +337,6 @@ def download( yield {"total": 100} yield {"downloaded": "Parsing streams..."} - env = os.environ.copy() - - if binary_path and binary_path.exists(): - binary_dir = str(binary_path.parent) - env["PATH"] = binary_dir + os.pathsep + env["PATH"] - try: with subprocess.Popen( [binaries.N_m3u8DL_RE, *arguments], @@ -362,7 +344,6 @@ def download( stderr=subprocess.STDOUT, text=True, encoding="utf-8", - env=env, # Assign to virtual environment variables ) as process: last_line = "" track_type = track.__class__.__name__ @@ -386,7 +367,7 @@ def download( yield {"completed": progress} if progress < 100 else {"downloaded": "Merging"} process.wait() - + if process.returncode != 0: if debug_logger and log_file_path: log_contents = "" From b8e2f3da3f7b18644ff9206b26cce039b59a580c Mon Sep 17 00:00:00 2001 From: Andy Date: Thu, 22 Jan 2026 20:21:00 -0700 Subject: [PATCH 09/38] feat(titles): use track source attribute for service name in filenames Allow services to set a custom `source` attribute on tracks, which will be used in the filename instead of the service class name. --- unshackle/core/titles/episode.py | 9 +++++++-- unshackle/core/titles/movie.py | 9 +++++++-- unshackle/core/titles/song.py | 9 +++++++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/unshackle/core/titles/episode.py b/unshackle/core/titles/episode.py index f578342..3522e65 100644 --- a/unshackle/core/titles/episode.py +++ b/unshackle/core/titles/episode.py @@ -155,9 +155,14 @@ class Episode(Title): resolution = int(primary_video_track.width * (9 / 16)) name += f" {resolution}p" - # Service + # Service (use track source if available) if show_service: - name += f" {self.service.__name__}" + source_name = None + if self.tracks: + first_track = next(iter(self.tracks), None) + if first_track and hasattr(first_track, "source") and first_track.source: + source_name = first_track.source + name += f" {source_name or self.service.__name__}" # 'WEB-DL' name += " WEB-DL" diff --git a/unshackle/core/titles/movie.py b/unshackle/core/titles/movie.py index fb1b22d..4e1d02c 100644 --- a/unshackle/core/titles/movie.py +++ b/unshackle/core/titles/movie.py @@ -90,9 +90,14 @@ class Movie(Title): resolution = int(primary_video_track.width * (9 / 16)) name += f" {resolution}p" - # Service + # Service (use track source if available) if show_service: - name += f" {self.service.__name__}" + source_name = None + if self.tracks: + first_track = next(iter(self.tracks), None) + if first_track and hasattr(first_track, "source") and first_track.source: + source_name = first_track.source + name += f" {source_name or self.service.__name__}" # 'WEB-DL' name += " WEB-DL" diff --git a/unshackle/core/titles/song.py b/unshackle/core/titles/song.py index 2e0a818..303b336 100644 --- a/unshackle/core/titles/song.py +++ b/unshackle/core/titles/song.py @@ -101,9 +101,14 @@ class Song(Title): name = str(self).split(" / ")[1] if config.scene_naming: - # Service + # Service (use track source if available) if show_service: - name += f" {self.service.__name__}" + source_name = None + if self.tracks: + first_track = next(iter(self.tracks), None) + if first_track and hasattr(first_track, "source") and first_track.source: + source_name = first_track.source + name += f" {source_name or self.service.__name__}" # 'WEB-DL' name += " WEB-DL" From 6b90a19632e54abd137269b66b0dcdbe59539be6 Mon Sep 17 00:00:00 2001 From: Andy Date: Fri, 23 Jan 2026 17:14:12 -0700 Subject: [PATCH 10/38] perf(aria2c): improve download performance with singleton manager - Use singleton _Aria2Manager to reuse one aria2c process via RPC - Add downloads via aria2.addUri instead of stdin input file - Track per-GID byte-level progress (completedLength/totalLength) - Add thread-safe operations with threading.Lock - Enable graceful cancellation by removing individual downloads via RPC --- unshackle/core/downloaders/aria2c.py | 459 +++++++++++++++------------ 1 file changed, 261 insertions(+), 198 deletions(-) diff --git a/unshackle/core/downloaders/aria2c.py b/unshackle/core/downloaders/aria2c.py index 6f5b5d0..af7d34f 100644 --- a/unshackle/core/downloaders/aria2c.py +++ b/unshackle/core/downloaders/aria2c.py @@ -1,6 +1,7 @@ import os import subprocess import textwrap +import threading import time from functools import partial from http.cookiejar import CookieJar @@ -49,6 +50,138 @@ def rpc(caller: Callable, secret: str, method: str, params: Optional[list[Any]] return +class _Aria2Manager: + """Singleton manager to run one aria2c process and enqueue downloads via RPC.""" + + def __init__(self) -> None: + self._proc: Optional[subprocess.Popen] = None + self._rpc_port: Optional[int] = None + self._rpc_secret: Optional[str] = None + self._rpc_uri: Optional[str] = None + self._session: Session = Session() + self._max_concurrent_downloads: int = 0 + self._max_connection_per_server: int = 1 + self._split_default: int = 5 + self._file_allocation: str = "prealloc" + self._proxy: Optional[str] = None + self._lock: threading.Lock = threading.Lock() + + def _build_args(self) -> list[str]: + args = [ + "--continue=true", + f"--max-concurrent-downloads={self._max_concurrent_downloads}", + f"--max-connection-per-server={self._max_connection_per_server}", + f"--split={self._split_default}", + "--max-file-not-found=5", + "--max-tries=5", + "--retry-wait=2", + "--allow-overwrite=true", + "--auto-file-renaming=false", + "--console-log-level=warn", + "--download-result=default", + f"--file-allocation={self._file_allocation}", + "--summary-interval=0", + "--enable-rpc=true", + f"--rpc-listen-port={self._rpc_port}", + f"--rpc-secret={self._rpc_secret}", + ] + if self._proxy: + args.extend(["--all-proxy", self._proxy]) + return args + + def ensure_started( + self, + proxy: Optional[str], + max_workers: Optional[int], + ) -> None: + with self._lock: + if self._proc and self._proc.poll() is None: + return + + if not binaries.Aria2: + debug_logger = get_debug_logger() + if debug_logger: + debug_logger.log( + level="ERROR", + operation="downloader_aria2c_binary_missing", + message="Aria2c executable not found in PATH or local binaries directory", + context={"searched_names": ["aria2c", "aria2"]}, + ) + raise EnvironmentError("Aria2c executable not found...") + + if not max_workers: + max_workers = min(32, (os.cpu_count() or 1) + 4) + elif not isinstance(max_workers, int): + raise TypeError(f"Expected max_workers to be {int}, not {type(max_workers)}") + + self._rpc_port = get_free_port() + self._rpc_secret = get_random_bytes(16).hex() + self._rpc_uri = f"http://127.0.0.1:{self._rpc_port}/jsonrpc" + + self._max_concurrent_downloads = int(config.aria2c.get("max_concurrent_downloads", max_workers)) + self._max_connection_per_server = int(config.aria2c.get("max_connection_per_server", 1)) + self._split_default = int(config.aria2c.get("split", 5)) + self._file_allocation = config.aria2c.get("file_allocation", "prealloc") + self._proxy = proxy or None + + args = self._build_args() + self._proc = subprocess.Popen( + [binaries.Aria2, *args], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + # Give aria2c a moment to start up and bind to the RPC port + time.sleep(0.5) + + @property + def rpc_uri(self) -> str: + assert self._rpc_uri + return self._rpc_uri + + @property + def rpc_secret(self) -> str: + assert self._rpc_secret + return self._rpc_secret + + @property + def session(self) -> Session: + return self._session + + def add_uris(self, uris: list[str], options: dict[str, Any]) -> str: + """Add a single download with multiple URIs via RPC.""" + gid = rpc( + caller=partial(self._session.post, url=self.rpc_uri), + secret=self.rpc_secret, + method="aria2.addUri", + params=[uris, options], + ) + return gid or "" + + def get_global_stat(self) -> dict[str, Any]: + return rpc( + caller=partial(self.session.post, url=self.rpc_uri), + secret=self.rpc_secret, + method="aria2.getGlobalStat", + ) or {} + + def tell_status(self, gid: str) -> Optional[dict[str, Any]]: + return rpc( + caller=partial(self.session.post, url=self.rpc_uri), + secret=self.rpc_secret, + method="aria2.tellStatus", + params=[gid, ["status", "errorCode", "errorMessage", "files", "completedLength", "totalLength"]], + ) + + def remove(self, gid: str) -> None: + rpc( + caller=partial(self.session.post, url=self.rpc_uri), + secret=self.rpc_secret, + method="aria2.forceRemove", + params=[gid], + ) + + +_manager = _Aria2Manager() + + def download( urls: Union[str, list[str], dict[str, Any], list[dict[str, Any]]], output_dir: Path, @@ -58,6 +191,7 @@ def download( proxy: Optional[str] = None, max_workers: Optional[int] = None, ) -> Generator[dict[str, Any], None, None]: + """Enqueue downloads to the singleton aria2c instance via stdin and track per-call progress via RPC.""" debug_logger = get_debug_logger() if not urls: @@ -92,102 +226,10 @@ def download( if not isinstance(urls, list): urls = [urls] - if not binaries.Aria2: - if debug_logger: - debug_logger.log( - level="ERROR", - operation="downloader_aria2c_binary_missing", - message="Aria2c executable not found in PATH or local binaries directory", - context={"searched_names": ["aria2c", "aria2"]}, - ) - raise EnvironmentError("Aria2c executable not found...") - - if proxy and not proxy.lower().startswith("http://"): - raise ValueError("Only HTTP proxies are supported by aria2(c)") - if cookies and not isinstance(cookies, CookieJar): cookies = cookiejar_from_dict(cookies) - url_files = [] - for i, url in enumerate(urls): - if isinstance(url, str): - url_data = {"url": url} - else: - url_data: dict[str, Any] = url - url_filename = filename.format(i=i, ext=get_extension(url_data["url"])) - url_text = url_data["url"] - url_text += f"\n\tdir={output_dir}" - url_text += f"\n\tout={url_filename}" - if cookies: - mock_request = requests.Request(url=url_data["url"]) - cookie_header = get_cookie_header(cookies, mock_request) - if cookie_header: - url_text += f"\n\theader=Cookie: {cookie_header}" - for key, value in url_data.items(): - if key == "url": - continue - if key == "headers": - for header_name, header_value in value.items(): - url_text += f"\n\theader={header_name}: {header_value}" - else: - url_text += f"\n\t{key}={value}" - url_files.append(url_text) - url_file = "\n".join(url_files) - - rpc_port = get_free_port() - rpc_secret = get_random_bytes(16).hex() - rpc_uri = f"http://127.0.0.1:{rpc_port}/jsonrpc" - rpc_session = Session() - - max_concurrent_downloads = int(config.aria2c.get("max_concurrent_downloads", max_workers)) - max_connection_per_server = int(config.aria2c.get("max_connection_per_server", 1)) - split = int(config.aria2c.get("split", 5)) - file_allocation = config.aria2c.get("file_allocation", "prealloc") - if len(urls) > 1: - split = 1 - file_allocation = "none" - - arguments = [ - # [Basic Options] - "--input-file", - "-", - "--all-proxy", - proxy or "", - "--continue=true", - # [Connection Options] - f"--max-concurrent-downloads={max_concurrent_downloads}", - f"--max-connection-per-server={max_connection_per_server}", - f"--split={split}", # each split uses their own connection - "--max-file-not-found=5", # counted towards --max-tries - "--max-tries=5", - "--retry-wait=2", - # [Advanced Options] - "--allow-overwrite=true", - "--auto-file-renaming=false", - "--console-log-level=warn", - "--download-result=default", - f"--file-allocation={file_allocation}", - "--summary-interval=0", - # [RPC Options] - "--enable-rpc=true", - f"--rpc-listen-port={rpc_port}", - f"--rpc-secret={rpc_secret}", - ] - - for header, value in (headers or {}).items(): - if header.lower() == "cookie": - raise ValueError("You cannot set Cookies as a header manually, please use the `cookies` param.") - if header.lower() == "accept-encoding": - # we cannot set an allowed encoding, or it will return compressed - # and the code is not set up to uncompress the data - continue - if header.lower() == "referer": - arguments.extend(["--referer", value]) - continue - if header.lower() == "user-agent": - arguments.extend(["--user-agent", value]) - continue - arguments.extend(["--header", f"{header}: {value}"]) + _manager.ensure_started(proxy=proxy, max_workers=max_workers) if debug_logger: first_url = urls[0] if isinstance(urls[0], str) else urls[0].get("url", "") @@ -202,128 +244,151 @@ def download( "first_url": url_display, "output_dir": str(output_dir), "filename": filename, - "max_concurrent_downloads": max_concurrent_downloads, - "max_connection_per_server": max_connection_per_server, - "split": split, - "file_allocation": file_allocation, "has_proxy": bool(proxy), - "rpc_port": rpc_port, }, ) - yield dict(total=len(urls)) + # Build options for each URI and add via RPC + gids: list[str] = [] + + for i, url in enumerate(urls): + if isinstance(url, str): + url_data = {"url": url} + else: + url_data: dict[str, Any] = url + + url_filename = filename.format(i=i, ext=get_extension(url_data["url"])) + + opts: dict[str, Any] = { + "dir": str(output_dir), + "out": url_filename, + "split": str(1 if len(urls) > 1 else int(config.aria2c.get("split", 5))), + } + + # Cookies as header + if cookies: + mock_request = requests.Request(url=url_data["url"]) + cookie_header = get_cookie_header(cookies, mock_request) + if cookie_header: + opts.setdefault("header", []).append(f"Cookie: {cookie_header}") + + # Global headers + for header, value in (headers or {}).items(): + if header.lower() == "cookie": + raise ValueError("You cannot set Cookies as a header manually, please use the `cookies` param.") + if header.lower() == "accept-encoding": + continue + if header.lower() == "referer": + opts["referer"] = str(value) + continue + if header.lower() == "user-agent": + opts["user-agent"] = str(value) + continue + opts.setdefault("header", []).append(f"{header}: {value}") + + # Per-url extra args + for key, value in url_data.items(): + if key == "url": + continue + if key == "headers": + for header_name, header_value in value.items(): + opts.setdefault("header", []).append(f"{header_name}: {header_value}") + else: + opts[key] = str(value) + + # Add via RPC + gid = _manager.add_uris([url_data["url"]], opts) + if gid: + gids.append(gid) + + yield dict(total=len(gids)) + + completed: set[str] = set() try: - p = subprocess.Popen([binaries.Aria2, *arguments], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL) + while len(completed) < len(gids): + if DOWNLOAD_CANCELLED.is_set(): + # Remove tracked downloads on cancel + for gid in gids: + if gid not in completed: + _manager.remove(gid) + yield dict(downloaded="[yellow]CANCELLED") + raise KeyboardInterrupt() - p.stdin.write(url_file.encode()) - p.stdin.close() + stats = _manager.get_global_stat() + dl_speed = int(stats.get("downloadSpeed", -1)) - while p.poll() is None: - global_stats: dict[str, Any] = ( - rpc(caller=partial(rpc_session.post, url=rpc_uri), secret=rpc_secret, method="aria2.getGlobalStat") - or {} - ) + # Aggregate progress across all GIDs for this call + total_completed = 0 + total_size = 0 - number_stopped = int(global_stats.get("numStoppedTotal", 0)) - download_speed = int(global_stats.get("downloadSpeed", -1)) + # Check each tracked GID + for gid in gids: + if gid in completed: + continue - if number_stopped: - yield dict(completed=number_stopped) - if download_speed != -1: - yield dict(downloaded=f"{filesize.decimal(download_speed)}/s") + status = _manager.tell_status(gid) + if not status: + continue - stopped_downloads: list[dict[str, Any]] = ( - rpc( - caller=partial(rpc_session.post, url=rpc_uri), - secret=rpc_secret, - method="aria2.tellStopped", - params=[0, 999999], - ) - or [] - ) + completed_length = int(status.get("completedLength", 0)) + total_length = int(status.get("totalLength", 0)) + total_completed += completed_length + total_size += total_length - for dl in stopped_downloads: - if dl["status"] == "error": - used_uri = next( - uri["uri"] - for file in dl["files"] - if file["selected"] == "true" - for uri in file["uris"] - if uri["status"] == "used" - ) - error = f"Download Error (#{dl['gid']}): {dl['errorMessage']} ({dl['errorCode']}), {used_uri}" - error_pretty = "\n ".join( - textwrap.wrap(error, width=console.width - 20, initial_indent="") - ) - console.log(Text.from_ansi("\n[Aria2c]: " + error_pretty)) - if debug_logger: - debug_logger.log( - level="ERROR", - operation="downloader_aria2c_download_error", - message=f"Aria2c download failed: {dl['errorMessage']}", - context={ - "gid": dl["gid"], - "error_code": dl["errorCode"], - "error_message": dl["errorMessage"], - "used_uri": used_uri[:200] + "..." if len(used_uri) > 200 else used_uri, - "completed_length": dl.get("completedLength"), - "total_length": dl.get("totalLength"), - }, - ) - raise ValueError(error) + state = status.get("status") + if state in ("complete", "error"): + completed.add(gid) + yield dict(completed=len(completed)) - if number_stopped == len(urls): - rpc(caller=partial(rpc_session.post, url=rpc_uri), secret=rpc_secret, method="aria2.shutdown") - break + if state == "error": + used_uri = None + try: + used_uri = next( + uri["uri"] + for file in status.get("files", []) + for uri in file.get("uris", []) + if uri.get("status") == "used" + ) + except Exception: + used_uri = "unknown" + error = f"Download Error (#{gid}): {status.get('errorMessage')} ({status.get('errorCode')}), {used_uri}" + error_pretty = "\n ".join(textwrap.wrap(error, width=console.width - 20, initial_indent="")) + console.log(Text.from_ansi("\n[Aria2c]: " + error_pretty)) + if debug_logger: + debug_logger.log( + level="ERROR", + operation="downloader_aria2c_download_error", + message=f"Aria2c download failed: {status.get('errorMessage')}", + context={ + "gid": gid, + "error_code": status.get("errorCode"), + "error_message": status.get("errorMessage"), + "used_uri": used_uri[:200] + "..." if used_uri and len(used_uri) > 200 else used_uri, + "completed_length": status.get("completedLength"), + "total_length": status.get("totalLength"), + }, + ) + raise ValueError(error) + + # Yield aggregate progress for this call's downloads + if total_size > 0: + # Yield both advance (bytes downloaded this iteration) and total for rich progress + if dl_speed != -1: + yield dict(downloaded=f"{filesize.decimal(dl_speed)}/s", advance=0, completed=total_completed, total=total_size) + else: + yield dict(advance=0, completed=total_completed, total=total_size) + elif dl_speed != -1: + yield dict(downloaded=f"{filesize.decimal(dl_speed)}/s") time.sleep(1) - - p.wait() - - if p.returncode != 0: - if debug_logger: - debug_logger.log( - level="ERROR", - operation="downloader_aria2c_failed", - message=f"Aria2c exited with code {p.returncode}", - context={ - "returncode": p.returncode, - "url_count": len(urls), - "output_dir": str(output_dir), - }, - ) - raise subprocess.CalledProcessError(p.returncode, arguments) - - if debug_logger: - debug_logger.log( - level="DEBUG", - operation="downloader_aria2c_complete", - message="Aria2c download completed successfully", - context={ - "url_count": len(urls), - "output_dir": str(output_dir), - "filename": filename, - }, - ) - - except ConnectionResetError: - # interrupted while passing URI to download - raise KeyboardInterrupt() - except subprocess.CalledProcessError as e: - if e.returncode in (7, 0xC000013A): - # 7 is when Aria2(c) handled the CTRL+C - # 0xC000013A is when it never got the chance to - raise KeyboardInterrupt() - raise except KeyboardInterrupt: - DOWNLOAD_CANCELLED.set() # skip pending track downloads - yield dict(downloaded="[yellow]CANCELLED") + DOWNLOAD_CANCELLED.set() raise except Exception as e: - DOWNLOAD_CANCELLED.set() # skip pending track downloads + DOWNLOAD_CANCELLED.set() yield dict(downloaded="[red]FAILED") - if debug_logger and not isinstance(e, (subprocess.CalledProcessError, ValueError)): + if debug_logger and not isinstance(e, ValueError): debug_logger.log( level="ERROR", operation="downloader_aria2c_exception", @@ -335,8 +400,6 @@ def download( }, ) raise - finally: - rpc(caller=partial(rpc_session.post, url=rpc_uri), secret=rpc_secret, method="aria2.shutdown") def aria2c( From 0c7d20c943b9d57ae339be19855b6c8744fc19b8 Mon Sep 17 00:00:00 2001 From: Andy Date: Fri, 23 Jan 2026 17:20:22 -0700 Subject: [PATCH 11/38] fix(api): validate Bearer prefix before extracting API key The replace("Bearer ", "") approach returned the full Authorization header value when the prefix was not present, incorrectly treating other auth schemes (e.g., "Basic xyz") as API keys. --- unshackle/core/api/api_keys.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/unshackle/core/api/api_keys.py b/unshackle/core/api/api_keys.py index 255a45c..8d868b9 100644 --- a/unshackle/core/api/api_keys.py +++ b/unshackle/core/api/api_keys.py @@ -18,7 +18,15 @@ def get_api_key_from_request(request: web.Request) -> Optional[str]: Returns: API key string or None """ - return request.headers.get("X-API-Key") or request.headers.get("Authorization", "").replace("Bearer ", "") + api_key = request.headers.get("X-API-Key") + if api_key: + return api_key + + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + return auth_header[7:] # len("Bearer ") == 7 + + return None def get_api_key_config(app: web.Application, api_key: str) -> Optional[Dict[str, Any]]: From 6f5f25fa9bf55104d7b9dac109a69724272f0c63 Mon Sep 17 00:00:00 2001 From: Andy Date: Fri, 23 Jan 2026 17:20:39 -0700 Subject: [PATCH 12/38] refactor(remote_auth): remove unused requests.Session The session was created with headers but never used. The class saves sessions locally via the cache rather than uploading to a remote server. --- unshackle/core/remote_auth.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/unshackle/core/remote_auth.py b/unshackle/core/remote_auth.py index 3b2a947..d852578 100644 --- a/unshackle/core/remote_auth.py +++ b/unshackle/core/remote_auth.py @@ -16,7 +16,6 @@ import logging from typing import Any, Dict, Optional import click -import requests import yaml from unshackle.core.api.session_serializer import serialize_session @@ -53,8 +52,6 @@ class RemoteAuthenticator: """ self.remote_url = remote_url.rstrip("/") self.api_key = api_key - self.session = requests.Session() - self.session.headers.update({"X-API-Key": self.api_key, "Content-Type": "application/json"}) def authenticate_service_locally( self, service_tag: str, profile: Optional[str] = None, force_reauth: bool = False From e3767716f3a11c3f014cd9bae0fc1adebe8e98fb Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 24 Jan 2026 10:33:40 -0700 Subject: [PATCH 13/38] feat(debug): add download output verification logging Add comprehensive debug logging to diagnose N_m3u8DL-RE download failures where the process exits successfully but produces no output files. --- unshackle/core/downloaders/n_m3u8dl_re.py | 59 ++++++++++++++++++++++- unshackle/core/manifests/dash.py | 56 +++++++++++++++++++++ unshackle/core/manifests/hls.py | 45 +++++++++++++++-- unshackle/core/manifests/ism.py | 55 +++++++++++++++++++++ 4 files changed, 210 insertions(+), 5 deletions(-) diff --git a/unshackle/core/downloaders/n_m3u8dl_re.py b/unshackle/core/downloaders/n_m3u8dl_re.py index 976b72f..3e69f1c 100644 --- a/unshackle/core/downloaders/n_m3u8dl_re.py +++ b/unshackle/core/downloaders/n_m3u8dl_re.py @@ -304,9 +304,15 @@ def download( arguments.extend(selection_args) log_file_path: Path | None = None + meta_json_path: Path | None = None if debug_logger: log_file_path = output_dir / f".n_m3u8dl_re_{filename}.log" - arguments.extend(["--log-file-path", str(log_file_path)]) + meta_json_path = output_dir / f"{filename}.meta.json" + arguments.extend([ + "--log-file-path", str(log_file_path), + "--log-level", "DEBUG", + "--write-meta-json", "true", + ]) track_url_display = track.url[:200] + "..." if len(track.url) > 200 else track.url debug_logger.log( @@ -394,6 +400,14 @@ def download( raise subprocess.CalledProcessError(process.returncode, arguments) if debug_logger: + output_dir_exists = output_dir.exists() + output_files = [] + if output_dir_exists: + try: + output_files = [f.name for f in output_dir.iterdir() if f.is_file()][:20] + except Exception: + output_files = [""] + debug_logger.log( level="DEBUG", operation="downloader_n_m3u8dl_re_complete", @@ -402,10 +416,47 @@ def download( "track_id": getattr(track, "id", None), "track_type": track.__class__.__name__, "output_dir": str(output_dir), + "output_dir_exists": output_dir_exists, + "output_files_count": len(output_files), + "output_files": output_files, "filename": filename, }, ) + # Warn if no output was produced - include N_m3u8DL-RE's logs for diagnosis + if not output_dir_exists or not output_files: + # Read N_m3u8DL-RE's log file for debugging + n_m3u8dl_log = "" + if log_file_path and log_file_path.exists(): + try: + n_m3u8dl_log = log_file_path.read_text(encoding="utf-8", errors="replace") + except Exception: + n_m3u8dl_log = "" + + # Read meta JSON to see what streams N_m3u8DL-RE parsed + meta_json_content = "" + if meta_json_path and meta_json_path.exists(): + try: + meta_json_content = meta_json_path.read_text(encoding="utf-8", errors="replace") + except Exception: + meta_json_content = "" + + debug_logger.log( + level="WARNING", + operation="downloader_n_m3u8dl_re_no_output", + message="N_m3u8DL-RE exited successfully but produced no output files", + context={ + "track_id": getattr(track, "id", None), + "track_type": track.__class__.__name__, + "output_dir": str(output_dir), + "output_dir_exists": output_dir_exists, + "selection_args": selection_args, + "track_url": track.url[:200] + "..." if len(track.url) > 200 else track.url, + "n_m3u8dl_re_log": n_m3u8dl_log, + "n_m3u8dl_re_meta_json": meta_json_content, + }, + ) + except ConnectionResetError: # interrupted while passing URI to download raise KeyboardInterrupt() @@ -437,11 +488,17 @@ def download( ) raise finally: + # Clean up temporary debug files if log_file_path and log_file_path.exists(): try: log_file_path.unlink() except Exception: pass + if meta_json_path and meta_json_path.exists(): + try: + meta_json_path.unlink() + except Exception: + pass def n_m3u8dl_re( diff --git a/unshackle/core/manifests/dash.py b/unshackle/core/manifests/dash.py index ce0d2a7..438351b 100644 --- a/unshackle/core/manifests/dash.py +++ b/unshackle/core/manifests/dash.py @@ -572,8 +572,64 @@ class DASH: for control_file in save_dir.glob("*.aria2__temp"): control_file.unlink() + # Verify output directory exists and contains files + if not save_dir.exists(): + error_msg = f"Output directory does not exist: {save_dir}" + if debug_logger: + debug_logger.log( + level="ERROR", + operation="manifest_dash_download_output_missing", + message=error_msg, + context={ + "track_id": getattr(track, "id", None), + "track_type": track.__class__.__name__, + "save_dir": str(save_dir), + "save_path": str(save_path), + "downloader": downloader.__name__, + "skip_merge": skip_merge, + }, + ) + raise FileNotFoundError(error_msg) + segments_to_merge = [x for x in sorted(save_dir.iterdir()) if x.is_file()] + if debug_logger: + debug_logger.log( + level="DEBUG", + operation="manifest_dash_download_complete", + message="DASH download complete, preparing to merge", + context={ + "track_id": getattr(track, "id", None), + "track_type": track.__class__.__name__, + "save_dir": str(save_dir), + "save_dir_exists": save_dir.exists(), + "segments_found": len(segments_to_merge), + "segment_files": [f.name for f in segments_to_merge[:10]], # Limit to first 10 + "downloader": downloader.__name__, + "skip_merge": skip_merge, + }, + ) + + if not segments_to_merge: + error_msg = f"No segment files found in output directory: {save_dir}" + if debug_logger: + # List all contents of the directory for debugging + all_contents = list(save_dir.iterdir()) if save_dir.exists() else [] + debug_logger.log( + level="ERROR", + operation="manifest_dash_download_no_segments", + message=error_msg, + context={ + "track_id": getattr(track, "id", None), + "track_type": track.__class__.__name__, + "save_dir": str(save_dir), + "directory_contents": [str(p) for p in all_contents], + "downloader": downloader.__name__, + "skip_merge": skip_merge, + }, + ) + raise FileNotFoundError(error_msg) + if skip_merge: # N_m3u8DL-RE handles merging and decryption internally shutil.move(segments_to_merge[0], save_path) diff --git a/unshackle/core/manifests/hls.py b/unshackle/core/manifests/hls.py index 3906a29..85ec245 100644 --- a/unshackle/core/manifests/hls.py +++ b/unshackle/core/manifests/hls.py @@ -650,6 +650,44 @@ class HLS: # finally merge all the discontinuity save files together to the final path segments_to_merge = find_segments_recursively(save_dir) + + if debug_logger: + debug_logger.log( + level="DEBUG", + operation="manifest_hls_download_complete", + message="HLS download complete, preparing to merge", + context={ + "track_id": getattr(track, "id", None), + "track_type": track.__class__.__name__, + "save_dir": str(save_dir), + "save_dir_exists": save_dir.exists(), + "segments_found": len(segments_to_merge), + "segment_files": [f.name for f in segments_to_merge[:10]], # Limit to first 10 + "downloader": downloader.__name__, + "skip_merge": skip_merge, + }, + ) + + if not segments_to_merge: + error_msg = f"No segment files found in output directory: {save_dir}" + if debug_logger: + all_contents = list(save_dir.iterdir()) if save_dir.exists() else [] + debug_logger.log( + level="ERROR", + operation="manifest_hls_download_no_segments", + message=error_msg, + context={ + "track_id": getattr(track, "id", None), + "track_type": track.__class__.__name__, + "save_dir": str(save_dir), + "save_dir_exists": save_dir.exists(), + "directory_contents": [str(p) for p in all_contents], + "downloader": downloader.__name__, + "skip_merge": skip_merge, + }, + ) + raise FileNotFoundError(error_msg) + if len(segments_to_merge) == 1: shutil.move(segments_to_merge[0], save_path) else: @@ -889,7 +927,8 @@ class HLS: elif key.keyformat and key.keyformat.lower() == WidevineCdm.urn: return key elif key.keyformat and key.keyformat.lower() in { - f"urn:uuid:{PR_PSSH.SYSTEM_ID}", "com.microsoft.playready" + f"urn:uuid:{PR_PSSH.SYSTEM_ID}", + "com.microsoft.playready", }: return key else: @@ -927,9 +966,7 @@ class HLS: pssh=WV_PSSH(key.uri.split(",")[-1]), **key._extra_params, # noqa ) - elif key.keyformat and key.keyformat.lower() in { - f"urn:uuid:{PR_PSSH.SYSTEM_ID}", "com.microsoft.playready" - }: + elif key.keyformat and key.keyformat.lower() in {f"urn:uuid:{PR_PSSH.SYSTEM_ID}", "com.microsoft.playready"}: drm = PlayReady( pssh=PR_PSSH(key.uri.split(",")[-1]), pssh_b64=key.uri.split(",")[-1], diff --git a/unshackle/core/manifests/ism.py b/unshackle/core/manifests/ism.py index 8cb6a3b..b047f5e 100644 --- a/unshackle/core/manifests/ism.py +++ b/unshackle/core/manifests/ism.py @@ -314,8 +314,63 @@ class ISM: for control_file in save_dir.glob("*.aria2__temp"): control_file.unlink() + # Verify output directory exists and contains files + if not save_dir.exists(): + error_msg = f"Output directory does not exist: {save_dir}" + if debug_logger: + debug_logger.log( + level="ERROR", + operation="manifest_ism_download_output_missing", + message=error_msg, + context={ + "track_id": getattr(track, "id", None), + "track_type": track.__class__.__name__, + "save_dir": str(save_dir), + "save_path": str(save_path), + "downloader": downloader.__name__, + "skip_merge": skip_merge, + }, + ) + raise FileNotFoundError(error_msg) + segments_to_merge = [x for x in sorted(save_dir.iterdir()) if x.is_file()] + if debug_logger: + debug_logger.log( + level="DEBUG", + operation="manifest_ism_download_complete", + message="ISM download complete, preparing to merge", + context={ + "track_id": getattr(track, "id", None), + "track_type": track.__class__.__name__, + "save_dir": str(save_dir), + "save_dir_exists": save_dir.exists(), + "segments_found": len(segments_to_merge), + "segment_files": [f.name for f in segments_to_merge[:10]], # Limit to first 10 + "downloader": downloader.__name__, + "skip_merge": skip_merge, + }, + ) + + if not segments_to_merge: + error_msg = f"No segment files found in output directory: {save_dir}" + if debug_logger: + all_contents = list(save_dir.iterdir()) if save_dir.exists() else [] + debug_logger.log( + level="ERROR", + operation="manifest_ism_download_no_segments", + message=error_msg, + context={ + "track_id": getattr(track, "id", None), + "track_type": track.__class__.__name__, + "save_dir": str(save_dir), + "directory_contents": [str(p) for p in all_contents], + "downloader": downloader.__name__, + "skip_merge": skip_merge, + }, + ) + raise FileNotFoundError(error_msg) + if skip_merge: shutil.move(segments_to_merge[0], save_path) else: From e77f000494c49b26e2e48e9af7cef932b4ca1218 Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 24 Jan 2026 11:23:13 -0700 Subject: [PATCH 14/38] feat: Gluetun VPN integration and remote service enhancements Major features: - Native Docker-based Gluetun VPN proxy provider with multi-provider support (NordVPN, Windscribe, Surfshark, ExpressVPN, and 50+ more) - Stateless remote service architecture with local session caching - Client-side authentication for remote services (browser, 2FA, OAuth support) Key changes: - core/proxies/windscribevpn.py: Enhanced proxy handling - core/crypto.py: Cryptographic utilities - docs/VPN_PROXY_SETUP.md: Comprehensive VPN/proxy documentation --- docs/VPN_PROXY_SETUP.md | 526 ++++++++++++++++++++++++ pyproject.toml | 1 - unshackle/commands/dl.py | 2 + unshackle/commands/remote_auth.py | 1 + unshackle/core/crypto.py | 284 +++++++++++++ unshackle/core/proxies/windscribevpn.py | 47 ++- uv.lock | 9 + 7 files changed, 857 insertions(+), 13 deletions(-) create mode 100644 docs/VPN_PROXY_SETUP.md create mode 100644 unshackle/core/crypto.py diff --git a/docs/VPN_PROXY_SETUP.md b/docs/VPN_PROXY_SETUP.md new file mode 100644 index 0000000..c74dc88 --- /dev/null +++ b/docs/VPN_PROXY_SETUP.md @@ -0,0 +1,526 @@ +# VPN-to-HTTP Proxy Bridge for Unshackle + +## Overview + +This guide explains how to use **Gluetun** - a Docker-based VPN client that creates an isolated HTTP proxy from VPN services (including WireGuard). This allows Unshackle to use VPN providers like ExpressVPN, Windscribe, NordVPN, and many others without affecting your system's normal internet connection. + +> **Note**: Unshackle now has **native Gluetun integration**! You can use `--proxy gluetun:windscribe:us` directly without manual Docker setup. See [CONFIG.md](../CONFIG.md#gluetun-dict) for configuration. The guide below is for advanced users who want to manage Gluetun containers manually. + +## Why This Approach? + +- **Network Isolation**: VPN connection runs in Docker container, doesn't affect host system +- **HTTP Proxy Interface**: Exposes standard HTTP proxy that Unshackle can use directly +- **WireGuard Support**: Modern, fast, and secure VPN protocol +- **Kill Switch**: Built-in protection prevents IP leaks if VPN disconnects +- **Multi-Provider**: Supports 50+ VPN providers out of the box +- **Cross-Platform**: Works on Linux and Windows (via Docker Desktop or WSL2) + +## Supported VPN Providers + +Gluetun supports many providers including: +- ExpressVPN +- Windscribe +- NordVPN +- Surfshark +- ProtonVPN +- Private Internet Access +- Mullvad +- And 50+ more + +Full list: https://github.com/qdm12/gluetun/wiki + +## Prerequisites + +### Linux +```bash +# Install Docker +curl -fsSL https://get.docker.com -o get-docker.sh +sudo sh get-docker.sh +sudo usermod -aG docker $USER +# Log out and back in for group changes to take effect +``` + +### Windows +- Install [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop/) +- Enable WSL2 backend (recommended) + +## Setup Instructions + +### 1. Create Gluetun Configuration Directory + +```bash +mkdir -p ~/gluetun-config +cd ~/gluetun-config +``` + +### 2. Create Docker Compose File + +Create `docker-compose.yml` with your VPN provider configuration: + +#### Example: Windscribe with WireGuard + +```yaml +version: "3" +services: + gluetun: + image: qmcgaw/gluetun:latest + container_name: gluetun + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + ports: + - 8888:8888/tcp # HTTP proxy + - 8388:8388/tcp # Shadowsocks (optional) + - 8388:8388/udp # Shadowsocks (optional) + environment: + # VPN Provider Settings + - VPN_SERVICE_PROVIDER=windscribe + - VPN_TYPE=wireguard + + # Get these from your Windscribe account + - WIREGUARD_PRIVATE_KEY=your_private_key_here + - WIREGUARD_ADDRESSES=your_address_here + - WIREGUARD_PRESHARED_KEY=your_preshared_key_here # if applicable + + # Server location (optional) + - SERVER_COUNTRIES=US + # or specific city + # - SERVER_CITIES=New York + + # HTTP Proxy Settings + - HTTPPROXY=on + - HTTPPROXY_LOG=on + - HTTPPROXY_LISTENING_ADDRESS=:8888 + + # Timezone + - TZ=America/New_York + + # Logging + - LOG_LEVEL=info + + restart: unless-stopped + + # Health check + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "https://api.ipify.org"] + interval: 30s + timeout: 10s + retries: 3 +``` + +#### Example: ExpressVPN with WireGuard + +```yaml +version: "3" +services: + gluetun: + image: qmcgaw/gluetun:latest + container_name: gluetun + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + ports: + - 8888:8888/tcp # HTTP proxy + environment: + - VPN_SERVICE_PROVIDER=expressvpn + - VPN_TYPE=wireguard + + # Get these from ExpressVPN's WireGuard configuration + - WIREGUARD_PRIVATE_KEY=your_private_key_here + - WIREGUARD_ADDRESSES=your_address_here + + - HTTPPROXY=on + - HTTPPROXY_LISTENING_ADDRESS=:8888 + - TZ=America/New_York + - LOG_LEVEL=info + + restart: unless-stopped +``` + +#### Example: NordVPN with WireGuard + +```yaml +version: "3" +services: + gluetun: + image: qmcgaw/gluetun:latest + container_name: gluetun + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + ports: + - 8888:8888/tcp # HTTP proxy + environment: + - VPN_SERVICE_PROVIDER=nordvpn + - VPN_TYPE=wireguard + + # NordVPN token (get from NordVPN dashboard) + - WIREGUARD_PRIVATE_KEY=your_private_key_here + - WIREGUARD_ADDRESSES=your_address_here + + - SERVER_COUNTRIES=US + + - HTTPPROXY=on + - HTTPPROXY_LISTENING_ADDRESS=:8888 + - TZ=America/New_York + - LOG_LEVEL=info + + restart: unless-stopped +``` + +### 3. Getting Your WireGuard Credentials + +#### Windscribe +1. Log into Windscribe account +2. Go to "My Account" → "WireGuard" +3. Generate a config file for your desired location +4. Extract the private key and addresses from the config + +#### ExpressVPN +1. Log into ExpressVPN +2. Navigate to the manual configuration section +3. Select WireGuard and download the configuration +4. Extract credentials from the config file + +#### NordVPN +1. Log into NordVPN dashboard +2. Go to Services → NordVPN → Manual setup +3. Generate WireGuard credentials +4. Copy the private key and addresses + +### 4. Start Gluetun Container + +```bash +cd ~/gluetun-config +docker-compose up -d +``` + +Check logs to verify connection: +```bash +docker logs gluetun -f +``` + +You should see messages indicating successful VPN connection and HTTP proxy starting on port 8888. + +### 5. Test the Proxy + +```bash +# Test that the proxy works +curl -x http://localhost:8888 https://api.ipify.org + +# This should show your VPN's IP address, not your real IP +``` + +## Integrating with Unshackle + +### Option 1: Using Basic Proxy Configuration + +Add to your Unshackle config (`~/.config/unshackle/config.yaml`): + +```yaml +proxies: + Basic: + us: "http://localhost:8888" + uk: "http://localhost:8888" # if you have multiple Gluetun containers +``` + +Then use in Unshackle: +```bash +uv run unshackle dl SERVICE_NAME CONTENT_ID --proxy us +``` + +### Option 2: Creating Multiple VPN Proxy Containers + +You can run multiple Gluetun containers for different regions: + +**gluetun-us.yml:** +```yaml +version: "3" +services: + gluetun-us: + image: qmcgaw/gluetun:latest + container_name: gluetun-us + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + ports: + - 8888:8888/tcp # HTTP proxy + environment: + - VPN_SERVICE_PROVIDER=windscribe + - VPN_TYPE=wireguard + - SERVER_COUNTRIES=US + - WIREGUARD_PRIVATE_KEY=your_key + - WIREGUARD_ADDRESSES=your_address + - HTTPPROXY=on + - HTTPPROXY_LISTENING_ADDRESS=:8888 + restart: unless-stopped +``` + +**gluetun-uk.yml:** +```yaml +version: "3" +services: + gluetun-uk: + image: qmcgaw/gluetun:latest + container_name: gluetun-uk + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + ports: + - 8889:8888/tcp # Different host port + environment: + - VPN_SERVICE_PROVIDER=windscribe + - VPN_TYPE=wireguard + - SERVER_COUNTRIES=GB + - WIREGUARD_PRIVATE_KEY=your_key + - WIREGUARD_ADDRESSES=your_address + - HTTPPROXY=on + - HTTPPROXY_LISTENING_ADDRESS=:8888 + restart: unless-stopped +``` + +Then in Unshackle config: +```yaml +proxies: + Basic: + us: "http://localhost:8888" + uk: "http://localhost:8889" + ca: "http://localhost:8890" +``` + +### Option 3: Using with Authentication (Recommended for Security) + +Add authentication to your Gluetun proxy: + +```yaml +environment: + - HTTPPROXY=on + - HTTPPROXY_LISTENING_ADDRESS=:8888 + - HTTPPROXY_USER=myusername + - HTTPPROXY_PASSWORD=mypassword +``` + +Then in Unshackle config: +```yaml +proxies: + Basic: + us: "http://myusername:mypassword@localhost:8888" +``` + +## Advanced Features + +### Port Forwarding (for torrenting services) + +Some VPN providers support port forwarding: + +```yaml +environment: + - VPN_PORT_FORWARDING=on + - VPN_PORT_FORWARDING_LISTENING_PORT=8000 +``` + +### SOCKS5 Proxy (Alternative to HTTP) + +Gluetun also supports SOCKS5 proxy: + +```yaml +ports: + - 1080:1080/tcp # SOCKS5 proxy +environment: + - SHADOWSOCKS=on + - SHADOWSOCKS_LISTENING_ADDRESS=:1080 +``` + +### DNS Over TLS + +For enhanced privacy: + +```yaml +environment: + - DOT=on + - DOT_PROVIDERS=cloudflare +``` + +### Custom Firewall Rules + +Block specific ports or IPs: + +```yaml +environment: + - FIREWALL_OUTBOUND_SUBNETS=192.168.1.0/24 # Allow LAN access +``` + +## Troubleshooting + +### Container Fails to Start + +Check logs: +```bash +docker logs gluetun +``` + +Common issues: +- Missing `NET_ADMIN` capability +- `/dev/net/tun` not available +- Invalid WireGuard credentials + +### VPN Not Connecting + +1. Verify credentials are correct +2. Check VPN provider status +3. Try different server location +4. Check firewall isn't blocking VPN ports + +### Proxy Not Working + +Test connectivity: +```bash +# Check if port is open +docker exec gluetun netstat -tlnp | grep 8888 + +# Test proxy directly +curl -v -x http://localhost:8888 https://api.ipify.org +``` + +### IP Leak Prevention + +Verify your IP is hidden: +```bash +# Without proxy (should show your real IP) +curl https://api.ipify.org + +# With proxy (should show VPN IP) +curl -x http://localhost:8888 https://api.ipify.org +``` + +### Performance Issues + +- WireGuard is generally faster than OpenVPN +- Try different VPN servers closer to your location +- Check container resource limits +- Monitor with `docker stats gluetun` + +## Managing Gluetun + +### Start Container +```bash +docker-compose up -d +``` + +### Stop Container +```bash +docker-compose down +``` + +### Restart Container +```bash +docker-compose restart +``` + +### Update Gluetun +```bash +docker-compose pull +docker-compose up -d +``` + +### View Logs +```bash +docker logs gluetun -f +``` + +### Check Status +```bash +docker ps | grep gluetun +``` + +## Windows-Specific Notes + +### Using Docker Desktop + +1. Ensure WSL2 backend is enabled in Docker Desktop settings +2. Use PowerShell or WSL2 terminal for commands +3. Access proxy from Windows: `http://localhost:8888` +4. Access from WSL2: `http://host.docker.internal:8888` + +### Using WSL2 Directly + +If running Unshackle in WSL2: +```yaml +proxies: + Basic: + us: "http://localhost:8888" # If Gluetun is in same WSL2 distro + # or + us: "http://host.docker.internal:8888" # If Gluetun is in Docker Desktop +``` + +## Network Isolation Benefits + +The Docker-based approach provides several benefits: + +1. **Namespace Isolation**: VPN connection exists only in container +2. **No System Route Changes**: Host routing table remains unchanged +3. **No Connection Drops**: Host internet connection unaffected +4. **Easy Switching**: Start/stop VPN without affecting other applications +5. **Multiple Simultaneous VPNs**: Run multiple containers with different locations +6. **Kill Switch**: Automatic with container networking + +## Performance Considerations + +- **WireGuard**: Modern protocol, faster than OpenVPN, less CPU usage +- **Docker Overhead**: Minimal (< 5% performance impact) +- **Memory Usage**: ~50-100MB per container +- **Network Latency**: Negligible with localhost connection + +## Security Considerations + +1. **Enable authentication** on HTTP proxy (HTTPPROXY_USER/PASSWORD) +2. **Bind to localhost only** (don't expose 0.0.0.0 unless needed) +3. **Use Docker networks** for container-to-container communication +4. **Keep Gluetun updated** for security patches +5. **Monitor logs** for unauthorized access attempts + +## References + +- [Gluetun GitHub Repository](https://github.com/qdm12/gluetun) +- [Gluetun Wiki - Setup Guides](https://github.com/qdm12/gluetun/wiki) +- [Windscribe Setup Guide](https://github.com/qdm12/gluetun/wiki/Windscribe) +- [Docker Installation](https://docs.docker.com/engine/install/) + +## Alternative Solutions + +If Gluetun doesn't meet your needs, consider: + +### 1. **Pritunl Client + Tinyproxy** +- Run Pritunl in Docker with Tinyproxy +- More complex setup but more control + +### 2. **OpenConnect + Privoxy** +- For Cisco AnyConnect VPNs +- Network namespace isolation on Linux + +### 3. **WireGuard + SOCKS5 Proxy** +- Manual WireGuard setup with microsocks/dante +- Maximum control but requires networking knowledge + +### 4. **Network Namespaces (Linux Only)** +```bash +# Create namespace +sudo ip netns add vpn + +# Setup WireGuard in namespace +sudo ip netns exec vpn wg-quick up wg0 + +# Run proxy in namespace +sudo ip netns exec vpn tinyproxy -d -c /etc/tinyproxy.conf +``` + +However, **Gluetun is recommended** for its ease of use, maintenance, and cross-platform support. + +## Conclusion + +Using Gluetun provides a robust, isolated, and easy-to-manage solution for connecting Unshackle to VPN services that don't offer HTTP proxies. The Docker-based approach ensures your system's network remains stable while giving you full VPN benefits for Unshackle downloads. diff --git a/pyproject.toml b/pyproject.toml index b98fa98..d9e8476 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,7 +88,6 @@ dev = [ "types-requests>=2.31.0.20240406,<3", "isort>=5.13.2,<8", "ruff>=0.3.7,<0.15", - "unshackle", ] [tool.hatch.build.targets.wheel] diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index d005477..3252abe 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -672,6 +672,8 @@ class dl: self.log.info(f"Loaded {proxy_provider.__class__.__name__}: {proxy_provider}") if proxy: + # Store original proxy query for service-specific proxy_map + original_proxy_query = proxy requested_provider = None if re.match(r"^[a-z]+:.+$", proxy, re.IGNORECASE): # requesting proxy from a specific proxy provider diff --git a/unshackle/commands/remote_auth.py b/unshackle/commands/remote_auth.py index 23b6de2..76f6ad0 100644 --- a/unshackle/commands/remote_auth.py +++ b/unshackle/commands/remote_auth.py @@ -80,6 +80,7 @@ def status_command(remote: Optional[str]) -> None: from unshackle.core.local_session_cache import get_local_session_cache + # Get local session cache cache = get_local_session_cache() diff --git a/unshackle/core/crypto.py b/unshackle/core/crypto.py new file mode 100644 index 0000000..d951bd6 --- /dev/null +++ b/unshackle/core/crypto.py @@ -0,0 +1,284 @@ +"""Cryptographic utilities for secure remote service authentication.""" + +import base64 +import hashlib +import json +import logging +import secrets +import time +from pathlib import Path +from typing import Any, Dict, Optional, Tuple + +try: + from nacl.public import Box, PrivateKey, PublicKey + from nacl.secret import SecretBox + from nacl.utils import random + + NACL_AVAILABLE = True +except ImportError: + NACL_AVAILABLE = False + +log = logging.getLogger("crypto") + + +class CryptoError(Exception): + """Cryptographic operation error.""" + + pass + + +class ServerKeyPair: + """ + Server-side key pair for secure remote authentication. + + Uses NaCl (libsodium) for public key cryptography. + The server generates a key pair and shares the public key with clients. + Clients encrypt sensitive data with the public key, which only the server can decrypt. + """ + + def __init__(self, private_key: Optional[PrivateKey] = None): + """ + Initialize server key pair. + + Args: + private_key: Existing private key, or None to generate new + """ + if not NACL_AVAILABLE: + raise CryptoError("PyNaCl is not installed. Install with: pip install pynacl") + + self.private_key = private_key or PrivateKey.generate() + self.public_key = self.private_key.public_key + + def get_public_key_b64(self) -> str: + """ + Get base64-encoded public key for sharing with clients. + + Returns: + Base64-encoded public key + """ + return base64.b64encode(bytes(self.public_key)).decode("utf-8") + + def decrypt_message(self, encrypted_message: str, client_public_key_b64: str) -> Dict[str, Any]: + """ + Decrypt a message from a client. + + Args: + encrypted_message: Base64-encoded encrypted message + client_public_key_b64: Base64-encoded client public key + + Returns: + Decrypted message as dictionary + """ + try: + # Decode keys + client_public_key = PublicKey(base64.b64decode(client_public_key_b64)) + encrypted_data = base64.b64decode(encrypted_message) + + # Create box for decryption + box = Box(self.private_key, client_public_key) + + # Decrypt + decrypted = box.decrypt(encrypted_data) + return json.loads(decrypted.decode("utf-8")) + + except Exception as e: + log.error(f"Decryption failed: {e}") + raise CryptoError(f"Failed to decrypt message: {e}") + + def save_to_file(self, path: Path) -> None: + """ + Save private key to file. + + Args: + path: Path to save the key + """ + path.parent.mkdir(parents=True, exist_ok=True) + key_data = { + "private_key": base64.b64encode(bytes(self.private_key)).decode("utf-8"), + "public_key": self.get_public_key_b64(), + } + path.write_text(json.dumps(key_data, indent=2), encoding="utf-8") + log.info(f"Server key pair saved to {path}") + + @classmethod + def load_from_file(cls, path: Path) -> "ServerKeyPair": + """ + Load private key from file. + + Args: + path: Path to load the key from + + Returns: + ServerKeyPair instance + """ + if not path.exists(): + raise CryptoError(f"Key file not found: {path}") + + try: + key_data = json.loads(path.read_text(encoding="utf-8")) + private_key_bytes = base64.b64decode(key_data["private_key"]) + private_key = PrivateKey(private_key_bytes) + log.info(f"Server key pair loaded from {path}") + return cls(private_key) + except Exception as e: + raise CryptoError(f"Failed to load key from {path}: {e}") + + +class ClientCrypto: + """ + Client-side cryptography for secure remote authentication. + + Generates ephemeral key pairs and encrypts sensitive data for the server. + """ + + def __init__(self): + """Initialize client crypto with ephemeral key pair.""" + if not NACL_AVAILABLE: + raise CryptoError("PyNaCl is not installed. Install with: pip install pynacl") + + # Generate ephemeral key pair for this session + self.private_key = PrivateKey.generate() + self.public_key = self.private_key.public_key + + def get_public_key_b64(self) -> str: + """ + Get base64-encoded public key for sending to server. + + Returns: + Base64-encoded public key + """ + return base64.b64encode(bytes(self.public_key)).decode("utf-8") + + def encrypt_credentials( + self, credentials: Dict[str, Any], server_public_key_b64: str + ) -> Tuple[str, str]: + """ + Encrypt credentials for the server. + + Args: + credentials: Dictionary containing sensitive data (username, password, cookies, etc.) + server_public_key_b64: Base64-encoded server public key + + Returns: + Tuple of (encrypted_message_b64, client_public_key_b64) + """ + try: + # Decode server public key + server_public_key = PublicKey(base64.b64decode(server_public_key_b64)) + + # Create box for encryption + box = Box(self.private_key, server_public_key) + + # Encrypt + message = json.dumps(credentials).encode("utf-8") + encrypted = box.encrypt(message) + + # Return base64-encoded encrypted message and client public key + encrypted_b64 = base64.b64encode(encrypted).decode("utf-8") + client_public_key_b64 = self.get_public_key_b64() + + return encrypted_b64, client_public_key_b64 + + except Exception as e: + log.error(f"Encryption failed: {e}") + raise CryptoError(f"Failed to encrypt credentials: {e}") + + +def encrypt_credential_data( + username: Optional[str], password: Optional[str], cookies: Optional[str], server_public_key_b64: str +) -> Tuple[str, str]: + """ + Helper function to encrypt credential data. + + Args: + username: Username or None + password: Password or None + cookies: Cookie file content or None + server_public_key_b64: Server's public key + + Returns: + Tuple of (encrypted_data_b64, client_public_key_b64) + """ + client_crypto = ClientCrypto() + + credentials = {} + if username and password: + credentials["username"] = username + credentials["password"] = password + if cookies: + credentials["cookies"] = cookies + + return client_crypto.encrypt_credentials(credentials, server_public_key_b64) + + +def decrypt_credential_data(encrypted_data_b64: str, client_public_key_b64: str, server_keypair: ServerKeyPair) -> Dict[str, Any]: + """ + Helper function to decrypt credential data. + + Args: + encrypted_data_b64: Base64-encoded encrypted data + client_public_key_b64: Client's public key + server_keypair: Server's key pair + + Returns: + Decrypted credentials dictionary + """ + return server_keypair.decrypt_message(encrypted_data_b64, client_public_key_b64) + + +# Session-only authentication helpers + + +def serialize_authenticated_session(service_instance) -> Dict[str, Any]: + """ + Serialize an authenticated service session for remote use. + + This extracts session cookies and headers WITHOUT including credentials. + + Args: + service_instance: Authenticated service instance + + Returns: + Dictionary with session data (cookies, headers) but NO credentials + """ + from unshackle.core.api.session_serializer import serialize_session + + session_data = serialize_session(service_instance.session) + + # Add additional metadata + session_data["authenticated"] = True + session_data["service_tag"] = service_instance.__class__.__name__ + + return session_data + + +def is_session_valid(session_data: Dict[str, Any]) -> bool: + """ + Check if session data appears valid. + + Args: + session_data: Session data dictionary + + Returns: + True if session has cookies or auth headers + """ + if not session_data: + return False + + # Check for cookies or authorization headers + has_cookies = bool(session_data.get("cookies")) + has_auth = "Authorization" in session_data.get("headers", {}) + + return has_cookies or has_auth + + +__all__ = [ + "ServerKeyPair", + "ClientCrypto", + "CryptoError", + "encrypt_credential_data", + "decrypt_credential_data", + "serialize_authenticated_session", + "is_session_valid", + "NACL_AVAILABLE", +] diff --git a/unshackle/core/proxies/windscribevpn.py b/unshackle/core/proxies/windscribevpn.py index c8ffd2d..d48eeea 100644 --- a/unshackle/core/proxies/windscribevpn.py +++ b/unshackle/core/proxies/windscribevpn.py @@ -45,22 +45,27 @@ class WindscribeVPN(Proxy): """ Get an HTTPS proxy URI for a WindscribeVPN server. - Note: Windscribe's static OpenVPN credentials work reliably on US, AU, and NZ servers. + Supports: + - Country code: "us", "ca", "gb" + - City selection: "us:seattle", "ca:toronto" """ query = query.lower() - supported_regions = {"us", "au", "nz"} + city = None - if query not in supported_regions and query not in self.server_map: - raise ValueError( - f"Windscribe proxy does not currently support the '{query.upper()}' region. " - f"Supported regions with reliable credentials: {', '.join(sorted(supported_regions))}. " - ) + # Check if query includes city specification (e.g., "ca:toronto") + if ":" in query: + query, city = query.split(":", maxsplit=1) + city = city.strip() - if query in self.server_map: + # Check server_map for pinned servers (can include city) + server_map_key = f"{query}:{city}" if city else query + if server_map_key in self.server_map: + hostname = self.server_map[server_map_key] + elif query in self.server_map and not city: hostname = self.server_map[query] else: if re.match(r"^[a-z]+$", query): - hostname = self.get_random_server(query) + hostname = self.get_random_server(query, city) else: raise ValueError(f"The query provided is unsupported and unrecognized: {query}") @@ -70,22 +75,40 @@ class WindscribeVPN(Proxy): hostname = hostname.split(':')[0] return f"https://{self.username}:{self.password}@{hostname}:443" - def get_random_server(self, country_code: str) -> Optional[str]: + def get_random_server(self, country_code: str, city: Optional[str] = None) -> Optional[str]: """ - Get a random server hostname for a country. + Get a random server hostname for a country, optionally filtered by city. - Returns None if no servers are available for the country. + Args: + country_code: The country code (e.g., "us", "ca") + city: Optional city name to filter by (case-insensitive) + + Returns: + A random hostname from matching servers, or None if none available. """ for location in self.countries: if location.get("country_code", "").lower() == country_code.lower(): hostnames = [] for group in location.get("groups", []): + # Filter by city if specified + if city: + group_city = group.get("city", "") + if group_city.lower() != city.lower(): + continue + + # Collect hostnames from this group for host in group.get("hosts", []): if hostname := host.get("hostname"): hostnames.append(hostname) if hostnames: return random.choice(hostnames) + elif city: + # No servers found for the specified city + raise ValueError( + f"No servers found in city '{city}' for country code '{country_code}'. " + "Try a different city or check the city name spelling." + ) return None diff --git a/uv.lock b/uv.lock index 334b91d..2c37609 100644 --- a/uv.lock +++ b/uv.lock @@ -1090,6 +1090,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/ec/1fb891d8a2660716aadb2143235481d15ed1cbfe3ad669194690b0604492/pycountry-24.6.1-py3-none-any.whl", hash = "sha256:f1a4fb391cd7214f8eefd39556d740adcc233c778a27f8942c8dca351d6ce06f", size = 6335189, upload-time = "2024-06-01T04:11:49.711Z" }, ] +[[package]] +name = "pycountry" +version = "24.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/57/c389fa68c50590881a75b7883eeb3dc15e9e73a0fdc001cdd45c13290c92/pycountry-24.6.1.tar.gz", hash = "sha256:b61b3faccea67f87d10c1f2b0fc0be714409e8fcdcc1315613174f6466c10221", size = 6043910, upload-time = "2024-06-01T04:12:15.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/ec/1fb891d8a2660716aadb2143235481d15ed1cbfe3ad669194690b0604492/pycountry-24.6.1-py3-none-any.whl", hash = "sha256:f1a4fb391cd7214f8eefd39556d740adcc233c778a27f8942c8dca351d6ce06f", size = 6335189, upload-time = "2024-06-01T04:11:49.711Z" }, +] + [[package]] name = "pycparser" version = "2.22" From 4b30090d87acbcf44fc6ebf53029f07d4e8aadc3 Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 24 Jan 2026 13:36:04 -0700 Subject: [PATCH 15/38] feat(gluetun): improve VPN connection display and Windscribe support --- .pre-commit-config.yaml | 1 + docs/VPN_PROXY_SETUP.md | 574 ++----- pyproject.toml | 1 + unshackle/commands/dl.py | 74 +- unshackle/commands/remote_auth.py | 1 - unshackle/core/crypto.py | 5 - unshackle/core/downloaders/n_m3u8dl_re.py | 2 +- unshackle/core/proxies/gluetun.py | 171 +- unshackle/core/titles/episode.py | 8 +- uv.lock | 1821 +++++++++++---------- 10 files changed, 1198 insertions(+), 1460 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 51fe152..a2f5adb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,6 +11,7 @@ repos: rev: v0.4.0 hooks: - id: poetry-ruff-check + args: [--fix] - repo: https://github.com/pycqa/isort rev: 6.0.1 hooks: diff --git a/docs/VPN_PROXY_SETUP.md b/docs/VPN_PROXY_SETUP.md index c74dc88..1c6c096 100644 --- a/docs/VPN_PROXY_SETUP.md +++ b/docs/VPN_PROXY_SETUP.md @@ -1,526 +1,136 @@ -# VPN-to-HTTP Proxy Bridge for Unshackle +# VPN Proxy Setup for Unshackle ## Overview -This guide explains how to use **Gluetun** - a Docker-based VPN client that creates an isolated HTTP proxy from VPN services (including WireGuard). This allows Unshackle to use VPN providers like ExpressVPN, Windscribe, NordVPN, and many others without affecting your system's normal internet connection. +Unshackle has **native Gluetun integration** that automatically creates and manages Docker containers to bridge VPN connections to HTTP proxies. Simply configure your VPN credentials in `unshackle.yaml` and use `--proxy gluetun::`. -> **Note**: Unshackle now has **native Gluetun integration**! You can use `--proxy gluetun:windscribe:us` directly without manual Docker setup. See [CONFIG.md](../CONFIG.md#gluetun-dict) for configuration. The guide below is for advanced users who want to manage Gluetun containers manually. +## Why Use VPN Proxies? -## Why This Approach? +- **Network Isolation**: VPN runs in a Docker container, doesn't affect your system's internet +- **Easy Switching**: Switch between regions without reconfiguring anything +- **Multiple Regions**: Use different VPN locations for different downloads -- **Network Isolation**: VPN connection runs in Docker container, doesn't affect host system -- **HTTP Proxy Interface**: Exposes standard HTTP proxy that Unshackle can use directly -- **WireGuard Support**: Modern, fast, and secure VPN protocol -- **Kill Switch**: Built-in protection prevents IP leaks if VPN disconnects -- **Multi-Provider**: Supports 50+ VPN providers out of the box -- **Cross-Platform**: Works on Linux and Windows (via Docker Desktop or WSL2) +## Requirements -## Supported VPN Providers +- Docker must be installed and running +- Verify with: `unshackle env check` -Gluetun supports many providers including: -- ExpressVPN -- Windscribe -- NordVPN -- Surfshark -- ProtonVPN -- Private Internet Access -- Mullvad -- And 50+ more +## Configuration -Full list: https://github.com/qdm12/gluetun/wiki - -## Prerequisites - -### Linux -```bash -# Install Docker -curl -fsSL https://get.docker.com -o get-docker.sh -sudo sh get-docker.sh -sudo usermod -aG docker $USER -# Log out and back in for group changes to take effect -``` - -### Windows -- Install [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop/) -- Enable WSL2 backend (recommended) - -## Setup Instructions - -### 1. Create Gluetun Configuration Directory - -```bash -mkdir -p ~/gluetun-config -cd ~/gluetun-config -``` - -### 2. Create Docker Compose File - -Create `docker-compose.yml` with your VPN provider configuration: - -#### Example: Windscribe with WireGuard +Add your VPN provider credentials to `unshackle.yaml`: ```yaml -version: "3" -services: - gluetun: - image: qmcgaw/gluetun:latest - container_name: gluetun - cap_add: - - NET_ADMIN - devices: - - /dev/net/tun:/dev/net/tun - ports: - - 8888:8888/tcp # HTTP proxy - - 8388:8388/tcp # Shadowsocks (optional) - - 8388:8388/udp # Shadowsocks (optional) - environment: - # VPN Provider Settings - - VPN_SERVICE_PROVIDER=windscribe - - VPN_TYPE=wireguard +gluetun: + base_port: 8888 # Starting port for HTTP proxies + auto_cleanup: true # Remove containers when done + container_prefix: "unshackle-gluetun" + verify_ip: true # Verify VPN IP matches expected region - # Get these from your Windscribe account - - WIREGUARD_PRIVATE_KEY=your_private_key_here - - WIREGUARD_ADDRESSES=your_address_here - - WIREGUARD_PRESHARED_KEY=your_preshared_key_here # if applicable + providers: + windscribe: + vpn_type: openvpn + credentials: + username: "YOUR_OPENVPN_USERNAME" + password: "YOUR_OPENVPN_PASSWORD" + server_countries: + us: US + uk: GB + ca: CA - # Server location (optional) - - SERVER_COUNTRIES=US - # or specific city - # - SERVER_CITIES=New York - - # HTTP Proxy Settings - - HTTPPROXY=on - - HTTPPROXY_LOG=on - - HTTPPROXY_LISTENING_ADDRESS=:8888 - - # Timezone - - TZ=America/New_York - - # Logging - - LOG_LEVEL=info - - restart: unless-stopped - - # Health check - healthcheck: - test: ["CMD", "wget", "--spider", "-q", "https://api.ipify.org"] - interval: 30s - timeout: 10s - retries: 3 + nordvpn: + vpn_type: openvpn + credentials: + username: "YOUR_SERVICE_USERNAME" + password: "YOUR_SERVICE_PASSWORD" + server_countries: + us: US + de: DE ``` -#### Example: ExpressVPN with WireGuard +## Getting Your VPN Credentials -```yaml -version: "3" -services: - gluetun: - image: qmcgaw/gluetun:latest - container_name: gluetun - cap_add: - - NET_ADMIN - devices: - - /dev/net/tun:/dev/net/tun - ports: - - 8888:8888/tcp # HTTP proxy - environment: - - VPN_SERVICE_PROVIDER=expressvpn - - VPN_TYPE=wireguard +### Windscribe - # Get these from ExpressVPN's WireGuard configuration - - WIREGUARD_PRIVATE_KEY=your_private_key_here - - WIREGUARD_ADDRESSES=your_address_here +1. Go to [windscribe.com/getconfig/openvpn](https://windscribe.com/getconfig/openvpn) +2. Generate a config file for any location +3. Copy the username and password shown - - HTTPPROXY=on - - HTTPPROXY_LISTENING_ADDRESS=:8888 - - TZ=America/New_York - - LOG_LEVEL=info +> **Note**: Windscribe uses region names like "US East" instead of country codes. Unshackle automatically converts codes like `us`, `ca`, `uk` to the correct region names. - restart: unless-stopped -``` +### NordVPN -#### Example: NordVPN with WireGuard - -```yaml -version: "3" -services: - gluetun: - image: qmcgaw/gluetun:latest - container_name: gluetun - cap_add: - - NET_ADMIN - devices: - - /dev/net/tun:/dev/net/tun - ports: - - 8888:8888/tcp # HTTP proxy - environment: - - VPN_SERVICE_PROVIDER=nordvpn - - VPN_TYPE=wireguard - - # NordVPN token (get from NordVPN dashboard) - - WIREGUARD_PRIVATE_KEY=your_private_key_here - - WIREGUARD_ADDRESSES=your_address_here - - - SERVER_COUNTRIES=US - - - HTTPPROXY=on - - HTTPPROXY_LISTENING_ADDRESS=:8888 - - TZ=America/New_York - - LOG_LEVEL=info - - restart: unless-stopped -``` - -### 3. Getting Your WireGuard Credentials - -#### Windscribe -1. Log into Windscribe account -2. Go to "My Account" → "WireGuard" -3. Generate a config file for your desired location -4. Extract the private key and addresses from the config - -#### ExpressVPN -1. Log into ExpressVPN -2. Navigate to the manual configuration section -3. Select WireGuard and download the configuration -4. Extract credentials from the config file - -#### NordVPN 1. Log into NordVPN dashboard -2. Go to Services → NordVPN → Manual setup -3. Generate WireGuard credentials -4. Copy the private key and addresses +2. Go to Services > NordVPN > Manual setup +3. Copy your service credentials (not your account email/password) -### 4. Start Gluetun Container +### Other Providers + +Gluetun supports 50+ VPN providers. See the [Gluetun Wiki](https://github.com/qdm12/gluetun-wiki/tree/main/setup/providers) for provider-specific setup instructions. + +## Usage + +Use the `--proxy` flag with the format `gluetun::`: ```bash -cd ~/gluetun-config -docker-compose up -d +# Connect via Windscribe to US +unshackle dl SERVICE CONTENT_ID --proxy gluetun:windscribe:us + +# Connect via NordVPN to Germany +unshackle dl SERVICE CONTENT_ID --proxy gluetun:nordvpn:de ``` -Check logs to verify connection: -```bash -docker logs gluetun -f -``` +Unshackle will automatically: -You should see messages indicating successful VPN connection and HTTP proxy starting on port 8888. - -### 5. Test the Proxy - -```bash -# Test that the proxy works -curl -x http://localhost:8888 https://api.ipify.org - -# This should show your VPN's IP address, not your real IP -``` - -## Integrating with Unshackle - -### Option 1: Using Basic Proxy Configuration - -Add to your Unshackle config (`~/.config/unshackle/config.yaml`): - -```yaml -proxies: - Basic: - us: "http://localhost:8888" - uk: "http://localhost:8888" # if you have multiple Gluetun containers -``` - -Then use in Unshackle: -```bash -uv run unshackle dl SERVICE_NAME CONTENT_ID --proxy us -``` - -### Option 2: Creating Multiple VPN Proxy Containers - -You can run multiple Gluetun containers for different regions: - -**gluetun-us.yml:** -```yaml -version: "3" -services: - gluetun-us: - image: qmcgaw/gluetun:latest - container_name: gluetun-us - cap_add: - - NET_ADMIN - devices: - - /dev/net/tun:/dev/net/tun - ports: - - 8888:8888/tcp # HTTP proxy - environment: - - VPN_SERVICE_PROVIDER=windscribe - - VPN_TYPE=wireguard - - SERVER_COUNTRIES=US - - WIREGUARD_PRIVATE_KEY=your_key - - WIREGUARD_ADDRESSES=your_address - - HTTPPROXY=on - - HTTPPROXY_LISTENING_ADDRESS=:8888 - restart: unless-stopped -``` - -**gluetun-uk.yml:** -```yaml -version: "3" -services: - gluetun-uk: - image: qmcgaw/gluetun:latest - container_name: gluetun-uk - cap_add: - - NET_ADMIN - devices: - - /dev/net/tun:/dev/net/tun - ports: - - 8889:8888/tcp # Different host port - environment: - - VPN_SERVICE_PROVIDER=windscribe - - VPN_TYPE=wireguard - - SERVER_COUNTRIES=GB - - WIREGUARD_PRIVATE_KEY=your_key - - WIREGUARD_ADDRESSES=your_address - - HTTPPROXY=on - - HTTPPROXY_LISTENING_ADDRESS=:8888 - restart: unless-stopped -``` - -Then in Unshackle config: -```yaml -proxies: - Basic: - us: "http://localhost:8888" - uk: "http://localhost:8889" - ca: "http://localhost:8890" -``` - -### Option 3: Using with Authentication (Recommended for Security) - -Add authentication to your Gluetun proxy: - -```yaml -environment: - - HTTPPROXY=on - - HTTPPROXY_LISTENING_ADDRESS=:8888 - - HTTPPROXY_USER=myusername - - HTTPPROXY_PASSWORD=mypassword -``` - -Then in Unshackle config: -```yaml -proxies: - Basic: - us: "http://myusername:mypassword@localhost:8888" -``` - -## Advanced Features - -### Port Forwarding (for torrenting services) - -Some VPN providers support port forwarding: - -```yaml -environment: - - VPN_PORT_FORWARDING=on - - VPN_PORT_FORWARDING_LISTENING_PORT=8000 -``` - -### SOCKS5 Proxy (Alternative to HTTP) - -Gluetun also supports SOCKS5 proxy: - -```yaml -ports: - - 1080:1080/tcp # SOCKS5 proxy -environment: - - SHADOWSOCKS=on - - SHADOWSOCKS_LISTENING_ADDRESS=:1080 -``` - -### DNS Over TLS - -For enhanced privacy: - -```yaml -environment: - - DOT=on - - DOT_PROVIDERS=cloudflare -``` - -### Custom Firewall Rules - -Block specific ports or IPs: - -```yaml -environment: - - FIREWALL_OUTBOUND_SUBNETS=192.168.1.0/24 # Allow LAN access -``` +1. Start a Gluetun Docker container with your credentials +2. Wait for the VPN connection to establish +3. Route your download through the VPN proxy +4. Clean up the container when done (if `auto_cleanup: true`) ## Troubleshooting +### Docker Not Running + +``` +Error: Docker is not running +``` + +Start Docker Desktop or the Docker daemon. + +### Invalid Credentials + +``` +Error: VPN authentication failed +``` + +Verify your credentials are correct. Use VPN service credentials from your provider's manual setup page, not your account login. + ### Container Fails to Start -Check logs: +Check Docker logs: + ```bash -docker logs gluetun +docker logs unshackle-gluetun-windscribe-us ``` -Common issues: -- Missing `NET_ADMIN` capability -- `/dev/net/tun` not available -- Invalid WireGuard credentials +### VPN Connection Timeout -### VPN Not Connecting +If the VPN connection hangs or times out, your network may be blocking the default UDP port 1194. Try using TCP port 443: -1. Verify credentials are correct -2. Check VPN provider status -3. Try different server location -4. Check firewall isn't blocking VPN ports - -### Proxy Not Working - -Test connectivity: -```bash -# Check if port is open -docker exec gluetun netstat -tlnp | grep 8888 - -# Test proxy directly -curl -v -x http://localhost:8888 https://api.ipify.org -``` - -### IP Leak Prevention - -Verify your IP is hidden: -```bash -# Without proxy (should show your real IP) -curl https://api.ipify.org - -# With proxy (should show VPN IP) -curl -x http://localhost:8888 https://api.ipify.org -``` - -### Performance Issues - -- WireGuard is generally faster than OpenVPN -- Try different VPN servers closer to your location -- Check container resource limits -- Monitor with `docker stats gluetun` - -## Managing Gluetun - -### Start Container -```bash -docker-compose up -d -``` - -### Stop Container -```bash -docker-compose down -``` - -### Restart Container -```bash -docker-compose restart -``` - -### Update Gluetun -```bash -docker-compose pull -docker-compose up -d -``` - -### View Logs -```bash -docker logs gluetun -f -``` - -### Check Status -```bash -docker ps | grep gluetun -``` - -## Windows-Specific Notes - -### Using Docker Desktop - -1. Ensure WSL2 backend is enabled in Docker Desktop settings -2. Use PowerShell or WSL2 terminal for commands -3. Access proxy from Windows: `http://localhost:8888` -4. Access from WSL2: `http://host.docker.internal:8888` - -### Using WSL2 Directly - -If running Unshackle in WSL2: ```yaml -proxies: - Basic: - us: "http://localhost:8888" # If Gluetun is in same WSL2 distro - # or - us: "http://host.docker.internal:8888" # If Gluetun is in Docker Desktop +windscribe: + vpn_type: openvpn + openvpn_port: 443 # Use TCP 443 for restricted networks + credentials: + username: "YOUR_USERNAME" + password: "YOUR_PASSWORD" ``` -## Network Isolation Benefits +### Verify VPN Connection -The Docker-based approach provides several benefits: - -1. **Namespace Isolation**: VPN connection exists only in container -2. **No System Route Changes**: Host routing table remains unchanged -3. **No Connection Drops**: Host internet connection unaffected -4. **Easy Switching**: Start/stop VPN without affecting other applications -5. **Multiple Simultaneous VPNs**: Run multiple containers with different locations -6. **Kill Switch**: Automatic with container networking - -## Performance Considerations - -- **WireGuard**: Modern protocol, faster than OpenVPN, less CPU usage -- **Docker Overhead**: Minimal (< 5% performance impact) -- **Memory Usage**: ~50-100MB per container -- **Network Latency**: Negligible with localhost connection - -## Security Considerations - -1. **Enable authentication** on HTTP proxy (HTTPPROXY_USER/PASSWORD) -2. **Bind to localhost only** (don't expose 0.0.0.0 unless needed) -3. **Use Docker networks** for container-to-container communication -4. **Keep Gluetun updated** for security patches -5. **Monitor logs** for unauthorized access attempts +The `verify_ip` option checks that your IP matches the expected region. If verification fails, try a different server location in your provider's settings. ## References -- [Gluetun GitHub Repository](https://github.com/qdm12/gluetun) -- [Gluetun Wiki - Setup Guides](https://github.com/qdm12/gluetun/wiki) -- [Windscribe Setup Guide](https://github.com/qdm12/gluetun/wiki/Windscribe) -- [Docker Installation](https://docs.docker.com/engine/install/) - -## Alternative Solutions - -If Gluetun doesn't meet your needs, consider: - -### 1. **Pritunl Client + Tinyproxy** -- Run Pritunl in Docker with Tinyproxy -- More complex setup but more control - -### 2. **OpenConnect + Privoxy** -- For Cisco AnyConnect VPNs -- Network namespace isolation on Linux - -### 3. **WireGuard + SOCKS5 Proxy** -- Manual WireGuard setup with microsocks/dante -- Maximum control but requires networking knowledge - -### 4. **Network Namespaces (Linux Only)** -```bash -# Create namespace -sudo ip netns add vpn - -# Setup WireGuard in namespace -sudo ip netns exec vpn wg-quick up wg0 - -# Run proxy in namespace -sudo ip netns exec vpn tinyproxy -d -c /etc/tinyproxy.conf -``` - -However, **Gluetun is recommended** for its ease of use, maintenance, and cross-platform support. - -## Conclusion - -Using Gluetun provides a robust, isolated, and easy-to-manage solution for connecting Unshackle to VPN services that don't offer HTTP proxies. The Docker-based approach ensures your system's network remains stable while giving you full VPN benefits for Unshackle downloads. +- [Gluetun GitHub](https://github.com/qdm12/gluetun) +- [Gluetun Wiki - Provider Setup](https://github.com/qdm12/gluetun-wiki) +- [CONFIG.md - Full gluetun options](../CONFIG.md#gluetun-dict) diff --git a/pyproject.toml b/pyproject.toml index d9e8476..2da4099 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ dependencies = [ "pysubs2>=1.7.0,<2", "PyExecJS>=1.5.1,<2", "pycountry>=24.6.1", + "language-data>=1.4.0", ] [project.urls] diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 3252abe..6d15f97 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -97,11 +97,7 @@ class dl: return None def prepare_temp_font( - self, - font_name: str, - matched_font: Path, - system_fonts: dict[str, Path], - temp_font_files: list[Path] + self, font_name: str, matched_font: Path, system_fonts: dict[str, Path], temp_font_files: list[Path] ) -> Path: """ Copy system font to temp and log if using fallback. @@ -116,10 +112,7 @@ class dl: Path to temp font file """ # Find the matched name for logging - matched_name = next( - (name for name, path in system_fonts.items() if path == matched_font), - None - ) + matched_name = next((name for name, path in system_fonts.items() if path == matched_font), None) if matched_name and matched_name.lower() != font_name.lower(): self.log.info(f"Using '{matched_name}' as fallback for '{font_name}'") @@ -136,10 +129,7 @@ class dl: return temp_path def attach_subtitle_fonts( - self, - font_names: list[str], - title: Title_T, - temp_font_files: list[Path] + self, font_names: list[str], title: Title_T, temp_font_files: list[Path] ) -> tuple[int, list[str]]: """ Attach fonts for subtitle rendering. @@ -672,16 +662,21 @@ class dl: self.log.info(f"Loaded {proxy_provider.__class__.__name__}: {proxy_provider}") if proxy: - # Store original proxy query for service-specific proxy_map - original_proxy_query = proxy requested_provider = None if re.match(r"^[a-z]+:.+$", proxy, re.IGNORECASE): # requesting proxy from a specific proxy provider requested_provider, proxy = proxy.split(":", maxsplit=1) # Match simple region codes (us, ca, uk1) or provider:region format (nordvpn:ca, windscribe:us) - if re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE) or re.match(r"^[a-z]+:[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE): + if re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE) or re.match( + r"^[a-z]+:[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE + ): proxy = proxy.lower() - with console.status(f"Getting a Proxy to {proxy}...", spinner="dots"): + status_msg = ( + f"Connecting to VPN ({proxy})..." + if requested_provider == "gluetun" + else f"Getting a Proxy to {proxy}..." + ) + with console.status(status_msg, spinner="dots"): if requested_provider: proxy_provider = next( (x for x in self.proxy_providers if x.__class__.__name__.lower() == requested_provider), @@ -690,18 +685,40 @@ class dl: if not proxy_provider: self.log.error(f"The proxy provider '{requested_provider}' was not recognised.") sys.exit(1) + proxy_query = proxy # Save query before overwriting with URI proxy_uri = proxy_provider.get_proxy(proxy) if not proxy_uri: self.log.error(f"The proxy provider {requested_provider} had no proxy for {proxy}") sys.exit(1) proxy = ctx.params["proxy"] = proxy_uri - self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}") + # Show connection info for Gluetun (IP, location) instead of proxy URL + if hasattr(proxy_provider, "get_connection_info"): + conn_info = proxy_provider.get_connection_info(proxy_query) + if conn_info and conn_info.get("public_ip"): + location_parts = [conn_info.get("city"), conn_info.get("country")] + location = ", ".join(p for p in location_parts if p) + self.log.info(f"VPN Connected: {conn_info['public_ip']} ({location})") + else: + self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}") + else: + self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}") else: for proxy_provider in self.proxy_providers: + proxy_query = proxy # Save query before overwriting with URI proxy_uri = proxy_provider.get_proxy(proxy) if proxy_uri: proxy = ctx.params["proxy"] = proxy_uri - self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}") + # Show connection info for Gluetun (IP, location) instead of proxy URL + if hasattr(proxy_provider, "get_connection_info"): + conn_info = proxy_provider.get_connection_info(proxy_query) + if conn_info and conn_info.get("public_ip"): + location_parts = [conn_info.get("city"), conn_info.get("country")] + location = ", ".join(p for p in location_parts if p) + self.log.info(f"VPN Connected: {conn_info['public_ip']} ({location})") + else: + self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}") + else: + self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}") break # Store proxy query info for service-specific overrides ctx.params["proxy_query"] = proxy @@ -1069,7 +1086,9 @@ class dl: title.tracks.add(non_sdh_sub) events.subscribe( events.Types.TRACK_MULTIPLEX, - lambda track, sub_id=non_sdh_sub.id: (track.strip_hearing_impaired()) if track.id == sub_id else None, + lambda track, sub_id=non_sdh_sub.id: (track.strip_hearing_impaired()) + if track.id == sub_id + else None, ) with console.status("Sorting tracks by language and bitrate...", spinner="dots"): @@ -1339,7 +1358,16 @@ class dl: self.log.error(f"There's no {processed_lang} Audio Track, cannot continue...") sys.exit(1) - if video_only or audio_only or subs_only or chapters_only or no_subs or no_audio or no_chapters or no_video: + if ( + video_only + or audio_only + or subs_only + or chapters_only + or no_subs + or no_audio + or no_chapters + or no_video + ): keep_videos = False keep_audio = False keep_subtitles = False @@ -1579,9 +1607,7 @@ class dl: if line.startswith("Style: "): font_names.append(line.removeprefix("Style: ").split(",")[1].strip()) - font_count, missing_fonts = self.attach_subtitle_fonts( - font_names, title, temp_font_files - ) + font_count, missing_fonts = self.attach_subtitle_fonts(font_names, title, temp_font_files) if font_count: self.log.info(f"Attached {font_count} fonts for the Subtitles") diff --git a/unshackle/commands/remote_auth.py b/unshackle/commands/remote_auth.py index 76f6ad0..23b6de2 100644 --- a/unshackle/commands/remote_auth.py +++ b/unshackle/commands/remote_auth.py @@ -80,7 +80,6 @@ def status_command(remote: Optional[str]) -> None: from unshackle.core.local_session_cache import get_local_session_cache - # Get local session cache cache = get_local_session_cache() diff --git a/unshackle/core/crypto.py b/unshackle/core/crypto.py index d951bd6..4bba793 100644 --- a/unshackle/core/crypto.py +++ b/unshackle/core/crypto.py @@ -1,18 +1,13 @@ """Cryptographic utilities for secure remote service authentication.""" import base64 -import hashlib import json import logging -import secrets -import time from pathlib import Path from typing import Any, Dict, Optional, Tuple try: from nacl.public import Box, PrivateKey, PublicKey - from nacl.secret import SecretBox - from nacl.utils import random NACL_AVAILABLE = True except ImportError: diff --git a/unshackle/core/downloaders/n_m3u8dl_re.py b/unshackle/core/downloaders/n_m3u8dl_re.py index 3e69f1c..a73502a 100644 --- a/unshackle/core/downloaders/n_m3u8dl_re.py +++ b/unshackle/core/downloaders/n_m3u8dl_re.py @@ -10,7 +10,7 @@ import requests from requests.cookies import cookiejar_from_dict, get_cookie_header from unshackle.core import binaries -from unshackle.core.binaries import FFMPEG, ShakaPackager, Mp4decrypt +from unshackle.core.binaries import FFMPEG, Mp4decrypt, ShakaPackager from unshackle.core.config import config from unshackle.core.console import console from unshackle.core.constants import DOWNLOAD_CANCELLED diff --git a/unshackle/core/proxies/gluetun.py b/unshackle/core/proxies/gluetun.py index 83986d6..9063816 100644 --- a/unshackle/core/proxies/gluetun.py +++ b/unshackle/core/proxies/gluetun.py @@ -102,6 +102,62 @@ class Gluetun(Proxy): "purevpn": "purevpn", } + # Windscribe uses specific region names instead of country codes + # See: https://github.com/qdm12/gluetun-wiki/blob/main/setup/providers/windscribe.md + WINDSCRIBE_REGION_MAP = { + # Country codes to Windscribe region names + "us": "US East", + "us-east": "US East", + "us-west": "US West", + "us-central": "US Central", + "ca": "Canada East", + "ca-east": "Canada East", + "ca-west": "Canada West", + "uk": "United Kingdom", + "gb": "United Kingdom", + "de": "Germany", + "fr": "France", + "nl": "Netherlands", + "au": "Australia", + "jp": "Japan", + "sg": "Singapore", + "hk": "Hong Kong", + "kr": "South Korea", + "in": "India", + "it": "Italy", + "es": "Spain", + "ch": "Switzerland", + "se": "Sweden", + "no": "Norway", + "dk": "Denmark", + "fi": "Finland", + "at": "Austria", + "be": "Belgium", + "ie": "Ireland", + "pl": "Poland", + "pt": "Portugal", + "cz": "Czech Republic", + "ro": "Romania", + "hu": "Hungary", + "gr": "Greece", + "tr": "Turkey", + "ru": "Russia", + "ua": "Ukraine", + "br": "Brazil", + "mx": "Mexico", + "ar": "Argentina", + "za": "South Africa", + "nz": "New Zealand", + "th": "Thailand", + "ph": "Philippines", + "id": "Indonesia", + "my": "Malaysia", + "vn": "Vietnam", + "tw": "Taiwan", + "ae": "United Arab Emirates", + "il": "Israel", + } + def __init__( self, providers: Optional[dict] = None, @@ -196,9 +252,7 @@ class Gluetun(Proxy): # Parse query parts = query.split(":") if len(parts) != 2: - raise ValueError( - f"Invalid query format: '{query}'. Expected 'provider:region' (e.g., 'windscribe:us')" - ) + raise ValueError(f"Invalid query format: '{query}'. Expected 'provider:region' (e.g., 'windscribe:us')") provider_name = parts[0].lower() region = parts[1].lower() @@ -206,9 +260,7 @@ class Gluetun(Proxy): # Check if provider is configured if provider_name not in self.providers: available = ", ".join(self.providers.keys()) - raise ValueError( - f"VPN provider '{provider_name}' not configured. Available providers: {available}" - ) + raise ValueError(f"VPN provider '{provider_name}' not configured. Available providers: {available}") # Create query key for tracking query_key = f"{provider_name}:{region}" @@ -333,11 +385,11 @@ class Gluetun(Proxy): # Get container logs for better error message logs = self._get_container_logs(container_name, tail=30) error_msg = f"Gluetun container '{container_name}' failed to start" - if hasattr(self, '_last_wait_error') and self._last_wait_error: + if hasattr(self, "_last_wait_error") and self._last_wait_error: error_msg += f": {self._last_wait_error}" if logs: # Extract last few relevant lines - log_lines = [line for line in logs.strip().split('\n') if line.strip()][-5:] + log_lines = [line for line in logs.strip().split("\n") if line.strip()][-5:] error_msg += "\nRecent logs:\n" + "\n".join(log_lines) raise RuntimeError(error_msg) @@ -396,22 +448,49 @@ class Gluetun(Proxy): success=True, ) + def get_connection_info(self, query: str) -> Optional[dict]: + """ + Get connection info for a proxy query. + + Args: + query: Query format "provider:region" (e.g., "windscribe:us") + + Returns: + Dict with connection info including public_ip, country, city, or None if not found. + """ + parts = query.split(":") + if len(parts) != 2: + return None + + provider_name = parts[0].lower() + region = parts[1].lower() + query_key = f"{provider_name}:{region}" + + container = self.active_containers.get(query_key) + if not container: + return None + + return { + "provider": container.get("provider"), + "region": container.get("region"), + "public_ip": container.get("public_ip"), + "country": container.get("ip_country"), + "city": container.get("ip_city"), + "org": container.get("ip_org"), + } + def _validate_provider_config(self, provider_name: str, config: dict): """Validate a provider's configuration.""" vpn_type = config.get("vpn_type", "wireguard").lower() credentials = config.get("credentials", {}) if vpn_type not in ["wireguard", "openvpn"]: - raise ValueError( - f"Provider '{provider_name}': Invalid vpn_type '{vpn_type}'. Use 'wireguard' or 'openvpn'" - ) + raise ValueError(f"Provider '{provider_name}': Invalid vpn_type '{vpn_type}'. Use 'wireguard' or 'openvpn'") if vpn_type == "wireguard": # private_key is always required for WireGuard if "private_key" not in credentials: - raise ValueError( - f"Provider '{provider_name}': WireGuard requires 'private_key' in credentials" - ) + raise ValueError(f"Provider '{provider_name}': WireGuard requires 'private_key' in credentials") # Provider-specific WireGuard requirements based on Gluetun wiki: # - NordVPN, ProtonVPN: only private_key required @@ -435,9 +514,7 @@ class Gluetun(Proxy): # Providers that require addresses (but not preshared_key) elif provider_lower in ["surfshark", "mullvad", "ivpn"]: if "addresses" not in credentials: - raise ValueError( - f"Provider '{provider_name}': WireGuard requires 'addresses' in credentials" - ) + raise ValueError(f"Provider '{provider_name}': WireGuard requires 'addresses' in credentials") elif vpn_type == "openvpn": if "username" not in credentials or "password" not in credentials: @@ -651,7 +728,12 @@ class Gluetun(Proxy): # Use country/city selection if country: if uses_regions: - env_vars["SERVER_REGIONS"] = country + # Convert country code to provider-specific region name + if gluetun_provider == "windscribe": + region_name = self.WINDSCRIBE_REGION_MAP.get(country.lower(), country) + env_vars["SERVER_REGIONS"] = region_name + else: + env_vars["SERVER_REGIONS"] = country else: env_vars["SERVER_COUNTRIES"] = country if city: @@ -666,6 +748,16 @@ class Gluetun(Proxy): # Merge extra environment variables env_vars.update(extra_env) + # Debug log environment variables (redact sensitive values) + if debug_logger: + safe_env = {k: ("***" if "KEY" in k or "PASSWORD" in k else v) for k, v in env_vars.items()} + debug_logger.log( + level="DEBUG", + operation="gluetun_env_vars", + message=f"Environment variables for {container_name}", + context={"env_vars": safe_env, "gluetun_provider": gluetun_provider}, + ) + # Build docker run command cmd = [ "docker", @@ -791,7 +883,7 @@ class Gluetun(Proxy): return None # Parse port from output like "map[8888/tcp:[{127.0.0.1 8888}]]" - port_match = re.search(r'127\.0\.0\.1\s+(\d+)', result.stdout) + port_match = re.search(r"127\.0\.0\.1\s+(\d+)", result.stdout) if not port_match: return None @@ -854,11 +946,9 @@ class Gluetun(Proxy): Returns: True if container is ready, False if it failed or timed out """ - log = logging.getLogger("Gluetun") debug_logger = get_debug_logger() start_time = time.time() last_error = None - last_status = None if debug_logger: debug_logger.log( @@ -900,21 +990,6 @@ class Gluetun(Proxy): proxy_ready = "[http proxy] listening" in all_logs vpn_ready = "initialization sequence completed" in all_logs - # Log status changes to help debug slow connections - current_status = None - if vpn_ready: - current_status = "VPN connected" - elif "peer connection initiated" in all_logs: - current_status = "VPN connecting..." - elif "[openvpn]" in all_logs or "[wireguard]" in all_logs: - current_status = "Starting VPN..." - elif "[firewall]" in all_logs: - current_status = "Configuring firewall..." - - if current_status and current_status != last_status: - log.info(current_status) - last_status = current_status - if proxy_ready and vpn_ready: # Give a brief moment for the proxy to fully initialize time.sleep(1) @@ -947,7 +1022,7 @@ class Gluetun(Proxy): for error in error_indicators: if error in all_logs: # Extract the error line for better messaging - for line in (stdout + stderr).split('\n'): + for line in (stdout + stderr).split("\n"): if error in line.lower(): last_error = line.strip() break @@ -975,7 +1050,6 @@ class Gluetun(Proxy): "container_name": container_name, "timeout": timeout, "last_error": last_error, - "last_status": last_status, }, success=False, duration_ms=duration_ms, @@ -986,10 +1060,7 @@ class Gluetun(Proxy): """Get exit information for a stopped container.""" try: result = subprocess.run( - [ - "docker", "inspect", container_name, - "--format", "{{.State.ExitCode}}:{{.State.Error}}" - ], + ["docker", "inspect", container_name, "--format", "{{.State.ExitCode}}:{{.State.Error}}"], capture_output=True, text=True, timeout=5, @@ -998,7 +1069,7 @@ class Gluetun(Proxy): parts = result.stdout.strip().split(":", 1) return { "exit_code": int(parts[0]) if parts[0].isdigit() else -1, - "error": parts[1] if len(parts) > 1 else "" + "error": parts[1] if len(parts) > 1 else "", } return None except (subprocess.TimeoutExpired, FileNotFoundError, ValueError): @@ -1104,7 +1175,13 @@ class Gluetun(Proxy): f"(IP: {ip_info.get('ip')}, City: {ip_info.get('city')})" ) - # Verification successful + # Verification successful - store IP info in container record + if query_key in self.active_containers: + self.active_containers[query_key]["public_ip"] = ip_info.get("ip") + self.active_containers[query_key]["ip_country"] = actual_country + self.active_containers[query_key]["ip_city"] = ip_info.get("city") + self.active_containers[query_key]["ip_org"] = ip_info.get("org") + duration_ms = (time.time() - start_time) * 1000 if debug_logger: debug_logger.log( @@ -1145,7 +1222,7 @@ class Gluetun(Proxy): # Wait before retry (exponential backoff) if attempt < max_retries - 1: - wait_time = 2 ** attempt # 1, 2, 4 seconds + wait_time = 2**attempt # 1, 2, 4 seconds time.sleep(wait_time) # All retries exhausted @@ -1253,9 +1330,9 @@ class Gluetun(Proxy): def __del__(self): """Cleanup containers on object destruction.""" - if hasattr(self, 'auto_cleanup') and self.auto_cleanup: + if hasattr(self, "auto_cleanup") and self.auto_cleanup: try: - if hasattr(self, 'active_containers') and self.active_containers: + if hasattr(self, "active_containers") and self.active_containers: self.cleanup() except Exception: pass diff --git a/unshackle/core/titles/episode.py b/unshackle/core/titles/episode.py index 3522e65..2217ea1 100644 --- a/unshackle/core/titles/episode.py +++ b/unshackle/core/titles/episode.py @@ -112,18 +112,18 @@ class Episode(Title): if config.dash_naming: # Format: Title - SXXEXX - Episode Name name = self.title.replace("$", "S") # e.g., Arli$$ - + # Add year if configured if self.year and config.series_year: name += f" {self.year}" - + # Add season and episode name += f" - S{self.season:02}E{self.number:02}" - + # Add episode name with dash separator if self.name: name += f" - {self.name}" - + name = name.strip() else: # Standard format without extra dashes diff --git a/uv.lock b/uv.lock index 2c37609..5f8c3c2 100644 --- a/uv.lock +++ b/uv.lock @@ -1,13 +1,14 @@ version = 1 +revision = 3 requires-python = ">=3.10, <3.13" [[package]] name = "aiohappyeyeballs" version = "2.6.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760 } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265 }, + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, ] [[package]] @@ -24,59 +25,59 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556 } +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/d6/5aec9313ee6ea9c7cde8b891b69f4ff4001416867104580670a31daeba5b/aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7", size = 738950 }, - { url = "https://files.pythonhosted.org/packages/68/03/8fa90a7e6d11ff20a18837a8e2b5dd23db01aabc475aa9271c8ad33299f5/aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821", size = 496099 }, - { url = "https://files.pythonhosted.org/packages/d2/23/b81f744d402510a8366b74eb420fc0cc1170d0c43daca12d10814df85f10/aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845", size = 491072 }, - { url = "https://files.pythonhosted.org/packages/d5/e1/56d1d1c0dd334cd203dd97706ce004c1aa24b34a813b0b8daf3383039706/aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af", size = 1671588 }, - { url = "https://files.pythonhosted.org/packages/5f/34/8d7f962604f4bc2b4e39eb1220dac7d4e4cba91fb9ba0474b4ecd67db165/aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940", size = 1640334 }, - { url = "https://files.pythonhosted.org/packages/94/1d/fcccf2c668d87337ddeef9881537baee13c58d8f01f12ba8a24215f2b804/aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160", size = 1722656 }, - { url = "https://files.pythonhosted.org/packages/aa/98/c6f3b081c4c606bc1e5f2ec102e87d6411c73a9ef3616fea6f2d5c98c062/aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7", size = 1817625 }, - { url = "https://files.pythonhosted.org/packages/2c/c0/cfcc3d2e11b477f86e1af2863f3858c8850d751ce8dc39c4058a072c9e54/aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455", size = 1672604 }, - { url = "https://files.pythonhosted.org/packages/1e/77/6b4ffcbcac4c6a5d041343a756f34a6dd26174ae07f977a64fe028dda5b0/aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279", size = 1554370 }, - { url = "https://files.pythonhosted.org/packages/f2/f0/e3ddfa93f17d689dbe014ba048f18e0c9f9b456033b70e94349a2e9048be/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e", size = 1642023 }, - { url = "https://files.pythonhosted.org/packages/eb/45/c14019c9ec60a8e243d06d601b33dcc4fd92379424bde3021725859d7f99/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d", size = 1649680 }, - { url = "https://files.pythonhosted.org/packages/9c/fd/09c9451dae5aa5c5ed756df95ff9ef549d45d4be663bafd1e4954fd836f0/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808", size = 1692407 }, - { url = "https://files.pythonhosted.org/packages/a6/81/938bc2ec33c10efd6637ccb3d22f9f3160d08e8f3aa2587a2c2d5ab578eb/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40", size = 1543047 }, - { url = "https://files.pythonhosted.org/packages/f7/23/80488ee21c8d567c83045e412e1d9b7077d27171591a4eb7822586e8c06a/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29", size = 1715264 }, - { url = "https://files.pythonhosted.org/packages/e2/83/259a8da6683182768200b368120ab3deff5370bed93880fb9a3a86299f34/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11", size = 1657275 }, - { url = "https://files.pythonhosted.org/packages/3f/4f/2c41f800a0b560785c10fb316216ac058c105f9be50bdc6a285de88db625/aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd", size = 434053 }, - { url = "https://files.pythonhosted.org/packages/80/df/29cd63c7ecfdb65ccc12f7d808cac4fa2a19544660c06c61a4a48462de0c/aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c", size = 456687 }, - { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051 }, - { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234 }, - { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979 }, - { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297 }, - { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172 }, - { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405 }, - { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449 }, - { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444 }, - { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038 }, - { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156 }, - { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340 }, - { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041 }, - { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024 }, - { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590 }, - { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355 }, - { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701 }, - { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678 }, - { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732 }, - { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293 }, - { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533 }, - { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839 }, - { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932 }, - { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906 }, - { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020 }, - { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181 }, - { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794 }, - { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900 }, - { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239 }, - { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527 }, - { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489 }, - { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852 }, - { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379 }, - { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253 }, - { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407 }, + { url = "https://files.pythonhosted.org/packages/36/d6/5aec9313ee6ea9c7cde8b891b69f4ff4001416867104580670a31daeba5b/aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7", size = 738950, upload-time = "2026-01-03T17:29:13.002Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/8fa90a7e6d11ff20a18837a8e2b5dd23db01aabc475aa9271c8ad33299f5/aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821", size = 496099, upload-time = "2026-01-03T17:29:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/b81f744d402510a8366b74eb420fc0cc1170d0c43daca12d10814df85f10/aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845", size = 491072, upload-time = "2026-01-03T17:29:16.922Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e1/56d1d1c0dd334cd203dd97706ce004c1aa24b34a813b0b8daf3383039706/aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af", size = 1671588, upload-time = "2026-01-03T17:29:18.539Z" }, + { url = "https://files.pythonhosted.org/packages/5f/34/8d7f962604f4bc2b4e39eb1220dac7d4e4cba91fb9ba0474b4ecd67db165/aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940", size = 1640334, upload-time = "2026-01-03T17:29:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/94/1d/fcccf2c668d87337ddeef9881537baee13c58d8f01f12ba8a24215f2b804/aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160", size = 1722656, upload-time = "2026-01-03T17:29:22.531Z" }, + { url = "https://files.pythonhosted.org/packages/aa/98/c6f3b081c4c606bc1e5f2ec102e87d6411c73a9ef3616fea6f2d5c98c062/aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7", size = 1817625, upload-time = "2026-01-03T17:29:24.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c0/cfcc3d2e11b477f86e1af2863f3858c8850d751ce8dc39c4058a072c9e54/aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455", size = 1672604, upload-time = "2026-01-03T17:29:26.099Z" }, + { url = "https://files.pythonhosted.org/packages/1e/77/6b4ffcbcac4c6a5d041343a756f34a6dd26174ae07f977a64fe028dda5b0/aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279", size = 1554370, upload-time = "2026-01-03T17:29:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f0/e3ddfa93f17d689dbe014ba048f18e0c9f9b456033b70e94349a2e9048be/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e", size = 1642023, upload-time = "2026-01-03T17:29:30.002Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/c14019c9ec60a8e243d06d601b33dcc4fd92379424bde3021725859d7f99/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d", size = 1649680, upload-time = "2026-01-03T17:29:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fd/09c9451dae5aa5c5ed756df95ff9ef549d45d4be663bafd1e4954fd836f0/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808", size = 1692407, upload-time = "2026-01-03T17:29:33.392Z" }, + { url = "https://files.pythonhosted.org/packages/a6/81/938bc2ec33c10efd6637ccb3d22f9f3160d08e8f3aa2587a2c2d5ab578eb/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40", size = 1543047, upload-time = "2026-01-03T17:29:34.855Z" }, + { url = "https://files.pythonhosted.org/packages/f7/23/80488ee21c8d567c83045e412e1d9b7077d27171591a4eb7822586e8c06a/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29", size = 1715264, upload-time = "2026-01-03T17:29:36.389Z" }, + { url = "https://files.pythonhosted.org/packages/e2/83/259a8da6683182768200b368120ab3deff5370bed93880fb9a3a86299f34/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11", size = 1657275, upload-time = "2026-01-03T17:29:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4f/2c41f800a0b560785c10fb316216ac058c105f9be50bdc6a285de88db625/aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd", size = 434053, upload-time = "2026-01-03T17:29:40.074Z" }, + { url = "https://files.pythonhosted.org/packages/80/df/29cd63c7ecfdb65ccc12f7d808cac4fa2a19544660c06c61a4a48462de0c/aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c", size = 456687, upload-time = "2026-01-03T17:29:41.819Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, + { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, + { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, + { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, + { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, + { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, + { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, + { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, + { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, + { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, ] [[package]] @@ -90,9 +91,9 @@ dependencies = [ { name = "pyyaml" }, { name = "rfc3339-validator" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/06/00ccb2c8afdde4ca7c3cac424d54715c7d90cdd4e13e1ca71d68f5b2e665/aiohttp_swagger3-0.10.0.tar.gz", hash = "sha256:a333c59328f64dd64587e5f276ee84dc256f587d09f2da6ddaae3812fa4d4f33", size = 1839028 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/06/00ccb2c8afdde4ca7c3cac424d54715c7d90cdd4e13e1ca71d68f5b2e665/aiohttp_swagger3-0.10.0.tar.gz", hash = "sha256:a333c59328f64dd64587e5f276ee84dc256f587d09f2da6ddaae3812fa4d4f33", size = 1839028, upload-time = "2025-02-11T10:51:26.974Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/8f/db4cb843999a3088846d170f38eda2182b50b5733387be8102fed171c53f/aiohttp_swagger3-0.10.0-py3-none-any.whl", hash = "sha256:0ae2d2ba7dbd8ea8fe1cffe8f0197db5d0aa979eb9679bd699ecd87923912509", size = 1826491 }, + { url = "https://files.pythonhosted.org/packages/0a/8f/db4cb843999a3088846d170f38eda2182b50b5733387be8102fed171c53f/aiohttp_swagger3-0.10.0-py3-none-any.whl", hash = "sha256:0ae2d2ba7dbd8ea8fe1cffe8f0197db5d0aa979eb9679bd699ecd87923912509", size = 1826491, upload-time = "2025-02-11T10:51:25.174Z" }, ] [[package]] @@ -103,253 +104,269 @@ dependencies = [ { name = "frozenlist" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007 } +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 }, + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] [[package]] name = "anyio" -version = "4.10.0" +version = "4.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, - { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252 } +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213 }, + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] [[package]] name = "appdirs" version = "1.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470 } +sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566 }, + { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, ] [[package]] name = "async-timeout" version = "5.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274 } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233 }, + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, ] [[package]] name = "attrs" -version = "25.3.0" +version = "25.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] [[package]] name = "beautifulsoup4" -version = "4.14.2" +version = "4.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822 } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392 }, + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] [[package]] name = "brotli" version = "1.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632 } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/10/a090475284fc4a71aed40a96f32e44a7fe5bda39687353dd977720b211b6/brotli-1.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3b90b767916ac44e93a8e28ce6adf8d551e43affb512f2377c732d486ac6514e", size = 863089 }, - { url = "https://files.pythonhosted.org/packages/03/41/17416630e46c07ac21e378c3464815dd2e120b441e641bc516ac32cc51d2/brotli-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6be67c19e0b0c56365c6a76e393b932fb0e78b3b56b711d180dd7013cb1fd984", size = 445442 }, - { url = "https://files.pythonhosted.org/packages/24/31/90cc06584deb5d4fcafc0985e37741fc6b9717926a78674bbb3ce018957e/brotli-1.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bbd5b5ccd157ae7913750476d48099aaf507a79841c0d04a9db4415b14842de", size = 1532658 }, - { url = "https://files.pythonhosted.org/packages/62/17/33bf0c83bcbc96756dfd712201d87342732fad70bb3472c27e833a44a4f9/brotli-1.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f3c908bcc404c90c77d5a073e55271a0a498f4e0756e48127c35d91cf155947", size = 1631241 }, - { url = "https://files.pythonhosted.org/packages/48/10/f47854a1917b62efe29bc98ac18e5d4f71df03f629184575b862ef2e743b/brotli-1.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1b557b29782a643420e08d75aea889462a4a8796e9a6cf5621ab05a3f7da8ef2", size = 1424307 }, - { url = "https://files.pythonhosted.org/packages/e4/b7/f88eb461719259c17483484ea8456925ee057897f8e64487d76e24e5e38d/brotli-1.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81da1b229b1889f25adadc929aeb9dbc4e922bd18561b65b08dd9343cfccca84", size = 1488208 }, - { url = "https://files.pythonhosted.org/packages/26/59/41bbcb983a0c48b0b8004203e74706c6b6e99a04f3c7ca6f4f41f364db50/brotli-1.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ff09cd8c5eec3b9d02d2408db41be150d8891c5566addce57513bf546e3d6c6d", size = 1597574 }, - { url = "https://files.pythonhosted.org/packages/8e/e6/8c89c3bdabbe802febb4c5c6ca224a395e97913b5df0dff11b54f23c1788/brotli-1.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a1778532b978d2536e79c05dac2d8cd857f6c55cd0c95ace5b03740824e0e2f1", size = 1492109 }, - { url = "https://files.pythonhosted.org/packages/ed/9a/4b19d4310b2dbd545c0c33f176b0528fa68c3cd0754e34b2f2bcf56548ae/brotli-1.2.0-cp310-cp310-win32.whl", hash = "sha256:b232029d100d393ae3c603c8ffd7e3fe6f798c5e28ddca5feabb8e8fdb732997", size = 334461 }, - { url = "https://files.pythonhosted.org/packages/ac/39/70981d9f47705e3c2b95c0847dfa3e7a37aa3b7c6030aedc4873081ed005/brotli-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:ef87b8ab2704da227e83a246356a2b179ef826f550f794b2c52cddb4efbd0196", size = 369035 }, - { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110 }, - { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438 }, - { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420 }, - { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619 }, - { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014 }, - { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661 }, - { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150 }, - { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505 }, - { url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451 }, - { url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035 }, - { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543 }, - { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288 }, - { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071 }, - { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913 }, - { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762 }, - { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494 }, - { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302 }, - { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913 }, - { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362 }, - { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115 }, + { url = "https://files.pythonhosted.org/packages/64/10/a090475284fc4a71aed40a96f32e44a7fe5bda39687353dd977720b211b6/brotli-1.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3b90b767916ac44e93a8e28ce6adf8d551e43affb512f2377c732d486ac6514e", size = 863089, upload-time = "2025-11-05T18:38:01.181Z" }, + { url = "https://files.pythonhosted.org/packages/03/41/17416630e46c07ac21e378c3464815dd2e120b441e641bc516ac32cc51d2/brotli-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6be67c19e0b0c56365c6a76e393b932fb0e78b3b56b711d180dd7013cb1fd984", size = 445442, upload-time = "2025-11-05T18:38:02.434Z" }, + { url = "https://files.pythonhosted.org/packages/24/31/90cc06584deb5d4fcafc0985e37741fc6b9717926a78674bbb3ce018957e/brotli-1.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bbd5b5ccd157ae7913750476d48099aaf507a79841c0d04a9db4415b14842de", size = 1532658, upload-time = "2025-11-05T18:38:03.588Z" }, + { url = "https://files.pythonhosted.org/packages/62/17/33bf0c83bcbc96756dfd712201d87342732fad70bb3472c27e833a44a4f9/brotli-1.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f3c908bcc404c90c77d5a073e55271a0a498f4e0756e48127c35d91cf155947", size = 1631241, upload-time = "2025-11-05T18:38:04.582Z" }, + { url = "https://files.pythonhosted.org/packages/48/10/f47854a1917b62efe29bc98ac18e5d4f71df03f629184575b862ef2e743b/brotli-1.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1b557b29782a643420e08d75aea889462a4a8796e9a6cf5621ab05a3f7da8ef2", size = 1424307, upload-time = "2025-11-05T18:38:05.587Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b7/f88eb461719259c17483484ea8456925ee057897f8e64487d76e24e5e38d/brotli-1.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81da1b229b1889f25adadc929aeb9dbc4e922bd18561b65b08dd9343cfccca84", size = 1488208, upload-time = "2025-11-05T18:38:06.613Z" }, + { url = "https://files.pythonhosted.org/packages/26/59/41bbcb983a0c48b0b8004203e74706c6b6e99a04f3c7ca6f4f41f364db50/brotli-1.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ff09cd8c5eec3b9d02d2408db41be150d8891c5566addce57513bf546e3d6c6d", size = 1597574, upload-time = "2025-11-05T18:38:07.838Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e6/8c89c3bdabbe802febb4c5c6ca224a395e97913b5df0dff11b54f23c1788/brotli-1.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a1778532b978d2536e79c05dac2d8cd857f6c55cd0c95ace5b03740824e0e2f1", size = 1492109, upload-time = "2025-11-05T18:38:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/ed/9a/4b19d4310b2dbd545c0c33f176b0528fa68c3cd0754e34b2f2bcf56548ae/brotli-1.2.0-cp310-cp310-win32.whl", hash = "sha256:b232029d100d393ae3c603c8ffd7e3fe6f798c5e28ddca5feabb8e8fdb732997", size = 334461, upload-time = "2025-11-05T18:38:10.729Z" }, + { url = "https://files.pythonhosted.org/packages/ac/39/70981d9f47705e3c2b95c0847dfa3e7a37aa3b7c6030aedc4873081ed005/brotli-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:ef87b8ab2704da227e83a246356a2b179ef826f550f794b2c52cddb4efbd0196", size = 369035, upload-time = "2025-11-05T18:38:11.827Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" }, + { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" }, + { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" }, + { url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" }, + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, ] [[package]] name = "certifi" -version = "2025.8.3" +version = "2026.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386 } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216 }, + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] [[package]] name = "cffi" -version = "1.17.1" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser" }, + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191 }, - { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592 }, - { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 }, - { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 }, - { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 }, - { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 }, - { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 }, - { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 }, - { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 }, - { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 }, - { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 }, - { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 }, - { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 }, - { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 }, - { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, - { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, - { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, - { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, - { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, - { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, - { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, - { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, - { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, - { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, - { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, - { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, - { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, - { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, - { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, - { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, - { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, - { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, - { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, - { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, - { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, ] [[package]] name = "cfgv" -version = "3.4.0" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] [[package]] name = "chardet" version = "5.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618 } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385 }, + { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.3" +version = "3.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371 } +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/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695 }, - { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153 }, - { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428 }, - { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627 }, - { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388 }, - { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077 }, - { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631 }, - { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210 }, - { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739 }, - { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825 }, - { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452 }, - { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483 }, - { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520 }, - { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876 }, - { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083 }, - { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295 }, - { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379 }, - { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018 }, - { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430 }, - { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600 }, - { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616 }, - { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108 }, - { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655 }, - { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223 }, - { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366 }, - { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104 }, - { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830 }, - { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854 }, - { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670 }, - { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501 }, - { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173 }, - { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822 }, - { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543 }, - { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175 }, + { 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/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" +version = "8.3.1" 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 } +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295 }, + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] [[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 } +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 }, + { 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 } +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 = "crccheck" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3c/d1/a943f4f1ca899917cc3fe1cb89d59348edd1b407503e4b02608e8d6b421e/crccheck-1.3.1.tar.gz", hash = "sha256:1544c0110bf0a697d875d4f29dc40d7079f9d4d402a9317383f55f90ca72563a", size = 41696 } +sdist = { url = "https://files.pythonhosted.org/packages/3c/d1/a943f4f1ca899917cc3fe1cb89d59348edd1b407503e4b02608e8d6b421e/crccheck-1.3.1.tar.gz", hash = "sha256:1544c0110bf0a697d875d4f29dc40d7079f9d4d402a9317383f55f90ca72563a", size = 41696, upload-time = "2025-07-10T07:01:08.683Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/b5/68a9054be852e61f31de1be4e8d95802646b93344ce17c2380a1748706fa/crccheck-1.3.1-py3-none-any.whl", hash = "sha256:1680c9a7bb1ca4bec45fa19b8ca64319f10d2ce4eb8b0d25d51cb99a20ca0108", size = 24608 }, + { url = "https://files.pythonhosted.org/packages/01/b5/68a9054be852e61f31de1be4e8d95802646b93344ce17c2380a1748706fa/crccheck-1.3.1-py3-none-any.whl", hash = "sha256:1680c9a7bb1ca4bec45fa19b8ca64319f10d2ce4eb8b0d25d51cb99a20ca0108", size = 24608, upload-time = "2025-07-10T07:01:07.25Z" }, ] [[package]] @@ -359,44 +376,44 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a7/35/c495bffc2056f2dadb32434f1feedd79abde2a7f8363e1974afa9c33c7e2/cryptography-45.0.7.tar.gz", hash = "sha256:4b1654dfc64ea479c242508eb8c724044f1e964a47d1d1cacc5132292d851971", size = 744980 } +sdist = { url = "https://files.pythonhosted.org/packages/a7/35/c495bffc2056f2dadb32434f1feedd79abde2a7f8363e1974afa9c33c7e2/cryptography-45.0.7.tar.gz", hash = "sha256:4b1654dfc64ea479c242508eb8c724044f1e964a47d1d1cacc5132292d851971", size = 744980, upload-time = "2025-09-01T11:15:03.146Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/91/925c0ac74362172ae4516000fe877912e33b5983df735ff290c653de4913/cryptography-45.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3be4f21c6245930688bd9e162829480de027f8bf962ede33d4f8ba7d67a00cee", size = 7041105 }, - { url = "https://files.pythonhosted.org/packages/fc/63/43641c5acce3a6105cf8bd5baeceeb1846bb63067d26dae3e5db59f1513a/cryptography-45.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:67285f8a611b0ebc0857ced2081e30302909f571a46bfa7a3cc0ad303fe015c6", size = 4205799 }, - { url = "https://files.pythonhosted.org/packages/bc/29/c238dd9107f10bfde09a4d1c52fd38828b1aa353ced11f358b5dd2507d24/cryptography-45.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:577470e39e60a6cd7780793202e63536026d9b8641de011ed9d8174da9ca5339", size = 4430504 }, - { url = "https://files.pythonhosted.org/packages/62/62/24203e7cbcc9bd7c94739428cd30680b18ae6b18377ae66075c8e4771b1b/cryptography-45.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4bd3e5c4b9682bc112d634f2c6ccc6736ed3635fc3319ac2bb11d768cc5a00d8", size = 4209542 }, - { url = "https://files.pythonhosted.org/packages/cd/e3/e7de4771a08620eef2389b86cd87a2c50326827dea5528feb70595439ce4/cryptography-45.0.7-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:465ccac9d70115cd4de7186e60cfe989de73f7bb23e8a7aa45af18f7412e75bf", size = 3889244 }, - { url = "https://files.pythonhosted.org/packages/96/b8/bca71059e79a0bb2f8e4ec61d9c205fbe97876318566cde3b5092529faa9/cryptography-45.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:16ede8a4f7929b4b7ff3642eba2bf79aa1d71f24ab6ee443935c0d269b6bc513", size = 4461975 }, - { url = "https://files.pythonhosted.org/packages/58/67/3f5b26937fe1218c40e95ef4ff8d23c8dc05aa950d54200cc7ea5fb58d28/cryptography-45.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8978132287a9d3ad6b54fcd1e08548033cc09dc6aacacb6c004c73c3eb5d3ac3", size = 4209082 }, - { url = "https://files.pythonhosted.org/packages/0e/e4/b3e68a4ac363406a56cf7b741eeb80d05284d8c60ee1a55cdc7587e2a553/cryptography-45.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b6a0e535baec27b528cb07a119f321ac024592388c5681a5ced167ae98e9fff3", size = 4460397 }, - { url = "https://files.pythonhosted.org/packages/22/49/2c93f3cd4e3efc8cb22b02678c1fad691cff9dd71bb889e030d100acbfe0/cryptography-45.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a24ee598d10befaec178efdff6054bc4d7e883f615bfbcd08126a0f4931c83a6", size = 4337244 }, - { url = "https://files.pythonhosted.org/packages/04/19/030f400de0bccccc09aa262706d90f2ec23d56bc4eb4f4e8268d0ddf3fb8/cryptography-45.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fa26fa54c0a9384c27fcdc905a2fb7d60ac6e47d14bc2692145f2b3b1e2cfdbd", size = 4568862 }, - { url = "https://files.pythonhosted.org/packages/29/56/3034a3a353efa65116fa20eb3c990a8c9f0d3db4085429040a7eef9ada5f/cryptography-45.0.7-cp311-abi3-win32.whl", hash = "sha256:bef32a5e327bd8e5af915d3416ffefdbe65ed975b646b3805be81b23580b57b8", size = 2936578 }, - { url = "https://files.pythonhosted.org/packages/b3/61/0ab90f421c6194705a99d0fa9f6ee2045d916e4455fdbb095a9c2c9a520f/cryptography-45.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:3808e6b2e5f0b46d981c24d79648e5c25c35e59902ea4391a0dcb3e667bf7443", size = 3405400 }, - { url = "https://files.pythonhosted.org/packages/63/e8/c436233ddf19c5f15b25ace33979a9dd2e7aa1a59209a0ee8554179f1cc0/cryptography-45.0.7-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bfb4c801f65dd61cedfc61a83732327fafbac55a47282e6f26f073ca7a41c3b2", size = 7021824 }, - { url = "https://files.pythonhosted.org/packages/bc/4c/8f57f2500d0ccd2675c5d0cc462095adf3faa8c52294ba085c036befb901/cryptography-45.0.7-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:81823935e2f8d476707e85a78a405953a03ef7b7b4f55f93f7c2d9680e5e0691", size = 4202233 }, - { url = "https://files.pythonhosted.org/packages/eb/ac/59b7790b4ccaed739fc44775ce4645c9b8ce54cbec53edf16c74fd80cb2b/cryptography-45.0.7-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3994c809c17fc570c2af12c9b840d7cea85a9fd3e5c0e0491f4fa3c029216d59", size = 4423075 }, - { url = "https://files.pythonhosted.org/packages/b8/56/d4f07ea21434bf891faa088a6ac15d6d98093a66e75e30ad08e88aa2b9ba/cryptography-45.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dad43797959a74103cb59c5dac71409f9c27d34c8a05921341fb64ea8ccb1dd4", size = 4204517 }, - { url = "https://files.pythonhosted.org/packages/e8/ac/924a723299848b4c741c1059752c7cfe09473b6fd77d2920398fc26bfb53/cryptography-45.0.7-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce7a453385e4c4693985b4a4a3533e041558851eae061a58a5405363b098fcd3", size = 3882893 }, - { url = "https://files.pythonhosted.org/packages/83/dc/4dab2ff0a871cc2d81d3ae6d780991c0192b259c35e4d83fe1de18b20c70/cryptography-45.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b04f85ac3a90c227b6e5890acb0edbaf3140938dbecf07bff618bf3638578cf1", size = 4450132 }, - { url = "https://files.pythonhosted.org/packages/12/dd/b2882b65db8fc944585d7fb00d67cf84a9cef4e77d9ba8f69082e911d0de/cryptography-45.0.7-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:48c41a44ef8b8c2e80ca4527ee81daa4c527df3ecbc9423c41a420a9559d0e27", size = 4204086 }, - { url = "https://files.pythonhosted.org/packages/5d/fa/1d5745d878048699b8eb87c984d4ccc5da4f5008dfd3ad7a94040caca23a/cryptography-45.0.7-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f3df7b3d0f91b88b2106031fd995802a2e9ae13e02c36c1fc075b43f420f3a17", size = 4449383 }, - { url = "https://files.pythonhosted.org/packages/36/8b/fc61f87931bc030598e1876c45b936867bb72777eac693e905ab89832670/cryptography-45.0.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd342f085542f6eb894ca00ef70236ea46070c8a13824c6bde0dfdcd36065b9b", size = 4332186 }, - { url = "https://files.pythonhosted.org/packages/0b/11/09700ddad7443ccb11d674efdbe9a832b4455dc1f16566d9bd3834922ce5/cryptography-45.0.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1993a1bb7e4eccfb922b6cd414f072e08ff5816702a0bdb8941c247a6b1b287c", size = 4561639 }, - { url = "https://files.pythonhosted.org/packages/71/ed/8f4c1337e9d3b94d8e50ae0b08ad0304a5709d483bfcadfcc77a23dbcb52/cryptography-45.0.7-cp37-abi3-win32.whl", hash = "sha256:18fcf70f243fe07252dcb1b268a687f2358025ce32f9f88028ca5c364b123ef5", size = 2926552 }, - { url = "https://files.pythonhosted.org/packages/bc/ff/026513ecad58dacd45d1d24ebe52b852165a26e287177de1d545325c0c25/cryptography-45.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:7285a89df4900ed3bfaad5679b1e668cb4b38a8de1ccbfc84b05f34512da0a90", size = 3392742 }, - { url = "https://files.pythonhosted.org/packages/13/3e/e42f1528ca1ea82256b835191eab1be014e0f9f934b60d98b0be8a38ed70/cryptography-45.0.7-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:de58755d723e86175756f463f2f0bddd45cc36fbd62601228a3f8761c9f58252", size = 3572442 }, - { url = "https://files.pythonhosted.org/packages/59/aa/e947693ab08674a2663ed2534cd8d345cf17bf6a1facf99273e8ec8986dc/cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a20e442e917889d1a6b3c570c9e3fa2fdc398c20868abcea268ea33c024c4083", size = 4142233 }, - { url = "https://files.pythonhosted.org/packages/24/06/09b6f6a2fc43474a32b8fe259038eef1500ee3d3c141599b57ac6c57612c/cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:258e0dff86d1d891169b5af222d362468a9570e2532923088658aa866eb11130", size = 4376202 }, - { url = "https://files.pythonhosted.org/packages/00/f2/c166af87e95ce6ae6d38471a7e039d3a0549c2d55d74e059680162052824/cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d97cf502abe2ab9eff8bd5e4aca274da8d06dd3ef08b759a8d6143f4ad65d4b4", size = 4141900 }, - { url = "https://files.pythonhosted.org/packages/16/b9/e96e0b6cb86eae27ea51fa8a3151535a18e66fe7c451fa90f7f89c85f541/cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:c987dad82e8c65ebc985f5dae5e74a3beda9d0a2a4daf8a1115f3772b59e5141", size = 4375562 }, - { url = "https://files.pythonhosted.org/packages/36/d0/36e8ee39274e9d77baf7d0dafda680cba6e52f3936b846f0d56d64fec915/cryptography-45.0.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c13b1e3afd29a5b3b2656257f14669ca8fa8d7956d509926f0b130b600b50ab7", size = 3322781 }, - { url = "https://files.pythonhosted.org/packages/99/4e/49199a4c82946938a3e05d2e8ad9482484ba48bbc1e809e3d506c686d051/cryptography-45.0.7-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a862753b36620af6fc54209264f92c716367f2f0ff4624952276a6bbd18cbde", size = 3584634 }, - { url = "https://files.pythonhosted.org/packages/16/ce/5f6ff59ea9c7779dba51b84871c19962529bdcc12e1a6ea172664916c550/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:06ce84dc14df0bf6ea84666f958e6080cdb6fe1231be2a51f3fc1267d9f3fb34", size = 4149533 }, - { url = "https://files.pythonhosted.org/packages/ce/13/b3cfbd257ac96da4b88b46372e662009b7a16833bfc5da33bb97dd5631ae/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d0c5c6bac22b177bf8da7435d9d27a6834ee130309749d162b26c3105c0795a9", size = 4385557 }, - { url = "https://files.pythonhosted.org/packages/1c/c5/8c59d6b7c7b439ba4fc8d0cab868027fd095f215031bc123c3a070962912/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:2f641b64acc00811da98df63df7d59fd4706c0df449da71cb7ac39a0732b40ae", size = 4149023 }, - { url = "https://files.pythonhosted.org/packages/55/32/05385c86d6ca9ab0b4d5bb442d2e3d85e727939a11f3e163fc776ce5eb40/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:f5414a788ecc6ee6bc58560e85ca624258a55ca434884445440a810796ea0e0b", size = 4385722 }, - { url = "https://files.pythonhosted.org/packages/23/87/7ce86f3fa14bc11a5a48c30d8103c26e09b6465f8d8e9d74cf7a0714f043/cryptography-45.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1f3d56f73595376f4244646dd5c5870c14c196949807be39e79e7bd9bac3da63", size = 3332908 }, + { url = "https://files.pythonhosted.org/packages/0c/91/925c0ac74362172ae4516000fe877912e33b5983df735ff290c653de4913/cryptography-45.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3be4f21c6245930688bd9e162829480de027f8bf962ede33d4f8ba7d67a00cee", size = 7041105, upload-time = "2025-09-01T11:13:59.684Z" }, + { url = "https://files.pythonhosted.org/packages/fc/63/43641c5acce3a6105cf8bd5baeceeb1846bb63067d26dae3e5db59f1513a/cryptography-45.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:67285f8a611b0ebc0857ced2081e30302909f571a46bfa7a3cc0ad303fe015c6", size = 4205799, upload-time = "2025-09-01T11:14:02.517Z" }, + { url = "https://files.pythonhosted.org/packages/bc/29/c238dd9107f10bfde09a4d1c52fd38828b1aa353ced11f358b5dd2507d24/cryptography-45.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:577470e39e60a6cd7780793202e63536026d9b8641de011ed9d8174da9ca5339", size = 4430504, upload-time = "2025-09-01T11:14:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/62/62/24203e7cbcc9bd7c94739428cd30680b18ae6b18377ae66075c8e4771b1b/cryptography-45.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4bd3e5c4b9682bc112d634f2c6ccc6736ed3635fc3319ac2bb11d768cc5a00d8", size = 4209542, upload-time = "2025-09-01T11:14:06.309Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e3/e7de4771a08620eef2389b86cd87a2c50326827dea5528feb70595439ce4/cryptography-45.0.7-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:465ccac9d70115cd4de7186e60cfe989de73f7bb23e8a7aa45af18f7412e75bf", size = 3889244, upload-time = "2025-09-01T11:14:08.152Z" }, + { url = "https://files.pythonhosted.org/packages/96/b8/bca71059e79a0bb2f8e4ec61d9c205fbe97876318566cde3b5092529faa9/cryptography-45.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:16ede8a4f7929b4b7ff3642eba2bf79aa1d71f24ab6ee443935c0d269b6bc513", size = 4461975, upload-time = "2025-09-01T11:14:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/58/67/3f5b26937fe1218c40e95ef4ff8d23c8dc05aa950d54200cc7ea5fb58d28/cryptography-45.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8978132287a9d3ad6b54fcd1e08548033cc09dc6aacacb6c004c73c3eb5d3ac3", size = 4209082, upload-time = "2025-09-01T11:14:11.229Z" }, + { url = "https://files.pythonhosted.org/packages/0e/e4/b3e68a4ac363406a56cf7b741eeb80d05284d8c60ee1a55cdc7587e2a553/cryptography-45.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b6a0e535baec27b528cb07a119f321ac024592388c5681a5ced167ae98e9fff3", size = 4460397, upload-time = "2025-09-01T11:14:12.924Z" }, + { url = "https://files.pythonhosted.org/packages/22/49/2c93f3cd4e3efc8cb22b02678c1fad691cff9dd71bb889e030d100acbfe0/cryptography-45.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a24ee598d10befaec178efdff6054bc4d7e883f615bfbcd08126a0f4931c83a6", size = 4337244, upload-time = "2025-09-01T11:14:14.431Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/030f400de0bccccc09aa262706d90f2ec23d56bc4eb4f4e8268d0ddf3fb8/cryptography-45.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fa26fa54c0a9384c27fcdc905a2fb7d60ac6e47d14bc2692145f2b3b1e2cfdbd", size = 4568862, upload-time = "2025-09-01T11:14:16.185Z" }, + { url = "https://files.pythonhosted.org/packages/29/56/3034a3a353efa65116fa20eb3c990a8c9f0d3db4085429040a7eef9ada5f/cryptography-45.0.7-cp311-abi3-win32.whl", hash = "sha256:bef32a5e327bd8e5af915d3416ffefdbe65ed975b646b3805be81b23580b57b8", size = 2936578, upload-time = "2025-09-01T11:14:17.638Z" }, + { url = "https://files.pythonhosted.org/packages/b3/61/0ab90f421c6194705a99d0fa9f6ee2045d916e4455fdbb095a9c2c9a520f/cryptography-45.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:3808e6b2e5f0b46d981c24d79648e5c25c35e59902ea4391a0dcb3e667bf7443", size = 3405400, upload-time = "2025-09-01T11:14:18.958Z" }, + { url = "https://files.pythonhosted.org/packages/63/e8/c436233ddf19c5f15b25ace33979a9dd2e7aa1a59209a0ee8554179f1cc0/cryptography-45.0.7-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bfb4c801f65dd61cedfc61a83732327fafbac55a47282e6f26f073ca7a41c3b2", size = 7021824, upload-time = "2025-09-01T11:14:20.954Z" }, + { url = "https://files.pythonhosted.org/packages/bc/4c/8f57f2500d0ccd2675c5d0cc462095adf3faa8c52294ba085c036befb901/cryptography-45.0.7-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:81823935e2f8d476707e85a78a405953a03ef7b7b4f55f93f7c2d9680e5e0691", size = 4202233, upload-time = "2025-09-01T11:14:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ac/59b7790b4ccaed739fc44775ce4645c9b8ce54cbec53edf16c74fd80cb2b/cryptography-45.0.7-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3994c809c17fc570c2af12c9b840d7cea85a9fd3e5c0e0491f4fa3c029216d59", size = 4423075, upload-time = "2025-09-01T11:14:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/d4f07ea21434bf891faa088a6ac15d6d98093a66e75e30ad08e88aa2b9ba/cryptography-45.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dad43797959a74103cb59c5dac71409f9c27d34c8a05921341fb64ea8ccb1dd4", size = 4204517, upload-time = "2025-09-01T11:14:25.679Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/924a723299848b4c741c1059752c7cfe09473b6fd77d2920398fc26bfb53/cryptography-45.0.7-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce7a453385e4c4693985b4a4a3533e041558851eae061a58a5405363b098fcd3", size = 3882893, upload-time = "2025-09-01T11:14:27.1Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/4dab2ff0a871cc2d81d3ae6d780991c0192b259c35e4d83fe1de18b20c70/cryptography-45.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b04f85ac3a90c227b6e5890acb0edbaf3140938dbecf07bff618bf3638578cf1", size = 4450132, upload-time = "2025-09-01T11:14:28.58Z" }, + { url = "https://files.pythonhosted.org/packages/12/dd/b2882b65db8fc944585d7fb00d67cf84a9cef4e77d9ba8f69082e911d0de/cryptography-45.0.7-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:48c41a44ef8b8c2e80ca4527ee81daa4c527df3ecbc9423c41a420a9559d0e27", size = 4204086, upload-time = "2025-09-01T11:14:30.572Z" }, + { url = "https://files.pythonhosted.org/packages/5d/fa/1d5745d878048699b8eb87c984d4ccc5da4f5008dfd3ad7a94040caca23a/cryptography-45.0.7-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f3df7b3d0f91b88b2106031fd995802a2e9ae13e02c36c1fc075b43f420f3a17", size = 4449383, upload-time = "2025-09-01T11:14:32.046Z" }, + { url = "https://files.pythonhosted.org/packages/36/8b/fc61f87931bc030598e1876c45b936867bb72777eac693e905ab89832670/cryptography-45.0.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd342f085542f6eb894ca00ef70236ea46070c8a13824c6bde0dfdcd36065b9b", size = 4332186, upload-time = "2025-09-01T11:14:33.95Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/09700ddad7443ccb11d674efdbe9a832b4455dc1f16566d9bd3834922ce5/cryptography-45.0.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1993a1bb7e4eccfb922b6cd414f072e08ff5816702a0bdb8941c247a6b1b287c", size = 4561639, upload-time = "2025-09-01T11:14:35.343Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/8f4c1337e9d3b94d8e50ae0b08ad0304a5709d483bfcadfcc77a23dbcb52/cryptography-45.0.7-cp37-abi3-win32.whl", hash = "sha256:18fcf70f243fe07252dcb1b268a687f2358025ce32f9f88028ca5c364b123ef5", size = 2926552, upload-time = "2025-09-01T11:14:36.929Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ff/026513ecad58dacd45d1d24ebe52b852165a26e287177de1d545325c0c25/cryptography-45.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:7285a89df4900ed3bfaad5679b1e668cb4b38a8de1ccbfc84b05f34512da0a90", size = 3392742, upload-time = "2025-09-01T11:14:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/13/3e/e42f1528ca1ea82256b835191eab1be014e0f9f934b60d98b0be8a38ed70/cryptography-45.0.7-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:de58755d723e86175756f463f2f0bddd45cc36fbd62601228a3f8761c9f58252", size = 3572442, upload-time = "2025-09-01T11:14:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/59/aa/e947693ab08674a2663ed2534cd8d345cf17bf6a1facf99273e8ec8986dc/cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a20e442e917889d1a6b3c570c9e3fa2fdc398c20868abcea268ea33c024c4083", size = 4142233, upload-time = "2025-09-01T11:14:41.305Z" }, + { url = "https://files.pythonhosted.org/packages/24/06/09b6f6a2fc43474a32b8fe259038eef1500ee3d3c141599b57ac6c57612c/cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:258e0dff86d1d891169b5af222d362468a9570e2532923088658aa866eb11130", size = 4376202, upload-time = "2025-09-01T11:14:43.047Z" }, + { url = "https://files.pythonhosted.org/packages/00/f2/c166af87e95ce6ae6d38471a7e039d3a0549c2d55d74e059680162052824/cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d97cf502abe2ab9eff8bd5e4aca274da8d06dd3ef08b759a8d6143f4ad65d4b4", size = 4141900, upload-time = "2025-09-01T11:14:45.089Z" }, + { url = "https://files.pythonhosted.org/packages/16/b9/e96e0b6cb86eae27ea51fa8a3151535a18e66fe7c451fa90f7f89c85f541/cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:c987dad82e8c65ebc985f5dae5e74a3beda9d0a2a4daf8a1115f3772b59e5141", size = 4375562, upload-time = "2025-09-01T11:14:47.166Z" }, + { url = "https://files.pythonhosted.org/packages/36/d0/36e8ee39274e9d77baf7d0dafda680cba6e52f3936b846f0d56d64fec915/cryptography-45.0.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c13b1e3afd29a5b3b2656257f14669ca8fa8d7956d509926f0b130b600b50ab7", size = 3322781, upload-time = "2025-09-01T11:14:48.747Z" }, + { url = "https://files.pythonhosted.org/packages/99/4e/49199a4c82946938a3e05d2e8ad9482484ba48bbc1e809e3d506c686d051/cryptography-45.0.7-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a862753b36620af6fc54209264f92c716367f2f0ff4624952276a6bbd18cbde", size = 3584634, upload-time = "2025-09-01T11:14:50.593Z" }, + { url = "https://files.pythonhosted.org/packages/16/ce/5f6ff59ea9c7779dba51b84871c19962529bdcc12e1a6ea172664916c550/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:06ce84dc14df0bf6ea84666f958e6080cdb6fe1231be2a51f3fc1267d9f3fb34", size = 4149533, upload-time = "2025-09-01T11:14:52.091Z" }, + { url = "https://files.pythonhosted.org/packages/ce/13/b3cfbd257ac96da4b88b46372e662009b7a16833bfc5da33bb97dd5631ae/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d0c5c6bac22b177bf8da7435d9d27a6834ee130309749d162b26c3105c0795a9", size = 4385557, upload-time = "2025-09-01T11:14:53.551Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c5/8c59d6b7c7b439ba4fc8d0cab868027fd095f215031bc123c3a070962912/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:2f641b64acc00811da98df63df7d59fd4706c0df449da71cb7ac39a0732b40ae", size = 4149023, upload-time = "2025-09-01T11:14:55.022Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/05385c86d6ca9ab0b4d5bb442d2e3d85e727939a11f3e163fc776ce5eb40/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:f5414a788ecc6ee6bc58560e85ca624258a55ca434884445440a810796ea0e0b", size = 4385722, upload-time = "2025-09-01T11:14:57.319Z" }, + { url = "https://files.pythonhosted.org/packages/23/87/7ce86f3fa14bc11a5a48c30d8103c26e09b6465f8d8e9d74cf7a0714f043/cryptography-45.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1f3d56f73595376f4244646dd5c5870c14c196949807be39e79e7bd9bac3da63", size = 3332908, upload-time = "2025-09-01T11:14:58.78Z" }, ] [[package]] @@ -406,9 +423,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "more-itertools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/33/9f/329d26121fe165be44b1dfff21aa0dc348f04633931f1d20ed6cf448a236/cssutils-2.11.1.tar.gz", hash = "sha256:0563a76513b6af6eebbe788c3bf3d01c920e46b3f90c8416738c5cfc773ff8e2", size = 711657 } +sdist = { url = "https://files.pythonhosted.org/packages/33/9f/329d26121fe165be44b1dfff21aa0dc348f04633931f1d20ed6cf448a236/cssutils-2.11.1.tar.gz", hash = "sha256:0563a76513b6af6eebbe788c3bf3d01c920e46b3f90c8416738c5cfc773ff8e2", size = 711657, upload-time = "2024-06-04T15:51:39.373Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/ec/bb273b7208c606890dc36540fe667d06ce840a6f62f9fae7e658fcdc90fb/cssutils-2.11.1-py3-none-any.whl", hash = "sha256:a67bfdfdff4f3867fab43698ec4897c1a828eca5973f4073321b3bccaf1199b1", size = 385747 }, + { url = "https://files.pythonhosted.org/packages/a7/ec/bb273b7208c606890dc36540fe667d06ce840a6f62f9fae7e658fcdc90fb/cssutils-2.11.1-py3-none-any.whl", hash = "sha256:a67bfdfdff4f3867fab43698ec4897c1a828eca5973f4073321b3bccaf1199b1", size = 385747, upload-time = "2024-06-04T15:51:37.499Z" }, ] [[package]] @@ -419,158 +436,155 @@ dependencies = [ { name = "certifi" }, { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4e/3d/f39ca1f8fdf14408888e7c25e15eed63eac5f47926e206fb93300d28378c/curl_cffi-0.13.0.tar.gz", hash = "sha256:62ecd90a382bd5023750e3606e0aa7cb1a3a8ba41c14270b8e5e149ebf72c5ca", size = 151303 } +sdist = { url = "https://files.pythonhosted.org/packages/4e/3d/f39ca1f8fdf14408888e7c25e15eed63eac5f47926e206fb93300d28378c/curl_cffi-0.13.0.tar.gz", hash = "sha256:62ecd90a382bd5023750e3606e0aa7cb1a3a8ba41c14270b8e5e149ebf72c5ca", size = 151303, upload-time = "2025-08-06T13:05:42.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/d1/acabfd460f1de26cad882e5ef344d9adde1507034528cb6f5698a2e6a2f1/curl_cffi-0.13.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:434cadbe8df2f08b2fc2c16dff2779fb40b984af99c06aa700af898e185bb9db", size = 5686337 }, - { url = "https://files.pythonhosted.org/packages/2c/1c/cdb4fb2d16a0e9de068e0e5bc02094e105ce58a687ff30b4c6f88e25a057/curl_cffi-0.13.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:59afa877a9ae09efa04646a7d068eeea48915a95d9add0a29854e7781679fcd7", size = 2994613 }, - { url = "https://files.pythonhosted.org/packages/04/3e/fdf617c1ec18c3038b77065d484d7517bb30f8fb8847224eb1f601a4e8bc/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d06ed389e45a7ca97b17c275dbedd3d6524560270e675c720e93a2018a766076", size = 7931353 }, - { url = "https://files.pythonhosted.org/packages/3d/10/6f30c05d251cf03ddc2b9fd19880f3cab8c193255e733444a2df03b18944/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4e0de45ab3b7a835c72bd53640c2347415111b43421b5c7a1a0b18deae2e541", size = 7486378 }, - { url = "https://files.pythonhosted.org/packages/77/81/5bdb7dd0d669a817397b2e92193559bf66c3807f5848a48ad10cf02bf6c7/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8eb4083371bbb94e9470d782de235fb5268bf43520de020c9e5e6be8f395443f", size = 8328585 }, - { url = "https://files.pythonhosted.org/packages/ce/c1/df5c6b4cfad41c08442e0f727e449f4fb5a05f8aa564d1acac29062e9e8e/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:28911b526e8cd4aa0e5e38401bfe6887e8093907272f1f67ca22e6beb2933a51", size = 8739831 }, - { url = "https://files.pythonhosted.org/packages/1a/91/6dd1910a212f2e8eafe57877bcf97748eb24849e1511a266687546066b8a/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6d433ffcb455ab01dd0d7bde47109083aa38b59863aa183d29c668ae4c96bf8e", size = 8711908 }, - { url = "https://files.pythonhosted.org/packages/6d/e4/15a253f9b4bf8d008c31e176c162d2704a7e0c5e24d35942f759df107b68/curl_cffi-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:66a6b75ce971de9af64f1b6812e275f60b88880577bac47ef1fa19694fa21cd3", size = 1614510 }, - { url = "https://files.pythonhosted.org/packages/f9/0f/9c5275f17ad6ff5be70edb8e0120fdc184a658c9577ca426d4230f654beb/curl_cffi-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:d438a3b45244e874794bc4081dc1e356d2bb926dcc7021e5a8fef2e2105ef1d8", size = 1365753 }, + { url = "https://files.pythonhosted.org/packages/19/d1/acabfd460f1de26cad882e5ef344d9adde1507034528cb6f5698a2e6a2f1/curl_cffi-0.13.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:434cadbe8df2f08b2fc2c16dff2779fb40b984af99c06aa700af898e185bb9db", size = 5686337, upload-time = "2025-08-06T13:05:28.985Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1c/cdb4fb2d16a0e9de068e0e5bc02094e105ce58a687ff30b4c6f88e25a057/curl_cffi-0.13.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:59afa877a9ae09efa04646a7d068eeea48915a95d9add0a29854e7781679fcd7", size = 2994613, upload-time = "2025-08-06T13:05:31.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3e/fdf617c1ec18c3038b77065d484d7517bb30f8fb8847224eb1f601a4e8bc/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d06ed389e45a7ca97b17c275dbedd3d6524560270e675c720e93a2018a766076", size = 7931353, upload-time = "2025-08-06T13:05:32.273Z" }, + { url = "https://files.pythonhosted.org/packages/3d/10/6f30c05d251cf03ddc2b9fd19880f3cab8c193255e733444a2df03b18944/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4e0de45ab3b7a835c72bd53640c2347415111b43421b5c7a1a0b18deae2e541", size = 7486378, upload-time = "2025-08-06T13:05:33.672Z" }, + { url = "https://files.pythonhosted.org/packages/77/81/5bdb7dd0d669a817397b2e92193559bf66c3807f5848a48ad10cf02bf6c7/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8eb4083371bbb94e9470d782de235fb5268bf43520de020c9e5e6be8f395443f", size = 8328585, upload-time = "2025-08-06T13:05:35.28Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c1/df5c6b4cfad41c08442e0f727e449f4fb5a05f8aa564d1acac29062e9e8e/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:28911b526e8cd4aa0e5e38401bfe6887e8093907272f1f67ca22e6beb2933a51", size = 8739831, upload-time = "2025-08-06T13:05:37.078Z" }, + { url = "https://files.pythonhosted.org/packages/1a/91/6dd1910a212f2e8eafe57877bcf97748eb24849e1511a266687546066b8a/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6d433ffcb455ab01dd0d7bde47109083aa38b59863aa183d29c668ae4c96bf8e", size = 8711908, upload-time = "2025-08-06T13:05:38.741Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e4/15a253f9b4bf8d008c31e176c162d2704a7e0c5e24d35942f759df107b68/curl_cffi-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:66a6b75ce971de9af64f1b6812e275f60b88880577bac47ef1fa19694fa21cd3", size = 1614510, upload-time = "2025-08-06T13:05:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0f/9c5275f17ad6ff5be70edb8e0120fdc184a658c9577ca426d4230f654beb/curl_cffi-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:d438a3b45244e874794bc4081dc1e356d2bb926dcc7021e5a8fef2e2105ef1d8", size = 1365753, upload-time = "2025-08-06T13:05:41.879Z" }, ] [[package]] name = "distlib" version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605 } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047 }, + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] [[package]] name = "ecpy" version = "1.2.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/48/3f8c1a252e3a46fd04e6fabc5e11c933b9c39cf84edd4e7c906e29c23750/ECPy-1.2.5.tar.gz", hash = "sha256:9635cffb9b6ecf7fd7f72aea1665829ac74a1d272006d0057d45a621aae20228", size = 38458 } +sdist = { url = "https://files.pythonhosted.org/packages/e0/48/3f8c1a252e3a46fd04e6fabc5e11c933b9c39cf84edd4e7c906e29c23750/ECPy-1.2.5.tar.gz", hash = "sha256:9635cffb9b6ecf7fd7f72aea1665829ac74a1d272006d0057d45a621aae20228", size = 38458, upload-time = "2020-10-26T11:56:16.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/35/4a113189f7138035a21bd255d30dc7bffc77c942c93b7948d2eac2e22429/ECPy-1.2.5-py3-none-any.whl", hash = "sha256:559c92e42406d9d1a6b2b8fc26e6ad7bc985f33903b72f426a56cb1073a25ce3", size = 43075 }, + { url = "https://files.pythonhosted.org/packages/e8/35/4a113189f7138035a21bd255d30dc7bffc77c942c93b7948d2eac2e22429/ECPy-1.2.5-py3-none-any.whl", hash = "sha256:559c92e42406d9d1a6b2b8fc26e6ad7bc985f33903b72f426a56cb1073a25ce3", size = 43075, upload-time = "2020-10-26T11:56:13.613Z" }, ] [[package]] name = "exceptiongroup" -version = "1.3.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 } +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] [[package]] name = "fastjsonschema" version = "2.19.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/7f/cedf77ace50aa60c566deaca9066750f06e1fcf6ad24f254d255bb976dd6/fastjsonschema-2.19.1.tar.gz", hash = "sha256:e3126a94bdc4623d3de4485f8d468a12f02a67921315ddc87836d6e456dc789d", size = 372732 } +sdist = { url = "https://files.pythonhosted.org/packages/ba/7f/cedf77ace50aa60c566deaca9066750f06e1fcf6ad24f254d255bb976dd6/fastjsonschema-2.19.1.tar.gz", hash = "sha256:e3126a94bdc4623d3de4485f8d468a12f02a67921315ddc87836d6e456dc789d", size = 372732, upload-time = "2023-12-28T14:02:06.823Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/b9/79691036d4a8f9857e74d1728b23f34f583b81350a27492edda58d5604e1/fastjsonschema-2.19.1-py3-none-any.whl", hash = "sha256:3672b47bc94178c9f23dbb654bf47440155d4db9df5f7bc47643315f9c405cd0", size = 23388 }, + { url = "https://files.pythonhosted.org/packages/9c/b9/79691036d4a8f9857e74d1728b23f34f583b81350a27492edda58d5604e1/fastjsonschema-2.19.1-py3-none-any.whl", hash = "sha256:3672b47bc94178c9f23dbb654bf47440155d4db9df5f7bc47643315f9c405cd0", size = 23388, upload-time = "2023-12-28T14:02:04.512Z" }, ] [[package]] name = "filelock" version = "3.20.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485 } +sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701 }, + { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, ] [[package]] name = "fonttools" version = "4.61.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756 } +sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756, upload-time = "2025-12-12T17:31:24.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/94/8a28707adb00bed1bf22dac16ccafe60faf2ade353dcb32c3617ee917307/fonttools-4.61.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c7db70d57e5e1089a274cbb2b1fd635c9a24de809a231b154965d415d6c6d24", size = 2854799 }, - { url = "https://files.pythonhosted.org/packages/94/93/c2e682faaa5ee92034818d8f8a8145ae73eb83619600495dcf8503fa7771/fonttools-4.61.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5fe9fd43882620017add5eabb781ebfbc6998ee49b35bd7f8f79af1f9f99a958", size = 2403032 }, - { url = "https://files.pythonhosted.org/packages/f1/62/1748f7e7e1ee41aa52279fd2e3a6d0733dc42a673b16932bad8e5d0c8b28/fonttools-4.61.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8db08051fc9e7d8bc622f2112511b8107d8f27cd89e2f64ec45e9825e8288da", size = 4897863 }, - { url = "https://files.pythonhosted.org/packages/69/69/4ca02ee367d2c98edcaeb83fc278d20972502ee071214ad9d8ca85e06080/fonttools-4.61.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a76d4cb80f41ba94a6691264be76435e5f72f2cb3cab0b092a6212855f71c2f6", size = 4859076 }, - { url = "https://files.pythonhosted.org/packages/8c/f5/660f9e3cefa078861a7f099107c6d203b568a6227eef163dd173bfc56bdc/fonttools-4.61.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a13fc8aeb24bad755eea8f7f9d409438eb94e82cf86b08fe77a03fbc8f6a96b1", size = 4875623 }, - { url = "https://files.pythonhosted.org/packages/63/d1/9d7c5091d2276ed47795c131c1bf9316c3c1ab2789c22e2f59e0572ccd38/fonttools-4.61.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b846a1fcf8beadeb9ea4f44ec5bdde393e2f1569e17d700bfc49cd69bde75881", size = 4993327 }, - { url = "https://files.pythonhosted.org/packages/6f/2d/28def73837885ae32260d07660a052b99f0aa00454867d33745dfe49dbf0/fonttools-4.61.1-cp310-cp310-win32.whl", hash = "sha256:78a7d3ab09dc47ac1a363a493e6112d8cabed7ba7caad5f54dbe2f08676d1b47", size = 1502180 }, - { url = "https://files.pythonhosted.org/packages/63/fa/bfdc98abb4dd2bd491033e85e3ba69a2313c850e759a6daa014bc9433b0f/fonttools-4.61.1-cp310-cp310-win_amd64.whl", hash = "sha256:eff1ac3cc66c2ac7cda1e64b4e2f3ffef474b7335f92fc3833fc632d595fcee6", size = 1550654 }, - { url = "https://files.pythonhosted.org/packages/69/12/bf9f4eaa2fad039356cc627587e30ed008c03f1cebd3034376b5ee8d1d44/fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09", size = 2852213 }, - { url = "https://files.pythonhosted.org/packages/ac/49/4138d1acb6261499bedde1c07f8c2605d1d8f9d77a151e5507fd3ef084b6/fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37", size = 2401689 }, - { url = "https://files.pythonhosted.org/packages/e5/fe/e6ce0fe20a40e03aef906af60aa87668696f9e4802fa283627d0b5ed777f/fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb", size = 5058809 }, - { url = "https://files.pythonhosted.org/packages/79/61/1ca198af22f7dd22c17ab86e9024ed3c06299cfdb08170640e9996d501a0/fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9", size = 5036039 }, - { url = "https://files.pythonhosted.org/packages/99/cc/fa1801e408586b5fce4da9f5455af8d770f4fc57391cd5da7256bb364d38/fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87", size = 5034714 }, - { url = "https://files.pythonhosted.org/packages/bf/aa/b7aeafe65adb1b0a925f8f25725e09f078c635bc22754f3fecb7456955b0/fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56", size = 5158648 }, - { url = "https://files.pythonhosted.org/packages/99/f9/08ea7a38663328881384c6e7777bbefc46fd7d282adfd87a7d2b84ec9d50/fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a", size = 2280681 }, - { url = "https://files.pythonhosted.org/packages/07/ad/37dd1ae5fa6e01612a1fbb954f0927681f282925a86e86198ccd7b15d515/fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7", size = 2331951 }, - { url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593 }, - { url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231 }, - { url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103 }, - { url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295 }, - { url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109 }, - { url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598 }, - { url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060 }, - { url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078 }, - { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996 }, + { url = "https://files.pythonhosted.org/packages/5b/94/8a28707adb00bed1bf22dac16ccafe60faf2ade353dcb32c3617ee917307/fonttools-4.61.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c7db70d57e5e1089a274cbb2b1fd635c9a24de809a231b154965d415d6c6d24", size = 2854799, upload-time = "2025-12-12T17:29:27.5Z" }, + { url = "https://files.pythonhosted.org/packages/94/93/c2e682faaa5ee92034818d8f8a8145ae73eb83619600495dcf8503fa7771/fonttools-4.61.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5fe9fd43882620017add5eabb781ebfbc6998ee49b35bd7f8f79af1f9f99a958", size = 2403032, upload-time = "2025-12-12T17:29:30.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/62/1748f7e7e1ee41aa52279fd2e3a6d0733dc42a673b16932bad8e5d0c8b28/fonttools-4.61.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8db08051fc9e7d8bc622f2112511b8107d8f27cd89e2f64ec45e9825e8288da", size = 4897863, upload-time = "2025-12-12T17:29:32.535Z" }, + { url = "https://files.pythonhosted.org/packages/69/69/4ca02ee367d2c98edcaeb83fc278d20972502ee071214ad9d8ca85e06080/fonttools-4.61.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a76d4cb80f41ba94a6691264be76435e5f72f2cb3cab0b092a6212855f71c2f6", size = 4859076, upload-time = "2025-12-12T17:29:34.907Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f5/660f9e3cefa078861a7f099107c6d203b568a6227eef163dd173bfc56bdc/fonttools-4.61.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a13fc8aeb24bad755eea8f7f9d409438eb94e82cf86b08fe77a03fbc8f6a96b1", size = 4875623, upload-time = "2025-12-12T17:29:37.33Z" }, + { url = "https://files.pythonhosted.org/packages/63/d1/9d7c5091d2276ed47795c131c1bf9316c3c1ab2789c22e2f59e0572ccd38/fonttools-4.61.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b846a1fcf8beadeb9ea4f44ec5bdde393e2f1569e17d700bfc49cd69bde75881", size = 4993327, upload-time = "2025-12-12T17:29:39.781Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2d/28def73837885ae32260d07660a052b99f0aa00454867d33745dfe49dbf0/fonttools-4.61.1-cp310-cp310-win32.whl", hash = "sha256:78a7d3ab09dc47ac1a363a493e6112d8cabed7ba7caad5f54dbe2f08676d1b47", size = 1502180, upload-time = "2025-12-12T17:29:42.217Z" }, + { url = "https://files.pythonhosted.org/packages/63/fa/bfdc98abb4dd2bd491033e85e3ba69a2313c850e759a6daa014bc9433b0f/fonttools-4.61.1-cp310-cp310-win_amd64.whl", hash = "sha256:eff1ac3cc66c2ac7cda1e64b4e2f3ffef474b7335f92fc3833fc632d595fcee6", size = 1550654, upload-time = "2025-12-12T17:29:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/69/12/bf9f4eaa2fad039356cc627587e30ed008c03f1cebd3034376b5ee8d1d44/fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09", size = 2852213, upload-time = "2025-12-12T17:29:46.675Z" }, + { url = "https://files.pythonhosted.org/packages/ac/49/4138d1acb6261499bedde1c07f8c2605d1d8f9d77a151e5507fd3ef084b6/fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37", size = 2401689, upload-time = "2025-12-12T17:29:48.769Z" }, + { url = "https://files.pythonhosted.org/packages/e5/fe/e6ce0fe20a40e03aef906af60aa87668696f9e4802fa283627d0b5ed777f/fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb", size = 5058809, upload-time = "2025-12-12T17:29:51.701Z" }, + { url = "https://files.pythonhosted.org/packages/79/61/1ca198af22f7dd22c17ab86e9024ed3c06299cfdb08170640e9996d501a0/fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9", size = 5036039, upload-time = "2025-12-12T17:29:53.659Z" }, + { url = "https://files.pythonhosted.org/packages/99/cc/fa1801e408586b5fce4da9f5455af8d770f4fc57391cd5da7256bb364d38/fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87", size = 5034714, upload-time = "2025-12-12T17:29:55.592Z" }, + { url = "https://files.pythonhosted.org/packages/bf/aa/b7aeafe65adb1b0a925f8f25725e09f078c635bc22754f3fecb7456955b0/fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56", size = 5158648, upload-time = "2025-12-12T17:29:57.861Z" }, + { url = "https://files.pythonhosted.org/packages/99/f9/08ea7a38663328881384c6e7777bbefc46fd7d282adfd87a7d2b84ec9d50/fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a", size = 2280681, upload-time = "2025-12-12T17:29:59.943Z" }, + { url = "https://files.pythonhosted.org/packages/07/ad/37dd1ae5fa6e01612a1fbb954f0927681f282925a86e86198ccd7b15d515/fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7", size = 2331951, upload-time = "2025-12-12T17:30:02.254Z" }, + { url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593, upload-time = "2025-12-12T17:30:04.225Z" }, + { url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231, upload-time = "2025-12-12T17:30:06.47Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103, upload-time = "2025-12-12T17:30:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295, upload-time = "2025-12-12T17:30:10.56Z" }, + { url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109, upload-time = "2025-12-12T17:30:12.874Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598, upload-time = "2025-12-12T17:30:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060, upload-time = "2025-12-12T17:30:18.058Z" }, + { url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078, upload-time = "2025-12-12T17:30:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" }, ] [[package]] name = "frozenlist" -version = "1.7.0" +version = "1.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078 } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/36/0da0a49409f6b47cc2d060dc8c9040b897b5902a8a4e37d9bc1deb11f680/frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a", size = 81304 }, - { url = "https://files.pythonhosted.org/packages/77/f0/77c11d13d39513b298e267b22eb6cb559c103d56f155aa9a49097221f0b6/frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61", size = 47735 }, - { url = "https://files.pythonhosted.org/packages/37/12/9d07fa18971a44150593de56b2f2947c46604819976784bcf6ea0d5db43b/frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d", size = 46775 }, - { url = "https://files.pythonhosted.org/packages/70/34/f73539227e06288fcd1f8a76853e755b2b48bca6747e99e283111c18bcd4/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e", size = 224644 }, - { url = "https://files.pythonhosted.org/packages/fb/68/c1d9c2f4a6e438e14613bad0f2973567586610cc22dcb1e1241da71de9d3/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9", size = 222125 }, - { url = "https://files.pythonhosted.org/packages/b9/d0/98e8f9a515228d708344d7c6986752be3e3192d1795f748c24bcf154ad99/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c", size = 233455 }, - { url = "https://files.pythonhosted.org/packages/79/df/8a11bcec5600557f40338407d3e5bea80376ed1c01a6c0910fcfdc4b8993/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981", size = 227339 }, - { url = "https://files.pythonhosted.org/packages/50/82/41cb97d9c9a5ff94438c63cc343eb7980dac4187eb625a51bdfdb7707314/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615", size = 212969 }, - { url = "https://files.pythonhosted.org/packages/13/47/f9179ee5ee4f55629e4f28c660b3fdf2775c8bfde8f9c53f2de2d93f52a9/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50", size = 222862 }, - { url = "https://files.pythonhosted.org/packages/1a/52/df81e41ec6b953902c8b7e3a83bee48b195cb0e5ec2eabae5d8330c78038/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa", size = 222492 }, - { url = "https://files.pythonhosted.org/packages/84/17/30d6ea87fa95a9408245a948604b82c1a4b8b3e153cea596421a2aef2754/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577", size = 238250 }, - { url = "https://files.pythonhosted.org/packages/8f/00/ecbeb51669e3c3df76cf2ddd66ae3e48345ec213a55e3887d216eb4fbab3/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59", size = 218720 }, - { url = "https://files.pythonhosted.org/packages/1a/c0/c224ce0e0eb31cc57f67742071bb470ba8246623c1823a7530be0e76164c/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e", size = 232585 }, - { url = "https://files.pythonhosted.org/packages/55/3c/34cb694abf532f31f365106deebdeac9e45c19304d83cf7d51ebbb4ca4d1/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd", size = 234248 }, - { url = "https://files.pythonhosted.org/packages/98/c0/2052d8b6cecda2e70bd81299e3512fa332abb6dcd2969b9c80dfcdddbf75/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718", size = 221621 }, - { url = "https://files.pythonhosted.org/packages/c5/bf/7dcebae315436903b1d98ffb791a09d674c88480c158aa171958a3ac07f0/frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e", size = 39578 }, - { url = "https://files.pythonhosted.org/packages/8f/5f/f69818f017fa9a3d24d1ae39763e29b7f60a59e46d5f91b9c6b21622f4cd/frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464", size = 43830 }, - { url = "https://files.pythonhosted.org/packages/34/7e/803dde33760128acd393a27eb002f2020ddb8d99d30a44bfbaab31c5f08a/frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a", size = 82251 }, - { url = "https://files.pythonhosted.org/packages/75/a9/9c2c5760b6ba45eae11334db454c189d43d34a4c0b489feb2175e5e64277/frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750", size = 48183 }, - { url = "https://files.pythonhosted.org/packages/47/be/4038e2d869f8a2da165f35a6befb9158c259819be22eeaf9c9a8f6a87771/frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd", size = 47107 }, - { url = "https://files.pythonhosted.org/packages/79/26/85314b8a83187c76a37183ceed886381a5f992975786f883472fcb6dc5f2/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2", size = 237333 }, - { url = "https://files.pythonhosted.org/packages/1f/fd/e5b64f7d2c92a41639ffb2ad44a6a82f347787abc0c7df5f49057cf11770/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f", size = 231724 }, - { url = "https://files.pythonhosted.org/packages/20/fb/03395c0a43a5976af4bf7534759d214405fbbb4c114683f434dfdd3128ef/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30", size = 245842 }, - { url = "https://files.pythonhosted.org/packages/d0/15/c01c8e1dffdac5d9803507d824f27aed2ba76b6ed0026fab4d9866e82f1f/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98", size = 239767 }, - { url = "https://files.pythonhosted.org/packages/14/99/3f4c6fe882c1f5514b6848aa0a69b20cb5e5d8e8f51a339d48c0e9305ed0/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86", size = 224130 }, - { url = "https://files.pythonhosted.org/packages/4d/83/220a374bd7b2aeba9d0725130665afe11de347d95c3620b9b82cc2fcab97/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae", size = 235301 }, - { url = "https://files.pythonhosted.org/packages/03/3c/3e3390d75334a063181625343e8daab61b77e1b8214802cc4e8a1bb678fc/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8", size = 234606 }, - { url = "https://files.pythonhosted.org/packages/23/1e/58232c19608b7a549d72d9903005e2d82488f12554a32de2d5fb59b9b1ba/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31", size = 248372 }, - { url = "https://files.pythonhosted.org/packages/c0/a4/e4a567e01702a88a74ce8a324691e62a629bf47d4f8607f24bf1c7216e7f/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7", size = 229860 }, - { url = "https://files.pythonhosted.org/packages/73/a6/63b3374f7d22268b41a9db73d68a8233afa30ed164c46107b33c4d18ecdd/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5", size = 245893 }, - { url = "https://files.pythonhosted.org/packages/6d/eb/d18b3f6e64799a79673c4ba0b45e4cfbe49c240edfd03a68be20002eaeaa/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898", size = 246323 }, - { url = "https://files.pythonhosted.org/packages/5a/f5/720f3812e3d06cd89a1d5db9ff6450088b8f5c449dae8ffb2971a44da506/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56", size = 233149 }, - { url = "https://files.pythonhosted.org/packages/69/68/03efbf545e217d5db8446acfd4c447c15b7c8cf4dbd4a58403111df9322d/frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7", size = 39565 }, - { url = "https://files.pythonhosted.org/packages/58/17/fe61124c5c333ae87f09bb67186d65038834a47d974fc10a5fadb4cc5ae1/frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d", size = 44019 }, - { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424 }, - { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952 }, - { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688 }, - { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084 }, - { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524 }, - { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493 }, - { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116 }, - { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557 }, - { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820 }, - { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542 }, - { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350 }, - { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093 }, - { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482 }, - { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590 }, - { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785 }, - { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487 }, - { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874 }, - { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106 }, + { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, + { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] [[package]] @@ -580,18 +594,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a5/d3/8650919bc3c7c6e90ee3fa7fd618bf373cbbe55dff043bd67353dbb20cd8/ftfy-6.3.1.tar.gz", hash = "sha256:9b3c3d90f84fb267fe64d375a07b7f8912d817cf86009ae134aa03e1819506ec", size = 308927 } +sdist = { url = "https://files.pythonhosted.org/packages/a5/d3/8650919bc3c7c6e90ee3fa7fd618bf373cbbe55dff043bd67353dbb20cd8/ftfy-6.3.1.tar.gz", hash = "sha256:9b3c3d90f84fb267fe64d375a07b7f8912d817cf86009ae134aa03e1819506ec", size = 308927, upload-time = "2024-10-26T00:50:35.149Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/6e/81d47999aebc1b155f81eca4477a616a70f238a2549848c38983f3c22a82/ftfy-6.3.1-py3-none-any.whl", hash = "sha256:7c70eb532015cd2f9adb53f101fb6c7945988d023a085d127d1573dc49dd0083", size = 44821 }, + { url = "https://files.pythonhosted.org/packages/ab/6e/81d47999aebc1b155f81eca4477a616a70f238a2549848c38983f3c22a82/ftfy-6.3.1-py3-none-any.whl", hash = "sha256:7c70eb532015cd2f9adb53f101fb6c7945988d023a085d127d1573dc49dd0083", size = 44821, upload-time = "2024-10-26T00:50:33.425Z" }, ] [[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 } +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 }, + { 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]] @@ -602,9 +616,9 @@ 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 } +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 }, + { 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]] @@ -617,180 +631,217 @@ dependencies = [ { 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 } +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 }, + { 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 = "identify" -version = "2.6.13" +version = "2.6.16" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ca/ffbabe3635bb839aa36b3a893c91a9b0d368cb4d8073e03a12896970af82/identify-2.6.13.tar.gz", hash = "sha256:da8d6c828e773620e13bfa86ea601c5a5310ba4bcd65edf378198b56a1f9fb32", size = 99243 } +sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/ce/461b60a3ee109518c055953729bf9ed089a04db895d47e95444071dcdef2/identify-2.6.13-py2.py3-none-any.whl", hash = "sha256:60381139b3ae39447482ecc406944190f690d4a2997f2584062089848361b33b", size = 99153 }, + { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" }, ] [[package]] name = "idna" -version = "3.10" +version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +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/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, + { 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 = "iso8601" version = "2.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b9/f3/ef59cee614d5e0accf6fd0cbba025b93b272e626ca89fb70a3e9187c5d15/iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df", size = 6522 } +sdist = { url = "https://files.pythonhosted.org/packages/b9/f3/ef59cee614d5e0accf6fd0cbba025b93b272e626ca89fb70a3e9187c5d15/iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df", size = 6522, upload-time = "2023-10-03T00:25:39.317Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545 }, + { url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545, upload-time = "2023-10-03T00:25:32.304Z" }, ] [[package]] name = "isort" version = "7.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049 } +sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672 }, + { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, ] [[package]] name = "jsonpickle" version = "4.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/a6/d07afcfdef402900229bcca795f80506b207af13a838d4d99ad45abf530c/jsonpickle-4.1.1.tar.gz", hash = "sha256:f86e18f13e2b96c1c1eede0b7b90095bbb61d99fedc14813c44dc2f361dbbae1", size = 316885 } +sdist = { url = "https://files.pythonhosted.org/packages/e4/a6/d07afcfdef402900229bcca795f80506b207af13a838d4d99ad45abf530c/jsonpickle-4.1.1.tar.gz", hash = "sha256:f86e18f13e2b96c1c1eede0b7b90095bbb61d99fedc14813c44dc2f361dbbae1", size = 316885, upload-time = "2025-06-02T20:36:11.57Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/73/04df8a6fa66d43a9fd45c30f283cc4afff17da671886e451d52af60bdc7e/jsonpickle-4.1.1-py3-none-any.whl", hash = "sha256:bb141da6057898aa2438ff268362b126826c812a1721e31cf08a6e142910dc91", size = 47125 }, + { url = "https://files.pythonhosted.org/packages/c1/73/04df8a6fa66d43a9fd45c30f283cc4afff17da671886e451d52af60bdc7e/jsonpickle-4.1.1-py3-none-any.whl", hash = "sha256:bb141da6057898aa2438ff268362b126826c812a1721e31cf08a6e142910dc91", size = 47125, upload-time = "2025-06-02T20:36:08.647Z" }, ] [[package]] name = "langcodes" -version = "3.5.0" +version = "3.5.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "language-data" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3a/7a/5a97e327063409a5caa21541e6d08ae4a0f2da328447e9f2c7b39e179226/langcodes-3.5.0.tar.gz", hash = "sha256:1eef8168d07e51e131a2497ffecad4b663f6208e7c3ae3b8dc15c51734a6f801", size = 191030 } +sdist = { url = "https://files.pythonhosted.org/packages/a9/75/f9edc5d72945019312f359e69ded9f82392a81d49c5051ed3209b100c0d2/langcodes-3.5.1.tar.gz", hash = "sha256:40bff315e01b01d11c2ae3928dd4f5cbd74dd38f9bd912c12b9a3606c143f731", size = 191084, upload-time = "2025-12-02T16:22:01.627Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6b/068c2ea7a712bf805c62445bd9e9c06d7340358ef2824150eceac027444b/langcodes-3.5.0-py3-none-any.whl", hash = "sha256:853c69d1a35e0e13da2f427bb68fb2fa4a8f4fb899e0c62ad8df8d073dcfed33", size = 182974 }, + { url = "https://files.pythonhosted.org/packages/dd/c1/d10b371bcba7abce05e2b33910e39c33cfa496a53f13640b7b8e10bb4d2b/langcodes-3.5.1-py3-none-any.whl", hash = "sha256:b6a9c25c603804e2d169165091d0cdb23934610524a21d226e4f463e8e958a72", size = 183050, upload-time = "2025-12-02T16:21:59.954Z" }, ] [[package]] name = "language-data" -version = "1.3.0" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "marisa-trie" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dd/ce/3f144716a9f2cbf42aa86ebc8b085a184be25c80aa453eea17c294d239c1/language_data-1.3.0.tar.gz", hash = "sha256:7600ef8aa39555145d06c89f0c324bf7dab834ea0b0a439d8243762e3ebad7ec", size = 5129310 } +sdist = { url = "https://files.pythonhosted.org/packages/61/50/2518b4d0805f4d1f10166837829ad0bd71dcee3ec33fa84aa8c0db23c13c/language_data-1.4.0.tar.gz", hash = "sha256:800e6457e7beda781c156e02d7707e38db2ded026472e07e2c055dc8446ee574", size = 5309660, upload-time = "2025-11-28T13:45:25.217Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/e9/5a5ffd9b286db82be70d677d0a91e4d58f7912bb8dd026ddeeb4abe70679/language_data-1.3.0-py3-none-any.whl", hash = "sha256:e2ee943551b5ae5f89cd0e801d1fc3835bb0ef5b7e9c3a4e8e17b2b214548fbf", size = 5385760 }, + { url = "https://files.pythonhosted.org/packages/ad/d1/68e2bcca94c9bbdc122a71e504e0b6a6c3e31541b1bad33fee0205996006/language_data-1.4.0-py3-none-any.whl", hash = "sha256:f741927c24ab14cbed2a57bc2bfe82b00cff266c427179597e8b14123364f084", size = 5572678, upload-time = "2025-11-28T13:45:23.381Z" }, +] + +[[package]] +name = "librt" +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/13/57b06758a13550c5f09563893b004f98e9537ee6ec67b7df85c3571c8832/librt-0.7.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b45306a1fc5f53c9330fbee134d8b3227fe5da2ab09813b892790400aa49352d", size = 56521, upload-time = "2026-01-14T12:54:40.066Z" }, + { url = "https://files.pythonhosted.org/packages/c2/24/bbea34d1452a10612fb45ac8356f95351ba40c2517e429602160a49d1fd0/librt-0.7.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:864c4b7083eeee250ed55135d2127b260d7eb4b5e953a9e5df09c852e327961b", size = 58456, upload-time = "2026-01-14T12:54:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/04/72/a168808f92253ec3a810beb1eceebc465701197dbc7e865a1c9ceb3c22c7/librt-0.7.8-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6938cc2de153bc927ed8d71c7d2f2ae01b4e96359126c602721340eb7ce1a92d", size = 164392, upload-time = "2026-01-14T12:54:42.843Z" }, + { url = "https://files.pythonhosted.org/packages/14/5c/4c0d406f1b02735c2e7af8ff1ff03a6577b1369b91aa934a9fa2cc42c7ce/librt-0.7.8-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66daa6ac5de4288a5bbfbe55b4caa7bf0cd26b3269c7a476ffe8ce45f837f87d", size = 172959, upload-time = "2026-01-14T12:54:44.602Z" }, + { url = "https://files.pythonhosted.org/packages/82/5f/3e85351c523f73ad8d938989e9a58c7f59fb9c17f761b9981b43f0025ce7/librt-0.7.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4864045f49dc9c974dadb942ac56a74cd0479a2aafa51ce272c490a82322ea3c", size = 186717, upload-time = "2026-01-14T12:54:45.986Z" }, + { url = "https://files.pythonhosted.org/packages/08/f8/18bfe092e402d00fe00d33aa1e01dda1bd583ca100b393b4373847eade6d/librt-0.7.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a36515b1328dc5b3ffce79fe204985ca8572525452eacabee2166f44bb387b2c", size = 184585, upload-time = "2026-01-14T12:54:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/4e/fc/f43972ff56fd790a9fa55028a52ccea1875100edbb856b705bd393b601e3/librt-0.7.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b7e7f140c5169798f90b80d6e607ed2ba5059784968a004107c88ad61fb3641d", size = 180497, upload-time = "2026-01-14T12:54:48.946Z" }, + { url = "https://files.pythonhosted.org/packages/e1/3a/25e36030315a410d3ad0b7d0f19f5f188e88d1613d7d3fd8150523ea1093/librt-0.7.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff71447cb778a4f772ddc4ce360e6ba9c95527ed84a52096bd1bbf9fee2ec7c0", size = 200052, upload-time = "2026-01-14T12:54:50.382Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b8/f3a5a1931ae2a6ad92bf6893b9ef44325b88641d58723529e2c2935e8abe/librt-0.7.8-cp310-cp310-win32.whl", hash = "sha256:047164e5f68b7a8ebdf9fae91a3c2161d3192418aadd61ddd3a86a56cbe3dc85", size = 43477, upload-time = "2026-01-14T12:54:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/fe/91/c4202779366bc19f871b4ad25db10fcfa1e313c7893feb942f32668e8597/librt-0.7.8-cp310-cp310-win_amd64.whl", hash = "sha256:d6f254d096d84156a46a84861183c183d30734e52383602443292644d895047c", size = 49806, upload-time = "2026-01-14T12:54:53.149Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a3/87ea9c1049f2c781177496ebee29430e4631f439b8553a4969c88747d5d8/librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f", size = 56507, upload-time = "2026-01-14T12:54:54.156Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4a/23bcef149f37f771ad30203d561fcfd45b02bc54947b91f7a9ac34815747/librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac", size = 58455, upload-time = "2026-01-14T12:54:55.978Z" }, + { url = "https://files.pythonhosted.org/packages/22/6e/46eb9b85c1b9761e0f42b6e6311e1cc544843ac897457062b9d5d0b21df4/librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c", size = 164956, upload-time = "2026-01-14T12:54:57.311Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3f/aa7c7f6829fb83989feb7ba9aa11c662b34b4bd4bd5b262f2876ba3db58d/librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8", size = 174364, upload-time = "2026-01-14T12:54:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2d/d57d154b40b11f2cb851c4df0d4c4456bacd9b1ccc4ecb593ddec56c1a8b/librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff", size = 188034, upload-time = "2026-01-14T12:55:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/59/f9/36c4dad00925c16cd69d744b87f7001792691857d3b79187e7a673e812fb/librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3", size = 186295, upload-time = "2026-01-14T12:55:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/23/9b/8a9889d3df5efb67695a67785028ccd58e661c3018237b73ad081691d0cb/librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75", size = 181470, upload-time = "2026-01-14T12:55:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/43/64/54d6ef11afca01fef8af78c230726a9394759f2addfbf7afc5e3cc032a45/librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873", size = 201713, upload-time = "2026-01-14T12:55:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/2d/29/73e7ed2991330b28919387656f54109139b49e19cd72902f466bd44415fd/librt-0.7.8-cp311-cp311-win32.whl", hash = "sha256:31724b93baa91512bd0a376e7cf0b59d8b631ee17923b1218a65456fa9bda2e7", size = 43803, upload-time = "2026-01-14T12:55:04.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/66766ff48ed02b4d78deea30392ae200bcbd99ae61ba2418b49fd50a4831/librt-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:978e8b5f13e52cf23a9e80f3286d7546baa70bc4ef35b51d97a709d0b28e537c", size = 50080, upload-time = "2026-01-14T12:55:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e3/33450438ff3a8c581d4ed7f798a70b07c3206d298cf0b87d3806e72e3ed8/librt-0.7.8-cp311-cp311-win_arm64.whl", hash = "sha256:20e3946863d872f7cabf7f77c6c9d370b8b3d74333d3a32471c50d3a86c0a232", size = 43383, upload-time = "2026-01-14T12:55:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" }, + { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" }, + { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" }, + { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" }, + { url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057, upload-time = "2026-01-14T12:55:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293, upload-time = "2026-01-14T12:55:19.179Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574, upload-time = "2026-01-14T12:55:20.185Z" }, ] [[package]] name = "lxml" version = "6.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426 } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/8a/f8192a08237ef2fb1b19733f709db88a4c43bc8ab8357f01cb41a27e7f6a/lxml-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e77dd455b9a16bbd2a5036a63ddbd479c19572af81b624e79ef422f929eef388", size = 8590589 }, - { url = "https://files.pythonhosted.org/packages/12/64/27bcd07ae17ff5e5536e8d88f4c7d581b48963817a13de11f3ac3329bfa2/lxml-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d444858b9f07cefff6455b983aea9a67f7462ba1f6cbe4a21e8bf6791bf2153", size = 4629671 }, - { url = "https://files.pythonhosted.org/packages/02/5a/a7d53b3291c324e0b6e48f3c797be63836cc52156ddf8f33cd72aac78866/lxml-6.0.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f952dacaa552f3bb8834908dddd500ba7d508e6ea6eb8c52eb2d28f48ca06a31", size = 4999961 }, - { url = "https://files.pythonhosted.org/packages/f5/55/d465e9b89df1761674d8672bb3e4ae2c47033b01ec243964b6e334c6743f/lxml-6.0.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71695772df6acea9f3c0e59e44ba8ac50c4f125217e84aab21074a1a55e7e5c9", size = 5157087 }, - { url = "https://files.pythonhosted.org/packages/62/38/3073cd7e3e8dfc3ba3c3a139e33bee3a82de2bfb0925714351ad3d255c13/lxml-6.0.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17f68764f35fd78d7c4cc4ef209a184c38b65440378013d24b8aecd327c3e0c8", size = 5067620 }, - { url = "https://files.pythonhosted.org/packages/4a/d3/1e001588c5e2205637b08985597827d3827dbaaece16348c8822bfe61c29/lxml-6.0.2-cp310-cp310-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:058027e261afed589eddcfe530fcc6f3402d7fd7e89bfd0532df82ebc1563dba", size = 5406664 }, - { url = "https://files.pythonhosted.org/packages/20/cf/cab09478699b003857ed6ebfe95e9fb9fa3d3c25f1353b905c9b73cfb624/lxml-6.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8ffaeec5dfea5881d4c9d8913a32d10cfe3923495386106e4a24d45300ef79c", size = 5289397 }, - { url = "https://files.pythonhosted.org/packages/a3/84/02a2d0c38ac9a8b9f9e5e1bbd3f24b3f426044ad618b552e9549ee91bd63/lxml-6.0.2-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:f2e3b1a6bb38de0bc713edd4d612969dd250ca8b724be8d460001a387507021c", size = 4772178 }, - { url = "https://files.pythonhosted.org/packages/56/87/e1ceadcc031ec4aa605fe95476892d0b0ba3b7f8c7dcdf88fdeff59a9c86/lxml-6.0.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d6690ec5ec1cce0385cb20896b16be35247ac8c2046e493d03232f1c2414d321", size = 5358148 }, - { url = "https://files.pythonhosted.org/packages/fe/13/5bb6cf42bb228353fd4ac5f162c6a84fd68a4d6f67c1031c8cf97e131fc6/lxml-6.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2a50c3c1d11cad0ebebbac357a97b26aa79d2bcaf46f256551152aa85d3a4d1", size = 5112035 }, - { url = "https://files.pythonhosted.org/packages/e4/e2/ea0498552102e59834e297c5c6dff8d8ded3db72ed5e8aad77871476f073/lxml-6.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3efe1b21c7801ffa29a1112fab3b0f643628c30472d507f39544fd48e9549e34", size = 4799111 }, - { url = "https://files.pythonhosted.org/packages/6a/9e/8de42b52a73abb8af86c66c969b3b4c2a96567b6ac74637c037d2e3baa60/lxml-6.0.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:59c45e125140b2c4b33920d21d83681940ca29f0b83f8629ea1a2196dc8cfe6a", size = 5351662 }, - { url = "https://files.pythonhosted.org/packages/28/a2/de776a573dfb15114509a37351937c367530865edb10a90189d0b4b9b70a/lxml-6.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:452b899faa64f1805943ec1c0c9ebeaece01a1af83e130b69cdefeda180bb42c", size = 5314973 }, - { url = "https://files.pythonhosted.org/packages/50/a0/3ae1b1f8964c271b5eec91db2043cf8c6c0bce101ebb2a633b51b044db6c/lxml-6.0.2-cp310-cp310-win32.whl", hash = "sha256:1e786a464c191ca43b133906c6903a7e4d56bef376b75d97ccbb8ec5cf1f0a4b", size = 3611953 }, - { url = "https://files.pythonhosted.org/packages/d1/70/bd42491f0634aad41bdfc1e46f5cff98825fb6185688dc82baa35d509f1a/lxml-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:dacf3c64ef3f7440e3167aa4b49aa9e0fb99e0aa4f9ff03795640bf94531bcb0", size = 4032695 }, - { url = "https://files.pythonhosted.org/packages/d2/d0/05c6a72299f54c2c561a6c6cbb2f512e047fca20ea97a05e57931f194ac4/lxml-6.0.2-cp310-cp310-win_arm64.whl", hash = "sha256:45f93e6f75123f88d7f0cfd90f2d05f441b808562bf0bc01070a00f53f5028b5", size = 3680051 }, - { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365 }, - { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793 }, - { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362 }, - { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152 }, - { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539 }, - { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853 }, - { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133 }, - { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944 }, - { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535 }, - { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343 }, - { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419 }, - { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008 }, - { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906 }, - { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357 }, - { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583 }, - { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591 }, - { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887 }, - { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818 }, - { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807 }, - { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179 }, - { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044 }, - { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685 }, - { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127 }, - { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958 }, - { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541 }, - { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426 }, - { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917 }, - { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795 }, - { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759 }, - { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666 }, - { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989 }, - { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456 }, - { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793 }, - { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836 }, - { url = "https://files.pythonhosted.org/packages/e7/9c/780c9a8fce3f04690b374f72f41306866b0400b9d0fdf3e17aaa37887eed/lxml-6.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e748d4cf8fef2526bb2a589a417eba0c8674e29ffcb570ce2ceca44f1e567bf6", size = 3939264 }, - { url = "https://files.pythonhosted.org/packages/f5/5a/1ab260c00adf645d8bf7dec7f920f744b032f69130c681302821d5debea6/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4ddb1049fa0579d0cbd00503ad8c58b9ab34d1254c77bc6a5576d96ec7853dba", size = 4216435 }, - { url = "https://files.pythonhosted.org/packages/f2/37/565f3b3d7ffede22874b6d86be1a1763d00f4ea9fc5b9b6ccb11e4ec8612/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb233f9c95f83707dae461b12b720c1af9c28c2d19208e1be03387222151daf5", size = 4325913 }, - { url = "https://files.pythonhosted.org/packages/22/ec/f3a1b169b2fb9d03467e2e3c0c752ea30e993be440a068b125fc7dd248b0/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc456d04db0515ce3320d714a1eac7a97774ff0849e7718b492d957da4631dd4", size = 4269357 }, - { url = "https://files.pythonhosted.org/packages/77/a2/585a28fe3e67daa1cf2f06f34490d556d121c25d500b10082a7db96e3bcd/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2613e67de13d619fd283d58bda40bff0ee07739f624ffee8b13b631abf33083d", size = 4412295 }, - { url = "https://files.pythonhosted.org/packages/7b/d9/a57dd8bcebd7c69386c20263830d4fa72d27e6b72a229ef7a48e88952d9a/lxml-6.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:24a8e756c982c001ca8d59e87c80c4d9dcd4d9b44a4cbeb8d9be4482c514d41d", size = 3516913 }, - { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829 }, - { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277 }, - { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433 }, - { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119 }, - { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314 }, - { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768 }, + { url = "https://files.pythonhosted.org/packages/db/8a/f8192a08237ef2fb1b19733f709db88a4c43bc8ab8357f01cb41a27e7f6a/lxml-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e77dd455b9a16bbd2a5036a63ddbd479c19572af81b624e79ef422f929eef388", size = 8590589, upload-time = "2025-09-22T04:00:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/27bcd07ae17ff5e5536e8d88f4c7d581b48963817a13de11f3ac3329bfa2/lxml-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d444858b9f07cefff6455b983aea9a67f7462ba1f6cbe4a21e8bf6791bf2153", size = 4629671, upload-time = "2025-09-22T04:00:15.411Z" }, + { url = "https://files.pythonhosted.org/packages/02/5a/a7d53b3291c324e0b6e48f3c797be63836cc52156ddf8f33cd72aac78866/lxml-6.0.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f952dacaa552f3bb8834908dddd500ba7d508e6ea6eb8c52eb2d28f48ca06a31", size = 4999961, upload-time = "2025-09-22T04:00:17.619Z" }, + { url = "https://files.pythonhosted.org/packages/f5/55/d465e9b89df1761674d8672bb3e4ae2c47033b01ec243964b6e334c6743f/lxml-6.0.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71695772df6acea9f3c0e59e44ba8ac50c4f125217e84aab21074a1a55e7e5c9", size = 5157087, upload-time = "2025-09-22T04:00:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/38/3073cd7e3e8dfc3ba3c3a139e33bee3a82de2bfb0925714351ad3d255c13/lxml-6.0.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17f68764f35fd78d7c4cc4ef209a184c38b65440378013d24b8aecd327c3e0c8", size = 5067620, upload-time = "2025-09-22T04:00:21.877Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d3/1e001588c5e2205637b08985597827d3827dbaaece16348c8822bfe61c29/lxml-6.0.2-cp310-cp310-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:058027e261afed589eddcfe530fcc6f3402d7fd7e89bfd0532df82ebc1563dba", size = 5406664, upload-time = "2025-09-22T04:00:23.714Z" }, + { url = "https://files.pythonhosted.org/packages/20/cf/cab09478699b003857ed6ebfe95e9fb9fa3d3c25f1353b905c9b73cfb624/lxml-6.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8ffaeec5dfea5881d4c9d8913a32d10cfe3923495386106e4a24d45300ef79c", size = 5289397, upload-time = "2025-09-22T04:00:25.544Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/02a2d0c38ac9a8b9f9e5e1bbd3f24b3f426044ad618b552e9549ee91bd63/lxml-6.0.2-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:f2e3b1a6bb38de0bc713edd4d612969dd250ca8b724be8d460001a387507021c", size = 4772178, upload-time = "2025-09-22T04:00:27.602Z" }, + { url = "https://files.pythonhosted.org/packages/56/87/e1ceadcc031ec4aa605fe95476892d0b0ba3b7f8c7dcdf88fdeff59a9c86/lxml-6.0.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d6690ec5ec1cce0385cb20896b16be35247ac8c2046e493d03232f1c2414d321", size = 5358148, upload-time = "2025-09-22T04:00:29.323Z" }, + { url = "https://files.pythonhosted.org/packages/fe/13/5bb6cf42bb228353fd4ac5f162c6a84fd68a4d6f67c1031c8cf97e131fc6/lxml-6.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2a50c3c1d11cad0ebebbac357a97b26aa79d2bcaf46f256551152aa85d3a4d1", size = 5112035, upload-time = "2025-09-22T04:00:31.061Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e2/ea0498552102e59834e297c5c6dff8d8ded3db72ed5e8aad77871476f073/lxml-6.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3efe1b21c7801ffa29a1112fab3b0f643628c30472d507f39544fd48e9549e34", size = 4799111, upload-time = "2025-09-22T04:00:33.11Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9e/8de42b52a73abb8af86c66c969b3b4c2a96567b6ac74637c037d2e3baa60/lxml-6.0.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:59c45e125140b2c4b33920d21d83681940ca29f0b83f8629ea1a2196dc8cfe6a", size = 5351662, upload-time = "2025-09-22T04:00:35.237Z" }, + { url = "https://files.pythonhosted.org/packages/28/a2/de776a573dfb15114509a37351937c367530865edb10a90189d0b4b9b70a/lxml-6.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:452b899faa64f1805943ec1c0c9ebeaece01a1af83e130b69cdefeda180bb42c", size = 5314973, upload-time = "2025-09-22T04:00:37.086Z" }, + { url = "https://files.pythonhosted.org/packages/50/a0/3ae1b1f8964c271b5eec91db2043cf8c6c0bce101ebb2a633b51b044db6c/lxml-6.0.2-cp310-cp310-win32.whl", hash = "sha256:1e786a464c191ca43b133906c6903a7e4d56bef376b75d97ccbb8ec5cf1f0a4b", size = 3611953, upload-time = "2025-09-22T04:00:39.224Z" }, + { url = "https://files.pythonhosted.org/packages/d1/70/bd42491f0634aad41bdfc1e46f5cff98825fb6185688dc82baa35d509f1a/lxml-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:dacf3c64ef3f7440e3167aa4b49aa9e0fb99e0aa4f9ff03795640bf94531bcb0", size = 4032695, upload-time = "2025-09-22T04:00:41.402Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d0/05c6a72299f54c2c561a6c6cbb2f512e047fca20ea97a05e57931f194ac4/lxml-6.0.2-cp310-cp310-win_arm64.whl", hash = "sha256:45f93e6f75123f88d7f0cfd90f2d05f441b808562bf0bc01070a00f53f5028b5", size = 3680051, upload-time = "2025-09-22T04:00:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, + { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" }, + { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" }, + { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" }, + { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" }, + { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" }, + { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, + { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, + { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/e7/9c/780c9a8fce3f04690b374f72f41306866b0400b9d0fdf3e17aaa37887eed/lxml-6.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e748d4cf8fef2526bb2a589a417eba0c8674e29ffcb570ce2ceca44f1e567bf6", size = 3939264, upload-time = "2025-09-22T04:04:32.892Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5a/1ab260c00adf645d8bf7dec7f920f744b032f69130c681302821d5debea6/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4ddb1049fa0579d0cbd00503ad8c58b9ab34d1254c77bc6a5576d96ec7853dba", size = 4216435, upload-time = "2025-09-22T04:04:34.907Z" }, + { url = "https://files.pythonhosted.org/packages/f2/37/565f3b3d7ffede22874b6d86be1a1763d00f4ea9fc5b9b6ccb11e4ec8612/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb233f9c95f83707dae461b12b720c1af9c28c2d19208e1be03387222151daf5", size = 4325913, upload-time = "2025-09-22T04:04:37.205Z" }, + { url = "https://files.pythonhosted.org/packages/22/ec/f3a1b169b2fb9d03467e2e3c0c752ea30e993be440a068b125fc7dd248b0/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc456d04db0515ce3320d714a1eac7a97774ff0849e7718b492d957da4631dd4", size = 4269357, upload-time = "2025-09-22T04:04:39.322Z" }, + { url = "https://files.pythonhosted.org/packages/77/a2/585a28fe3e67daa1cf2f06f34490d556d121c25d500b10082a7db96e3bcd/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2613e67de13d619fd283d58bda40bff0ee07739f624ffee8b13b631abf33083d", size = 4412295, upload-time = "2025-09-22T04:04:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/a57dd8bcebd7c69386c20263830d4fa72d27e6b72a229ef7a48e88952d9a/lxml-6.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:24a8e756c982c001ca8d59e87c80c4d9dcd4d9b44a4cbeb8d9be4482c514d41d", size = 3516913, upload-time = "2025-09-22T04:04:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" }, + { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" }, + { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" }, + { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, ] [[package]] name = "marisa-trie" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c5/e3/c9066e74076b90f9701ccd23d6a0b8c1d583feefdec576dc3e1bb093c50d/marisa_trie-1.3.1.tar.gz", hash = "sha256:97107fd12f30e4f8fea97790343a2d2d9a79d93697fe14e1b6f6363c984ff85b", size = 212454 } +sdist = { url = "https://files.pythonhosted.org/packages/c5/e3/c9066e74076b90f9701ccd23d6a0b8c1d583feefdec576dc3e1bb093c50d/marisa_trie-1.3.1.tar.gz", hash = "sha256:97107fd12f30e4f8fea97790343a2d2d9a79d93697fe14e1b6f6363c984ff85b", size = 212454, upload-time = "2025-08-26T15:13:18.401Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/eb/c18113555950ea25c421a5e8f7f280a9d7e9198a072f89d33ae9a5725ead/marisa_trie-1.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7e957aa4251a8e70b9fe02a16b2d190f18787902da563cb7ba865508b8e8fb04", size = 172432 }, - { url = "https://files.pythonhosted.org/packages/b5/98/6d3f507a7340697d25d53839e68b516d3d01a3714edf33d484896250189b/marisa_trie-1.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e5888b269e790356ce4525f3e8df1fe866d1497b7d7fb7548cfec883cb985288", size = 156327 }, - { url = "https://files.pythonhosted.org/packages/be/39/78d6def87a6effec6480ef1474d4cc81ef9845c78281ac5a6c07a6440744/marisa_trie-1.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f81344d212cb41992340b0b8a67e375f44da90590b884204fd3fa5e02107df2", size = 1219155 }, - { url = "https://files.pythonhosted.org/packages/a6/b4/3b60c26cb9a2c623f47eeed84cfa6ebd3f71c5bd95ef32ed526e4ac689dc/marisa_trie-1.3.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3715d779561699471edde70975e07b1de7dddb2816735d40ed16be4b32054188", size = 1239413 }, - { url = "https://files.pythonhosted.org/packages/21/ef/9c7fca5bf133bdb144317843881c8b0c74d2acb7fa209f793c29422e7669/marisa_trie-1.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:47631614c5243ed7d15ae0af8245fcc0599f5b7921fae2a4ae992afb27c9afbb", size = 2161737 }, - { url = "https://files.pythonhosted.org/packages/1c/03/d5f630498bf4b8baf2d6484651255f601e9fdc6d42a83288e8b2420ebc9b/marisa_trie-1.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ad82ab8a58562cf69e6b786debcc7638b28df12f9f1c7bcffb07efb5c1f09cbd", size = 2250038 }, - { url = "https://files.pythonhosted.org/packages/00/6b/c12f055dbb13d22b0f8e1f3da9cb734f581b516cc0e3c909e3f39368f676/marisa_trie-1.3.1-cp310-cp310-win32.whl", hash = "sha256:9f92d3577c72d5a97af5c8e3d98247b79c8ccfb64ebf611311dcf631b11e5604", size = 117232 }, - { url = "https://files.pythonhosted.org/packages/f2/fd/988a19587c7bb8f03fb80e17335f75ca2d5538df4909727012b4bdff8f99/marisa_trie-1.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:a5a0a58ffe2a7eb3f870214c6df8f9a43ce768bd8fed883e6ba8c77645666b63", size = 143231 }, - { url = "https://files.pythonhosted.org/packages/a7/bf/2f1fe6c9fcd2b509c6dfaaf26e35128947d6d3718d0b39510903c55b7bed/marisa_trie-1.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ef045f694ef66079b4e00c4c9063a00183d6af7d1ff643de6ea5c3b0d9af01b", size = 174027 }, - { url = "https://files.pythonhosted.org/packages/a9/5a/de7936d58ed0de847180cee2b95143d420223c5ade0c093d55113f628237/marisa_trie-1.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cbd28f95d5f30d9a7af6130869568e75bfd7ef2e0adfb1480f1f44480f5d3603", size = 158478 }, - { url = "https://files.pythonhosted.org/packages/48/cc/80611aadefcd0bcf8cd1795cb4643bb27213319a221ba04fe071da0b75cd/marisa_trie-1.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b173ec46d521308f7c97d96d6e05cf2088e0548f82544ec9a8656af65593304d", size = 1257535 }, - { url = "https://files.pythonhosted.org/packages/36/89/c4eeefb956318047036e6bdc572b6112b2059d595e85961267a90aa40458/marisa_trie-1.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:954fef9185f8a79441b4e433695116636bf66402945cfee404f8983bafa59788", size = 1275566 }, - { url = "https://files.pythonhosted.org/packages/c4/63/d775a2fdfc4b555120381cd2aa6dff1845576bc14fb13796ae1b1e8dbaf7/marisa_trie-1.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ca644534f15f85bba14c412afc17de07531e79a766ce85b8dbf3f8b6e7758f20", size = 2199831 }, - { url = "https://files.pythonhosted.org/packages/50/aa/e5053927dc3cac77acc9b27f6f87e75c880f5d3d5eac9111fe13b1d8bf6f/marisa_trie-1.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3834304fdeaa1c9b73596ad5a6c01a44fc19c13c115194704b85f7fbdf0a7b8e", size = 2283830 }, - { url = "https://files.pythonhosted.org/packages/71/3e/e314906d0de5b1a44780a23c79bb62a9aafd876e2a4e80fb34f58c721da4/marisa_trie-1.3.1-cp311-cp311-win32.whl", hash = "sha256:70b4c96f9119cfeb4dc6a0cf4afc9f92f0b002cde225bcd910915d976c78e66a", size = 117335 }, - { url = "https://files.pythonhosted.org/packages/b0/2b/85623566621135de3d57497811f94679b4fb2a8f16148ef67133c2abab7a/marisa_trie-1.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:986eaf35a7f63c878280609ecd37edf8a074f7601c199acfec81d03f1ee9a39a", size = 143985 }, - { url = "https://files.pythonhosted.org/packages/3f/40/ee7ea61b88d62d2189b5c4a27bc0fc8d9c32f8b8dc6daf1c93a7b7ad34ac/marisa_trie-1.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b7c1e7fa6c3b855e8cfbabf38454d7decbaba1c567d0cd58880d033c6b363bd", size = 173454 }, - { url = "https://files.pythonhosted.org/packages/9c/fc/58635811586898041004b2197a085253706ede211324a53ec01612a50e20/marisa_trie-1.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c12b44c190deb0d67655021da1f2d0a7d61a257bf844101cf982e68ed344f28d", size = 155305 }, - { url = "https://files.pythonhosted.org/packages/fe/98/88ca0c98d37034a3237acaf461d210cbcfeb6687929e5ba0e354971fa3ed/marisa_trie-1.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9688c7b45f744366a4ef661e399f24636ebe440d315ab35d768676c59c613186", size = 1244834 }, - { url = "https://files.pythonhosted.org/packages/f3/5f/93b3e3607ccd693a768eafee60829cd14ea1810b75aa48e8b20e27b332c4/marisa_trie-1.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99a00cab4cf9643a87977c87a5c8961aa44fff8d5dd46e00250135f686e7dedf", size = 1265148 }, - { url = "https://files.pythonhosted.org/packages/db/6e/051d7d25c7fb2b3df605c8bd782513ebbb33fddf3bae6cf46cf268cca89f/marisa_trie-1.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:83efc045fc58ca04c91a96c9b894d8a19ac6553677a76f96df01ff9f0405f53d", size = 2172726 }, - { url = "https://files.pythonhosted.org/packages/58/da/244d9d4e414ce6c73124cba4cc293dd140bf3b04ca18dec64c2775cca951/marisa_trie-1.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0b9816ab993001a7854b02a7daec228892f35bd5ab0ac493bacbd1b80baec9f1", size = 2256104 }, - { url = "https://files.pythonhosted.org/packages/c4/f1/1a36ecd7da6668685a7753522af89a19928ffc80f1cc1dbc301af216f011/marisa_trie-1.3.1-cp312-cp312-win32.whl", hash = "sha256:c785fd6dae9daa6825734b7b494cdac972f958be1f9cb3fb1f32be8598d2b936", size = 115624 }, - { url = "https://files.pythonhosted.org/packages/35/b2/aabd1c9f1c102aa31d66633ed5328c447be166e0a703f9723e682478fd83/marisa_trie-1.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:9868b7a8e0f648d09ffe25ac29511e6e208cc5fb0d156c295385f9d5dc2a138e", size = 138562 }, + { url = "https://files.pythonhosted.org/packages/36/eb/c18113555950ea25c421a5e8f7f280a9d7e9198a072f89d33ae9a5725ead/marisa_trie-1.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7e957aa4251a8e70b9fe02a16b2d190f18787902da563cb7ba865508b8e8fb04", size = 172432, upload-time = "2025-08-26T15:11:51.329Z" }, + { url = "https://files.pythonhosted.org/packages/b5/98/6d3f507a7340697d25d53839e68b516d3d01a3714edf33d484896250189b/marisa_trie-1.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e5888b269e790356ce4525f3e8df1fe866d1497b7d7fb7548cfec883cb985288", size = 156327, upload-time = "2025-08-26T15:11:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/be/39/78d6def87a6effec6480ef1474d4cc81ef9845c78281ac5a6c07a6440744/marisa_trie-1.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f81344d212cb41992340b0b8a67e375f44da90590b884204fd3fa5e02107df2", size = 1219155, upload-time = "2025-08-26T15:11:53.915Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b4/3b60c26cb9a2c623f47eeed84cfa6ebd3f71c5bd95ef32ed526e4ac689dc/marisa_trie-1.3.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3715d779561699471edde70975e07b1de7dddb2816735d40ed16be4b32054188", size = 1239413, upload-time = "2025-08-26T15:11:55.655Z" }, + { url = "https://files.pythonhosted.org/packages/21/ef/9c7fca5bf133bdb144317843881c8b0c74d2acb7fa209f793c29422e7669/marisa_trie-1.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:47631614c5243ed7d15ae0af8245fcc0599f5b7921fae2a4ae992afb27c9afbb", size = 2161737, upload-time = "2025-08-26T15:11:56.832Z" }, + { url = "https://files.pythonhosted.org/packages/1c/03/d5f630498bf4b8baf2d6484651255f601e9fdc6d42a83288e8b2420ebc9b/marisa_trie-1.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ad82ab8a58562cf69e6b786debcc7638b28df12f9f1c7bcffb07efb5c1f09cbd", size = 2250038, upload-time = "2025-08-26T15:11:58.165Z" }, + { url = "https://files.pythonhosted.org/packages/00/6b/c12f055dbb13d22b0f8e1f3da9cb734f581b516cc0e3c909e3f39368f676/marisa_trie-1.3.1-cp310-cp310-win32.whl", hash = "sha256:9f92d3577c72d5a97af5c8e3d98247b79c8ccfb64ebf611311dcf631b11e5604", size = 117232, upload-time = "2025-08-26T15:11:59.616Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fd/988a19587c7bb8f03fb80e17335f75ca2d5538df4909727012b4bdff8f99/marisa_trie-1.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:a5a0a58ffe2a7eb3f870214c6df8f9a43ce768bd8fed883e6ba8c77645666b63", size = 143231, upload-time = "2025-08-26T15:12:00.52Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bf/2f1fe6c9fcd2b509c6dfaaf26e35128947d6d3718d0b39510903c55b7bed/marisa_trie-1.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ef045f694ef66079b4e00c4c9063a00183d6af7d1ff643de6ea5c3b0d9af01b", size = 174027, upload-time = "2025-08-26T15:12:01.434Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5a/de7936d58ed0de847180cee2b95143d420223c5ade0c093d55113f628237/marisa_trie-1.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cbd28f95d5f30d9a7af6130869568e75bfd7ef2e0adfb1480f1f44480f5d3603", size = 158478, upload-time = "2025-08-26T15:12:02.429Z" }, + { url = "https://files.pythonhosted.org/packages/48/cc/80611aadefcd0bcf8cd1795cb4643bb27213319a221ba04fe071da0b75cd/marisa_trie-1.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b173ec46d521308f7c97d96d6e05cf2088e0548f82544ec9a8656af65593304d", size = 1257535, upload-time = "2025-08-26T15:12:04.271Z" }, + { url = "https://files.pythonhosted.org/packages/36/89/c4eeefb956318047036e6bdc572b6112b2059d595e85961267a90aa40458/marisa_trie-1.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:954fef9185f8a79441b4e433695116636bf66402945cfee404f8983bafa59788", size = 1275566, upload-time = "2025-08-26T15:12:05.874Z" }, + { url = "https://files.pythonhosted.org/packages/c4/63/d775a2fdfc4b555120381cd2aa6dff1845576bc14fb13796ae1b1e8dbaf7/marisa_trie-1.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ca644534f15f85bba14c412afc17de07531e79a766ce85b8dbf3f8b6e7758f20", size = 2199831, upload-time = "2025-08-26T15:12:07.175Z" }, + { url = "https://files.pythonhosted.org/packages/50/aa/e5053927dc3cac77acc9b27f6f87e75c880f5d3d5eac9111fe13b1d8bf6f/marisa_trie-1.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3834304fdeaa1c9b73596ad5a6c01a44fc19c13c115194704b85f7fbdf0a7b8e", size = 2283830, upload-time = "2025-08-26T15:12:08.319Z" }, + { url = "https://files.pythonhosted.org/packages/71/3e/e314906d0de5b1a44780a23c79bb62a9aafd876e2a4e80fb34f58c721da4/marisa_trie-1.3.1-cp311-cp311-win32.whl", hash = "sha256:70b4c96f9119cfeb4dc6a0cf4afc9f92f0b002cde225bcd910915d976c78e66a", size = 117335, upload-time = "2025-08-26T15:12:09.776Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2b/85623566621135de3d57497811f94679b4fb2a8f16148ef67133c2abab7a/marisa_trie-1.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:986eaf35a7f63c878280609ecd37edf8a074f7601c199acfec81d03f1ee9a39a", size = 143985, upload-time = "2025-08-26T15:12:10.988Z" }, + { url = "https://files.pythonhosted.org/packages/3f/40/ee7ea61b88d62d2189b5c4a27bc0fc8d9c32f8b8dc6daf1c93a7b7ad34ac/marisa_trie-1.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b7c1e7fa6c3b855e8cfbabf38454d7decbaba1c567d0cd58880d033c6b363bd", size = 173454, upload-time = "2025-08-26T15:12:12.13Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fc/58635811586898041004b2197a085253706ede211324a53ec01612a50e20/marisa_trie-1.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c12b44c190deb0d67655021da1f2d0a7d61a257bf844101cf982e68ed344f28d", size = 155305, upload-time = "2025-08-26T15:12:13.374Z" }, + { url = "https://files.pythonhosted.org/packages/fe/98/88ca0c98d37034a3237acaf461d210cbcfeb6687929e5ba0e354971fa3ed/marisa_trie-1.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9688c7b45f744366a4ef661e399f24636ebe440d315ab35d768676c59c613186", size = 1244834, upload-time = "2025-08-26T15:12:14.795Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5f/93b3e3607ccd693a768eafee60829cd14ea1810b75aa48e8b20e27b332c4/marisa_trie-1.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99a00cab4cf9643a87977c87a5c8961aa44fff8d5dd46e00250135f686e7dedf", size = 1265148, upload-time = "2025-08-26T15:12:16.229Z" }, + { url = "https://files.pythonhosted.org/packages/db/6e/051d7d25c7fb2b3df605c8bd782513ebbb33fddf3bae6cf46cf268cca89f/marisa_trie-1.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:83efc045fc58ca04c91a96c9b894d8a19ac6553677a76f96df01ff9f0405f53d", size = 2172726, upload-time = "2025-08-26T15:12:18.467Z" }, + { url = "https://files.pythonhosted.org/packages/58/da/244d9d4e414ce6c73124cba4cc293dd140bf3b04ca18dec64c2775cca951/marisa_trie-1.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0b9816ab993001a7854b02a7daec228892f35bd5ab0ac493bacbd1b80baec9f1", size = 2256104, upload-time = "2025-08-26T15:12:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f1/1a36ecd7da6668685a7753522af89a19928ffc80f1cc1dbc301af216f011/marisa_trie-1.3.1-cp312-cp312-win32.whl", hash = "sha256:c785fd6dae9daa6825734b7b494cdac972f958be1f9cb3fb1f32be8598d2b936", size = 115624, upload-time = "2025-08-26T15:12:21.233Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/aabd1c9f1c102aa31d66633ed5328c447be166e0a703f9723e682478fd83/marisa_trie-1.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:9868b7a8e0f648d09ffe25ac29511e6e208cc5fb0d156c295385f9d5dc2a138e", size = 138562, upload-time = "2025-08-26T15:12:22.632Z" }, ] [[package]] @@ -800,175 +851,176 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070 } +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321 }, + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] [[package]] name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] name = "more-itertools" -version = "10.7.0" +version = "10.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/a0/834b0cebabbfc7e311f30b46c8188790a37f89fc8d756660346fe5abfd09/more_itertools-10.7.0.tar.gz", hash = "sha256:9fddd5403be01a94b204faadcff459ec3568cf110265d3c54323e1e866ad29d3", size = 127671 } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/9f/7ba6f94fc1e9ac3d2b853fdff3035fb2fa5afbed898c4a72b8a020610594/more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e", size = 65278 }, + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, ] [[package]] name = "multidict" -version = "6.6.4" +version = "6.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/7f/0652e6ed47ab288e3756ea9c0df8b14950781184d4bd7883f4d87dd41245/multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd", size = 101843 } +sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/6b/86f353088c1358e76fd30b0146947fddecee812703b604ee901e85cd2a80/multidict-6.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b8aa6f0bd8125ddd04a6593437bad6a7e70f300ff4180a531654aa2ab3f6d58f", size = 77054 }, - { url = "https://files.pythonhosted.org/packages/19/5d/c01dc3d3788bb877bd7f5753ea6eb23c1beeca8044902a8f5bfb54430f63/multidict-6.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b9e5853bbd7264baca42ffc53391b490d65fe62849bf2c690fa3f6273dbcd0cb", size = 44914 }, - { url = "https://files.pythonhosted.org/packages/46/44/964dae19ea42f7d3e166474d8205f14bb811020e28bc423d46123ddda763/multidict-6.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0af5f9dee472371e36d6ae38bde009bd8ce65ac7335f55dcc240379d7bed1495", size = 44601 }, - { url = "https://files.pythonhosted.org/packages/31/20/0616348a1dfb36cb2ab33fc9521de1f27235a397bf3f59338e583afadd17/multidict-6.6.4-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:d24f351e4d759f5054b641c81e8291e5d122af0fca5c72454ff77f7cbe492de8", size = 224821 }, - { url = "https://files.pythonhosted.org/packages/14/26/5d8923c69c110ff51861af05bd27ca6783011b96725d59ccae6d9daeb627/multidict-6.6.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db6a3810eec08280a172a6cd541ff4a5f6a97b161d93ec94e6c4018917deb6b7", size = 242608 }, - { url = "https://files.pythonhosted.org/packages/5c/cc/e2ad3ba9459aa34fa65cf1f82a5c4a820a2ce615aacfb5143b8817f76504/multidict-6.6.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a1b20a9d56b2d81e2ff52ecc0670d583eaabaa55f402e8d16dd062373dbbe796", size = 222324 }, - { url = "https://files.pythonhosted.org/packages/19/db/4ed0f65701afbc2cb0c140d2d02928bb0fe38dd044af76e58ad7c54fd21f/multidict-6.6.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c9854df0eaa610a23494c32a6f44a3a550fb398b6b51a56e8c6b9b3689578db", size = 253234 }, - { url = "https://files.pythonhosted.org/packages/94/c1/5160c9813269e39ae14b73debb907bfaaa1beee1762da8c4fb95df4764ed/multidict-6.6.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4bb7627fd7a968f41905a4d6343b0d63244a0623f006e9ed989fa2b78f4438a0", size = 251613 }, - { url = "https://files.pythonhosted.org/packages/05/a9/48d1bd111fc2f8fb98b2ed7f9a115c55a9355358432a19f53c0b74d8425d/multidict-6.6.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caebafea30ed049c57c673d0b36238b1748683be2593965614d7b0e99125c877", size = 241649 }, - { url = "https://files.pythonhosted.org/packages/85/2a/f7d743df0019408768af8a70d2037546a2be7b81fbb65f040d76caafd4c5/multidict-6.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ad887a8250eb47d3ab083d2f98db7f48098d13d42eb7a3b67d8a5c795f224ace", size = 239238 }, - { url = "https://files.pythonhosted.org/packages/cb/b8/4f4bb13323c2d647323f7919201493cf48ebe7ded971717bfb0f1a79b6bf/multidict-6.6.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:ed8358ae7d94ffb7c397cecb62cbac9578a83ecefc1eba27b9090ee910e2efb6", size = 233517 }, - { url = "https://files.pythonhosted.org/packages/33/29/4293c26029ebfbba4f574febd2ed01b6f619cfa0d2e344217d53eef34192/multidict-6.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ecab51ad2462197a4c000b6d5701fc8585b80eecb90583635d7e327b7b6923eb", size = 243122 }, - { url = "https://files.pythonhosted.org/packages/20/60/a1c53628168aa22447bfde3a8730096ac28086704a0d8c590f3b63388d0c/multidict-6.6.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c5c97aa666cf70e667dfa5af945424ba1329af5dd988a437efeb3a09430389fb", size = 248992 }, - { url = "https://files.pythonhosted.org/packages/a3/3b/55443a0c372f33cae5d9ec37a6a973802884fa0ab3586659b197cf8cc5e9/multidict-6.6.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9a950b7cf54099c1209f455ac5970b1ea81410f2af60ed9eb3c3f14f0bfcf987", size = 243708 }, - { url = "https://files.pythonhosted.org/packages/7c/60/a18c6900086769312560b2626b18e8cca22d9e85b1186ba77f4755b11266/multidict-6.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:163c7ea522ea9365a8a57832dea7618e6cbdc3cd75f8c627663587459a4e328f", size = 237498 }, - { url = "https://files.pythonhosted.org/packages/11/3d/8bdd8bcaff2951ce2affccca107a404925a2beafedd5aef0b5e4a71120a6/multidict-6.6.4-cp310-cp310-win32.whl", hash = "sha256:17d2cbbfa6ff20821396b25890f155f40c986f9cfbce5667759696d83504954f", size = 41415 }, - { url = "https://files.pythonhosted.org/packages/c0/53/cab1ad80356a4cd1b685a254b680167059b433b573e53872fab245e9fc95/multidict-6.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:ce9a40fbe52e57e7edf20113a4eaddfacac0561a0879734e636aa6d4bb5e3fb0", size = 46046 }, - { url = "https://files.pythonhosted.org/packages/cf/9a/874212b6f5c1c2d870d0a7adc5bb4cfe9b0624fa15cdf5cf757c0f5087ae/multidict-6.6.4-cp310-cp310-win_arm64.whl", hash = "sha256:01d0959807a451fe9fdd4da3e139cb5b77f7328baf2140feeaf233e1d777b729", size = 43147 }, - { url = "https://files.pythonhosted.org/packages/6b/7f/90a7f01e2d005d6653c689039977f6856718c75c5579445effb7e60923d1/multidict-6.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c7a0e9b561e6460484318a7612e725df1145d46b0ef57c6b9866441bf6e27e0c", size = 76472 }, - { url = "https://files.pythonhosted.org/packages/54/a3/bed07bc9e2bb302ce752f1dabc69e884cd6a676da44fb0e501b246031fdd/multidict-6.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6bf2f10f70acc7a2446965ffbc726e5fc0b272c97a90b485857e5c70022213eb", size = 44634 }, - { url = "https://files.pythonhosted.org/packages/a7/4b/ceeb4f8f33cf81277da464307afeaf164fb0297947642585884f5cad4f28/multidict-6.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66247d72ed62d5dd29752ffc1d3b88f135c6a8de8b5f63b7c14e973ef5bda19e", size = 44282 }, - { url = "https://files.pythonhosted.org/packages/03/35/436a5da8702b06866189b69f655ffdb8f70796252a8772a77815f1812679/multidict-6.6.4-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:105245cc6b76f51e408451a844a54e6823bbd5a490ebfe5bdfc79798511ceded", size = 229696 }, - { url = "https://files.pythonhosted.org/packages/b6/0e/915160be8fecf1fca35f790c08fb74ca684d752fcba62c11daaf3d92c216/multidict-6.6.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cbbc54e58b34c3bae389ef00046be0961f30fef7cb0dd9c7756aee376a4f7683", size = 246665 }, - { url = "https://files.pythonhosted.org/packages/08/ee/2f464330acd83f77dcc346f0b1a0eaae10230291450887f96b204b8ac4d3/multidict-6.6.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:56c6b3652f945c9bc3ac6c8178cd93132b8d82dd581fcbc3a00676c51302bc1a", size = 225485 }, - { url = "https://files.pythonhosted.org/packages/71/cc/9a117f828b4d7fbaec6adeed2204f211e9caf0a012692a1ee32169f846ae/multidict-6.6.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b95494daf857602eccf4c18ca33337dd2be705bccdb6dddbfc9d513e6addb9d9", size = 257318 }, - { url = "https://files.pythonhosted.org/packages/25/77/62752d3dbd70e27fdd68e86626c1ae6bccfebe2bb1f84ae226363e112f5a/multidict-6.6.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e5b1413361cef15340ab9dc61523e653d25723e82d488ef7d60a12878227ed50", size = 254689 }, - { url = "https://files.pythonhosted.org/packages/00/6e/fac58b1072a6fc59af5e7acb245e8754d3e1f97f4f808a6559951f72a0d4/multidict-6.6.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e167bf899c3d724f9662ef00b4f7fef87a19c22b2fead198a6f68b263618df52", size = 246709 }, - { url = "https://files.pythonhosted.org/packages/01/ef/4698d6842ef5e797c6db7744b0081e36fb5de3d00002cc4c58071097fac3/multidict-6.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aaea28ba20a9026dfa77f4b80369e51cb767c61e33a2d4043399c67bd95fb7c6", size = 243185 }, - { url = "https://files.pythonhosted.org/packages/aa/c9/d82e95ae1d6e4ef396934e9b0e942dfc428775f9554acf04393cce66b157/multidict-6.6.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8c91cdb30809a96d9ecf442ec9bc45e8cfaa0f7f8bdf534e082c2443a196727e", size = 237838 }, - { url = "https://files.pythonhosted.org/packages/57/cf/f94af5c36baaa75d44fab9f02e2a6bcfa0cd90acb44d4976a80960759dbc/multidict-6.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1a0ccbfe93ca114c5d65a2471d52d8829e56d467c97b0e341cf5ee45410033b3", size = 246368 }, - { url = "https://files.pythonhosted.org/packages/4a/fe/29f23460c3d995f6a4b678cb2e9730e7277231b981f0b234702f0177818a/multidict-6.6.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:55624b3f321d84c403cb7d8e6e982f41ae233d85f85db54ba6286f7295dc8a9c", size = 253339 }, - { url = "https://files.pythonhosted.org/packages/29/b6/fd59449204426187b82bf8a75f629310f68c6adc9559dc922d5abe34797b/multidict-6.6.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4a1fb393a2c9d202cb766c76208bd7945bc194eba8ac920ce98c6e458f0b524b", size = 246933 }, - { url = "https://files.pythonhosted.org/packages/19/52/d5d6b344f176a5ac3606f7a61fb44dc746e04550e1a13834dff722b8d7d6/multidict-6.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:43868297a5759a845fa3a483fb4392973a95fb1de891605a3728130c52b8f40f", size = 242225 }, - { url = "https://files.pythonhosted.org/packages/ec/d3/5b2281ed89ff4d5318d82478a2a2450fcdfc3300da48ff15c1778280ad26/multidict-6.6.4-cp311-cp311-win32.whl", hash = "sha256:ed3b94c5e362a8a84d69642dbeac615452e8af9b8eb825b7bc9f31a53a1051e2", size = 41306 }, - { url = "https://files.pythonhosted.org/packages/74/7d/36b045c23a1ab98507aefd44fd8b264ee1dd5e5010543c6fccf82141ccef/multidict-6.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:d8c112f7a90d8ca5d20213aa41eac690bb50a76da153e3afb3886418e61cb22e", size = 46029 }, - { url = "https://files.pythonhosted.org/packages/0f/5e/553d67d24432c5cd52b49047f2d248821843743ee6d29a704594f656d182/multidict-6.6.4-cp311-cp311-win_arm64.whl", hash = "sha256:3bb0eae408fa1996d87247ca0d6a57b7fc1dcf83e8a5c47ab82c558c250d4adf", size = 43017 }, - { url = "https://files.pythonhosted.org/packages/05/f6/512ffd8fd8b37fb2680e5ac35d788f1d71bbaf37789d21a820bdc441e565/multidict-6.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ffb87be160942d56d7b87b0fdf098e81ed565add09eaa1294268c7f3caac4c8", size = 76516 }, - { url = "https://files.pythonhosted.org/packages/99/58/45c3e75deb8855c36bd66cc1658007589662ba584dbf423d01df478dd1c5/multidict-6.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d191de6cbab2aff5de6c5723101705fd044b3e4c7cfd587a1929b5028b9714b3", size = 45394 }, - { url = "https://files.pythonhosted.org/packages/fd/ca/e8c4472a93a26e4507c0b8e1f0762c0d8a32de1328ef72fd704ef9cc5447/multidict-6.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38a0956dd92d918ad5feff3db8fcb4a5eb7dba114da917e1a88475619781b57b", size = 43591 }, - { url = "https://files.pythonhosted.org/packages/05/51/edf414f4df058574a7265034d04c935aa84a89e79ce90fcf4df211f47b16/multidict-6.6.4-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6865f6d3b7900ae020b495d599fcf3765653bc927951c1abb959017f81ae8287", size = 237215 }, - { url = "https://files.pythonhosted.org/packages/c8/45/8b3d6dbad8cf3252553cc41abea09ad527b33ce47a5e199072620b296902/multidict-6.6.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a2088c126b6f72db6c9212ad827d0ba088c01d951cee25e758c450da732c138", size = 258299 }, - { url = "https://files.pythonhosted.org/packages/3c/e8/8ca2e9a9f5a435fc6db40438a55730a4bf4956b554e487fa1b9ae920f825/multidict-6.6.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0f37bed7319b848097085d7d48116f545985db988e2256b2e6f00563a3416ee6", size = 242357 }, - { url = "https://files.pythonhosted.org/packages/0f/84/80c77c99df05a75c28490b2af8f7cba2a12621186e0a8b0865d8e745c104/multidict-6.6.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:01368e3c94032ba6ca0b78e7ccb099643466cf24f8dc8eefcfdc0571d56e58f9", size = 268369 }, - { url = "https://files.pythonhosted.org/packages/0d/e9/920bfa46c27b05fb3e1ad85121fd49f441492dca2449c5bcfe42e4565d8a/multidict-6.6.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe323540c255db0bffee79ad7f048c909f2ab0edb87a597e1c17da6a54e493c", size = 269341 }, - { url = "https://files.pythonhosted.org/packages/af/65/753a2d8b05daf496f4a9c367fe844e90a1b2cac78e2be2c844200d10cc4c/multidict-6.6.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8eb3025f17b0a4c3cd08cda49acf312a19ad6e8a4edd9dbd591e6506d999402", size = 256100 }, - { url = "https://files.pythonhosted.org/packages/09/54/655be13ae324212bf0bc15d665a4e34844f34c206f78801be42f7a0a8aaa/multidict-6.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbc14f0365534d35a06970d6a83478b249752e922d662dc24d489af1aa0d1be7", size = 253584 }, - { url = "https://files.pythonhosted.org/packages/5c/74/ab2039ecc05264b5cec73eb018ce417af3ebb384ae9c0e9ed42cb33f8151/multidict-6.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:75aa52fba2d96bf972e85451b99d8e19cc37ce26fd016f6d4aa60da9ab2b005f", size = 251018 }, - { url = "https://files.pythonhosted.org/packages/af/0a/ccbb244ac848e56c6427f2392741c06302bbfba49c0042f1eb3c5b606497/multidict-6.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fefd4a815e362d4f011919d97d7b4a1e566f1dde83dc4ad8cfb5b41de1df68d", size = 251477 }, - { url = "https://files.pythonhosted.org/packages/0e/b0/0ed49bba775b135937f52fe13922bc64a7eaf0a3ead84a36e8e4e446e096/multidict-6.6.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db9801fe021f59a5b375ab778973127ca0ac52429a26e2fd86aa9508f4d26eb7", size = 263575 }, - { url = "https://files.pythonhosted.org/packages/3e/d9/7fb85a85e14de2e44dfb6a24f03c41e2af8697a6df83daddb0e9b7569f73/multidict-6.6.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a650629970fa21ac1fb06ba25dabfc5b8a2054fcbf6ae97c758aa956b8dba802", size = 259649 }, - { url = "https://files.pythonhosted.org/packages/03/9e/b3a459bcf9b6e74fa461a5222a10ff9b544cb1cd52fd482fb1b75ecda2a2/multidict-6.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:452ff5da78d4720d7516a3a2abd804957532dd69296cb77319c193e3ffb87e24", size = 251505 }, - { url = "https://files.pythonhosted.org/packages/86/a2/8022f78f041dfe6d71e364001a5cf987c30edfc83c8a5fb7a3f0974cff39/multidict-6.6.4-cp312-cp312-win32.whl", hash = "sha256:8c2fcb12136530ed19572bbba61b407f655e3953ba669b96a35036a11a485793", size = 41888 }, - { url = "https://files.pythonhosted.org/packages/c7/eb/d88b1780d43a56db2cba24289fa744a9d216c1a8546a0dc3956563fd53ea/multidict-6.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:047d9425860a8c9544fed1b9584f0c8bcd31bcde9568b047c5e567a1025ecd6e", size = 46072 }, - { url = "https://files.pythonhosted.org/packages/9f/16/b929320bf5750e2d9d4931835a4c638a19d2494a5b519caaaa7492ebe105/multidict-6.6.4-cp312-cp312-win_arm64.whl", hash = "sha256:14754eb72feaa1e8ae528468f24250dd997b8e2188c3d2f593f9eba259e4b364", size = 43222 }, - { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313 }, + { url = "https://files.pythonhosted.org/packages/a9/63/7bdd4adc330abcca54c85728db2327130e49e52e8c3ce685cec44e0f2e9f/multidict-6.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9f474ad5acda359c8758c8accc22032c6abe6dc87a8be2440d097785e27a9349", size = 77153, upload-time = "2025-10-06T14:48:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bb/b6c35ff175ed1a3142222b78455ee31be71a8396ed3ab5280fbe3ebe4e85/multidict-6.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a9db5a870f780220e931d0002bbfd88fb53aceb6293251e2c839415c1b20e", size = 44993, upload-time = "2025-10-06T14:48:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/e0/1f/064c77877c5fa6df6d346e68075c0f6998547afe952d6471b4c5f6a7345d/multidict-6.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03ca744319864e92721195fa28c7a3b2bc7b686246b35e4078c1e4d0eb5466d3", size = 44607, upload-time = "2025-10-06T14:48:29.581Z" }, + { url = "https://files.pythonhosted.org/packages/04/7a/bf6aa92065dd47f287690000b3d7d332edfccb2277634cadf6a810463c6a/multidict-6.7.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f0e77e3c0008bc9316e662624535b88d360c3a5d3f81e15cf12c139a75250046", size = 241847, upload-time = "2025-10-06T14:48:32.107Z" }, + { url = "https://files.pythonhosted.org/packages/94/39/297a8de920f76eda343e4ce05f3b489f0ab3f9504f2576dfb37b7c08ca08/multidict-6.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08325c9e5367aa379a3496aa9a022fe8837ff22e00b94db256d3a1378c76ab32", size = 242616, upload-time = "2025-10-06T14:48:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/39/3a/d0eee2898cfd9d654aea6cb8c4addc2f9756e9a7e09391cfe55541f917f7/multidict-6.7.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e2862408c99f84aa571ab462d25236ef9cb12a602ea959ba9c9009a54902fc73", size = 222333, upload-time = "2025-10-06T14:48:35.9Z" }, + { url = "https://files.pythonhosted.org/packages/05/48/3b328851193c7a4240815b71eea165b49248867bbb6153a0aee227a0bb47/multidict-6.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d72a9a2d885f5c208b0cb91ff2ed43636bb7e345ec839ff64708e04f69a13cc", size = 253239, upload-time = "2025-10-06T14:48:37.302Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ca/0706a98c8d126a89245413225ca4a3fefc8435014de309cf8b30acb68841/multidict-6.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:478cc36476687bac1514d651cbbaa94b86b0732fb6855c60c673794c7dd2da62", size = 251618, upload-time = "2025-10-06T14:48:38.963Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/9c7992f245554d8b173f6f0a048ad24b3e645d883f096857ec2c0822b8bd/multidict-6.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6843b28b0364dc605f21481c90fadb5f60d9123b442eb8a726bb74feef588a84", size = 241655, upload-time = "2025-10-06T14:48:40.312Z" }, + { url = "https://files.pythonhosted.org/packages/31/79/26a85991ae67efd1c0b1fc2e0c275b8a6aceeb155a68861f63f87a798f16/multidict-6.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23bfeee5316266e5ee2d625df2d2c602b829435fc3a235c2ba2131495706e4a0", size = 239245, upload-time = "2025-10-06T14:48:41.848Z" }, + { url = "https://files.pythonhosted.org/packages/14/1e/75fa96394478930b79d0302eaf9a6c69f34005a1a5251ac8b9c336486ec9/multidict-6.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:680878b9f3d45c31e1f730eef731f9b0bc1da456155688c6745ee84eb818e90e", size = 233523, upload-time = "2025-10-06T14:48:43.749Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5e/085544cb9f9c4ad2b5d97467c15f856df8d9bac410cffd5c43991a5d878b/multidict-6.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:eb866162ef2f45063acc7a53a88ef6fe8bf121d45c30ea3c9cd87ce7e191a8d4", size = 243129, upload-time = "2025-10-06T14:48:45.225Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c3/e9d9e2f20c9474e7a8fcef28f863c5cbd29bb5adce6b70cebe8bdad0039d/multidict-6.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:df0e3bf7993bdbeca5ac25aa859cf40d39019e015c9c91809ba7093967f7a648", size = 248999, upload-time = "2025-10-06T14:48:46.703Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3f/df171b6efa3239ae33b97b887e42671cd1d94d460614bfb2c30ffdab3b95/multidict-6.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:661709cdcd919a2ece2234f9bae7174e5220c80b034585d7d8a755632d3e2111", size = 243711, upload-time = "2025-10-06T14:48:48.146Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2f/9b5564888c4e14b9af64c54acf149263721a283aaf4aa0ae89b091d5d8c1/multidict-6.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:096f52730c3fb8ed419db2d44391932b63891b2c5ed14850a7e215c0ba9ade36", size = 237504, upload-time = "2025-10-06T14:48:49.447Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3a/0bd6ca0f7d96d790542d591c8c3354c1e1b6bfd2024d4d92dc3d87485ec7/multidict-6.7.0-cp310-cp310-win32.whl", hash = "sha256:afa8a2978ec65d2336305550535c9c4ff50ee527914328c8677b3973ade52b85", size = 41422, upload-time = "2025-10-06T14:48:50.789Z" }, + { url = "https://files.pythonhosted.org/packages/00/35/f6a637ea2c75f0d3b7c7d41b1189189acff0d9deeb8b8f35536bb30f5e33/multidict-6.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:b15b3afff74f707b9275d5ba6a91ae8f6429c3ffb29bbfd216b0b375a56f13d7", size = 46050, upload-time = "2025-10-06T14:48:51.938Z" }, + { url = "https://files.pythonhosted.org/packages/e7/b8/f7bf8329b39893d02d9d95cf610c75885d12fc0f402b1c894e1c8e01c916/multidict-6.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:4b73189894398d59131a66ff157837b1fafea9974be486d036bb3d32331fdbf0", size = 43153, upload-time = "2025-10-06T14:48:53.146Z" }, + { url = "https://files.pythonhosted.org/packages/34/9e/5c727587644d67b2ed479041e4b1c58e30afc011e3d45d25bbe35781217c/multidict-6.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4d409aa42a94c0b3fa617708ef5276dfe81012ba6753a0370fcc9d0195d0a1fc", size = 76604, upload-time = "2025-10-06T14:48:54.277Z" }, + { url = "https://files.pythonhosted.org/packages/17/e4/67b5c27bd17c085a5ea8f1ec05b8a3e5cba0ca734bfcad5560fb129e70ca/multidict-6.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14c9e076eede3b54c636f8ce1c9c252b5f057c62131211f0ceeec273810c9721", size = 44715, upload-time = "2025-10-06T14:48:55.445Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e1/866a5d77be6ea435711bef2a4291eed11032679b6b28b56b4776ab06ba3e/multidict-6.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c09703000a9d0fa3c3404b27041e574cc7f4df4c6563873246d0e11812a94b6", size = 44332, upload-time = "2025-10-06T14:48:56.706Z" }, + { url = "https://files.pythonhosted.org/packages/31/61/0c2d50241ada71ff61a79518db85ada85fdabfcf395d5968dae1cbda04e5/multidict-6.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a265acbb7bb33a3a2d626afbe756371dce0279e7b17f4f4eda406459c2b5ff1c", size = 245212, upload-time = "2025-10-06T14:48:58.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e0/919666a4e4b57fff1b57f279be1c9316e6cdc5de8a8b525d76f6598fefc7/multidict-6.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51cb455de290ae462593e5b1cb1118c5c22ea7f0d3620d9940bf695cea5a4bd7", size = 246671, upload-time = "2025-10-06T14:49:00.004Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cc/d027d9c5a520f3321b65adea289b965e7bcbd2c34402663f482648c716ce/multidict-6.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db99677b4457c7a5c5a949353e125ba72d62b35f74e26da141530fbb012218a7", size = 225491, upload-time = "2025-10-06T14:49:01.393Z" }, + { url = "https://files.pythonhosted.org/packages/75/c4/bbd633980ce6155a28ff04e6a6492dd3335858394d7bb752d8b108708558/multidict-6.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f470f68adc395e0183b92a2f4689264d1ea4b40504a24d9882c27375e6662bb9", size = 257322, upload-time = "2025-10-06T14:49:02.745Z" }, + { url = "https://files.pythonhosted.org/packages/4c/6d/d622322d344f1f053eae47e033b0b3f965af01212de21b10bcf91be991fb/multidict-6.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0db4956f82723cc1c270de9c6e799b4c341d327762ec78ef82bb962f79cc07d8", size = 254694, upload-time = "2025-10-06T14:49:04.15Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9f/78f8761c2705d4c6d7516faed63c0ebdac569f6db1bef95e0d5218fdc146/multidict-6.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e56d780c238f9e1ae66a22d2adf8d16f485381878250db8d496623cd38b22bd", size = 246715, upload-time = "2025-10-06T14:49:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/950818e04f91b9c2b95aab3d923d9eabd01689d0dcd889563988e9ea0fd8/multidict-6.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d14baca2ee12c1a64740d4531356ba50b82543017f3ad6de0deb943c5979abb", size = 243189, upload-time = "2025-10-06T14:49:07.37Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3d/77c79e1934cad2ee74991840f8a0110966d9599b3af95964c0cd79bb905b/multidict-6.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:295a92a76188917c7f99cda95858c822f9e4aae5824246bba9b6b44004ddd0a6", size = 237845, upload-time = "2025-10-06T14:49:08.759Z" }, + { url = "https://files.pythonhosted.org/packages/63/1b/834ce32a0a97a3b70f86437f685f880136677ac00d8bce0027e9fd9c2db7/multidict-6.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39f1719f57adbb767ef592a50ae5ebb794220d1188f9ca93de471336401c34d2", size = 246374, upload-time = "2025-10-06T14:49:10.574Z" }, + { url = "https://files.pythonhosted.org/packages/23/ef/43d1c3ba205b5dec93dc97f3fba179dfa47910fc73aaaea4f7ceb41cec2a/multidict-6.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0a13fb8e748dfc94749f622de065dd5c1def7e0d2216dba72b1d8069a389c6ff", size = 253345, upload-time = "2025-10-06T14:49:12.331Z" }, + { url = "https://files.pythonhosted.org/packages/6b/03/eaf95bcc2d19ead522001f6a650ef32811aa9e3624ff0ad37c445c7a588c/multidict-6.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e3aa16de190d29a0ea1b48253c57d99a68492c8dd8948638073ab9e74dc9410b", size = 246940, upload-time = "2025-10-06T14:49:13.821Z" }, + { url = "https://files.pythonhosted.org/packages/e8/df/ec8a5fd66ea6cd6f525b1fcbb23511b033c3e9bc42b81384834ffa484a62/multidict-6.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a048ce45dcdaaf1defb76b2e684f997fb5abf74437b6cb7b22ddad934a964e34", size = 242229, upload-time = "2025-10-06T14:49:15.603Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a2/59b405d59fd39ec86d1142630e9049243015a5f5291ba49cadf3c090c541/multidict-6.7.0-cp311-cp311-win32.whl", hash = "sha256:a90af66facec4cebe4181b9e62a68be65e45ac9b52b67de9eec118701856e7ff", size = 41308, upload-time = "2025-10-06T14:49:16.871Z" }, + { url = "https://files.pythonhosted.org/packages/32/0f/13228f26f8b882c34da36efa776c3b7348455ec383bab4a66390e42963ae/multidict-6.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:95b5ffa4349df2887518bb839409bcf22caa72d82beec453216802f475b23c81", size = 46037, upload-time = "2025-10-06T14:49:18.457Z" }, + { url = "https://files.pythonhosted.org/packages/84/1f/68588e31b000535a3207fd3c909ebeec4fb36b52c442107499c18a896a2a/multidict-6.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:329aa225b085b6f004a4955271a7ba9f1087e39dcb7e65f6284a988264a63912", size = 43023, upload-time = "2025-10-06T14:49:19.648Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9e/9f61ac18d9c8b475889f32ccfa91c9f59363480613fc807b6e3023d6f60b/multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184", size = 76877, upload-time = "2025-10-06T14:49:20.884Z" }, + { url = "https://files.pythonhosted.org/packages/38/6f/614f09a04e6184f8824268fce4bc925e9849edfa654ddd59f0b64508c595/multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45", size = 45467, upload-time = "2025-10-06T14:49:22.054Z" }, + { url = "https://files.pythonhosted.org/packages/b3/93/c4f67a436dd026f2e780c433277fff72be79152894d9fc36f44569cab1a6/multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa", size = 43834, upload-time = "2025-10-06T14:49:23.566Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f5/013798161ca665e4a422afbc5e2d9e4070142a9ff8905e482139cd09e4d0/multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7", size = 250545, upload-time = "2025-10-06T14:49:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/71/2f/91dbac13e0ba94669ea5119ba267c9a832f0cb65419aca75549fcf09a3dc/multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e", size = 258305, upload-time = "2025-10-06T14:49:26.778Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b0/754038b26f6e04488b48ac621f779c341338d78503fb45403755af2df477/multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546", size = 242363, upload-time = "2025-10-06T14:49:28.562Z" }, + { url = "https://files.pythonhosted.org/packages/87/15/9da40b9336a7c9fa606c4cf2ed80a649dffeb42b905d4f63a1d7eb17d746/multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4", size = 268375, upload-time = "2025-10-06T14:49:29.96Z" }, + { url = "https://files.pythonhosted.org/packages/82/72/c53fcade0cc94dfaad583105fd92b3a783af2091eddcb41a6d5a52474000/multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1", size = 269346, upload-time = "2025-10-06T14:49:31.404Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e2/9baffdae21a76f77ef8447f1a05a96ec4bc0a24dae08767abc0a2fe680b8/multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d", size = 256107, upload-time = "2025-10-06T14:49:32.974Z" }, + { url = "https://files.pythonhosted.org/packages/3c/06/3f06f611087dc60d65ef775f1fb5aca7c6d61c6db4990e7cda0cef9b1651/multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304", size = 253592, upload-time = "2025-10-06T14:49:34.52Z" }, + { url = "https://files.pythonhosted.org/packages/20/24/54e804ec7945b6023b340c412ce9c3f81e91b3bf5fa5ce65558740141bee/multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12", size = 251024, upload-time = "2025-10-06T14:49:35.956Z" }, + { url = "https://files.pythonhosted.org/packages/14/48/011cba467ea0b17ceb938315d219391d3e421dfd35928e5dbdc3f4ae76ef/multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62", size = 251484, upload-time = "2025-10-06T14:49:37.631Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2f/919258b43bb35b99fa127435cfb2d91798eb3a943396631ef43e3720dcf4/multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0", size = 263579, upload-time = "2025-10-06T14:49:39.502Z" }, + { url = "https://files.pythonhosted.org/packages/31/22/a0e884d86b5242b5a74cf08e876bdf299e413016b66e55511f7a804a366e/multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a", size = 259654, upload-time = "2025-10-06T14:49:41.32Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/17e10e1b5c5f5a40f2fcbb45953c9b215f8a4098003915e46a93f5fcaa8f/multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8", size = 251511, upload-time = "2025-10-06T14:49:46.021Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9a/201bb1e17e7af53139597069c375e7b0dcbd47594604f65c2d5359508566/multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4", size = 41895, upload-time = "2025-10-06T14:49:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/e2/348cd32faad84eaf1d20cce80e2bb0ef8d312c55bca1f7fa9865e7770aaf/multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b", size = 46073, upload-time = "2025-10-06T14:49:50.28Z" }, + { url = "https://files.pythonhosted.org/packages/25/ec/aad2613c1910dce907480e0c3aa306905830f25df2e54ccc9dea450cb5aa/multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec", size = 43226, upload-time = "2025-10-06T14:49:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, ] [[package]] name = "mypy" -version = "1.18.2" +version = "1.19.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846 } +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/6f/657961a0743cff32e6c0611b63ff1c1970a0b482ace35b069203bf705187/mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c", size = 12807973 }, - { url = "https://files.pythonhosted.org/packages/10/e9/420822d4f661f13ca8900f5fa239b40ee3be8b62b32f3357df9a3045a08b/mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e", size = 11896527 }, - { url = "https://files.pythonhosted.org/packages/aa/73/a05b2bbaa7005f4642fcfe40fb73f2b4fb6bb44229bd585b5878e9a87ef8/mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b", size = 12507004 }, - { url = "https://files.pythonhosted.org/packages/4f/01/f6e4b9f0d031c11ccbd6f17da26564f3a0f3c4155af344006434b0a05a9d/mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66", size = 13245947 }, - { url = "https://files.pythonhosted.org/packages/d7/97/19727e7499bfa1ae0773d06afd30ac66a58ed7437d940c70548634b24185/mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428", size = 13499217 }, - { url = "https://files.pythonhosted.org/packages/9f/4f/90dc8c15c1441bf31cf0f9918bb077e452618708199e530f4cbd5cede6ff/mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed", size = 9766753 }, - { url = "https://files.pythonhosted.org/packages/88/87/cafd3ae563f88f94eec33f35ff722d043e09832ea8530ef149ec1efbaf08/mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f", size = 12731198 }, - { url = "https://files.pythonhosted.org/packages/0f/e0/1e96c3d4266a06d4b0197ace5356d67d937d8358e2ee3ffac71faa843724/mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341", size = 11817879 }, - { url = "https://files.pythonhosted.org/packages/72/ef/0c9ba89eb03453e76bdac5a78b08260a848c7bfc5d6603634774d9cd9525/mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d", size = 12427292 }, - { url = "https://files.pythonhosted.org/packages/1a/52/ec4a061dd599eb8179d5411d99775bec2a20542505988f40fc2fee781068/mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86", size = 13163750 }, - { url = "https://files.pythonhosted.org/packages/c4/5f/2cf2ceb3b36372d51568f2208c021870fe7834cf3186b653ac6446511839/mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37", size = 13351827 }, - { url = "https://files.pythonhosted.org/packages/c8/7d/2697b930179e7277529eaaec1513f8de622818696857f689e4a5432e5e27/mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8", size = 9757983 }, - { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273 }, - { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910 }, - { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585 }, - { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562 }, - { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296 }, - { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828 }, - { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367 }, + { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, ] [[package]] name = "mypy-extensions" version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] name = "mypy-protobuf" -version = "3.6.0" +version = "3.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, { name = "types-protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4d/6f/282d64d66bf48ce60e38a6560753f784e0f88ab245ac2fb5e93f701a36cd/mypy-protobuf-3.6.0.tar.gz", hash = "sha256:02f242eb3409f66889f2b1a3aa58356ec4d909cdd0f93115622e9e70366eca3c", size = 24445 } +sdist = { url = "https://files.pythonhosted.org/packages/99/bd/92ee9e7d2ff30cb57b36bd6f61ee4da8a05acf32f6a6121883b58720e9d4/mypy_protobuf-3.7.0.tar.gz", hash = "sha256:912fb281f7c7b3e3a7c9b8695712618a716fddbab70f6ad63eaf68eda80c5efe", size = 25690, upload-time = "2025-11-17T22:11:12.809Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/73/d6b999782ae22f16971cc05378b3b33f6a89ede3b9619e8366aa23484bca/mypy_protobuf-3.6.0-py3-none-any.whl", hash = "sha256:56176e4d569070e7350ea620262478b49b7efceba4103d468448f1d21492fd6c", size = 16434 }, + { url = "https://files.pythonhosted.org/packages/69/32/709df4390d155bd72e75f2a9bce6004c2958a21f31817f3a2c7750299f70/mypy_protobuf-3.7.0-py3-none-any.whl", hash = "sha256:85256e9d4da935722ce8fbaa8d19397e1a2989aa8075c96577987de9fe7cea4d", size = 17488, upload-time = "2025-11-17T22:11:11.62Z" }, ] [[package]] name = "nodeenv" -version = "1.9.1" +version = "1.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] [[package]] name = "pathspec" -version = "0.12.1" +version = "1.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/bb8e495d5262bfec41ab5cb18f522f1012933347fb5d9e62452d446baca2/pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", size = 130841, upload-time = "2026-01-09T15:46:46.009Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, + { url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" }, ] [[package]] name = "platformdirs" -version = "4.5.0" +version = "4.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632 } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651 }, + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, ] [[package]] @@ -976,12 +1028,12 @@ name = "pproxy" version = "2.7.9" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/c6/673a10a729061d2594b85aedd7dd2e470db4d54b12d4f95a306353bb2967/pproxy-2.7.9-py3-none-any.whl", hash = "sha256:a073d02616a47c43e1d20a547918c307dbda598c6d53869b165025f3cfe58e80", size = 42842 }, + { url = "https://files.pythonhosted.org/packages/d4/c6/673a10a729061d2594b85aedd7dd2e470db4d54b12d4f95a306353bb2967/pproxy-2.7.9-py3-none-any.whl", hash = "sha256:a073d02616a47c43e1d20a547918c307dbda598c6d53869b165025f3cfe58e80", size = 42842, upload-time = "2024-01-16T11:33:35.286Z" }, ] [[package]] name = "pre-commit" -version = "4.4.0" +version = "4.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -990,81 +1042,78 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/49/7845c2d7bf6474efd8e27905b51b11e6ce411708c91e829b93f324de9929/pre_commit-4.4.0.tar.gz", hash = "sha256:f0233ebab440e9f17cabbb558706eb173d19ace965c68cdce2c081042b4fab15", size = 197501 } +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/11/574fe7d13acf30bfd0a8dd7fa1647040f2b8064f13f43e8c963b1e65093b/pre_commit-4.4.0-py2.py3-none-any.whl", hash = "sha256:b35ea52957cbf83dcc5d8ee636cbead8624e3a15fbfa61a370e42158ac8a5813", size = 226049 }, + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] [[package]] name = "propcache" -version = "0.3.2" +version = "0.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139 } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/14/510deed325e262afeb8b360043c5d7c960da7d3ecd6d6f9496c9c56dc7f4/propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770", size = 73178 }, - { url = "https://files.pythonhosted.org/packages/cd/4e/ad52a7925ff01c1325653a730c7ec3175a23f948f08626a534133427dcff/propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3", size = 43133 }, - { url = "https://files.pythonhosted.org/packages/63/7c/e9399ba5da7780871db4eac178e9c2e204c23dd3e7d32df202092a1ed400/propcache-0.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3def3da3ac3ce41562d85db655d18ebac740cb3fa4367f11a52b3da9d03a5cc3", size = 43039 }, - { url = "https://files.pythonhosted.org/packages/22/e1/58da211eb8fdc6fc854002387d38f415a6ca5f5c67c1315b204a5d3e9d7a/propcache-0.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bec58347a5a6cebf239daba9bda37dffec5b8d2ce004d9fe4edef3d2815137e", size = 201903 }, - { url = "https://files.pythonhosted.org/packages/c4/0a/550ea0f52aac455cb90111c8bab995208443e46d925e51e2f6ebdf869525/propcache-0.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55ffda449a507e9fbd4aca1a7d9aa6753b07d6166140e5a18d2ac9bc49eac220", size = 213362 }, - { url = "https://files.pythonhosted.org/packages/5a/af/9893b7d878deda9bb69fcf54600b247fba7317761b7db11fede6e0f28bd0/propcache-0.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a67fb39229a8a8491dd42f864e5e263155e729c2e7ff723d6e25f596b1e8cb", size = 210525 }, - { url = "https://files.pythonhosted.org/packages/7c/bb/38fd08b278ca85cde36d848091ad2b45954bc5f15cce494bb300b9285831/propcache-0.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da1cf97b92b51253d5b68cf5a2b9e0dafca095e36b7f2da335e27dc6172a614", size = 198283 }, - { url = "https://files.pythonhosted.org/packages/78/8c/9fe55bd01d362bafb413dfe508c48753111a1e269737fa143ba85693592c/propcache-0.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f559e127134b07425134b4065be45b166183fdcb433cb6c24c8e4149056ad50", size = 191872 }, - { url = "https://files.pythonhosted.org/packages/54/14/4701c33852937a22584e08abb531d654c8bcf7948a8f87ad0a4822394147/propcache-0.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aff2e4e06435d61f11a428360a932138d0ec288b0a31dd9bd78d200bd4a2b339", size = 199452 }, - { url = "https://files.pythonhosted.org/packages/16/44/447f2253d859602095356007657ee535e0093215ea0b3d1d6a41d16e5201/propcache-0.3.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4927842833830942a5d0a56e6f4839bc484785b8e1ce8d287359794818633ba0", size = 191567 }, - { url = "https://files.pythonhosted.org/packages/f2/b3/e4756258749bb2d3b46defcff606a2f47410bab82be5824a67e84015b267/propcache-0.3.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6107ddd08b02654a30fb8ad7a132021759d750a82578b94cd55ee2772b6ebea2", size = 193015 }, - { url = "https://files.pythonhosted.org/packages/1e/df/e6d3c7574233164b6330b9fd697beeac402afd367280e6dc377bb99b43d9/propcache-0.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:70bd8b9cd6b519e12859c99f3fc9a93f375ebd22a50296c3a295028bea73b9e7", size = 204660 }, - { url = "https://files.pythonhosted.org/packages/b2/53/e4d31dd5170b4a0e2e6b730f2385a96410633b4833dc25fe5dffd1f73294/propcache-0.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2183111651d710d3097338dd1893fcf09c9f54e27ff1a8795495a16a469cc90b", size = 206105 }, - { url = "https://files.pythonhosted.org/packages/7f/fe/74d54cf9fbe2a20ff786e5f7afcfde446588f0cf15fb2daacfbc267b866c/propcache-0.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb075ad271405dcad8e2a7ffc9a750a3bf70e533bd86e89f0603e607b93aa64c", size = 196980 }, - { url = "https://files.pythonhosted.org/packages/22/ec/c469c9d59dada8a7679625e0440b544fe72e99311a4679c279562051f6fc/propcache-0.3.2-cp310-cp310-win32.whl", hash = "sha256:404d70768080d3d3bdb41d0771037da19d8340d50b08e104ca0e7f9ce55fce70", size = 37679 }, - { url = "https://files.pythonhosted.org/packages/38/35/07a471371ac89d418f8d0b699c75ea6dca2041fbda360823de21f6a9ce0a/propcache-0.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:7435d766f978b4ede777002e6b3b6641dd229cd1da8d3d3106a45770365f9ad9", size = 41459 }, - { url = "https://files.pythonhosted.org/packages/80/8d/e8b436717ab9c2cfc23b116d2c297305aa4cd8339172a456d61ebf5669b8/propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be", size = 74207 }, - { url = "https://files.pythonhosted.org/packages/d6/29/1e34000e9766d112171764b9fa3226fa0153ab565d0c242c70e9945318a7/propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f", size = 43648 }, - { url = "https://files.pythonhosted.org/packages/46/92/1ad5af0df781e76988897da39b5f086c2bf0f028b7f9bd1f409bb05b6874/propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9", size = 43496 }, - { url = "https://files.pythonhosted.org/packages/b3/ce/e96392460f9fb68461fabab3e095cb00c8ddf901205be4eae5ce246e5b7e/propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf", size = 217288 }, - { url = "https://files.pythonhosted.org/packages/c5/2a/866726ea345299f7ceefc861a5e782b045545ae6940851930a6adaf1fca6/propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9", size = 227456 }, - { url = "https://files.pythonhosted.org/packages/de/03/07d992ccb6d930398689187e1b3c718339a1c06b8b145a8d9650e4726166/propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66", size = 225429 }, - { url = "https://files.pythonhosted.org/packages/5d/e6/116ba39448753b1330f48ab8ba927dcd6cf0baea8a0ccbc512dfb49ba670/propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df", size = 213472 }, - { url = "https://files.pythonhosted.org/packages/a6/85/f01f5d97e54e428885a5497ccf7f54404cbb4f906688a1690cd51bf597dc/propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2", size = 204480 }, - { url = "https://files.pythonhosted.org/packages/e3/79/7bf5ab9033b8b8194cc3f7cf1aaa0e9c3256320726f64a3e1f113a812dce/propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7", size = 214530 }, - { url = "https://files.pythonhosted.org/packages/31/0b/bd3e0c00509b609317df4a18e6b05a450ef2d9a963e1d8bc9c9415d86f30/propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95", size = 205230 }, - { url = "https://files.pythonhosted.org/packages/7a/23/fae0ff9b54b0de4e819bbe559508da132d5683c32d84d0dc2ccce3563ed4/propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e", size = 206754 }, - { url = "https://files.pythonhosted.org/packages/b7/7f/ad6a3c22630aaa5f618b4dc3c3598974a72abb4c18e45a50b3cdd091eb2f/propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e", size = 218430 }, - { url = "https://files.pythonhosted.org/packages/5b/2c/ba4f1c0e8a4b4c75910742f0d333759d441f65a1c7f34683b4a74c0ee015/propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf", size = 223884 }, - { url = "https://files.pythonhosted.org/packages/88/e4/ebe30fc399e98572019eee82ad0caf512401661985cbd3da5e3140ffa1b0/propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e", size = 211480 }, - { url = "https://files.pythonhosted.org/packages/96/0a/7d5260b914e01d1d0906f7f38af101f8d8ed0dc47426219eeaf05e8ea7c2/propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897", size = 37757 }, - { url = "https://files.pythonhosted.org/packages/e1/2d/89fe4489a884bc0da0c3278c552bd4ffe06a1ace559db5ef02ef24ab446b/propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39", size = 41500 }, - { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674 }, - { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570 }, - { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094 }, - { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958 }, - { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894 }, - { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672 }, - { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395 }, - { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510 }, - { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949 }, - { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258 }, - { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036 }, - { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684 }, - { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562 }, - { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142 }, - { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711 }, - { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479 }, - { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663 }, + { url = "https://files.pythonhosted.org/packages/3c/0e/934b541323035566a9af292dba85a195f7b78179114f2c6ebb24551118a9/propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db", size = 79534, upload-time = "2025-10-08T19:46:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/db0d03d96726d995dc7171286c6ba9d8d14251f37433890f88368951a44e/propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8", size = 45526, upload-time = "2025-10-08T19:46:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c3/82728404aea669e1600f304f2609cde9e665c18df5a11cdd57ed73c1dceb/propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925", size = 47263, upload-time = "2025-10-08T19:46:05.405Z" }, + { url = "https://files.pythonhosted.org/packages/df/1b/39313ddad2bf9187a1432654c38249bab4562ef535ef07f5eb6eb04d0b1b/propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21", size = 201012, upload-time = "2025-10-08T19:46:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/5b/01/f1d0b57d136f294a142acf97f4ed58c8e5b974c21e543000968357115011/propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5", size = 209491, upload-time = "2025-10-08T19:46:08.909Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c8/038d909c61c5bb039070b3fb02ad5cccdb1dde0d714792e251cdb17c9c05/propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db", size = 215319, upload-time = "2025-10-08T19:46:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/08/57/8c87e93142b2c1fa2408e45695205a7ba05fb5db458c0bf5c06ba0e09ea6/propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7", size = 196856, upload-time = "2025-10-08T19:46:12.003Z" }, + { url = "https://files.pythonhosted.org/packages/42/df/5615fec76aa561987a534759b3686008a288e73107faa49a8ae5795a9f7a/propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4", size = 193241, upload-time = "2025-10-08T19:46:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/62949eb3a7a54afe8327011c90aca7e03547787a88fb8bd9726806482fea/propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60", size = 190552, upload-time = "2025-10-08T19:46:14.938Z" }, + { url = "https://files.pythonhosted.org/packages/30/ee/ab4d727dd70806e5b4de96a798ae7ac6e4d42516f030ee60522474b6b332/propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f", size = 200113, upload-time = "2025-10-08T19:46:16.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0b/38b46208e6711b016aa8966a3ac793eee0d05c7159d8342aa27fc0bc365e/propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900", size = 200778, upload-time = "2025-10-08T19:46:18.023Z" }, + { url = "https://files.pythonhosted.org/packages/cf/81/5abec54355ed344476bee711e9f04815d4b00a311ab0535599204eecc257/propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c", size = 193047, upload-time = "2025-10-08T19:46:19.449Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b6/1f237c04e32063cb034acd5f6ef34ef3a394f75502e72703545631ab1ef6/propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb", size = 38093, upload-time = "2025-10-08T19:46:20.643Z" }, + { url = "https://files.pythonhosted.org/packages/a6/67/354aac4e0603a15f76439caf0427781bcd6797f370377f75a642133bc954/propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37", size = 41638, upload-time = "2025-10-08T19:46:21.935Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e1/74e55b9fd1a4c209ff1a9a824bf6c8b3d1fc5a1ac3eabe23462637466785/propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581", size = 38229, upload-time = "2025-10-08T19:46:23.368Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, + { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, + { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, + { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, + { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] [[package]] name = "protobuf" -version = "6.33.0" +version = "6.33.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/ff/64a6c8f420818bb873713988ca5492cba3a7946be57e027ac63495157d97/protobuf-6.33.0.tar.gz", hash = "sha256:140303d5c8d2037730c548f8c7b93b20bb1dc301be280c378b82b8894589c954", size = 443463 } +sdist = { url = "https://files.pythonhosted.org/packages/53/b8/cda15d9d46d03d4aa3a67cb6bffe05173440ccf86a9541afaf7ac59a1b6b/protobuf-6.33.4.tar.gz", hash = "sha256:dc2e61bca3b10470c1912d166fe0af67bfc20eb55971dcef8dfa48ce14f0ed91", size = 444346, upload-time = "2026-01-12T18:33:40.109Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/ee/52b3fa8feb6db4a833dfea4943e175ce645144532e8a90f72571ad85df4e/protobuf-6.33.0-cp310-abi3-win32.whl", hash = "sha256:d6101ded078042a8f17959eccd9236fb7a9ca20d3b0098bbcb91533a5680d035", size = 425593 }, - { url = "https://files.pythonhosted.org/packages/7b/c6/7a465f1825872c55e0341ff4a80198743f73b69ce5d43ab18043699d1d81/protobuf-6.33.0-cp310-abi3-win_amd64.whl", hash = "sha256:9a031d10f703f03768f2743a1c403af050b6ae1f3480e9c140f39c45f81b13ee", size = 436882 }, - { url = "https://files.pythonhosted.org/packages/e1/a9/b6eee662a6951b9c3640e8e452ab3e09f117d99fc10baa32d1581a0d4099/protobuf-6.33.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:905b07a65f1a4b72412314082c7dbfae91a9e8b68a0cc1577515f8df58ecf455", size = 427521 }, - { url = "https://files.pythonhosted.org/packages/10/35/16d31e0f92c6d2f0e77c2a3ba93185130ea13053dd16200a57434c882f2b/protobuf-6.33.0-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e0697ece353e6239b90ee43a9231318302ad8353c70e6e45499fa52396debf90", size = 324445 }, - { url = "https://files.pythonhosted.org/packages/e6/eb/2a981a13e35cda8b75b5585aaffae2eb904f8f351bdd3870769692acbd8a/protobuf-6.33.0-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:e0a1715e4f27355afd9570f3ea369735afc853a6c3951a6afe1f80d8569ad298", size = 339159 }, - { url = "https://files.pythonhosted.org/packages/21/51/0b1cbad62074439b867b4e04cc09b93f6699d78fd191bed2bbb44562e077/protobuf-6.33.0-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:35be49fd3f4fefa4e6e2aacc35e8b837d6703c37a2168a55ac21e9b1bc7559ef", size = 323172 }, - { url = "https://files.pythonhosted.org/packages/07/d1/0a28c21707807c6aacd5dc9c3704b2aa1effbf37adebd8caeaf68b17a636/protobuf-6.33.0-py3-none-any.whl", hash = "sha256:25c9e1963c6734448ea2d308cfa610e692b801304ba0908d7bfa564ac5132995", size = 170477 }, + { url = "https://files.pythonhosted.org/packages/e0/be/24ef9f3095bacdf95b458543334d0c4908ccdaee5130420bf064492c325f/protobuf-6.33.4-cp310-abi3-win32.whl", hash = "sha256:918966612c8232fc6c24c78e1cd89784307f5814ad7506c308ee3cf86662850d", size = 425612, upload-time = "2026-01-12T18:33:29.656Z" }, + { url = "https://files.pythonhosted.org/packages/31/ad/e5693e1974a28869e7cd244302911955c1cebc0161eb32dfa2b25b6e96f0/protobuf-6.33.4-cp310-abi3-win_amd64.whl", hash = "sha256:8f11ffae31ec67fc2554c2ef891dcb561dae9a2a3ed941f9e134c2db06657dbc", size = 436962, upload-time = "2026-01-12T18:33:31.345Z" }, + { url = "https://files.pythonhosted.org/packages/66/15/6ee23553b6bfd82670207ead921f4d8ef14c107e5e11443b04caeb5ab5ec/protobuf-6.33.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2fe67f6c014c84f655ee06f6f66213f9254b3a8b6bda6cda0ccd4232c73c06f0", size = 427612, upload-time = "2026-01-12T18:33:32.646Z" }, + { url = "https://files.pythonhosted.org/packages/2b/48/d301907ce6d0db75f959ca74f44b475a9caa8fcba102d098d3c3dd0f2d3f/protobuf-6.33.4-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:757c978f82e74d75cba88eddec479df9b99a42b31193313b75e492c06a51764e", size = 324484, upload-time = "2026-01-12T18:33:33.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/1c/e53078d3f7fe710572ab2dcffd993e1e3b438ae71cfc031b71bae44fcb2d/protobuf-6.33.4-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c7c64f259c618f0bef7bee042075e390debbf9682334be2b67408ec7c1c09ee6", size = 339256, upload-time = "2026-01-12T18:33:35.231Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8e/971c0edd084914f7ee7c23aa70ba89e8903918adca179319ee94403701d5/protobuf-6.33.4-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:3df850c2f8db9934de4cf8f9152f8dc2558f49f298f37f90c517e8e5c84c30e9", size = 323311, upload-time = "2026-01-12T18:33:36.305Z" }, + { url = "https://files.pythonhosted.org/packages/75/b1/1dc83c2c661b4c62d56cc081706ee33a4fc2835bd90f965baa2663ef7676/protobuf-6.33.4-py3-none-any.whl", hash = "sha256:1fe3730068fcf2e595816a6c34fe66eeedd37d51d0400b72fabc848811fdc1bc", size = 170532, upload-time = "2026-01-12T18:33:39.199Z" }, ] [[package]] @@ -1076,18 +1125,9 @@ dependencies = [ { name = "cssutils" }, { name = "lxml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/d1/b332cc006803e69cffe50862d36e081728974893519218abe7b9de63f7c1/pycaption-2.2.19.tar.gz", hash = "sha256:2c57d7c61eaf545e08838d4129c21954cbd39b6e960e414be1290c449d42942a", size = 111300 } +sdist = { url = "https://files.pythonhosted.org/packages/47/d1/b332cc006803e69cffe50862d36e081728974893519218abe7b9de63f7c1/pycaption-2.2.19.tar.gz", hash = "sha256:2c57d7c61eaf545e08838d4129c21954cbd39b6e960e414be1290c449d42942a", size = 111300, upload-time = "2025-09-30T07:15:24.438Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/35/c7/d13c57e5a3408df2e5d910853e957ec8e253b41ba531e0f32036c8321240/pycaption-2.2.19-py3-none-any.whl", hash = "sha256:7eb84a05d40bb80400689f9431d05d8b77dec6535938b419ebed2c9d67283a4f", size = 124970 }, -] - -[[package]] -name = "pycountry" -version = "24.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/57/c389fa68c50590881a75b7883eeb3dc15e9e73a0fdc001cdd45c13290c92/pycountry-24.6.1.tar.gz", hash = "sha256:b61b3faccea67f87d10c1f2b0fc0be714409e8fcdcc1315613174f6466c10221", size = 6043910, upload-time = "2024-06-01T04:12:15.05Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/ec/1fb891d8a2660716aadb2143235481d15ed1cbfe3ad669194690b0604492/pycountry-24.6.1-py3-none-any.whl", hash = "sha256:f1a4fb391cd7214f8eefd39556d740adcc233c778a27f8942c8dca351d6ce06f", size = 6335189, upload-time = "2024-06-01T04:11:49.711Z" }, + { url = "https://files.pythonhosted.org/packages/35/c7/d13c57e5a3408df2e5d910853e957ec8e253b41ba531e0f32036c8321240/pycaption-2.2.19-py3-none-any.whl", hash = "sha256:7eb84a05d40bb80400689f9431d05d8b77dec6535938b419ebed2c9d67283a4f", size = 124970, upload-time = "2025-09-30T07:15:21.945Z" }, ] [[package]] @@ -1101,59 +1141,59 @@ wheels = [ [[package]] name = "pycparser" -version = "2.22" +version = "3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] [[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 } +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/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484 }, - { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636 }, - { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005 }, + { 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 = "pycryptodomex" version = "3.23.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157 } +sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240 }, - { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042 }, - { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227 }, - { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578 }, - { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166 }, - { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467 }, - { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104 }, - { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038 }, - { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969 }, - { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124 }, - { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161 }, - { url = "https://files.pythonhosted.org/packages/f3/b8/3e76d948c3c4ac71335bbe75dac53e154b40b0f8f1f022dfa295257a0c96/pycryptodomex-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ebfff755c360d674306e5891c564a274a47953562b42fb74a5c25b8fc1fb1cb5", size = 1627695 }, - { url = "https://files.pythonhosted.org/packages/6a/cf/80f4297a4820dfdfd1c88cf6c4666a200f204b3488103d027b5edd9176ec/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eca54f4bb349d45afc17e3011ed4264ef1cc9e266699874cdd1349c504e64798", size = 1675772 }, - { url = "https://files.pythonhosted.org/packages/d1/42/1e969ee0ad19fe3134b0e1b856c39bd0b70d47a4d0e81c2a8b05727394c9/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2596e643d4365e14d0879dc5aafe6355616c61c2176009270f3048f6d9a61f", size = 1668083 }, - { url = "https://files.pythonhosted.org/packages/6e/c3/1de4f7631fea8a992a44ba632aa40e0008764c0fb9bf2854b0acf78c2cf2/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fdfac7cda115bca3a5abb2f9e43bc2fb66c2b65ab074913643803ca7083a79ea", size = 1706056 }, - { url = "https://files.pythonhosted.org/packages/f2/5f/af7da8e6f1e42b52f44a24d08b8e4c726207434e2593732d39e7af5e7256/pycryptodomex-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:14c37aaece158d0ace436f76a7bb19093db3b4deade9797abfc39ec6cd6cc2fe", size = 1806478 }, + { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" }, + { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" }, + { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b8/3e76d948c3c4ac71335bbe75dac53e154b40b0f8f1f022dfa295257a0c96/pycryptodomex-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ebfff755c360d674306e5891c564a274a47953562b42fb74a5c25b8fc1fb1cb5", size = 1627695, upload-time = "2025-05-17T17:23:17.38Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/80f4297a4820dfdfd1c88cf6c4666a200f204b3488103d027b5edd9176ec/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eca54f4bb349d45afc17e3011ed4264ef1cc9e266699874cdd1349c504e64798", size = 1675772, upload-time = "2025-05-17T17:23:19.202Z" }, + { url = "https://files.pythonhosted.org/packages/d1/42/1e969ee0ad19fe3134b0e1b856c39bd0b70d47a4d0e81c2a8b05727394c9/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2596e643d4365e14d0879dc5aafe6355616c61c2176009270f3048f6d9a61f", size = 1668083, upload-time = "2025-05-17T17:23:21.867Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c3/1de4f7631fea8a992a44ba632aa40e0008764c0fb9bf2854b0acf78c2cf2/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fdfac7cda115bca3a5abb2f9e43bc2fb66c2b65ab074913643803ca7083a79ea", size = 1706056, upload-time = "2025-05-17T17:23:24.031Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5f/af7da8e6f1e42b52f44a24d08b8e4c726207434e2593732d39e7af5e7256/pycryptodomex-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:14c37aaece158d0ace436f76a7bb19093db3b4deade9797abfc39ec6cd6cc2fe", size = 1806478, upload-time = "2025-05-17T17:23:26.066Z" }, ] [[package]] @@ -1163,37 +1203,37 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/8e/aedef81641c8dca6fd0fb7294de5bed9c45f3397d67fddf755c1042c2642/PyExecJS-1.5.1.tar.gz", hash = "sha256:34cc1d070976918183ff7bdc0ad71f8157a891c92708c00c5fbbff7a769f505c", size = 13344 } +sdist = { url = "https://files.pythonhosted.org/packages/ba/8e/aedef81641c8dca6fd0fb7294de5bed9c45f3397d67fddf755c1042c2642/PyExecJS-1.5.1.tar.gz", hash = "sha256:34cc1d070976918183ff7bdc0ad71f8157a891c92708c00c5fbbff7a769f505c", size = 13344, upload-time = "2018-01-18T04:33:55.126Z" } [[package]] name = "pygments" version = "2.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] name = "pyjwt" version = "2.10.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 }, + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, ] [[package]] name = "pymediainfo" version = "7.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/80/80a6fb21005b81e30f6193d45cba13857df09f5d483e0551fa6fbb3aaeed/pymediainfo-7.0.1.tar.gz", hash = "sha256:0d5df59ecc615e24c56f303b8f651579c6accab7265715e5d429186d7ba21514", size = 441563 } +sdist = { url = "https://files.pythonhosted.org/packages/4d/80/80a6fb21005b81e30f6193d45cba13857df09f5d483e0551fa6fbb3aaeed/pymediainfo-7.0.1.tar.gz", hash = "sha256:0d5df59ecc615e24c56f303b8f651579c6accab7265715e5d429186d7ba21514", size = 441563, upload-time = "2025-02-12T14:33:15.038Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/4a/d895646df3d3ff617b54d7f06a02ed9d6f5b86673030a543927310e0f7ed/pymediainfo-7.0.1-py3-none-macosx_10_10_universal2.whl", hash = "sha256:286f3bf6299be0997093254e0f371855bc5cf2aaf8641d19455a011e3ee3a84d", size = 6983332 }, - { url = "https://files.pythonhosted.org/packages/77/df/bc6b5a08e908c64a81f6ff169716d408ce7380ceff44e1eceb095f49e0dc/pymediainfo-7.0.1-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:3648e2379fa67bd02433d1e28c707df3a53834dd480680615a9fefd2266f1182", size = 5768082 }, - { url = "https://files.pythonhosted.org/packages/02/10/a9bc1446a48d3a15940eb1af79a71978f368f27e2cc86f9ec3ec2d206a20/pymediainfo-7.0.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:cde98112f1ce486589b17a12e5da42085faea996224f7c67fa45b8c1dca719c6", size = 6001553 }, - { url = "https://files.pythonhosted.org/packages/ed/7f/c48f8514cb60c9ff9be81b6f383e73e66c7461ef854a1b62628e3c823f13/pymediainfo-7.0.1-py3-none-win32.whl", hash = "sha256:01bcaf82b72cefbf4b96f13b2547e1b2e0e734bab7173d7c33f7f01acc07c98b", size = 3125046 }, - { url = "https://files.pythonhosted.org/packages/e7/26/9d50c2a330541bc36c0ea7ce29eeff5b0c35c2624139660df8bcfa9ae3ce/pymediainfo-7.0.1-py3-none-win_amd64.whl", hash = "sha256:13224fa7590e198763b8baf072e704ea81d334e71aa32a469091460e243893c7", size = 3271232 }, + { url = "https://files.pythonhosted.org/packages/a6/4a/d895646df3d3ff617b54d7f06a02ed9d6f5b86673030a543927310e0f7ed/pymediainfo-7.0.1-py3-none-macosx_10_10_universal2.whl", hash = "sha256:286f3bf6299be0997093254e0f371855bc5cf2aaf8641d19455a011e3ee3a84d", size = 6983332, upload-time = "2025-02-12T14:42:47.412Z" }, + { url = "https://files.pythonhosted.org/packages/77/df/bc6b5a08e908c64a81f6ff169716d408ce7380ceff44e1eceb095f49e0dc/pymediainfo-7.0.1-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:3648e2379fa67bd02433d1e28c707df3a53834dd480680615a9fefd2266f1182", size = 5768082, upload-time = "2025-02-12T14:33:10.543Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/a9bc1446a48d3a15940eb1af79a71978f368f27e2cc86f9ec3ec2d206a20/pymediainfo-7.0.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:cde98112f1ce486589b17a12e5da42085faea996224f7c67fa45b8c1dca719c6", size = 6001553, upload-time = "2025-02-12T14:33:12.663Z" }, + { url = "https://files.pythonhosted.org/packages/ed/7f/c48f8514cb60c9ff9be81b6f383e73e66c7461ef854a1b62628e3c823f13/pymediainfo-7.0.1-py3-none-win32.whl", hash = "sha256:01bcaf82b72cefbf4b96f13b2547e1b2e0e734bab7173d7c33f7f01acc07c98b", size = 3125046, upload-time = "2025-02-12T15:04:39.89Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/9d50c2a330541bc36c0ea7ce29eeff5b0c35c2624139660df8bcfa9ae3ce/pymediainfo-7.0.1-py3-none-win_amd64.whl", hash = "sha256:13224fa7590e198763b8baf072e704ea81d334e71aa32a469091460e243893c7", size = 3271232, upload-time = "2025-02-12T15:07:13.672Z" }, ] [[package]] @@ -1203,18 +1243,18 @@ 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 } +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 }, + { 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 = "pymysql" version = "1.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/ae/1fe3fcd9f959efa0ebe200b8de88b5a5ce3e767e38c7ac32fb179f16a388/pymysql-1.1.2.tar.gz", hash = "sha256:4961d3e165614ae65014e361811a724e2044ad3ea3739de9903ae7c21f539f03", size = 48258 } +sdist = { url = "https://files.pythonhosted.org/packages/f5/ae/1fe3fcd9f959efa0ebe200b8de88b5a5ce3e767e38c7ac32fb179f16a388/pymysql-1.1.2.tar.gz", hash = "sha256:4961d3e165614ae65014e361811a724e2044ad3ea3739de9903ae7c21f539f03", size = 48258, upload-time = "2025-08-24T12:55:55.146Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl", hash = "sha256:e6b1d89711dd51f8f74b1631fe08f039e7d76cf67a42a323d3178f0f25762ed9", size = 45300 }, + { url = "https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl", hash = "sha256:e6b1d89711dd51f8f74b1631fe08f039e7d76cf67a42a323d3178f0f25762ed9", size = 45300, upload-time = "2025-08-24T12:55:53.394Z" }, ] [[package]] @@ -1232,27 +1272,27 @@ dependencies = [ { name = "requests" }, { name = "xmltodict" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/f2/6d75b6d10a8361b53a2acbe959d51aa586418e9af497381a9f5c436ca488/pyplayready-0.6.3.tar.gz", hash = "sha256:b9b82a32c2cced9c43f910eb1fb891545f1491dc063c1eb9c20634e2417eda76", size = 58019 } +sdist = { url = "https://files.pythonhosted.org/packages/53/f2/6d75b6d10a8361b53a2acbe959d51aa586418e9af497381a9f5c436ca488/pyplayready-0.6.3.tar.gz", hash = "sha256:b9b82a32c2cced9c43f910eb1fb891545f1491dc063c1eb9c20634e2417eda76", size = 58019, upload-time = "2025-08-20T19:32:43.642Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/7f/64d5ff5d765f9f2138ee7cc196fd9401f9eae0fb514c66660ad4e56584fa/pyplayready-0.6.3-py3-none-any.whl", hash = "sha256:82f35434e790a7da21df57ec053a2924ceb63622c5a6c5ff9f0fa03db0531c57", size = 66162 }, + { url = "https://files.pythonhosted.org/packages/5b/7f/64d5ff5d765f9f2138ee7cc196fd9401f9eae0fb514c66660ad4e56584fa/pyplayready-0.6.3-py3-none-any.whl", hash = "sha256:82f35434e790a7da21df57ec053a2924ceb63622c5a6c5ff9f0fa03db0531c57", size = 66162, upload-time = "2025-08-20T19:32:42.62Z" }, ] [[package]] name = "pysocks" version = "1.7.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429 } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725 }, + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, ] [[package]] name = "pysubs2" version = "1.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/31/4a/becf78d9d3df56e6c4a9c50b83794e5436b6c5ab6dd8a3f934e94c89338c/pysubs2-1.8.0.tar.gz", hash = "sha256:3397bb58a4a15b1325ba2ae3fd4d7c214e2c0ddb9f33190d6280d783bb433b20", size = 1130048 } +sdist = { url = "https://files.pythonhosted.org/packages/31/4a/becf78d9d3df56e6c4a9c50b83794e5436b6c5ab6dd8a3f934e94c89338c/pysubs2-1.8.0.tar.gz", hash = "sha256:3397bb58a4a15b1325ba2ae3fd4d7c214e2c0ddb9f33190d6280d783bb433b20", size = 1130048, upload-time = "2024-12-24T12:39:47.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/09/0fc0719162e5ad723f71d41cf336f18b6b5054d70dc0fe42ace6b4d2bdc9/pysubs2-1.8.0-py3-none-any.whl", hash = "sha256:05716f5039a9ebe32cd4d7673f923cf36204f3a3e99987f823ab83610b7035a0", size = 43516 }, + { url = "https://files.pythonhosted.org/packages/99/09/0fc0719162e5ad723f71d41cf336f18b6b5054d70dc0fe42ace6b4d2bdc9/pysubs2-1.8.0-py3-none-any.whl", hash = "sha256:05716f5039a9ebe32cd4d7673f923cf36204f3a3e99987f823ab83610b7035a0", size = 43516, upload-time = "2024-12-24T12:39:44.469Z" }, ] [[package]] @@ -1268,9 +1308,9 @@ dependencies = [ { name = "requests" }, { name = "unidecode" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/b6/4855cb958892653029f3cafa8a4724d554b847de0a43a3808cea109b9e78/pywidevine-1.9.0.tar.gz", hash = "sha256:6742daf5fd797c5a4813eb1300efb3181ffcddd0c8c478ee28c7c536aa0e51b2", size = 75511 } +sdist = { url = "https://files.pythonhosted.org/packages/52/b6/4855cb958892653029f3cafa8a4724d554b847de0a43a3808cea109b9e78/pywidevine-1.9.0.tar.gz", hash = "sha256:6742daf5fd797c5a4813eb1300efb3181ffcddd0c8c478ee28c7c536aa0e51b2", size = 75511, upload-time = "2025-10-27T09:13:15.909Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/e2/e692647f12008495654868e99c972696f2b8cb39637b507c409f5f99b4b6/pywidevine-1.9.0-py3-none-any.whl", hash = "sha256:70b5726abc2c3fe763f070da853b7c87d6aeb6131a27778743187258bf97e492", size = 70419 }, + { url = "https://files.pythonhosted.org/packages/dd/e2/e692647f12008495654868e99c972696f2b8cb39637b507c409f5f99b4b6/pywidevine-1.9.0-py3-none-any.whl", hash = "sha256:70b5726abc2c3fe763f070da853b7c87d6aeb6131a27778743187258bf97e492", size = 70419, upload-time = "2025-10-27T09:13:14.303Z" }, ] [package.optional-dependencies] @@ -1282,36 +1322,36 @@ serve = [ 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 } +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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415 }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, + { 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" }, ] [[package]] @@ -1324,9 +1364,9 @@ dependencies = [ { 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 } +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 }, + { 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.optional-dependencies] @@ -1341,22 +1381,22 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513 } +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490 }, + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, ] [[package]] name = "rich" -version = "14.2.0" +version = "14.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990 } +sdist = { url = "https://files.pythonhosted.org/packages/aa/9c/137848452e130e71f3ca9a9876751ddcac99e4b1f248ed297996c8c2d728/rich-14.3.0.tar.gz", hash = "sha256:b75e54d3abbcc49137e83e4db54dc86c5e47687eebc95aa0305363231a36e699", size = 230113, upload-time = "2026-01-24T12:25:46.336Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393 }, + { url = "https://files.pythonhosted.org/packages/fa/e0/83cbdcb81b5cbbbe355648dd402b410437806544f48ee218a2354798f012/rich-14.3.0-py3-none-any.whl", hash = "sha256:0b8c1e368c1125b9e993c2d2f1342802525f4853fc6dac2e8e9e88bac0f45bce", size = 309950, upload-time = "2026-01-24T12:25:44.679Z" }, ] [[package]] @@ -1366,125 +1406,119 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "iso8601" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/48/1ed7004063170f4314c16e8c97b9c144e25e503e9fe316fd5e9f6c8d7980/rlaphoenix.m3u8-3.4.0.tar.gz", hash = "sha256:6f8c71350bc8a07f2bd714d1e72b0a483455b9c6e5141a5a308bf264b6718504", size = 44713 } +sdist = { url = "https://files.pythonhosted.org/packages/f1/48/1ed7004063170f4314c16e8c97b9c144e25e503e9fe316fd5e9f6c8d7980/rlaphoenix.m3u8-3.4.0.tar.gz", hash = "sha256:6f8c71350bc8a07f2bd714d1e72b0a483455b9c6e5141a5a308bf264b6718504", size = 44713, upload-time = "2023-03-09T21:37:40.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/d2/d2ffaecbfff0c057b5824a82b57b709b1c5b2966c970e4c5d6e1d8109b21/rlaphoenix.m3u8-3.4.0-py3-none-any.whl", hash = "sha256:cd2c22195c747d52c63189d4bd5f664e1fc5ea202f5a7396b7336581f26a2838", size = 24767 }, + { url = "https://files.pythonhosted.org/packages/52/d2/d2ffaecbfff0c057b5824a82b57b709b1c5b2966c970e4c5d6e1d8109b21/rlaphoenix.m3u8-3.4.0-py3-none-any.whl", hash = "sha256:cd2c22195c747d52c63189d4bd5f664e1fc5ea202f5a7396b7336581f26a2838", size = 24767, upload-time = "2023-03-09T21:37:38.326Z" }, ] [[package]] name = "ruamel-yaml" -version = "0.18.14" +version = "0.18.17" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ruamel-yaml-clib", marker = "platform_python_implementation == 'CPython'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/87/6da0df742a4684263261c253f00edd5829e6aca970fff69e75028cccc547/ruamel.yaml-0.18.14.tar.gz", hash = "sha256:7227b76aaec364df15936730efbf7d72b30c0b79b1d578bbb8e3dcb2d81f52b7", size = 145511 } +sdist = { url = "https://files.pythonhosted.org/packages/3a/2b/7a1f1ebcd6b3f14febdc003e658778d81e76b40df2267904ee6b13f0c5c6/ruamel_yaml-0.18.17.tar.gz", hash = "sha256:9091cd6e2d93a3a4b157ddb8fabf348c3de7f1fb1381346d985b6b247dcd8d3c", size = 149602, upload-time = "2025-12-17T20:02:55.757Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/6d/6fe4805235e193aad4aaf979160dd1f3c487c57d48b810c816e6e842171b/ruamel.yaml-0.18.14-py3-none-any.whl", hash = "sha256:710ff198bb53da66718c7db27eec4fbcc9aa6ca7204e4c1df2f282b6fe5eb6b2", size = 118570 }, + { url = "https://files.pythonhosted.org/packages/af/fe/b6045c782f1fd1ae317d2a6ca1884857ce5c20f59befe6ab25a8603c43a7/ruamel_yaml-0.18.17-py3-none-any.whl", hash = "sha256:9c8ba9eb3e793efdf924b60d521820869d5bf0cb9c6f1b82d82de8295e290b9d", size = 121594, upload-time = "2025-12-17T20:02:07.657Z" }, ] [[package]] name = "ruamel-yaml-clib" -version = "0.2.12" +version = "0.2.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/84/80203abff8ea4993a87d823a5f632e4d92831ef75d404c9fc78d0176d2b5/ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f", size = 225315 } +sdist = { url = "https://files.pythonhosted.org/packages/ea/97/60fda20e2fb54b83a61ae14648b0817c8f5d84a3821e40bfbdae1437026a/ruamel_yaml_clib-0.2.15.tar.gz", hash = "sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600", size = 225794, upload-time = "2025-11-16T16:12:59.761Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/57/40a958e863e299f0c74ef32a3bde9f2d1ea8d69669368c0c502a0997f57f/ruamel.yaml.clib-0.2.12-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5", size = 131301 }, - { url = "https://files.pythonhosted.org/packages/98/a8/29a3eb437b12b95f50a6bcc3d7d7214301c6c529d8fdc227247fa84162b5/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969", size = 633728 }, - { url = "https://files.pythonhosted.org/packages/35/6d/ae05a87a3ad540259c3ad88d71275cbd1c0f2d30ae04c65dcbfb6dcd4b9f/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd5415dded15c3822597455bc02bcd66e81ef8b7a48cb71a33628fc9fdde39df", size = 722230 }, - { url = "https://files.pythonhosted.org/packages/7f/b7/20c6f3c0b656fe609675d69bc135c03aac9e3865912444be6339207b6648/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76", size = 686712 }, - { url = "https://files.pythonhosted.org/packages/cd/11/d12dbf683471f888d354dac59593873c2b45feb193c5e3e0f2ebf85e68b9/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6", size = 663936 }, - { url = "https://files.pythonhosted.org/packages/72/14/4c268f5077db5c83f743ee1daeb236269fa8577133a5cfa49f8b382baf13/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd", size = 696580 }, - { url = "https://files.pythonhosted.org/packages/30/fc/8cd12f189c6405a4c1cf37bd633aa740a9538c8e40497c231072d0fef5cf/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a", size = 663393 }, - { url = "https://files.pythonhosted.org/packages/80/29/c0a017b704aaf3cbf704989785cd9c5d5b8ccec2dae6ac0c53833c84e677/ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da", size = 100326 }, - { url = "https://files.pythonhosted.org/packages/3a/65/fa39d74db4e2d0cd252355732d966a460a41cd01c6353b820a0952432839/ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28", size = 118079 }, - { url = "https://files.pythonhosted.org/packages/fb/8f/683c6ad562f558cbc4f7c029abcd9599148c51c54b5ef0f24f2638da9fbb/ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6", size = 132224 }, - { url = "https://files.pythonhosted.org/packages/3c/d2/b79b7d695e2f21da020bd44c782490578f300dd44f0a4c57a92575758a76/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e", size = 641480 }, - { url = "https://files.pythonhosted.org/packages/68/6e/264c50ce2a31473a9fdbf4fa66ca9b2b17c7455b31ef585462343818bd6c/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e", size = 739068 }, - { url = "https://files.pythonhosted.org/packages/86/29/88c2567bc893c84d88b4c48027367c3562ae69121d568e8a3f3a8d363f4d/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52", size = 703012 }, - { url = "https://files.pythonhosted.org/packages/11/46/879763c619b5470820f0cd6ca97d134771e502776bc2b844d2adb6e37753/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642", size = 704352 }, - { url = "https://files.pythonhosted.org/packages/02/80/ece7e6034256a4186bbe50dee28cd032d816974941a6abf6a9d65e4228a7/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2", size = 737344 }, - { url = "https://files.pythonhosted.org/packages/f0/ca/e4106ac7e80efbabdf4bf91d3d32fc424e41418458251712f5672eada9ce/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3", size = 714498 }, - { url = "https://files.pythonhosted.org/packages/67/58/b1f60a1d591b771298ffa0428237afb092c7f29ae23bad93420b1eb10703/ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4", size = 100205 }, - { url = "https://files.pythonhosted.org/packages/b4/4f/b52f634c9548a9291a70dfce26ca7ebce388235c93588a1068028ea23fcc/ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb", size = 118185 }, - { url = "https://files.pythonhosted.org/packages/48/41/e7a405afbdc26af961678474a55373e1b323605a4f5e2ddd4a80ea80f628/ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632", size = 133433 }, - { url = "https://files.pythonhosted.org/packages/ec/b0/b850385604334c2ce90e3ee1013bd911aedf058a934905863a6ea95e9eb4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d", size = 647362 }, - { url = "https://files.pythonhosted.org/packages/44/d0/3f68a86e006448fb6c005aee66565b9eb89014a70c491d70c08de597f8e4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c", size = 754118 }, - { url = "https://files.pythonhosted.org/packages/52/a9/d39f3c5ada0a3bb2870d7db41901125dbe2434fa4f12ca8c5b83a42d7c53/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd", size = 706497 }, - { url = "https://files.pythonhosted.org/packages/b0/fa/097e38135dadd9ac25aecf2a54be17ddf6e4c23e43d538492a90ab3d71c6/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31", size = 698042 }, - { url = "https://files.pythonhosted.org/packages/ec/d5/a659ca6f503b9379b930f13bc6b130c9f176469b73b9834296822a83a132/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680", size = 745831 }, - { url = "https://files.pythonhosted.org/packages/db/5d/36619b61ffa2429eeaefaab4f3374666adf36ad8ac6330d855848d7d36fd/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d", size = 715692 }, - { url = "https://files.pythonhosted.org/packages/b1/82/85cb92f15a4231c89b95dfe08b09eb6adca929ef7df7e17ab59902b6f589/ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5", size = 98777 }, - { url = "https://files.pythonhosted.org/packages/d7/8f/c3654f6f1ddb75daf3922c3d8fc6005b1ab56671ad56ffb874d908bfa668/ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4", size = 115523 }, + { url = "https://files.pythonhosted.org/packages/f7/5a/4ab767cd42dcd65b83c323e1620d7c01ee60a52f4032fb7b61501f45f5c2/ruamel_yaml_clib-0.2.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:88eea8baf72f0ccf232c22124d122a7f26e8a24110a0273d9bcddcb0f7e1fa03", size = 147454, upload-time = "2025-11-16T16:13:02.54Z" }, + { url = "https://files.pythonhosted.org/packages/40/44/184173ac1e74fd35d308108bcbf83904d6ef8439c70763189225a166b238/ruamel_yaml_clib-0.2.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b6f7d74d094d1f3a4e157278da97752f16ee230080ae331fcc219056ca54f77", size = 132467, upload-time = "2025-11-16T16:13:03.539Z" }, + { url = "https://files.pythonhosted.org/packages/49/1b/2d2077a25fe682ae335007ca831aff42e3cbc93c14066675cf87a6c7fc3e/ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4be366220090d7c3424ac2b71c90d1044ea34fca8c0b88f250064fd06087e614", size = 693454, upload-time = "2025-11-16T20:22:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/90/16/e708059c4c429ad2e33be65507fc1730641e5f239fb2964efc1ba6edea94/ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f66f600833af58bea694d5892453f2270695b92200280ee8c625ec5a477eed3", size = 700345, upload-time = "2025-11-16T16:13:04.771Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/0e8ef51df1f0950300541222e3332f20707a9c210b98f981422937d1278c/ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da3d6adadcf55a93c214d23941aef4abfd45652110aed6580e814152f385b862", size = 731306, upload-time = "2025-11-16T16:13:06.312Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f4/2cdb54b142987ddfbd01fc45ac6bd882695fbcedb9d8bbf796adc3fc3746/ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9fde97ecb7bb9c41261c2ce0da10323e9227555c674989f8d9eb7572fc2098d", size = 692415, upload-time = "2025-11-16T16:13:07.465Z" }, + { url = "https://files.pythonhosted.org/packages/a0/07/40b5fc701cce8240a3e2d26488985d3bbdc446e9fe397c135528d412fea6/ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:05c70f7f86be6f7bee53794d80050a28ae7e13e4a0087c1839dcdefd68eb36b6", size = 705007, upload-time = "2025-11-16T20:22:42.856Z" }, + { url = "https://files.pythonhosted.org/packages/82/19/309258a1df6192fb4a77ffa8eae3e8150e8d0ffa56c1b6fa92e450ba2740/ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f1d38cbe622039d111b69e9ca945e7e3efebb30ba998867908773183357f3ed", size = 723974, upload-time = "2025-11-16T16:13:08.72Z" }, + { url = "https://files.pythonhosted.org/packages/67/3a/d6ee8263b521bfceb5cd2faeb904a15936480f2bb01c7ff74a14ec058ca4/ruamel_yaml_clib-0.2.15-cp310-cp310-win32.whl", hash = "sha256:fe239bdfdae2302e93bd6e8264bd9b71290218fff7084a9db250b55caaccf43f", size = 102836, upload-time = "2025-11-16T16:13:10.27Z" }, + { url = "https://files.pythonhosted.org/packages/ed/03/92aeb5c69018387abc49a8bb4f83b54a0471d9ef48e403b24bac68f01381/ruamel_yaml_clib-0.2.15-cp310-cp310-win_amd64.whl", hash = "sha256:468858e5cbde0198337e6a2a78eda8c3fb148bdf4c6498eaf4bc9ba3f8e780bd", size = 121917, upload-time = "2025-11-16T16:13:12.145Z" }, + { url = "https://files.pythonhosted.org/packages/2c/80/8ce7b9af532aa94dd83360f01ce4716264db73de6bc8efd22c32341f6658/ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c583229f336682b7212a43d2fa32c30e643d3076178fb9f7a6a14dde85a2d8bd", size = 147998, upload-time = "2025-11-16T16:13:13.241Z" }, + { url = "https://files.pythonhosted.org/packages/53/09/de9d3f6b6701ced5f276d082ad0f980edf08ca67114523d1b9264cd5e2e0/ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56ea19c157ed8c74b6be51b5fa1c3aff6e289a041575f0556f66e5fb848bb137", size = 132743, upload-time = "2025-11-16T16:13:14.265Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f7/73a9b517571e214fe5c246698ff3ed232f1ef863c8ae1667486625ec688a/ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5fea0932358e18293407feb921d4f4457db837b67ec1837f87074667449f9401", size = 731459, upload-time = "2025-11-16T20:22:44.338Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a2/0dc0013169800f1c331a6f55b1282c1f4492a6d32660a0cf7b89e6684919/ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71831bd61fbdb7aa0399d5c4da06bea37107ab5c79ff884cc07f2450910262", size = 749289, upload-time = "2025-11-16T16:13:15.633Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ed/3fb20a1a96b8dc645d88c4072df481fe06e0289e4d528ebbdcc044ebc8b3/ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:617d35dc765715fa86f8c3ccdae1e4229055832c452d4ec20856136acc75053f", size = 777630, upload-time = "2025-11-16T16:13:16.898Z" }, + { url = "https://files.pythonhosted.org/packages/60/50/6842f4628bc98b7aa4733ab2378346e1441e150935ad3b9f3c3c429d9408/ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b45498cc81a4724a2d42273d6cfc243c0547ad7c6b87b4f774cb7bcc131c98d", size = 744368, upload-time = "2025-11-16T16:13:18.117Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b0/128ae8e19a7d794c2e36130a72b3bb650ce1dd13fb7def6cf10656437dcf/ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:def5663361f6771b18646620fca12968aae730132e104688766cf8a3b1d65922", size = 745233, upload-time = "2025-11-16T20:22:45.833Z" }, + { url = "https://files.pythonhosted.org/packages/75/05/91130633602d6ba7ce3e07f8fc865b40d2a09efd4751c740df89eed5caf9/ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:014181cdec565c8745b7cbc4de3bf2cc8ced05183d986e6d1200168e5bb59490", size = 770963, upload-time = "2025-11-16T16:13:19.344Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4b/fd4542e7f33d7d1bc64cc9ac9ba574ce8cf145569d21f5f20133336cdc8c/ruamel_yaml_clib-0.2.15-cp311-cp311-win32.whl", hash = "sha256:d290eda8f6ada19e1771b54e5706b8f9807e6bb08e873900d5ba114ced13e02c", size = 102640, upload-time = "2025-11-16T16:13:20.498Z" }, + { url = "https://files.pythonhosted.org/packages/bb/eb/00ff6032c19c7537371e3119287999570867a0eafb0154fccc80e74bf57a/ruamel_yaml_clib-0.2.15-cp311-cp311-win_amd64.whl", hash = "sha256:bdc06ad71173b915167702f55d0f3f027fc61abd975bd308a0968c02db4a4c3e", size = 121996, upload-time = "2025-11-16T16:13:21.855Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/5fde11a0722d676e469d3d6f78c6a17591b9c7e0072ca359801c4bd17eee/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb15a2e2a90c8475df45c0949793af1ff413acfb0a716b8b94e488ea95ce7cff", size = 149088, upload-time = "2025-11-16T16:13:22.836Z" }, + { url = "https://files.pythonhosted.org/packages/85/82/4d08ac65ecf0ef3b046421985e66301a242804eb9a62c93ca3437dc94ee0/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64da03cbe93c1e91af133f5bec37fd24d0d4ba2418eaf970d7166b0a26a148a2", size = 134553, upload-time = "2025-11-16T16:13:24.151Z" }, + { url = "https://files.pythonhosted.org/packages/b9/cb/22366d68b280e281a932403b76da7a988108287adff2bfa5ce881200107a/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f6d3655e95a80325b84c4e14c080b2470fe4f33b6846f288379ce36154993fb1", size = 737468, upload-time = "2025-11-16T20:22:47.335Z" }, + { url = "https://files.pythonhosted.org/packages/71/73/81230babf8c9e33770d43ed9056f603f6f5f9665aea4177a2c30ae48e3f3/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71845d377c7a47afc6592aacfea738cc8a7e876d586dfba814501d8c53c1ba60", size = 753349, upload-time = "2025-11-16T16:13:26.269Z" }, + { url = "https://files.pythonhosted.org/packages/61/62/150c841f24cda9e30f588ef396ed83f64cfdc13b92d2f925bb96df337ba9/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e5499db1ccbc7f4b41f0565e4f799d863ea720e01d3e99fa0b7b5fcd7802c9", size = 788211, upload-time = "2025-11-16T16:13:27.441Z" }, + { url = "https://files.pythonhosted.org/packages/30/93/e79bd9cbecc3267499d9ead919bd61f7ddf55d793fb5ef2b1d7d92444f35/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4b293a37dc97e2b1e8a1aec62792d1e52027087c8eea4fc7b5abd2bdafdd6642", size = 743203, upload-time = "2025-11-16T16:13:28.671Z" }, + { url = "https://files.pythonhosted.org/packages/8d/06/1eb640065c3a27ce92d76157f8efddb184bd484ed2639b712396a20d6dce/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512571ad41bba04eac7268fe33f7f4742210ca26a81fe0c75357fa682636c690", size = 747292, upload-time = "2025-11-16T20:22:48.584Z" }, + { url = "https://files.pythonhosted.org/packages/a5/21/ee353e882350beab65fcc47a91b6bdc512cace4358ee327af2962892ff16/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5e9f630c73a490b758bf14d859a39f375e6999aea5ddd2e2e9da89b9953486a", size = 771624, upload-time = "2025-11-16T16:13:29.853Z" }, + { url = "https://files.pythonhosted.org/packages/57/34/cc1b94057aa867c963ecf9ea92ac59198ec2ee3a8d22a126af0b4d4be712/ruamel_yaml_clib-0.2.15-cp312-cp312-win32.whl", hash = "sha256:f4421ab780c37210a07d138e56dd4b51f8642187cdfb433eb687fe8c11de0144", size = 100342, upload-time = "2025-11-16T16:13:31.067Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e5/8925a4208f131b218f9a7e459c0d6fcac8324ae35da269cb437894576366/ruamel_yaml_clib-0.2.15-cp312-cp312-win_amd64.whl", hash = "sha256:2b216904750889133d9222b7b873c199d48ecbb12912aca78970f84a5aa1a4bc", size = 119013, upload-time = "2025-11-16T16:13:32.164Z" }, ] [[package]] name = "ruff" -version = "0.14.4" +version = "0.14.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/55/cccfca45157a2031dcbb5a462a67f7cf27f8b37d4b3b1cd7438f0f5c1df6/ruff-0.14.4.tar.gz", hash = "sha256:f459a49fe1085a749f15414ca76f61595f1a2cc8778ed7c279b6ca2e1fd19df3", size = 5587844 } +sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/b9/67240254166ae1eaa38dec32265e9153ac53645a6c6670ed36ad00722af8/ruff-0.14.4-py3-none-linux_armv6l.whl", hash = "sha256:e6604613ffbcf2297cd5dcba0e0ac9bd0c11dc026442dfbb614504e87c349518", size = 12606781 }, - { url = "https://files.pythonhosted.org/packages/46/c8/09b3ab245d8652eafe5256ab59718641429f68681ee713ff06c5c549f156/ruff-0.14.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d99c0b52b6f0598acede45ee78288e5e9b4409d1ce7f661f0fa36d4cbeadf9a4", size = 12946765 }, - { url = "https://files.pythonhosted.org/packages/14/bb/1564b000219144bf5eed2359edc94c3590dd49d510751dad26202c18a17d/ruff-0.14.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9358d490ec030f1b51d048a7fd6ead418ed0826daf6149e95e30aa67c168af33", size = 11928120 }, - { url = "https://files.pythonhosted.org/packages/a3/92/d5f1770e9988cc0742fefaa351e840d9aef04ec24ae1be36f333f96d5704/ruff-0.14.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b40d27924f1f02dfa827b9c0712a13c0e4b108421665322218fc38caf615c2", size = 12370877 }, - { url = "https://files.pythonhosted.org/packages/e2/29/e9282efa55f1973d109faf839a63235575519c8ad278cc87a182a366810e/ruff-0.14.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f5e649052a294fe00818650712083cddc6cc02744afaf37202c65df9ea52efa5", size = 12408538 }, - { url = "https://files.pythonhosted.org/packages/8e/01/930ed6ecfce130144b32d77d8d69f5c610e6d23e6857927150adf5d7379a/ruff-0.14.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa082a8f878deeba955531f975881828fd6afd90dfa757c2b0808aadb437136e", size = 13141942 }, - { url = "https://files.pythonhosted.org/packages/6a/46/a9c89b42b231a9f487233f17a89cbef9d5acd538d9488687a02ad288fa6b/ruff-0.14.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1043c6811c2419e39011890f14d0a30470f19d47d197c4858b2787dfa698f6c8", size = 14544306 }, - { url = "https://files.pythonhosted.org/packages/78/96/9c6cf86491f2a6d52758b830b89b78c2ae61e8ca66b86bf5a20af73d20e6/ruff-0.14.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9f3a936ac27fb7c2a93e4f4b943a662775879ac579a433291a6f69428722649", size = 14210427 }, - { url = "https://files.pythonhosted.org/packages/71/f4/0666fe7769a54f63e66404e8ff698de1dcde733e12e2fd1c9c6efb689cb5/ruff-0.14.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95643ffd209ce78bc113266b88fba3d39e0461f0cbc8b55fb92505030fb4a850", size = 13658488 }, - { url = "https://files.pythonhosted.org/packages/ee/79/6ad4dda2cfd55e41ac9ed6d73ef9ab9475b1eef69f3a85957210c74ba12c/ruff-0.14.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:456daa2fa1021bc86ca857f43fe29d5d8b3f0e55e9f90c58c317c1dcc2afc7b5", size = 13354908 }, - { url = "https://files.pythonhosted.org/packages/b5/60/f0b6990f740bb15c1588601d19d21bcc1bd5de4330a07222041678a8e04f/ruff-0.14.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f911bba769e4a9f51af6e70037bb72b70b45a16db5ce73e1f72aefe6f6d62132", size = 13587803 }, - { url = "https://files.pythonhosted.org/packages/c9/da/eaaada586f80068728338e0ef7f29ab3e4a08a692f92eb901a4f06bbff24/ruff-0.14.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:76158a7369b3979fa878612c623a7e5430c18b2fd1c73b214945c2d06337db67", size = 12279654 }, - { url = "https://files.pythonhosted.org/packages/66/d4/b1d0e82cf9bf8aed10a6d45be47b3f402730aa2c438164424783ac88c0ed/ruff-0.14.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f3b8f3b442d2b14c246e7aeca2e75915159e06a3540e2f4bed9f50d062d24469", size = 12357520 }, - { url = "https://files.pythonhosted.org/packages/04/f4/53e2b42cc82804617e5c7950b7079d79996c27e99c4652131c6a1100657f/ruff-0.14.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c62da9a06779deecf4d17ed04939ae8b31b517643b26370c3be1d26f3ef7dbde", size = 12719431 }, - { url = "https://files.pythonhosted.org/packages/a2/94/80e3d74ed9a72d64e94a7b7706b1c1ebaa315ef2076fd33581f6a1cd2f95/ruff-0.14.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a443a83a1506c684e98acb8cb55abaf3ef725078be40237463dae4463366349", size = 13464394 }, - { url = "https://files.pythonhosted.org/packages/54/1a/a49f071f04c42345c793d22f6cf5e0920095e286119ee53a64a3a3004825/ruff-0.14.4-py3-none-win32.whl", hash = "sha256:643b69cb63cd996f1fc7229da726d07ac307eae442dd8974dbc7cf22c1e18fff", size = 12493429 }, - { url = "https://files.pythonhosted.org/packages/bc/22/e58c43e641145a2b670328fb98bc384e20679b5774258b1e540207580266/ruff-0.14.4-py3-none-win_amd64.whl", hash = "sha256:26673da283b96fe35fa0c939bf8411abec47111644aa9f7cfbd3c573fb125d2c", size = 13635380 }, - { url = "https://files.pythonhosted.org/packages/30/bd/4168a751ddbbf43e86544b4de8b5c3b7be8d7167a2a5cb977d274e04f0a1/ruff-0.14.4-py3-none-win_arm64.whl", hash = "sha256:dd09c292479596b0e6fec8cd95c65c3a6dc68e9ad17b8f2382130f87ff6a75bb", size = 12663065 }, + { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" }, + { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" }, + { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" }, + { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, ] [[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, -] - -[[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 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] name = "sortedcontainers" version = "2.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 }, + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] [[package]] name = "soupsieve" -version = "2.7" +version = "2.8.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418 } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677 }, + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, ] [[package]] name = "srt" version = "3.5.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/b7/4a1bc231e0681ebf339337b0cd05b91dc6a0d701fa852bb812e244b7a030/srt-3.5.3.tar.gz", hash = "sha256:4884315043a4f0740fd1f878ed6caa376ac06d70e135f306a6dc44632eed0cc0", size = 28296 } +sdist = { url = "https://files.pythonhosted.org/packages/66/b7/4a1bc231e0681ebf339337b0cd05b91dc6a0d701fa852bb812e244b7a030/srt-3.5.3.tar.gz", hash = "sha256:4884315043a4f0740fd1f878ed6caa376ac06d70e135f306a6dc44632eed0cc0", size = 28296, upload-time = "2023-03-28T02:35:44.007Z" } [[package]] name = "subby" @@ -1505,92 +1539,90 @@ dependencies = [ name = "subtitle-filter" version = "1.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/6a/34a3205895c1c9b64e48d4a8f02edc8f7aadc8592daa5f0074fdf1b9a4a4/subtitle_filter-1.5.0.tar.gz", hash = "sha256:6994738b48a5dcf39532521c8a105f21f345760e0e1299028f3abcdcf332e4f7", size = 8464 } +sdist = { url = "https://files.pythonhosted.org/packages/15/6a/34a3205895c1c9b64e48d4a8f02edc8f7aadc8592daa5f0074fdf1b9a4a4/subtitle_filter-1.5.0.tar.gz", hash = "sha256:6994738b48a5dcf39532521c8a105f21f345760e0e1299028f3abcdcf332e4f7", size = 8464, upload-time = "2024-08-01T22:42:49.359Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/40/c5d138e1f302b25240678943422a646feea52bab1f594c669c101c5e5070/subtitle_filter-1.5.0-py3-none-any.whl", hash = "sha256:6b506315be64870fba2e6894a70d76389407ce58c325fdf05129e0530f0a0f5b", size = 8346 }, + { url = "https://files.pythonhosted.org/packages/10/40/c5d138e1f302b25240678943422a646feea52bab1f594c669c101c5e5070/subtitle_filter-1.5.0-py3-none-any.whl", hash = "sha256:6b506315be64870fba2e6894a70d76389407ce58c325fdf05129e0530f0a0f5b", size = 8346, upload-time = "2024-08-01T22:42:47.787Z" }, ] [[package]] name = "tinycss" version = "0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/59/af583fff6236c7d2f94f8175c40ce501dcefb8d1b42e4bb7a2622dff689e/tinycss-0.4.tar.gz", hash = "sha256:12306fb50e5e9e7eaeef84b802ed877488ba80e35c672867f548c0924a76716e", size = 87759 } +sdist = { url = "https://files.pythonhosted.org/packages/05/59/af583fff6236c7d2f94f8175c40ce501dcefb8d1b42e4bb7a2622dff689e/tinycss-0.4.tar.gz", hash = "sha256:12306fb50e5e9e7eaeef84b802ed877488ba80e35c672867f548c0924a76716e", size = 87759, upload-time = "2016-09-23T16:30:14.894Z" } [[package]] name = "tomli" -version = "2.2.1" +version = "2.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, - { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, - { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, - { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, - { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, - { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, - { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, - { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, - { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, - { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, - { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, - { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, - { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, - { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, - { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, - { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, - { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, - { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, - { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, - { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, - { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, ] [[package]] name = "types-protobuf" -version = "4.25.0.20240417" +version = "6.32.1.20251210" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/21/757620113af23233496c04b8a66e0201e78695495b1db8e676672608588b/types-protobuf-4.25.0.20240417.tar.gz", hash = "sha256:c34eff17b9b3a0adb6830622f0f302484e4c089f533a46e3f147568313544352", size = 53340 } +sdist = { url = "https://files.pythonhosted.org/packages/c2/59/c743a842911887cd96d56aa8936522b0cd5f7a7f228c96e81b59fced45be/types_protobuf-6.32.1.20251210.tar.gz", hash = "sha256:c698bb3f020274b1a2798ae09dc773728ce3f75209a35187bd11916ebfde6763", size = 63900, upload-time = "2025-12-10T03:14:25.451Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/78/6f0351f80a682c4005775c7e1fd2b17235b88d87c85ef59214bd1f60ff60/types_protobuf-4.25.0.20240417-py3-none-any.whl", hash = "sha256:e9b613227c2127e3d4881d75d93c93b4d6fd97b5f6a099a0b654a05351c8685d", size = 67896 }, + { url = "https://files.pythonhosted.org/packages/aa/43/58e75bac4219cbafee83179505ff44cae3153ec279be0e30583a73b8f108/types_protobuf-6.32.1.20251210-py3-none-any.whl", hash = "sha256:2641f78f3696822a048cfb8d0ff42ccd85c25f12f871fbebe86da63793692140", size = 77921, upload-time = "2025-12-10T03:14:24.477Z" }, ] [[package]] name = "types-pymysql" -version = "1.1.0.20250711" +version = "1.1.0.20251220" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/e1/76938a6a6873f909ee915238524614b9c478c0a20795383b8c40a51dd6bb/types_pymysql-1.1.0.20250711.tar.gz", hash = "sha256:38690d5e6d290eb56b9a451c79643c86663e1993344b818c69128977867a5ab9", size = 21740 } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/e959dd6d2f8e3b3c3f058d79ac9ece328922a5a8770c707fe9c3a757481c/types_pymysql-1.1.0.20251220.tar.gz", hash = "sha256:ae1c3df32a777489431e2e9963880a0df48f6591e0aa2fd3a6fabd9dee6eca54", size = 22184, upload-time = "2025-12-20T03:07:38.689Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/a9/bd475dc96209b5acdae2f95577c4ad05de265a5721c7f08b0f2eba435a5b/types_pymysql-1.1.0.20250711-py3-none-any.whl", hash = "sha256:29c8dbaeb38c005ea0b81fd2e1fbe58f1774d58105f41812fbf1dfc4701ae392", size = 22834 }, + { url = "https://files.pythonhosted.org/packages/8b/fa/4f4d3bfca9ef6dd17d69ed18b96564c53b32d3ce774132308d0bee849f10/types_pymysql-1.1.0.20251220-py3-none-any.whl", hash = "sha256:fa1082af7dea6c53b6caa5784241924b1296ea3a8d3bd060417352c5e10c0618", size = 23067, upload-time = "2025-12-20T03:07:37.766Z" }, ] [[package]] name = "types-requests" -version = "2.32.4.20250809" +version = "2.32.4.20260107" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ed/b0/9355adb86ec84d057fea765e4c49cce592aaf3d5117ce5609a95a7fc3dac/types_requests-2.32.4.20250809.tar.gz", hash = "sha256:d8060de1c8ee599311f56ff58010fb4902f462a1470802cf9f6ed27bc46c4df3", size = 23027 } +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/a0663907082280664d745929205a89d41dffb29e89a50f753af7d57d0a96/types_requests-2.32.4.20260107.tar.gz", hash = "sha256:018a11ac158f801bfa84857ddec1650750e393df8a004a8a9ae2a9bec6fcb24f", size = 23165, upload-time = "2026-01-07T03:20:54.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/6f/ec0012be842b1d888d46884ac5558fd62aeae1f0ec4f7a581433d890d4b5/types_requests-2.32.4.20250809-py3-none-any.whl", hash = "sha256:f73d1832fb519ece02c85b1f09d5f0dd3108938e7d47e7f94bbfa18a6782b163", size = 20644 }, + { url = "https://files.pythonhosted.org/packages/1c/12/709ea261f2bf91ef0a26a9eed20f2623227a8ed85610c1e54c5805692ecb/types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d", size = 20676, upload-time = "2026-01-07T03:20:52.929Z" }, ] [[package]] name = "typing-extensions" -version = "4.14.1" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673 } +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/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906 }, + { 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 } +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 }, + { 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]] @@ -1613,6 +1645,7 @@ dependencies = [ { name = "httpx" }, { name = "jsonpickle" }, { name = "langcodes" }, + { name = "language-data" }, { name = "lxml" }, { name = "pproxy" }, { name = "protobuf" }, @@ -1649,7 +1682,6 @@ dev = [ { name = "types-protobuf" }, { name = "types-pymysql" }, { name = "types-requests" }, - { name = "unshackle" }, { name = "virtualenv" }, ] @@ -1670,6 +1702,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1,<0.29" }, { name = "jsonpickle", specifier = ">=3.0.4,<5" }, { name = "langcodes", specifier = ">=3.4.0,<4" }, + { name = "language-data", specifier = ">=1.4.0" }, { name = "lxml", specifier = ">=5.2.1,<7" }, { name = "pproxy", specifier = ">=2.7.9,<3" }, { name = "protobuf", specifier = ">=4.25.3,<7" }, @@ -1706,7 +1739,6 @@ dev = [ { name = "types-protobuf", specifier = ">=4.24.0.20240408,<7" }, { name = "types-pymysql", specifier = ">=1.1.0.1,<2" }, { name = "types-requests", specifier = ">=2.31.0.20240406,<3" }, - { name = "unshackle", editable = "." }, { name = "virtualenv", specifier = ">=20.36.1,<22" }, ] @@ -1714,9 +1746,9 @@ dev = [ name = "urllib3" version = "2.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556 } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584 }, + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] [[package]] @@ -1729,90 +1761,87 @@ dependencies = [ { name = "platformdirs" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239 } +sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258 }, + { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, ] [[package]] name = "wcwidth" -version = "0.3.0" +version = "0.3.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/38/75/2144b65e4fba12a2d9868e9a3f99db7fa0760670d064603634bef9ff1709/wcwidth-0.3.0.tar.gz", hash = "sha256:af1a2fb0b83ef4a7fc0682a4c95ca2576e14d0280bca2a9e67b7dc9f2733e123", size = 172238 } +sdist = { url = "https://files.pythonhosted.org/packages/b5/3a/c63d2afd6dc2cad55a44bea48c7db75edde859e320bdceb9351ba63fceb6/wcwidth-0.3.3.tar.gz", hash = "sha256:f8f7d42c8a067d909b80b425342d02c423c5edc546347475e1d402fe3d35bb63", size = 233784, upload-time = "2026-01-24T16:23:58.578Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/0e/a5f0257ab47492b7afb5fb60347d14ba19445e2773fc8352d4be6bd2f6f8/wcwidth-0.3.0-py3-none-any.whl", hash = "sha256:073a1acb250e4add96cfd5ef84e0036605cd6e0d0782c8c15c80e42202348458", size = 85520 }, + { url = "https://files.pythonhosted.org/packages/4f/bc/ab575ebf0254577034d23908299b0d13ea5d7ceb35f43a5c08acf2252826/wcwidth-0.3.3-py3-none-any.whl", hash = "sha256:8e9056c446f21c7393514946d143a748c56aad72476844d3f215f7915276508f", size = 86509, upload-time = "2026-01-24T16:23:56.966Z" }, ] [[package]] name = "xmltodict" version = "0.14.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/05/51dcca9a9bf5e1bce52582683ce50980bcadbc4fa5143b9f2b19ab99958f/xmltodict-0.14.2.tar.gz", hash = "sha256:201e7c28bb210e374999d1dde6382923ab0ed1a8a5faeece48ab525b7810a553", size = 51942 } +sdist = { url = "https://files.pythonhosted.org/packages/50/05/51dcca9a9bf5e1bce52582683ce50980bcadbc4fa5143b9f2b19ab99958f/xmltodict-0.14.2.tar.gz", hash = "sha256:201e7c28bb210e374999d1dde6382923ab0ed1a8a5faeece48ab525b7810a553", size = 51942, upload-time = "2024-10-16T06:10:29.683Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/45/fc303eb433e8a2a271739c98e953728422fa61a3c1f36077a49e395c972e/xmltodict-0.14.2-py2.py3-none-any.whl", hash = "sha256:20cc7d723ed729276e808f26fb6b3599f786cbc37e06c65e192ba77c40f20aac", size = 9981 }, + { url = "https://files.pythonhosted.org/packages/d6/45/fc303eb433e8a2a271739c98e953728422fa61a3c1f36077a49e395c972e/xmltodict-0.14.2-py2.py3-none-any.whl", hash = "sha256:20cc7d723ed729276e808f26fb6b3599f786cbc37e06c65e192ba77c40f20aac", size = 9981, upload-time = "2024-10-16T06:10:27.649Z" }, ] [[package]] name = "yarl" -version = "1.20.1" +version = "1.22.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428 } +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/65/7fed0d774abf47487c64be14e9223749468922817b5e8792b8a64792a1bb/yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4", size = 132910 }, - { url = "https://files.pythonhosted.org/packages/8a/7b/988f55a52da99df9e56dc733b8e4e5a6ae2090081dc2754fc8fd34e60aa0/yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a", size = 90644 }, - { url = "https://files.pythonhosted.org/packages/f7/de/30d98f03e95d30c7e3cc093759982d038c8833ec2451001d45ef4854edc1/yarl-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c869f2651cc77465f6cd01d938d91a11d9ea5d798738c1dc077f3de0b5e5fed", size = 89322 }, - { url = "https://files.pythonhosted.org/packages/e0/7a/f2f314f5ebfe9200724b0b748de2186b927acb334cf964fd312eb86fc286/yarl-1.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62915e6688eb4d180d93840cda4110995ad50c459bf931b8b3775b37c264af1e", size = 323786 }, - { url = "https://files.pythonhosted.org/packages/15/3f/718d26f189db96d993d14b984ce91de52e76309d0fd1d4296f34039856aa/yarl-1.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:41ebd28167bc6af8abb97fec1a399f412eec5fd61a3ccbe2305a18b84fb4ca73", size = 319627 }, - { url = "https://files.pythonhosted.org/packages/a5/76/8fcfbf5fa2369157b9898962a4a7d96764b287b085b5b3d9ffae69cdefd1/yarl-1.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21242b4288a6d56f04ea193adde174b7e347ac46ce6bc84989ff7c1b1ecea84e", size = 339149 }, - { url = "https://files.pythonhosted.org/packages/3c/95/d7fc301cc4661785967acc04f54a4a42d5124905e27db27bb578aac49b5c/yarl-1.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bea21cdae6c7eb02ba02a475f37463abfe0a01f5d7200121b03e605d6a0439f8", size = 333327 }, - { url = "https://files.pythonhosted.org/packages/65/94/e21269718349582eee81efc5c1c08ee71c816bfc1585b77d0ec3f58089eb/yarl-1.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f8a891e4a22a89f5dde7862994485e19db246b70bb288d3ce73a34422e55b23", size = 326054 }, - { url = "https://files.pythonhosted.org/packages/32/ae/8616d1f07853704523519f6131d21f092e567c5af93de7e3e94b38d7f065/yarl-1.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd803820d44c8853a109a34e3660e5a61beae12970da479cf44aa2954019bf70", size = 315035 }, - { url = "https://files.pythonhosted.org/packages/48/aa/0ace06280861ef055855333707db5e49c6e3a08840a7ce62682259d0a6c0/yarl-1.20.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b982fa7f74c80d5c0c7b5b38f908971e513380a10fecea528091405f519b9ebb", size = 338962 }, - { url = "https://files.pythonhosted.org/packages/20/52/1e9d0e6916f45a8fb50e6844f01cb34692455f1acd548606cbda8134cd1e/yarl-1.20.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:33f29ecfe0330c570d997bcf1afd304377f2e48f61447f37e846a6058a4d33b2", size = 335399 }, - { url = "https://files.pythonhosted.org/packages/f2/65/60452df742952c630e82f394cd409de10610481d9043aa14c61bf846b7b1/yarl-1.20.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:835ab2cfc74d5eb4a6a528c57f05688099da41cf4957cf08cad38647e4a83b30", size = 338649 }, - { url = "https://files.pythonhosted.org/packages/7b/f5/6cd4ff38dcde57a70f23719a838665ee17079640c77087404c3d34da6727/yarl-1.20.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46b5e0ccf1943a9a6e766b2c2b8c732c55b34e28be57d8daa2b3c1d1d4009309", size = 358563 }, - { url = "https://files.pythonhosted.org/packages/d1/90/c42eefd79d0d8222cb3227bdd51b640c0c1d0aa33fe4cc86c36eccba77d3/yarl-1.20.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:df47c55f7d74127d1b11251fe6397d84afdde0d53b90bedb46a23c0e534f9d24", size = 357609 }, - { url = "https://files.pythonhosted.org/packages/03/c8/cea6b232cb4617514232e0f8a718153a95b5d82b5290711b201545825532/yarl-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76d12524d05841276b0e22573f28d5fbcb67589836772ae9244d90dd7d66aa13", size = 350224 }, - { url = "https://files.pythonhosted.org/packages/ce/a3/eaa0ab9712f1f3d01faf43cf6f1f7210ce4ea4a7e9b28b489a2261ca8db9/yarl-1.20.1-cp310-cp310-win32.whl", hash = "sha256:6c4fbf6b02d70e512d7ade4b1f998f237137f1417ab07ec06358ea04f69134f8", size = 81753 }, - { url = "https://files.pythonhosted.org/packages/8f/34/e4abde70a9256465fe31c88ed02c3f8502b7b5dead693a4f350a06413f28/yarl-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef6c4d69554d44b7f9d923245f8ad9a707d971e6209d51279196d8e8fe1ae16", size = 86817 }, - { url = "https://files.pythonhosted.org/packages/b1/18/893b50efc2350e47a874c5c2d67e55a0ea5df91186b2a6f5ac52eff887cd/yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e", size = 133833 }, - { url = "https://files.pythonhosted.org/packages/89/ed/b8773448030e6fc47fa797f099ab9eab151a43a25717f9ac043844ad5ea3/yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b", size = 91070 }, - { url = "https://files.pythonhosted.org/packages/e3/e3/409bd17b1e42619bf69f60e4f031ce1ccb29bd7380117a55529e76933464/yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b", size = 89818 }, - { url = "https://files.pythonhosted.org/packages/f8/77/64d8431a4d77c856eb2d82aa3de2ad6741365245a29b3a9543cd598ed8c5/yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4", size = 347003 }, - { url = "https://files.pythonhosted.org/packages/8d/d2/0c7e4def093dcef0bd9fa22d4d24b023788b0a33b8d0088b51aa51e21e99/yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1", size = 336537 }, - { url = "https://files.pythonhosted.org/packages/f0/f3/fc514f4b2cf02cb59d10cbfe228691d25929ce8f72a38db07d3febc3f706/yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833", size = 362358 }, - { url = "https://files.pythonhosted.org/packages/ea/6d/a313ac8d8391381ff9006ac05f1d4331cee3b1efaa833a53d12253733255/yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d", size = 357362 }, - { url = "https://files.pythonhosted.org/packages/00/70/8f78a95d6935a70263d46caa3dd18e1f223cf2f2ff2037baa01a22bc5b22/yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8", size = 348979 }, - { url = "https://files.pythonhosted.org/packages/cb/05/42773027968968f4f15143553970ee36ead27038d627f457cc44bbbeecf3/yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf", size = 337274 }, - { url = "https://files.pythonhosted.org/packages/05/be/665634aa196954156741ea591d2f946f1b78ceee8bb8f28488bf28c0dd62/yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e", size = 363294 }, - { url = "https://files.pythonhosted.org/packages/eb/90/73448401d36fa4e210ece5579895731f190d5119c4b66b43b52182e88cd5/yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389", size = 358169 }, - { url = "https://files.pythonhosted.org/packages/c3/b0/fce922d46dc1eb43c811f1889f7daa6001b27a4005587e94878570300881/yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f", size = 362776 }, - { url = "https://files.pythonhosted.org/packages/f1/0d/b172628fce039dae8977fd22caeff3eeebffd52e86060413f5673767c427/yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845", size = 381341 }, - { url = "https://files.pythonhosted.org/packages/6b/9b/5b886d7671f4580209e855974fe1cecec409aa4a89ea58b8f0560dc529b1/yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1", size = 379988 }, - { url = "https://files.pythonhosted.org/packages/73/be/75ef5fd0fcd8f083a5d13f78fd3f009528132a1f2a1d7c925c39fa20aa79/yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e", size = 371113 }, - { url = "https://files.pythonhosted.org/packages/50/4f/62faab3b479dfdcb741fe9e3f0323e2a7d5cd1ab2edc73221d57ad4834b2/yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773", size = 81485 }, - { url = "https://files.pythonhosted.org/packages/f0/09/d9c7942f8f05c32ec72cd5c8e041c8b29b5807328b68b4801ff2511d4d5e/yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e", size = 86686 }, - { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667 }, - { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025 }, - { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709 }, - { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287 }, - { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429 }, - { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429 }, - { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862 }, - { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616 }, - { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954 }, - { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575 }, - { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061 }, - { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142 }, - { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894 }, - { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378 }, - { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069 }, - { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249 }, - { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710 }, - { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542 }, + { url = "https://files.pythonhosted.org/packages/d1/43/a2204825342f37c337f5edb6637040fa14e365b2fcc2346960201d457579/yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e", size = 140517, upload-time = "2025-10-06T14:08:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/674f3e6f02266428c56f704cd2501c22f78e8b2eeb23f153117cc86fb28a/yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f", size = 93495, upload-time = "2025-10-06T14:08:46.2Z" }, + { url = "https://files.pythonhosted.org/packages/b8/12/5b274d8a0f30c07b91b2f02cba69152600b47830fcfb465c108880fcee9c/yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf", size = 94400, upload-time = "2025-10-06T14:08:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7f/df1b6949b1fa1aa9ff6de6e2631876ad4b73c4437822026e85d8acb56bb1/yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a", size = 347545, upload-time = "2025-10-06T14:08:49.683Z" }, + { url = "https://files.pythonhosted.org/packages/84/09/f92ed93bd6cd77872ab6c3462df45ca45cd058d8f1d0c9b4f54c1704429f/yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c", size = 319598, upload-time = "2025-10-06T14:08:51.215Z" }, + { url = "https://files.pythonhosted.org/packages/c3/97/ac3f3feae7d522cf7ccec3d340bb0b2b61c56cb9767923df62a135092c6b/yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147", size = 363893, upload-time = "2025-10-06T14:08:53.144Z" }, + { url = "https://files.pythonhosted.org/packages/06/49/f3219097403b9c84a4d079b1d7bda62dd9b86d0d6e4428c02d46ab2c77fc/yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb", size = 371240, upload-time = "2025-10-06T14:08:55.036Z" }, + { url = "https://files.pythonhosted.org/packages/35/9f/06b765d45c0e44e8ecf0fe15c9eacbbde342bb5b7561c46944f107bfb6c3/yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6", size = 346965, upload-time = "2025-10-06T14:08:56.722Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/599e7cea8d0fcb1694323b0db0dda317fa3162f7b90166faddecf532166f/yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0", size = 342026, upload-time = "2025-10-06T14:08:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/95/6f/9dfd12c8bc90fea9eab39832ee32ea48f8e53d1256252a77b710c065c89f/yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda", size = 335637, upload-time = "2025-10-06T14:09:00.506Z" }, + { url = "https://files.pythonhosted.org/packages/57/2e/34c5b4eb9b07e16e873db5b182c71e5f06f9b5af388cdaa97736d79dd9a6/yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc", size = 359082, upload-time = "2025-10-06T14:09:01.936Z" }, + { url = "https://files.pythonhosted.org/packages/31/71/fa7e10fb772d273aa1f096ecb8ab8594117822f683bab7d2c5a89914c92a/yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737", size = 357811, upload-time = "2025-10-06T14:09:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/26/da/11374c04e8e1184a6a03cf9c8f5688d3e5cec83ed6f31ad3481b3207f709/yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467", size = 351223, upload-time = "2025-10-06T14:09:05.401Z" }, + { url = "https://files.pythonhosted.org/packages/82/8f/e2d01f161b0c034a30410e375e191a5d27608c1f8693bab1a08b089ca096/yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea", size = 82118, upload-time = "2025-10-06T14:09:11.148Z" }, + { url = "https://files.pythonhosted.org/packages/62/46/94c76196642dbeae634c7a61ba3da88cd77bed875bf6e4a8bed037505aa6/yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca", size = 86852, upload-time = "2025-10-06T14:09:12.958Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/7df4f179d3b1a6dcb9a4bd2ffbc67642746fcafdb62580e66876ce83fff4/yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b", size = 82012, upload-time = "2025-10-06T14:09:14.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" }, + { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" }, + { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" }, + { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" }, + { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" }, + { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" }, + { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, + { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, + { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, + { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, + { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, + { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, ] From 91a2d76f8867559bbdc28d85c599dae40c7e64fc Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 24 Jan 2026 16:10:45 -0700 Subject: [PATCH 16/38] refactor: remove remote-service code until feature is more complete Temporarily removes client-side remote service discovery and authentication until the implementation is more fleshed out and working. --- CONFIG.md | 26 -- unshackle/commands/remote_auth.py | 225 ---------- unshackle/commands/serve.py | 23 - unshackle/core/api/handlers.py | 99 ++++- unshackle/core/api/remote_handlers.py | 328 +++++++++++++- unshackle/core/api/routes.py | 6 +- unshackle/core/config.py | 2 - unshackle/core/crypto.py | 279 ------------ unshackle/core/local_session_cache.py | 274 ------------ unshackle/core/remote_auth.py | 276 ------------ unshackle/core/remote_service.py | 593 -------------------------- unshackle/core/remote_services.py | 245 ----------- unshackle/core/services.py | 44 +- 13 files changed, 420 insertions(+), 2000 deletions(-) delete mode 100644 unshackle/commands/remote_auth.py delete mode 100644 unshackle/core/crypto.py delete mode 100644 unshackle/core/local_session_cache.py delete mode 100644 unshackle/core/remote_auth.py delete mode 100644 unshackle/core/remote_service.py delete mode 100644 unshackle/core/remote_services.py diff --git a/CONFIG.md b/CONFIG.md index f777f2d..942657a 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -1182,32 +1182,6 @@ remote_cdm: [pywidevine]: https://github.com/rlaphoenix/pywidevine -## remote_services (list\[dict]) - -Configure connections to remote unshackle REST API servers to access services running on other instances. -This allows you to use services from remote unshackle installations as if they were local. - -Each entry requires: - -- `url` (str): The base URL of the remote unshackle REST API server -- `api_key` (str): API key for authenticating with the remote server -- `name` (str, optional): Friendly name for the remote service (for logging/display purposes) - -For example, - -```yaml -remote_services: - - url: "https://remote-unshackle.example.com" - api_key: "your_api_key_here" - name: "Remote US Server" - - url: "https://remote-unshackle-eu.example.com" - api_key: "another_api_key" - name: "Remote EU Server" -``` - -**Note**: The remote unshackle instances must have the REST API enabled and running. Services from all -configured remote servers will be available alongside your local services. - ## scene_naming (bool) Set scene-style naming for titles. When `true` uses scene naming patterns (e.g., `Prime.Suspect.S07E01...`), when diff --git a/unshackle/commands/remote_auth.py b/unshackle/commands/remote_auth.py deleted file mode 100644 index 23b6de2..0000000 --- a/unshackle/commands/remote_auth.py +++ /dev/null @@ -1,225 +0,0 @@ -"""CLI command for authenticating remote services.""" - -from typing import Optional - -import click -from rich.table import Table - -from unshackle.core.config import config -from unshackle.core.console import console -from unshackle.core.constants import context_settings -from unshackle.core.remote_auth import RemoteAuthenticator - - -@click.group(short_help="Manage remote service authentication.", context_settings=context_settings) -def remote_auth() -> None: - """Authenticate and manage sessions for remote services.""" - pass - - -@remote_auth.command(name="authenticate") -@click.argument("service", type=str) -@click.option( - "-r", "--remote", type=str, help="Remote server name or URL (from config)", required=False -) -@click.option("-p", "--profile", type=str, help="Profile to use for authentication") -def authenticate_command(service: str, remote: Optional[str], profile: Optional[str]) -> None: - """ - Authenticate a service locally and upload session to remote server. - - This command: - 1. Authenticates the service locally (shows browser, handles 2FA, etc.) - 2. Extracts the authenticated session - 3. Uploads the session to the remote server - - The server will use this pre-authenticated session for all requests. - - Examples: - unshackle remote-auth authenticate DSNP - unshackle remote-auth authenticate NF --profile john - unshackle remote-auth auth AMZN --remote my-server - """ - # Get remote server config - remote_config = _get_remote_config(remote) - if not remote_config: - return - - remote_url = remote_config["url"] - api_key = remote_config["api_key"] - server_name = remote_config.get("name", remote_url) - - console.print(f"\n[bold cyan]Authenticating {service} for remote server:[/bold cyan] {server_name}") - console.print(f"[dim]Server: {remote_url}[/dim]\n") - - # Create authenticator - authenticator = RemoteAuthenticator(remote_url, api_key) - - # Authenticate and save locally - success = authenticator.authenticate_and_save(service, profile) - - if success: - console.print(f"\n[bold green]✓ Success![/bold green] Session saved locally. You can now use remote_{service} service.") - else: - console.print(f"\n[bold red]✗ Failed to authenticate {service}[/bold red]") - raise click.Abort() - - -@remote_auth.command(name="status") -@click.option( - "-r", "--remote", type=str, help="Remote server name or URL (from config)", required=False -) -def status_command(remote: Optional[str]) -> None: - """ - Show status of all authenticated sessions in local cache. - - Examples: - unshackle remote-auth status - unshackle remote-auth status --remote my-server - """ - import datetime - - from unshackle.core.local_session_cache import get_local_session_cache - - # Get local session cache - cache = get_local_session_cache() - - # Get remote server config (optional filter) - remote_url = None - if remote: - remote_config = _get_remote_config(remote) - if remote_config: - remote_url = remote_config["url"] - server_name = remote_config.get("name", remote_url) - else: - server_name = "All Remotes" - - # Get sessions (filtered by remote if specified) - sessions = cache.list_sessions(remote_url) - - if not sessions: - if remote_url: - console.print(f"\n[yellow]No authenticated sessions for {server_name}[/yellow]") - else: - console.print("\n[yellow]No authenticated sessions in local cache[/yellow]") - console.print("\nUse [cyan]unshackle remote-auth authenticate [/cyan] to add sessions") - return - - # Display sessions in table - table = Table(title=f"Local Authenticated Sessions - {server_name}") - table.add_column("Remote", style="magenta") - table.add_column("Service", style="cyan") - table.add_column("Profile", style="green") - table.add_column("Cached", style="dim") - table.add_column("Age", style="yellow") - table.add_column("Status", style="bold") - - for session in sessions: - cached_time = datetime.datetime.fromtimestamp(session["cached_at"]).strftime("%Y-%m-%d %H:%M") - - # Format age - age_seconds = session["age_seconds"] - if age_seconds < 3600: - age_str = f"{age_seconds // 60}m" - elif age_seconds < 86400: - age_str = f"{age_seconds // 3600}h" - else: - age_str = f"{age_seconds // 86400}d" - - # Status - status = "[red]Expired" if session["expired"] else "[green]Valid" - - # Short remote URL for display - remote_display = session["remote_url"].replace("https://", "").replace("http://", "") - if len(remote_display) > 30: - remote_display = remote_display[:27] + "..." - - table.add_row( - remote_display, - session["service_tag"], - session["profile"], - cached_time, - age_str, - status - ) - - console.print() - console.print(table) - console.print("\n[dim]Sessions are stored locally and expire after 24 hours[/dim]") - console.print() - - -@remote_auth.command(name="delete") -@click.argument("service", type=str) -@click.option( - "-r", "--remote", type=str, help="Remote server name or URL (from config)", required=False -) -@click.option("-p", "--profile", type=str, default="default", help="Profile name") -def delete_command(service: str, remote: Optional[str], profile: str) -> None: - """ - Delete an authenticated session from local cache. - - Examples: - unshackle remote-auth delete DSNP - unshackle remote-auth delete NF --profile john - """ - from unshackle.core.local_session_cache import get_local_session_cache - - # Get remote server config - remote_config = _get_remote_config(remote) - if not remote_config: - return - - remote_url = remote_config["url"] - - cache = get_local_session_cache() - - console.print(f"\n[yellow]Deleting local session for {service} (profile: {profile})...[/yellow]") - - deleted = cache.delete_session(remote_url, service, profile) - - if deleted: - console.print("[green]✓ Session deleted from local cache[/green]") - else: - console.print(f"[red]✗ No session found for {service} (profile: {profile})[/red]") - - -def _get_remote_config(remote: Optional[str]) -> Optional[dict]: - """ - Get remote server configuration. - - Args: - remote: Remote server name or URL, or None for first configured remote - - Returns: - Remote config dict or None - """ - if not config.remote_services: - console.print("[red]No remote services configured in unshackle.yaml[/red]") - console.print("\nAdd a remote service to your config:") - console.print("[dim]remote_services:") - console.print(" - url: https://your-server.com") - console.print(" api_key: your-api-key") - console.print(" name: my-server[/dim]") - return None - - # If no remote specified, use the first one - if not remote: - return config.remote_services[0] - - # Check if remote is a name - for remote_config in config.remote_services: - if remote_config.get("name") == remote: - return remote_config - - # Check if remote is a URL - for remote_config in config.remote_services: - if remote_config.get("url") == remote: - return remote_config - - console.print(f"[red]Remote server '{remote}' not found in config[/red]") - console.print("\nAvailable remotes:") - for remote_config in config.remote_services: - name = remote_config.get("name", remote_config.get("url")) - console.print(f" - {name}") - - return None diff --git a/unshackle/commands/serve.py b/unshackle/commands/serve.py index 5d37057..b510350 100644 --- a/unshackle/commands/serve.py +++ b/unshackle/commands/serve.py @@ -62,29 +62,6 @@ def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug tier: "premium" default_cdm: "chromecdm_2101" allowed_cdms: ["*"] # or list specific CDMs: ["chromecdm_2101", "chromecdm_2202"] - - \b - REMOTE SERVICES: - The server exposes endpoints that allow remote unshackle clients to use - your configured services without needing the service implementations. - Remote clients can authenticate, get titles/tracks, and receive session data - for downloading. Configure remote clients in unshackle.yaml: - - \b - remote_services: - - url: "http://your-server:8786" - api_key: "your-api-key" - name: "my-server" - - \b - Available remote endpoints: - - GET /api/remote/services - List available services - - POST /api/remote/{service}/search - Search for content - - POST /api/remote/{service}/titles - Get titles - - POST /api/remote/{service}/tracks - Get tracks - - POST /api/remote/{service}/chapters - Get chapters - - POST /api/remote/{service}/license - Get DRM license (uses client CDM) - - POST /api/remote/{service}/decrypt - Decrypt using server CDM (premium only) """ from pywidevine import serve as pywidevine_serve diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index ba94adb..2d779e2 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -191,12 +191,73 @@ def serialize_title(title: Title_T) -> Dict[str, Any]: return result -def serialize_video_track(track: Video) -> Dict[str, Any]: +def serialize_drm(drm_list) -> Optional[List[Dict[str, Any]]]: + """Serialize DRM objects to JSON-serializable list.""" + if not drm_list: + return None + + if not isinstance(drm_list, list): + drm_list = [drm_list] + + result = [] + for drm in drm_list: + drm_info = {} + drm_class = drm.__class__.__name__ + drm_info["type"] = drm_class.lower() + + # Get PSSH - handle both Widevine and PlayReady + if hasattr(drm, "_pssh") and drm._pssh: + try: + pssh_obj = drm._pssh + # Try to get base64 representation + if hasattr(pssh_obj, "dumps"): + # pywidevine PSSH has dumps() method + drm_info["pssh"] = pssh_obj.dumps() + elif hasattr(pssh_obj, "__bytes__"): + # Convert to base64 + import base64 + drm_info["pssh"] = base64.b64encode(bytes(pssh_obj)).decode() + elif hasattr(pssh_obj, "to_base64"): + drm_info["pssh"] = pssh_obj.to_base64() + else: + # Fallback - str() works for pywidevine PSSH + pssh_str = str(pssh_obj) + # Check if it's already base64-like or an object repr + if not pssh_str.startswith("<"): + drm_info["pssh"] = pssh_str + except Exception: + pass + + # Get KIDs + if hasattr(drm, "kids") and drm.kids: + drm_info["kids"] = [str(kid) for kid in drm.kids] + + # Get content keys if available + if hasattr(drm, "content_keys") and drm.content_keys: + drm_info["content_keys"] = {str(k): v for k, v in drm.content_keys.items()} + + # Get license URL - essential for remote licensing + if hasattr(drm, "license_url") and drm.license_url: + drm_info["license_url"] = str(drm.license_url) + elif hasattr(drm, "_license_url") and drm._license_url: + drm_info["license_url"] = str(drm._license_url) + + result.append(drm_info) + + return result if result else None + + +def serialize_video_track(track: Video, include_url: bool = False) -> Dict[str, Any]: """Convert video track to JSON-serializable dict.""" codec_name = track.codec.name if hasattr(track.codec, "name") else str(track.codec) range_name = track.range.name if hasattr(track.range, "name") else str(track.range) - return { + # Get descriptor for N_m3u8DL-RE compatibility (HLS, DASH, URL, etc.) + descriptor_name = None + if hasattr(track, "descriptor") and track.descriptor: + descriptor_name = track.descriptor.name if hasattr(track.descriptor, "name") else str(track.descriptor) + + result = { "id": str(track.id), "codec": codec_name, "codec_display": VIDEO_CODEC_MAP.get(codec_name, codec_name), @@ -208,15 +269,24 @@ def serialize_video_track(track: Video) -> Dict[str, Any]: "range": range_name, "range_display": DYNAMIC_RANGE_MAP.get(range_name, range_name), "language": str(track.language) if track.language else None, - "drm": str(track.drm) if hasattr(track, "drm") and track.drm else None, + "drm": serialize_drm(track.drm) if hasattr(track, "drm") and track.drm else None, + "descriptor": descriptor_name, } + if include_url and hasattr(track, "url") and track.url: + result["url"] = str(track.url) + return result -def serialize_audio_track(track: Audio) -> Dict[str, Any]: +def serialize_audio_track(track: Audio, include_url: bool = False) -> Dict[str, Any]: """Convert audio track to JSON-serializable dict.""" codec_name = track.codec.name if hasattr(track.codec, "name") else str(track.codec) - return { + # Get descriptor for N_m3u8DL-RE compatibility + descriptor_name = None + if hasattr(track, "descriptor") and track.descriptor: + descriptor_name = track.descriptor.name if hasattr(track.descriptor, "name") else str(track.descriptor) + + result = { "id": str(track.id), "codec": codec_name, "codec_display": AUDIO_CODEC_MAP.get(codec_name, codec_name), @@ -225,20 +295,33 @@ def serialize_audio_track(track: Audio) -> Dict[str, Any]: "language": str(track.language) if track.language else None, "atmos": track.atmos if hasattr(track, "atmos") else False, "descriptive": track.descriptive if hasattr(track, "descriptive") else False, - "drm": str(track.drm) if hasattr(track, "drm") and track.drm else None, + "drm": serialize_drm(track.drm) if hasattr(track, "drm") and track.drm else None, + "descriptor": descriptor_name, } + if include_url and hasattr(track, "url") and track.url: + result["url"] = str(track.url) + return result -def serialize_subtitle_track(track: Subtitle) -> Dict[str, Any]: +def serialize_subtitle_track(track: Subtitle, include_url: bool = False) -> Dict[str, Any]: """Convert subtitle track to JSON-serializable dict.""" - return { + # Get descriptor for compatibility + descriptor_name = None + if hasattr(track, "descriptor") and track.descriptor: + descriptor_name = track.descriptor.name if hasattr(track.descriptor, "name") else str(track.descriptor) + + result = { "id": str(track.id), "codec": track.codec.name if hasattr(track.codec, "name") else str(track.codec), "language": str(track.language) if track.language else None, "forced": track.forced if hasattr(track, "forced") else False, "sdh": track.sdh if hasattr(track, "sdh") else False, "cc": track.cc if hasattr(track, "cc") else False, + "descriptor": descriptor_name, } + if include_url and hasattr(track, "url") and track.url: + result["url"] = str(track.url) + return result async def list_titles_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response: diff --git a/unshackle/core/api/remote_handlers.py b/unshackle/core/api/remote_handlers.py index b4a8cc3..db60ee2 100644 --- a/unshackle/core/api/remote_handlers.py +++ b/unshackle/core/api/remote_handlers.py @@ -31,6 +31,31 @@ log = logging.getLogger("api.remote") SESSION_EXPIRY_TIME = 86400 +class CDMProxy: + """ + Lightweight CDM proxy that holds CDM properties sent from client. + + This allows services to check CDM properties (like security_level) + without needing an actual CDM loaded on the server. + """ + + def __init__(self, cdm_info: Dict[str, Any]): + """ + Initialize CDM proxy from client-provided info. + + Args: + cdm_info: Dictionary with CDM properties (type, security_level, etc.) + """ + self.cdm_type = cdm_info.get("type", "widevine") + self.security_level = cdm_info.get("security_level", 3) + self.is_playready = self.cdm_type == "playready" + self.device_type = cdm_info.get("device_type") + self.is_remote = cdm_info.get("is_remote", False) + + def __repr__(self): + return f"CDMProxy(type={self.cdm_type}, L{self.security_level})" + + def load_cookies_from_content(cookies_content: Optional[str]) -> Optional[http.cookiejar.MozillaCookieJar]: """ Load cookies from raw cookie file content. @@ -754,8 +779,12 @@ async def remote_get_tracks(request: web.Request) -> web.Response: f"Please resolve the proxy on the client side before sending to server." }, status=400) + # Create CDM proxy from client-provided info (default to L3 Widevine if not provided) + cdm_info = data.get("cdm_info") or {"type": "widevine", "security_level": 3} + cdm = CDMProxy(cdm_info) + ctx = click.Context(dummy_service) - ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=[], profile=profile) + ctx.obj = ContextData(config=service_config, cdm=cdm, proxy_providers=[], profile=profile) ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy} service_module = Services.load(normalized_service) @@ -771,7 +800,7 @@ async def remote_get_tracks(request: web.Request) -> web.Response: # Add additional parameters for key, value in data.items(): - if key not in ["title", "title_id", "url", "profile", "wanted", "season", "episode", "proxy", "no_proxy"]: + if key not in ["title", "title_id", "url", "profile", "wanted", "season", "episode", "proxy", "no_proxy", "cdm_info"]: service_kwargs[key] = value # Get service parameters @@ -942,14 +971,35 @@ async def remote_get_tracks(request: web.Request) -> web.Response: if hasattr(service_module, "GEOFENCE"): geofence = list(service_module.GEOFENCE) + # Try to extract license URL from service (for remote licensing) + license_url = None + title_id = first_title.id if hasattr(first_title, "id") else str(first_title) + + # Check playback_data for license URL + if hasattr(service_instance, "playback_data") and title_id in service_instance.playback_data: + playback_data = service_instance.playback_data[title_id] + # DSNP pattern + if "drm" in playback_data and "licenseServerUrl" in playback_data.get("drm", {}): + license_url = playback_data["drm"]["licenseServerUrl"] + elif "stream" in playback_data and "drm" in playback_data["stream"]: + drm_info = playback_data["stream"]["drm"] + if isinstance(drm_info, dict) and "licenseServerUrl" in drm_info: + license_url = drm_info["licenseServerUrl"] + + # Check service config for license URL + if not license_url and hasattr(service_instance, "config"): + if "license_url" in service_instance.config: + license_url = service_instance.config["license_url"] + response_data = { "status": "success", "title": serialize_title(first_title), - "video": [serialize_video_track(t) for t in video_tracks], - "audio": [serialize_audio_track(t) for t in audio_tracks], - "subtitles": [serialize_subtitle_track(t) for t in tracks.subtitles], + "video": [serialize_video_track(t, include_url=True) for t in video_tracks], + "audio": [serialize_audio_track(t, include_url=True) for t in audio_tracks], + "subtitles": [serialize_subtitle_track(t, include_url=True) for t in tracks.subtitles], "session": session_data, - "geofence": geofence + "geofence": geofence, + "license_url": license_url, } return web.json_response(response_data) @@ -959,6 +1009,272 @@ async def remote_get_tracks(request: web.Request) -> web.Response: return web.json_response({"status": "error", "message": "Internal server error while getting tracks"}, status=500) +async def remote_get_manifest(request: web.Request) -> web.Response: + """ + Get manifest URL and session from a remote service. + + This endpoint returns the manifest URL and authenticated session, + allowing the client to fetch and parse the manifest locally. + --- + summary: Get manifest info from remote service + description: Get manifest URL and session for client-side parsing + parameters: + - name: service + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - title + properties: + title: + type: string + description: Title identifier + cdm_info: + type: object + description: Client CDM info (type, security_level) + responses: + '200': + description: Manifest info + """ + service_tag = request.match_info.get("service") + + try: + data = await request.json() + except Exception: + return web.json_response({"status": "error", "message": "Invalid JSON request body"}, status=400) + + title = data.get("title") or data.get("title_id") or data.get("url") + if not title: + return web.json_response( + {"status": "error", "message": "Missing required parameter: title"}, + status=400, + ) + + normalized_service = validate_service(service_tag) + if not normalized_service: + return web.json_response( + {"status": "error", "message": f"Invalid or unavailable service: {service_tag}"}, status=400 + ) + + try: + profile = data.get("profile") + + service_config_path = Services.get_path(normalized_service) / config.filenames.config + if service_config_path.exists(): + service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8")) + else: + service_config = {} + merge_dict(config.services.get(normalized_service), service_config) + + @click.command() + @click.pass_context + def dummy_service(ctx: click.Context) -> None: + pass + + proxy_param = data.get("proxy") + no_proxy = data.get("no_proxy", False) + + if proxy_param and not no_proxy: + import re + if not re.match(r"^https?://", proxy_param): + return web.json_response({ + "status": "error", + "error_code": "INVALID_PROXY", + "message": "Proxy must be a fully resolved URL" + }, status=400) + + # Create CDM proxy from client-provided info + cdm_info = data.get("cdm_info") or {"type": "widevine", "security_level": 3} + cdm = CDMProxy(cdm_info) + + ctx = click.Context(dummy_service) + ctx.obj = ContextData(config=service_config, cdm=cdm, proxy_providers=[], profile=profile) + ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy} + + service_module = Services.load(normalized_service) + + dummy_service.name = normalized_service + dummy_service.params = [click.Argument([title], type=str)] + ctx.invoked_subcommand = normalized_service + + service_ctx = click.Context(dummy_service, parent=ctx) + service_ctx.obj = ctx.obj + + service_kwargs = {"title": title} + + for key, value in data.items(): + if key not in ["title", "title_id", "url", "profile", "proxy", "no_proxy", "cdm_info"]: + service_kwargs[key] = value + + service_init_params = inspect.signature(service_module.__init__).parameters + + if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"): + for param in service_module.cli.params: + if hasattr(param, "name") and param.name not in service_kwargs: + if hasattr(param, "default") and param.default is not None: + service_kwargs[param.name] = param.default + + for param_name, param_info in service_init_params.items(): + if param_name not in service_kwargs and param_name not in ["self", "ctx"]: + if param_info.default is inspect.Parameter.empty: + if param_name == "meta_lang": + service_kwargs[param_name] = None + elif param_name == "movie": + service_kwargs[param_name] = False + + filtered_kwargs = {k: v for k, v in service_kwargs.items() if k in service_init_params} + + service_instance = service_module(service_ctx, **filtered_kwargs) + + # Authenticate + cookies, credential, pre_authenticated_session, session_error = get_auth_from_request(data, normalized_service, profile) + + if session_error == "SESSION_EXPIRED": + return web.json_response({ + "status": "error", + "error_code": "SESSION_EXPIRED", + "message": f"Session expired for {normalized_service}. Please re-authenticate." + }, status=401) + + try: + if pre_authenticated_session: + deserialize_session(pre_authenticated_session, service_instance.session) + else: + if not cookies and not credential: + return web.json_response({ + "status": "error", + "error_code": "AUTH_REQUIRED", + "message": f"Authentication required for {normalized_service}" + }, status=401) + service_instance.authenticate(cookies, credential) + except Exception as e: + log.error(f"Authentication failed: {e}") + return web.json_response({ + "status": "error", + "error_code": "AUTH_REQUIRED", + "message": f"Authentication failed for {normalized_service}" + }, status=401) + + # Get titles + titles = service_instance.get_titles() + + if hasattr(titles, "__iter__") and not isinstance(titles, str): + titles_list = list(titles) + else: + titles_list = [titles] if titles else [] + + if not titles_list: + return web.json_response({"status": "error", "message": "No titles found"}, status=404) + + # Handle episode filtering (wanted parameter) + wanted_param = data.get("wanted") + season = data.get("season") + episode = data.get("episode") + target_title = None + + if wanted_param or (season is not None and episode is not None): + # Filter to matching episode + wanted = None + if wanted_param: + from unshackle.core.utils.click_types import SeasonRange + try: + season_range = SeasonRange() + wanted = season_range.parse_tokens(wanted_param) + except Exception: + pass + elif season is not None and episode is not None: + wanted = [f"{season}x{episode}"] + + if wanted: + for t in titles_list: + if isinstance(t, Episode): + episode_key = f"{t.season}x{t.number}" + if episode_key in wanted: + target_title = t + break + + if not target_title: + target_title = titles_list[0] + + # Now we need to get the manifest URL + # This is service-specific, so we call get_tracks but extract manifest info + + # Call get_tracks to populate playback_data + try: + _ = service_instance.get_tracks(target_title) + except Exception as e: + log.warning(f"get_tracks failed, trying to extract manifest anyway: {e}") + + # Extract manifest URL from service's playback_data + manifest_url = None + manifest_type = "hls" # Default + playback_data = {} + + # Check for playback_data (DSNP, HMAX, etc.) + if hasattr(service_instance, "playback_data"): + title_id = target_title.id if hasattr(target_title, "id") else str(target_title) + if title_id in service_instance.playback_data: + playback_data = service_instance.playback_data[title_id] + + # Try to extract manifest URL from common patterns + # Pattern 1: DSNP style - stream.sources[0].complete.url + if "stream" in playback_data and "sources" in playback_data["stream"]: + sources = playback_data["stream"]["sources"] + if sources and "complete" in sources[0]: + manifest_url = sources[0]["complete"].get("url") + + # Pattern 2: Direct manifest_url field + if not manifest_url and "manifest_url" in playback_data: + manifest_url = playback_data["manifest_url"] + + # Pattern 3: url field at top level + if not manifest_url and "url" in playback_data: + manifest_url = playback_data["url"] + + # Check for manifest attribute on service + if not manifest_url and hasattr(service_instance, "manifest"): + manifest_url = service_instance.manifest + + # Check for manifest_url attribute on service + if not manifest_url and hasattr(service_instance, "manifest_url"): + manifest_url = service_instance.manifest_url + + # Detect manifest type from URL + if manifest_url: + if manifest_url.endswith(".mpd") or "dash" in manifest_url.lower(): + manifest_type = "dash" + elif manifest_url.endswith(".m3u8") or manifest_url.endswith(".m3u"): + manifest_type = "hls" + + # Serialize session + session_data = serialize_session(service_instance.session) + + # Serialize title info + title_info = serialize_title(target_title) + + response_data = { + "status": "success", + "title": title_info, + "manifest_url": manifest_url, + "manifest_type": manifest_type, + "playback_data": playback_data, + "session": session_data, + } + + return web.json_response(response_data) + + except Exception: + log.exception("Error getting remote manifest") + return web.json_response({"status": "error", "message": "Internal server error while getting manifest"}, status=500) + + async def remote_get_chapters(request: web.Request) -> web.Response: """ Get chapters from a remote service. diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index a458dd6..d8ce5a4 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -9,8 +9,8 @@ from unshackle.core.api.errors import APIError, APIErrorCode, build_error_respon from unshackle.core.api.handlers import (cancel_download_job_handler, download_handler, get_download_job_handler, list_download_jobs_handler, list_titles_handler, list_tracks_handler) from unshackle.core.api.remote_handlers import (remote_decrypt, remote_get_chapters, remote_get_license, - remote_get_titles, remote_get_tracks, remote_list_services, - remote_search) + remote_get_manifest, remote_get_titles, remote_get_tracks, + remote_list_services, remote_search) from unshackle.core.services import Services from unshackle.core.update_checker import UpdateChecker @@ -738,6 +738,7 @@ def setup_routes(app: web.Application) -> None: app.router.add_post("/api/remote/{service}/search", remote_search) app.router.add_post("/api/remote/{service}/titles", remote_get_titles) app.router.add_post("/api/remote/{service}/tracks", remote_get_tracks) + app.router.add_post("/api/remote/{service}/manifest", remote_get_manifest) app.router.add_post("/api/remote/{service}/chapters", remote_get_chapters) app.router.add_post("/api/remote/{service}/license", remote_get_license) app.router.add_post("/api/remote/{service}/decrypt", remote_decrypt) @@ -771,6 +772,7 @@ def setup_swagger(app: web.Application) -> None: web.post("/api/remote/{service}/search", remote_search), web.post("/api/remote/{service}/titles", remote_get_titles), web.post("/api/remote/{service}/tracks", remote_get_tracks), + web.post("/api/remote/{service}/manifest", remote_get_manifest), web.post("/api/remote/{service}/chapters", remote_get_chapters), web.post("/api/remote/{service}/license", remote_get_license), web.post("/api/remote/{service}/decrypt", remote_decrypt), diff --git a/unshackle/core/config.py b/unshackle/core/config.py index 19545c2..153d7ce 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -105,8 +105,6 @@ class Config: self.debug: bool = kwargs.get("debug", False) self.debug_keys: bool = kwargs.get("debug_keys", False) - self.remote_services: list[dict] = kwargs.get("remote_services") or [] - @classmethod def from_yaml(cls, path: Path) -> Config: if not path.exists(): diff --git a/unshackle/core/crypto.py b/unshackle/core/crypto.py deleted file mode 100644 index 4bba793..0000000 --- a/unshackle/core/crypto.py +++ /dev/null @@ -1,279 +0,0 @@ -"""Cryptographic utilities for secure remote service authentication.""" - -import base64 -import json -import logging -from pathlib import Path -from typing import Any, Dict, Optional, Tuple - -try: - from nacl.public import Box, PrivateKey, PublicKey - - NACL_AVAILABLE = True -except ImportError: - NACL_AVAILABLE = False - -log = logging.getLogger("crypto") - - -class CryptoError(Exception): - """Cryptographic operation error.""" - - pass - - -class ServerKeyPair: - """ - Server-side key pair for secure remote authentication. - - Uses NaCl (libsodium) for public key cryptography. - The server generates a key pair and shares the public key with clients. - Clients encrypt sensitive data with the public key, which only the server can decrypt. - """ - - def __init__(self, private_key: Optional[PrivateKey] = None): - """ - Initialize server key pair. - - Args: - private_key: Existing private key, or None to generate new - """ - if not NACL_AVAILABLE: - raise CryptoError("PyNaCl is not installed. Install with: pip install pynacl") - - self.private_key = private_key or PrivateKey.generate() - self.public_key = self.private_key.public_key - - def get_public_key_b64(self) -> str: - """ - Get base64-encoded public key for sharing with clients. - - Returns: - Base64-encoded public key - """ - return base64.b64encode(bytes(self.public_key)).decode("utf-8") - - def decrypt_message(self, encrypted_message: str, client_public_key_b64: str) -> Dict[str, Any]: - """ - Decrypt a message from a client. - - Args: - encrypted_message: Base64-encoded encrypted message - client_public_key_b64: Base64-encoded client public key - - Returns: - Decrypted message as dictionary - """ - try: - # Decode keys - client_public_key = PublicKey(base64.b64decode(client_public_key_b64)) - encrypted_data = base64.b64decode(encrypted_message) - - # Create box for decryption - box = Box(self.private_key, client_public_key) - - # Decrypt - decrypted = box.decrypt(encrypted_data) - return json.loads(decrypted.decode("utf-8")) - - except Exception as e: - log.error(f"Decryption failed: {e}") - raise CryptoError(f"Failed to decrypt message: {e}") - - def save_to_file(self, path: Path) -> None: - """ - Save private key to file. - - Args: - path: Path to save the key - """ - path.parent.mkdir(parents=True, exist_ok=True) - key_data = { - "private_key": base64.b64encode(bytes(self.private_key)).decode("utf-8"), - "public_key": self.get_public_key_b64(), - } - path.write_text(json.dumps(key_data, indent=2), encoding="utf-8") - log.info(f"Server key pair saved to {path}") - - @classmethod - def load_from_file(cls, path: Path) -> "ServerKeyPair": - """ - Load private key from file. - - Args: - path: Path to load the key from - - Returns: - ServerKeyPair instance - """ - if not path.exists(): - raise CryptoError(f"Key file not found: {path}") - - try: - key_data = json.loads(path.read_text(encoding="utf-8")) - private_key_bytes = base64.b64decode(key_data["private_key"]) - private_key = PrivateKey(private_key_bytes) - log.info(f"Server key pair loaded from {path}") - return cls(private_key) - except Exception as e: - raise CryptoError(f"Failed to load key from {path}: {e}") - - -class ClientCrypto: - """ - Client-side cryptography for secure remote authentication. - - Generates ephemeral key pairs and encrypts sensitive data for the server. - """ - - def __init__(self): - """Initialize client crypto with ephemeral key pair.""" - if not NACL_AVAILABLE: - raise CryptoError("PyNaCl is not installed. Install with: pip install pynacl") - - # Generate ephemeral key pair for this session - self.private_key = PrivateKey.generate() - self.public_key = self.private_key.public_key - - def get_public_key_b64(self) -> str: - """ - Get base64-encoded public key for sending to server. - - Returns: - Base64-encoded public key - """ - return base64.b64encode(bytes(self.public_key)).decode("utf-8") - - def encrypt_credentials( - self, credentials: Dict[str, Any], server_public_key_b64: str - ) -> Tuple[str, str]: - """ - Encrypt credentials for the server. - - Args: - credentials: Dictionary containing sensitive data (username, password, cookies, etc.) - server_public_key_b64: Base64-encoded server public key - - Returns: - Tuple of (encrypted_message_b64, client_public_key_b64) - """ - try: - # Decode server public key - server_public_key = PublicKey(base64.b64decode(server_public_key_b64)) - - # Create box for encryption - box = Box(self.private_key, server_public_key) - - # Encrypt - message = json.dumps(credentials).encode("utf-8") - encrypted = box.encrypt(message) - - # Return base64-encoded encrypted message and client public key - encrypted_b64 = base64.b64encode(encrypted).decode("utf-8") - client_public_key_b64 = self.get_public_key_b64() - - return encrypted_b64, client_public_key_b64 - - except Exception as e: - log.error(f"Encryption failed: {e}") - raise CryptoError(f"Failed to encrypt credentials: {e}") - - -def encrypt_credential_data( - username: Optional[str], password: Optional[str], cookies: Optional[str], server_public_key_b64: str -) -> Tuple[str, str]: - """ - Helper function to encrypt credential data. - - Args: - username: Username or None - password: Password or None - cookies: Cookie file content or None - server_public_key_b64: Server's public key - - Returns: - Tuple of (encrypted_data_b64, client_public_key_b64) - """ - client_crypto = ClientCrypto() - - credentials = {} - if username and password: - credentials["username"] = username - credentials["password"] = password - if cookies: - credentials["cookies"] = cookies - - return client_crypto.encrypt_credentials(credentials, server_public_key_b64) - - -def decrypt_credential_data(encrypted_data_b64: str, client_public_key_b64: str, server_keypair: ServerKeyPair) -> Dict[str, Any]: - """ - Helper function to decrypt credential data. - - Args: - encrypted_data_b64: Base64-encoded encrypted data - client_public_key_b64: Client's public key - server_keypair: Server's key pair - - Returns: - Decrypted credentials dictionary - """ - return server_keypair.decrypt_message(encrypted_data_b64, client_public_key_b64) - - -# Session-only authentication helpers - - -def serialize_authenticated_session(service_instance) -> Dict[str, Any]: - """ - Serialize an authenticated service session for remote use. - - This extracts session cookies and headers WITHOUT including credentials. - - Args: - service_instance: Authenticated service instance - - Returns: - Dictionary with session data (cookies, headers) but NO credentials - """ - from unshackle.core.api.session_serializer import serialize_session - - session_data = serialize_session(service_instance.session) - - # Add additional metadata - session_data["authenticated"] = True - session_data["service_tag"] = service_instance.__class__.__name__ - - return session_data - - -def is_session_valid(session_data: Dict[str, Any]) -> bool: - """ - Check if session data appears valid. - - Args: - session_data: Session data dictionary - - Returns: - True if session has cookies or auth headers - """ - if not session_data: - return False - - # Check for cookies or authorization headers - has_cookies = bool(session_data.get("cookies")) - has_auth = "Authorization" in session_data.get("headers", {}) - - return has_cookies or has_auth - - -__all__ = [ - "ServerKeyPair", - "ClientCrypto", - "CryptoError", - "encrypt_credential_data", - "decrypt_credential_data", - "serialize_authenticated_session", - "is_session_valid", - "NACL_AVAILABLE", -] diff --git a/unshackle/core/local_session_cache.py b/unshackle/core/local_session_cache.py deleted file mode 100644 index ae54ade..0000000 --- a/unshackle/core/local_session_cache.py +++ /dev/null @@ -1,274 +0,0 @@ -"""Local client-side session cache for remote services. - -Sessions are stored ONLY on the client machine, never on the server. -The server is completely stateless and receives session data with each request. -""" - -import json -import logging -import time -from pathlib import Path -from typing import Any, Dict, Optional - -log = logging.getLogger("LocalSessionCache") - - -class LocalSessionCache: - """ - Client-side session cache. - - Stores authenticated sessions locally (similar to cookies/cache). - Server never stores sessions - client sends session with each request. - """ - - def __init__(self, cache_dir: Path): - """ - Initialize local session cache. - - Args: - cache_dir: Directory to store session cache files - """ - self.cache_dir = cache_dir - self.cache_dir.mkdir(parents=True, exist_ok=True) - self.sessions_file = cache_dir / "remote_sessions.json" - - # Load existing sessions - self.sessions: Dict[str, Dict[str, Dict[str, Any]]] = self._load_sessions() - - def _load_sessions(self) -> Dict[str, Dict[str, Dict[str, Any]]]: - """Load sessions from cache file.""" - if not self.sessions_file.exists(): - return {} - - try: - data = json.loads(self.sessions_file.read_text(encoding="utf-8")) - log.debug(f"Loaded {len(data)} remote sessions from cache") - return data - except Exception as e: - log.error(f"Failed to load sessions cache: {e}") - return {} - - def _save_sessions(self) -> None: - """Save sessions to cache file.""" - try: - self.sessions_file.write_text( - json.dumps(self.sessions, indent=2, ensure_ascii=False), - encoding="utf-8" - ) - log.debug(f"Saved {len(self.sessions)} remote sessions to cache") - except Exception as e: - log.error(f"Failed to save sessions cache: {e}") - - def store_session( - self, - remote_url: str, - service_tag: str, - profile: str, - session_data: Dict[str, Any] - ) -> None: - """ - Store an authenticated session locally. - - Args: - remote_url: Remote server URL (as key) - service_tag: Service tag - profile: Profile name - session_data: Authenticated session data - """ - # Create nested structure - if remote_url not in self.sessions: - self.sessions[remote_url] = {} - if service_tag not in self.sessions[remote_url]: - self.sessions[remote_url][service_tag] = {} - - # Store session with metadata - self.sessions[remote_url][service_tag][profile] = { - "session_data": session_data, - "cached_at": time.time(), - "service_tag": service_tag, - "profile": profile, - } - - self._save_sessions() - log.info(f"Cached session for {service_tag} (profile: {profile}, remote: {remote_url})") - - def get_session( - self, - remote_url: str, - service_tag: str, - profile: str - ) -> Optional[Dict[str, Any]]: - """ - Retrieve a cached session. - - Args: - remote_url: Remote server URL - service_tag: Service tag - profile: Profile name - - Returns: - Session data or None if not found/expired - """ - try: - session_entry = self.sessions[remote_url][service_tag][profile] - - # Check if expired (24 hours) - age = time.time() - session_entry["cached_at"] - if age > 86400: # 24 hours - log.info(f"Session expired for {service_tag} (age: {age:.0f}s)") - self.delete_session(remote_url, service_tag, profile) - return None - - log.debug(f"Using cached session for {service_tag} (profile: {profile})") - return session_entry["session_data"] - - except KeyError: - log.debug(f"No cached session for {service_tag} (profile: {profile})") - return None - - def has_session( - self, - remote_url: str, - service_tag: str, - profile: str - ) -> bool: - """ - Check if a valid session exists. - - Args: - remote_url: Remote server URL - service_tag: Service tag - profile: Profile name - - Returns: - True if valid session exists - """ - session = self.get_session(remote_url, service_tag, profile) - return session is not None - - def delete_session( - self, - remote_url: str, - service_tag: str, - profile: str - ) -> bool: - """ - Delete a cached session. - - Args: - remote_url: Remote server URL - service_tag: Service tag - profile: Profile name - - Returns: - True if session was deleted - """ - try: - del self.sessions[remote_url][service_tag][profile] - - # Clean up empty nested dicts - if not self.sessions[remote_url][service_tag]: - del self.sessions[remote_url][service_tag] - if not self.sessions[remote_url]: - del self.sessions[remote_url] - - self._save_sessions() - log.info(f"Deleted cached session for {service_tag} (profile: {profile})") - return True - - except KeyError: - return False - - def list_sessions(self, remote_url: Optional[str] = None) -> list[Dict[str, Any]]: - """ - List all cached sessions. - - Args: - remote_url: Optional filter by remote URL - - Returns: - List of session metadata - """ - sessions = [] - - remotes = [remote_url] if remote_url else self.sessions.keys() - - for remote in remotes: - if remote not in self.sessions: - continue - - for service_tag, profiles in self.sessions[remote].items(): - for profile, entry in profiles.items(): - age = time.time() - entry["cached_at"] - - sessions.append({ - "remote_url": remote, - "service_tag": service_tag, - "profile": profile, - "cached_at": entry["cached_at"], - "age_seconds": int(age), - "expired": age > 86400, - "has_cookies": bool(entry["session_data"].get("cookies")), - "has_headers": bool(entry["session_data"].get("headers")), - }) - - return sessions - - def cleanup_expired(self) -> int: - """ - Remove expired sessions (older than 24 hours). - - Returns: - Number of sessions removed - """ - removed = 0 - current_time = time.time() - - for remote_url in list(self.sessions.keys()): - for service_tag in list(self.sessions[remote_url].keys()): - for profile in list(self.sessions[remote_url][service_tag].keys()): - entry = self.sessions[remote_url][service_tag][profile] - age = current_time - entry["cached_at"] - - if age > 86400: # 24 hours - del self.sessions[remote_url][service_tag][profile] - removed += 1 - log.info(f"Removed expired session for {service_tag} (age: {age:.0f}s)") - - # Clean up empty dicts - if not self.sessions[remote_url][service_tag]: - del self.sessions[remote_url][service_tag] - if not self.sessions[remote_url]: - del self.sessions[remote_url] - - if removed > 0: - self._save_sessions() - - return removed - - -# Global instance -_local_session_cache: Optional[LocalSessionCache] = None - - -def get_local_session_cache() -> LocalSessionCache: - """ - Get the global local session cache instance. - - Returns: - LocalSessionCache instance - """ - global _local_session_cache - - if _local_session_cache is None: - from unshackle.core.config import config - cache_dir = config.directories.cache / "remote_sessions" - _local_session_cache = LocalSessionCache(cache_dir) - - # Clean up expired sessions on init - _local_session_cache.cleanup_expired() - - return _local_session_cache - - -__all__ = ["LocalSessionCache", "get_local_session_cache"] diff --git a/unshackle/core/remote_auth.py b/unshackle/core/remote_auth.py deleted file mode 100644 index d852578..0000000 --- a/unshackle/core/remote_auth.py +++ /dev/null @@ -1,276 +0,0 @@ -"""Client-side authentication for remote services. - -This module handles authenticating services locally on the client side, -then sending the authenticated session to the remote server. - -This approach allows: -- Interactive browser-based logins -- 2FA/CAPTCHA handling -- OAuth flows -- Any authentication that requires user interaction - -The server NEVER sees credentials - only authenticated sessions. -""" - -import logging -from typing import Any, Dict, Optional - -import click -import yaml - -from unshackle.core.api.session_serializer import serialize_session -from unshackle.core.config import config -from unshackle.core.console import console -from unshackle.core.credential import Credential -from unshackle.core.local_session_cache import get_local_session_cache -from unshackle.core.services import Services -from unshackle.core.utils.click_types import ContextData -from unshackle.core.utils.collections import merge_dict - -log = logging.getLogger("RemoteAuth") - - -class RemoteAuthenticator: - """ - Handles client-side authentication for remote services. - - Workflow: - 1. Load service locally - 2. Authenticate using local credentials/cookies (can show browser, handle 2FA) - 3. Extract authenticated session - 4. Upload session to remote server - 5. Server uses the pre-authenticated session - """ - - def __init__(self, remote_url: str, api_key: str): - """ - Initialize remote authenticator. - - Args: - remote_url: Base URL of remote server - api_key: API key for remote server - """ - self.remote_url = remote_url.rstrip("/") - self.api_key = api_key - - def authenticate_service_locally( - self, service_tag: str, profile: Optional[str] = None, force_reauth: bool = False - ) -> Dict[str, Any]: - """ - Authenticate a service locally and extract the session. - - This runs the service authentication on the CLIENT side where browsers, - 2FA, and interactive prompts can work. - - Args: - service_tag: Service to authenticate (e.g., "DSNP", "NF") - profile: Optional profile to use for credentials - force_reauth: Force re-authentication even if session exists - - Returns: - Serialized session data - - Raises: - ValueError: If service not found or authentication fails - """ - console.print(f"[cyan]Authenticating {service_tag} locally...[/cyan]") - - # Validate service exists - if service_tag not in Services.get_tags(): - raise ValueError(f"Service {service_tag} not found locally") - - # Load service - service_module = Services.load(service_tag) - - # Load service config - service_config_path = Services.get_path(service_tag) / config.filenames.config - if service_config_path.exists(): - service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8")) - else: - service_config = {} - merge_dict(config.services.get(service_tag), service_config) - - # Create Click context - @click.command() - @click.pass_context - def dummy_command(ctx: click.Context) -> None: - pass - - ctx = click.Context(dummy_command) - ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=[], profile=profile) - - # Create service instance - try: - # Get service initialization parameters - import inspect - - service_init_params = inspect.signature(service_module.__init__).parameters - service_kwargs = {} - - # Extract defaults from click command - if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"): - for param in service_module.cli.params: - if hasattr(param, "name") and param.name not in service_kwargs: - if hasattr(param, "default") and param.default is not None: - service_kwargs[param.name] = param.default - - # Filter to only valid parameters - filtered_kwargs = {k: v for k, v in service_kwargs.items() if k in service_init_params} - - # Create service instance - service_instance = service_module(ctx, **filtered_kwargs) - - # Get credentials and cookies - cookies = self._get_cookie_jar(service_tag, profile) - credential = self._get_credentials(service_tag, profile) - - # Authenticate the service - console.print("[yellow]Authenticating... (this may show browser or prompts)[/yellow]") - service_instance.authenticate(cookies=cookies, credential=credential) - - # Serialize the authenticated session - session_data = serialize_session(service_instance.session) - - # Add metadata - session_data["service_tag"] = service_tag - session_data["profile"] = profile - session_data["authenticated"] = True - - console.print(f"[green]✓ {service_tag} authenticated successfully![/green]") - log.info(f"Authenticated {service_tag} (profile: {profile or 'default'})") - - return session_data - - except Exception as e: - console.print(f"[red]✗ Authentication failed: {e}[/red]") - log.error(f"Failed to authenticate {service_tag}: {e}") - raise ValueError(f"Authentication failed for {service_tag}: {e}") - - def save_session_locally(self, session_data: Dict[str, Any]) -> bool: - """ - Save authenticated session to local cache. - - The session is stored only on the client machine, never on the server. - The server is completely stateless. - - Args: - session_data: Serialized session data - - Returns: - True if save successful - """ - service_tag = session_data.get("service_tag") - profile = session_data.get("profile", "default") - - console.print("[cyan]Saving session to local cache...[/cyan]") - - try: - # Get local session cache - cache = get_local_session_cache() - - # Store session locally - cache.store_session( - remote_url=self.remote_url, - service_tag=service_tag, - profile=profile, - session_data=session_data - ) - - console.print("[green]✓ Session saved locally![/green]") - log.info(f"Saved session for {service_tag} (profile: {profile}) to local cache") - return True - - except Exception as e: - console.print(f"[red]✗ Save failed: {e}[/red]") - log.error(f"Failed to save session locally: {e}") - return False - - def authenticate_and_save(self, service_tag: str, profile: Optional[str] = None) -> bool: - """ - Authenticate locally and save session to local cache in one step. - - Args: - service_tag: Service to authenticate - profile: Optional profile - - Returns: - True if successful - """ - try: - # Authenticate locally - session_data = self.authenticate_service_locally(service_tag, profile) - - # Save to local cache - return self.save_session_locally(session_data) - - except Exception as e: - console.print(f"[red]Authentication and save failed: {e}[/red]") - return False - - def check_local_session_status(self, service_tag: str, profile: Optional[str] = None) -> Dict[str, Any]: - """ - Check if a session exists in local cache. - - Args: - service_tag: Service tag - profile: Optional profile - - Returns: - Session status info - """ - try: - cache = get_local_session_cache() - session_data = cache.get_session(self.remote_url, service_tag, profile or "default") - - if session_data: - # Get metadata - sessions = cache.list_sessions(self.remote_url) - for session in sessions: - if session["service_tag"] == service_tag and session["profile"] == (profile or "default"): - return { - "status": "success", - "exists": True, - "session_info": session - } - - return { - "status": "success", - "exists": False, - "message": f"No session found for {service_tag} (profile: {profile or 'default'})" - } - - except Exception as e: - log.error(f"Failed to check session status: {e}") - return {"status": "error", "message": "Failed to check session status"} - - def _get_cookie_jar(self, service_tag: str, profile: Optional[str]): - """Get cookie jar for service and profile.""" - from unshackle.commands.dl import dl - - return dl.get_cookie_jar(service_tag, profile) - - def _get_credentials(self, service_tag: str, profile: Optional[str]) -> Optional[Credential]: - """Get credentials for service and profile.""" - from unshackle.commands.dl import dl - - return dl.get_credentials(service_tag, profile) - - -def authenticate_remote_service(remote_url: str, api_key: str, service_tag: str, profile: Optional[str] = None) -> bool: - """ - Helper function to authenticate a remote service. - - Args: - remote_url: Remote server URL - api_key: API key - service_tag: Service to authenticate - profile: Optional profile - - Returns: - True if successful - """ - authenticator = RemoteAuthenticator(remote_url, api_key) - return authenticator.authenticate_and_save(service_tag, profile) - - -__all__ = ["RemoteAuthenticator", "authenticate_remote_service"] diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py deleted file mode 100644 index 23d178c..0000000 --- a/unshackle/core/remote_service.py +++ /dev/null @@ -1,593 +0,0 @@ -"""Remote service implementation for connecting to remote unshackle servers.""" - -import logging -import time -from collections.abc import Generator -from http.cookiejar import CookieJar -from typing import Any, Dict, Optional, Union - -import click -import requests -from rich.padding import Padding -from rich.rule import Rule - -from unshackle.core.api.session_serializer import deserialize_session -from unshackle.core.console import console -from unshackle.core.credential import Credential -from unshackle.core.local_session_cache import get_local_session_cache -from unshackle.core.search_result import SearchResult -from unshackle.core.titles import Episode, Movie, Movies, Series -from unshackle.core.tracks import Chapter, Chapters, Tracks -from unshackle.core.tracks.audio import Audio -from unshackle.core.tracks.subtitle import Subtitle -from unshackle.core.tracks.video import Video - - -class RemoteService: - """ - Remote Service wrapper that connects to a remote unshackle server. - - This class mimics the Service interface but delegates all operations - to a remote unshackle server via API calls. It receives session data - from the remote server which is then used locally for downloading. - """ - - ALIASES: tuple[str, ...] = () - GEOFENCE: tuple[str, ...] = () - - def __init__( - self, - ctx: click.Context, - remote_url: str, - api_key: str, - service_tag: str, - service_metadata: Dict[str, Any], - **kwargs, - ): - """ - Initialize remote service. - - Args: - ctx: Click context - remote_url: Base URL of the remote unshackle server - api_key: API key for authentication - service_tag: The service tag on the remote server (e.g., "DSNP") - service_metadata: Metadata about the service from remote discovery - **kwargs: Additional service-specific parameters - """ - console.print(Padding(Rule(f"[rule.text]Remote Service: {service_tag}"), (1, 2))) - - self.log = logging.getLogger(f"RemoteService.{service_tag}") - self.remote_url = remote_url.rstrip("/") - self.api_key = api_key - self.service_tag = service_tag - self.service_metadata = service_metadata - self.ctx = ctx - self.kwargs = kwargs - - # Set GEOFENCE and ALIASES from metadata - if "geofence" in service_metadata: - self.GEOFENCE = tuple(service_metadata["geofence"]) - if "aliases" in service_metadata: - self.ALIASES = tuple(service_metadata["aliases"]) - - # Create a session for API calls to the remote server - self.api_session = requests.Session() - self.api_session.headers.update({"X-API-Key": self.api_key, "Content-Type": "application/json"}) - - # This session will receive data from remote for actual downloading - self.session = requests.Session() - - # Store authentication state - self.authenticated = False - self.credential = None - self.cookies_content = None # Raw cookie file content to send to remote - - # Get profile from context if available - self.profile = "default" - if hasattr(ctx, "obj") and hasattr(ctx.obj, "profile"): - self.profile = ctx.obj.profile or "default" - - # Initialize proxy providers for resolving proxy credentials - self._proxy_providers = None - if hasattr(ctx, "obj") and hasattr(ctx.obj, "proxy_providers"): - self._proxy_providers = ctx.obj.proxy_providers - - def _resolve_proxy_locally(self, proxy: str) -> Optional[str]: - """ - Resolve proxy parameter locally using client's proxy providers. - - This allows the client to resolve proxy providers (like NordVPN) and - send the full proxy URI with credentials to the server. - - Args: - proxy: Proxy parameter (e.g., "nordvpn:ca1066", "us2104", or full URI) - - Returns: - Resolved proxy URI with credentials, or None if no_proxy - """ - if not proxy: - return None - - import re - - # If already a full URI, return as-is - if re.match(r"^https?://", proxy): - self.log.debug(f"Using explicit proxy URI: {proxy}") - return proxy - - # Try to resolve using local proxy providers - if self._proxy_providers: - try: - from unshackle.core.api.handlers import resolve_proxy - - resolved = resolve_proxy(proxy, self._proxy_providers) - self.log.info(f"Resolved proxy '{proxy}' to: {resolved}") - return resolved - except Exception as e: - self.log.warning(f"Failed to resolve proxy locally: {e}") - # Fall back to sending proxy parameter as-is for server to resolve - return proxy - else: - self.log.debug(f"No proxy providers available, sending proxy as-is: {proxy}") - return proxy - - def _add_proxy_to_request(self, data: Dict[str, Any]) -> None: - """ - Add resolved proxy information to request data. - - Resolves proxy using local proxy providers and adds to request. - Server will use the resolved proxy URI (with credentials). - - Args: - data: Request data dictionary to modify - """ - if hasattr(self.ctx, "params"): - no_proxy = self.ctx.params.get("no_proxy", False) - proxy_param = self.ctx.params.get("proxy") - - if no_proxy: - data["no_proxy"] = True - elif proxy_param: - # Resolve proxy locally to get credentials - resolved_proxy = self._resolve_proxy_locally(proxy_param) - if resolved_proxy: - data["proxy"] = resolved_proxy - self.log.debug(f"Sending resolved proxy to server: {resolved_proxy}") - - def _make_request(self, endpoint: str, data: Optional[Dict[str, Any]] = None, retry_count: int = 0) -> Dict[str, Any]: - """ - Make an API request to the remote server with retry logic. - - Automatically handles authentication: - 1. Check for cached session - send with request if found - 2. If session expired, re-authenticate automatically - 3. If no session, send credentials (server tries to auth) - 4. If server returns AUTH_REQUIRED, authenticate locally - 5. Retry request with new session - - Args: - endpoint: API endpoint path (e.g., "/api/remote/DSNP/titles") - data: Optional JSON data to send - retry_count: Current retry attempt (for internal use) - - Returns: - Response JSON data - - Raises: - ConnectionError: If the request fails after all retries - """ - url = f"{self.remote_url}{endpoint}" - max_retries = 3 # Max network retries - retry_delays = [2, 4, 8] # Exponential backoff in seconds - - # Ensure data is a dictionary - if data is None: - data = {} - - # Priority 1: Check for pre-authenticated session in local cache - cache = get_local_session_cache() - cached_session = cache.get_session(self.remote_url, self.service_tag, self.profile) - - if cached_session: - # Send pre-authenticated session data (server never stores it) - self.log.debug(f"Using cached session for {self.service_tag}") - data["pre_authenticated_session"] = cached_session - else: - # Priority 2: Fallback to credentials/cookies (old behavior) - # This allows server to authenticate if no local session exists - if self.cookies_content: - data["cookies"] = self.cookies_content - - if self.credential: - data["credential"] = {"username": self.credential.username, "password": self.credential.password} - - try: - if data: - response = self.api_session.post(url, json=data) - else: - response = self.api_session.get(url) - - response.raise_for_status() - result = response.json() - - # Check if session expired - re-authenticate automatically - if result.get("error_code") == "SESSION_EXPIRED": - console.print(f"[yellow]Session expired for {self.service_tag}[/yellow]") - console.print("[cyan]Re-authenticating...[/cyan]") - - # Delete expired session from cache - cache.delete_session(self.remote_url, self.service_tag, self.profile) - - # Perform local authentication - session_data = self._authenticate_locally() - - if session_data: - # Save to cache for future requests - cache.store_session( - remote_url=self.remote_url, - service_tag=self.service_tag, - profile=self.profile, - session_data=session_data - ) - - # Retry request with new session - data["pre_authenticated_session"] = session_data - # Remove old auth data - data.pop("cookies", None) - data.pop("credential", None) - - # Retry the request - response = self.api_session.post(url, json=data) - response.raise_for_status() - result = response.json() - - # Check if server requires authentication - elif result.get("error_code") == "AUTH_REQUIRED" and not cached_session: - console.print(f"[yellow]Authentication required for {self.service_tag}[/yellow]") - console.print("[cyan]Authenticating locally...[/cyan]") - - # Perform local authentication - session_data = self._authenticate_locally() - - if session_data: - # Save to cache for future requests - cache.store_session( - remote_url=self.remote_url, - service_tag=self.service_tag, - profile=self.profile, - session_data=session_data - ) - - # Retry request with authenticated session - data["pre_authenticated_session"] = session_data - # Remove old auth data - data.pop("cookies", None) - data.pop("credential", None) - - # Retry the request - response = self.api_session.post(url, json=data) - response.raise_for_status() - result = response.json() - - # Apply session data if present - if "session" in result: - deserialize_session(result["session"], self.session) - - return result - - except requests.RequestException as e: - # Retry on network errors with exponential backoff - if retry_count < max_retries: - delay = retry_delays[retry_count] - self.log.warning(f"Request failed (attempt {retry_count + 1}/{max_retries + 1}): {e}") - self.log.info(f"Retrying in {delay} seconds...") - time.sleep(delay) - return self._make_request(endpoint, data, retry_count + 1) - else: - self.log.error(f"Remote API request failed after {max_retries + 1} attempts: {e}") - raise ConnectionError(f"Failed to communicate with remote server after {max_retries + 1} attempts: {e}") - - def _authenticate_locally(self) -> Optional[Dict[str, Any]]: - """ - Authenticate the service locally when server requires it. - - This performs interactive authentication (browser, 2FA, etc.) - and returns the authenticated session. - - Returns: - Serialized session data or None if authentication fails - """ - from unshackle.core.remote_auth import RemoteAuthenticator - - try: - authenticator = RemoteAuthenticator(self.remote_url, self.api_key) - session_data = authenticator.authenticate_service_locally(self.service_tag, self.profile) - console.print("[green]✓ Authentication successful![/green]") - return session_data - - except Exception as e: - console.print(f"[red]✗ Authentication failed: {e}[/red]") - self.log.error(f"Local authentication failed: {e}") - return None - - def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: - """ - Prepare authentication data to send to remote service. - - Stores cookies and credentials to send with each API request. - The remote server will use these for authentication. - - Args: - cookies: Cookie jar from local configuration - credential: Credentials from local configuration - """ - self.log.info("Preparing authentication for remote server...") - self.credential = credential - - # Read cookies file content if cookies provided - if cookies and hasattr(cookies, "filename") and cookies.filename: - try: - from pathlib import Path - - cookie_file = Path(cookies.filename) - if cookie_file.exists(): - self.cookies_content = cookie_file.read_text() - self.log.info(f"Loaded cookies from {cookie_file}") - except Exception as e: - self.log.warning(f"Could not read cookie file: {e}") - - self.authenticated = True - self.log.info("Authentication data ready for remote server") - - def search(self, query: Optional[str] = None) -> Generator[SearchResult, None, None]: - """ - Search for content on the remote service. - - Args: - query: Search query string - - Yields: - SearchResult objects - """ - if query is None: - query = self.kwargs.get("query", "") - - self.log.info(f"Searching remote service for: {query}") - - data = {"query": query} - - # Add proxy information (resolved locally with credentials) - self._add_proxy_to_request(data) - - response = self._make_request(f"/api/remote/{self.service_tag}/search", data) - - if response.get("status") == "success" and "results" in response: - for result in response["results"]: - yield SearchResult( - id_=result["id"], - title=result["title"], - description=result.get("description"), - label=result.get("label"), - url=result.get("url"), - ) - - def get_titles(self) -> Union[Movies, Series]: - """ - Get titles from the remote service. - - Returns: - Movies or Series object containing title information - """ - title = self.kwargs.get("title") - - if not title: - raise ValueError("No title provided") - - self.log.info(f"Getting titles from remote service for: {title}") - - data = {"title": title} - - # Add additional parameters - for key, value in self.kwargs.items(): - if key not in ["title"]: - data[key] = value - - # Add proxy information (resolved locally with credentials) - self._add_proxy_to_request(data) - - response = self._make_request(f"/api/remote/{self.service_tag}/titles", data) - - if response.get("status") != "success" or "titles" not in response: - raise ValueError(f"Failed to get titles from remote: {response.get('message', 'Unknown error')}") - - titles_data = response["titles"] - - # Deserialize titles - titles = [] - for title_info in titles_data: - if title_info["type"] == "movie": - titles.append( - Movie( - id_=title_info.get("id", title), - service=self.__class__, - name=title_info["name"], - year=title_info.get("year"), - data=title_info, - ) - ) - elif title_info["type"] == "episode": - titles.append( - Episode( - id_=title_info.get("id", title), - service=self.__class__, - title=title_info.get("series_title", title_info["name"]), - season=title_info.get("season", 0), - number=title_info.get("number", 0), - name=title_info.get("name"), - year=title_info.get("year"), - data=title_info, - ) - ) - - # Return appropriate container - if titles and isinstance(titles[0], Episode): - return Series(titles) - else: - return Movies(titles) - - def get_tracks(self, title: Union[Movie, Episode]) -> Tracks: - """ - Get tracks from the remote service. - - Args: - title: Title object to get tracks for - - Returns: - Tracks object containing video, audio, and subtitle tracks - """ - self.log.info(f"Getting tracks from remote service for: {title}") - - title_input = self.kwargs.get("title") - data = {"title": title_input} - - # Add episode information if applicable - if isinstance(title, Episode): - data["season"] = title.season - data["episode"] = title.number - - # Add additional parameters - for key, value in self.kwargs.items(): - if key not in ["title"]: - data[key] = value - - # Add proxy information (resolved locally with credentials) - self._add_proxy_to_request(data) - - response = self._make_request(f"/api/remote/{self.service_tag}/tracks", data) - - if response.get("status") != "success": - raise ValueError(f"Failed to get tracks from remote: {response.get('message', 'Unknown error')}") - - # Handle multiple episodes response - if "episodes" in response: - # For multiple episodes, return tracks for the matching title - for episode_data in response["episodes"]: - episode_title = episode_data["title"] - if ( - isinstance(title, Episode) - and episode_title.get("season") == title.season - and episode_title.get("number") == title.number - ): - return self._deserialize_tracks(episode_data, title) - - raise ValueError(f"Could not find tracks for {title.season}x{title.number} in remote response") - - # Single title response - return self._deserialize_tracks(response, title) - - def _deserialize_tracks(self, data: Dict[str, Any], title: Union[Movie, Episode]) -> Tracks: - """ - Deserialize tracks from API response. - - Args: - data: Track data from API - title: Title object these tracks belong to - - Returns: - Tracks object - """ - tracks = Tracks() - - # Deserialize video tracks - for video_data in data.get("video", []): - video = Video( - id_=video_data["id"], - url="", # URL will be populated during download from manifests - codec=Video.Codec[video_data["codec"]], - bitrate=video_data.get("bitrate", 0) * 1000 if video_data.get("bitrate") else None, - width=video_data.get("width"), - height=video_data.get("height"), - fps=video_data.get("fps"), - range_=Video.Range[video_data["range"]] if video_data.get("range") else None, - language=video_data.get("language"), - drm=video_data.get("drm"), - ) - tracks.add(video) - - # Deserialize audio tracks - for audio_data in data.get("audio", []): - audio = Audio( - id_=audio_data["id"], - url="", # URL will be populated during download - codec=Audio.Codec[audio_data["codec"]], - bitrate=audio_data.get("bitrate", 0) * 1000 if audio_data.get("bitrate") else None, - channels=audio_data.get("channels"), - language=audio_data.get("language"), - descriptive=audio_data.get("descriptive", False), - drm=audio_data.get("drm"), - ) - if audio_data.get("atmos"): - audio.atmos = True - tracks.add(audio) - - # Deserialize subtitle tracks - for subtitle_data in data.get("subtitles", []): - subtitle = Subtitle( - id_=subtitle_data["id"], - url="", # URL will be populated during download - codec=Subtitle.Codec[subtitle_data["codec"]], - language=subtitle_data.get("language"), - forced=subtitle_data.get("forced", False), - sdh=subtitle_data.get("sdh", False), - cc=subtitle_data.get("cc", False), - ) - tracks.add(subtitle) - - return tracks - - def get_chapters(self, title: Union[Movie, Episode]) -> Chapters: - """ - Get chapters from the remote service. - - Args: - title: Title object to get chapters for - - Returns: - Chapters object - """ - self.log.info(f"Getting chapters from remote service for: {title}") - - title_input = self.kwargs.get("title") - data = {"title": title_input} - - # Add episode information if applicable - if isinstance(title, Episode): - data["season"] = title.season - data["episode"] = title.number - - # Add proxy information (resolved locally with credentials) - self._add_proxy_to_request(data) - - response = self._make_request(f"/api/remote/{self.service_tag}/chapters", data) - - if response.get("status") != "success": - self.log.warning(f"Failed to get chapters from remote: {response.get('message', 'Unknown error')}") - return Chapters() - - chapters = Chapters() - for chapter_data in response.get("chapters", []): - chapters.add(Chapter(timestamp=chapter_data["timestamp"], name=chapter_data.get("name"))) - - return chapters - - @staticmethod - def get_session() -> requests.Session: - """ - Create a session for the remote service. - - Returns: - A requests.Session object - """ - session = requests.Session() - return session diff --git a/unshackle/core/remote_services.py b/unshackle/core/remote_services.py deleted file mode 100644 index cca45a3..0000000 --- a/unshackle/core/remote_services.py +++ /dev/null @@ -1,245 +0,0 @@ -"""Remote service discovery and management.""" - -import logging -from pathlib import Path -from typing import Any, Dict, List, Optional - -import requests - -from unshackle.core.config import config -from unshackle.core.remote_service import RemoteService - -log = logging.getLogger("RemoteServices") - - -class RemoteServiceManager: - """ - Manages discovery and registration of remote services. - - This class connects to configured remote unshackle servers, - discovers available services, and creates RemoteService instances - that can be used like local services. - """ - - def __init__(self): - """Initialize the remote service manager.""" - self.remote_services: Dict[str, type] = {} - self.remote_configs: List[Dict[str, Any]] = [] - - def discover_services(self) -> None: - """ - Discover services from all configured remote servers. - - Reads the remote_services configuration, connects to each server, - retrieves available services, and creates RemoteService classes - for each discovered service. - """ - if not config.remote_services: - log.debug("No remote services configured") - return - - log.info(f"Discovering services from {len(config.remote_services)} remote server(s)...") - - for remote_config in config.remote_services: - try: - self._discover_from_server(remote_config) - except Exception as e: - log.error(f"Failed to discover services from {remote_config.get('url')}: {e}") - continue - - log.info(f"Discovered {len(self.remote_services)} remote service(s)") - - def _discover_from_server(self, remote_config: Dict[str, Any]) -> None: - """ - Discover services from a single remote server. - - Args: - remote_config: Configuration for the remote server - (must contain 'url' and 'api_key') - """ - url = remote_config.get("url", "").rstrip("/") - api_key = remote_config.get("api_key", "") - server_name = remote_config.get("name", url) - - if not url: - log.warning("Remote service configuration missing 'url', skipping") - return - - if not api_key: - log.warning(f"Remote service {url} missing 'api_key', skipping") - return - - log.info(f"Connecting to remote server: {server_name}") - - try: - # Query the remote server for available services - response = requests.get( - f"{url}/api/remote/services", - headers={"X-API-Key": api_key, "Content-Type": "application/json"}, - timeout=10, - ) - - response.raise_for_status() - data = response.json() - - if data.get("status") != "success" or "services" not in data: - log.error(f"Invalid response from {url}: {data}") - return - - services = data["services"] - log.info(f"Found {len(services)} service(s) on {server_name}") - - # Create RemoteService classes for each service - for service_info in services: - self._register_remote_service(url, api_key, service_info, server_name) - - except requests.RequestException as e: - log.error(f"Failed to connect to remote server {url}: {e}") - raise - - def _register_remote_service( - self, remote_url: str, api_key: str, service_info: Dict[str, Any], server_name: str - ) -> None: - """ - Register a remote service as a local service class. - - Args: - remote_url: Base URL of the remote server - api_key: API key for authentication - service_info: Service metadata from the remote server - server_name: Friendly name of the remote server - """ - service_tag = service_info.get("tag") - if not service_tag: - log.warning(f"Service info missing 'tag': {service_info}") - return - - # Create a unique tag for the remote service - # Use "remote_" prefix to distinguish from local services - remote_tag = f"remote_{service_tag}" - - # Check if this remote service is already registered - if remote_tag in self.remote_services: - log.debug(f"Remote service {remote_tag} already registered, skipping") - return - - log.info(f"Registering remote service: {remote_tag} from {server_name}") - - # Create a dynamic class that inherits from RemoteService - # This allows us to create instances with the cli() method for Click integration - class DynamicRemoteService(RemoteService): - """Dynamically created remote service class.""" - - def __init__(self, ctx, **kwargs): - super().__init__( - ctx=ctx, - remote_url=remote_url, - api_key=api_key, - service_tag=service_tag, - service_metadata=service_info, - **kwargs, - ) - - @staticmethod - def cli(): - """CLI method for Click integration.""" - import click - - # Create a dynamic Click command for this service - @click.command( - name=remote_tag, - short_help=f"Remote: {service_info.get('help', service_tag)}", - help=service_info.get("help", f"Remote service for {service_tag}"), - ) - @click.argument("title", type=str, required=False) - @click.option("-q", "--query", type=str, help="Search query") - @click.pass_context - def remote_service_cli(ctx, title=None, query=None, **kwargs): - # Combine title and kwargs - params = {**kwargs} - if title: - params["title"] = title - if query: - params["query"] = query - - return DynamicRemoteService(ctx, **params) - - return remote_service_cli - - # Set class name for better debugging - DynamicRemoteService.__name__ = remote_tag - DynamicRemoteService.__module__ = "unshackle.remote_services" - - # Set GEOFENCE and ALIASES - if "geofence" in service_info: - DynamicRemoteService.GEOFENCE = tuple(service_info["geofence"]) - if "aliases" in service_info: - # Add "remote_" prefix to aliases too - DynamicRemoteService.ALIASES = tuple(f"remote_{alias}" for alias in service_info["aliases"]) - - # Register the service - self.remote_services[remote_tag] = DynamicRemoteService - - def get_service(self, tag: str) -> Optional[type]: - """ - Get a remote service class by tag. - - Args: - tag: Service tag (e.g., "remote_DSNP") - - Returns: - RemoteService class or None if not found - """ - return self.remote_services.get(tag) - - def get_all_services(self) -> Dict[str, type]: - """ - Get all registered remote services. - - Returns: - Dictionary mapping service tags to RemoteService classes - """ - return self.remote_services.copy() - - def get_service_path(self, tag: str) -> Optional[Path]: - """ - Get the path for a remote service. - - Remote services don't have local paths, so this returns None. - This method exists for compatibility with the Services interface. - - Args: - tag: Service tag - - Returns: - None (remote services have no local path) - """ - return None - - -# Global instance -_remote_service_manager: Optional[RemoteServiceManager] = None - - -def get_remote_service_manager() -> RemoteServiceManager: - """ - Get the global RemoteServiceManager instance. - - Creates the instance on first call and discovers services. - - Returns: - RemoteServiceManager instance - """ - global _remote_service_manager - - if _remote_service_manager is None: - _remote_service_manager = RemoteServiceManager() - try: - _remote_service_manager.discover_services() - except Exception as e: - log.error(f"Failed to discover remote services: {e}") - - return _remote_service_manager - - -__all__ = ("RemoteServiceManager", "get_remote_service_manager") diff --git a/unshackle/core/services.py b/unshackle/core/services.py index 97f64bf..14b7dc9 100644 --- a/unshackle/core/services.py +++ b/unshackle/core/services.py @@ -25,17 +25,6 @@ class Services(click.MultiCommand): # Click-specific methods - @staticmethod - def _get_remote_services(): - """Get remote services from the manager (lazy import to avoid circular dependency).""" - try: - from unshackle.core.remote_services import get_remote_service_manager - - manager = get_remote_service_manager() - return manager.get_all_services() - except Exception: - return {} - def list_commands(self, ctx: click.Context) -> list[str]: """Returns a list of all available Services as command names for Click.""" return Services.get_tags() @@ -62,25 +51,14 @@ class Services(click.MultiCommand): @staticmethod def get_tags() -> list[str]: - """Returns a list of service tags from all available Services (local + remote).""" - local_tags = [x.parent.stem for x in _SERVICES] - remote_services = Services._get_remote_services() - remote_tags = list(remote_services.keys()) - return local_tags + remote_tags + """Returns a list of service tags from all available Services.""" + return [x.parent.stem for x in _SERVICES] @staticmethod def get_path(name: str) -> Path: """Get the directory path of a command.""" tag = Services.get_tag(name) - # Check if it's a remote service - remote_services = Services._get_remote_services() - if tag in remote_services: - # Remote services don't have local paths - # Return a dummy path or raise an appropriate error - # For now, we'll raise KeyError to indicate no path exists - raise KeyError(f"Remote service '{tag}' has no local path") - for service in _SERVICES: if service.parent.stem == tag: return service.parent @@ -96,36 +74,20 @@ class Services(click.MultiCommand): original_value = value value = value.lower() - # Check local services for path in _SERVICES: tag = path.parent.stem if value in (tag.lower(), *_ALIASES.get(tag, [])): return tag - # Check remote services - remote_services = Services._get_remote_services() - for tag, service_class in remote_services.items(): - if value == tag.lower(): - return tag - if hasattr(service_class, "ALIASES"): - if value in (alias.lower() for alias in service_class.ALIASES): - return tag - return original_value @staticmethod def load(tag: str) -> Service: - """Load a Service module by Service tag (local or remote).""" - # Check local services first + """Load a Service module by Service tag.""" module = _MODULES.get(tag) if module: return module - # Check remote services - remote_services = Services._get_remote_services() - if tag in remote_services: - return remote_services[tag] - raise KeyError(f"There is no Service added by the Tag '{tag}'") From 98d579dc9b54df98e3d339c5066eda1bdd4fef80 Mon Sep 17 00:00:00 2001 From: Andy Date: Mon, 26 Jan 2026 00:48:20 -0700 Subject: [PATCH 17/38] feat(serve): add PlayReady CDM support alongside Widevine --- unshackle/commands/serve.py | 153 ++++++++++++++++++++++--------- unshackle/unshackle-example.yaml | 10 +- 2 files changed, 115 insertions(+), 48 deletions(-) diff --git a/unshackle/commands/serve.py b/unshackle/commands/serve.py index b510350..d7e5d4d 100644 --- a/unshackle/commands/serve.py +++ b/unshackle/commands/serve.py @@ -11,12 +11,17 @@ from unshackle.core.constants import context_settings @click.command( - short_help="Serve your Local Widevine Devices and REST API for Remote Access.", context_settings=context_settings + short_help="Serve your Local Widevine/PlayReady Devices and REST API for Remote Access.", + context_settings=context_settings, ) -@click.option("-h", "--host", type=str, default="0.0.0.0", help="Host to serve from.") +@click.option("-h", "--host", type=str, default="127.0.0.1", help="Host to serve from.") @click.option("-p", "--port", type=int, default=8786, help="Port to serve from.") @click.option("--caddy", is_flag=True, default=False, help="Also serve with Caddy.") -@click.option("--api-only", is_flag=True, default=False, help="Serve only the REST API, not pywidevine CDM.") +@click.option( + "--api-only", is_flag=True, default=False, help="Serve only the REST API, not pywidevine/pyplayready CDM." +) +@click.option("--no-widevine", is_flag=True, default=False, help="Disable Widevine CDM endpoints.") +@click.option("--no-playready", is_flag=True, default=False, help="Disable PlayReady CDM endpoints.") @click.option("--no-key", is_flag=True, default=False, help="Disable API key authentication (allows all requests).") @click.option( "--debug-api", @@ -30,13 +35,24 @@ from unshackle.core.constants import context_settings default=False, help="Enable debug logging for API operations.", ) -def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug_api: bool, debug: bool) -> None: +def serve( + host: str, + port: int, + caddy: bool, + api_only: bool, + no_widevine: bool, + no_playready: bool, + no_key: bool, + debug_api: bool, + debug: bool, +) -> None: """ - Serve your Local Widevine Devices and REST API for Remote Access. + Serve your Local Widevine and PlayReady Devices and REST API for Remote Access. \b - Host as 127.0.0.1 may block remote access even if port-forwarded. - Instead, use 0.0.0.0 and ensure the TCP port you choose is forwarded. + CDM ENDPOINTS: + - Widevine: /{device}/open, /{device}/close/{session_id}, etc. + - PlayReady: /playready/{device}/open, /playready/{device}/close/{session_id}, etc. \b You may serve with Caddy at the same time with --caddy. You can use Caddy @@ -44,39 +60,31 @@ def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug next to the unshackle config. \b - The REST API provides programmatic access to unshackle functionality. - Configure authentication in your config under serve.api_secret and serve.api_keys. - - \b - API KEY TIERS: - Premium API keys can use server-side CDM for decryption. Configure in unshackle.yaml: + DEVICE CONFIGURATION: + WVD files are auto-loaded from the WVDs directory, PRD files from the PRDs directory. + Configure user access in unshackle.yaml: \b serve: api_secret: "your-api-secret" - api_keys: - - key: "basic-user-key" - tier: "basic" - allowed_cdms: [] - - key: "premium-user-key" - tier: "premium" - default_cdm: "chromecdm_2101" - allowed_cdms: ["*"] # or list specific CDMs: ["chromecdm_2101", "chromecdm_2202"] + users: + your-secret-key: + devices: ["device_name"] # Widevine devices + playready_devices: ["device_name"] # PlayReady devices + username: user """ + from pyplayready.remote import serve as pyplayready_serve from pywidevine import serve as pywidevine_serve log = logging.getLogger("serve") - # Configure logging level based on --debug flag if debug: logging.basicConfig(level=logging.DEBUG, format="%(name)s - %(levelname)s - %(message)s") log.info("Debug logging enabled for API operations") else: - # Set API loggers to WARNING to reduce noise unless --debug is used logging.getLogger("api").setLevel(logging.WARNING) logging.getLogger("api.remote").setLevel(logging.WARNING) - # Validate API secret for REST API routes (unless --no-key is used) if not no_key: api_secret = config.serve.get("api_secret") if not api_secret: @@ -90,6 +98,9 @@ def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug if debug_api: log.warning("Running with --debug-api: Error responses will include technical debug information!") + if api_only and (no_widevine or no_playready): + raise click.ClickException("Cannot use --api-only with --no-widevine or --no-playready.") + if caddy: if not binaries.Caddy: raise click.ClickException('Caddy executable "caddy" not found but is required for --caddy.') @@ -104,9 +115,12 @@ def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug config.serve["devices"] = [] config.serve["devices"].extend(list(config.directories.wvds.glob("*.wvd"))) + if not config.serve.get("playready_devices"): + config.serve["playready_devices"] = [] + config.serve["playready_devices"].extend(list(config.directories.prds.glob("*.prd"))) + if api_only: - # API-only mode: serve just the REST API - log.info("Starting REST API server (pywidevine CDM disabled)") + log.info("Starting REST API server (pywidevine/pyplayready CDM disabled)") if no_key: app = web.Application(middlewares=[cors_middleware]) app["config"] = {"users": []} @@ -121,35 +135,84 @@ def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug log.info("(Press CTRL+C to quit)") web.run_app(app, host=host, port=port, print=None) else: - # Integrated mode: serve both pywidevine + REST API - log.info("Starting integrated server (pywidevine CDM + REST API)") + serve_widevine = not no_widevine + serve_playready = not no_playready + + serve_config = dict(config.serve) + wvd_devices = serve_config.get("devices", []) if serve_widevine else [] + prd_devices = serve_config.get("playready_devices", []) if serve_playready else [] + + cdm_parts = [] + if serve_widevine: + cdm_parts.append("pywidevine CDM") + if serve_playready: + cdm_parts.append("pyplayready CDM") + log.info(f"Starting integrated server ({' + '.join(cdm_parts)} + REST API)") + + wvd_device_names = [d.stem if hasattr(d, "stem") else str(d) for d in wvd_devices] + prd_device_names = [d.stem if hasattr(d, "stem") else str(d) for d in prd_devices] + + if not serve_config.get("users") or not isinstance(serve_config["users"], dict): + serve_config["users"] = {} + + if not no_key and api_secret not in serve_config["users"]: + serve_config["users"][api_secret] = { + "devices": wvd_device_names, + "playready_devices": prd_device_names, + "username": "api_user", + } + + for user_key, user_config in serve_config["users"].items(): + if "playready_devices" not in user_config: + user_config["playready_devices"] = prd_device_names - # Create integrated app with both pywidevine and API routes if no_key: app = web.Application(middlewares=[cors_middleware]) - app["config"] = dict(config.serve) - app["config"]["users"] = [] else: app = web.Application(middlewares=[cors_middleware, pywidevine_serve.authentication]) - # Setup config - add API secret to users for authentication - serve_config = dict(config.serve) - if not serve_config.get("users") or not isinstance(serve_config["users"], dict): - serve_config["users"] = {} - if api_secret not in serve_config["users"]: - device_names = [d.stem if hasattr(d, "stem") else str(d) for d in serve_config.get("devices", [])] - serve_config["users"][api_secret] = { - "devices": device_names, - "username": "api_user" - } - app["config"] = serve_config - app.on_startup.append(pywidevine_serve._startup) - app.on_cleanup.append(pywidevine_serve._cleanup) - app.add_routes(pywidevine_serve.routes) + app["config"] = serve_config app["debug_api"] = debug_api + + if serve_widevine: + app.on_startup.append(pywidevine_serve._startup) + app.on_cleanup.append(pywidevine_serve._cleanup) + app.add_routes(pywidevine_serve.routes) + + if serve_playready and prd_devices: + if no_key: + playready_app = web.Application() + else: + playready_app = web.Application(middlewares=[pyplayready_serve.authentication]) + + # PlayReady subapp config maps playready_devices to "devices" for pyplayready compatibility + playready_config = { + "devices": prd_devices, + "users": { + user_key: { + "devices": user_cfg.get("playready_devices", prd_device_names), + "username": user_cfg.get("username", "user"), + } + for user_key, user_cfg in serve_config["users"].items() + } + if not no_key + else [], + } + playready_app["config"] = playready_config + playready_app.on_startup.append(pyplayready_serve._startup) + playready_app.on_cleanup.append(pyplayready_serve._cleanup) + playready_app.add_routes(pyplayready_serve.routes) + + app.add_subapp("/playready", playready_app) + log.info(f"PlayReady CDM endpoints available at http://{host}:{port}/playready/") + elif serve_playready: + log.info("No PlayReady devices found, skipping PlayReady CDM endpoints") + setup_routes(app) setup_swagger(app) + if serve_widevine: + log.info(f"Widevine CDM endpoints available at http://{host}:{port}/{{device}}/open") log.info(f"REST API endpoints available at http://{host}:{port}/api/") log.info(f"Swagger UI available at http://{host}:{port}/api/docs/") log.info("(Press CTRL+C to quit)") diff --git a/unshackle/unshackle-example.yaml b/unshackle/unshackle-example.yaml index 0e25aa6..14a23a9 100644 --- a/unshackle/unshackle-example.yaml +++ b/unshackle/unshackle-example.yaml @@ -365,16 +365,20 @@ subtitle: # Combined with no sub_format setting, ensures subtitles remain in their original format (default: true) preserve_formatting: true -# Configuration for pywidevine's serve functionality +# Configuration for pywidevine and pyplayready's serve functionality serve: api_secret: "your-secret-key-here" users: secret_key_for_user: - devices: + devices: # Widevine devices (WVDs) this user can access - generic_nexus_4464_l3 + playready_devices: # PlayReady devices (PRDs) this user can access + - playready_device_sl3000 username: user - # devices: + # devices: # Widevine device paths (auto-populated from directories.wvds) # - '/path/to/device.wvd' + # playready_devices: # PlayReady device paths (auto-populated from directories.prds) + # - '/path/to/device.prd' # Configuration data for each Service services: From 4b1d938b493803c3a361a01b0f538ca77a65c2f1 Mon Sep 17 00:00:00 2001 From: Andy Date: Mon, 26 Jan 2026 01:08:54 -0700 Subject: [PATCH 18/38] feat(cdm): add remote PlayReady CDM support via pyplayready RemoteCdm --- unshackle/commands/dl.py | 26 ++++++++++++++++++-------- unshackle/unshackle-example.yaml | 9 +++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 6d15f97..06fa306 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -27,6 +27,7 @@ from construct import ConstError from pymediainfo import MediaInfo from pyplayready.cdm import Cdm as PlayReadyCdm from pyplayready.device import Device as PlayReadyDevice +from pyplayready.remote.remotecdm import RemoteCdm as PlayReadyRemoteCdm from pywidevine.cdm import Cdm as WidevineCdm from pywidevine.device import Device from pywidevine.remotecdm import RemoteCdm @@ -2426,14 +2427,23 @@ class dl: return CustomRemoteCDM(service_name=service, vaults=self.vaults, **cdm_api) else: - return RemoteCdm( - device_type=cdm_api["Device Type"], - system_id=cdm_api["System ID"], - security_level=cdm_api["Security Level"], - host=cdm_api["Host"], - secret=cdm_api["Secret"], - device_name=cdm_api["Device Name"], - ) + device_type = cdm_api.get("Device Type", cdm_api.get("device_type", "")) + if str(device_type).upper() == "PLAYREADY": + return PlayReadyRemoteCdm( + security_level=cdm_api.get("Security Level", cdm_api.get("security_level", 3000)), + host=cdm_api.get("Host", cdm_api.get("host")), + secret=cdm_api.get("Secret", cdm_api.get("secret")), + device_name=cdm_api.get("Device Name", cdm_api.get("device_name")), + ) + else: + return RemoteCdm( + device_type=cdm_api["Device Type"], + system_id=cdm_api["System ID"], + security_level=cdm_api["Security Level"], + host=cdm_api["Host"], + secret=cdm_api["Secret"], + device_name=cdm_api["Device Name"], + ) prd_path = config.directories.prds / f"{cdm_name}.prd" if not prd_path.is_file(): diff --git a/unshackle/unshackle-example.yaml b/unshackle/unshackle-example.yaml index 14a23a9..dcd78d4 100644 --- a/unshackle/unshackle-example.yaml +++ b/unshackle/unshackle-example.yaml @@ -264,6 +264,15 @@ remote_cdm: host: "https://keyxtractor.decryptlabs.com" secret: "your_decrypt_labs_api_key_here" + # PyPlayReady RemoteCdm - connects to an unshackle serve instance + - name: "playready_remote" + Device Type: PLAYREADY + System ID: 0 + Security Level: 3000 # 2000 for SL2000, 3000 for SL3000 + Host: "http://127.0.0.1:8786/playready" # Include /playready path + Secret: "your-api-secret-key" + Device Name: "my_prd_device" # Device name on the serve instance + # Key Vaults store your obtained Content Encryption Keys (CEKs) # Use 'no_push: true' to prevent a vault from receiving pushed keys # while still allowing it to provide keys when requested From ab762fc81fa3b7612f5064dd652711bb12c51af0 Mon Sep 17 00:00:00 2001 From: Andy Date: Mon, 26 Jan 2026 22:01:47 -0700 Subject: [PATCH 19/38] fix(serve): correct PlayReady RemoteCDM server validation --- unshackle/commands/dl.py | 13 +++++++++---- unshackle/commands/serve.py | 26 +++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 06fa306..9a97711 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -628,12 +628,17 @@ class dl: "device_type": self.cdm.device_type.name, } else: - self.log.info( - f"Loaded PlayReady CDM: {self.cdm.certificate_chain.get_name()} (L{self.cdm.security_level})" - ) + # Handle both local PlayReady CDM and RemoteCdm (which has certificate_chain=None) + is_remote = self.cdm.certificate_chain is None and hasattr(self.cdm, "device_name") + if is_remote: + cdm_name = self.cdm.device_name + self.log.info(f"Loaded PlayReady Remote CDM: {cdm_name} (L{self.cdm.security_level})") + else: + cdm_name = self.cdm.certificate_chain.get_name() if self.cdm.certificate_chain else "Unknown" + self.log.info(f"Loaded PlayReady CDM: {cdm_name} (L{self.cdm.security_level})") cdm_info = { "type": "PlayReady", - "certificate": self.cdm.certificate_chain.get_name(), + "certificate": cdm_name, "security_level": self.cdm.security_level, } diff --git a/unshackle/commands/serve.py b/unshackle/commands/serve.py index d7e5d4d..9276976 100644 --- a/unshackle/commands/serve.py +++ b/unshackle/commands/serve.py @@ -166,10 +166,26 @@ def serve( if "playready_devices" not in user_config: user_config["playready_devices"] = prd_device_names + def create_serve_authentication(serve_playready_flag: bool): + @web.middleware + async def serve_authentication(request: web.Request, handler) -> web.Response: + if serve_playready_flag and request.path in ("/playready", "/playready/"): + response = await handler(request) + else: + response = await pywidevine_serve.authentication(request, handler) + + if serve_playready_flag and request.path.startswith("/playready"): + from pyplayready import __version__ as pyplayready_version + response.headers["Server"] = f"https://git.gay/ready-dl/pyplayready serve v{pyplayready_version}" + + return response + return serve_authentication + if no_key: app = web.Application(middlewares=[cors_middleware]) else: - app = web.Application(middlewares=[cors_middleware, pywidevine_serve.authentication]) + serve_auth = create_serve_authentication(serve_playready and bool(prd_devices)) + app = web.Application(middlewares=[cors_middleware, serve_auth]) app["config"] = serve_config app["debug_api"] = debug_api @@ -203,6 +219,14 @@ def serve( playready_app.on_cleanup.append(pyplayready_serve._cleanup) playready_app.add_routes(pyplayready_serve.routes) + async def playready_ping(_: web.Request) -> web.Response: + from pyplayready import __version__ as pyplayready_version + response = web.json_response({"message": "OK"}) + response.headers["Server"] = f"https://git.gay/ready-dl/pyplayready serve v{pyplayready_version}" + return response + + app.router.add_route("*", "/playready", playready_ping) + app.add_subapp("/playready", playready_app) log.info(f"PlayReady CDM endpoints available at http://{host}:{port}/playready/") elif serve_playready: From b72a5dd84a33d1740daae3c197d8684858580f54 Mon Sep 17 00:00:00 2001 From: Andy Date: Mon, 26 Jan 2026 22:02:36 -0700 Subject: [PATCH 20/38] fix(n_m3u8dl_re): remove duplicate --write-meta-json argument causing download failures --- unshackle/core/downloaders/n_m3u8dl_re.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/unshackle/core/downloaders/n_m3u8dl_re.py b/unshackle/core/downloaders/n_m3u8dl_re.py index a73502a..d6b7d08 100644 --- a/unshackle/core/downloaders/n_m3u8dl_re.py +++ b/unshackle/core/downloaders/n_m3u8dl_re.py @@ -182,7 +182,6 @@ def build_download_args( "--tmp-dir": output_dir, "--thread-count": thread_count, "--download-retry-count": retry_count, - "--write-meta-json": False, } if FFMPEG: args["--ffmpeg-binary-path"] = str(FFMPEG) @@ -304,14 +303,11 @@ def download( arguments.extend(selection_args) log_file_path: Path | None = None - meta_json_path: Path | None = None if debug_logger: log_file_path = output_dir / f".n_m3u8dl_re_{filename}.log" - meta_json_path = output_dir / f"{filename}.meta.json" arguments.extend([ "--log-file-path", str(log_file_path), "--log-level", "DEBUG", - "--write-meta-json", "true", ]) track_url_display = track.url[:200] + "..." if len(track.url) > 200 else track.url @@ -433,14 +429,6 @@ def download( except Exception: n_m3u8dl_log = "" - # Read meta JSON to see what streams N_m3u8DL-RE parsed - meta_json_content = "" - if meta_json_path and meta_json_path.exists(): - try: - meta_json_content = meta_json_path.read_text(encoding="utf-8", errors="replace") - except Exception: - meta_json_content = "" - debug_logger.log( level="WARNING", operation="downloader_n_m3u8dl_re_no_output", @@ -453,7 +441,6 @@ def download( "selection_args": selection_args, "track_url": track.url[:200] + "..." if len(track.url) > 200 else track.url, "n_m3u8dl_re_log": n_m3u8dl_log, - "n_m3u8dl_re_meta_json": meta_json_content, }, ) @@ -494,11 +481,6 @@ def download( log_file_path.unlink() except Exception: pass - if meta_json_path and meta_json_path.exists(): - try: - meta_json_path.unlink() - except Exception: - pass def n_m3u8dl_re( From a96bc9e4a667edde66478e66a92645ba41df0652 Mon Sep 17 00:00:00 2001 From: Aerglonus Date: Tue, 27 Jan 2026 00:48:03 -0600 Subject: [PATCH 21/38] fix(proxies): Fixes WindscribeVPN server authentication --- unshackle/commands/dl.py | 28 ++++- unshackle/core/proxies/windscribevpn.py | 142 +++++++++++++++--------- unshackle/core/service.py | 14 ++- unshackle/core/utilities.py | 8 +- 4 files changed, 128 insertions(+), 64 deletions(-) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 9a97711..c5b23c6 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -58,11 +58,25 @@ from unshackle.core.titles.episode import Episode from unshackle.core.tracks import Audio, Subtitle, Tracks, Video from unshackle.core.tracks.attachment import Attachment from unshackle.core.tracks.hybrid import Hybrid -from unshackle.core.utilities import (find_font_with_fallbacks, get_debug_logger, get_system_fonts, init_debug_logger, - is_close_match, suggest_font_packages, time_elapsed_since) +from unshackle.core.utilities import ( + find_font_with_fallbacks, + get_debug_logger, + get_system_fonts, + init_debug_logger, + is_close_match, + suggest_font_packages, + time_elapsed_since, +) from unshackle.core.utils import tags -from unshackle.core.utils.click_types import (LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, ContextData, MultipleChoice, - SubtitleCodecChoice, VideoCodecChoice) +from unshackle.core.utils.click_types import ( + LANGUAGE_RANGE, + QUALITY_LIST, + SEASON_RANGE, + ContextData, + MultipleChoice, + SubtitleCodecChoice, + VideoCodecChoice, +) from unshackle.core.utils.collections import merge_dict from unshackle.core.utils.subprocess import ffprobe from unshackle.core.vaults import Vaults @@ -673,8 +687,10 @@ class dl: # requesting proxy from a specific proxy provider requested_provider, proxy = proxy.split(":", maxsplit=1) # Match simple region codes (us, ca, uk1) or provider:region format (nordvpn:ca, windscribe:us) - if re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE) or re.match( - r"^[a-z]+:[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE + if ( + requested_provider + or re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE) + or re.match(r"^[a-z]+:[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE) ): proxy = proxy.lower() status_msg = ( diff --git a/unshackle/core/proxies/windscribevpn.py b/unshackle/core/proxies/windscribevpn.py index d48eeea..a374abc 100644 --- a/unshackle/core/proxies/windscribevpn.py +++ b/unshackle/core/proxies/windscribevpn.py @@ -1,8 +1,10 @@ +import base64 import json -import random import re from typing import Optional +from urllib.parse import quote + import requests from unshackle.core.proxies.proxy import Proxy @@ -14,8 +16,10 @@ class WindscribeVPN(Proxy): Proxy Service using WindscribeVPN Service Credentials. A username and password must be provided. These are Service Credentials, not your Login Credentials. - The Service Credentials can be found here: https://windscribe.com/getconfig/openvpn + The Service Credentials can be found login in through the Windscribe Extension. + Both username and password are Base64 encoded. """ + if not username: raise ValueError("No Username was provided to the WindscribeVPN Proxy Service.") if not password: @@ -24,12 +28,22 @@ class WindscribeVPN(Proxy): if server_map is not None and not isinstance(server_map, dict): raise TypeError(f"Expected server_map to be a dict mapping a region to a hostname, not '{server_map!r}'.") - self.username = username - self.password = password + self.username = self._try_decode(username) + self.password = self._try_decode(password) self.server_map = server_map or {} self.countries = self.get_countries() + @staticmethod + def _try_decode(value: str) -> str: + """ + Attempt to decode a Base64 string, returning original if failed. + """ + try: + return base64.b64decode(value).decode("utf-8") + except Exception: + return value + def __repr__(self) -> str: countries = len(set(x.get("country_code") for x in self.countries if x.get("country_code"))) servers = sum( @@ -44,10 +58,11 @@ class WindscribeVPN(Proxy): def get_proxy(self, query: str) -> Optional[str]: """ Get an HTTPS proxy URI for a WindscribeVPN server. - Supports: - - Country code: "us", "ca", "gb" - - City selection: "us:seattle", "ca:toronto" + - Country code: "us", "ca", "gb" + - City selection: "us:seattle", "ca:toronto" + - Server code: "us-central-096", "uk-london-055" + Note: Windscribes static OpenVPN credentials from the configurator are per server use the extension credentials. """ query = query.lower() city = None @@ -57,69 +72,94 @@ class WindscribeVPN(Proxy): query, city = query.split(":", maxsplit=1) city = city.strip() - # Check server_map for pinned servers (can include city) + safe_username = quote(self.username, safe="") + safe_password = quote(self.password, safe="") + + proxy = f"https://{safe_username}:{safe_password}@" + server_map_key = f"{query}:{city}" if city else query - if server_map_key in self.server_map: - hostname = self.server_map[server_map_key] - elif query in self.server_map and not city: - hostname = self.server_map[query] - else: - if re.match(r"^[a-z]+$", query): - hostname = self.get_random_server(query, city) + try: + if server_map_key in self.server_map: + # Use a forced server from server_map if provided + hostname = f"{self.server_map[server_map_key]}.totallyacdn.com" + elif "-" in query and not city: + # Supports server codes like "windscribe:us-central-096" + hostname = f"{query}.totallyacdn.com" else: - raise ValueError(f"The query provided is unsupported and unrecognized: {query}") + # Query is likely a country code (e.g., "us") or country+city (e.g., "us:seattle") and not in server_map + if re.match(r"^[a-z]+$", query): + hostname = self.get_random_server(query, city) + else: + raise ValueError(f"The query provided is unsupported and unrecognized: {query}") + except ValueError as e: + raise Exception(f"Windscribe Proxy Error: {e}") + if not hostname: + raise Exception(f"Windscribe has no servers for {query!r}") - if not hostname: - return None + return f"{proxy}{hostname}:443" - hostname = hostname.split(':')[0] - return f"https://{self.username}:{self.password}@{hostname}:443" - - def get_random_server(self, country_code: str, city: Optional[str] = None) -> Optional[str]: + def get_random_server(self, country_code: str, city: Optional[str]) -> Optional[str]: """ - Get a random server hostname for a country, optionally filtered by city. - + Get a random server hostname for a country. Args: country_code: The country code (e.g., "us", "ca") city: Optional city name to filter by (case-insensitive) - Returns: - A random hostname from matching servers, or None if none available. + The hostname of a server in the specified country (and city if provided). + + - If city is provided but not found, falls back to any server in the country. + Raise error if no servers are available for the country. """ - for location in self.countries: - if location.get("country_code", "").lower() == country_code.lower(): - hostnames = [] - for group in location.get("groups", []): - # Filter by city if specified - if city: - group_city = group.get("city", "") - if group_city.lower() != city.lower(): - continue - # Collect hostnames from this group - for host in group.get("hosts", []): - if hostname := host.get("hostname"): - hostnames.append(hostname) + country_code = country_code.lower() - if hostnames: - return random.choice(hostnames) - elif city: - # No servers found for the specified city - raise ValueError( - f"No servers found in city '{city}' for country code '{country_code}'. " - "Try a different city or check the city name spelling." - ) + # Find the country entry + location = next( + (c for c in self.countries if c.get("country_code", "").lower() == country_code), + None, + ) - return None + if not location: + raise ValueError(f"No servers found for country code '{country_code}'.") + + all_hosts = [] + city_hosts = [] + + for group in location.get("groups", []): + group_city = group.get("city", "").lower() + + for host in group.get("hosts", []): + entry = { + "hostname": host["hostname"], + "health": host.get("health", float("inf")), + } + all_hosts.append(entry) + + if city and group_city == city.lower(): + city_hosts.append(entry) + + # Prefer city-specific servers if available and select the healthiest + if city_hosts: + return min(city_hosts, key=lambda x: x["health"])["hostname"] + + # Fallback to country-level servers and select the healthiest + if all_hosts: + return min(all_hosts, key=lambda x: x["health"])["hostname"] + + # Country exists but has zero servers + raise ValueError( + f"No servers found in city '{city}' for country code '{country_code}'. Try a different city or check the city name spelling." + ) @staticmethod def get_countries() -> list[dict]: """Get a list of available Countries and their metadata.""" res = requests.get( - url="https://assets.windscribe.com/serverlist/firefox/1/1", + url="https://assets.windscribe.com/serverlist/chrome/1/937dd9fcfba6925d7a9253ab34e655a453719e02", headers={ - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", - "Content-Type": "application/json", + "Host": "assets.windscribe.com", + "Connection": "keep-alive", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", }, ) if not res.ok: diff --git a/unshackle/core/service.py b/unshackle/core/service.py index d39cb55..a5dc143 100644 --- a/unshackle/core/service.py +++ b/unshackle/core/service.py @@ -89,7 +89,9 @@ class Service(metaclass=ABCMeta): proxy = mapped_proxy_uri self.log.info(f"Using mapped proxy from {proxy_provider.__class__.__name__}: {proxy}") else: - self.log.warning(f"Failed to get proxy for mapped value '{mapped_value}', using default") + self.log.warning( + f"Failed to get proxy for mapped value '{mapped_value}', using default" + ) else: self.log.warning(f"Proxy provider '{proxy_provider_name}' not found, using default proxy") else: @@ -140,11 +142,11 @@ class Service(metaclass=ABCMeta): } ) # Always verify proxy IP - proxies can change exit nodes - try: - proxy_ip_info = get_ip_info(self.session) - self.current_region = proxy_ip_info.get("country", "").lower() if proxy_ip_info else None - except Exception as e: - self.log.warning(f"Failed to verify proxy IP: {e}") + proxy_ip_info = get_ip_info(self.session) + if proxy_ip_info: + self.current_region = proxy_ip_info.get("country", "").lower() + else: + self.log.warning("Failed to verify proxy IP, falling back to proxy config for region") # Fallback to extracting region from proxy config self.current_region = get_region_from_proxy(proxy) else: diff --git a/unshackle/core/utilities.py b/unshackle/core/utilities.py index c015459..d85422c 100644 --- a/unshackle/core/utilities.py +++ b/unshackle/core/utilities.py @@ -359,7 +359,13 @@ def get_ip_info(session: Optional[requests.Session] = None) -> dict: If you provide a Requests Session with a Proxy, that proxies IP information is what will be returned. """ - return (session or requests.Session()).get("https://ipinfo.io/json").json() + try: + response = (session or requests.Session()).get("https://ipinfo.io/json", timeout=10) + if response.ok: + return response.json() + except (requests.RequestException, json.JSONDecodeError): + pass + return None def get_cached_ip_info(session: Optional[requests.Session] = None) -> Optional[dict]: From 8c8c9368baa6ad8b74394ab7182375ef57164113 Mon Sep 17 00:00:00 2001 From: Andy Date: Thu, 29 Jan 2026 10:34:03 -0700 Subject: [PATCH 22/38] fix(manifests): correct DRM type selection for remote PlayReady CDMs HLS: Filter segment keys by CDM type during aria2c merge phase to prevent incorrect Widevine selection when using PlayReady-only CDMs. The merge phase now uses filter_keys_for_cdm() before get_supported_key(), matching the pattern used in initial licensing. DASH: Extend PlayReady CDM detection to include remote CDMs with is_playready attribute, not just native PlayReadyCdm instances. This ensures correct DRM extraction order from init_data when using remote PlayReady CDMs. --- unshackle/core/manifests/dash.py | 3 ++- unshackle/core/manifests/hls.py | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/unshackle/core/manifests/dash.py b/unshackle/core/manifests/dash.py index 438351b..fd51557 100644 --- a/unshackle/core/manifests/dash.py +++ b/unshackle/core/manifests/dash.py @@ -468,7 +468,8 @@ class DASH: track.data["dash"]["segment_durations"] = segment_durations if init_data and isinstance(track, (Video, Audio)): - if isinstance(cdm, PlayReadyCdm): + prefers_playready = isinstance(cdm, PlayReadyCdm) or (hasattr(cdm, "is_playready") and cdm.is_playready) + if prefers_playready: try: track.drm = [PlayReady.from_init_data(init_data)] except PlayReady.Exceptions.PSSHNotFound: diff --git a/unshackle/core/manifests/hls.py b/unshackle/core/manifests/hls.py index 85ec245..2f3dd1f 100644 --- a/unshackle/core/manifests/hls.py +++ b/unshackle/core/manifests/hls.py @@ -591,7 +591,11 @@ class HLS: segment_keys = getattr(segment, "keys", None) if segment_keys: - key = HLS.get_supported_key(segment_keys) + if cdm: + cdm_segment_keys = HLS.filter_keys_for_cdm(segment_keys, cdm) + key = HLS.get_supported_key(cdm_segment_keys) if cdm_segment_keys else HLS.get_supported_key(segment_keys) + else: + key = HLS.get_supported_key(segment_keys) if encryption_data and encryption_data[0] != key and i != 0 and segment not in unwanted_segments: decrypt(include_this_segment=False) From 385fcb2752bfbd3718b2a84e2e0906882702f2f0 Mon Sep 17 00:00:00 2001 From: Andy Date: Fri, 30 Jan 2026 15:52:06 +0000 Subject: [PATCH 23/38] Revert "Merge pull request #64 from Aerglonus/dev" This reverts commit 55bc2b16ee98b6c7db5429d65ad24efcd5a286a2, reversing changes made to 8c8c9368baa6ad8b74394ab7182375ef57164113. --- unshackle/commands/dl.py | 28 +---- unshackle/core/proxies/windscribevpn.py | 142 +++++++++--------------- unshackle/core/service.py | 14 +-- unshackle/core/utilities.py | 8 +- 4 files changed, 64 insertions(+), 128 deletions(-) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index c5b23c6..9a97711 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -58,25 +58,11 @@ from unshackle.core.titles.episode import Episode from unshackle.core.tracks import Audio, Subtitle, Tracks, Video from unshackle.core.tracks.attachment import Attachment from unshackle.core.tracks.hybrid import Hybrid -from unshackle.core.utilities import ( - find_font_with_fallbacks, - get_debug_logger, - get_system_fonts, - init_debug_logger, - is_close_match, - suggest_font_packages, - time_elapsed_since, -) +from unshackle.core.utilities import (find_font_with_fallbacks, get_debug_logger, get_system_fonts, init_debug_logger, + is_close_match, suggest_font_packages, time_elapsed_since) from unshackle.core.utils import tags -from unshackle.core.utils.click_types import ( - LANGUAGE_RANGE, - QUALITY_LIST, - SEASON_RANGE, - ContextData, - MultipleChoice, - SubtitleCodecChoice, - VideoCodecChoice, -) +from unshackle.core.utils.click_types import (LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, ContextData, MultipleChoice, + SubtitleCodecChoice, VideoCodecChoice) from unshackle.core.utils.collections import merge_dict from unshackle.core.utils.subprocess import ffprobe from unshackle.core.vaults import Vaults @@ -687,10 +673,8 @@ class dl: # requesting proxy from a specific proxy provider requested_provider, proxy = proxy.split(":", maxsplit=1) # Match simple region codes (us, ca, uk1) or provider:region format (nordvpn:ca, windscribe:us) - if ( - requested_provider - or re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE) - or re.match(r"^[a-z]+:[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE) + if re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE) or re.match( + r"^[a-z]+:[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE ): proxy = proxy.lower() status_msg = ( diff --git a/unshackle/core/proxies/windscribevpn.py b/unshackle/core/proxies/windscribevpn.py index a374abc..d48eeea 100644 --- a/unshackle/core/proxies/windscribevpn.py +++ b/unshackle/core/proxies/windscribevpn.py @@ -1,10 +1,8 @@ -import base64 import json +import random import re from typing import Optional -from urllib.parse import quote - import requests from unshackle.core.proxies.proxy import Proxy @@ -16,10 +14,8 @@ class WindscribeVPN(Proxy): Proxy Service using WindscribeVPN Service Credentials. A username and password must be provided. These are Service Credentials, not your Login Credentials. - The Service Credentials can be found login in through the Windscribe Extension. - Both username and password are Base64 encoded. + The Service Credentials can be found here: https://windscribe.com/getconfig/openvpn """ - if not username: raise ValueError("No Username was provided to the WindscribeVPN Proxy Service.") if not password: @@ -28,22 +24,12 @@ class WindscribeVPN(Proxy): if server_map is not None and not isinstance(server_map, dict): raise TypeError(f"Expected server_map to be a dict mapping a region to a hostname, not '{server_map!r}'.") - self.username = self._try_decode(username) - self.password = self._try_decode(password) + self.username = username + self.password = password self.server_map = server_map or {} self.countries = self.get_countries() - @staticmethod - def _try_decode(value: str) -> str: - """ - Attempt to decode a Base64 string, returning original if failed. - """ - try: - return base64.b64decode(value).decode("utf-8") - except Exception: - return value - def __repr__(self) -> str: countries = len(set(x.get("country_code") for x in self.countries if x.get("country_code"))) servers = sum( @@ -58,11 +44,10 @@ class WindscribeVPN(Proxy): def get_proxy(self, query: str) -> Optional[str]: """ Get an HTTPS proxy URI for a WindscribeVPN server. + Supports: - - Country code: "us", "ca", "gb" - - City selection: "us:seattle", "ca:toronto" - - Server code: "us-central-096", "uk-london-055" - Note: Windscribes static OpenVPN credentials from the configurator are per server use the extension credentials. + - Country code: "us", "ca", "gb" + - City selection: "us:seattle", "ca:toronto" """ query = query.lower() city = None @@ -72,94 +57,69 @@ class WindscribeVPN(Proxy): query, city = query.split(":", maxsplit=1) city = city.strip() - safe_username = quote(self.username, safe="") - safe_password = quote(self.password, safe="") - - proxy = f"https://{safe_username}:{safe_password}@" - + # Check server_map for pinned servers (can include city) server_map_key = f"{query}:{city}" if city else query - try: - if server_map_key in self.server_map: - # Use a forced server from server_map if provided - hostname = f"{self.server_map[server_map_key]}.totallyacdn.com" - elif "-" in query and not city: - # Supports server codes like "windscribe:us-central-096" - hostname = f"{query}.totallyacdn.com" + if server_map_key in self.server_map: + hostname = self.server_map[server_map_key] + elif query in self.server_map and not city: + hostname = self.server_map[query] + else: + if re.match(r"^[a-z]+$", query): + hostname = self.get_random_server(query, city) else: - # Query is likely a country code (e.g., "us") or country+city (e.g., "us:seattle") and not in server_map - if re.match(r"^[a-z]+$", query): - hostname = self.get_random_server(query, city) - else: - raise ValueError(f"The query provided is unsupported and unrecognized: {query}") - except ValueError as e: - raise Exception(f"Windscribe Proxy Error: {e}") - if not hostname: - raise Exception(f"Windscribe has no servers for {query!r}") + raise ValueError(f"The query provided is unsupported and unrecognized: {query}") - return f"{proxy}{hostname}:443" + if not hostname: + return None - def get_random_server(self, country_code: str, city: Optional[str]) -> Optional[str]: + hostname = hostname.split(':')[0] + return f"https://{self.username}:{self.password}@{hostname}:443" + + def get_random_server(self, country_code: str, city: Optional[str] = None) -> Optional[str]: """ - Get a random server hostname for a country. + Get a random server hostname for a country, optionally filtered by city. + Args: country_code: The country code (e.g., "us", "ca") city: Optional city name to filter by (case-insensitive) + Returns: - The hostname of a server in the specified country (and city if provided). - - - If city is provided but not found, falls back to any server in the country. - Raise error if no servers are available for the country. + A random hostname from matching servers, or None if none available. """ + for location in self.countries: + if location.get("country_code", "").lower() == country_code.lower(): + hostnames = [] + for group in location.get("groups", []): + # Filter by city if specified + if city: + group_city = group.get("city", "") + if group_city.lower() != city.lower(): + continue - country_code = country_code.lower() + # Collect hostnames from this group + for host in group.get("hosts", []): + if hostname := host.get("hostname"): + hostnames.append(hostname) - # Find the country entry - location = next( - (c for c in self.countries if c.get("country_code", "").lower() == country_code), - None, - ) + if hostnames: + return random.choice(hostnames) + elif city: + # No servers found for the specified city + raise ValueError( + f"No servers found in city '{city}' for country code '{country_code}'. " + "Try a different city or check the city name spelling." + ) - if not location: - raise ValueError(f"No servers found for country code '{country_code}'.") - - all_hosts = [] - city_hosts = [] - - for group in location.get("groups", []): - group_city = group.get("city", "").lower() - - for host in group.get("hosts", []): - entry = { - "hostname": host["hostname"], - "health": host.get("health", float("inf")), - } - all_hosts.append(entry) - - if city and group_city == city.lower(): - city_hosts.append(entry) - - # Prefer city-specific servers if available and select the healthiest - if city_hosts: - return min(city_hosts, key=lambda x: x["health"])["hostname"] - - # Fallback to country-level servers and select the healthiest - if all_hosts: - return min(all_hosts, key=lambda x: x["health"])["hostname"] - - # Country exists but has zero servers - raise ValueError( - f"No servers found in city '{city}' for country code '{country_code}'. Try a different city or check the city name spelling." - ) + return None @staticmethod def get_countries() -> list[dict]: """Get a list of available Countries and their metadata.""" res = requests.get( - url="https://assets.windscribe.com/serverlist/chrome/1/937dd9fcfba6925d7a9253ab34e655a453719e02", + url="https://assets.windscribe.com/serverlist/firefox/1/1", headers={ - "Host": "assets.windscribe.com", - "Connection": "keep-alive", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "Content-Type": "application/json", }, ) if not res.ok: diff --git a/unshackle/core/service.py b/unshackle/core/service.py index a5dc143..d39cb55 100644 --- a/unshackle/core/service.py +++ b/unshackle/core/service.py @@ -89,9 +89,7 @@ class Service(metaclass=ABCMeta): proxy = mapped_proxy_uri self.log.info(f"Using mapped proxy from {proxy_provider.__class__.__name__}: {proxy}") else: - self.log.warning( - f"Failed to get proxy for mapped value '{mapped_value}', using default" - ) + self.log.warning(f"Failed to get proxy for mapped value '{mapped_value}', using default") else: self.log.warning(f"Proxy provider '{proxy_provider_name}' not found, using default proxy") else: @@ -142,11 +140,11 @@ class Service(metaclass=ABCMeta): } ) # Always verify proxy IP - proxies can change exit nodes - proxy_ip_info = get_ip_info(self.session) - if proxy_ip_info: - self.current_region = proxy_ip_info.get("country", "").lower() - else: - self.log.warning("Failed to verify proxy IP, falling back to proxy config for region") + try: + proxy_ip_info = get_ip_info(self.session) + self.current_region = proxy_ip_info.get("country", "").lower() if proxy_ip_info else None + except Exception as e: + self.log.warning(f"Failed to verify proxy IP: {e}") # Fallback to extracting region from proxy config self.current_region = get_region_from_proxy(proxy) else: diff --git a/unshackle/core/utilities.py b/unshackle/core/utilities.py index d85422c..c015459 100644 --- a/unshackle/core/utilities.py +++ b/unshackle/core/utilities.py @@ -359,13 +359,7 @@ def get_ip_info(session: Optional[requests.Session] = None) -> dict: If you provide a Requests Session with a Proxy, that proxies IP information is what will be returned. """ - try: - response = (session or requests.Session()).get("https://ipinfo.io/json", timeout=10) - if response.ok: - return response.json() - except (requests.RequestException, json.JSONDecodeError): - pass - return None + return (session or requests.Session()).get("https://ipinfo.io/json").json() def get_cached_ip_info(session: Optional[requests.Session] = None) -> Optional[dict]: From e2b65cef4a068a0327a1ddba3b6dd6d675ef3b88 Mon Sep 17 00:00:00 2001 From: Andy Date: Fri, 30 Jan 2026 22:38:46 +0000 Subject: [PATCH 24/38] fix(proxy): remove regional restrictions from WindscribeVPN OpenVPN credentials now work reliably on all regions, not just US, AU, and NZ. Remove the supported_regions check that was blocking other country codes. --- unshackle/core/proxies/windscribevpn.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/unshackle/core/proxies/windscribevpn.py b/unshackle/core/proxies/windscribevpn.py index c8ffd2d..686fc3b 100644 --- a/unshackle/core/proxies/windscribevpn.py +++ b/unshackle/core/proxies/windscribevpn.py @@ -42,19 +42,8 @@ class WindscribeVPN(Proxy): return f"{countries} Countr{['ies', 'y'][countries == 1]} ({servers} Server{['s', ''][servers == 1]})" def get_proxy(self, query: str) -> Optional[str]: - """ - Get an HTTPS proxy URI for a WindscribeVPN server. - - Note: Windscribe's static OpenVPN credentials work reliably on US, AU, and NZ servers. - """ + """Get an HTTPS proxy URI for a WindscribeVPN server.""" query = query.lower() - supported_regions = {"us", "au", "nz"} - - if query not in supported_regions and query not in self.server_map: - raise ValueError( - f"Windscribe proxy does not currently support the '{query.upper()}' region. " - f"Supported regions with reliable credentials: {', '.join(sorted(supported_regions))}. " - ) if query in self.server_map: hostname = self.server_map[query] From c352884c17d7b213abd84f36662adc5923775dd9 Mon Sep 17 00:00:00 2001 From: Andy Date: Fri, 30 Jan 2026 15:52:44 -0700 Subject: [PATCH 25/38] fix(proxy): collect servers from all locations in WindscribeVPN Previously get_random_server only collected servers from the first location matching a country code. Now it collects from all matching locations before selecting randomly. --- unshackle/core/proxies/windscribevpn.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/unshackle/core/proxies/windscribevpn.py b/unshackle/core/proxies/windscribevpn.py index d48eeea..25d5af0 100644 --- a/unshackle/core/proxies/windscribevpn.py +++ b/unshackle/core/proxies/windscribevpn.py @@ -86,9 +86,11 @@ class WindscribeVPN(Proxy): Returns: A random hostname from matching servers, or None if none available. """ + hostnames = [] + + # Collect hostnames from ALL locations matching the country code for location in self.countries: if location.get("country_code", "").lower() == country_code.lower(): - hostnames = [] for group in location.get("groups", []): # Filter by city if specified if city: @@ -101,14 +103,14 @@ class WindscribeVPN(Proxy): if hostname := host.get("hostname"): hostnames.append(hostname) - if hostnames: - return random.choice(hostnames) - elif city: - # No servers found for the specified city - raise ValueError( - f"No servers found in city '{city}' for country code '{country_code}'. " - "Try a different city or check the city name spelling." - ) + if hostnames: + return random.choice(hostnames) + elif city: + # No servers found for the specified city + raise ValueError( + f"No servers found in city '{city}' for country code '{country_code}'. " + "Try a different city or check the city name spelling." + ) return None From 5cf488cb348d186e49eed44894b80360cc0979c0 Mon Sep 17 00:00:00 2001 From: Andy Date: Fri, 30 Jan 2026 16:34:49 -0700 Subject: [PATCH 26/38] docs: add configuration documentation WIP --- docs/ADVANCED_CONFIG.md | 104 +++++++++++ docs/DOWNLOAD_CONFIG.md | 174 +++++++++++++++++ docs/DRM_CONFIG.md | 403 ++++++++++++++++++++++++++++++++++++++++ docs/GLUETUN.md | 90 +++++---- docs/NETWORK_CONFIG.md | 154 +++++++++++++++ docs/OUTPUT_CONFIG.md | 123 ++++++++++++ docs/SERVICE_CONFIG.md | 116 ++++++++++++ docs/SUBTITLE_CONFIG.md | 39 ++++ docs/VPN_PROXY_SETUP.md | 136 -------------- 9 files changed, 1170 insertions(+), 169 deletions(-) create mode 100644 docs/ADVANCED_CONFIG.md create mode 100644 docs/DOWNLOAD_CONFIG.md create mode 100644 docs/DRM_CONFIG.md create mode 100644 docs/NETWORK_CONFIG.md create mode 100644 docs/OUTPUT_CONFIG.md create mode 100644 docs/SERVICE_CONFIG.md create mode 100644 docs/SUBTITLE_CONFIG.md delete mode 100644 docs/VPN_PROXY_SETUP.md diff --git a/docs/ADVANCED_CONFIG.md b/docs/ADVANCED_CONFIG.md new file mode 100644 index 0000000..865d6d7 --- /dev/null +++ b/docs/ADVANCED_CONFIG.md @@ -0,0 +1,104 @@ +# Advanced & System Configuration + +This document covers advanced features, debugging, and system-level configuration options. + +## serve (dict) + +Configuration data for pywidevine's serve functionality run through unshackle. +This effectively allows you to run `unshackle serve` to start serving pywidevine Serve-compliant CDMs right from your +local widevine device files. + +- `api_secret` - Secret key for REST API authentication. When set, enables the REST API server alongside the CDM serve functionality. This key is required for authenticating API requests. +- `devices` - List of Widevine device files (.wvd). If not specified, auto-populated from the WVDs directory. +- `playready_devices` - List of PlayReady device files (.prd). If not specified, auto-populated from the PRDs directory. +- `users` - Dictionary mapping user secret keys to their access configuration: + - `devices` - List of Widevine devices this user can access + - `playready_devices` - List of PlayReady devices this user can access + - `username` - Internal logging name for the user (not visible to users) + +For example, + +```yaml +serve: + api_secret: "your-secret-key-here" + users: + secret_key_for_jane: # 32bit hex recommended, case-sensitive + devices: # list of allowed Widevine devices for this user + - generic_nexus_4464_l3 + playready_devices: # list of allowed PlayReady devices for this user + - my_playready_device + username: jane # only for internal logging, users will not see this name + secret_key_for_james: + devices: + - generic_nexus_4464_l3 + username: james + secret_key_for_john: + devices: + - generic_nexus_4464_l3 + username: john + # devices can be manually specified by path if you don't want to add it to + # unshackle's WVDs directory for whatever reason + # devices: + # - 'C:\Users\john\Devices\test_devices_001.wvd' +``` + +--- + +## debug (bool) + +Enables comprehensive debug logging. Default: `false` + +When enabled (either via config or the `-d`/`--debug` CLI flag): +- Sets console log level to DEBUG for verbose output +- Creates JSON Lines (`.jsonl`) debug log files with structured logging +- Logs detailed information about sessions, service configuration, DRM operations, and errors with full stack traces + +For example, + +```yaml +debug: true +``` + +--- + +## debug_keys (bool) + +Controls whether actual decryption keys (CEKs) are included in debug logs. Default: `false` + +When enabled: +- Content encryption keys are logged in debug output +- Only affects `content_key` and `key` fields (the actual CEKs) +- Key metadata (`kid`, `keys_count`, `key_id`) is always logged regardless of this setting +- Passwords, tokens, cookies, and session tokens remain redacted even when enabled + +For example, + +```yaml +debug_keys: true +``` + +--- + +## set_terminal_bg (bool) + +Controls whether unshackle should set the terminal background color. Default: `false` + +For example, + +```yaml +set_terminal_bg: true +``` + +--- + +## update_checks (bool) + +Check for updates from the GitHub repository on startup. Default: `true`. + +--- + +## update_check_interval (int) + +How often to check for updates, in hours. Default: `24`. + +--- diff --git a/docs/DOWNLOAD_CONFIG.md b/docs/DOWNLOAD_CONFIG.md new file mode 100644 index 0000000..c5f2b47 --- /dev/null +++ b/docs/DOWNLOAD_CONFIG.md @@ -0,0 +1,174 @@ +# Download & Processing Configuration + +This document covers configuration options related to downloading and processing media content. + +## aria2c (dict) + +- `max_concurrent_downloads` + Maximum number of parallel downloads. Default: `min(32,(cpu_count+4))` + Note: Overrides the `max_workers` parameter of the aria2(c) downloader function. +- `max_connection_per_server` + Maximum number of connections to one server for each download. Default: `1` +- `split` + Split a file into N chunks and download each chunk on its own connection. Default: `5` +- `file_allocation` + Specify file allocation method. Default: `"prealloc"` + + - `"none"` doesn't pre-allocate file space. + - `"prealloc"` pre-allocates file space before download begins. This may take some time depending on the size of the + file. + - `"falloc"` is your best choice if you are using newer file systems such as ext4 (with extents support), btrfs, xfs + or NTFS (MinGW build only). It allocates large(few GiB) files almost instantly. Don't use falloc with legacy file + systems such as ext3 and FAT32 because it takes almost same time as prealloc, and it blocks aria2 entirely until + allocation finishes. falloc may not be available if your system doesn't have posix_fallocate(3) function. + - `"trunc"` uses ftruncate(2) system call or platform-specific counterpart to truncate a file to a specified length. + +--- + +## curl_impersonate (dict) + +- `browser` - The Browser to impersonate as. A list of available Browsers and Versions are listed here: + + + Default: `"chrome124"` + +For example, + +```yaml +curl_impersonate: + browser: "chrome120" +``` + +--- + +## downloader (str | dict) + +Choose what software to use to download data throughout unshackle where needed. +You may provide a single downloader globally or a mapping of service tags to +downloaders. + +Options: + +- `requests` (default) - +- `aria2c` - +- `curl_impersonate` - (via ) +- `n_m3u8dl_re` - + +Note that aria2c can reach the highest speeds as it utilizes threading and more connections than the other downloaders. However, aria2c can also be one of the more unstable downloaders. It will work one day, then not another day. It also does not support HTTP(S) proxies while the other downloaders do. + +Example mapping: + +```yaml +downloader: + NF: requests + AMZN: n_m3u8dl_re + DSNP: n_m3u8dl_re + default: requests +``` + +The `default` entry is optional. If omitted, `requests` will be used for services not listed. + +--- + +## n_m3u8dl_re (dict) + +Configuration for N_m3u8DL-RE downloader. This downloader is particularly useful for HLS streams. + +- `thread_count` + Number of threads to use for downloading. Default: Uses the same value as max_workers from the command. +- `ad_keyword` + Keyword to identify and potentially skip advertisement segments. Default: `None` +- `use_proxy` + Whether to use proxy when downloading. Default: `true` +- `retry_count` + Number of times to retry failed downloads. Default: `10` + +For example, + +```yaml +n_m3u8dl_re: + thread_count: 16 + ad_keyword: "advertisement" + use_proxy: true + retry_count: 10 +``` + +--- + +## dl (dict) + +Pre-define default options and switches of the `dl` command. +The values will be ignored if explicitly set in the CLI call. + +The Key must be the same value Python click would resolve it to as an argument. +E.g., `@click.option("-r", "--range", "range_", type=...` actually resolves as `range_` variable. + +For example to set the default primary language to download to German, + +```yaml +lang: de +``` + +You can also set multiple preferred languages using a list, e.g., + +```yaml +lang: + - en + - fr +``` + +to set how many tracks to download concurrently to 4 and download threads to 16, + +```yaml +downloads: 4 +workers: 16 +``` + +to set `--bitrate=CVBR` for the AMZN service, + +```yaml +lang: de +AMZN: + bitrate: CVBR +``` + +or to change the output subtitle format from the default (original format) to WebVTT, + +```yaml +sub_format: vtt +``` + +--- + +## decryption (str | dict) + +Choose what software to use to decrypt DRM-protected content throughout unshackle where needed. +You may provide a single decryption method globally or a mapping of service tags to +decryption methods. + +Options: + +- `shaka` (default) - Shaka Packager - +- `mp4decrypt` - mp4decrypt from Bento4 - + +Note that Shaka Packager is the traditional method and works with most services. mp4decrypt +is an alternative that may work better with certain services that have specific encryption formats. + +Example mapping: + +```yaml +decryption: + ATVP: mp4decrypt + AMZN: shaka + default: shaka +``` + +The `default` entry is optional. If omitted, `shaka` will be used for services not listed. + +Simple configuration (single method for all services): + +```yaml +decryption: mp4decrypt +``` + +--- diff --git a/docs/DRM_CONFIG.md b/docs/DRM_CONFIG.md new file mode 100644 index 0000000..2981a38 --- /dev/null +++ b/docs/DRM_CONFIG.md @@ -0,0 +1,403 @@ +# DRM & CDM Configuration + +This document covers Digital Rights Management (DRM) and Content Decryption Module (CDM) configuration options. + +## cdm (dict) + +Pre-define which Widevine or PlayReady device to use for each Service by Service Tag as Key (case-sensitive). +The value should be a WVD or PRD filename without the file extension. When +loading the device, unshackle will look in both the `WVDs` and `PRDs` directories +for a matching file. + +For example, + +```yaml +AMZN: chromecdm_903_l3 +NF: nexus_6_l1 +``` + +You may also specify this device based on the profile used. + +For example, + +```yaml +AMZN: chromecdm_903_l3 +NF: nexus_6_l1 +DSNP: + john_sd: chromecdm_903_l3 + jane_uhd: nexus_5_l1 +``` + +You can also specify a fallback value to predefine if a match was not made. +This can be done using `default` key. This can help reduce redundancy in your specifications. + +For example, the following has the same result as the previous example, as well as all other +services and profiles being pre-defined to use `chromecdm_903_l3`. + +```yaml +NF: nexus_6_l1 +DSNP: + jane_uhd: nexus_5_l1 +default: chromecdm_903_l3 +``` + +--- + +## remote_cdm (list\[dict]) + +Configure remote CDM (Content Decryption Module) APIs to use for decrypting DRM-protected content. +Remote CDMs allow you to use high-security CDMs (L1/L2 for Widevine, SL2000/SL3000 for PlayReady) without +having the physical device files locally. + +unshackle supports multiple types of remote CDM providers: + +1. **DecryptLabs CDM** - Official DecryptLabs KeyXtractor API with intelligent caching +2. **Custom API CDM** - Highly configurable adapter for any third-party CDM API +3. **Legacy PyWidevine Serve** - Standard pywidevine serve-compliant APIs + +The name of each defined remote CDM can be referenced in the `cdm` configuration as if it was a local device file. + +### DecryptLabs Remote CDM + +DecryptLabs provides a professional CDM API service with support for multiple device types and intelligent key caching. + +**Supported Devices:** +- **Widevine**: `ChromeCDM` (L3), `L1` (Security Level 1), `L2` (Security Level 2) +- **PlayReady**: `SL2` (SL2000), `SL3` (SL3000) + +**Configuration:** + +```yaml +remote_cdm: + # Widevine L1 Device + - name: decrypt_labs_l1 + type: decrypt_labs # Required: identifies as DecryptLabs CDM + device_name: L1 # Required: must match exactly (L1, L2, ChromeCDM, SL2, SL3) + host: https://keyxtractor.decryptlabs.com + secret: YOUR_API_KEY # Your DecryptLabs API key + + # Widevine L2 Device + - name: decrypt_labs_l2 + type: decrypt_labs + device_name: L2 + host: https://keyxtractor.decryptlabs.com + secret: YOUR_API_KEY + + # Chrome CDM (L3) + - name: decrypt_labs_chrome + type: decrypt_labs + device_name: ChromeCDM + host: https://keyxtractor.decryptlabs.com + secret: YOUR_API_KEY + + # PlayReady SL2000 + - name: decrypt_labs_playready_sl2 + type: decrypt_labs + device_name: SL2 + device_type: PLAYREADY # Required for PlayReady + host: https://keyxtractor.decryptlabs.com + secret: YOUR_API_KEY + + # PlayReady SL3000 + - name: decrypt_labs_playready_sl3 + type: decrypt_labs + device_name: SL3 + device_type: PLAYREADY + host: https://keyxtractor.decryptlabs.com + secret: YOUR_API_KEY +``` + +**Features:** +- Intelligent key caching system (reduces API calls) +- Automatic integration with unshackle's vault system +- Support for both Widevine and PlayReady +- Multiple security levels (L1, L2, L3, SL2000, SL3000) + +**Note:** The `device_type` and `security_level` fields are optional metadata. They don't affect API communication +but are used for internal device identification. + +### Custom API Remote CDM + +A highly configurable CDM adapter that can work with virtually any third-party CDM API through YAML configuration. +This allows you to integrate custom CDM services without writing code. + +**Basic Example:** + +```yaml +remote_cdm: + - name: custom_chrome_cdm + type: custom_api # Required: identifies as Custom API CDM + host: https://your-cdm-api.com + timeout: 30 # Optional: request timeout in seconds + + device: + name: ChromeCDM + type: CHROME # CHROME, ANDROID, PLAYREADY + system_id: 27175 + security_level: 3 + + auth: + type: bearer # bearer, header, basic, body + key: YOUR_API_TOKEN + + endpoints: + get_request: + path: /get-challenge + method: POST + decrypt_response: + path: /get-keys + method: POST + + caching: + enabled: true # Enable key caching + use_vaults: true # Integrate with vault system +``` + +**Advanced Example with Field Mapping:** + +```yaml +remote_cdm: + - name: advanced_custom_api + type: custom_api + host: https://api.example.com + device: + name: L1 + type: ANDROID + security_level: 1 + + # Authentication configuration + auth: + type: header + header_name: X-API-Key + key: YOUR_SECRET_KEY + custom_headers: + User-Agent: Unshackle/2.0.0 + X-Client-Version: "1.0" + + # Endpoint configuration + endpoints: + get_request: + path: /v2/challenge + method: POST + timeout: 30 + decrypt_response: + path: /v2/decrypt + method: POST + timeout: 30 + + # Request parameter mapping + request_mapping: + get_request: + param_names: + init_data: pssh # Rename 'init_data' to 'pssh' + scheme: device_type # Rename 'scheme' to 'device_type' + static_params: + api_version: "2.0" # Add static parameter + decrypt_response: + param_names: + license_request: challenge + license_response: license + + # Response field mapping + response_mapping: + get_request: + fields: + challenge: data.challenge # Deep field access + session_id: session.id + success_conditions: + - status == 'ok' # Validate response + decrypt_response: + fields: + keys: data.keys + key_fields: + kid: key_id # Map 'kid' field + key: content_key # Map 'key' field + + caching: + enabled: true + use_vaults: true + check_cached_first: true # Check cache before API calls +``` + +**Supported Authentication Types:** +- `bearer` - Bearer token authentication +- `header` - Custom header authentication +- `basic` - HTTP Basic authentication +- `body` - Credentials in request body + +### Legacy PyWidevine Serve Format + +Standard [pywidevine] serve-compliant remote CDM configuration (backwards compatibility). + +```yaml +remote_cdm: + - name: legacy_chrome_cdm + device_name: chrome + device_type: CHROME + system_id: 27175 + security_level: 3 + host: https://domain.com/api + secret: secret_key +``` + +**Note:** If the `type` field is not specified, the entry is treated as a legacy pywidevine serve CDM. + +[pywidevine]: https://github.com/rlaphoenix/pywidevine + +--- + +## decrypt_labs_api_key (str) + +API key for DecryptLabs CDM service integration. + +When set, enables the use of DecryptLabs remote CDM services in your `remote_cdm` configuration. +This is used specifically for `type: "decrypt_labs"` entries in the remote CDM list. + +For example, + +```yaml +decrypt_labs_api_key: "your_api_key_here" +``` + +**Note**: This is different from the per-CDM `secret` field in `remote_cdm` entries. This provides a global +API key that can be referenced across multiple DecryptLabs CDM configurations. If a `remote_cdm` entry with +`type: "decrypt_labs"` does not have a `secret` field specified, the global `decrypt_labs_api_key` will be +used as a fallback. + +--- + +## key_vaults (list\[dict]) + +Key Vaults store your obtained Content Encryption Keys (CEKs) and Key IDs per-service. + +This can help reduce unnecessary License calls even during the first download. This is because a Service may +provide the same Key ID and CEK for both Video and Audio, as well as for multiple resolutions or bitrates. + +You can have as many Key Vaults as you would like. It's nice to share Key Vaults or use a unified Vault on +Teams as sharing CEKs immediately can help reduce License calls drastically. + +Four types of Vaults are in the Core codebase: API, SQLite, MySQL, and HTTP. API and HTTP make HTTP requests to a RESTful API, +whereas SQLite and MySQL directly connect to an SQLite or MySQL Database. + +Note: SQLite and MySQL vaults have to connect directly to the Host/IP. It cannot be in front of a PHP API or such. +Beware that some Hosting Providers do not let you access the MySQL server outside their intranet and may not be +accessible outside their hosting platform. + +Additional behavior: + +- `no_push` (bool): Optional per-vault flag. When `true`, the vault will not receive pushed keys (writes) but + will still be queried and can provide keys for lookups. Useful for read-only/backup vaults. + +### Using an API Vault + +API vaults use a specific HTTP request format, therefore API or HTTP Key Vault APIs from other projects or services may +not work in unshackle. The API format can be seen in the [API Vault Code](unshackle/vaults/API.py). + +```yaml +- type: API + name: "John#0001's Vault" # arbitrary vault name + uri: "https://key-vault.example.com" # api base uri (can also be an IP or IP:Port) + # uri: "127.0.0.1:80/key-vault" + # uri: "https://api.example.com/key-vault" + token: "random secret key" # authorization token + # no_push: true # optional; make this API vault read-only (lookups only) +``` + +### Using a MySQL Vault + +MySQL vaults can be either MySQL or MariaDB servers. I recommend MariaDB. +A MySQL Vault can be on a local or remote network, but I recommend SQLite for local Vaults. + +```yaml +- type: MySQL + name: "John#0001's Vault" # arbitrary vault name + host: "127.0.0.1" # host/ip + # port: 3306 # port (defaults to 3306) + database: vault # database used for unshackle + username: jane11 + password: Doe123 + # no_push: false # optional; defaults to false +``` + +I recommend giving only a trustable user (or yourself) CREATE permission and then use unshackle to cache at least one CEK +per Service to have it create the tables. If you don't give any user permissions to create tables, you will need to +make tables yourself. + +- Use a password on all user accounts. +- Never use the root account with unshackle (even if it's you). +- Do not give multiple users the same username and/or password. +- Only give users access to the database used for unshackle. +- You may give trusted users CREATE permission so unshackle can create tables if needed. +- Other uses should only be given SELECT and INSERT permissions. + +### Using an SQLite Vault + +SQLite Vaults are usually only used for locally stored vaults. This vault may be stored on a mounted Cloud storage +drive, but I recommend using SQLite exclusively as an offline-only vault. Effectively this is your backup vault in +case something happens to your MySQL Vault. + +```yaml +- type: SQLite + name: "My Local Vault" # arbitrary vault name + path: "C:/Users/Jane11/Documents/unshackle/data/key_vault.db" + # no_push: true # optional; commonly true for local backup vaults +``` + +**Note**: You do not need to create the file at the specified path. +SQLite will create a new SQLite database at that path if one does not exist. +Try not to accidentally move the `db` file once created without reflecting the change in the config, or you will end +up with multiple databases. + +If you work on a Team I recommend every team member having their own SQLite Vault even if you all use a MySQL vault +together. + +### Using an HTTP Vault + +HTTP Vaults provide flexible HTTP-based key storage with support for multiple API modes. This vault type +is useful for integrating with various third-party key vault APIs. + +```yaml +- type: HTTP + name: "My HTTP Vault" + host: "https://vault-api.example.com" + api_key: "your_api_key" # or use 'password' field + api_mode: "json" # query, json, or decrypt_labs + # username: "user" # required for query mode only + # no_push: false # optional; defaults to false +``` + +**Supported API Modes:** + +- `query` - Uses GET requests with query parameters. Requires `username` field. +- `json` - Uses POST requests with JSON payloads. Token-based authentication. +- `decrypt_labs` - DecryptLabs API format. Read-only mode (`no_push` is forced to `true`). + +**Example configurations:** + +```yaml +# Query mode (requires username) +- type: HTTP + name: "Query Vault" + host: "https://api.example.com/keys" + username: "myuser" + password: "mypassword" + api_mode: "query" + +# JSON mode +- type: HTTP + name: "JSON Vault" + host: "https://api.example.com/vault" + api_key: "secret_token" + api_mode: "json" + +# DecryptLabs mode (read-only) +- type: HTTP + name: "DecryptLabs Cache" + host: "https://keyxtractor.decryptlabs.com/cache" + api_key: "your_decrypt_labs_api_key" + api_mode: "decrypt_labs" +``` + +**Note**: The `decrypt_labs` mode is always read-only and cannot receive pushed keys. + +--- diff --git a/docs/GLUETUN.md b/docs/GLUETUN.md index 1787c30..07c39d1 100644 --- a/docs/GLUETUN.md +++ b/docs/GLUETUN.md @@ -25,10 +25,11 @@ Add to `~/.config/unshackle/unshackle.yaml`: proxy_providers: gluetun: providers: - nordvpn: - vpn_type: wireguard + windscribe: + vpn_type: openvpn credentials: - private_key: YOUR_PRIVATE_KEY + username: "YOUR_OPENVPN_USERNAME" + password: "YOUR_OPENVPN_PASSWORD" ``` ### 2. Usage @@ -36,57 +37,80 @@ proxy_providers: Use 2-letter country codes directly: ```bash -uv run unshackle dl SERVICE CONTENT --proxy gluetun:nordvpn:us -uv run unshackle dl SERVICE CONTENT --proxy gluetun:nordvpn:uk +unshackle dl SERVICE CONTENT --proxy gluetun:windscribe:us +unshackle dl SERVICE CONTENT --proxy gluetun:windscribe:uk ``` Format: `gluetun:provider:region` ## Provider Credential Requirements -Each provider has different credential requirements. See the [Gluetun Wiki](https://github.com/qdm12/gluetun-wiki/tree/main/setup/providers) for complete details. +**OpenVPN (Recommended)**: Most providers support OpenVPN with just `username` and `password` - the simplest setup. -| Provider | VPN Type | Required Credentials | -|----------|----------|---------------------| -| NordVPN | WireGuard | `private_key` only | -| ProtonVPN | WireGuard | `private_key` only | -| Windscribe | WireGuard | `private_key`, `addresses`, `preshared_key` (all required) | -| Surfshark | WireGuard | `private_key`, `addresses` | -| Mullvad | WireGuard | `private_key`, `addresses` | -| IVPN | WireGuard | `private_key`, `addresses` | -| ExpressVPN | OpenVPN | `username`, `password` (no WireGuard support) | -| Any | OpenVPN | `username`, `password` | +**WireGuard**: Requires private keys and varies by provider. See the [Gluetun Wiki](https://github.com/qdm12/gluetun-wiki/tree/main/setup/providers) for provider-specific requirements. -### Configuration Examples +## Getting Your Credentials + +### Windscribe (OpenVPN) + +1. Go to [windscribe.com/getconfig/openvpn](https://windscribe.com/getconfig/openvpn) +2. Log in with your Windscribe account +3. Select any location and click "Get Config" +4. Copy the username and password shown + +### NordVPN (OpenVPN) + +1. Go to [NordVPN Service Credentials](https://my.nordaccount.com/dashboard/nordvpn/manual-configuration/service-credentials/) +2. Log in with your NordVPN account +3. Generate or view your service credentials +4. Copy the username and password + +> **Note**: Use service credentials, NOT your account email/password. + +### WireGuard Credentials (Advanced) + +WireGuard requires private keys instead of username/password. See the [Gluetun Wiki](https://github.com/qdm12/gluetun-wiki/tree/main/setup/providers) for provider-specific WireGuard setup. + +## Configuration Examples + +**OpenVPN (Recommended)** + +Most providers support OpenVPN with just username and password: -**NordVPN/ProtonVPN** (only private_key needed): ```yaml +providers: + windscribe: + vpn_type: openvpn + credentials: + username: YOUR_OPENVPN_USERNAME + password: YOUR_OPENVPN_PASSWORD + + nordvpn: + vpn_type: openvpn + credentials: + username: YOUR_SERVICE_USERNAME + password: YOUR_SERVICE_PASSWORD +``` + +**WireGuard (Advanced)** + +WireGuard can be faster but requires more complex credential setup: + +```yaml +# NordVPN/ProtonVPN (only private_key needed) providers: nordvpn: vpn_type: wireguard credentials: private_key: YOUR_PRIVATE_KEY -``` -**Windscribe** (all three credentials required): -```yaml -providers: +# Windscribe (all three credentials required) windscribe: vpn_type: wireguard credentials: private_key: YOUR_PRIVATE_KEY addresses: 10.x.x.x/32 - preshared_key: YOUR_PRESHARED_KEY # Required, can be empty string -``` - -**OpenVPN** (any provider): -```yaml -providers: - expressvpn: - vpn_type: openvpn - credentials: - username: YOUR_USERNAME - password: YOUR_PASSWORD + preshared_key: YOUR_PRESHARED_KEY ``` ## Server Selection diff --git a/docs/NETWORK_CONFIG.md b/docs/NETWORK_CONFIG.md new file mode 100644 index 0000000..8e7c131 --- /dev/null +++ b/docs/NETWORK_CONFIG.md @@ -0,0 +1,154 @@ +# Network & Proxy Configuration + +This document covers network and proxy configuration options for bypassing geofencing and managing connections. + +## proxy_providers (dict) + +Enable external proxy provider services. These proxies will be used automatically where needed as defined by the +Service's GEOFENCE class property, but can also be explicitly used with `--proxy`. You can specify which provider +to use by prefixing it with the provider key name, e.g., `--proxy basic:de` or `--proxy nordvpn:de`. Some providers +support specific query formats for selecting a country/server. + +### basic (dict[str, str|list]) + +Define a mapping of country to proxy to use where required. +The keys are region Alpha 2 Country Codes. Alpha 2 Country Codes are `[a-z]{2}` codes, e.g., `us`, `gb`, and `jp`. +Don't get this mixed up with language codes like `en` vs. `gb`, or `ja` vs. `jp`. + +Do note that each key's value can be a list of strings, or a string. For example, + +```yaml +us: + - "http://john%40email.tld:password123@proxy-us.domain.tld:8080" + - "http://jane%40email.tld:password456@proxy-us.domain2.tld:8080" +de: "https://127.0.0.1:8080" +``` + +Note that if multiple proxies are defined for a region, then by default one will be randomly chosen. +You can choose a specific one by specifying it's number, e.g., `--proxy basic:us2` will choose the +second proxy of the US list. + +### nordvpn (dict) + +Set your NordVPN Service credentials with `username` and `password` keys to automate the use of NordVPN as a Proxy +system where required. + +You can also specify specific servers to use per-region with the `server_map` key. +Sometimes a specific server works best for a service than others, so hard-coding one for a day or two helps. + +For example, + +```yaml +username: zxqsR7C5CyGwmGb6KSvk8qsZ # example of the login format +password: wXVHmht22hhRKUEQ32PQVjCZ +server_map: + us: 12 # force US server #12 for US proxies +``` + +The username and password should NOT be your normal NordVPN Account Credentials. +They should be the `Service credentials` which can be found on your Nord Account Dashboard. + +Once set, you can also specifically opt in to use a NordVPN proxy by specifying `--proxy=gb` or such. +You can even set a specific server number this way, e.g., `--proxy=gb2366`. + +Note that `gb` is used instead of `uk` to be more consistent across regional systems. + +### surfsharkvpn (dict) + +Enable Surfshark VPN proxy service using Surfshark Service credentials (not your login password). +You may pin specific server IDs per region using `server_map`. + +```yaml +username: your_surfshark_service_username # https://my.surfshark.com/vpn/manual-setup/main/openvpn +password: your_surfshark_service_password # service credentials, not account password +server_map: + us: 3844 # force US server #3844 + gb: 2697 # force GB server #2697 + au: 4621 # force AU server #4621 +``` + +### hola (dict) + +Enable Hola VPN proxy service. Requires the `hola-proxy` binary to be installed and available in your PATH. + +```yaml +proxy_providers: + hola: {} +``` + +Once configured, use `--proxy hola:us` or similar to connect through Hola. + +### windscribevpn (dict) + +Enable Windscribe VPN proxy service using static OpenVPN service credentials. + +Use the service credentials from https://windscribe.com/getconfig/openvpn (not your account login credentials). + +```yaml +proxy_providers: + windscribevpn: + username: openvpn_username # From https://windscribe.com/getconfig/openvpn + password: openvpn_password # Service credentials, NOT your account password +``` + +#### Server Mapping + +You can optionally pin specific servers using `server_map`: + +```yaml +proxy_providers: + windscribevpn: + username: openvpn_username + password: openvpn_password + server_map: + us: us-central-096.totallyacdn.com # Force specific US server + gb: uk-london-001.totallyacdn.com # Force specific UK server +``` + +Once configured, use `--proxy windscribe:us` or `--proxy windscribe:gb` etc. to connect through Windscribe. + +### Legacy nordvpn Configuration + +**Legacy configuration. Use `proxy_providers.nordvpn` instead.** + +Set your NordVPN Service credentials with `username` and `password` keys to automate the use of NordVPN as a Proxy +system where required. + +You can also specify specific servers to use per-region with the `server_map` key. +Sometimes a specific server works best for a service than others, so hard-coding one for a day or two helps. + +For example, + +```yaml +nordvpn: + username: zxqsR7C5CyGwmGb6KSvk8qsZ # example of the login format + password: wXVHmht22hhRKUEQ32PQVjCZ + server_map: + us: 12 # force US server #12 for US proxies +``` + +The username and password should NOT be your normal NordVPN Account Credentials. +They should be the `Service credentials` which can be found on your Nord Account Dashboard. + +Note that `gb` is used instead of `uk` to be more consistent across regional systems. + +--- + +## headers (dict) + +Case-Insensitive dictionary of headers that all Services begin their Request Session state with. +All requests will use these unless changed explicitly or implicitly via a Server response. +These should be sane defaults and anything that would only be useful for some Services should not +be put here. + +Avoid headers like 'Accept-Encoding' as that would be a compatibility header that Python-requests will +set for you. + +I recommend using, + +```yaml +Accept-Language: "en-US,en;q=0.8" +User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.75 Safari/537.36" +``` + +--- diff --git a/docs/OUTPUT_CONFIG.md b/docs/OUTPUT_CONFIG.md new file mode 100644 index 0000000..c7c209a --- /dev/null +++ b/docs/OUTPUT_CONFIG.md @@ -0,0 +1,123 @@ +# Output & Naming Configuration + +This document covers output file organization and naming configuration options. + +## filenames (dict) + +Override the default filenames used across unshackle. +The filenames use various variables that are replaced during runtime. + +The following filenames are available and may be overridden: + +- `log` - Log filenames. Uses `{name}` and `{time}` variables. +- `debug_log` - Debug log filenames. Uses `{service}` and `{time}` variables. +- `config` - Service configuration filenames. +- `root_config` - Root configuration filename. +- `chapters` - Chapter export filenames. Uses `{title}` and `{random}` variables. +- `subtitle` - Subtitle export filenames. Uses `{id}` and `{language}` variables. + +For example, + +```yaml +filenames: + log: "unshackle_{name}_{time}.log" + debug_log: "unshackle_debug_{service}_{time}.jsonl" + config: "config.yaml" + root_config: "unshackle.yaml" + chapters: "Chapters_{title}_{random}.txt" + subtitle: "Subtitle_{id}_{language}.srt" +``` + +--- + +## scene_naming (bool) + +Set scene-style naming for titles. When `true` uses scene naming patterns (e.g., `Prime.Suspect.S07E01...`), when +`false` uses a more human-readable style (e.g., `Prime Suspect S07E01 ...`). Default: `true`. + +--- + +## series_year (bool) + +Whether to include the series year in series names for episodes and folders. Default: `true`. + +--- + +## tag (str) + +Group or Username to postfix to the end of download filenames following a dash. +Only applies when `scene_naming` is enabled. +For example, `tag: "J0HN"` will have `-J0HN` at the end of all download filenames. + +--- + +## tag_group_name (bool) + +Enable/disable tagging downloads with your group name when `tag` is set. Default: `true`. + +--- + +## tag_imdb_tmdb (bool) + +Enable/disable tagging downloaded files with IMDB/TMDB/TVDB identifiers (when available). Default: `true`. + +--- + +## muxing (dict) + +- `set_title` + Set the container title to `Show SXXEXX Episode Name` or `Movie (Year)`. Default: `true` + +--- + +## chapter_fallback_name (str) + +The Chapter Name to use when exporting a Chapter without a Name. +The default is no fallback name at all and no Chapter name will be set. + +The fallback name can use the following variables in f-string style: + +- `{i}`: The Chapter number starting at 1. + E.g., `"Chapter {i}"`: "Chapter 1", "Intro", "Chapter 3". +- `{j}`: A number starting at 1 that increments any time a Chapter has no title. + E.g., `"Chapter {j}"`: "Chapter 1", "Intro", "Chapter 2". + +These are formatted with f-strings, directives are supported. +For example, `"Chapter {i:02}"` will result in `"Chapter 01"`. + +--- + +## directories (dict) + +Override the default directories used across unshackle. +The directories are set to common values by default. + +The following directories are available and may be overridden, + +- `commands` - CLI Command Classes. +- `services` - Service Classes. +- `vaults` - Vault Classes. +- `fonts` - Font files (ttf or otf). +- `downloads` - Downloads. +- `temp` - Temporary files or conversions during download. +- `cache` - Expiring data like Authorization tokens, or other misc data. +- `cookies` - Expiring Cookie data. +- `logs` - Logs. +- `wvds` - Widevine Devices. +- `prds` - PlayReady Devices. +- `dcsl` - Device Certificate Status List. + +Notes: + +- `services` accepts either a single directory or a list of directories to search for service modules. + +For example, + +```yaml +downloads: "D:/Downloads/unshackle" +temp: "D:/Temp/unshackle" +``` + +There are directories not listed that cannot be modified as they are crucial to the operation of unshackle. + +--- diff --git a/docs/SERVICE_CONFIG.md b/docs/SERVICE_CONFIG.md new file mode 100644 index 0000000..849cb91 --- /dev/null +++ b/docs/SERVICE_CONFIG.md @@ -0,0 +1,116 @@ +# Service Integration & Authentication Configuration + +This document covers service-specific configuration, authentication, and metadata integration options. + +## services (dict) + +Configuration data for each Service. The Service will have the data within this section merged into the `config.yaml` +before provided to the Service class. + +Think of this config to be used for more sensitive configuration data, like user or device-specific API keys, IDs, +device attributes, and so on. A `config.yaml` file is typically shared and not meant to be modified, so use this for +any sensitive configuration data. + +The Key is the Service Tag, but can take any arbitrary form for its value. It's expected to begin as either a list or +a dictionary. + +For example, + +```yaml +NOW: + client: + auth_scheme: MESSO + # ... more sensitive data +``` + +--- + +## credentials (dict[str, str|list|dict]) + +Specify login credentials to use for each Service, and optionally per-profile. + +For example, + +```yaml +ALL4: jane@gmail.com:LoremIpsum100 # directly +AMZN: # or per-profile, optionally with a default + default: jane@example.tld:LoremIpsum99 # <-- used by default if -p/--profile is not used + james: james@gmail.com:TheFriend97 + john: john@example.tld:LoremIpsum98 +NF: # the `default` key is not necessary, but no credential will be used by default + john: john@gmail.com:TheGuyWhoPaysForTheNetflix69420 +``` + +The value should be in string form, i.e. `john@gmail.com:password123` or `john:password123`. +Any arbitrary values can be used on the left (username/password/phone) and right (password/secret). +You can also specify these in list form, i.e., `["john@gmail.com", ":PasswordWithAColon"]`. + +If you specify multiple credentials with keys like the `AMZN` and `NF` example above, then you should +use a `default` key or no credential will be loaded automatically unless you use `-p/--profile`. You +do not have to use a `default` key at all. + +Please be aware that this information is sensitive and to keep it safe. Do not share your config. + +--- + +## tmdb_api_key (str) + +API key for The Movie Database (TMDB). This is used for tagging downloaded files with TMDB, +IMDB and TVDB identifiers. Leave empty to disable automatic lookups. + +To obtain a TMDB API key: + +1. Create an account at +2. Go to to register for API access +3. Fill out the API application form with your project details +4. Once approved, you'll receive your API key + +For example, + +```yaml +tmdb_api_key: cf66bf18956kca5311ada3bebb84eb9a # Not a real key +``` + +**Note**: Keep your API key secure and do not share it publicly. This key is used by the core/utils/tags.py module to fetch metadata from TMDB for proper file tagging. + +--- + +## simkl_client_id (str) + +Client ID for SIMKL API integration. SIMKL is used as a metadata source for improved title matching and tagging, +especially when a TMDB API key is not configured. + +To obtain a SIMKL Client ID: + +1. Create an account at +2. Go to +3. Register a new application to receive your Client ID + +For example, + +```yaml +simkl_client_id: "your_client_id_here" +``` + +**Note**: While optional, having a SIMKL Client ID improves metadata lookup reliability. SIMKL serves as an alternative or fallback metadata source to TMDB. This is used by the `core/utils/tags.py` module. + +--- + +## title_cache_enabled (bool) + +Enable/disable caching of title metadata to reduce redundant API calls. Default: `true`. + +--- + +## title_cache_time (int) + +Cache duration in seconds for title metadata. Default: `1800` (30 minutes). + +--- + +## title_cache_max_retention (int) + +Maximum retention time in seconds for serving slightly stale cached title metadata when API calls fail. +Default: `86400` (24 hours). Effective retention is `min(title_cache_time + grace, title_cache_max_retention)`. + +--- diff --git a/docs/SUBTITLE_CONFIG.md b/docs/SUBTITLE_CONFIG.md new file mode 100644 index 0000000..cc27870 --- /dev/null +++ b/docs/SUBTITLE_CONFIG.md @@ -0,0 +1,39 @@ +# Subtitle Processing Configuration + +This document covers subtitle processing and formatting options. + +## subtitle (dict) + +Control subtitle conversion and SDH (hearing-impaired) stripping behavior. + +- `conversion_method`: How to convert subtitles between formats. Default: `pysubs2`. + - `auto`: Use subby for WebVTT/SAMI, standard for others. + - `subby`: Always use subby with CommonIssuesFixer. + - `subtitleedit`: Prefer SubtitleEdit when available; otherwise fallback to standard conversion. + - `pycaption`: Use only the pycaption library (no SubtitleEdit, no subby). + - `pysubs2`: Use pysubs2 library (supports SRT, SSA, ASS, WebVTT, TTML, SAMI, MicroDVD, MPL2, TMP formats). + +- `sdh_method`: How to strip SDH cues. Default: `auto`. + - `auto`: Try subby for SRT first, then SubtitleEdit, then filter-subs. + - `subby`: Use subby's SDHStripper. **Note:** Only works with SRT files; other formats will fall back to alternative methods. + - `subtitleedit`: Use SubtitleEdit's RemoveTextForHI when available. + - `filter-subs`: Use the subtitle-filter library. + +- `strip_sdh`: Enable/disable automatic SDH (hearing-impaired) cue stripping. Default: `true`. + +- `convert_before_strip`: When using `filter-subs` SDH method, automatically convert subtitles to SRT format first for better compatibility. Default: `true`. + +- `preserve_formatting`: Keep original subtitle tags and positioning during conversion. Default: `true`. + +Example: + +```yaml +subtitle: + conversion_method: pysubs2 + sdh_method: auto + strip_sdh: true + convert_before_strip: true + preserve_formatting: true +``` + +--- diff --git a/docs/VPN_PROXY_SETUP.md b/docs/VPN_PROXY_SETUP.md deleted file mode 100644 index 1c6c096..0000000 --- a/docs/VPN_PROXY_SETUP.md +++ /dev/null @@ -1,136 +0,0 @@ -# VPN Proxy Setup for Unshackle - -## Overview - -Unshackle has **native Gluetun integration** that automatically creates and manages Docker containers to bridge VPN connections to HTTP proxies. Simply configure your VPN credentials in `unshackle.yaml` and use `--proxy gluetun::`. - -## Why Use VPN Proxies? - -- **Network Isolation**: VPN runs in a Docker container, doesn't affect your system's internet -- **Easy Switching**: Switch between regions without reconfiguring anything -- **Multiple Regions**: Use different VPN locations for different downloads - -## Requirements - -- Docker must be installed and running -- Verify with: `unshackle env check` - -## Configuration - -Add your VPN provider credentials to `unshackle.yaml`: - -```yaml -gluetun: - base_port: 8888 # Starting port for HTTP proxies - auto_cleanup: true # Remove containers when done - container_prefix: "unshackle-gluetun" - verify_ip: true # Verify VPN IP matches expected region - - providers: - windscribe: - vpn_type: openvpn - credentials: - username: "YOUR_OPENVPN_USERNAME" - password: "YOUR_OPENVPN_PASSWORD" - server_countries: - us: US - uk: GB - ca: CA - - nordvpn: - vpn_type: openvpn - credentials: - username: "YOUR_SERVICE_USERNAME" - password: "YOUR_SERVICE_PASSWORD" - server_countries: - us: US - de: DE -``` - -## Getting Your VPN Credentials - -### Windscribe - -1. Go to [windscribe.com/getconfig/openvpn](https://windscribe.com/getconfig/openvpn) -2. Generate a config file for any location -3. Copy the username and password shown - -> **Note**: Windscribe uses region names like "US East" instead of country codes. Unshackle automatically converts codes like `us`, `ca`, `uk` to the correct region names. - -### NordVPN - -1. Log into NordVPN dashboard -2. Go to Services > NordVPN > Manual setup -3. Copy your service credentials (not your account email/password) - -### Other Providers - -Gluetun supports 50+ VPN providers. See the [Gluetun Wiki](https://github.com/qdm12/gluetun-wiki/tree/main/setup/providers) for provider-specific setup instructions. - -## Usage - -Use the `--proxy` flag with the format `gluetun::`: - -```bash -# Connect via Windscribe to US -unshackle dl SERVICE CONTENT_ID --proxy gluetun:windscribe:us - -# Connect via NordVPN to Germany -unshackle dl SERVICE CONTENT_ID --proxy gluetun:nordvpn:de -``` - -Unshackle will automatically: - -1. Start a Gluetun Docker container with your credentials -2. Wait for the VPN connection to establish -3. Route your download through the VPN proxy -4. Clean up the container when done (if `auto_cleanup: true`) - -## Troubleshooting - -### Docker Not Running - -``` -Error: Docker is not running -``` - -Start Docker Desktop or the Docker daemon. - -### Invalid Credentials - -``` -Error: VPN authentication failed -``` - -Verify your credentials are correct. Use VPN service credentials from your provider's manual setup page, not your account login. - -### Container Fails to Start - -Check Docker logs: - -```bash -docker logs unshackle-gluetun-windscribe-us -``` - -### VPN Connection Timeout - -If the VPN connection hangs or times out, your network may be blocking the default UDP port 1194. Try using TCP port 443: - -```yaml -windscribe: - vpn_type: openvpn - openvpn_port: 443 # Use TCP 443 for restricted networks - credentials: - username: "YOUR_USERNAME" - password: "YOUR_PASSWORD" -``` - -### Verify VPN Connection - -The `verify_ip` option checks that your IP matches the expected region. If verification fails, try a different server location in your provider's settings. - -## References - -- [Gluetun GitHub](https://github.com/qdm12/gluetun) -- [Gluetun Wiki - Provider Setup](https://github.com/qdm12/gluetun-wiki) -- [CONFIG.md - Full gluetun options](../CONFIG.md#gluetun-dict) From caf67a69989f1a1e97e400481bb23a1c3aeb9e03 Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 31 Jan 2026 16:58:10 -0700 Subject: [PATCH 27/38] feat(env): add ML-Worker binary for DRM licensing --- unshackle/commands/env.py | 7 +++++++ unshackle/core/binaries.py | 2 ++ 2 files changed, 9 insertions(+) diff --git a/unshackle/commands/env.py b/unshackle/commands/env.py index 94d9a0b..adc48d4 100644 --- a/unshackle/commands/env.py +++ b/unshackle/commands/env.py @@ -52,6 +52,13 @@ def check() -> None: "desc": "DRM decryption", "cat": "DRM", }, + { + "name": "ML-Worker", + "binary": binaries.ML_Worker, + "required": False, + "desc": "DRM licensing", + "cat": "DRM", + }, # HDR Processing {"name": "dovi_tool", "binary": binaries.DoviTool, "required": False, "desc": "Dolby Vision", "cat": "HDR"}, { diff --git a/unshackle/core/binaries.py b/unshackle/core/binaries.py index 1b56061..d984c0c 100644 --- a/unshackle/core/binaries.py +++ b/unshackle/core/binaries.py @@ -53,6 +53,7 @@ DoviTool = find("dovi_tool") HDR10PlusTool = find("hdr10plus_tool", "HDR10Plus_tool") Mp4decrypt = find("mp4decrypt") Docker = find("docker") +ML_Worker = find("ML-Worker") __all__ = ( @@ -73,5 +74,6 @@ __all__ = ( "HDR10PlusTool", "Mp4decrypt", "Docker", + "ML_Worker", "find", ) From ef338f01242ccdfe34a6c21c94d21ec0dd4f8698 Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 31 Jan 2026 20:00:30 -0700 Subject: [PATCH 28/38] fix(downloader): correct progress bar tracking for segmented downloads Use segmented=True when downloading multiple URLs to prevent inner downloads from overriding the total segment count, which caused the progress bar to always appear green (finished state). This is still WIP so will continue to monitor. --- unshackle/core/downloaders/requests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/unshackle/core/downloaders/requests.py b/unshackle/core/downloaders/requests.py index 06cab3d..cb3ce5a 100644 --- a/unshackle/core/downloaders/requests.py +++ b/unshackle/core/downloaders/requests.py @@ -122,7 +122,7 @@ def download( last_speed_refresh = now download_sizes.clear() - if content_length and written < content_length: + if not segmented and content_length and written < content_length: raise IOError(f"Failed to read {content_length} bytes from the track URI.") yield dict(file_downloaded=save_path, written=written) @@ -264,7 +264,7 @@ def requests( try: with ThreadPoolExecutor(max_workers=max_workers) as pool: - for future in as_completed(pool.submit(download, session=session, segmented=False, **url) for url in urls): + for future in as_completed(pool.submit(download, session=session, segmented=True, **url) for url in urls): try: yield from future.result() except KeyboardInterrupt: From 3fcad1aa01437be1e6d7f08171580a1dbd08f454 Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 31 Jan 2026 22:05:44 -0700 Subject: [PATCH 29/38] feat(drm): add MonaLisa DRM support to core infrastructure - Add MonaLisaCDM class wrapping wasmtime for key extraction - Add MonaLisa DRM class with decrypt_segment() for per-segment decryption - Display Content ID and keys in download output (matching Widevine/PlayReady) - Add wasmtime dependency for WASM module execution --- pyproject.toml | 1 + unshackle/commands/dl.py | 22 +- unshackle/core/cdm/__init__.py | 3 +- unshackle/core/cdm/monalisa/__init__.py | 3 + unshackle/core/cdm/monalisa/monalisa_cdm.py | 371 ++++++++++++++++++++ unshackle/core/drm/__init__.py | 5 +- unshackle/core/drm/monalisa.py | 280 +++++++++++++++ unshackle/core/manifests/hls.py | 6 +- uv.lock | 21 ++ 9 files changed, 707 insertions(+), 5 deletions(-) create mode 100644 unshackle/core/cdm/monalisa/__init__.py create mode 100644 unshackle/core/cdm/monalisa/monalisa_cdm.py create mode 100644 unshackle/core/drm/monalisa.py diff --git a/pyproject.toml b/pyproject.toml index 2da4099..8c128d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ dependencies = [ "PyExecJS>=1.5.1,<2", "pycountry>=24.6.1", "language-data>=1.4.0", + "wasmtime>=41.0.0", ] [project.urls] diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 9a97711..b4c5434 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -47,7 +47,7 @@ from unshackle.core.config import config from unshackle.core.console import console from unshackle.core.constants import DOWNLOAD_LICENCE_ONLY, AnyTrack, context_settings from unshackle.core.credential import Credential -from unshackle.core.drm import DRM_T, PlayReady, Widevine +from unshackle.core.drm import DRM_T, MonaLisa, PlayReady, Widevine from unshackle.core.events import events from unshackle.core.proxies import Basic, Gluetun, Hola, NordVPN, SurfsharkVPN, WindscribeVPN from unshackle.core.service import Service @@ -2250,6 +2250,26 @@ class dl: export.write_text(jsonpickle.dumps(keys, indent=4), encoding="utf8") + elif isinstance(drm, MonaLisa): + with self.DRM_TABLE_LOCK: + display_id = drm.content_id or drm.pssh + pssh_display = self.truncate_pssh_for_display(display_id, "MonaLisa") + cek_tree = Tree(Text.assemble(("MonaLisa", "cyan"), (f"({pssh_display})", "text"), overflow="fold")) + pre_existing_tree = next( + (x for x in table.columns[0].cells if isinstance(x, Tree) and x.label == cek_tree.label), None + ) + if pre_existing_tree: + cek_tree = pre_existing_tree + + for kid_, key in drm.content_keys.items(): + label = f"[text2]{kid_.hex}:{key}" + if not any(f"{kid_.hex}:{key}" in x.label for x in cek_tree.children): + cek_tree.add(label) + + if cek_tree.children and not pre_existing_tree: + table.add_row() + table.add_row(cek_tree) + @staticmethod def get_cookie_path(service: str, profile: Optional[str]) -> Optional[Path]: """Get Service Cookie File Path for Profile.""" diff --git a/unshackle/core/cdm/__init__.py b/unshackle/core/cdm/__init__.py index 226f9ea..349099b 100644 --- a/unshackle/core/cdm/__init__.py +++ b/unshackle/core/cdm/__init__.py @@ -1,4 +1,5 @@ from .custom_remote_cdm import CustomRemoteCDM from .decrypt_labs_remote_cdm import DecryptLabsRemoteCDM +from .monalisa import MonaLisaCDM -__all__ = ["DecryptLabsRemoteCDM", "CustomRemoteCDM"] +__all__ = ["DecryptLabsRemoteCDM", "CustomRemoteCDM", "MonaLisaCDM"] diff --git a/unshackle/core/cdm/monalisa/__init__.py b/unshackle/core/cdm/monalisa/__init__.py new file mode 100644 index 0000000..999975f --- /dev/null +++ b/unshackle/core/cdm/monalisa/__init__.py @@ -0,0 +1,3 @@ +from .monalisa_cdm import MonaLisaCDM + +__all__ = ["MonaLisaCDM"] diff --git a/unshackle/core/cdm/monalisa/monalisa_cdm.py b/unshackle/core/cdm/monalisa/monalisa_cdm.py new file mode 100644 index 0000000..c5880e1 --- /dev/null +++ b/unshackle/core/cdm/monalisa/monalisa_cdm.py @@ -0,0 +1,371 @@ +""" +MonaLisa CDM - WASM-based Content Decryption Module wrapper. + +This module provides key extraction from MonaLisa-protected content using +a WebAssembly module that runs locally via wasmtime. +""" + +import base64 +import ctypes +import json +import re +import uuid +from pathlib import Path +from typing import Dict, Optional, Union + +import wasmtime + +from unshackle.core import binaries + + +class MonaLisaCDM: + """ + MonaLisa CDM wrapper for WASM-based key extraction. + + This CDM differs from Widevine/PlayReady in that it does not use a + challenge/response flow with a license server. Instead, the license + (ticket) is provided directly by the service API, and keys are extracted + locally via the WASM module. + """ + + DYNAMIC_BASE = 6065008 + DYNAMICTOP_PTR = 821968 + LICENSE_KEY_OFFSET = 0x5C8C0C + LICENSE_KEY_LENGTH = 16 + + ENV_STRINGS = ( + "USER=web_user", + "LOGNAME=web_user", + "PATH=/", + "PWD=/", + "HOME=/home/web_user", + "LANG=zh_CN.UTF-8", + "_=./this.program", + ) + + def __init__(self, device_path: Path): + """ + Initialize the MonaLisa CDM. + + Args: + device_path: Path to the device file (.mld). + """ + device_path = Path(device_path) + + self.device_path = device_path + self.base_dir = device_path.parent + + if not self.device_path.is_file(): + raise FileNotFoundError(f"Device file not found at: {self.device_path}") + + try: + data = json.loads(self.device_path.read_text(encoding="utf-8", errors="replace")) + except Exception as e: + raise ValueError(f"Invalid device file (JSON): {e}") + + wasm_path_str = data.get("wasm_path") + if not wasm_path_str: + raise ValueError("Device file missing 'wasm_path'") + + wasm_filename = Path(wasm_path_str).name + wasm_path = self.base_dir / wasm_filename + + if not wasm_path.exists(): + raise FileNotFoundError(f"WASM file not found at: {wasm_path}") + + try: + self.engine = wasmtime.Engine() + if wasm_path.suffix.lower() == ".wat": + self.module = wasmtime.Module.from_file(self.engine, str(wasm_path)) + else: + self.module = wasmtime.Module(self.engine, wasm_path.read_bytes()) + except Exception as e: + raise RuntimeError(f"Failed to load WASM module: {e}") + + self.store = None + self.memory = None + self.instance = None + self.exports = {} + self.ctx = None + + @staticmethod + def get_worker_path() -> Optional[Path]: + """Get ML-Worker binary path from the unshackle binaries system.""" + if binaries.ML_Worker: + return Path(binaries.ML_Worker) + return None + + def open(self) -> int: + """ + Open a CDM session. + + Returns: + Session ID (always 1 for MonaLisa). + + Raises: + RuntimeError: If session initialization fails. + """ + try: + self.store = wasmtime.Store(self.engine) + memory_type = wasmtime.MemoryType(wasmtime.Limits(256, 256)) + self.memory = wasmtime.Memory(self.store, memory_type) + + self._write_i32(self.DYNAMICTOP_PTR, self.DYNAMIC_BASE) + imports = self._build_imports() + self.instance = wasmtime.Instance(self.store, self.module, imports) + + ex = self.instance.exports(self.store) + self.exports = { + "___wasm_call_ctors": ex["s"], + "_monalisa_context_alloc": ex["D"], + "monalisa_set_license": ex["F"], + "_monalisa_set_canvas_id": ex["t"], + "_monalisa_version_get": ex["A"], + "monalisa_get_line_number": ex["v"], + "stackAlloc": ex["N"], + "stackSave": ex["L"], + "stackRestore": ex["M"], + } + + self.exports["___wasm_call_ctors"](self.store) + self.ctx = self.exports["_monalisa_context_alloc"](self.store) + return 1 + except Exception as e: + raise RuntimeError(f"Failed to initialize session: {e}") + + def close(self, session_id: int = 1) -> None: + """ + Close the CDM session and release resources. + + Args: + session_id: The session ID to close (unused, for API compatibility). + """ + self.store = None + self.memory = None + self.instance = None + self.exports = {} + self.ctx = None + + def extract_keys(self, license_data: Union[str, bytes]) -> Dict: + """ + Extract decryption keys from license/ticket data. + + Args: + license_data: The license ticket, either as base64 string or raw bytes. + + Returns: + Dictionary with keys: kid (hex), key (hex), type ("CONTENT"). + + Raises: + RuntimeError: If session not open or license validation fails. + ValueError: If license_data is empty. + """ + if not self.instance or not self.memory or self.ctx is None: + raise RuntimeError("Session not open. Call open() first.") + + if not license_data: + raise ValueError("license_data is empty") + + if isinstance(license_data, bytes): + license_b64 = base64.b64encode(license_data).decode("utf-8") + else: + license_b64 = license_data + + ret = self._ccall( + "monalisa_set_license", + int, + self.ctx, + license_b64, + len(license_b64), + "0", + ) + + if ret != 0: + raise RuntimeError(f"License validation failed with code: {ret}") + + key_bytes = self._extract_license_key_bytes() + + # Extract DCID from license to generate KID + try: + decoded = base64.b64decode(license_b64).decode("ascii", errors="ignore") + except Exception: + decoded = "" + + m = re.search( + r"DCID-[A-Z0-9]+-[A-Z0-9]+-\d{8}-\d{6}-[A-Z0-9]+-\d{10}-[A-Z0-9]+", + decoded, + ) + if m: + kid_bytes = uuid.uuid5(uuid.NAMESPACE_DNS, m.group()).bytes + else: + kid_bytes = uuid.UUID(int=0).bytes + + return {"kid": kid_bytes.hex(), "key": key_bytes.hex(), "type": "CONTENT"} + + def _extract_license_key_bytes(self) -> bytes: + """Extract the 16-byte decryption key from WASM memory.""" + data_ptr = self.memory.data_ptr(self.store) + data_len = self.memory.data_len(self.store) + + if self.LICENSE_KEY_OFFSET + self.LICENSE_KEY_LENGTH > data_len: + raise RuntimeError("License key offset beyond memory bounds") + + mem_ptr = ctypes.cast(data_ptr, ctypes.POINTER(ctypes.c_ubyte * data_len)) + start = self.LICENSE_KEY_OFFSET + end = self.LICENSE_KEY_OFFSET + self.LICENSE_KEY_LENGTH + + return bytes(mem_ptr.contents[start:end]) + + def _ccall(self, func_name: str, return_type: type, *args): + """Call a WASM function with automatic string conversion.""" + stack = 0 + converted_args = [] + + for arg in args: + if isinstance(arg, str): + if stack == 0: + stack = self.exports["stackSave"](self.store) + max_length = (len(arg) << 2) + 1 + ptr = self.exports["stackAlloc"](self.store, max_length) + self._string_to_utf8(arg, ptr, max_length) + converted_args.append(ptr) + else: + converted_args.append(arg) + + result = self.exports[func_name](self.store, *converted_args) + + if stack != 0: + self.exports["stackRestore"](self.store, stack) + + if return_type is bool: + return bool(result) + return result + + def _write_i32(self, addr: int, value: int) -> None: + """Write a 32-bit integer to WASM memory.""" + data = self.memory.data_ptr(self.store) + mem_ptr = ctypes.cast(data, ctypes.POINTER(ctypes.c_int32)) + mem_ptr[addr >> 2] = value + + def _string_to_utf8(self, data: str, ptr: int, max_length: int) -> int: + """Convert string to UTF-8 and write to WASM memory.""" + encoded = data.encode("utf-8") + write_length = min(len(encoded), max_length - 1) + + mem_data = self.memory.data_ptr(self.store) + mem_ptr = ctypes.cast(mem_data, ctypes.POINTER(ctypes.c_ubyte)) + + for i in range(write_length): + mem_ptr[ptr + i] = encoded[i] + mem_ptr[ptr + write_length] = 0 + return write_length + + def _write_ascii_to_memory(self, string: str, buffer: int, dont_add_null: int = 0) -> None: + """Write ASCII string to WASM memory.""" + mem_data = self.memory.data_ptr(self.store) + mem_ptr = ctypes.cast(mem_data, ctypes.POINTER(ctypes.c_ubyte)) + + encoded = string.encode("utf-8") + for i, byte_val in enumerate(encoded): + mem_ptr[buffer + i] = byte_val + + if dont_add_null == 0: + mem_ptr[buffer + len(encoded)] = 0 + + def _build_imports(self): + """Build the WASM import stubs required by the MonaLisa module.""" + + def sys_fcntl64(a, b, c): + return 0 + + def fd_write(a, b, c, d): + return 0 + + def fd_close(a): + return 0 + + def sys_ioctl(a, b, c): + return 0 + + def sys_open(a, b, c): + return 0 + + def sys_rmdir(a): + return 0 + + def sys_unlink(a): + return 0 + + def clock(): + return 0 + + def time(a): + return 0 + + def emscripten_run_script(a): + return None + + def fd_seek(a, b, c, d, e): + return 0 + + def emscripten_resize_heap(a): + return 0 + + def fd_read(a, b, c, d): + return 0 + + def emscripten_run_script_string(a): + return 0 + + def emscripten_run_script_int(a): + return 1 + + def emscripten_memcpy_big(dest, src, num): + mem_data = self.memory.data_ptr(self.store) + data_len = self.memory.data_len(self.store) + if num is None: + num = data_len - 1 + mem_ptr = ctypes.cast(mem_data, ctypes.POINTER(ctypes.c_ubyte)) + for i in range(num): + if dest + i < data_len and src + i < data_len: + mem_ptr[dest + i] = mem_ptr[src + i] + return dest + + def environ_get(environ_ptr, environ_buf): + buf_size = 0 + for index, string in enumerate(self.ENV_STRINGS): + ptr = environ_buf + buf_size + self._write_i32(environ_ptr + index * 4, ptr) + self._write_ascii_to_memory(string, ptr) + buf_size += len(string) + 1 + return 0 + + def environ_sizes_get(penviron_count, penviron_buf_size): + self._write_i32(penviron_count, len(self.ENV_STRINGS)) + buf_size = sum(len(s) + 1 for s in self.ENV_STRINGS) + self._write_i32(penviron_buf_size, buf_size) + return 0 + + i32 = wasmtime.ValType.i32() + + return [ + wasmtime.Func(self.store, wasmtime.FuncType([i32, i32, i32], [i32]), sys_fcntl64), + wasmtime.Func(self.store, wasmtime.FuncType([i32, i32, i32, i32], [i32]), fd_write), + wasmtime.Func(self.store, wasmtime.FuncType([i32], [i32]), fd_close), + wasmtime.Func(self.store, wasmtime.FuncType([i32, i32, i32], [i32]), sys_ioctl), + wasmtime.Func(self.store, wasmtime.FuncType([i32, i32, i32], [i32]), sys_open), + wasmtime.Func(self.store, wasmtime.FuncType([i32], [i32]), sys_rmdir), + wasmtime.Func(self.store, wasmtime.FuncType([i32], [i32]), sys_unlink), + wasmtime.Func(self.store, wasmtime.FuncType([], [i32]), clock), + wasmtime.Func(self.store, wasmtime.FuncType([i32], [i32]), time), + wasmtime.Func(self.store, wasmtime.FuncType([i32], []), emscripten_run_script), + wasmtime.Func(self.store, wasmtime.FuncType([i32, i32, i32, i32, i32], [i32]), fd_seek), + wasmtime.Func(self.store, wasmtime.FuncType([i32, i32, i32], [i32]), emscripten_memcpy_big), + wasmtime.Func(self.store, wasmtime.FuncType([i32], [i32]), emscripten_resize_heap), + wasmtime.Func(self.store, wasmtime.FuncType([i32, i32], [i32]), environ_get), + wasmtime.Func(self.store, wasmtime.FuncType([i32, i32], [i32]), environ_sizes_get), + wasmtime.Func(self.store, wasmtime.FuncType([i32, i32, i32, i32], [i32]), fd_read), + wasmtime.Func(self.store, wasmtime.FuncType([i32], [i32]), emscripten_run_script_string), + wasmtime.Func(self.store, wasmtime.FuncType([i32], [i32]), emscripten_run_script_int), + self.memory, + ] diff --git a/unshackle/core/drm/__init__.py b/unshackle/core/drm/__init__.py index 7622ae6..d94e912 100644 --- a/unshackle/core/drm/__init__.py +++ b/unshackle/core/drm/__init__.py @@ -1,10 +1,11 @@ from typing import Union from unshackle.core.drm.clearkey import ClearKey +from unshackle.core.drm.monalisa import MonaLisa from unshackle.core.drm.playready import PlayReady from unshackle.core.drm.widevine import Widevine -DRM_T = Union[ClearKey, Widevine, PlayReady] +DRM_T = Union[ClearKey, Widevine, PlayReady, MonaLisa] -__all__ = ("ClearKey", "Widevine", "PlayReady", "DRM_T") +__all__ = ("ClearKey", "Widevine", "PlayReady", "MonaLisa", "DRM_T") diff --git a/unshackle/core/drm/monalisa.py b/unshackle/core/drm/monalisa.py new file mode 100644 index 0000000..f89d764 --- /dev/null +++ b/unshackle/core/drm/monalisa.py @@ -0,0 +1,280 @@ +""" +MonaLisa DRM System. + +A WASM-based DRM system that uses local key extraction and two-stage +segment decryption (ML-Worker binary + AES-ECB). +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from typing import Any, Optional, Union +from uuid import UUID + +from Cryptodome.Cipher import AES +from Cryptodome.Util.Padding import unpad + + +class MonaLisa: + """ + MonaLisa DRM System. + + Unlike Widevine/PlayReady, MonaLisa does not use a challenge/response flow + with a license server. Instead, the PSSH value (ticket) is provided directly + by the service API, and keys are extracted locally via a WASM module. + + Decryption is performed in two stages: + 1. ML-Worker binary: Removes MonaLisa encryption layer (bbts -> ents) + 2. AES-ECB decryption: Final decryption with service-provided key + """ + + class Exceptions: + class TicketNotFound(Exception): + """Raised when no PSSH/ticket data is provided.""" + + class KeyExtractionFailed(Exception): + """Raised when key extraction from the ticket fails.""" + + class WorkerNotFound(Exception): + """Raised when the ML-Worker binary is not found.""" + + class DecryptionFailed(Exception): + """Raised when segment decryption fails.""" + + def __init__( + self, + ticket: Union[str, bytes], + aes_key: Union[str, bytes], + device_path: Path, + **kwargs: Any, + ): + """ + Initialize MonaLisa DRM. + + Args: + ticket: PSSH value from service API (base64 string or raw bytes). + aes_key: AES-ECB key for second-stage decryption (hex string or bytes). + device_path: Path to the CDM device file (.mld). + **kwargs: Additional metadata stored in self.data. + + Raises: + TicketNotFound: If ticket/PSSH is empty. + KeyExtractionFailed: If key extraction fails. + """ + if not ticket: + raise MonaLisa.Exceptions.TicketNotFound("No PSSH/ticket data provided.") + + self._ticket = ticket + + # Store AES key for second-stage decryption + if isinstance(aes_key, str): + self._aes_key = bytes.fromhex(aes_key) + else: + self._aes_key = aes_key + + self._device_path = device_path + self._kid: Optional[UUID] = None + self._key: Optional[str] = None + self.data: dict = kwargs or {} + + # Extract keys immediately + self._extract_keys() + + def _extract_keys(self) -> None: + """Extract keys from the ticket using the MonaLisa CDM.""" + # Import here to avoid circular import + from unshackle.core.cdm.monalisa import MonaLisaCDM + + try: + cdm = MonaLisaCDM(device_path=self._device_path) + session_id = cdm.open() + try: + keys = cdm.extract_keys(self._ticket) + if keys: + kid_hex = keys.get("kid") + if kid_hex: + self._kid = UUID(hex=kid_hex) + self._key = keys.get("key") + finally: + cdm.close(session_id) + except Exception as e: + raise MonaLisa.Exceptions.KeyExtractionFailed(f"Failed to extract keys: {e}") + + @classmethod + def from_ticket( + cls, + ticket: Union[str, bytes], + aes_key: Union[str, bytes], + device_path: Path, + ) -> MonaLisa: + """ + Create a MonaLisa DRM instance from a PSSH/ticket. + + Args: + ticket: PSSH value from service API. + aes_key: AES-ECB key for second-stage decryption. + device_path: Path to the CDM device file (.mld). + + Returns: + MonaLisa DRM instance with extracted keys. + """ + return cls(ticket=ticket, aes_key=aes_key, device_path=device_path) + + @property + def kid(self) -> Optional[UUID]: + """Get the Key ID.""" + return self._kid + + @property + def key(self) -> Optional[str]: + """Get the content key as hex string.""" + return self._key + + @property + def pssh(self) -> str: + """ + Get the raw PSSH/ticket value as a string. + + Returns: + The raw PSSH value as a base64 string. + """ + if isinstance(self._ticket, bytes): + return self._ticket.decode("utf-8") + return self._ticket + + @property + def content_id(self) -> Optional[str]: + """ + Extract the Content ID from the PSSH for display. + + The PSSH contains an embedded Content ID at bytes 21-75 with format: + H5DCID-V3-P1-YYYYMMDD-HHMMSS-MEDIAID-TIMESTAMP-SUFFIX + + Returns: + The Content ID string if extractable, None otherwise. + """ + import base64 + + try: + # Decode base64 PSSH to get raw bytes + if isinstance(self._ticket, bytes): + data = self._ticket + else: + data = base64.b64decode(self._ticket) + + # Content ID is at bytes 21-75 (55 bytes) + if len(data) >= 76: + content_id = data[21:76].decode("ascii") + # Validate it looks like a content ID + if content_id.startswith("H5DCID-"): + return content_id + except Exception: + pass + + return None + + @property + def content_keys(self) -> dict[UUID, str]: + """ + Get content keys in the same format as Widevine/PlayReady. + + Returns: + Dictionary mapping KID to key hex string. + """ + if self._kid and self._key: + return {self._kid: self._key} + return {} + + def decrypt_segment(self, segment_path: Path) -> None: + """ + Decrypt a single segment using two-stage decryption. + + Stage 1: ML-Worker binary (bbts -> ents) + Stage 2: AES-ECB decryption (ents -> ts) + + Args: + segment_path: Path to the encrypted segment file. + + Raises: + WorkerNotFound: If ML-Worker binary is not available. + DecryptionFailed: If decryption fails at any stage. + """ + if not self._key: + return + + # Import here to avoid circular import + from unshackle.core.cdm.monalisa import MonaLisaCDM + + worker_path = MonaLisaCDM.get_worker_path() + if not worker_path or not worker_path.exists(): + raise MonaLisa.Exceptions.WorkerNotFound("ML-Worker not found.") + + bbts_path = segment_path.with_suffix(".bbts") + ents_path = segment_path.with_suffix(".ents") + + try: + if segment_path.exists(): + segment_path.replace(bbts_path) + else: + raise MonaLisa.Exceptions.DecryptionFailed(f"Segment file does not exist: {segment_path}") + + # Stage 1: ML-Worker decryption + cmd = [str(worker_path), self._key, str(bbts_path), str(ents_path)] + + startupinfo = None + if sys.platform == "win32": + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + + process = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + startupinfo=startupinfo, + ) + + if process.returncode != 0: + raise MonaLisa.Exceptions.DecryptionFailed( + f"ML-Worker failed for {segment_path.name}: {process.stderr}" + ) + + if not ents_path.exists(): + raise MonaLisa.Exceptions.DecryptionFailed( + f"Decrypted .ents file was not created for {segment_path.name}" + ) + + # Stage 2: AES-ECB decryption + with open(ents_path, "rb") as f: + ents_data = f.read() + + crypto = AES.new(self._aes_key, AES.MODE_ECB) + decrypted_data = unpad(crypto.decrypt(ents_data), AES.block_size) + + # Write decrypted segment back to original path + with open(segment_path, "wb") as f: + f.write(decrypted_data) + + except MonaLisa.Exceptions.DecryptionFailed: + raise + except Exception as e: + raise MonaLisa.Exceptions.DecryptionFailed(f"Failed to decrypt segment {segment_path.name}: {e}") + finally: + if ents_path.exists(): + os.remove(ents_path) + if bbts_path != segment_path and bbts_path.exists(): + os.remove(bbts_path) + + def decrypt(self, _path: Path) -> None: + """ + MonaLisa uses per-segment decryption during download via the + on_segment_downloaded callback. By the time this method is called, + the content has already been decrypted and muxed into a container. + + Args: + path: Path to the file (ignored). + """ + pass diff --git a/unshackle/core/manifests/hls.py b/unshackle/core/manifests/hls.py index 2f3dd1f..86133c0 100644 --- a/unshackle/core/manifests/hls.py +++ b/unshackle/core/manifests/hls.py @@ -30,7 +30,7 @@ from requests import Session from unshackle.core import binaries from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack from unshackle.core.downloaders import requests as requests_downloader -from unshackle.core.drm import DRM_T, ClearKey, PlayReady, Widevine +from unshackle.core.drm import DRM_T, ClearKey, MonaLisa, PlayReady, Widevine from unshackle.core.events import events from unshackle.core.tracks import Audio, Subtitle, Tracks, Video from unshackle.core.utilities import get_debug_logger, get_extension, is_close_match, try_ensure_utf8 @@ -316,6 +316,10 @@ class HLS: progress(downloaded="[red]FAILED") raise + if not initial_drm_licensed and session_drm and isinstance(session_drm, MonaLisa): + if license_widevine: + license_widevine(session_drm) + if DOWNLOAD_LICENCE_ONLY.is_set(): progress(downloaded="[yellow]SKIPPED") return diff --git a/uv.lock b/uv.lock index 5f8c3c2..21487dd 100644 --- a/uv.lock +++ b/uv.lock @@ -1670,6 +1670,7 @@ dependencies = [ { name = "subtitle-filter" }, { name = "unidecode" }, { name = "urllib3" }, + { name = "wasmtime" }, ] [package.dev-dependencies] @@ -1727,6 +1728,7 @@ requires-dist = [ { name = "subtitle-filter", specifier = ">=1.4.9,<2" }, { name = "unidecode", specifier = ">=1.3.8,<2" }, { name = "urllib3", specifier = ">=2.6.3,<3" }, + { name = "wasmtime", specifier = ">=41.0.0" }, ] [package.metadata.requires-dev] @@ -1766,6 +1768,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, ] +[[package]] +name = "wasmtime" +version = "41.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/68/6dc0e7156f883afe0129dd89e4031c8d1163131794ba6ce9e454a09168ad/wasmtime-41.0.0.tar.gz", hash = "sha256:fc2aaacf3ba794eac8baeb739939b2f7903e12d6b78edddc0b7f3ac3a9af6dfc", size = 117354, upload-time = "2026-01-20T18:18:00.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/f9/f6aef5de536d12652d97cf162f124cbdd642150c7da61ffa7863272cdab7/wasmtime-41.0.0-py3-none-android_26_arm64_v8a.whl", hash = "sha256:f5a6e237b5b94188ef9867926b447f779f540c729c92e4d91cc946f2bee7c282", size = 6837018, upload-time = "2026-01-20T18:17:41.489Z" }, + { url = "https://files.pythonhosted.org/packages/04/b9/42ec977972b2dcc8c61e3a40644d24d229b41fba151410644e44e35e6eb1/wasmtime-41.0.0-py3-none-android_26_x86_64.whl", hash = "sha256:4a3e33d0d3cf49062eaa231f748f54af991e89e9a795c5ab9d4f0eee85736e4c", size = 7654957, upload-time = "2026-01-20T18:17:43.285Z" }, + { url = "https://files.pythonhosted.org/packages/18/ca/6cce49b03c35c7fecb4437fd98990c64694a5e0024f9279bef0ddef000f7/wasmtime-41.0.0-py3-none-any.whl", hash = "sha256:5f6721406a6cd186d11f34e6d4991c4d536387b0c577d09a56bd93b8a3cf10c2", size = 6325757, upload-time = "2026-01-20T18:17:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/d91cb80322cc7ae10bfa5db8cea4e0b9bb112f0c100b4486783ab16c1c22/wasmtime-41.0.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:2107360212fce33ed2adcfc33b7e75ed7136380a17d3ed598a5bab376dcf9e1b", size = 7471888, upload-time = "2026-01-20T18:17:46.185Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/dcc80973d2ec58a1978b838887ccbd84d56900cf66dec5fb730bec3bd081/wasmtime-41.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f475df32ce9bfec4f6d0e124a49ca4a89e2ee71ccca460677f5237b1c8ee92ae", size = 6507285, upload-time = "2026-01-20T18:17:48.138Z" }, + { url = "https://files.pythonhosted.org/packages/bd/df/0867edd9ec26eb2e5eee7674a55f82c23ec27dd1d38d2d401f0e308eb920/wasmtime-41.0.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ad7e866430313eb2ee07c85811e524344884489d00896f3b2246b65553fe322c", size = 7732024, upload-time = "2026-01-20T18:17:50.207Z" }, + { url = "https://files.pythonhosted.org/packages/bb/48/b748a2e70478feabc5c876d90e90a39f4aba35378f5ee822f607e8f29c69/wasmtime-41.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:e0ea44584f60dcfa620af82d4fc2589248bcf64a93905b54ac3144242113b48a", size = 6800017, upload-time = "2026-01-20T18:17:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/14/29/43656c3a464d437d62421de16f2de2db645647bab0a0153deea30bfdade4/wasmtime-41.0.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1dabb20a2751f01b835095013426a76091bd0bdb36ca9bcfc49c910b78347438", size = 6840763, upload-time = "2026-01-20T18:17:53.125Z" }, + { url = "https://files.pythonhosted.org/packages/9f/09/4608b65fa35ce5fc1479e138293a1166b4ea817cfa9a79f019ab6d7013d8/wasmtime-41.0.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d9627dfc5625b4947ea35c819561da358838fe76f65bda8ffe01ce34df8b32b1", size = 7754016, upload-time = "2026-01-20T18:17:55.346Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9d/236bb367270579e4f628fb7b04fe93541151df7953006f3766607fc667c9/wasmtime-41.0.0-py3-none-win_amd64.whl", hash = "sha256:4f29171d73b71f232b6fe86cba77526fee84139f1590071af5facba401b0c9eb", size = 6325764, upload-time = "2026-01-20T18:17:57.034Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/bba9c0368c377250ab24fd005a7a1e9076121778c1e83b1bcc092ab84f86/wasmtime-41.0.0-py3-none-win_arm64.whl", hash = "sha256:0c4bcaba055e78fc161f497b85f39f1d35d475f0341b1e0259fa0a4b49e223e8", size = 5392238, upload-time = "2026-01-20T18:17:59.052Z" }, +] + [[package]] name = "wcwidth" version = "0.3.3" From d4328f0eb725d1de124e7cc40b56e554bf5757c0 Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 31 Jan 2026 22:06:04 -0700 Subject: [PATCH 30/38] fix(binaries): search subdirectories for binary files Allow binaries to be found in subdirectories of the binaries folder. --- unshackle/core/binaries.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/unshackle/core/binaries.py b/unshackle/core/binaries.py index d984c0c..598387c 100644 --- a/unshackle/core/binaries.py +++ b/unshackle/core/binaries.py @@ -17,6 +17,10 @@ def find(*names: str) -> Optional[Path]: if local_binaries_dir.exists(): candidate_paths = [local_binaries_dir / f"{name}{ext}", local_binaries_dir / name / f"{name}{ext}"] + for subdir in local_binaries_dir.iterdir(): + if subdir.is_dir(): + candidate_paths.append(subdir / f"{name}{ext}") + for path in candidate_paths: if path.is_file(): # On Unix-like systems, check if file is executable From d0d8044fb30f5f46dd4af9bc176887a0a3f1190a Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 31 Jan 2026 23:51:57 -0700 Subject: [PATCH 31/38] feat(video): detect interlaced scan type from MPD manifests --- unshackle/core/manifests/dash.py | 6 ++++++ unshackle/core/titles/episode.py | 7 ++++++- unshackle/core/titles/movie.py | 7 ++++++- unshackle/core/tracks/video.py | 8 ++++++++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/unshackle/core/manifests/dash.py b/unshackle/core/manifests/dash.py index fd51557..3e7913b 100644 --- a/unshackle/core/manifests/dash.py +++ b/unshackle/core/manifests/dash.py @@ -151,6 +151,11 @@ class DASH: if not track_fps and segment_base is not None: track_fps = segment_base.get("timescale") + scan_type = None + scan_type_str = get("scanType") + if scan_type_str and scan_type_str.lower() == "interlaced": + scan_type = Video.ScanType.INTERLACED + track_args = dict( range_=self.get_video_range( codecs, findall("SupplementalProperty"), findall("EssentialProperty") @@ -159,6 +164,7 @@ class DASH: width=get("width") or 0, height=get("height") or 0, fps=track_fps or None, + scan_type=scan_type, ) elif content_type == "audio": track_type = Audio diff --git a/unshackle/core/titles/episode.py b/unshackle/core/titles/episode.py index 2217ea1..b80afe8 100644 --- a/unshackle/core/titles/episode.py +++ b/unshackle/core/titles/episode.py @@ -153,7 +153,12 @@ class Episode(Title): # likely a movie or HD source, so it's most likely widescreen so # 16:9 canvas makes the most sense. resolution = int(primary_video_track.width * (9 / 16)) - name += f" {resolution}p" + # Determine scan type suffix - default to "p", use "i" only if explicitly interlaced + scan_suffix = "p" + scan_type = getattr(primary_video_track, 'scan_type', None) + if scan_type and str(scan_type).lower() == "interlaced": + scan_suffix = "i" + name += f" {resolution}{scan_suffix}" # Service (use track source if available) if show_service: diff --git a/unshackle/core/titles/movie.py b/unshackle/core/titles/movie.py index 4e1d02c..2346a82 100644 --- a/unshackle/core/titles/movie.py +++ b/unshackle/core/titles/movie.py @@ -88,7 +88,12 @@ class Movie(Title): # likely a movie or HD source, so it's most likely widescreen so # 16:9 canvas makes the most sense. resolution = int(primary_video_track.width * (9 / 16)) - name += f" {resolution}p" + # Determine scan type suffix - default to "p", use "i" only if explicitly interlaced + scan_suffix = "p" + scan_type = getattr(primary_video_track, 'scan_type', None) + if scan_type and str(scan_type).lower() == "interlaced": + scan_suffix = "i" + name += f" {resolution}{scan_suffix}" # Service (use track source if available) if show_service: diff --git a/unshackle/core/tracks/video.py b/unshackle/core/tracks/video.py index 5c54631..79fbb92 100644 --- a/unshackle/core/tracks/video.py +++ b/unshackle/core/tracks/video.py @@ -186,6 +186,10 @@ class Video(Track): # for some reason there's no Dolby Vision info tag raise ValueError(f"The M3U Range Tag '{tag}' is not a supported Video Range") + class ScanType(str, Enum): + PROGRESSIVE = "progressive" + INTERLACED = "interlaced" + def __init__( self, *args: Any, @@ -195,6 +199,7 @@ class Video(Track): width: Optional[int] = None, height: Optional[int] = None, fps: Optional[Union[str, int, float]] = None, + scan_type: Optional[Video.ScanType] = None, **kwargs: Any, ) -> None: """ @@ -232,6 +237,8 @@ class Video(Track): raise TypeError(f"Expected height to be a {int}, not {height!r}") if not isinstance(fps, (str, int, float, type(None))): raise TypeError(f"Expected fps to be a {str}, {int}, or {float}, not {fps!r}") + if not isinstance(scan_type, (Video.ScanType, type(None))): + raise TypeError(f"Expected scan_type to be a {Video.ScanType}, not {scan_type!r}") self.codec = codec self.range = range_ or Video.Range.SDR @@ -256,6 +263,7 @@ class Video(Track): except Exception as e: raise ValueError("Expected fps to be a number, float, or a string as numerator/denominator form, " + str(e)) + self.scan_type = scan_type self.needs_duration_fix = False def __str__(self) -> str: From 6dd1ce6df9b085a20a384f3349463618b2ea7379 Mon Sep 17 00:00:00 2001 From: Andy Date: Sun, 1 Feb 2026 11:12:29 -0700 Subject: [PATCH 32/38] fix(dash): handle high startNumber in SegmentTimeline for DVR manifests When a DASH manifest has a high startNumber (common in DVR/catch-up content from live streams), the segment range calculation would produce an empty range because end_number was set to len(segment_durations) rather than being offset by startNumber. --- unshackle/core/manifests/dash.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/unshackle/core/manifests/dash.py b/unshackle/core/manifests/dash.py index 3e7913b..a2c92a3 100644 --- a/unshackle/core/manifests/dash.py +++ b/unshackle/core/manifests/dash.py @@ -372,6 +372,9 @@ class DASH: if not end_number: end_number = len(segment_durations) + # Handle high startNumber in DVR/catch-up manifests where startNumber > segment count + if start_number > end_number: + end_number = start_number + len(segment_durations) - 1 for t, n in zip(segment_durations, range(start_number, end_number + 1)): segments.append( From a07191ac4f35274edeeee802032841f685baa052 Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 31 Jan 2026 22:06:04 -0700 Subject: [PATCH 33/38] fix(binaries): search subdirectories for binary files Allow binaries to be found in subdirectories of the binaries folder. --- unshackle/core/binaries.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/unshackle/core/binaries.py b/unshackle/core/binaries.py index d984c0c..598387c 100644 --- a/unshackle/core/binaries.py +++ b/unshackle/core/binaries.py @@ -17,6 +17,10 @@ def find(*names: str) -> Optional[Path]: if local_binaries_dir.exists(): candidate_paths = [local_binaries_dir / f"{name}{ext}", local_binaries_dir / name / f"{name}{ext}"] + for subdir in local_binaries_dir.iterdir(): + if subdir.is_dir(): + candidate_paths.append(subdir / f"{name}{ext}") + for path in candidate_paths: if path.is_file(): # On Unix-like systems, check if file is executable From ecedcb93eb446a0202938ffda9242de3a1bf1de3 Mon Sep 17 00:00:00 2001 From: Andy Date: Mon, 2 Feb 2026 08:24:13 -0700 Subject: [PATCH 34/38] fix(drm): hide Shaka Packager message for MonaLisa decryption --- unshackle/commands/dl.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index b4c5434..f3eda8d 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -1634,7 +1634,8 @@ class dl: drm = track.get_drm_for_cdm(self.cdm) if drm and hasattr(drm, "decrypt"): drm.decrypt(track.path) - has_decrypted = True + if not isinstance(drm, MonaLisa): + has_decrypted = True events.emit(events.Types.TRACK_REPACKED, track=track) else: self.log.warning( From cc55fd89228a5b0f1f785aed30ca9208da08d99b Mon Sep 17 00:00:00 2001 From: Andy Date: Mon, 2 Feb 2026 10:59:15 -0700 Subject: [PATCH 35/38] fix(dash): add CENC namespace support for PSSH extraction Some MPD manifests use the cenc: namespace prefix for PSSH elements (e.g., ) instead of non-namespaced . This caused DRM extraction to fail for services. - Add {urn:mpeg:cenc:2013}pssh fallback for Widevine PSSH extraction - Add {urn:mpeg:cenc:2013}pssh fallback for PlayReady PSSH extraction --- unshackle/core/manifests/dash.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/unshackle/core/manifests/dash.py b/unshackle/core/manifests/dash.py index a2c92a3..caebcc9 100644 --- a/unshackle/core/manifests/dash.py +++ b/unshackle/core/manifests/dash.py @@ -866,7 +866,7 @@ class DASH: urn = (protection.get("schemeIdUri") or "").lower() if urn == WidevineCdm.urn: - pssh_text = protection.findtext("pssh") + pssh_text = protection.findtext("pssh") or protection.findtext("{urn:mpeg:cenc:2013}pssh") if not pssh_text: continue pssh = PSSH(pssh_text) @@ -897,6 +897,7 @@ class DASH: elif urn in ("urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95", "urn:microsoft:playready"): pr_pssh_b64 = ( protection.findtext("pssh") + or protection.findtext("{urn:mpeg:cenc:2013}pssh") or protection.findtext("pro") or protection.findtext("{urn:microsoft:playready}pro") ) From 1cde8964c1bd71807b446d715479f62502b9a1ca Mon Sep 17 00:00:00 2001 From: Andy Date: Mon, 2 Feb 2026 12:02:16 -0700 Subject: [PATCH 36/38] fix(dash): preserve MPD DRM instead of overwriting from init segment The init_data DRM extraction was unconditionally overwriting DRM already extracted from MPD ContentProtection elements. This caused failures when init segments contain malformed PSSH data while the MPD has valid PSSH. Now only falls back to init_data extraction when no DRM was found from the manifest, matching the behavior in version 2.1.0. --- unshackle/core/manifests/dash.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unshackle/core/manifests/dash.py b/unshackle/core/manifests/dash.py index caebcc9..c8a7ef7 100644 --- a/unshackle/core/manifests/dash.py +++ b/unshackle/core/manifests/dash.py @@ -476,7 +476,7 @@ class DASH: track.data["dash"]["timescale"] = int(segment_timescale) track.data["dash"]["segment_durations"] = segment_durations - if init_data and isinstance(track, (Video, Audio)): + if not track.drm and init_data and isinstance(track, (Video, Audio)): prefers_playready = isinstance(cdm, PlayReadyCdm) or (hasattr(cdm, "is_playready") and cdm.is_playready) if prefers_playready: try: From 5b9be075de734a860c840a137690dc566a9cddac Mon Sep 17 00:00:00 2001 From: Andy Date: Mon, 2 Feb 2026 20:51:09 -0700 Subject: [PATCH 37/38] feat(audio): codec lists and split muxing --- unshackle/commands/dl.py | 124 +++++++++++++++++++------ unshackle/core/api/download_manager.py | 1 + unshackle/core/api/handlers.py | 14 ++- unshackle/core/api/routes.py | 2 +- unshackle/core/utils/click_types.py | 49 ++++++++++ unshackle/unshackle-example.yaml | 5 + 6 files changed, 164 insertions(+), 31 deletions(-) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index f3eda8d..85421d3 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -61,8 +61,8 @@ from unshackle.core.tracks.hybrid import Hybrid from unshackle.core.utilities import (find_font_with_fallbacks, get_debug_logger, get_system_fonts, init_debug_logger, is_close_match, suggest_font_packages, time_elapsed_since) from unshackle.core.utils import tags -from unshackle.core.utils.click_types import (LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, ContextData, MultipleChoice, - SubtitleCodecChoice, VideoCodecChoice) +from unshackle.core.utils.click_types import (AUDIO_CODEC_LIST, LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, + ContextData, MultipleChoice, SubtitleCodecChoice, VideoCodecChoice) from unshackle.core.utils.collections import merge_dict from unshackle.core.utils.subprocess import ffprobe from unshackle.core.vaults import Vaults @@ -204,9 +204,9 @@ class dl: @click.option( "-a", "--acodec", - type=click.Choice(Audio.Codec, case_sensitive=False), - default=None, - help="Audio Codec to download, defaults to any codec.", + type=AUDIO_CODEC_LIST, + default=[], + help="Audio Codec(s) to download (comma-separated), e.g., 'AAC,EC3'. Defaults to any.", ) @click.option( "-vb", @@ -245,6 +245,13 @@ class dl: default=False, help="Exclude Dolby Atmos audio tracks when selecting audio.", ) + @click.option( + "--split-audio", + "split_audio", + is_flag=True, + default=None, + help="Create separate output files per audio codec instead of merging all audio.", + ) @click.option( "-w", "--wanted", @@ -751,7 +758,7 @@ class dl: service: Service, quality: list[int], vcodec: Optional[Video.Codec], - acodec: Optional[Audio.Codec], + acodec: list[Audio.Codec], vbitrate: int, abitrate: int, range_: list[Video.Range], @@ -789,6 +796,7 @@ class dl: workers: Optional[int], downloads: int, best_available: bool, + split_audio: Optional[bool] = None, *_: Any, **__: Any, ) -> None: @@ -796,6 +804,15 @@ class dl: self.search_source = None start_time = time.time() + if not acodec: + acodec = [] + elif isinstance(acodec, Audio.Codec): + acodec = [acodec] + elif isinstance(acodec, str) or ( + isinstance(acodec, list) and not all(isinstance(v, Audio.Codec) for v in acodec) + ): + acodec = AUDIO_CODEC_LIST.convert(acodec) + if require_subs and s_lang != ["all"]: self.log.error("--require-subs and --s-lang cannot be used together") sys.exit(1) @@ -1307,9 +1324,10 @@ class dl: if not audio_description: title.tracks.select_audio(lambda x: not x.descriptive) # exclude descriptive audio if acodec: - title.tracks.select_audio(lambda x: x.codec == acodec) + title.tracks.select_audio(lambda x: x.codec in acodec) if not title.tracks.audio: - self.log.error(f"There's no {acodec.name} Audio Tracks...") + codec_names = ", ".join(c.name for c in acodec) + self.log.error(f"No audio tracks matching codecs: {codec_names}") sys.exit(1) if channels: title.tracks.select_audio(lambda x: math.ceil(x.channels) == math.ceil(channels)) @@ -1348,15 +1366,27 @@ class dl: if "best" in processed_lang: unique_languages = {track.language for track in title.tracks.audio} selected_audio = [] - for language in unique_languages: - highest_quality = max( - (track for track in title.tracks.audio if track.language == language), - key=lambda x: x.bitrate or 0, - ) - selected_audio.append(highest_quality) + if acodec and len(acodec) > 1: + for language in unique_languages: + for codec in acodec: + candidates = [ + track + for track in title.tracks.audio + if track.language == language and track.codec == codec + ] + if not candidates: + continue + selected_audio.append(max(candidates, key=lambda x: x.bitrate or 0)) + else: + for language in unique_languages: + highest_quality = max( + (track for track in title.tracks.audio if track.language == language), + key=lambda x: x.bitrate or 0, + ) + selected_audio.append(highest_quality) title.tracks.audio = selected_audio elif "all" not in processed_lang: - per_language = 1 + per_language = 0 if acodec and len(acodec) > 1 else 1 title.tracks.audio = title.tracks.by_language( title.tracks.audio, processed_lang, per_language=per_language, exact_match=exact_lang ) @@ -1657,6 +1687,7 @@ class dl: self.log.info("Repacked one or more tracks with FFMPEG") muxed_paths = [] + muxed_audio_codecs: dict[Path, Optional[Audio.Codec]] = {} if no_mux: # Skip muxing, handle individual track files @@ -1673,7 +1704,40 @@ class dl: console=console, ) - multiplex_tasks: list[tuple[TaskID, Tracks]] = [] + if split_audio is not None: + merge_audio = not split_audio + else: + merge_audio = config.muxing.get("merge_audio", True) + + multiplex_tasks: list[tuple[TaskID, Tracks, Optional[Audio.Codec]]] = [] + + def clone_tracks_for_audio(base_tracks: Tracks, audio_tracks: list[Audio]) -> Tracks: + task_tracks = Tracks() + task_tracks.videos = list(base_tracks.videos) + task_tracks.audio = audio_tracks + task_tracks.subtitles = list(base_tracks.subtitles) + task_tracks.chapters = base_tracks.chapters + task_tracks.attachments = list(base_tracks.attachments) + return task_tracks + + def enqueue_mux_tasks(task_description: str, base_tracks: Tracks) -> None: + if merge_audio or not base_tracks.audio: + task_id = progress.add_task(f"{task_description}...", total=None, start=False) + multiplex_tasks.append((task_id, base_tracks, None)) + return + + audio_by_codec: dict[Optional[Audio.Codec], list[Audio]] = {} + for audio_track in base_tracks.audio: + audio_by_codec.setdefault(audio_track.codec, []).append(audio_track) + + for audio_codec, codec_audio_tracks in audio_by_codec.items(): + description = task_description + if audio_codec: + description = f"{task_description} {audio_codec.name}" + + task_id = progress.add_task(f"{description}...", total=None, start=False) + task_tracks = clone_tracks_for_audio(base_tracks, codec_audio_tracks) + multiplex_tasks.append((task_id, task_tracks, audio_codec)) # Check if we're in hybrid mode if any(r == Video.Range.HYBRID for r in range_) and title.tracks.videos: @@ -1713,11 +1777,8 @@ class dl: if default_output.exists(): shutil.move(str(default_output), str(hybrid_output_path)) - # Create a mux task for this resolution - task_description = f"Multiplexing Hybrid HDR10+DV {resolution}p" - task_id = progress.add_task(f"{task_description}...", total=None, start=False) - # Create tracks with the hybrid video output for this resolution + task_description = f"Multiplexing Hybrid HDR10+DV {resolution}p" task_tracks = Tracks(title.tracks) + title.tracks.chapters + title.tracks.attachments # Create a new video track for the hybrid output @@ -1727,7 +1788,7 @@ class dl: hybrid_track.needs_duration_fix = True task_tracks.videos = [hybrid_track] - multiplex_tasks.append((task_id, task_tracks)) + enqueue_mux_tasks(task_description, task_tracks) console.print() else: @@ -1740,16 +1801,15 @@ class dl: if len(range_) > 1: task_description += f" {video_track.range.name}" - task_id = progress.add_task(f"{task_description}...", total=None, start=False) - task_tracks = Tracks(title.tracks) + title.tracks.chapters + title.tracks.attachments if video_track: task_tracks.videos = [video_track] - multiplex_tasks.append((task_id, task_tracks)) + enqueue_mux_tasks(task_description, task_tracks) with Live(Padding(progress, (0, 5, 1, 5)), console=console): - for task_id, task_tracks in multiplex_tasks: + mux_index = 0 + for task_id, task_tracks, audio_codec in multiplex_tasks: progress.start_task(task_id) # TODO: Needed? audio_expected = not video_only and not no_audio muxed_path, return_code, errors = task_tracks.mux( @@ -1759,7 +1819,16 @@ class dl: audio_expected=audio_expected, title_language=title.language, ) + if muxed_path.exists(): + mux_index += 1 + unique_path = muxed_path.with_name( + f"{muxed_path.stem}.{mux_index}{muxed_path.suffix}" + ) + if unique_path != muxed_path: + shutil.move(muxed_path, unique_path) + muxed_path = unique_path muxed_paths.append(muxed_path) + muxed_audio_codecs[muxed_path] = audio_codec if return_code >= 2: self.log.error(f"Failed to Mux video to Matroska file ({return_code}):") elif return_code == 1 or errors: @@ -1771,8 +1840,6 @@ class dl: self.log.warning(line) if return_code >= 2: sys.exit(1) - for video_track in task_tracks.videos: - video_track.delete() for track in title.tracks: track.delete() @@ -1847,6 +1914,9 @@ class dl: media_info = MediaInfo.parse(muxed_path) final_dir = config.directories.downloads final_filename = title.get_filename(media_info, show_service=not no_source) + audio_codec_suffix = muxed_audio_codecs.get(muxed_path) + if audio_codec_suffix: + final_filename = f"{final_filename}.{audio_codec_suffix.name}" if not no_folder and isinstance(title, (Episode, Song)): final_dir /= title.get_filename(media_info, show_service=not no_source, folder=True) diff --git a/unshackle/core/api/download_manager.py b/unshackle/core/api/download_manager.py index 2f45d44..0297181 100644 --- a/unshackle/core/api/download_manager.py +++ b/unshackle/core/api/download_manager.py @@ -227,6 +227,7 @@ def _perform_download( range_=params.get("range", ["SDR"]), channels=params.get("channels"), no_atmos=params.get("no_atmos", False), + split_audio=params.get("split_audio"), wanted=params.get("wanted", []), latest_episode=params.get("latest_episode", False), lang=params.get("lang", ["orig"]), diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 2d779e2..2704f9c 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -748,9 +748,17 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]: return f"Invalid vcodec: {data['vcodec']}. Must be one of: {', '.join(valid_vcodecs)}" if "acodec" in data and data["acodec"]: - valid_acodecs = ["AAC", "AC3", "EAC3", "OPUS", "FLAC", "ALAC", "VORBIS", "DTS"] - if data["acodec"].upper() not in valid_acodecs: - return f"Invalid acodec: {data['acodec']}. Must be one of: {', '.join(valid_acodecs)}" + valid_acodecs = ["AAC", "AC3", "EC3", "EAC3", "DD", "DD+", "AC4", "OPUS", "FLAC", "ALAC", "VORBIS", "OGG", "DTS"] + if isinstance(data["acodec"], str): + acodec_values = [v.strip() for v in data["acodec"].split(",") if v.strip()] + elif isinstance(data["acodec"], list): + acodec_values = [str(v).strip() for v in data["acodec"] if str(v).strip()] + else: + return "acodec must be a string or list" + + invalid = [value for value in acodec_values if value.upper() not in valid_acodecs] + if invalid: + return f"Invalid acodec: {', '.join(invalid)}. Must be one of: {', '.join(valid_acodecs)}" if "sub_format" in data and data["sub_format"]: valid_sub_formats = ["SRT", "VTT", "ASS", "SSA"] diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index d8ce5a4..164c7c8 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -416,7 +416,7 @@ async def download(request: web.Request) -> web.Response: description: Video codec to download (e.g., H264, H265, VP9, AV1) (default - None) acodec: type: string - description: Audio codec to download (e.g., AAC, AC3, EAC3) (default - None) + description: Audio codec(s) to download (e.g., AAC or AAC,EC3) (default - None) vbitrate: type: integer description: Video bitrate in kbps (default - None) diff --git a/unshackle/core/utils/click_types.py b/unshackle/core/utils/click_types.py index 8707732..64cea97 100644 --- a/unshackle/core/utils/click_types.py +++ b/unshackle/core/utils/click_types.py @@ -5,6 +5,8 @@ import click from click.shell_completion import CompletionItem from pywidevine.cdm import Cdm as WidevineCdm +from unshackle.core.tracks.audio import Audio + class VideoCodecChoice(click.Choice): """ @@ -241,6 +243,52 @@ class QualityList(click.ParamType): return sorted(resolutions, reverse=True) +class AudioCodecList(click.ParamType): + """Parses comma-separated audio codecs like 'AAC,EC3'.""" + + name = "audio_codec_list" + + def __init__(self, codec_enum): + self.codec_enum = codec_enum + self._name_to_codec: dict[str, Audio.Codec] = {} + for codec in codec_enum: + self._name_to_codec[codec.name.lower()] = codec + self._name_to_codec[codec.value.lower()] = codec + + aliases = { + "eac3": "EC3", + "ddp": "EC3", + "vorbis": "OGG", + } + for alias, target in aliases.items(): + if target in codec_enum.__members__: + self._name_to_codec[alias] = codec_enum[target] + + def convert(self, value: Any, param: Optional[click.Parameter] = None, ctx: Optional[click.Context] = None) -> list: + if not value: + return [] + if isinstance(value, self.codec_enum): + return [value] + if isinstance(value, list): + if all(isinstance(v, self.codec_enum) for v in value): + return value + values = [str(v).strip() for v in value] + else: + values = [v.strip() for v in str(value).split(",")] + + codecs = [] + for val in values: + if not val: + continue + key = val.lower() + if key in self._name_to_codec: + codecs.append(self._name_to_codec[key]) + else: + valid = sorted(set(self._name_to_codec.keys())) + self.fail(f"'{val}' is not valid. Choices: {', '.join(valid)}", param, ctx) + return list(dict.fromkeys(codecs)) # Remove duplicates, preserve order + + class MultipleChoice(click.Choice): """ The multiple choice type allows multiple values to be checked against @@ -288,5 +336,6 @@ class MultipleChoice(click.Choice): SEASON_RANGE = SeasonRange() LANGUAGE_RANGE = LanguageRange() QUALITY_LIST = QualityList() +AUDIO_CODEC_LIST = AudioCodecList(Audio.Codec) # VIDEO_CODEC_CHOICE will be created dynamically when imported diff --git a/unshackle/unshackle-example.yaml b/unshackle/unshackle-example.yaml index dcd78d4..66617f6 100644 --- a/unshackle/unshackle-example.yaml +++ b/unshackle/unshackle-example.yaml @@ -62,6 +62,11 @@ debug_keys: # Muxing configuration muxing: set_title: false + # merge_audio: Merge all audio tracks into each output file + # true (default): All selected audio in one MKV per quality + # false: Separate MKV per (quality, audio_codec) combination + # Example: Title.1080p.AAC.mkv, Title.1080p.EC3.mkv + merge_audio: true # Login credentials for each Service credentials: From cacb6950939405e09e204ee112f57af7f3e66d84 Mon Sep 17 00:00:00 2001 From: Andy Date: Mon, 2 Feb 2026 21:43:27 -0700 Subject: [PATCH 38/38] fix(subtitles): preserve sidecar originals Use original subtitle files for sidecar output while keeping muxed conversion behavior. Fixes #59 --- unshackle/commands/dl.py | 140 +++++++++++++++++++++++++++++++ unshackle/core/tracks/tracks.py | 59 ++++++------- unshackle/unshackle-example.yaml | 8 ++ 3 files changed, 179 insertions(+), 28 deletions(-) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 85421d3..cde73cc 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -179,6 +179,99 @@ class dl: self.log.info(f" $ sudo apt install {package_cmd}") self.log.info(f" → Provides: {', '.join(fonts)}") + def generate_sidecar_subtitle_path( + self, + subtitle: Subtitle, + base_filename: str, + output_dir: Path, + target_codec: Optional[Subtitle.Codec] = None, + source_path: Optional[Path] = None, + ) -> Path: + """Generate sidecar path: {base}.{lang}[.forced][.sdh].{ext}""" + lang_suffix = str(subtitle.language) if subtitle.language else "und" + forced_suffix = ".forced" if subtitle.forced else "" + sdh_suffix = ".sdh" if (subtitle.sdh or subtitle.cc) else "" + + extension = (target_codec or subtitle.codec or Subtitle.Codec.SubRip).extension + if ( + not target_codec + and not subtitle.codec + and source_path + and source_path.suffix + ): + extension = source_path.suffix.lstrip(".") + + filename = f"{base_filename}.{lang_suffix}{forced_suffix}{sdh_suffix}.{extension}" + return output_dir / filename + + def output_subtitle_sidecars( + self, + subtitles: list[Subtitle], + base_filename: str, + output_dir: Path, + sidecar_format: str, + original_paths: Optional[dict[str, Path]] = None, + ) -> list[Path]: + """Output subtitles as sidecar files, converting if needed.""" + created_paths: list[Path] = [] + config.directories.temp.mkdir(parents=True, exist_ok=True) + + for subtitle in subtitles: + source_path = subtitle.path + if sidecar_format == "original" and original_paths and subtitle.id in original_paths: + source_path = original_paths[subtitle.id] + + if not source_path or not source_path.exists(): + continue + + # Determine target codec + if sidecar_format == "original": + target_codec = None + if source_path.suffix: + try: + target_codec = Subtitle.Codec.from_mime(source_path.suffix.lstrip(".")) + except ValueError: + target_codec = None + else: + target_codec = Subtitle.Codec.from_mime(sidecar_format) + + sidecar_path = self.generate_sidecar_subtitle_path( + subtitle, base_filename, output_dir, target_codec, source_path=source_path + ) + + # Copy or convert + if not target_codec or subtitle.codec == target_codec: + shutil.copy2(source_path, sidecar_path) + else: + # Create temp copy for conversion to preserve original + temp_path = config.directories.temp / f"sidecar_{subtitle.id}{source_path.suffix}" + shutil.copy2(source_path, temp_path) + + temp_sub = Subtitle( + subtitle.url, + subtitle.language, + is_original_lang=subtitle.is_original_lang, + descriptor=subtitle.descriptor, + codec=subtitle.codec, + forced=subtitle.forced, + sdh=subtitle.sdh, + cc=subtitle.cc, + id_=f"{subtitle.id}_sc", + ) + temp_sub.path = temp_path + try: + temp_sub.convert(target_codec) + if temp_sub.path and temp_sub.path.exists(): + shutil.copy2(temp_sub.path, sidecar_path) + finally: + if temp_sub.path and temp_sub.path.exists(): + temp_sub.path.unlink(missing_ok=True) + temp_path.unlink(missing_ok=True) + + created_paths.append(sidecar_path) + + return created_paths + @click.command( short_help="Download, Decrypt, and Mux tracks for titles from a Service.", cls=Services, @@ -1626,6 +1719,25 @@ class dl: break video_track_n += 1 + # Subtitle output mode configuration (for sidecar originals) + subtitle_output_mode = config.subtitle.get("output_mode", "mux") + sidecar_format = config.subtitle.get("sidecar_format", "srt") + skip_subtitle_mux = ( + subtitle_output_mode == "sidecar" and (title.tracks.videos or title.tracks.audio) + ) + sidecar_subtitles: list[Subtitle] = [] + sidecar_original_paths: dict[str, Path] = {} + if subtitle_output_mode in ("sidecar", "both") and not no_mux: + sidecar_subtitles = [s for s in title.tracks.subtitles if s.path and s.path.exists()] + if sidecar_format == "original": + config.directories.temp.mkdir(parents=True, exist_ok=True) + for subtitle in sidecar_subtitles: + original_path = ( + config.directories.temp / f"sidecar_original_{subtitle.id}{subtitle.path.suffix}" + ) + shutil.copy2(subtitle.path, original_path) + sidecar_original_paths[subtitle.id] = original_path + with console.status("Converting Subtitles..."): for subtitle in title.tracks.subtitles: if sub_format: @@ -1818,6 +1930,7 @@ class dl: delete=False, audio_expected=audio_expected, title_language=title.language, + skip_subtitles=skip_subtitle_mux, ) if muxed_path.exists(): mux_index += 1 @@ -1840,6 +1953,31 @@ class dl: self.log.warning(line) if return_code >= 2: sys.exit(1) + + # Output sidecar subtitles before deleting track files + if sidecar_subtitles and not no_mux: + media_info = MediaInfo.parse(muxed_paths[0]) if muxed_paths else None + if media_info: + base_filename = title.get_filename(media_info, show_service=not no_source) + else: + base_filename = str(title) + + sidecar_dir = config.directories.downloads + if not no_folder and isinstance(title, (Episode, Song)) and media_info: + sidecar_dir /= title.get_filename(media_info, show_service=not no_source, folder=True) + sidecar_dir.mkdir(parents=True, exist_ok=True) + + with console.status("Saving subtitle sidecar files..."): + created = self.output_subtitle_sidecars( + sidecar_subtitles, + base_filename, + sidecar_dir, + sidecar_format, + original_paths=sidecar_original_paths or None, + ) + if created: + self.log.info(f"Saved {len(created)} sidecar subtitle files") + for track in title.tracks: track.delete() @@ -1853,6 +1991,8 @@ class dl: # Clean up temp fonts for temp_path in temp_font_files: temp_path.unlink(missing_ok=True) + for temp_path in sidecar_original_paths.values(): + temp_path.unlink(missing_ok=True) else: # dont mux diff --git a/unshackle/core/tracks/tracks.py b/unshackle/core/tracks/tracks.py index fd9d78d..ce41d10 100644 --- a/unshackle/core/tracks/tracks.py +++ b/unshackle/core/tracks/tracks.py @@ -314,6 +314,7 @@ class Tracks: progress: Optional[partial] = None, audio_expected: bool = True, title_language: Optional[Language] = None, + skip_subtitles: bool = False, ) -> tuple[Path, int, list[str]]: """ Multiplex all the Tracks into a Matroska Container file. @@ -328,6 +329,7 @@ class Tracks: if embedded audio metadata should be added. title_language: The title's intended language. Used to select the best video track for audio metadata when multiple video tracks exist. + skip_subtitles: Skip muxing subtitle tracks into the container. """ if self.videos and not self.audio and audio_expected: video_track = None @@ -439,34 +441,35 @@ class Tracks: ] ) - for st in self.subtitles: - if not st.path or not st.path.exists(): - raise ValueError("Text Track must be downloaded before muxing...") - events.emit(events.Types.TRACK_MULTIPLEX, track=st) - default = bool(self.audio and is_close_match(st.language, [self.audio[0].language]) and st.forced) - cl.extend( - [ - "--track-name", - f"0:{st.get_track_name() or ''}", - "--language", - f"0:{st.language}", - "--sub-charset", - "0:UTF-8", - "--forced-track", - f"0:{st.forced}", - "--default-track", - f"0:{default}", - "--hearing-impaired-flag", - f"0:{st.sdh}", - "--original-flag", - f"0:{st.is_original_lang}", - "--compression", - "0:none", # disable extra compression (probably zlib) - "(", - str(st.path), - ")", - ] - ) + if not skip_subtitles: + for st in self.subtitles: + if not st.path or not st.path.exists(): + raise ValueError("Text Track must be downloaded before muxing...") + events.emit(events.Types.TRACK_MULTIPLEX, track=st) + default = bool(self.audio and is_close_match(st.language, [self.audio[0].language]) and st.forced) + cl.extend( + [ + "--track-name", + f"0:{st.get_track_name() or ''}", + "--language", + f"0:{st.language}", + "--sub-charset", + "0:UTF-8", + "--forced-track", + f"0:{st.forced}", + "--default-track", + f"0:{default}", + "--hearing-impaired-flag", + f"0:{st.sdh}", + "--original-flag", + f"0:{st.is_original_lang}", + "--compression", + "0:none", # disable extra compression (probably zlib) + "(", + str(st.path), + ")", + ] + ) if self.chapters: chapters_path = config.directories.temp / config.filenames.chapters.format( diff --git a/unshackle/unshackle-example.yaml b/unshackle/unshackle-example.yaml index 66617f6..281140b 100644 --- a/unshackle/unshackle-example.yaml +++ b/unshackle/unshackle-example.yaml @@ -378,6 +378,14 @@ subtitle: # When true, skips pycaption processing for WebVTT files to keep tags like , , positioning intact # Combined with no sub_format setting, ensures subtitles remain in their original format (default: true) preserve_formatting: true + # output_mode: Output mode for subtitles + # - mux: Embed subtitles in MKV container only (default) + # - sidecar: Save subtitles as separate files only + # - both: Embed in MKV AND save as sidecar files + output_mode: mux + # sidecar_format: Format for sidecar subtitle files + # Options: srt, vtt, ass, original (keep current format) + sidecar_format: srt # Configuration for pywidevine and pyplayready's serve functionality serve: