diff --git a/README.md b/README.md index d9e73f3..7141575 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,17 @@ TwinVine is the easy way to handle your download tasks. **usage** - - + +TwinVine is a sophisticated piece of software engineering, (if I am allowed to say that) , handling two Python packages at once, each inter-playing with the other. It needs treating differently from anything you will have used before. + +All access must be done via the package manager - uv. You have two ways in + ``` - uv run envied dl (options) (service) (program url) - uv run vinefeeder - to open the interactive GUI for search, browse, etc. - To go to the command line use of 'envied' after using the GUI: close the GUI directly, or ctrl+c in terminal. + uv run vinefeeder + uv run envied dl --select-titles + + + To go to the command line use of 'envied', on Linix you may select 'run envied' after clicking the GUI envied button. On Windows, close the GUI directly, or ctrl+c to end the program and return to the terminal. ``` diff --git a/UPDATES.md b/UPDATES.md index a2e893c..388bdaa 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,10 +1,5 @@ **Updates** -**16 Feb 2026** - -- upstream envied to 3.0.0 - envied's --select-titles option now follows dev branch of unshackle. - **19 Jan 2026** - upstream envied to 2.3.0 diff --git a/packages/envied/pyproject.toml b/packages/envied/pyproject.toml index 0c8ef60..5a65ec3 100644 --- a/packages/envied/pyproject.toml +++ b/packages/envied/pyproject.toml @@ -4,10 +4,10 @@ build-backend = "uv_build" [project] name = "envied" -version = "3.0.0" +version = "4.0.0" description = "Modular Movie, TV, and Music Archival Software." authors = [{ name = "rlaphoenix and others" }] -requires-python = ">=3.10,<3.13" +requires-python = ">=3.10,<=3.14" readme = "README.md" license = "GPL-3.0-only" keywords = [ @@ -26,57 +26,60 @@ classifiers = [ "Topic :: Security :: Cryptography", ] dependencies = [ - "appdirs>=1.4.4,<2", - "Brotli>=1.1.0,<2", - "click>=8.1.8,<9", - "construct>=2.8.8,<3", - "crccheck>=1.3.0,<2", - "fonttools>=4.61.1,<5", - "jsonpickle>=4.1.1,<5", - "langcodes>=3.5.0,<4", - "lxml>=6.0.2,<7", - "pproxy>=2.7.9,<3", - "protobuf>=6.33.3,<7", - "pycaption>=2.2.6,<3", - "pycryptodomex>=3.23.0,<4", - "PyJWT>=2.10.1,<3", - "pymediainfo>=6.1.0,<8", - "pymp4>=1.4.0,<2", - "pymysql>=1.1.0,<2", - "pywidevine[serve]>=1.9.0,<2", - "PyYAML>=6.0.1,<7", - "requests[socks]>=2.32.5,<3", - "rich>=14.2.0,<15", - "rlaphoenix.m3u8>=3.4.0,<4", - "ruamel.yaml>=0.19.1,<0.20", - "sortedcontainers>=2.4.0,<3", - "subtitle-filter>=1.4.9,<2", - "Unidecode>=1.3.8,<2", - "urllib3>=2.6.2,<3", - "chardet>=5.2.0,<6", - - # curl-cffi is 0.x; keep a tighter cap to avoid accidental breaking changes. - "curl-cffi>=0.14.0,<0.15", - "pyplayready>=0.8.2,<0.9", - "httpx>=0.28.1,<0.29", - "cryptography>=45.0.6,<46.0.0", - - # These were unpinned in your list; consider adding bounds once you confirm versions you’ve tested with. - "subby", + + "appdirs>=1.4.4,<2", + "Brotli>=1.1.0,<2", + "click>=8.1.8,<9", + "construct>=2.8.8,<3", + "crccheck>=1.3.0,<2", + "filelock>=3.20.3,<4", + "fonttools>=4.60.2,<5", + "jsonpickle>=3.0.4,<5", + "langcodes>=3.4.0,<4", + "lxml>=5.2.1,<7", + "pproxy>=2.7.9,<3", + "protobuf>=4.25.3,<7", + "pycaption>=2.2.6,<3", + "pycryptodomex>=3.20.0,<4", + "pyjwt>=2.8.0,<3", + "pymediainfo>=6.1.0,<8", + "pymp4>=1.4.0,<2", + "pymysql>=1.1.0,<2", + "pywidevine[serve]>=1.8.0,<2", + "PyYAML>=6.0.1,<7", + "requests[socks]>=2.32.5,<3", + "rich>=13.7.1,<15", + "rlaphoenix.m3u8>=3.4.0,<4", + "ruamel.yaml>=0.18.6,<0.19", + "sortedcontainers>=2.4.0,<3", + "subtitle-filter>=1.4.9,<2", + "Unidecode>=1.3.8,<2", + "urllib3>=2.6.3,<3", + "chardet>=5.2.0,<6", + "curl-cffi>=0.7.0b4,<0.14", + "pyplayready>=0.8.3,<0.9", + "httpx>=0.28.1,<0.29", + "cryptography>=45.0.0,<47", + "subby", + "aiohttp>=3.13.3,<4", + "aiohttp-swagger3>=0.9.0,<1", + "pysubs2>=1.7.0,<2", + "PyExecJS>=1.5.1,<2", + "pycountry>=24.6.1", + "language-data>=1.4.0", + "wasmtime>=41.0.0", + "animeapi-py>=0.6.0", + # envied specifix "webvtt-py>=0.5.1,<0.6", "isodate>=0.7.2,<0.8", - #"beaupy>=3.10.2,<4", "scrapy>=2.14.0,<3", "mypy>=1.19.1,<2", - "pysubs2>=1.7.0,<2", - "PyExecJS>=1.5.1,<2", - "pycountry>=24.6.1", - "language-data>=1.4.0", - "wasmtime>=41.0.0", + + # Keeping your existing cap style here; latest visible is 0.10.0. - "aiohttp-swagger3>=0.10.0,<1", + #"aiohttp-swagger3>=0.10.0,<1", ] diff --git a/packages/envied/src/envied/CHANGELOG.md b/packages/envied/src/envied/CHANGELOG.md index 954da95..0d7f5d8 100644 --- a/packages/envied/src/envied/CHANGELOG.md +++ b/packages/envied/src/envied/CHANGELOG.md @@ -5,6 +5,158 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.0.0] - 2026-03-17 + +### Features + +- *hybrid*: Add L5 active area and dynamic L6 luminance metadata +- *debug*: Add JSONL debug logging to decryption, muxing, and all downloaders +- *debug*: Log binary tool versions at session start +- *dl*: Add --repack flag to insert REPACK tag in output filenames +- *core*: Add TrackRequest system for multi-codec/multi-range support +- *dl*: Add --output option to override download directory +- *tracks*: Add edition tags to output filenames +- *templates*: [**breaking**] Add customizable output filename templates +- *templates*: Add configurable language tagging rule engine +- Update unshackle version to 4.0.0 +- *dl*: Add --animeapi and --enrich options for anime metadata and tagging +- *dl*: Add skip messages for --no-audio and --no-chapters flags +- *dl*: Extract closed captions from HLS manifests and improve CC extraction +- *dl*: Add --worst flag and SHIELD OkHttp fingerprint preset + +### Bug Fixes + +- *n_m3u8dl_re*: Pass all content keys for DualKey DRM decryption +- *hybrid*: Skip bitrate filter for DV tracks in HYBRID mode +- *attachment*: Sanitize filenames with illegal Windows characters +- *hybrid*: Accept HDR10+ tracks as valid base layer for HYBRID mode +- *dl*: Allow selection of audio tracks for 'all' languages in addition to 'best' +- *dl*: Overwrite existing files on re-download and use atomic replace +- *dl*: Handle cross-device moves when temp and downloads differ +- *track*: Fallback to requests downloader from n_m3u8dl_re for unsupported track types +- *n_m3u8dl_re*: Disable segment count validation for duration-based DASH +- Correct formatting and add missing newlines in selector and EXAMPLE service +- *dependencies*: Update pyplayready version to 0.8.3 and adjust dependencies +- *drm*: Update PlayReady KID extraction for pyplayready 0.8.3 compatibility +- *api*: Resolve Sentinel serialization, missing params, and add search endpoint +- *dash*: Pass period_filter to n_m3u8dl_re via filtered MPD file +- *title*: Add HDR Vivid Format HDR Tag +- *ism*: Prevent duplicate track IDs for audio tracks with same lang/codec/bitrate +- *aria2c*: Correct progress bar tracking for HLS downloads +- *dl*: Filter CC subtitle languages with --s-lang and extract all manifest CCs + +### Documentation + +- Update and correct configuration documentation + +### Changes + +- *hybrid*: Replace log.info with console status and add JSONL debug logging +- *dl*: Remove legacy multi-fetch loop for unmigrated services +- *example*: Migrate EXAMPLE service to track_request pattern +- *providers*: Extract metadata providers into modular system + +## [3.0.0] - 2026-02-15 + +### Features + +- *titles*: Use track source attribute for service name in filenames +- *debug*: Add download output verification logging +- Gluetun VPN integration and remote service enhancements +- *gluetun*: Improve VPN connection display and Windscribe support +- *serve*: Add PlayReady CDM support alongside Widevine +- *cdm*: Add remote PlayReady CDM support via pyplayready RemoteCdm +- *env*: Add ML-Worker binary for DRM licensing +- *video*: Detect interlaced scan type from MPD manifests +- *drm*: Add MonaLisa DRM support to core infrastructure +- *audio*: Codec lists and split muxing +- *proxy*: Add specific server selection for WindscribeVPN +- *cdm*: Normalize CDM detection for local and remote implementations +- *HLS*: Improve audio codec handling with error handling for codec extraction +- *tracks*: Prioritize Atmos audio tracks over higher bitrate non-Atmos + +### Bug Fixes + +- *subs*: Update SubtitleEdit CLI syntax and respect conversion_method +- *n_m3u8dl_re*: Include language in DASH audio track selection +- *hls*: Prefer media playlist keys over session keys for accurate KID matching +- *deps*: Upgrade vulnerable dependencies for security alerts +- *serve*: Use correct pywidevine users config format +- *cdm*: Correct error key casing in Decrypt Labs API response parsing +- *api*: Validate Bearer prefix before extracting API key +- *serve*: Correct PlayReady RemoteCDM server validation +- *n_m3u8dl_re*: Remove duplicate --write-meta-json argument causing download failures +- *manifests*: Correct DRM type selection for remote PlayReady CDMs +- *proxies*: Fixes WindscribeVPN server authentication +- *subs*: Route pycaption-unsupported formats to pysubs2 in auto mode +- *proxy*: Remove regional restrictions from WindscribeVPN +- *proxy*: Collect servers from all locations in WindscribeVPN +- *downloader*: Correct progress bar tracking for segmented downloads +- *binaries*: Search subdirectories for binary files +- *dash*: Handle high startNumber in SegmentTimeline for DVR manifests +- *drm*: Hide Shaka Packager message for MonaLisa decryption +- *dash*: Add CENC namespace support for PSSH extraction +- *dash*: Preserve MPD DRM instead of overwriting from init segment +- *subtitles*: Preserve sidecar originals +- *mux*: Avoid audio codec suffix on split-audio outputs +- *dl*: Prevent attachment downloads during --skip-dl +- *progress*: Force track bar completion on terminal states +- *progress*: Bind per-track bars and force terminal completion +- *dl*: Keep descriptive and standard audio for requested langs +- *dl*: Always clean up hybrid temp hevc outputs +- *hls*: Finalize n_m3u8dl_re outputs +- *downloader*: Restore requests progress for single-url downloads +- *dl*: Invert audio codec suffixing when splitting +- *dl*: Support snake_case keys for RemoteCdm +- *aria2c*: Warn on config mismatch and wait for RPC ready +- *serve*: [**breaking**] Make PlayReady users config consistently a mapping +- *dl*: Preserve proxy_query selector (not resolved URI) +- *gluetun*: Stop leaking proxy/vpn secrets to process list +- *monalisa*: Avoid leaking secrets and add worker safety +- *dl*: Avoid selecting all variants when multiple audio codecs requested +- *hls*: Keep range offset numeric and align MonaLisa licensing +- *titles*: Remove trailing space from HDR dynamic range label +- *config*: Normalize playready_remote remote_cdm keys +- *titles*: Avoid None/double spaces in HDR tokens +- *naming*: Keep technical tokens with scene_naming off +- *api*: Log PSSH extraction failures +- *proxies*: Harden surfshark and windscribe selection +- *service*: Redact proxy credentials in logs +- *monalisa*: Harden wasm calls and license handling +- *hls*: Remove no-op encryption_data reassignment +- *serve*: Default PlayReady access to none +- *tracks*: Close temp session and improve path type error +- *main*: Update copyright year dynamically in version display + +### Reverts + +- *monalisa*: Pass key via argv again + +### Documentation + +- Add configuration documentation WIP +- *changelog*: Add 2.4.0 release notes +- *changelog*: Update cliff config and regenerate changelog +- *changelog*: Complete 2.4.0 notes +- *config*: Clarify sdh_method uses subtitle-filter + +### Performance Improvements + +- *aria2c*: Improve download performance with singleton manager + +### Changes + +- *remote_auth*: Remove unused requests.Session +- Remove remote-service code until feature is more complete + +### Maintenance + +- *api*: Remove remote services + +### Chore + +- *release*: [**breaking**] Bump version to 3.0.0 + ## [2.3.0] - 2026-01-18 ### Added diff --git a/packages/envied/src/envied/binaries/ML-Worker b/packages/envied/src/envied/binaries/ML-Worker new file mode 100644 index 0000000..fbc9b2b Binary files /dev/null and b/packages/envied/src/envied/binaries/ML-Worker differ diff --git a/packages/envied/src/envied/commands/dl.bak b/packages/envied/src/envied/commands/dl.bak new file mode 100644 index 0000000..479f137 --- /dev/null +++ b/packages/envied/src/envied/commands/dl.bak @@ -0,0 +1,2774 @@ +from __future__ import annotations + +import html +import logging +import math +import random +import re +import shutil +import subprocess +import sys +import time +from concurrent import futures +from concurrent.futures import ThreadPoolExecutor +from copy import deepcopy +from functools import partial +from http.cookiejar import CookieJar, MozillaCookieJar +from itertools import product +from pathlib import Path +from threading import Lock +from typing import Any, Callable, Optional +from uuid import UUID + +import click +import jsonpickle +import yaml +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 +from rich.console import Group +from rich.live import Live +from rich.padding import Padding +from rich.panel import Panel +from rich.progress import BarColumn, Progress, SpinnerColumn, TaskID, TextColumn, TimeRemainingColumn +from rich.rule import Rule +from rich.table import Table +from rich.text import Text +from rich.tree import Tree + +from envied.core import binaries +from envied.core.cdm import CustomRemoteCDM, DecryptLabsRemoteCDM +from envied.core.cdm.detect import is_playready_cdm, is_widevine_cdm +from envied.core.config import config +from envied.core.console import console +from envied.core.constants import DOWNLOAD_LICENCE_ONLY, AnyTrack, context_settings +from envied.core.credential import Credential +from envied.core.drm import DRM_T, MonaLisa, PlayReady, Widevine +from envied.core.events import events +from envied.core.proxies import Basic, Gluetun, Hola, NordVPN, SurfsharkVPN, WindscribeVPN +from envied.core.service import Service +from envied.core.services import Services +from envied.core.title_cacher import get_account_hash +from envied.core.titles import Movie, Movies, Series, Song, Title_T +from envied.core.titles.episode import Episode +from envied.core.tracks import Audio, Subtitle, Tracks, Video +from envied.core.tracks.attachment import Attachment +from envied.core.tracks.hybrid import Hybrid +from envied.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 envied.core.utils import tags +from envied.core.utils.click_types import (AUDIO_CODEC_LIST, LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, + ContextData, MultipleChoice, SubtitleCodecChoice, VideoCodecChoice) +from envied.core.utils.collections import merge_dict +from envied.core.utils.subprocess import ffprobe +from envied.core.vaults import Vaults + + +class dl: + @staticmethod + def truncate_pssh_for_display(pssh_string: str, drm_type: str) -> str: + """Truncate PSSH string for display when not in debug mode.""" + if logging.root.level == logging.DEBUG or not pssh_string: + return pssh_string + + max_width = console.width - len(drm_type) - 12 + if len(pssh_string) <= max_width: + return pssh_string + + return pssh_string[: max_width - 3] + "..." + + def find_custom_font(self, font_name: str) -> Optional[Path]: + """ + Find font in custom fonts directory. + + Args: + font_name: Font family name to find + + Returns: + Path to font file, or None if not found + """ + family_dir = Path(config.directories.fonts, font_name) + if family_dir.exists(): + fonts = list(family_dir.glob("*.*tf")) + return fonts[0] if fonts else None + return None + + def prepare_temp_font( + 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. + + Args: + font_name: Requested font name + matched_font: Path to matched system font + system_fonts: Dictionary of available system fonts + temp_font_files: List to track temp files for cleanup + + Returns: + 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) + + if matched_name and matched_name.lower() != font_name.lower(): + self.log.info(f"Using '{matched_name}' as fallback for '{font_name}'") + + # Create unique temp file path + safe_name = font_name.replace(" ", "_").replace("/", "_") + temp_path = config.directories.temp / f"font_{safe_name}{matched_font.suffix}" + + # Copy if not already exists + if not temp_path.exists(): + shutil.copy2(matched_font, temp_path) + temp_font_files.append(temp_path) + + return temp_path + + def attach_subtitle_fonts( + self, font_names: list[str], title: Title_T, temp_font_files: list[Path] + ) -> tuple[int, list[str]]: + """ + Attach fonts for subtitle rendering. + + Args: + font_names: List of font names requested by subtitles + title: Title object to attach fonts to + temp_font_files: List to track temp files for cleanup + + Returns: + Tuple of (fonts_attached_count, missing_fonts_list) + """ + system_fonts = get_system_fonts() + + font_count = 0 + missing_fonts = [] + + for font_name in set(font_names): + # Try custom fonts first + if custom_font := self.find_custom_font(font_name): + title.tracks.add(Attachment(path=custom_font, name=f"{font_name} ({custom_font.stem})")) + font_count += 1 + continue + + # Try system fonts with fallback + if system_font := find_font_with_fallbacks(font_name, system_fonts): + temp_path = self.prepare_temp_font(font_name, system_font, system_fonts, temp_font_files) + title.tracks.add(Attachment(path=temp_path, name=f"{font_name} ({system_font.stem})")) + font_count += 1 + else: + self.log.warning(f"Subtitle uses font '{font_name}' but it could not be found") + missing_fonts.append(font_name) + + return font_count, missing_fonts + + def suggest_missing_fonts(self, missing_fonts: list[str]) -> None: + """ + Show package installation suggestions for missing fonts. + + Args: + missing_fonts: List of font names that couldn't be found + """ + if suggestions := suggest_font_packages(missing_fonts): + self.log.info("Install font packages to improve subtitle rendering:") + for package_cmd, fonts in suggestions.items(): + 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, + context_settings=dict(**context_settings, default_map=config.dl, token_normalize_func=Services.get_tag), + ) + @click.option( + "-p", "--profile", type=str, default=None, help="Profile to use for Credentials and Cookies (if available)." + ) + @click.option( + "-q", + "--quality", + type=QUALITY_LIST, + default=[], + help="Download Resolution(s), defaults to the best available resolution.", + ) + @click.option( + "-v", + "--vcodec", + type=VideoCodecChoice(Video.Codec), + default=None, + help="Video Codec to download, defaults to any codec.", + ) + @click.option( + "-a", + "--acodec", + type=AUDIO_CODEC_LIST, + default=[], + help="Audio Codec(s) to download (comma-separated), e.g., 'AAC,EC3'. Defaults to any.", + ) + @click.option( + "-vb", + "--vbitrate", + type=int, + default=None, + help="Video Bitrate to download (in kbps), defaults to highest available.", + ) + @click.option( + "-ab", + "--abitrate", + type=int, + default=None, + help="Audio Bitrate to download (in kbps), defaults to highest available.", + ) + @click.option( + "-r", + "--range", + "range_", + type=MultipleChoice(Video.Range, case_sensitive=False), + default=[Video.Range.SDR], + help="Video Color Range(s) to download, defaults to SDR.", + ) + @click.option( + "-c", + "--channels", + type=float, + default=None, + help="Audio Channel(s) to download. Matches sub-channel layouts like 5.1 with 6.0 implicitly.", + ) + @click.option( + "-naa", + "--noatmos", + "no_atmos", + is_flag=True, + 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", + type=SEASON_RANGE, + default=None, + help="Wanted episodes, e.g. `S01-S05,S07`, `S01E01-S02E03`, `S02-S02E03`, e.t.c, defaults to all.", + ) + @click.option( + "-l", + "--lang", + type=LANGUAGE_RANGE, + 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", + type=LANGUAGE_RANGE, + default=[], + help="Language wanted for Video, you would use this if the video language doesn't match the audio.", + ) + @click.option( + "-al", + "--a-lang", + type=LANGUAGE_RANGE, + default=[], + help="Language wanted for Audio, overrides -l/--lang for audio tracks.", + ) + @click.option("-sl", "--s-lang", type=LANGUAGE_RANGE, default=["all"], help="Language wanted for Subtitles.") + @click.option( + "--require-subs", + type=LANGUAGE_RANGE, + default=[], + help="Required subtitle languages. Downloads all subtitles only if these languages exist. Cannot be used with --s-lang.", + ) + @click.option("-fs", "--forced-subs", is_flag=True, default=False, help="Include forced subtitle tracks.") + @click.option( + "--exact-lang", + is_flag=True, + default=False, + help="Use exact language matching (no variants). With this flag, -l es-419 matches ONLY es-419, not es-ES or other variants.", + ) + @click.option( + "--proxy", + type=str, + default=None, + help="Proxy URI to use. If a 2-letter country is provided, it will try get a proxy from the config.", + ) + @click.option( + "--tag", type=str, default=None, help="Set the Group Tag to be used, overriding the one in config if any." + ) + @click.option( + "--tmdb", + "tmdb_id", + type=int, + default=None, + help="Use this TMDB ID for tagging instead of automatic lookup.", + ) + @click.option( + "--tmdb-name", + "tmdb_name", + is_flag=True, + default=False, + help="Rename titles using the name returned from TMDB lookup.", + ) + @click.option( + "--tmdb-year", + "tmdb_year", + is_flag=True, + default=False, + help="Use the release year from TMDB for naming and tagging.", + ) + @click.option( + "--sub-format", + type=SubtitleCodecChoice(Subtitle.Codec), + default=None, + help="Set Output Subtitle Format, only converting if necessary.", + ) + @click.option("-V", "--video-only", is_flag=True, default=False, help="Only download video tracks.") + @click.option("-A", "--audio-only", is_flag=True, default=False, help="Only download audio tracks.") + @click.option("-S", "--subs-only", is_flag=True, default=False, help="Only download subtitle tracks.") + @click.option("-C", "--chapters-only", is_flag=True, default=False, help="Only download chapters.") + @click.option("-ns", "--no-subs", is_flag=True, default=False, help="Do not download subtitle tracks.") + @click.option("-na", "--no-audio", is_flag=True, default=False, help="Do not download audio tracks.") + @click.option("-nc", "--no-chapters", is_flag=True, default=False, help="Do not download chapters tracks.") + @click.option("-nv", "--no-video", is_flag=True, default=False, help="Do not download video tracks.") + @click.option("-ad", "--audio-description", is_flag=True, default=False, help="Download audio description tracks.") + @click.option( + "--slow", + is_flag=True, + default=False, + help="Add a 60-120 second delay between each Title download to act more like a real device. " + "This is recommended if you are downloading high-risk titles or streams.", + ) + @click.option( + "--list", + "list_", + is_flag=True, + default=False, + help="Skip downloading and list available tracks and what tracks would have been downloaded.", + ) + @click.option( + "--list-titles", + is_flag=True, + default=False, + help="Skip downloading, only list available titles that would have been downloaded.", + ) + @click.option( + "--skip-dl", is_flag=True, default=False, help="Skip downloading while still retrieving the decryption keys." + ) + @click.option("--export", type=Path, help="Export Decryption Keys as you obtain them to a JSON file.") + @click.option( + "--cdm-only/--vaults-only", + is_flag=True, + default=None, + help="Only use CDM, or only use Key Vaults for retrieval of Decryption Keys.", + ) + @click.option("--no-proxy", is_flag=True, default=False, help="Force disable all proxy use.") + @click.option("--no-folder", is_flag=True, default=False, help="Disable folder creation for TV Shows.") + @click.option( + "--no-source", is_flag=True, default=False, help="Disable the source tag from the output file name and path." + ) + @click.option("--no-mux", is_flag=True, default=False, help="Do not mux tracks into a container file.") + @click.option( + "--workers", + type=int, + default=None, + help="Max workers/threads to download with per-track. Default depends on the downloader.", + ) + @click.option("--downloads", type=int, default=1, help="Amount of tracks to download concurrently.") + @click.option("--no-cache", "no_cache", is_flag=True, default=False, help="Bypass title cache for this download.") + @click.option( + "--reset-cache", "reset_cache", is_flag=True, default=False, help="Clear title cache before fetching." + ) + @click.option( + "--best-available", + "best_available", + is_flag=True, + default=False, + help="Continue with best available quality if requested resolutions are not available.", + ) + @click.pass_context + def cli(ctx: click.Context, **kwargs: Any) -> dl: + return dl(ctx, **kwargs) + + DRM_TABLE_LOCK = Lock() + + def __init__( + self, + ctx: click.Context, + no_proxy: bool, + profile: Optional[str] = None, + proxy: Optional[str] = None, + tag: Optional[str] = None, + tmdb_id: Optional[int] = None, + tmdb_name: bool = False, + tmdb_year: bool = False, + *_: Any, + **__: Any, + ): + if not ctx.invoked_subcommand: + raise ValueError("A subcommand to invoke was not specified, the main code cannot continue.") + + self.log = logging.getLogger("download") + + self.service = Services.get_tag(ctx.invoked_subcommand) + service_dl_config = config.services.get(self.service, {}).get("dl", {}) + if service_dl_config: + param_types = {param.name: param.type for param in ctx.command.params if param.name} + + for param_name, service_value in service_dl_config.items(): + if param_name not in ctx.params: + continue + + current_value = ctx.params[param_name] + global_default = config.dl.get(param_name) + param_type = param_types.get(param_name) + + try: + if param_type and global_default is not None: + global_default = param_type.convert(global_default, None, ctx) + except Exception as e: + self.log.debug(f"Failed to convert global default for '{param_name}': {e}") + + if current_value == global_default or (current_value is None and global_default is None): + try: + converted_value = service_value + if param_type and service_value is not None: + converted_value = param_type.convert(service_value, None, ctx) + + ctx.params[param_name] = converted_value + self.log.debug(f"Applied service-specific '{param_name}' override: {converted_value}") + except Exception as e: + self.log.warning( + f"Failed to apply service-specific '{param_name}' override: {e}. " + f"Check that the value '{service_value}' is valid for this parameter." + ) + + self.profile = profile + self.tmdb_id = tmdb_id + self.tmdb_name = tmdb_name + self.tmdb_year = tmdb_year + + # Initialize debug logger with service name if debug logging is enabled + if config.debug or logging.root.level == logging.DEBUG: + from collections import defaultdict + from datetime import datetime + + debug_log_path = config.directories.logs / config.filenames.debug_log.format_map( + defaultdict(str, service=self.service, time=datetime.now().strftime("%Y%m%d-%H%M%S")) + ) + init_debug_logger(log_path=debug_log_path, enabled=True, log_keys=config.debug_keys) + self.debug_logger = get_debug_logger() + + if self.debug_logger: + self.debug_logger.log( + level="INFO", + operation="download_init", + message=f"Download command initialized for service {self.service}", + service=self.service, + context={ + "profile": profile, + "proxy": proxy, + "tag": tag, + "tmdb_id": tmdb_id, + "tmdb_name": tmdb_name, + "tmdb_year": tmdb_year, + "cli_params": { + k: v + for k, v in ctx.params.items() + if k not in ["profile", "proxy", "tag", "tmdb_id", "tmdb_name", "tmdb_year"] + }, + }, + ) + else: + self.debug_logger = None + + if self.profile: + self.log.info(f"Using profile: '{self.profile}'") + + with console.status("Loading Service Config...", spinner="dots"): + service_config_path = Services.get_path(self.service) / config.filenames.config + if service_config_path.exists(): + self.service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8")) + self.log.info("Service Config loaded") + if self.debug_logger: + self.debug_logger.log( + level="DEBUG", + operation="load_service_config", + service=self.service, + context={"config_path": str(service_config_path), "config": self.service_config}, + ) + else: + self.service_config = {} + merge_dict(config.services.get(self.service), self.service_config) + + if getattr(config, "downloader_map", None): + config.downloader = config.downloader_map.get(self.service, config.downloader) + + if getattr(config, "decryption_map", None): + config.decryption = config.decryption_map.get(self.service, config.decryption) + + service_config = config.services.get(self.service, {}) + if service_config: + reserved_keys = { + "profiles", + "api_key", + "certificate", + "api_endpoint", + "region", + "device", + "endpoints", + "client", + "dl", + } + + for config_key, override_value in service_config.items(): + if config_key in reserved_keys or not isinstance(override_value, dict): + continue + + if hasattr(config, config_key): + current_config = getattr(config, config_key, {}) + + if isinstance(current_config, dict): + merged_config = deepcopy(current_config) + merge_dict(override_value, merged_config) + setattr(config, config_key, merged_config) + + self.log.debug( + f"Applied service-specific '{config_key}' overrides for {self.service}: {override_value}" + ) + + cdm_only = ctx.params.get("cdm_only") + + if cdm_only: + self.vaults = Vaults(self.service) + self.log.info("CDM-only mode: Skipping vault loading") + if self.debug_logger: + self.debug_logger.log( + level="INFO", + operation="vault_loading_skipped", + service=self.service, + context={"reason": "cdm_only flag set"}, + ) + else: + with console.status("Loading Key Vaults...", spinner="dots"): + self.vaults = Vaults(self.service) + total_vaults = len(config.key_vaults) + failed_vaults = [] + + for vault in config.key_vaults: + vault_type = vault["type"] + vault_name = vault.get("name", vault_type) + vault_copy = vault.copy() + del vault_copy["type"] + + if vault_type.lower() == "api" and "decrypt_labs" in vault_name.lower(): + if "token" not in vault_copy or not vault_copy["token"]: + if config.decrypt_labs_api_key: + vault_copy["token"] = config.decrypt_labs_api_key + else: + self.log.warning( + f"No token provided for DecryptLabs vault '{vault_name}' and no global " + "decrypt_labs_api_key configured" + ) + + if vault_type.lower() == "sqlite": + try: + self.vaults.load_critical(vault_type, **vault_copy) + self.log.debug(f"Successfully loaded vault: {vault_name} ({vault_type})") + except Exception as e: + self.log.error(f"vault failure: {vault_name} ({vault_type}) - {e}") + raise + else: + # Other vaults (MySQL, HTTP, API) - soft fail + if not self.vaults.load(vault_type, **vault_copy): + failed_vaults.append(vault_name) + self.log.debug(f"Failed to load vault: {vault_name} ({vault_type})") + else: + self.log.debug(f"Successfully loaded vault: {vault_name} ({vault_type})") + + loaded_count = len(self.vaults) + if failed_vaults: + self.log.warning(f"Failed to load {len(failed_vaults)} vault(s): {', '.join(failed_vaults)}") + self.log.info(f"Loaded {loaded_count}/{total_vaults} Vaults") + + # Debug: Show detailed vault status + if loaded_count > 0: + vault_names = [vault.name for vault in self.vaults] + self.log.debug(f"Active vaults: {', '.join(vault_names)}") + else: + self.log.debug("No vaults are currently active") + + with console.status("Loading DRM CDM...", spinner="dots"): + try: + self.cdm = self.get_cdm(self.service, self.profile) + except ValueError as e: + self.log.error(f"Failed to load CDM, {e}") + if self.debug_logger: + self.debug_logger.log_error("load_cdm", e, service=self.service) + sys.exit(1) + + if self.cdm: + cdm_info = {} + if isinstance(self.cdm, DecryptLabsRemoteCDM): + drm_type = "PlayReady" if self.cdm.is_playready else "Widevine" + self.log.info(f"Loaded {drm_type} Remote CDM: DecryptLabs (L{self.cdm.security_level})") + cdm_info = {"type": "DecryptLabs", "drm_type": drm_type, "security_level": self.cdm.security_level} + elif hasattr(self.cdm, "device_type") and self.cdm.device_type.name in ["ANDROID", "CHROME"]: + self.log.info(f"Loaded Widevine CDM: {self.cdm.system_id} (L{self.cdm.security_level})") + cdm_info = { + "type": "Widevine", + "system_id": self.cdm.system_id, + "security_level": self.cdm.security_level, + "device_type": self.cdm.device_type.name, + } + else: + # 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": cdm_name, + "security_level": self.cdm.security_level, + } + + if self.debug_logger and cdm_info: + self.debug_logger.log( + level="INFO", operation="load_cdm", service=self.service, context={"cdm": cdm_info} + ) + + self.proxy_providers = [] + if no_proxy: + ctx.params["proxy"] = None + else: + with console.status("Loading Proxy Providers...", spinner="dots"): + if config.proxy_providers.get("basic"): + self.proxy_providers.append(Basic(**config.proxy_providers["basic"])) + if config.proxy_providers.get("nordvpn"): + self.proxy_providers.append(NordVPN(**config.proxy_providers["nordvpn"])) + if config.proxy_providers.get("surfsharkvpn"): + 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: + self.log.info(f"Loaded {proxy_provider.__class__.__name__}: {proxy_provider}") + + if 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 + ): + proxy = proxy.lower() + # Preserve the original user query (region code) for service-specific proxy_map overrides. + # NOTE: `proxy` may be overwritten with the resolved proxy URI later. + proxy_query = proxy + 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), + None, + ) + if not proxy_provider: + self.log.error(f"The proxy provider '{requested_provider}' was not recognised.") + sys.exit(1) + 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 + # 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_uri = proxy_provider.get_proxy(proxy) + if proxy_uri: + proxy = ctx.params["proxy"] = proxy_uri + # 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_query + 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 + ) + + if tag: + config.tag = tag + + # needs to be added this way instead of @cli.result_callback to be + # able to keep `self` as the first positional + self.cli._result_callback = self.result + + def result( + self, + service: Service, + quality: list[int], + vcodec: Optional[Video.Codec], + acodec: list[Audio.Codec], + vbitrate: int, + abitrate: int, + range_: list[Video.Range], + channels: float, + no_atmos: bool, + wanted: list[str], + latest_episode: bool, + lang: list[str], + v_lang: list[str], + a_lang: list[str], + s_lang: list[str], + require_subs: list[str], + forced_subs: bool, + exact_lang: bool, + sub_format: Optional[Subtitle.Codec], + video_only: bool, + audio_only: bool, + subs_only: bool, + chapters_only: bool, + no_subs: bool, + no_audio: bool, + no_chapters: bool, + no_video: bool, + audio_description: bool, + slow: bool, + list_: bool, + list_titles: bool, + skip_dl: bool, + export: Optional[Path], + cdm_only: Optional[bool], + no_proxy: bool, + no_folder: bool, + no_source: bool, + no_mux: bool, + workers: Optional[int], + downloads: int, + best_available: bool, + split_audio: Optional[bool] = None, + *_: Any, + **__: Any, + ) -> None: + self.tmdb_searched = False + self.search_source = None + start_time = time.time() + + if skip_dl: + DOWNLOAD_LICENCE_ONLY.set() + + 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) + + # Check if dovi_tool is available when hybrid mode is requested + if any(r == Video.Range.HYBRID for r in range_): + from envied.core.binaries import DoviTool + + if not DoviTool: + self.log.error("Unable to run hybrid mode: dovi_tool not detected") + self.log.error("Please install dovi_tool from https://github.com/quietvoid/dovi_tool") + sys.exit(1) + + if cdm_only is None: + vaults_only = None + else: + vaults_only = not cdm_only + + if self.debug_logger: + self.debug_logger.log( + level="DEBUG", + operation="drm_mode_config", + service=self.service, + context={ + "cdm_only": cdm_only, + "vaults_only": vaults_only, + "mode": "CDM only" if cdm_only else ("Vaults only" if vaults_only else "Both CDM and Vaults"), + }, + ) + + with console.status("Authenticating with Service...", spinner="dots"): + try: + cookies = self.get_cookie_jar(self.service, self.profile) + credential = self.get_credentials(self.service, self.profile) + service.authenticate(cookies, credential) + if cookies or credential: + self.log.info("Authenticated with Service") + if self.debug_logger: + self.debug_logger.log( + level="INFO", + operation="authenticate", + service=self.service, + context={ + "has_cookies": bool(cookies), + "has_credentials": bool(credential), + "profile": self.profile, + }, + ) + except Exception as e: + if self.debug_logger: + self.debug_logger.log_error( + "authenticate", e, service=self.service, context={"profile": self.profile} + ) + raise + + with console.status("Fetching Title Metadata...", spinner="dots"): + try: + titles = service.get_titles_cached() + if not titles: + self.log.error("No titles returned, nothing to download...") + if self.debug_logger: + self.debug_logger.log( + level="ERROR", + operation="get_titles", + service=self.service, + message="No titles returned from service", + success=False, + ) + sys.exit(1) + except Exception as e: + if self.debug_logger: + self.debug_logger.log_error("get_titles", e, service=self.service) + raise + + if self.debug_logger: + titles_info = { + "type": titles.__class__.__name__, + "count": len(titles) if hasattr(titles, "__len__") else 1, + "title": str(titles), + } + if hasattr(titles, "seasons"): + titles_info["seasons"] = len(titles.seasons) if hasattr(titles, "seasons") else 0 + self.debug_logger.log( + level="INFO", operation="get_titles", service=self.service, context={"titles": titles_info} + ) + + title_cacher = service.title_cache if hasattr(service, "title_cache") else None + cache_title_id = None + if hasattr(service, "title"): + cache_title_id = service.title + elif hasattr(service, "title_id"): + cache_title_id = service.title_id + cache_region = service.current_region if hasattr(service, "current_region") else None + cache_account_hash = get_account_hash(service.credential) if hasattr(service, "credential") else None + + if (self.tmdb_year or self.tmdb_name) and self.tmdb_id: + sample_title = titles[0] if hasattr(titles, "__getitem__") else titles + kind = "tv" if isinstance(sample_title, Episode) else "movie" + + tmdb_year_val = None + tmdb_name_val = None + + if self.tmdb_year: + tmdb_year_val = tags.get_year( + self.tmdb_id, kind, title_cacher, cache_title_id, cache_region, cache_account_hash + ) + + if self.tmdb_name: + tmdb_name_val = tags.get_title( + self.tmdb_id, kind, title_cacher, cache_title_id, cache_region, cache_account_hash + ) + + if isinstance(titles, (Series, Movies)): + for t in titles: + if tmdb_year_val: + t.year = tmdb_year_val + if tmdb_name_val: + if isinstance(t, Episode): + t.title = tmdb_name_val + else: + t.name = tmdb_name_val + else: + if tmdb_year_val: + titles.year = tmdb_year_val + if tmdb_name_val: + if isinstance(titles, Episode): + titles.title = tmdb_name_val + else: + titles.name = tmdb_name_val + + console.print(Padding(Rule(f"[rule.text]{titles.__class__.__name__}: {titles}"), (1, 2))) + + console.print(Padding(titles.tree(verbose=list_titles), (0, 5))) + if list_titles: + return + + # Determine the latest episode if --latest-episode is set + latest_episode_id = None + if latest_episode and isinstance(titles, Series) and len(titles) > 0: + # Series is already sorted by (season, number, year) + # The last episode in the sorted list is the latest + latest_ep = titles[-1] + latest_episode_id = f"{latest_ep.season}x{latest_ep.number}" + self.log.info(f"Latest episode mode: Selecting S{latest_ep.season:02}E{latest_ep.number:02}") + + for i, title in enumerate(titles): + if isinstance(title, Episode) and latest_episode and latest_episode_id: + # If --latest-episode is set, only process the latest episode + if f"{title.season}x{title.number}" != latest_episode_id: + continue + elif isinstance(title, Episode) and wanted and f"{title.season}x{title.number}" not in wanted: + continue + + console.print(Padding(Rule(f"[rule.text]{title}"), (1, 2))) + temp_font_files = [] + + if isinstance(title, Episode) and not self.tmdb_searched: + kind = "tv" + if self.tmdb_id: + tmdb_title = tags.get_title( + self.tmdb_id, kind, title_cacher, cache_title_id, cache_region, cache_account_hash + ) + else: + self.tmdb_id, tmdb_title, self.search_source = tags.search_show_info( + title.title, title.year, kind, title_cacher, cache_title_id, cache_region, cache_account_hash + ) + if not (self.tmdb_id and tmdb_title and tags.fuzzy_match(tmdb_title, title.title)): + self.tmdb_id = None + if list_ or list_titles: + if self.tmdb_id: + console.print( + Padding( + f"Search -> {tmdb_title or '?'} [bright_black](ID {self.tmdb_id})", + (0, 5), + ) + ) + else: + console.print(Padding("Search -> [bright_black]No match found[/]", (0, 5))) + self.tmdb_searched = True + + if isinstance(title, Movie) and (list_ or list_titles) and not self.tmdb_id: + movie_id, movie_title, _ = tags.search_show_info( + title.name, title.year, "movie", title_cacher, cache_title_id, cache_region, cache_account_hash + ) + if movie_id: + console.print( + Padding( + f"Search -> {movie_title or '?'} [bright_black](ID {movie_id})", + (0, 5), + ) + ) + else: + console.print(Padding("Search -> [bright_black]No match found[/]", (0, 5))) + + if self.tmdb_id and getattr(self, "search_source", None) != "simkl": + kind = "tv" if isinstance(title, Episode) else "movie" + tags.external_ids(self.tmdb_id, kind, title_cacher, cache_title_id, cache_region, cache_account_hash) + + if slow and i != 0: + delay = random.randint(60, 120) + with console.status(f"Delaying by {delay} seconds..."): + time.sleep(delay) + + with console.status("Subscribing to events...", spinner="dots"): + events.reset() + events.subscribe(events.Types.SEGMENT_DOWNLOADED, service.on_segment_downloaded) + events.subscribe(events.Types.TRACK_DOWNLOADED, service.on_track_downloaded) + events.subscribe(events.Types.TRACK_DECRYPTED, service.on_track_decrypted) + events.subscribe(events.Types.TRACK_REPACKED, service.on_track_repacked) + events.subscribe(events.Types.TRACK_MULTIPLEX, service.on_track_multiplex) + + if hasattr(service, "NO_SUBTITLES") and service.NO_SUBTITLES: + console.log("Skipping subtitles - service does not support subtitle downloads") + no_subs = True + s_lang = None + title.tracks.subtitles = [] + elif no_subs: + console.log("Skipped subtitles as --no-subs was used...") + s_lang = None + title.tracks.subtitles = [] + + if no_video: + console.log("Skipped video as --no-video was used...") + v_lang = None + title.tracks.videos = [] + + with console.status("Getting tracks...", spinner="dots"): + try: + title.tracks.add(service.get_tracks(title), warn_only=True) + title.tracks.chapters = service.get_chapters(title) + except Exception as e: + if self.debug_logger: + self.debug_logger.log_error( + "get_tracks", e, service=self.service, context={"title": str(title)} + ) + raise + + if self.debug_logger: + tracks_info = { + "title": str(title), + "video_tracks": len(title.tracks.videos), + "audio_tracks": len(title.tracks.audio), + "subtitle_tracks": len(title.tracks.subtitles), + "has_chapters": bool(title.tracks.chapters), + "videos": [ + { + "codec": str(v.codec), + "resolution": f"{v.width}x{v.height}" if v.width and v.height else "unknown", + "bitrate": v.bitrate, + "range": str(v.range), + "language": str(v.language) if v.language else None, + "drm": [str(type(d).__name__) for d in v.drm] if v.drm else [], + } + for v in title.tracks.videos + ], + "audio": [ + { + "codec": str(a.codec), + "bitrate": a.bitrate, + "channels": a.channels, + "language": str(a.language) if a.language else None, + "descriptive": a.descriptive, + "drm": [str(type(d).__name__) for d in a.drm] if a.drm else [], + } + for a in title.tracks.audio + ], + "subtitles": [ + { + "codec": str(s.codec), + "language": str(s.language) if s.language else None, + "forced": s.forced, + "sdh": s.sdh, + } + for s in title.tracks.subtitles + ], + } + self.debug_logger.log( + level="INFO", operation="get_tracks", service=self.service, context=tracks_info + ) + + # strip SDH subs to non-SDH if no equivalent same-lang non-SDH is available + # uses a loose check, e.g, wont strip en-US SDH sub if a non-SDH en-GB is available + # Check if automatic SDH stripping is enabled in config + if config.subtitle.get("strip_sdh", True): + for subtitle in title.tracks.subtitles: + if subtitle.sdh and not any( + is_close_match(subtitle.language, [x.language]) + for x in title.tracks.subtitles + if not x.sdh and not x.forced + ): + non_sdh_sub = deepcopy(subtitle) + non_sdh_sub.id += "_stripped" + non_sdh_sub.sdh = False + 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, + ) + + with console.status("Sorting tracks by language and bitrate...", spinner="dots"): + video_sort_lang = v_lang or lang + processed_video_sort_lang = [] + for language in video_sort_lang: + if language == "orig": + if title.language: + orig_lang = str(title.language) if hasattr(title.language, "__str__") else title.language + if orig_lang not in processed_video_sort_lang: + processed_video_sort_lang.append(orig_lang) + else: + if language not in processed_video_sort_lang: + processed_video_sort_lang.append(language) + + audio_sort_lang = a_lang or lang + processed_audio_sort_lang = [] + for language in audio_sort_lang: + if language == "orig": + if title.language: + orig_lang = str(title.language) if hasattr(title.language, "__str__") else title.language + if orig_lang not in processed_audio_sort_lang: + processed_audio_sort_lang.append(orig_lang) + else: + if language not in processed_audio_sort_lang: + processed_audio_sort_lang.append(language) + + title.tracks.sort_videos(by_language=processed_video_sort_lang) + title.tracks.sort_audio(by_language=processed_audio_sort_lang) + title.tracks.sort_subtitles(by_language=s_lang) + + if list_: + available_tracks, _ = title.tracks.tree() + console.print(Padding(Panel(available_tracks, title="Available Tracks"), (0, 5))) + continue + + with console.status("Selecting tracks...", spinner="dots"): + if isinstance(title, (Movie, Episode)): + # filter video tracks + if vcodec: + title.tracks.select_video(lambda x: x.codec == vcodec) + if not title.tracks.videos: + self.log.error(f"There's no {vcodec.name} Video Track...") + sys.exit(1) + + if range_: + # Special handling for HYBRID - don't filter, keep all HDR10 and DV tracks + if Video.Range.HYBRID not in range_: + title.tracks.select_video(lambda x: x.range in range_) + missing_ranges = [r for r in range_ if not any(x.range == r for x in title.tracks.videos)] + for color_range in missing_ranges: + self.log.warning(f"Skipping {color_range.name} video tracks as none are available.") + + if vbitrate: + title.tracks.select_video(lambda x: x.bitrate and x.bitrate // 1000 == vbitrate) + if not title.tracks.videos: + self.log.error(f"There's no {vbitrate}kbps Video Track...") + sys.exit(1) + + video_languages = [lang for lang in (v_lang or lang) if lang != "best"] + if video_languages and "all" not in video_languages: + processed_video_lang = [] + for language in video_languages: + if language == "orig": + if title.language: + orig_lang = ( + str(title.language) if hasattr(title.language, "__str__") else title.language + ) + if orig_lang not in processed_video_lang: + processed_video_lang.append(orig_lang) + else: + self.log.warning( + "Original language not available for title, skipping 'orig' selection for video" + ) + else: + if language not in processed_video_lang: + processed_video_lang.append(language) + title.tracks.videos = title.tracks.by_language( + title.tracks.videos, processed_video_lang, exact_match=exact_lang + ) + if not title.tracks.videos: + self.log.error(f"There's no {processed_video_lang} Video Track...") + sys.exit(1) + + if quality: + missing_resolutions = [] + if any(r == Video.Range.HYBRID for r in range_): + title.tracks.select_video(title.tracks.select_hybrid(title.tracks.videos, quality)) + else: + title.tracks.by_resolutions(quality) + + for resolution in quality: + if any(v.height == resolution for v in title.tracks.videos): + continue + if any(int(v.width * 9 / 16) == resolution for v in title.tracks.videos): + continue + missing_resolutions.append(resolution) + + if missing_resolutions: + res_list = "" + if len(missing_resolutions) > 1: + res_list = ", ".join([f"{x}p" for x in missing_resolutions[:-1]]) + " or " + res_list = f"{res_list}{missing_resolutions[-1]}p" + plural = "s" if len(missing_resolutions) > 1 else "" + + if best_available: + self.log.warning( + f"There's no {res_list} Video Track{plural}, continuing with available qualities..." + ) + else: + self.log.error(f"There's no {res_list} Video Track{plural}...") + sys.exit(1) + + # choose best track by range and quality + if any(r == Video.Range.HYBRID for r in range_): + # For hybrid mode, always apply hybrid selection + # If no quality specified, use only the best (highest) resolution + if not quality: + # Get the highest resolution available + best_resolution = max((v.height for v in title.tracks.videos), default=None) + if best_resolution: + # Use the hybrid selection logic with only the best resolution + title.tracks.select_video( + title.tracks.select_hybrid(title.tracks.videos, [best_resolution]) + ) + # If quality was specified, hybrid selection was already applied above + else: + selected_videos: list[Video] = [] + for resolution, color_range in product(quality or [None], range_ or [None]): + match = next( + ( + t + for t in title.tracks.videos + if ( + not resolution + or t.height == resolution + or int(t.width * (9 / 16)) == resolution + ) + and (not color_range or t.range == color_range) + ), + None, + ) + if match and match not in selected_videos: + selected_videos.append(match) + title.tracks.videos = selected_videos + + # validate hybrid mode requirements + if any(r == Video.Range.HYBRID for r in range_): + hdr10_tracks = [v for v in title.tracks.videos if v.range == Video.Range.HDR10] + dv_tracks = [v for v in title.tracks.videos if v.range == Video.Range.DV] + + if not hdr10_tracks and not dv_tracks: + available_ranges = sorted(set(v.range.name for v in title.tracks.videos)) + self.log.error("HYBRID mode requires both HDR10 and DV tracks, but neither is available") + self.log.error( + f"Available ranges: {', '.join(available_ranges) if available_ranges else 'none'}" + ) + sys.exit(1) + elif not hdr10_tracks: + available_ranges = sorted(set(v.range.name for v in title.tracks.videos)) + self.log.error("HYBRID mode requires both HDR10 and DV tracks, but only DV is available") + self.log.error(f"Available ranges: {', '.join(available_ranges)}") + sys.exit(1) + elif not dv_tracks: + available_ranges = sorted(set(v.range.name for v in title.tracks.videos)) + self.log.error("HYBRID mode requires both HDR10 and DV tracks, but only HDR10 is available") + self.log.error(f"Available ranges: {', '.join(available_ranges)}") + sys.exit(1) + + # filter subtitle tracks + if require_subs: + missing_langs = [ + lang + for lang in require_subs + if not any(is_close_match(lang, [sub.language]) for sub in title.tracks.subtitles) + ] + + if missing_langs: + self.log.error(f"Required subtitle language(s) not found: {', '.join(missing_langs)}") + sys.exit(1) + + self.log.info( + f"Required languages found ({', '.join(require_subs)}), downloading all available subtitles" + ) + elif s_lang and "all" not in s_lang: + from envied.core.utilities import is_exact_match + + match_func = is_exact_match if exact_lang else is_close_match + + missing_langs = [ + lang_ + for lang_ in s_lang + if not any(match_func(lang_, [sub.language]) for sub in title.tracks.subtitles) + ] + if missing_langs: + self.log.error(", ".join(missing_langs) + " not found in tracks") + sys.exit(1) + + title.tracks.select_subtitles(lambda x: match_func(x.language, s_lang)) + if not title.tracks.subtitles: + self.log.error(f"There's no {s_lang} Subtitle Track...") + sys.exit(1) + + if not forced_subs: + title.tracks.select_subtitles(lambda x: not x.forced) + + # filter audio tracks + # might have no audio tracks if part of the video, e.g. transport stream hls + if len(title.tracks.audio) > 0: + 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 in acodec) + if not title.tracks.audio: + 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)) + if not title.tracks.audio: + self.log.error(f"There's no {channels} Audio Track...") + sys.exit(1) + if no_atmos: + title.tracks.audio = [x for x in title.tracks.audio if not x.atmos] + if not title.tracks.audio: + self.log.error("No non-Atmos audio tracks available...") + sys.exit(1) + if abitrate: + title.tracks.select_audio(lambda x: x.bitrate and x.bitrate // 1000 == abitrate) + if not title.tracks.audio: + self.log.error(f"There's no {abitrate}kbps Audio Track...") + sys.exit(1) + audio_languages = a_lang or lang + if audio_languages: + processed_lang = [] + for language in audio_languages: + if language == "orig": + if title.language: + orig_lang = ( + str(title.language) if hasattr(title.language, "__str__") else title.language + ) + if orig_lang not in processed_lang: + processed_lang.append(orig_lang) + else: + self.log.warning( + "Original language not available for title, skipping 'orig' selection" + ) + else: + if language not in processed_lang: + processed_lang.append(language) + + if "best" in processed_lang: + unique_languages = {track.language for track in title.tracks.audio} + selected_audio = [] + for language in unique_languages: + codecs_to_check = acodec if (acodec and len(acodec) > 1) else [None] + for codec in codecs_to_check: + base_candidates = [ + t + for t in title.tracks.audio + if t.language == language and (codec is None or t.codec == codec) + ] + if not base_candidates: + continue + if audio_description: + standards = [t for t in base_candidates if not t.descriptive] + if standards: + selected_audio.append(max(standards, key=lambda x: x.bitrate or 0)) + descs = [t for t in base_candidates if t.descriptive] + if descs: + selected_audio.append(max(descs, key=lambda x: x.bitrate or 0)) + else: + selected_audio.append(max(base_candidates, key=lambda x: x.bitrate or 0)) + title.tracks.audio = selected_audio + elif "all" not in processed_lang: + # If multiple codecs were explicitly requested, pick the best track per codec per + # requested language instead of selecting *all* bitrate variants of a codec. + if acodec and len(acodec) > 1: + selected_audio: list[Audio] = [] + + for language in processed_lang: + for codec in acodec: + codec_tracks = [a for a in title.tracks.audio if a.codec == codec] + if not codec_tracks: + continue + + candidates = title.tracks.by_language( + codec_tracks, [language], per_language=0, exact_match=exact_lang + ) + if not candidates: + continue + + if audio_description: + standards = [t for t in candidates if not t.descriptive] + if standards: + selected_audio.append(max(standards, key=lambda x: x.bitrate or 0)) + descs = [t for t in candidates if t.descriptive] + if descs: + selected_audio.append(max(descs, key=lambda x: x.bitrate or 0)) + else: + selected_audio.append(max(candidates, key=lambda x: x.bitrate or 0)) + + title.tracks.audio = selected_audio + else: + per_language = 1 + if audio_description: + standard_audio = [a for a in title.tracks.audio if not a.descriptive] + selected_standards = title.tracks.by_language( + standard_audio, processed_lang, per_language=per_language, exact_match=exact_lang + ) + desc_audio = [a for a in title.tracks.audio if a.descriptive] + # Include all descriptive tracks for the requested languages. + selected_descs = title.tracks.by_language( + desc_audio, processed_lang, per_language=0, exact_match=exact_lang + ) + title.tracks.audio = selected_standards + selected_descs + else: + title.tracks.audio = title.tracks.by_language( + title.tracks.audio, + processed_lang, + per_language=per_language, + exact_match=exact_lang, + ) + if not title.tracks.audio: + 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 + ): + keep_videos = False + keep_audio = False + keep_subtitles = False + keep_chapters = False + + if video_only or audio_only or subs_only or chapters_only: + if video_only: + keep_videos = True + if audio_only: + keep_audio = True + if subs_only: + keep_subtitles = True + if chapters_only: + keep_chapters = True + else: + keep_videos = True + keep_audio = True + keep_subtitles = True + keep_chapters = True + + if no_subs: + keep_subtitles = False + if no_audio: + keep_audio = False + if no_chapters: + keep_chapters = False + if no_video: + keep_videos = False + + kept_tracks = [] + if keep_videos: + kept_tracks.extend(title.tracks.videos) + if keep_audio: + kept_tracks.extend(title.tracks.audio) + if keep_subtitles: + kept_tracks.extend(title.tracks.subtitles) + if keep_chapters: + kept_tracks.extend(title.tracks.chapters) + kept_tracks.extend(title.tracks.attachments) + + title.tracks = Tracks(kept_tracks) + + selected_tracks, tracks_progress_callables = title.tracks.tree(add_progress=True) + + for track in title.tracks: + if hasattr(track, "needs_drm_loading") and track.needs_drm_loading: + track.load_drm_if_needed(service) + + download_table = Table.grid() + download_table.add_row(selected_tracks) + + video_tracks = title.tracks.videos + if video_tracks: + highest_quality = max((track.height for track in video_tracks if track.height), default=0) + if highest_quality > 0: + if is_widevine_cdm(self.cdm): + quality_based_cdm = self.get_cdm( + self.service, self.profile, drm="widevine", quality=highest_quality + ) + if quality_based_cdm and quality_based_cdm != self.cdm: + self.log.debug( + f"Pre-selecting Widevine CDM based on highest quality {highest_quality}p across all video tracks" + ) + self.cdm = quality_based_cdm + elif is_playready_cdm(self.cdm): + quality_based_cdm = self.get_cdm( + self.service, self.profile, drm="playready", quality=highest_quality + ) + if quality_based_cdm and quality_based_cdm != self.cdm: + self.log.debug( + f"Pre-selecting PlayReady CDM based on highest quality {highest_quality}p across all video tracks" + ) + self.cdm = quality_based_cdm + + dl_start_time = time.time() + + try: + with Live(Padding(download_table, (1, 5)), console=console, refresh_per_second=5): + with ThreadPoolExecutor(downloads) as pool: + for download in futures.as_completed( + ( + pool.submit( + track.download, + session=service.session, + prepare_drm=partial( + partial(self.prepare_drm, table=download_table), + track=track, + title=title, + certificate=partial( + service.get_widevine_service_certificate, + title=title, + track=track, + ), + licence=partial( + service.get_playready_license + if ( + is_playready_cdm(self.cdm) + ) + and hasattr(service, "get_playready_license") + else service.get_widevine_license, + title=title, + track=track, + ), + cdm_only=cdm_only, + vaults_only=vaults_only, + export=export, + ), + cdm=self.cdm, + max_workers=workers, + progress=tracks_progress_callables[i], + ) + for i, track in enumerate(title.tracks) + ) + ): + download.result() + + except KeyboardInterrupt: + console.print(Padding(":x: Download Cancelled...", (0, 5, 1, 5))) + if self.debug_logger: + self.debug_logger.log( + level="WARNING", + operation="download_tracks", + service=self.service, + message="Download cancelled by user", + context={"title": str(title)}, + ) + return + except Exception as e: # noqa + error_messages = [ + ":x: Download Failed...", + ] + if isinstance(e, EnvironmentError): + error_messages.append(f" {e}") + if isinstance(e, ValueError): + error_messages.append(f" {e}") + if isinstance(e, (AttributeError, TypeError)): + console.print_exception() + else: + error_messages.append( + " An unexpected error occurred in one of the download workers.", + ) + if hasattr(e, "returncode"): + error_messages.append(f" Binary call failed, Process exit code: {e.returncode}") + error_messages.append(" See the error trace above for more information.") + if isinstance(e, subprocess.CalledProcessError): + # CalledProcessError already lists the exception trace + console.print_exception() + console.print(Padding(Group(*error_messages), (1, 5))) + + if self.debug_logger: + self.debug_logger.log_error( + "download_tracks", + e, + service=self.service, + context={ + "title": str(title), + "error_type": type(e).__name__, + "tracks_count": len(title.tracks), + "returncode": getattr(e, "returncode", None), + }, + ) + return + + if skip_dl: + console.log("Skipped downloads as --skip-dl was used...") + else: + dl_time = time_elapsed_since(dl_start_time) + console.print(Padding(f"Track downloads finished in [progress.elapsed]{dl_time}[/]", (0, 5))) + + video_track_n = 0 + + while ( + not title.tracks.subtitles + and not no_subs + and not (hasattr(service, "NO_SUBTITLES") and service.NO_SUBTITLES) + and not video_only + and not no_video + and len(title.tracks.videos) > video_track_n + and any( + x.get("codec_name", "").startswith("eia_") + for x in ffprobe(title.tracks.videos[video_track_n].path).get("streams", []) + ) + ): + with console.status(f"Checking Video track {video_track_n + 1} for Closed Captions..."): + try: + # TODO: Figure out the real language, it might be different + # EIA-CC tracks sadly don't carry language information :( + # TODO: Figure out if the CC language is original lang or not. + # Will need to figure out above first to do so. + video_track = title.tracks.videos[video_track_n] + track_id = f"ccextractor-{video_track.id}" + cc_lang = title.language or video_track.language + cc = video_track.ccextractor( + track_id=track_id, + out_path=config.directories.temp + / config.filenames.subtitle.format(id=track_id, language=cc_lang), + language=cc_lang, + original=False, + ) + if cc: + # will not appear in track listings as it's added after all times it lists + title.tracks.add(cc) + self.log.info(f"Extracted a Closed Caption from Video track {video_track_n + 1}") + else: + self.log.info(f"No Closed Captions were found in Video track {video_track_n + 1}") + except EnvironmentError: + self.log.error( + "Cannot extract Closed Captions as the ccextractor executable was not found..." + ) + 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: + if subtitle.codec != sub_format: + subtitle.convert(sub_format) + elif subtitle.codec == Subtitle.Codec.TimedTextMarkupLang: + # MKV does not support TTML, VTT is the next best option + subtitle.convert(Subtitle.Codec.WebVTT) + + with console.status("Checking Subtitles for Fonts..."): + font_names = [] + for subtitle in title.tracks.subtitles: + if subtitle.codec == Subtitle.Codec.SubStationAlphav4: + for line in subtitle.path.read_text("utf8").splitlines(): + 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) + + if font_count: + self.log.info(f"Attached {font_count} fonts for the Subtitles") + + if missing_fonts and sys.platform != "win32": + self.suggest_missing_fonts(missing_fonts) + + # Handle DRM decryption BEFORE repacking (must decrypt first!) + service_name = service.__class__.__name__.upper() + decryption_method = config.decryption_map.get(service_name, config.decryption) + decrypt_tool = "mp4decrypt" if decryption_method.lower() == "mp4decrypt" else "Shaka Packager" + + drm_tracks = [track for track in title.tracks if track.drm] + if drm_tracks: + with console.status(f"Decrypting tracks with {decrypt_tool}..."): + has_decrypted = False + for track in drm_tracks: + drm = track.get_drm_for_cdm(self.cdm) + if drm and hasattr(drm, "decrypt"): + drm.decrypt(track.path) + if not isinstance(drm, MonaLisa): + has_decrypted = True + events.emit(events.Types.TRACK_REPACKED, track=track) + else: + self.log.warning( + f"No matching DRM found for track {track} with CDM type {type(self.cdm).__name__}" + ) + if has_decrypted: + self.log.info(f"Decrypted tracks with {decrypt_tool}") + + # Now repack the decrypted tracks + with console.status("Repackaging tracks with FFMPEG..."): + has_repacked = False + for track in title.tracks: + if track.needs_repack: + track.repackage() + has_repacked = True + events.emit(events.Types.TRACK_REPACKED, track=track) + if has_repacked: + # we don't want to fill up the log with "Repacked x track" + self.log.info("Repacked one or more tracks with FFMPEG") + + muxed_paths = [] + muxed_audio_codecs: dict[Path, Optional[Audio.Codec]] = {} + append_audio_codec_suffix = True + + if no_mux: + # Skip muxing, handle individual track files + for track in title.tracks: + if track.path and track.path.exists(): + muxed_paths.append(track.path) + elif isinstance(title, (Movie, Episode)): + progress = Progress( + TextColumn("[progress.description]{task.description}"), + SpinnerColumn(finished_text=""), + BarColumn(), + "•", + TimeRemainingColumn(compact=True, elapsed_when_finished=True), + console=console, + ) + + merge_audio = ( + (not split_audio) if split_audio is not None else config.muxing.get("merge_audio", True) + ) + # When we split audio (merge_audio=False), multiple outputs may exist per title, so suffix codec. + append_audio_codec_suffix = not merge_audio + + multiplex_tasks: list[tuple[TaskID, Tracks, Optional[Audio.Codec]]] = [] + # Track hybrid-processing outputs explicitly so we can always clean them up, + # even if muxing fails early (e.g. SystemExit) before the normal delete loop. + hybrid_temp_paths: list[Path] = [] + + 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: + # Hybrid mode: process DV and HDR10 tracks separately for each resolution + self.log.info("Processing Hybrid HDR10+DV tracks...") + + # Group video tracks by resolution + resolutions_processed = set() + hdr10_tracks = [v for v in title.tracks.videos if v.range == Video.Range.HDR10] + dv_tracks = [v for v in title.tracks.videos if v.range == Video.Range.DV] + + for hdr10_track in hdr10_tracks: + resolution = hdr10_track.height + if resolution in resolutions_processed: + continue + resolutions_processed.add(resolution) + + # Find matching DV track for this resolution (use the lowest DV resolution) + matching_dv = min(dv_tracks, key=lambda v: v.height) if dv_tracks else None + + if matching_dv: + # Create track pair for this resolution + resolution_tracks = [hdr10_track, matching_dv] + + for track in resolution_tracks: + track.needs_duration_fix = True + + # Run the hybrid processing for this resolution + Hybrid(resolution_tracks, self.service) + + # Create unique output filename for this resolution + hybrid_filename = f"HDR10-DV-{resolution}p.hevc" + hybrid_output_path = config.directories.temp / hybrid_filename + hybrid_temp_paths.append(hybrid_output_path) + + # The Hybrid class creates HDR10-DV.hevc, rename it for this resolution + default_output = config.directories.temp / "HDR10-DV.hevc" + if default_output.exists(): + # If a previous run left this behind, replace it to avoid move() failures. + hybrid_output_path.unlink(missing_ok=True) + shutil.move(str(default_output), str(hybrid_output_path)) + + # 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 + hybrid_track = deepcopy(hdr10_track) + hybrid_track.id = f"hybrid_{hdr10_track.id}_{resolution}" + hybrid_track.path = hybrid_output_path + hybrid_track.range = Video.Range.DV # It's now a DV track + hybrid_track.needs_duration_fix = True + title.tracks.add(hybrid_track) + task_tracks.videos = [hybrid_track] + + enqueue_mux_tasks(task_description, task_tracks) + + console.print() + else: + # Normal mode: process each video track separately + for video_track in title.tracks.videos or [None]: + task_description = "Multiplexing" + if video_track: + if len(quality) > 1: + task_description += f" {video_track.height}p" + if len(range_) > 1: + task_description += f" {video_track.range.name}" + + task_tracks = Tracks(title.tracks) + title.tracks.chapters + title.tracks.attachments + if video_track: + task_tracks.videos = [video_track] + + enqueue_mux_tasks(task_description, task_tracks) + + try: + with Live(Padding(progress, (0, 5, 1, 5)), console=console): + 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( + str(title), + progress=partial(progress.update, task_id=task_id), + delete=False, + audio_expected=audio_expected, + title_language=title.language, + skip_subtitles=skip_subtitle_mux, + ) + 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: + self.log.warning("mkvmerge had at least one warning or error, continuing anyway...") + for line in errors: + if line.startswith("#GUI#error"): + self.log.error(line) + else: + 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() + + # Clear temp font attachment paths and delete other attachments + for attachment in title.tracks.attachments: + if attachment.path and attachment.path in temp_font_files: + attachment.path = None + else: + attachment.delete() + + # 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) + finally: + # Hybrid() produces a temp HEVC output we rename; make sure it's never left behind. + # Also attempt to remove the default hybrid output name if it still exists. + for temp_path in hybrid_temp_paths: + try: + temp_path.unlink(missing_ok=True) + except PermissionError: + self.log.warning(f"Failed to delete temp file (in use?): {temp_path}") + try: + (config.directories.temp / "HDR10-DV.hevc").unlink(missing_ok=True) + except PermissionError: + self.log.warning( + f"Failed to delete temp file (in use?): {config.directories.temp / 'HDR10-DV.hevc'}" + ) + + else: + # dont mux + muxed_paths.append(title.tracks.audio[0].path) + + if no_mux: + # Handle individual track files without muxing + final_dir = config.directories.downloads + if not no_folder and isinstance(title, (Episode, Song)): + # Create folder based on title + # Use first available track for filename generation + sample_track = ( + title.tracks.videos[0] + if title.tracks.videos + else ( + title.tracks.audio[0] + if title.tracks.audio + else (title.tracks.subtitles[0] if title.tracks.subtitles else None) + ) + ) + if sample_track and sample_track.path: + media_info = MediaInfo.parse(sample_track.path) + final_dir /= title.get_filename(media_info, show_service=not no_source, folder=True) + + final_dir.mkdir(parents=True, exist_ok=True) + + for track_path in muxed_paths: + # Generate appropriate filename for each track + media_info = MediaInfo.parse(track_path) + base_filename = title.get_filename(media_info, show_service=not no_source) + + # Add track type suffix to filename + track = next((t for t in title.tracks if t.path == track_path), None) + if track: + if isinstance(track, Video): + track_suffix = f".{track.codec.name if hasattr(track.codec, 'name') else 'video'}" + elif isinstance(track, Audio): + lang_suffix = f".{track.language}" if track.language else "" + track_suffix = ( + f"{lang_suffix}.{track.codec.name if hasattr(track.codec, 'name') else 'audio'}" + ) + elif isinstance(track, Subtitle): + lang_suffix = f".{track.language}" if track.language else "" + forced_suffix = ".forced" if track.forced else "" + sdh_suffix = ".sdh" if track.sdh else "" + track_suffix = f"{lang_suffix}{forced_suffix}{sdh_suffix}" + else: + track_suffix = "" + + final_path = final_dir / f"{base_filename}{track_suffix}{track_path.suffix}" + else: + final_path = final_dir / f"{base_filename}{track_path.suffix}" + + shutil.move(track_path, final_path) + self.log.debug(f"Saved: {final_path.name}") + else: + # Handle muxed files + for muxed_path in muxed_paths: + 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 not no_folder and isinstance(title, (Episode, Song)): + final_dir /= title.get_filename(media_info, show_service=not no_source, folder=True) + + final_dir.mkdir(parents=True, exist_ok=True) + final_path = final_dir / f"{final_filename}{muxed_path.suffix}" + if final_path.exists() and audio_codec_suffix and append_audio_codec_suffix: + sep = "." if config.scene_naming else " " + final_filename = f"{final_filename.rstrip()}{sep}{audio_codec_suffix.name}" + final_path = final_dir / f"{final_filename}{muxed_path.suffix}" + + if final_path.exists(): + sep = "." if config.scene_naming else " " + i = 2 + while final_path.exists(): + final_path = final_dir / f"{final_filename.rstrip()}{sep}{i}{muxed_path.suffix}" + i += 1 + + shutil.move(muxed_path, final_path) + tags.tag_file(final_path, title, self.tmdb_id) + + title_dl_time = time_elapsed_since(dl_start_time) + console.print( + Padding(f":tada: Title downloaded in [progress.elapsed]{title_dl_time}[/]!", (0, 5, 1, 5)) + ) + + # update cookies + cookie_file = self.get_cookie_path(self.service, self.profile) + if cookie_file: + self.save_cookies(cookie_file, service.session.cookies) + + dl_time = time_elapsed_since(start_time) + + console.print(Padding(f"Processed all titles in [progress.elapsed]{dl_time}", (0, 5, 1, 5))) + + def prepare_drm( + self, + drm: DRM_T, + track: AnyTrack, + title: Title_T, + certificate: Callable, + licence: Callable, + track_kid: Optional[UUID] = None, + table: Table = None, + cdm_only: bool = False, + vaults_only: bool = False, + export: Optional[Path] = None, + ) -> None: + """ + Prepare the DRM by getting decryption data like KIDs, Keys, and such. + The DRM object should be ready for decryption once this function ends. + """ + if not drm: + return + + track_quality = None + if isinstance(track, Video) and track.height: + track_quality = track.height + + if isinstance(drm, Widevine): + if not is_widevine_cdm(self.cdm): + widevine_cdm = self.get_cdm(self.service, self.profile, drm="widevine", quality=track_quality) + if widevine_cdm: + if track_quality: + self.log.info(f"Switching to Widevine CDM for Widevine {track_quality}p content") + else: + self.log.info("Switching to Widevine CDM for Widevine content") + self.cdm = widevine_cdm + + elif isinstance(drm, PlayReady): + if not is_playready_cdm(self.cdm): + playready_cdm = self.get_cdm(self.service, self.profile, drm="playready", quality=track_quality) + if playready_cdm: + if track_quality: + self.log.info(f"Switching to PlayReady CDM for PlayReady {track_quality}p content") + else: + self.log.info("Switching to PlayReady CDM for PlayReady content") + self.cdm = playready_cdm + + if isinstance(drm, Widevine): + if self.debug_logger: + self.debug_logger.log_drm_operation( + drm_type="Widevine", + operation="prepare_drm", + service=self.service, + context={ + "track": str(track), + "title": str(title), + "pssh": drm.pssh.dumps() if drm.pssh else None, + "kids": [k.hex for k in drm.kids], + "track_kid": track_kid.hex if track_kid else None, + }, + ) + + with self.DRM_TABLE_LOCK: + pssh_display = self.truncate_pssh_for_display(drm.pssh.dumps(), "Widevine") + cek_tree = Tree(Text.assemble(("Widevine", "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 + + need_license = False + all_kids = list(drm.kids) + if track_kid and track_kid not in all_kids: + all_kids.append(track_kid) + + for kid in all_kids: + if kid in drm.content_keys: + continue + + is_track_kid = ["", "*"][kid == track_kid] + + if not cdm_only: + content_key, vault_used = self.vaults.get_key(kid) + if content_key: + drm.content_keys[kid] = content_key + label = f"[text2]{kid.hex}:{content_key}{is_track_kid} from {vault_used}" + if not any(f"{kid.hex}:{content_key}" in x.label for x in cek_tree.children): + cek_tree.add(label) + self.vaults.add_key(kid, content_key, excluding=vault_used) + + if self.debug_logger: + self.debug_logger.log_vault_query( + vault_name=vault_used, + operation="get_key_success", + service=self.service, + context={ + "kid": kid.hex, + "content_key": content_key, + "track": str(track), + "from_cache": True, + }, + ) + elif vaults_only: + msg = f"No Vault has a Key for {kid.hex} and --vaults-only was used" + cek_tree.add(f"[logging.level.error]{msg}") + if not pre_existing_tree: + table.add_row(cek_tree) + if self.debug_logger: + self.debug_logger.log( + level="ERROR", + operation="vault_key_not_found", + service=self.service, + message=msg, + context={"kid": kid.hex, "track": str(track)}, + ) + raise Widevine.Exceptions.CEKNotFound(msg) + else: + need_license = True + + if kid not in drm.content_keys and cdm_only: + need_license = True + + if need_license and not vaults_only: + from_vaults = drm.content_keys.copy() + + if self.debug_logger: + self.debug_logger.log( + level="INFO", + operation="get_license", + service=self.service, + message="Requesting Widevine license from service", + context={ + "track": str(track), + "kids_needed": [k.hex for k in all_kids if k not in drm.content_keys], + }, + ) + + try: + if self.service == "NF": + drm.get_NF_content_keys(cdm=self.cdm, licence=licence, certificate=certificate) + else: + drm.get_content_keys(cdm=self.cdm, licence=licence, certificate=certificate) + except Exception as e: + if isinstance(e, (Widevine.Exceptions.EmptyLicense, Widevine.Exceptions.CEKNotFound)): + msg = str(e) + else: + msg = f"An exception occurred in the Service's license function: {e}" + cek_tree.add(f"[logging.level.error]{msg}") + if not pre_existing_tree: + table.add_row(cek_tree) + if self.debug_logger: + self.debug_logger.log_error( + "get_license", + e, + service=self.service, + context={"track": str(track), "exception_type": type(e).__name__}, + ) + raise e + + if self.debug_logger: + self.debug_logger.log( + level="INFO", + operation="license_keys_retrieved", + service=self.service, + context={ + "track": str(track), + "keys_count": len(drm.content_keys), + "kids": [k.hex for k in drm.content_keys.keys()], + }, + ) + + for kid_, key in drm.content_keys.items(): + if key == "0" * 32: + key = f"[red]{key}[/]" + is_track_kid_marker = ["", "*"][kid_ == track_kid] + label = f"[text2]{kid_.hex}:{key}{is_track_kid_marker}" + if not any(f"{kid_.hex}:{key}" in x.label for x in cek_tree.children): + cek_tree.add(label) + + drm.content_keys = { + kid_: key for kid_, key in drm.content_keys.items() if key and key.count("0") != len(key) + } + + # The CDM keys may have returned blank content keys for KIDs we got from vaults. + # So we re-add the keys from vaults earlier overwriting blanks or removed KIDs data. + drm.content_keys.update(from_vaults) + + successful_caches = self.vaults.add_keys(drm.content_keys) + self.log.info( + f"Cached {len(drm.content_keys)} Key{'' if len(drm.content_keys) == 1 else 's'} to " + f"{successful_caches}/{len(self.vaults)} Vaults" + ) + + if track_kid and track_kid not in drm.content_keys: + msg = f"No Content Key for KID {track_kid.hex} was returned in the License" + cek_tree.add(f"[logging.level.error]{msg}") + if not pre_existing_tree: + table.add_row(cek_tree) + raise Widevine.Exceptions.CEKNotFound(msg) + + if cek_tree.children and not pre_existing_tree: + table.add_row() + table.add_row(cek_tree) + + if export: + keys = {} + if export.is_file(): + keys = jsonpickle.loads(export.read_text(encoding="utf8")) or {} + if str(title) not in keys: + keys[str(title)] = {} + if str(track) not in keys[str(title)]: + keys[str(title)][str(track)] = {} + + track_data = keys[str(title)][str(track)] + track_data["url"] = track.url + track_data["descriptor"] = track.descriptor.name + + if "keys" not in track_data: + track_data["keys"] = {} + for kid, key in drm.content_keys.items(): + track_data["keys"][kid.hex] = key + + export.write_text(jsonpickle.dumps(keys, indent=4), encoding="utf8") + + elif isinstance(drm, PlayReady): + if self.debug_logger: + self.debug_logger.log_drm_operation( + drm_type="PlayReady", + operation="prepare_drm", + service=self.service, + context={ + "track": str(track), + "title": str(title), + "pssh": drm.pssh_b64 or "", + "kids": [k.hex for k in drm.kids], + "track_kid": track_kid.hex if track_kid else None, + }, + ) + + with self.DRM_TABLE_LOCK: + pssh_display = self.truncate_pssh_for_display(drm.pssh_b64 or "", "PlayReady") + cek_tree = Tree( + Text.assemble( + ("PlayReady", "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 + + need_license = False + all_kids = list(drm.kids) + if track_kid and track_kid not in all_kids: + all_kids.append(track_kid) + + for kid in all_kids: + if kid in drm.content_keys: + continue + + is_track_kid = ["", "*"][kid == track_kid] + + if not cdm_only: + content_key, vault_used = self.vaults.get_key(kid) + if content_key: + drm.content_keys[kid] = content_key + label = f"[text2]{kid.hex}:{content_key}{is_track_kid} from {vault_used}" + if not any(f"{kid.hex}:{content_key}" in x.label for x in cek_tree.children): + cek_tree.add(label) + self.vaults.add_key(kid, content_key, excluding=vault_used) + + if self.debug_logger: + self.debug_logger.log_vault_query( + vault_name=vault_used, + operation="get_key_success", + service=self.service, + context={ + "kid": kid.hex, + "content_key": content_key, + "track": str(track), + "from_cache": True, + "drm_type": "PlayReady", + }, + ) + elif vaults_only: + msg = f"No Vault has a Key for {kid.hex} and --vaults-only was used" + cek_tree.add(f"[logging.level.error]{msg}") + if not pre_existing_tree: + table.add_row(cek_tree) + if self.debug_logger: + self.debug_logger.log( + level="ERROR", + operation="vault_key_not_found", + service=self.service, + message=msg, + context={"kid": kid.hex, "track": str(track), "drm_type": "PlayReady"}, + ) + raise PlayReady.Exceptions.CEKNotFound(msg) + else: + need_license = True + + if kid not in drm.content_keys and cdm_only: + need_license = True + + if need_license and not vaults_only: + from_vaults = drm.content_keys.copy() + + try: + drm.get_content_keys(cdm=self.cdm, licence=licence, certificate=certificate) + except Exception as e: + if isinstance(e, (PlayReady.Exceptions.EmptyLicense, PlayReady.Exceptions.CEKNotFound)): + msg = str(e) + else: + msg = f"An exception occurred in the Service's license function: {e}" + cek_tree.add(f"[logging.level.error]{msg}") + if not pre_existing_tree: + table.add_row(cek_tree) + if self.debug_logger: + self.debug_logger.log_error( + "get_license_playready", + e, + service=self.service, + context={ + "track": str(track), + "exception_type": type(e).__name__, + "drm_type": "PlayReady", + }, + ) + raise e + + for kid_, key in drm.content_keys.items(): + is_track_kid_marker = ["", "*"][kid_ == track_kid] + label = f"[text2]{kid_.hex}:{key}{is_track_kid_marker}" + if not any(f"{kid_.hex}:{key}" in x.label for x in cek_tree.children): + cek_tree.add(label) + + drm.content_keys.update(from_vaults) + + successful_caches = self.vaults.add_keys(drm.content_keys) + self.log.info( + f"Cached {len(drm.content_keys)} Key{'' if len(drm.content_keys) == 1 else 's'} to " + f"{successful_caches}/{len(self.vaults)} Vaults" + ) + + if track_kid and track_kid not in drm.content_keys: + msg = f"No Content Key for KID {track_kid.hex} was returned in the License" + cek_tree.add(f"[logging.level.error]{msg}") + if not pre_existing_tree: + table.add_row(cek_tree) + raise PlayReady.Exceptions.CEKNotFound(msg) + + if cek_tree.children and not pre_existing_tree: + table.add_row() + table.add_row(cek_tree) + + if export: + keys = {} + if export.is_file(): + keys = jsonpickle.loads(export.read_text(encoding="utf8")) or {} + if str(title) not in keys: + keys[str(title)] = {} + if str(track) not in keys[str(title)]: + keys[str(title)][str(track)] = {} + + track_data = keys[str(title)][str(track)] + track_data["url"] = track.url + track_data["descriptor"] = track.descriptor.name + + if "keys" not in track_data: + track_data["keys"] = {} + for kid, key in drm.content_keys.items(): + track_data["keys"][kid.hex] = key + + 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.""" + direct_cookie_file = config.directories.cookies / f"{service}.txt" + profile_cookie_file = config.directories.cookies / service / f"{profile}.txt" + default_cookie_file = config.directories.cookies / service / "default.txt" + + if direct_cookie_file.exists(): + return direct_cookie_file + elif profile_cookie_file.exists(): + return profile_cookie_file + elif default_cookie_file.exists(): + return default_cookie_file + + @staticmethod + def get_cookie_jar(service: str, profile: Optional[str]) -> Optional[MozillaCookieJar]: + """Get Service Cookies for Profile.""" + cookie_file = dl.get_cookie_path(service, profile) + if cookie_file: + cookie_jar = MozillaCookieJar(cookie_file) + cookie_data = html.unescape(cookie_file.read_text("utf8")).splitlines(keepends=False) + for i, line in enumerate(cookie_data): + if line and not line.startswith("#"): + line_data = line.lstrip().split("\t") + # Disable client-side expiry checks completely across everywhere + # Even though the cookies are loaded under ignore_expires=True, stuff + # like python-requests may not use them if they are expired + line_data[4] = "" + cookie_data[i] = "\t".join(line_data) + cookie_data = "\n".join(cookie_data) + cookie_file.write_text(cookie_data, "utf8") + cookie_jar.load(ignore_discard=True, ignore_expires=True) + return cookie_jar + + @staticmethod + def save_cookies(path: Path, cookies: CookieJar): + if hasattr(cookies, "jar"): + cookies = cookies.jar + + cookie_jar = MozillaCookieJar(path) + cookie_jar.load() + for cookie in cookies: + cookie_jar.set_cookie(cookie) + cookie_jar.save(ignore_discard=True) + + @staticmethod + def get_credentials(service: str, profile: Optional[str]) -> Optional[Credential]: + """Get Service Credentials for Profile.""" + credentials = config.credentials.get(service) + if credentials: + if isinstance(credentials, dict): + if profile: + credentials = credentials.get(profile) or credentials.get("default") + else: + credentials = credentials.get("default") + if credentials: + if isinstance(credentials, list): + return Credential(*credentials) + return Credential.loads(credentials) # type: ignore + + def get_cdm( + self, + service: str, + profile: Optional[str] = None, + drm: Optional[str] = None, + quality: Optional[int] = None, + ) -> Optional[object]: + """ + Get CDM for a specified service (either Local or Remote CDM). + Now supports quality-based selection when quality is provided. + Raises a ValueError if there's a problem getting a CDM. + """ + cdm_name = config.cdm.get(service) or config.cdm.get("default") + if not cdm_name: + return None + + if isinstance(cdm_name, dict): + if quality: + quality_match = None + quality_keys = [] + + for key in cdm_name.keys(): + if ( + isinstance(key, str) + and any(op in key for op in [">=", ">", "<=", "<"]) + or (isinstance(key, str) and key.isdigit()) + ): + quality_keys.append(key) + + def sort_quality_key(key): + if key.isdigit(): + return (0, int(key)) # Exact matches first + elif key.startswith(">="): + return (1, -int(key[2:])) # >= descending + elif key.startswith(">"): + return (1, -int(key[1:])) # > descending + elif key.startswith("<="): + return (2, int(key[2:])) # <= ascending + elif key.startswith("<"): + return (2, int(key[1:])) # < ascending + return (3, 0) # Other keys last + + quality_keys.sort(key=sort_quality_key) + + for key in quality_keys: + if key.isdigit() and quality == int(key): + quality_match = cdm_name[key] + self.log.debug(f"Selected CDM based on exact quality match {quality}p: {quality_match}") + break + elif key.startswith(">="): + threshold = int(key[2:]) + if quality >= threshold: + quality_match = cdm_name[key] + self.log.debug(f"Selected CDM based on quality {quality}p >= {threshold}p: {quality_match}") + break + elif key.startswith(">"): + threshold = int(key[1:]) + if quality > threshold: + quality_match = cdm_name[key] + self.log.debug(f"Selected CDM based on quality {quality}p > {threshold}p: {quality_match}") + break + elif key.startswith("<="): + threshold = int(key[2:]) + if quality <= threshold: + quality_match = cdm_name[key] + self.log.debug(f"Selected CDM based on quality {quality}p <= {threshold}p: {quality_match}") + break + elif key.startswith("<"): + threshold = int(key[1:]) + if quality < threshold: + quality_match = cdm_name[key] + self.log.debug(f"Selected CDM based on quality {quality}p < {threshold}p: {quality_match}") + break + + if quality_match: + cdm_name = quality_match + + if isinstance(cdm_name, dict): + lower_keys = {k.lower(): v for k, v in cdm_name.items()} + if {"widevine", "playready"} & lower_keys.keys(): + drm_key = None + if drm: + drm_key = { + "wv": "widevine", + "widevine": "widevine", + "pr": "playready", + "playready": "playready", + }.get(drm.lower()) + cdm_name = lower_keys.get(drm_key or "widevine") or lower_keys.get("playready") + else: + cdm_name = cdm_name.get(profile) or cdm_name.get("default") or config.cdm.get("default") + if not cdm_name: + return None + + cdm_api = next(iter(x.copy() for x in config.remote_cdm if x["name"] == cdm_name), None) + if cdm_api: + cdm_type = cdm_api.get("type") + + if cdm_type == "decrypt_labs": + del cdm_api["name"] + del cdm_api["type"] + + if "secret" not in cdm_api or not cdm_api["secret"]: + if config.decrypt_labs_api_key: + cdm_api["secret"] = config.decrypt_labs_api_key + else: + raise ValueError( + f"No secret provided for DecryptLabs CDM '{cdm_name}' and no global " + "decrypt_labs_api_key configured" + ) + + # All DecryptLabs CDMs use DecryptLabsRemoteCDM + return DecryptLabsRemoteCDM(service_name=service, vaults=self.vaults, **cdm_api) + + elif cdm_type == "custom_api": + del cdm_api["name"] + del cdm_api["type"] + + # All Custom API CDMs use CustomRemoteCDM + return CustomRemoteCDM(service_name=service, vaults=self.vaults, **cdm_api) + + else: + 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.get("Device Type", cdm_api.get("device_type", "")), + system_id=cdm_api.get("System ID", cdm_api.get("system_id", "")), + 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")), + ) + + prd_path = config.directories.prds / f"{cdm_name}.prd" + if not prd_path.is_file(): + prd_path = config.directories.wvds / f"{cdm_name}.prd" + if prd_path.is_file(): + device = PlayReadyDevice.load(prd_path) + return PlayReadyCdm.from_device(device) + + cdm_path = config.directories.wvds / f"{cdm_name}.wvd" + if not cdm_path.is_file(): + raise ValueError(f"{cdm_name} does not exist or is not a file") + + try: + device = Device.load(cdm_path) + except ConstError as e: + if "expected 2 but parsed 1" in str(e): + raise ValueError( + f"{cdm_name}.wvd seems to be a v1 WVD file, use `pywidevine migrate --help` to migrate it to v2." + ) + raise ValueError(f"{cdm_name}.wvd is an invalid or corrupt Widevine Device file, {e}") + + return WidevineCdm.from_device(device) diff --git a/packages/envied/src/envied/commands/dl.py b/packages/envied/src/envied/commands/dl.py index cfa3139..25ec99a 100644 --- a/packages/envied/src/envied/commands/dl.py +++ b/packages/envied/src/envied/commands/dl.py @@ -3,6 +3,7 @@ from __future__ import annotations import html import logging import math +import os import random import re import shutil @@ -24,6 +25,7 @@ import click import jsonpickle import yaml from construct import ConstError +from langcodes import Language from pymediainfo import MediaInfo from pyplayready.cdm import Cdm as PlayReadyCdm from pyplayready.device import Device as PlayReadyDevice @@ -41,7 +43,7 @@ from rich.table import Table from rich.text import Text from rich.tree import Tree -from envied.core import binaries +from envied.core import binaries, providers from envied.core.cdm import CustomRemoteCDM, DecryptLabsRemoteCDM from envied.core.cdm.detect import is_playready_cdm, is_widevine_cdm from envied.core.config import config @@ -60,10 +62,11 @@ from envied.core.tracks import Audio, Subtitle, Tracks, Video from envied.core.tracks.attachment import Attachment from envied.core.tracks.hybrid import Hybrid from envied.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) + is_close_match, is_exact_match, suggest_font_packages, time_elapsed_since) from envied.core.utils import tags from envied.core.utils.click_types import (AUDIO_CODEC_LIST, LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, - ContextData, MultipleChoice, SubtitleCodecChoice, VideoCodecChoice) + ContextData, MultipleChoice, MultipleVideoCodecChoice, + SubtitleCodecChoice) from envied.core.utils.collections import merge_dict from envied.core.utils.selector import select_multiple from envied.core.utils.subprocess import ffprobe @@ -195,12 +198,7 @@ class dl: 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 - ): + 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}" @@ -292,9 +290,9 @@ class dl: @click.option( "-v", "--vcodec", - type=VideoCodecChoice(Video.Codec), - default=None, - help="Video Codec to download, defaults to any codec.", + type=MultipleVideoCodecChoice(Video.Codec), + default=[], + help="Video Codec(s) to download, defaults to any codec.", ) @click.option( "-a", @@ -410,6 +408,7 @@ class dl: @click.option( "--tag", type=str, default=None, help="Set the Group Tag to be used, overriding the one in config if any." ) + @click.option("--repack", is_flag=True, default=False, help="Add REPACK tag to the output filename.") @click.option( "--tmdb", "tmdb_id", @@ -418,18 +417,24 @@ class dl: help="Use this TMDB ID for tagging instead of automatic lookup.", ) @click.option( - "--tmdb-name", - "tmdb_name", - is_flag=True, - default=False, - help="Rename titles using the name returned from TMDB lookup.", + "--animeapi", + "animeapi_id", + type=str, + default=None, + help="Anime database ID via AnimeAPI (e.g. mal:12345, anilist:98765). Defaults to MAL if no prefix.", ) @click.option( - "--tmdb-year", - "tmdb_year", + "--enrich", is_flag=True, default=False, - help="Use the release year from TMDB for naming and tagging.", + help="Override show title and year from external source. Requires --tmdb, --imdb, or --animeapi.", + ) + @click.option( + "--imdb", + "imdb_id", + type=str, + default=None, + help="Use this IMDB ID (e.g. tt1375666) for tagging instead of automatic lookup.", ) @click.option( "--sub-format", @@ -489,10 +494,24 @@ class dl: help="Max workers/threads to download with per-track. Default depends on the downloader.", ) @click.option("--downloads", type=int, default=1, help="Amount of tracks to download concurrently.") + @click.option( + "-o", + "--output", + "output_dir", + type=Path, + default=None, + help="Override the output directory for this download, instead of the one in config.", + ) @click.option("--no-cache", "no_cache", is_flag=True, default=False, help="Bypass title cache for this download.") @click.option( "--reset-cache", "reset_cache", is_flag=True, default=False, help="Clear title cache before fetching." ) + @click.option( + "--worst", + is_flag=True, + default=False, + help="Select the lowest bitrate track within the specified quality. Requires -q/--quality.", + ) @click.option( "--best-available", "best_available", @@ -512,10 +531,13 @@ class dl: no_proxy: bool, profile: Optional[str] = None, proxy: Optional[str] = None, + repack: bool = False, tag: Optional[str] = None, tmdb_id: Optional[int] = None, - tmdb_name: bool = False, - tmdb_year: bool = False, + imdb_id: Optional[str] = None, + animeapi_id: Optional[str] = None, + enrich: bool = False, + output_dir: Optional[Path] = None, *_: Any, **__: Any, ): @@ -559,8 +581,23 @@ class dl: self.profile = profile self.tmdb_id = tmdb_id - self.tmdb_name = tmdb_name - self.tmdb_year = tmdb_year + self.imdb_id = imdb_id + self.enrich = enrich + self.animeapi_title: Optional[str] = None + self.output_dir = output_dir + + if animeapi_id: + from envied.core.utils.animeapi import resolve_animeapi + + anime_title, anime_ids = resolve_animeapi(animeapi_id) + self.animeapi_title = anime_title + if not self.tmdb_id and anime_ids.tmdb_id: + self.tmdb_id = anime_ids.tmdb_id + if not self.imdb_id and anime_ids.imdb_id: + self.imdb_id = anime_ids.imdb_id + + if self.enrich and not (self.tmdb_id or self.imdb_id or self.animeapi_title): + raise click.UsageError("--enrich requires --tmdb, --imdb, or --animeapi to provide a metadata source.") # Initialize debug logger with service name if debug logging is enabled if config.debug or logging.root.level == logging.DEBUG: @@ -583,16 +620,75 @@ class dl: "profile": profile, "proxy": proxy, "tag": tag, - "tmdb_id": tmdb_id, - "tmdb_name": tmdb_name, - "tmdb_year": tmdb_year, + "tmdb_id": self.tmdb_id, + "imdb_id": self.imdb_id, + "animeapi_id": animeapi_id, + "enrich": enrich, "cli_params": { k: v for k, v in ctx.params.items() - if k not in ["profile", "proxy", "tag", "tmdb_id", "tmdb_name", "tmdb_year"] + if k + not in [ + "profile", + "proxy", + "tag", + "tmdb_id", + "imdb_id", + "animeapi_id", + "enrich", + ] }, }, ) + + # Log binary versions for diagnostics + binary_versions = {} + for name, binary in [ + ("shaka_packager", binaries.ShakaPackager), + ("mp4decrypt", binaries.Mp4decrypt), + ("n_m3u8dl_re", binaries.N_m3u8DL_RE), + ("mkvmerge", binaries.MKVToolNix), + ("ffmpeg", binaries.FFMPEG), + ("ffprobe", binaries.FFProbe), + ]: + if binary: + version = None + try: + if name == "shaka_packager": + r = subprocess.run( + [str(binary), "--version"], capture_output=True, text=True, timeout=5 + ) + version = (r.stdout or r.stderr or "").strip() + elif name in ("ffmpeg", "ffprobe"): + r = subprocess.run([str(binary), "-version"], capture_output=True, text=True, timeout=5) + version = (r.stdout or "").split("\n")[0].strip() + elif name == "mkvmerge": + r = subprocess.run( + [str(binary), "--version"], capture_output=True, text=True, timeout=5 + ) + version = (r.stdout or "").strip() + elif name == "mp4decrypt": + r = subprocess.run([str(binary)], capture_output=True, text=True, timeout=5) + output = (r.stdout or "") + (r.stderr or "") + lines = [line.strip() for line in output.split("\n") if line.strip()] + version = " | ".join(lines[:2]) if lines else None + elif name == "n_m3u8dl_re": + r = subprocess.run( + [str(binary), "--version"], capture_output=True, text=True, timeout=5 + ) + version = (r.stdout or r.stderr or "").strip().split("\n")[0] + except Exception: + version = "" + binary_versions[name] = {"path": str(binary), "version": version} + else: + binary_versions[name] = None + + self.debug_logger.log( + level="DEBUG", + operation="binary_versions", + message="Binary tool versions", + context=binary_versions, + ) else: self.debug_logger = None @@ -848,6 +944,9 @@ class dl: config=self.service_config, cdm=self.cdm, proxy_providers=self.proxy_providers, profile=self.profile ) + if repack: + config.repack = True + if tag: config.tag = tag @@ -859,7 +958,7 @@ class dl: self, service: Service, quality: list[int], - vcodec: Optional[Video.Codec], + vcodec: list[Video.Codec], acodec: list[Audio.Codec], vbitrate: int, abitrate: int, @@ -898,6 +997,7 @@ class dl: no_mux: bool, workers: Optional[int], downloads: int, + worst: bool, best_available: bool, split_audio: Optional[bool] = None, *_: Any, @@ -923,6 +1023,14 @@ class dl: self.log.error("--require-subs and --s-lang cannot be used together") sys.exit(1) + if worst and not quality: + self.log.error("--worst requires -q/--quality to be specified") + sys.exit(1) + + if select_titles and wanted: + self.log.error("--select-titles and -w/--wanted cannot be used together") + sys.exit(1) + # Check if dovi_tool is available when hybrid mode is requested if any(r == Video.Range.HYBRID for r in range_): from envied.core.binaries import DoviTool @@ -1014,40 +1122,51 @@ class dl: cache_region = service.current_region if hasattr(service, "current_region") else None cache_account_hash = get_account_hash(service.credential) if hasattr(service, "credential") else None - if (self.tmdb_year or self.tmdb_name) and self.tmdb_id: + if self.enrich: sample_title = titles[0] if hasattr(titles, "__getitem__") else titles kind = "tv" if isinstance(sample_title, Episode) else "movie" - tmdb_year_val = None - tmdb_name_val = None + enrich_title: Optional[str] = None + enrich_year: Optional[int] = None - if self.tmdb_year: - tmdb_year_val = tags.get_year( + if self.animeapi_title: + enrich_title = self.animeapi_title + + if self.tmdb_id: + if not enrich_title: + enrich_title = providers.get_title_by_id( + self.tmdb_id, kind, title_cacher, cache_title_id, cache_region, cache_account_hash + ) + enrich_year = providers.get_year_by_id( self.tmdb_id, kind, title_cacher, cache_title_id, cache_region, cache_account_hash ) + elif self.imdb_id: + imdbapi = providers.get_provider("imdbapi") + if imdbapi: + imdb_result = imdbapi.get_by_id(self.imdb_id, kind) + if imdb_result: + if not enrich_title: + enrich_title = imdb_result.title + enrich_year = imdb_result.year - if self.tmdb_name: - tmdb_name_val = tags.get_title( - self.tmdb_id, kind, title_cacher, cache_title_id, cache_region, cache_account_hash - ) - - if isinstance(titles, (Series, Movies)): - for t in titles: - if tmdb_year_val: - t.year = tmdb_year_val - if tmdb_name_val: - if isinstance(t, Episode): - t.title = tmdb_name_val + if enrich_title or enrich_year: + if isinstance(titles, (Series, Movies)): + for t in titles: + if enrich_title: + if isinstance(t, Episode): + t.title = enrich_title + else: + t.name = enrich_title + if enrich_year and not t.year: + t.year = enrich_year + else: + if enrich_title: + if isinstance(titles, Episode): + titles.title = enrich_title else: - t.name = tmdb_name_val - else: - if tmdb_year_val: - titles.year = tmdb_year_val - if tmdb_name_val: - if isinstance(titles, Episode): - titles.title = tmdb_name_val - else: - titles.name = tmdb_name_val + titles.name = enrich_title + if enrich_year and not titles.year: + titles.year = enrich_year console.print(Padding(Rule(f"[rule.text]{titles.__class__.__name__}: {titles}"), (1, 2))) @@ -1056,13 +1175,13 @@ class dl: return # Enables manual selection for Series when --select-titles is set - if select_titles and type(titles) == Series: - console.print(Padding(Rule(f"[rule.text]Select Titles"), (1, 2))) - + if select_titles and isinstance(titles, Series): + console.print(Padding(Rule("[rule.text]Select Titles"), (1, 2))) + selection_titles = [] dependencies = {} original_indices = {} - + current_season = None current_season_header_idx = -1 @@ -1081,37 +1200,40 @@ class dl: # Note: Headers are not mapped to actual title indices # Format display name - display_name = ((t.name[:35].rstrip() + "…") if len(t.name) > 35 else t.name) if t.name else None - + display_name = ((t.name[:30].rstrip() + "…") if len(t.name) > 30 else t.name) if t.name else None + # Apply indentation only for multiple seasons prefix = " " if multiple_seasons else "" - option_text = ( - f"{prefix}{t.number}" + (f". {display_name}" if t.name else "") - ) - + option_text = f"{prefix}{t.number}" + (f". {display_name}" if t.name else "") + selection_titles.append(option_text) current_ui_idx = len(selection_titles) - 1 - + # Map UI index to actual title index original_indices[current_ui_idx] = i - + # Link episode to season header for group selection if current_season_header_idx != -1: dependencies[current_season_header_idx].append(current_ui_idx) selection_start = time.time() - + # Execute selector with dependencies (headers select all children) selected_ui_idx = select_multiple( selection_titles, minimal_count=1, page_size=8, return_indices=True, - dependencies=dependencies + dependencies=dependencies, + collapse_on_start=multiple_seasons, ) + if not selected_ui_idx: + console.print(Padding(":x: Selection Cancelled...", (0, 5, 1, 5))) + return + selection_end = time.time() - start_time += (selection_end - selection_start) + start_time += selection_end - selection_start # Map UI indices back to title indices (excluding headers) selected_idx = [] @@ -1155,15 +1277,20 @@ class dl: if isinstance(title, Episode) and not self.tmdb_searched: kind = "tv" + tmdb_title: Optional[str] = None if self.tmdb_id: - tmdb_title = tags.get_title( + tmdb_title = providers.get_title_by_id( self.tmdb_id, kind, title_cacher, cache_title_id, cache_region, cache_account_hash ) else: - self.tmdb_id, tmdb_title, self.search_source = tags.search_show_info( + result = providers.search_metadata( title.title, title.year, kind, title_cacher, cache_title_id, cache_region, cache_account_hash ) - if not (self.tmdb_id and tmdb_title and tags.fuzzy_match(tmdb_title, title.title)): + if result and result.title and providers.fuzzy_match(result.title, title.title): + self.tmdb_id = result.external_ids.tmdb_id + tmdb_title = result.title + self.search_source = result.source + else: self.tmdb_id = None if list_ or list_titles: if self.tmdb_id: @@ -1178,22 +1305,25 @@ class dl: self.tmdb_searched = True if isinstance(title, Movie) and (list_ or list_titles) and not self.tmdb_id: - movie_id, movie_title, _ = tags.search_show_info( + movie_result = providers.search_metadata( title.name, title.year, "movie", title_cacher, cache_title_id, cache_region, cache_account_hash ) - if movie_id: + if movie_result and movie_result.external_ids.tmdb_id: console.print( Padding( - f"Search -> {movie_title or '?'} [bright_black](ID {movie_id})", + f"Search -> {movie_result.title or '?'} " + f"[bright_black](ID {movie_result.external_ids.tmdb_id})", (0, 5), ) ) else: console.print(Padding("Search -> [bright_black]No match found[/]", (0, 5))) - if self.tmdb_id and getattr(self, "search_source", None) != "simkl": + if self.tmdb_id and getattr(self, "search_source", None) not in ("simkl", "imdbapi"): kind = "tv" if isinstance(title, Episode) else "movie" - tags.external_ids(self.tmdb_id, kind, title_cacher, cache_title_id, cache_region, cache_account_hash) + providers.fetch_external_ids( + self.tmdb_id, kind, title_cacher, cache_title_id, cache_region, cache_account_hash + ) if slow and i != 0: delay = random.randint(60, 120) @@ -1223,6 +1353,15 @@ class dl: v_lang = None title.tracks.videos = [] + if no_audio: + console.log("Skipped audio as --no-audio was used...") + a_lang = None + title.tracks.audio = [] + + if no_chapters: + console.log("Skipped chapters as --no-chapters was used...") + title.tracks.chapters = [] + with console.status("Getting tracks...", spinner="dots"): try: title.tracks.add(service.get_tracks(title), warn_only=True) @@ -1336,9 +1475,12 @@ class dl: if isinstance(title, (Movie, Episode)): # filter video tracks if vcodec: - title.tracks.select_video(lambda x: x.codec == vcodec) + title.tracks.select_video(lambda x: x.codec in vcodec) + missing_codecs = [c for c in vcodec if not any(x.codec == c for x in title.tracks.videos)] + for codec in missing_codecs: + self.log.warning(f"Skipping {codec.name} video tracks as none are available.") if not title.tracks.videos: - self.log.error(f"There's no {vcodec.name} Video Track...") + self.log.error(f"There's no {', '.join(c.name for c in vcodec)} Video Track...") sys.exit(1) if range_: @@ -1350,10 +1492,20 @@ class dl: self.log.warning(f"Skipping {color_range.name} video tracks as none are available.") if vbitrate: - title.tracks.select_video(lambda x: x.bitrate and x.bitrate // 1000 == vbitrate) - if not title.tracks.videos: - self.log.error(f"There's no {vbitrate}kbps Video Track...") - sys.exit(1) + if any(r == Video.Range.HYBRID for r in range_): + # In HYBRID mode, only apply bitrate filter to non-DV tracks + # DV tracks are kept regardless since they're only used for RPU metadata + title.tracks.select_video( + lambda x: x.range == Video.Range.DV or (x.bitrate and x.bitrate // 1000 == vbitrate) + ) + if not any(x.range != Video.Range.DV for x in title.tracks.videos): + self.log.error(f"There's no {vbitrate}kbps Video Track...") + sys.exit(1) + else: + title.tracks.select_video(lambda x: x.bitrate and x.bitrate // 1000 == vbitrate) + if not title.tracks.videos: + self.log.error(f"There's no {vbitrate}kbps Video Track...") + sys.exit(1) video_languages = [lang for lang in (v_lang or lang) if lang != "best"] if video_languages and "all" not in video_languages: @@ -1380,10 +1532,38 @@ class dl: self.log.error(f"There's no {processed_video_lang} Video Track...") sys.exit(1) + has_hybrid = any(r == Video.Range.HYBRID for r in range_) + non_hybrid_ranges = [r for r in range_ if r != Video.Range.HYBRID] + if quality: missing_resolutions = [] - if any(r == Video.Range.HYBRID for r in range_): - title.tracks.select_video(title.tracks.select_hybrid(title.tracks.videos, quality)) + if has_hybrid: + # Split tracks: hybrid candidates vs non-hybrid + hybrid_candidate_tracks = [ + v + for v in title.tracks.videos + if v.range in (Video.Range.HDR10, Video.Range.HDR10P, Video.Range.DV) + ] + non_hybrid_tracks = [ + v + for v in title.tracks.videos + if v.range not in (Video.Range.HDR10, Video.Range.HDR10P, Video.Range.DV) + ] + + # Apply hybrid selection to HDR10+DV tracks + hybrid_filter = title.tracks.select_hybrid(hybrid_candidate_tracks, quality) + hybrid_selected = list(filter(hybrid_filter, hybrid_candidate_tracks)) + + if non_hybrid_ranges and non_hybrid_tracks: + # Also filter non-hybrid tracks by resolution + non_hybrid_selected = [ + v + for v in non_hybrid_tracks + if any(v.height == res or int(v.width * (9 / 16)) == res for res in quality) + ] + title.tracks.videos = hybrid_selected + non_hybrid_selected + else: + title.tracks.videos = hybrid_selected else: title.tracks.by_resolutions(quality) @@ -1410,60 +1590,111 @@ class dl: sys.exit(1) # choose best track by range and quality - if any(r == Video.Range.HYBRID for r in range_): - # For hybrid mode, always apply hybrid selection - # If no quality specified, use only the best (highest) resolution + if has_hybrid: + # Apply hybrid selection for HYBRID tracks + hybrid_candidate_tracks = [ + v + for v in title.tracks.videos + if v.range in (Video.Range.HDR10, Video.Range.HDR10P, Video.Range.DV) + ] + non_hybrid_tracks = [ + v + for v in title.tracks.videos + if v.range not in (Video.Range.HDR10, Video.Range.HDR10P, Video.Range.DV) + ] + if not quality: - # Get the highest resolution available - best_resolution = max((v.height for v in title.tracks.videos), default=None) + best_resolution = max((v.height for v in hybrid_candidate_tracks), default=None) if best_resolution: - # Use the hybrid selection logic with only the best resolution - title.tracks.select_video( - title.tracks.select_hybrid(title.tracks.videos, [best_resolution]) - ) - # If quality was specified, hybrid selection was already applied above - else: - selected_videos: list[Video] = [] - for resolution, color_range in product(quality or [None], range_ or [None]): - match = next( - ( + hybrid_filter = title.tracks.select_hybrid(hybrid_candidate_tracks, [best_resolution]) + hybrid_selected = list(filter(hybrid_filter, hybrid_candidate_tracks)) + else: + hybrid_selected = [] + else: + hybrid_filter = title.tracks.select_hybrid(hybrid_candidate_tracks, quality) + hybrid_selected = list(filter(hybrid_filter, hybrid_candidate_tracks)) + + # For non-hybrid ranges, apply Cartesian product selection + non_hybrid_selected: list[Video] = [] + if non_hybrid_ranges and non_hybrid_tracks: + for resolution, color_range, codec in product( + quality or [None], non_hybrid_ranges, vcodec or [None] + ): + candidates = [ t - for t in title.tracks.videos + for t in non_hybrid_tracks if ( not resolution or t.height == resolution or int(t.width * (9 / 16)) == resolution ) and (not color_range or t.range == color_range) - ), - None, - ) + and (not codec or t.codec == codec) + ] + match = candidates[-1] if worst and candidates else next(iter(candidates), None) + if match and match not in non_hybrid_selected: + non_hybrid_selected.append(match) + + title.tracks.videos = hybrid_selected + non_hybrid_selected + else: + selected_videos: list[Video] = [] + for resolution, color_range, codec in product( + quality or [None], range_ or [None], vcodec or [None] + ): + candidates = [ + t + for t in title.tracks.videos + if ( + not resolution + or t.height == resolution + or int(t.width * (9 / 16)) == resolution + ) + and (not color_range or t.range == color_range) + and (not codec or t.codec == codec) + ] + match = candidates[-1] if worst and candidates else next(iter(candidates), None) if match and match not in selected_videos: selected_videos.append(match) title.tracks.videos = selected_videos # validate hybrid mode requirements if any(r == Video.Range.HYBRID for r in range_): - hdr10_tracks = [v for v in title.tracks.videos if v.range == Video.Range.HDR10] + base_tracks = [ + v for v in title.tracks.videos if v.range in (Video.Range.HDR10, Video.Range.HDR10P) + ] dv_tracks = [v for v in title.tracks.videos if v.range == Video.Range.DV] - if not hdr10_tracks and not dv_tracks: + hybrid_failed = False + if not base_tracks and not dv_tracks: available_ranges = sorted(set(v.range.name for v in title.tracks.videos)) - self.log.error("HYBRID mode requires both HDR10 and DV tracks, but neither is available") - self.log.error( + msg = "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but neither is available" + msg_detail = ( f"Available ranges: {', '.join(available_ranges) if available_ranges else 'none'}" ) - sys.exit(1) - elif not hdr10_tracks: + hybrid_failed = True + elif not base_tracks: available_ranges = sorted(set(v.range.name for v in title.tracks.videos)) - self.log.error("HYBRID mode requires both HDR10 and DV tracks, but only DV is available") - self.log.error(f"Available ranges: {', '.join(available_ranges)}") - sys.exit(1) + msg = "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only DV is available" + msg_detail = f"Available ranges: {', '.join(available_ranges)}" + hybrid_failed = True elif not dv_tracks: available_ranges = sorted(set(v.range.name for v in title.tracks.videos)) - self.log.error("HYBRID mode requires both HDR10 and DV tracks, but only HDR10 is available") - self.log.error(f"Available ranges: {', '.join(available_ranges)}") - sys.exit(1) + msg = "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only HDR10 is available" + msg_detail = f"Available ranges: {', '.join(available_ranges)}" + hybrid_failed = True + + if hybrid_failed: + other_ranges = [r for r in range_ if r != Video.Range.HYBRID] + if best_available and other_ranges: + self.log.warning(msg) + self.log.warning( + f"Continuing with remaining range(s): {', '.join(r.name for r in other_ranges)}" + ) + range_ = other_ranges + else: + self.log.error(msg) + self.log.error(msg_detail) + sys.exit(1) # filter subtitle tracks if require_subs: @@ -1481,8 +1712,6 @@ class dl: f"Required languages found ({', '.join(require_subs)}), downloading all available subtitles" ) elif s_lang and "all" not in s_lang: - from envied.core.utilities import is_exact_match - match_func = is_exact_match if exact_lang else is_close_match missing_langs = [ @@ -1547,7 +1776,7 @@ class dl: if language not in processed_lang: processed_lang.append(language) - if "best" in processed_lang: + if "best" in processed_lang or "all" in processed_lang: unique_languages = {track.language for track in title.tracks.audio} selected_audio = [] for language in unique_languages: @@ -1570,7 +1799,7 @@ class dl: else: selected_audio.append(max(base_candidates, key=lambda x: x.bitrate or 0)) title.tracks.audio = selected_audio - elif "all" not in processed_lang: + else: # If multiple codecs were explicitly requested, pick the best track per codec per # requested language instead of selecting *all* bitrate variants of a codec. if acodec and len(acodec) > 1: @@ -1604,7 +1833,10 @@ class dl: if audio_description: standard_audio = [a for a in title.tracks.audio if not a.descriptive] selected_standards = title.tracks.by_language( - standard_audio, processed_lang, per_language=per_language, exact_match=exact_lang + standard_audio, + processed_lang, + per_language=per_language, + exact_match=exact_lang, ) desc_audio = [a for a in title.tracks.audio if a.descriptive] # Include all descriptive tracks for the requested languages. @@ -1728,10 +1960,7 @@ class dl: ), licence=partial( service.get_playready_license - if ( - is_playready_cdm(self.cdm) - ) - and hasattr(service, "get_playready_license") + if is_playready_cdm(self.cdm) else service.get_widevine_license, title=title, track=track, @@ -1802,55 +2031,10 @@ class dl: dl_time = time_elapsed_since(dl_start_time) console.print(Padding(f"Track downloads finished in [progress.elapsed]{dl_time}[/]", (0, 5))) - video_track_n = 0 - - while ( - not title.tracks.subtitles - and not no_subs - and not (hasattr(service, "NO_SUBTITLES") and service.NO_SUBTITLES) - and not video_only - and not no_video - and len(title.tracks.videos) > video_track_n - and any( - x.get("codec_name", "").startswith("eia_") - for x in ffprobe(title.tracks.videos[video_track_n].path).get("streams", []) - ) - ): - with console.status(f"Checking Video track {video_track_n + 1} for Closed Captions..."): - try: - # TODO: Figure out the real language, it might be different - # EIA-CC tracks sadly don't carry language information :( - # TODO: Figure out if the CC language is original lang or not. - # Will need to figure out above first to do so. - video_track = title.tracks.videos[video_track_n] - track_id = f"ccextractor-{video_track.id}" - cc_lang = title.language or video_track.language - cc = video_track.ccextractor( - track_id=track_id, - out_path=config.directories.temp - / config.filenames.subtitle.format(id=track_id, language=cc_lang), - language=cc_lang, - original=False, - ) - if cc: - # will not appear in track listings as it's added after all times it lists - title.tracks.add(cc) - self.log.info(f"Extracted a Closed Caption from Video track {video_track_n + 1}") - else: - self.log.info(f"No Closed Captions were found in Video track {video_track_n + 1}") - except EnvironmentError: - self.log.error( - "Cannot extract Closed Captions as the ccextractor executable was not found..." - ) - 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) - ) + 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: @@ -1912,6 +2096,75 @@ class dl: if has_decrypted: self.log.info(f"Decrypted tracks with {decrypt_tool}") + # Extract Closed Captions from decrypted video tracks + if ( + not no_subs + and not (hasattr(service, "NO_SUBTITLES") and service.NO_SUBTITLES) + and not video_only + and not no_video + ): + match_func = is_exact_match if exact_lang else is_close_match + for video_track_n, video_track in enumerate(title.tracks.videos): + has_manifest_cc = bool(getattr(video_track, "closed_captions", None)) + has_eia_cc = ( + not has_manifest_cc + and not title.tracks.subtitles + and any( + x.get("codec_name", "").startswith("eia_") + for x in ffprobe(video_track.path).get("streams", []) + ) + ) + if not has_manifest_cc and not has_eia_cc: + continue + + # Build list of CC entries to extract + if has_manifest_cc: + cc_entries = video_track.closed_captions + # Filter CC languages against --s-lang if specified + if s_lang and "all" not in s_lang: + cc_entries = [ + entry for entry in cc_entries + if entry.get("language") + and match_func(Language.get(entry["language"]), s_lang) + ] + if not cc_entries: + continue + else: + # EIA fallback: single entry with unknown language + cc_entries = [{}] + + with console.status(f"Checking Video track {video_track_n + 1} for Closed Captions..."): + try: + for cc_idx, cc_entry in enumerate(cc_entries): + cc_lang = ( + Language.get(cc_entry["language"]) + if cc_entry.get("language") + else title.language or video_track.language + ) + track_id = f"ccextractor-{video_track.id}-{cc_idx}" + cc = video_track.ccextractor( + track_id=track_id, + out_path=config.directories.temp + / config.filenames.subtitle.format(id=track_id, language=cc_lang), + language=cc_lang, + original=False, + ) + if cc: + cc.cc = True + title.tracks.add(cc) + self.log.info( + f"Extracted a Closed Caption ({cc_lang}) from Video track {video_track_n + 1}" + ) + else: + self.log.info( + f"No Closed Captions were found in Video track {video_track_n + 1}" + ) + except EnvironmentError: + self.log.error( + "Cannot extract Closed Captions as the ccextractor executable was not found..." + ) + break + # Now repack the decrypted tracks with console.status("Repackaging tracks with FFMPEG..."): has_repacked = False @@ -1987,12 +2240,14 @@ class dl: # Hybrid mode: process DV and HDR10 tracks separately for each resolution self.log.info("Processing Hybrid HDR10+DV tracks...") - # Group video tracks by resolution + # Group video tracks by resolution (prefer HDR10+ over HDR10 as base) resolutions_processed = set() - hdr10_tracks = [v for v in title.tracks.videos if v.range == Video.Range.HDR10] + base_tracks_list = [ + v for v in title.tracks.videos if v.range in (Video.Range.HDR10P, Video.Range.HDR10) + ] dv_tracks = [v for v in title.tracks.videos if v.range == Video.Range.DV] - for hdr10_track in hdr10_tracks: + for hdr10_track in base_tracks_list: resolution = hdr10_track.height if resolution in resolutions_processed: continue @@ -2048,6 +2303,8 @@ class dl: task_description += f" {video_track.height}p" if len(range_) > 1: task_description += f" {video_track.range.name}" + if len(vcodec) > 1: + task_description += f" {video_track.codec.name}" task_tracks = Tracks(title.tracks) + title.tracks.chapters + title.tracks.attachments if video_track: @@ -2099,9 +2356,11 @@ class dl: else: base_filename = str(title) - sidecar_dir = config.directories.downloads + sidecar_dir = self.output_dir or 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 /= 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..."): @@ -2151,7 +2410,7 @@ class dl: if no_mux: # Handle individual track files without muxing - final_dir = config.directories.downloads + final_dir = self.output_dir or config.directories.downloads if not no_folder and isinstance(title, (Episode, Song)): # Create folder based on title # Use first available track for filename generation @@ -2201,9 +2460,10 @@ class dl: self.log.debug(f"Saved: {final_path.name}") else: # Handle muxed files + used_final_paths: set[Path] = set() for muxed_path in muxed_paths: media_info = MediaInfo.parse(muxed_path) - final_dir = config.directories.downloads + final_dir = self.output_dir or config.directories.downloads final_filename = title.get_filename(media_info, show_service=not no_source) audio_codec_suffix = muxed_audio_codecs.get(muxed_path) @@ -2212,20 +2472,29 @@ class dl: final_dir.mkdir(parents=True, exist_ok=True) final_path = final_dir / f"{final_filename}{muxed_path.suffix}" + template_type = ( + "series" if isinstance(title, Episode) else "songs" if isinstance(title, Song) else "movies" + ) + sep = config.get_template_separator(template_type) + if final_path.exists() and audio_codec_suffix and append_audio_codec_suffix: - sep = "." if config.scene_naming else " " final_filename = f"{final_filename.rstrip()}{sep}{audio_codec_suffix.name}" final_path = final_dir / f"{final_filename}{muxed_path.suffix}" - if final_path.exists(): - sep = "." if config.scene_naming else " " + if final_path in used_final_paths: i = 2 - while final_path.exists(): + while final_path in used_final_paths: final_path = final_dir / f"{final_filename.rstrip()}{sep}{i}{muxed_path.suffix}" i += 1 - shutil.move(muxed_path, final_path) - tags.tag_file(final_path, title, self.tmdb_id) + try: + os.replace(muxed_path, final_path) + except OSError: + if final_path.exists(): + final_path.unlink() + shutil.move(muxed_path, final_path) + used_final_paths.add(final_path) + tags.tag_file(final_path, title, self.tmdb_id, self.imdb_id) title_dl_time = time_elapsed_since(dl_start_time) console.print( diff --git a/packages/envied/src/envied/core/__init__.py b/packages/envied/src/envied/core/__init__.py index 528787c..ce1305b 100644 --- a/packages/envied/src/envied/core/__init__.py +++ b/packages/envied/src/envied/core/__init__.py @@ -1 +1 @@ -__version__ = "3.0.0" +__version__ = "4.0.0" diff --git a/packages/envied/src/envied/core/__main__.py b/packages/envied/src/envied/core/__main__.py index 22037d8..d25c718 100644 --- a/packages/envied/src/envied/core/__main__.py +++ b/packages/envied/src/envied/core/__main__.py @@ -1,5 +1,6 @@ import atexit import logging +from datetime import datetime import click import urllib3 diff --git a/packages/envied/src/envied/core/api/download_manager.py b/packages/envied/src/envied/core/api/download_manager.py index e6eb4ed..b8a15db 100644 --- a/packages/envied/src/envied/core/api/download_manager.py +++ b/packages/envied/src/envied/core/api/download_manager.py @@ -10,6 +10,7 @@ from contextlib import suppress from dataclasses import dataclass, field from datetime import datetime, timedelta from enum import Enum +from pathlib import Path from typing import Any, Callable, Dict, List, Optional log = logging.getLogger("download_manager") @@ -105,11 +106,44 @@ def _perform_download( from envied.commands.dl import dl from envied.core.config import config from envied.core.services import Services + from envied.core.tracks import Subtitle, Video from envied.core.utils.click_types import ContextData from envied.core.utils.collections import merge_dict log.info(f"Starting sync download for job {job_id}") + # Convert string parameters to enums (API receives strings, dl.result() expects enums) + vcodec_raw = params.get("vcodec") + if vcodec_raw: + if isinstance(vcodec_raw, str): + vcodec_raw = [vcodec_raw] + if isinstance(vcodec_raw, list) and vcodec_raw and not isinstance(vcodec_raw[0], Video.Codec): + codec_map = {c.name.upper(): c for c in Video.Codec} + codec_map.update({c.value.upper(): c for c in Video.Codec}) + params["vcodec"] = [codec_map[v.upper()] for v in vcodec_raw if v.upper() in codec_map] + else: + params["vcodec"] = [] + + range_raw = params.get("range") + if range_raw: + if isinstance(range_raw, str): + range_raw = [range_raw] + if isinstance(range_raw, list) and range_raw and not isinstance(range_raw[0], Video.Range): + range_map = {r.name.upper(): r for r in Video.Range} + range_map.update({r.value.upper(): r for r in Video.Range}) + params["range"] = [range_map[r.upper()] for r in range_raw if r.upper() in range_map] + else: + params["range"] = [Video.Range.SDR] + + sub_format_raw = params.get("sub_format") + if sub_format_raw and isinstance(sub_format_raw, str): + sub_map = {c.name.upper(): c for c in Subtitle.Codec} + sub_map.update({c.value.upper(): c for c in Subtitle.Codec}) + params["sub_format"] = sub_map.get(sub_format_raw.upper()) + + if params.get("export") and isinstance(params["export"], str): + params["export"] = Path(params["export"]) + # Load service configuration service_config_path = Services.get_path(service) / config.filenames.config if service_config_path.exists(): @@ -127,10 +161,15 @@ def _perform_download( "proxy": params.get("proxy"), "no_proxy": params.get("no_proxy", False), "profile": params.get("profile"), + "repack": params.get("repack", False), "tag": params.get("tag"), "tmdb_id": params.get("tmdb_id"), - "tmdb_name": params.get("tmdb_name", False), - "tmdb_year": params.get("tmdb_year", False), + "imdb_id": params.get("imdb_id"), + "animeapi_id": params.get("animeapi_id"), + "enrich": params.get("enrich", False), + "output_dir": Path(params["output_dir"]) if params.get("output_dir") else None, + "no_cache": params.get("no_cache", False), + "reset_cache": params.get("reset_cache", False), } dl_instance = dl( @@ -138,10 +177,13 @@ def _perform_download( no_proxy=params.get("no_proxy", False), profile=params.get("profile"), proxy=params.get("proxy"), + repack=params.get("repack", False), tag=params.get("tag"), tmdb_id=params.get("tmdb_id"), - tmdb_name=params.get("tmdb_name", False), - tmdb_year=params.get("tmdb_year", False), + imdb_id=params.get("imdb_id"), + animeapi_id=params.get("animeapi_id"), + enrich=params.get("enrich", False), + output_dir=Path(params["output_dir"]) if params.get("output_dir") else None, ) service_module = Services.load(service) @@ -220,14 +262,14 @@ def _perform_download( dl_instance.result( service=service_instance, quality=params.get("quality", []), - vcodec=params.get("vcodec"), + vcodec=params.get("vcodec", []), acodec=params.get("acodec"), vbitrate=params.get("vbitrate"), abitrate=params.get("abitrate"), range_=params.get("range", ["SDR"]), channels=params.get("channels"), no_atmos=params.get("no_atmos", False), - split_audio=params.get("split_audio"), + select_titles=False, wanted=params.get("wanted", []), latest_episode=params.get("latest_episode", False), lang=params.get("lang", ["orig"]), @@ -245,6 +287,7 @@ def _perform_download( no_subs=params.get("no_subs", False), no_audio=params.get("no_audio", False), no_chapters=params.get("no_chapters", False), + no_video=params.get("no_video", False), audio_description=params.get("audio_description", False), slow=params.get("slow", False), list_=False, @@ -259,6 +302,7 @@ def _perform_download( workers=params.get("workers"), downloads=params.get("downloads", 1), best_available=params.get("best_available", False), + split_audio=params.get("split_audio"), ) except SystemExit as exc: diff --git a/packages/envied/src/envied/core/api/handlers.py b/packages/envied/src/envied/core/api/handlers.py index 02b5179..f01e73e 100644 --- a/packages/envied/src/envied/core/api/handlers.py +++ b/packages/envied/src/envied/core/api/handlers.py @@ -1,3 +1,4 @@ +import enum import logging from typing import Any, Dict, List, Optional @@ -42,8 +43,10 @@ DEFAULT_DOWNLOAD_PARAMS = { "no_subs": False, "no_audio": False, "no_chapters": False, + "no_video": False, "audio_description": False, "slow": False, + "split_audio": None, "skip_dl": False, "export": None, "cdm_only": None, @@ -54,6 +57,11 @@ DEFAULT_DOWNLOAD_PARAMS = { "workers": None, "downloads": 1, "best_available": False, + "repack": False, + "imdb_id": None, + "output_dir": None, + "no_cache": False, + "reset_cache": False, } @@ -341,6 +349,137 @@ def serialize_subtitle_track(track: Subtitle, include_url: bool = False) -> Dict return result +async def search_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response: + """Handle search request.""" + import inspect + + import click + import yaml + + from envied.commands.dl import dl + from envied.core.config import config + from envied.core.services import Services + from envied.core.utils.click_types import ContextData + from envied.core.utils.collections import merge_dict + + service_tag = data.get("service") + query = data.get("query") + + if not service_tag: + raise APIError(APIErrorCode.MISSING_SERVICE, "Missing required 'service' field") + if not query: + raise APIError(APIErrorCode.INVALID_PARAMETERS, "Missing required 'query' field") + + normalized_service = Services.get_tag(service_tag) + if not normalized_service: + raise APIError( + APIErrorCode.INVALID_SERVICE, + f"Service '{service_tag}' not found", + details={"service": service_tag}, + ) + + profile = data.get("profile") + proxy_param = data.get("proxy") + no_proxy = data.get("no_proxy", False) + + 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) + + proxy_providers = [] + if not no_proxy: + proxy_providers = initialize_proxy_providers() + + if proxy_param and not no_proxy: + try: + resolved_proxy = resolve_proxy(proxy_param, proxy_providers) + proxy_param = resolved_proxy + except ValueError as e: + raise APIError( + APIErrorCode.INVALID_PROXY, + f"Proxy error: {e}", + details={"proxy": proxy_param, "service": normalized_service}, + ) + + @click.command() + @click.pass_context + def dummy_service(ctx: click.Context) -> None: + pass + + ctx = click.Context(dummy_service) + ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=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_init_params = inspect.signature(service_module.__init__).parameters + service_kwargs = {"title": query} + + # 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 and not isinstance(param.default, enum.Enum): + 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 + else: + service_kwargs[param_name] = None + + # Filter to only accepted params + accepted_params = set(service_init_params.keys()) - {"self", "ctx"} + service_kwargs = {k: v for k, v in service_kwargs.items() if k in accepted_params} + + try: + service_instance = service_module(service_ctx, **service_kwargs) + except Exception as exc: + raise APIError( + APIErrorCode.SERVICE_ERROR, + f"Failed to initialize service: {exc}", + details={"service": normalized_service}, + ) + + # Authenticate + cookies = dl.get_cookie_jar(normalized_service, profile) + credential = dl.get_credentials(normalized_service, profile) + service_instance.authenticate(cookies, credential) + + # Search + results = [] + try: + for result in service_instance.search(): + results.append({ + "id": result.id, + "title": result.title, + "description": result.description, + "label": result.label, + "url": result.url, + }) + except NotImplementedError: + raise APIError( + APIErrorCode.SERVICE_ERROR, + f"Search is not supported by {normalized_service}", + details={"service": normalized_service}, + ) + + return web.json_response({"results": results, "count": len(results)}) + + async def list_titles_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response: """Handle list-titles request.""" service_tag = data.get("service") @@ -439,7 +578,7 @@ async def list_titles_handler(data: Dict[str, Any], request: Optional[web.Reques for param in service_module.cli.params: if hasattr(param, "name") and param.name not in service_kwargs: # Add default value if parameter is not already provided - if hasattr(param, "default") and param.default is not None: + if hasattr(param, "default") and param.default is not None and not isinstance(param.default, enum.Enum): service_kwargs[param.name] = param.default # Handle required parameters that don't have click defaults @@ -587,7 +726,7 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques for param in service_module.cli.params: if hasattr(param, "name") and param.name not in service_kwargs: # Add default value if parameter is not already provided - if hasattr(param, "default") and param.default is not None: + if hasattr(param, "default") and param.default is not None and not isinstance(param.default, enum.Enum): service_kwargs[param.name] = param.default # Handle required parameters that don't have click defaults @@ -631,7 +770,10 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques try: season_range = SeasonRange() - wanted = season_range.parse_tokens(wanted_param) + if isinstance(wanted_param, list): + wanted = season_range.parse_tokens(*wanted_param) + else: + wanted = season_range.parse_tokens(wanted_param) log.debug(f"Parsed wanted '{wanted_param}' into {len(wanted)} episodes: {wanted[:10]}...") except Exception as e: raise APIError( @@ -760,9 +902,17 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]: None if valid, error message string if invalid """ if "vcodec" in data and data["vcodec"]: - valid_vcodecs = ["H264", "H265", "VP9", "AV1"] - if data["vcodec"].upper() not in valid_vcodecs: - return f"Invalid vcodec: {data['vcodec']}. Must be one of: {', '.join(valid_vcodecs)}" + valid_vcodecs = ["H264", "H265", "H.264", "H.265", "AVC", "HEVC", "VC1", "VC-1", "VP8", "VP9", "AV1"] + if isinstance(data["vcodec"], str): + vcodec_values = [v.strip() for v in data["vcodec"].split(",") if v.strip()] + elif isinstance(data["vcodec"], list): + vcodec_values = [str(v).strip() for v in data["vcodec"] if str(v).strip()] + else: + return "vcodec must be a string or list" + + invalid = [value for value in vcodec_values if value.upper() not in valid_vcodecs] + if invalid: + return f"Invalid vcodec: {', '.join(invalid)}. Must be one of: {', '.join(valid_vcodecs)}" if "acodec" in data and data["acodec"]: valid_acodecs = ["AAC", "AC3", "EC3", "EAC3", "DD", "DD+", "AC4", "OPUS", "FLAC", "ALAC", "VORBIS", "OGG", "DTS"] @@ -778,7 +928,7 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]: 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"] + valid_sub_formats = ["SRT", "VTT", "ASS", "SSA", "TTML", "STPP", "WVTT", "SMI", "SUB", "MPL2", "TMP"] if data["sub_format"].upper() not in valid_sub_formats: return f"Invalid sub_format: {data['sub_format']}. Must be one of: {', '.join(valid_sub_formats)}" @@ -880,7 +1030,7 @@ async def download_handler(data: Dict[str, Any], request: Optional[web.Request] # Extract default values from the service's 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 hasattr(param, "default") and param.default is not None: + if hasattr(param, "name") and hasattr(param, "default") and param.default is not None and not isinstance(param.default, enum.Enum): # Store service-specific defaults (e.g., drm_system, hydrate_track, profile for NF) service_specific_defaults[param.name] = param.default diff --git a/packages/envied/src/envied/core/api/routes.py b/packages/envied/src/envied/core/api/routes.py index e85ad81..704847b 100644 --- a/packages/envied/src/envied/core/api/routes.py +++ b/packages/envied/src/envied/core/api/routes.py @@ -7,7 +7,8 @@ from aiohttp_swagger3 import SwaggerDocs, SwaggerInfo, SwaggerUiSettings from envied.core import __version__ from envied.core.api.errors import APIError, APIErrorCode, build_error_response, handle_api_exception from envied.core.api.handlers import (cancel_download_job_handler, download_handler, get_download_job_handler, - list_download_jobs_handler, list_titles_handler, list_tracks_handler) + list_download_jobs_handler, list_titles_handler, list_tracks_handler, + search_handler) from envied.core.services import Services from envied.core.update_checker import UpdateChecker @@ -199,6 +200,93 @@ async def services(request: web.Request) -> web.Response: return handle_api_exception(e, context={"operation": "list_services"}, debug_mode=debug_mode) +async def search(request: web.Request) -> web.Response: + """ + Search for titles from a service. + --- + summary: Search for titles + description: Search for titles by query string from a service + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - service + - query + properties: + service: + type: string + description: Service tag (e.g., NF, AMZN, ATV) + query: + type: string + description: Search query string + profile: + type: string + description: Profile to use for credentials and cookies (default - None) + proxy: + type: string + description: Proxy URI or country code (default - None) + no_proxy: + type: boolean + description: Force disable all proxy use (default - false) + responses: + '200': + description: Search results + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + type: object + properties: + id: + type: string + description: Title ID for use with other endpoints + title: + type: string + description: Title name + description: + type: string + description: Title description + label: + type: string + description: Informative label (e.g., availability, region) + url: + type: string + description: URL to the title page + count: + type: integer + description: Number of results returned + '400': + description: Invalid request + """ + try: + data = await request.json() + except Exception as e: + return build_error_response( + APIError( + APIErrorCode.INVALID_INPUT, + "Invalid JSON request body", + details={"error": str(e)}, + ), + request.app.get("debug_api", False), + ) + + try: + return await search_handler(data, request) + except APIError as e: + return build_error_response(e, request.app.get("debug_api", False)) + except Exception as e: + log.exception("Error in search") + debug_mode = request.app.get("debug_api", False) + return handle_api_exception(e, context={"operation": "search"}, debug_mode=debug_mode) + + async def list_titles(request: web.Request) -> web.Response: """ List titles for a service and title ID. @@ -409,11 +497,19 @@ async def download(request: web.Request) -> web.Response: type: integer description: Download resolution(s) (default - best available) vcodec: - type: string - description: Video codec to download (e.g., H264, H265, VP9, AV1) (default - None) + oneOf: + - type: string + - type: array + items: + type: string + description: Video codec(s) to download (e.g., "H265" or ["H264", "H265"]) - accepts H264, H265, AVC, HEVC, VP8, VP9, AV1, VC1 (default - None) acodec: - type: string - description: Audio codec(s) to download (e.g., AAC or AAC,EC3) (default - None) + oneOf: + - type: string + - type: array + items: + type: string + description: Audio codec(s) to download (e.g., "AAC" or ["AAC", "EC3"]) - accepts AAC, AC3, EC3, AC4, OPUS, FLAC, ALAC, DTS, OGG (default - None) vbitrate: type: integer description: Video bitrate in kbps (default - None) @@ -424,7 +520,7 @@ async def download(request: web.Request) -> web.Response: type: array items: type: string - description: Video color range (SDR, HDR10, DV) (default - ["SDR"]) + description: Video color range (SDR, HDR10, HDR10+, HLG, DV, HYBRID) (default - ["SDR"]) channels: type: number description: Audio channels (e.g., 2.0, 5.1, 7.1) (default - None) @@ -494,12 +590,18 @@ async def download(request: web.Request) -> web.Response: no_chapters: type: boolean description: Do not download chapters (default - false) + no_video: + type: boolean + description: Do not download video tracks (default - false) audio_description: type: boolean description: Download audio description tracks (default - false) slow: type: boolean description: Add 60-120s delay between downloads (default - false) + split_audio: + type: boolean + description: Create separate output files per audio codec instead of merging all audio (default - null) skip_dl: type: boolean description: Skip downloading, only retrieve decryption keys (default - false) @@ -521,12 +623,12 @@ async def download(request: web.Request) -> web.Response: tmdb_id: type: integer description: Use this TMDB ID for tagging (default - None) - tmdb_name: + animeapi_id: + type: string + description: Anime database ID via AnimeAPI, e.g. mal:12345 (default - None) + enrich: type: boolean - description: Rename titles using TMDB name (default - false) - tmdb_year: - type: boolean - description: Use release year from TMDB (default - false) + description: Override show title and year from external source (default - false) no_folder: type: boolean description: Disable folder creation for TV shows (default - false) @@ -545,6 +647,21 @@ async def download(request: web.Request) -> web.Response: best_available: type: boolean description: Continue with best available if requested quality unavailable (default - false) + repack: + type: boolean + description: Add REPACK tag to the output filename (default - false) + imdb_id: + type: string + description: Use this IMDB ID (e.g. tt1375666) for tagging (default - None) + output_dir: + type: string + description: Override the output directory for this download (default - None) + no_cache: + type: boolean + description: Bypass title cache for this download (default - false) + reset_cache: + type: boolean + description: Clear title cache before fetching (default - false) responses: '202': description: Download job created @@ -723,6 +840,7 @@ def setup_routes(app: web.Application) -> None: """Setup all API routes.""" app.router.add_get("/api/health", health) app.router.add_get("/api/services", services) + app.router.add_post("/api/search", search) app.router.add_post("/api/list-titles", list_titles) app.router.add_post("/api/list-tracks", list_tracks) app.router.add_post("/api/download", download) @@ -748,6 +866,7 @@ def setup_swagger(app: web.Application) -> None: [ web.get("/api/health", health), web.get("/api/services", services), + web.post("/api/search", search), web.post("/api/list-titles", list_titles), web.post("/api/list-tracks", list_tracks), web.post("/api/download", download), diff --git a/packages/envied/src/envied/core/config.py b/packages/envied/src/envied/core/config.py index e59fe63..36b0796 100644 --- a/packages/envied/src/envied/core/config.py +++ b/packages/envied/src/envied/core/config.py @@ -1,5 +1,7 @@ from __future__ import annotations +import re +import warnings from pathlib import Path from typing import Any, Optional @@ -93,9 +95,26 @@ class Config: self.decrypt_labs_api_key: str = kwargs.get("decrypt_labs_api_key") or "" 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.language_tags: dict = kwargs.get("language_tags") or {} + self.output_template: dict = kwargs.get("output_template") or {} + + if kwargs.get("scene_naming") is not None: + raise SystemExit( + "ERROR: The 'scene_naming' option has been removed.\n" + "Please configure 'output_template' in your envied.yaml instead.\n" + "See unshackle-example.yaml for examples." + ) + + if not self.output_template: + raise SystemExit( + "ERROR: No 'output_template' configured in your envied.yaml.\n" + "Please add an 'output_template' section with movies, series, and songs templates.\n" + "See unshackle-example.yaml for examples." + ) + + self._validate_output_templates() + self.unicode_filenames: bool = kwargs.get("unicode_filenames", False) self.title_cache_time: int = kwargs.get("title_cache_time", 1800) # 30 minutes default @@ -105,6 +124,78 @@ class Config: self.debug: bool = kwargs.get("debug", False) self.debug_keys: bool = kwargs.get("debug_keys", False) + def _validate_output_templates(self) -> None: + """Validate output template configurations and warn about potential issues.""" + if not self.output_template: + return + + valid_variables = { + "title", + "year", + "season", + "episode", + "season_episode", + "episode_name", + "quality", + "resolution", + "source", + "tag", + "track_number", + "artist", + "album", + "disc", + "audio", + "audio_channels", + "audio_full", + "atmos", + "dual", + "multi", + "video", + "hdr", + "hfr", + "edition", + "repack", + "lang_tag", + } + + unsafe_chars = r'[<>:"/\\|?*]' + + for template_type, template_str in self.output_template.items(): + if not isinstance(template_str, str): + warnings.warn(f"Template '{template_type}' must be a string, got {type(template_str).__name__}") + continue + + variables = re.findall(r"\{([^}]+)\}", template_str) + + for var in variables: + var_clean = var.rstrip("?") + if var_clean not in valid_variables: + warnings.warn(f"Unknown template variable '{var}' in {template_type} template") + + test_template = re.sub(r"\{[^}]+\}", "TEST", template_str) + if re.search(unsafe_chars, test_template): + warnings.warn(f"Template '{template_type}' may contain filesystem-unsafe characters") + + if not template_str.strip(): + warnings.warn(f"Template '{template_type}' is empty") + + def get_template_separator(self, template_type: str = "movies") -> str: + """Get the filename separator for the given template type. + + Analyzes the active template to determine whether it uses dots or spaces + between variables. Falls back to dot separator (scene-style) by default. + + Args: + template_type: One of "movies", "series", or "songs". + """ + template = self.output_template[template_type] + between_vars = re.findall(r"\}([^{]*)\{", template) + separator_text = "".join(between_vars) + dot_count = separator_text.count(".") + space_count = separator_text.count(" ") + + return " " if space_count > dot_count else "." + @classmethod def from_yaml(cls, path: Path) -> Config: if not path.exists(): diff --git a/packages/envied/src/envied/core/downloaders/aria2c.py b/packages/envied/src/envied/core/downloaders/aria2c.py index be94b69..b319d42 100644 --- a/packages/envied/src/envied/core/downloaders/aria2c.py +++ b/packages/envied/src/envied/core/downloaders/aria2c.py @@ -431,14 +431,24 @@ def download( 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) + progress_data = {"advance": 0} + + if len(gids) > 1: + # Multi-file mode (e.g., HLS): Return the count of completed segments + progress_data["completed"] = len(completed) + progress_data["total"] = len(gids) + else: + # Single-file mode: Return the total bytes downloaded + progress_data["completed"] = total_completed + if total_size > 0: + progress_data["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") + progress_data["total"] = None + + if dl_speed != -1: + progress_data["downloaded"] = f"{filesize.decimal(dl_speed)}/s" + + yield progress_data time.sleep(1) except KeyboardInterrupt: diff --git a/packages/envied/src/envied/core/downloaders/n_m3u8dl_re.py b/packages/envied/src/envied/core/downloaders/n_m3u8dl_re.py index 4b95384..738f9ce 100644 --- a/packages/envied/src/envied/core/downloaders/n_m3u8dl_re.py +++ b/packages/envied/src/envied/core/downloaders/n_m3u8dl_re.py @@ -191,6 +191,9 @@ def build_download_args( args["--skip-merge"] = skip_merge if ad_keyword: args["--ad-keyword"] = ad_keyword + # Disable segment count validation to work around N_m3u8DL-RE's Math.Ceiling + # bug in duration-based SegmentTemplate calculation (see nilaoda/N_m3u8DL-RE#108) + args["--check-segments-count"] = False key_args = [] if content_keys: diff --git a/packages/envied/src/envied/core/drm/playready.py b/packages/envied/src/envied/core/drm/playready.py index 969f373..951c760 100644 --- a/packages/envied/src/envied/core/drm/playready.py +++ b/packages/envied/src/envied/core/drm/playready.py @@ -44,16 +44,15 @@ class PlayReady: else: kids = [] - # Extract KIDs using pyplayready's method (may miss some KIDs) + # Extract KIDs using pyplayready's WrmHeader key_ids if not kids: for header in pssh.wrm_headers: - try: - signed_ids, _, _, _ = header.read_attributes() - except Exception: - continue - for signed_id in signed_ids: + for signed_key_id in getattr(header, "key_ids", []): try: - kids.append(UUID(bytes_le=base64.b64decode(signed_id.value))) + if isinstance(signed_key_id.value, UUID): + kids.append(signed_key_id.value) + else: + kids.append(UUID(bytes_le=base64.b64decode(signed_key_id.value))) except Exception: continue @@ -119,7 +118,33 @@ class PlayReady: except Exception: pass - # Also get individual KID + # v4.2/v4.3: DATA/PROTECTINFO/KIDS/KID + protectinfo_kids = root.findall(".//pr:DATA/pr:PROTECTINFO/pr:KIDS/pr:KID", ns) + for kid_elem in protectinfo_kids: + value = kid_elem.get("VALUE") + if value: + try: + kid_bytes = base64.b64decode(value + "==") + kid_uuid = UUID(bytes_le=kid_bytes) + if kid_uuid not in kids: + kids.append(kid_uuid) + except Exception: + pass + + # v4.1: DATA/PROTECTINFO/KID + protectinfo_kid = root.findall(".//pr:DATA/pr:PROTECTINFO/pr:KID", ns) + for kid_elem in protectinfo_kid: + value = kid_elem.get("VALUE") + if value: + try: + kid_bytes = base64.b64decode(value + "==") + kid_uuid = UUID(bytes_le=kid_bytes) + if kid_uuid not in kids: + kids.append(kid_uuid) + except Exception: + pass + + # v4.0: DATA/KID individual_kids = root.findall(".//pr:DATA/pr:KID", ns) for kid_elem in individual_kids: if kid_elem.text: diff --git a/packages/envied/src/envied/core/manifests/dash.py b/packages/envied/src/envied/core/manifests/dash.py index df2b3b7..7e183b2 100644 --- a/packages/envied/src/envied/core/manifests/dash.py +++ b/packages/envied/src/envied/core/manifests/dash.py @@ -7,7 +7,7 @@ import math import re import shutil import sys -from copy import copy +from copy import copy, deepcopy from functools import partial from pathlib import Path from typing import Any, Callable, Optional, Union @@ -18,6 +18,7 @@ from zlib import crc32 import requests from curl_cffi.requests import Session as CurlSession from langcodes import Language, tag_is_valid +from lxml import etree from lxml.etree import Element, ElementTree from pyplayready.system.pssh import PSSH as PR_PSSH from pywidevine.cdm import Cdm as WidevineCdm @@ -101,14 +102,22 @@ class DASH: """ tracks = Tracks() + filtered_period_ids: list[str] = [] + for period in self.manifest.findall("Period"): if callable(period_filter) and period_filter(period): + if period_id := period.get("id"): + filtered_period_ids.append(period_id) continue if next(iter(period.xpath("SegmentType/@value")), "content") != "content": + if period_id := period.get("id"): + filtered_period_ids.append(period_id) continue if "urn:amazon:primevideo:cachingBreadth" in [ x.get("schemeIdUri") for x in period.findall("SupplementalProperty") ]: + if period_id := period.get("id"): + filtered_period_ids.append(period_id) continue for adaptation_set in period.findall("AdaptationSet"): @@ -235,6 +244,7 @@ class DASH: "period": period, "adaptation_set": adaptation_set, "representation": rep, + "filtered_period_ids": filtered_period_ids, } }, **track_args, @@ -541,6 +551,26 @@ class DASH: skip_merge = False if downloader.__name__ == "n_m3u8dl_re": skip_merge = True + + # When periods were filtered out during to_tracks(), n_m3u8dl_re will re-parse + # the raw MPD and download ALL periods (including ads/pre-rolls). Write a filtered + # MPD with the rejected periods removed so n_m3u8dl_re downloads the correct content. + filtered_period_ids = track.data.get("dash", {}).get("filtered_period_ids", []) + if filtered_period_ids: + filtered_manifest = deepcopy(manifest) + for child in list(filtered_manifest): + if not hasattr(child.tag, "find"): + continue + if child.tag == "Period" and child.get("id") in filtered_period_ids: + filtered_manifest.remove(child) + + filtered_mpd_path = save_dir / f".{track.id}_filtered.mpd" + filtered_mpd_path.parent.mkdir(parents=True, exist_ok=True) + etree.ElementTree(filtered_manifest).write( + str(filtered_mpd_path), xml_declaration=True, encoding="utf-8" + ) + track.from_file = filtered_mpd_path + downloader_args.update( { "filename": track.id, @@ -578,6 +608,11 @@ class DASH: status_update["downloaded"] = f"DASH {downloaded}" progress(**status_update) + # Clean up filtered MPD temp file before enumerating segments + filtered_mpd_path = save_dir / f".{track.id}_filtered.mpd" + if filtered_mpd_path.exists(): + filtered_mpd_path.unlink() + # see https://github.com/devine-dl/devine/issues/71 for control_file in save_dir.glob("*.aria2__temp"): control_file.unlink() diff --git a/packages/envied/src/envied/core/manifests/hls.py b/packages/envied/src/envied/core/manifests/hls.py index 5ac0504..bc6fe6e 100644 --- a/packages/envied/src/envied/core/manifests/hls.py +++ b/packages/envied/src/envied/core/manifests/hls.py @@ -112,6 +112,15 @@ class HLS: session_drm = HLS.get_all_drm(session_keys) audio_codecs_by_group_id: dict[str, Audio.Codec] = {} + cc_by_group_id: dict[str, list[dict[str, Any]]] = {} + for media in self.manifest.media: + if media.type == "CLOSED-CAPTIONS": + cc_by_group_id.setdefault(media.group_id, []).append({ + "language": media.language, + "name": media.name, + "instream_id": media.instream_id, + "characteristics": media.characteristics, + }) tracks = Tracks() for playlist in self.manifest.playlists: @@ -161,6 +170,9 @@ class HLS: width=playlist.stream_info.resolution[0] if playlist.stream_info.resolution else None, height=playlist.stream_info.resolution[1] if playlist.stream_info.resolution else None, fps=playlist.stream_info.frame_rate, + closed_captions=cc_by_group_id.get( + (playlist.stream_info.closed_captions or "").strip('"'), [] + ), ) if primary_track_type is Video else {} diff --git a/packages/envied/src/envied/core/manifests/ism.py b/packages/envied/src/envied/core/manifests/ism.py index 596421f..38e2395 100644 --- a/packages/envied/src/envied/core/manifests/ism.py +++ b/packages/envied/src/envied/core/manifests/ism.py @@ -145,7 +145,14 @@ class ISM: fragment_time += duration_frag track_id = hashlib.md5( - f"{codec}-{track_lang}-{ql.get('Bitrate') or 0}-{ql.get('Index') or 0}".encode() + "{codec}-{lang}-{bitrate}-{index}-{name}-{url}".format( + codec=codec, + lang=track_lang, + bitrate=ql.get("Bitrate") or 0, + index=ql.get("Index") or 0, + name=stream_index.get("Name") or "", + url=stream_index.get("Url") or "", + ).encode() ).hexdigest() data = { diff --git a/packages/envied/src/envied/core/providers/__init__.py b/packages/envied/src/envied/core/providers/__init__.py new file mode 100644 index 0000000..10267b1 --- /dev/null +++ b/packages/envied/src/envied/core/providers/__init__.py @@ -0,0 +1,428 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +import requests + +from envied.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, fuzzy_match, log +from envied.core.providers.imdbapi import IMDBApiProvider +from envied.core.providers.simkl import SimklProvider +from envied.core.providers.tmdb import TMDBProvider + +if TYPE_CHECKING: + from envied.core.title_cacher import TitleCacher + +# Ordered by priority: IMDBApi (free), SIMKL, TMDB +ALL_PROVIDERS: list[type[MetadataProvider]] = [IMDBApiProvider, SimklProvider, TMDBProvider] + + +def get_available_providers() -> list[MetadataProvider]: + """Return instantiated providers that have valid credentials.""" + return [cls() for cls in ALL_PROVIDERS if cls().is_available()] + + +def get_provider(name: str) -> Optional[MetadataProvider]: + """Get a specific provider by name.""" + for cls in ALL_PROVIDERS: + if cls.NAME == name: + p = cls() + return p if p.is_available() else None + return None + + +# -- Public API (replaces tags.py functions) -- + + +def search_metadata( + title: str, + year: Optional[int], + kind: str, + title_cacher: Optional[TitleCacher] = None, + cache_title_id: Optional[str] = None, + cache_region: Optional[str] = None, + cache_account_hash: Optional[str] = None, +) -> Optional[MetadataResult]: + """Search all available providers for metadata. Returns best match.""" + # Check cache first + if title_cacher and cache_title_id: + for cls in ALL_PROVIDERS: + p = cls() + if not p.is_available(): + continue + cached = title_cacher.get_cached_provider(p.NAME, cache_title_id, kind, cache_region, cache_account_hash) + if cached: + result = _cached_to_result(cached, p.NAME, kind) + if result and result.title and fuzzy_match(result.title, title): + log.debug("Using cached %s data for %r", p.NAME, title) + return result + + # Search providers in priority order + for cls in ALL_PROVIDERS: + p = cls() + if not p.is_available(): + continue + try: + result = p.search(title, year, kind) + except (requests.RequestException, ValueError, KeyError) as exc: + log.debug("%s search failed: %s", p.NAME, exc) + continue + if result and result.title and fuzzy_match(result.title, title): + # Enrich with cross-referenced IDs if we have IMDB but missing TMDB/TVDB + enrich_ids(result) + # Cache the result (include enriched IDs so they survive round-trip) + if title_cacher and cache_title_id and result.raw: + try: + cache_data = result.raw + if result.external_ids.tmdb_id or result.external_ids.tvdb_id: + cache_data = { + **result.raw, + "_enriched_ids": _external_ids_to_dict(result.external_ids), + } + title_cacher.cache_provider( + p.NAME, cache_title_id, cache_data, kind, cache_region, cache_account_hash + ) + except Exception as exc: + log.debug("Failed to cache %s data: %s", p.NAME, exc) + return result + + return None + + +def get_title_by_id( + tmdb_id: int, + kind: str, + title_cacher: Optional[TitleCacher] = None, + cache_title_id: Optional[str] = None, + cache_region: Optional[str] = None, + cache_account_hash: Optional[str] = None, +) -> Optional[str]: + """Get title name by TMDB ID.""" + # Check cache first + if title_cacher and cache_title_id: + cached = title_cacher.get_cached_provider("tmdb", cache_title_id, kind, cache_region, cache_account_hash) + if cached and cached.get("detail"): + detail = cached["detail"] + tmdb_title = detail.get("title") or detail.get("name") + if tmdb_title: + log.debug("Using cached TMDB title: %r", tmdb_title) + return tmdb_title + + tmdb = get_provider("tmdb") + if not tmdb: + return None + result = tmdb.get_by_id(tmdb_id, kind) + if not result: + return None + + # Cache if possible + if title_cacher and cache_title_id and result.raw: + try: + ext_ids = tmdb.get_external_ids(tmdb_id, kind) + title_cacher.cache_provider( + "tmdb", + cache_title_id, + {"detail": result.raw, "external_ids": _external_ids_to_dict(ext_ids)}, + kind, + cache_region, + cache_account_hash, + ) + except Exception as exc: + log.debug("Failed to cache TMDB data: %s", exc) + + return result.title + + +def get_year_by_id( + tmdb_id: int, + kind: str, + title_cacher: Optional[TitleCacher] = None, + cache_title_id: Optional[str] = None, + cache_region: Optional[str] = None, + cache_account_hash: Optional[str] = None, +) -> Optional[int]: + """Get release year by TMDB ID.""" + # Check cache first + if title_cacher and cache_title_id: + cached = title_cacher.get_cached_provider("tmdb", cache_title_id, kind, cache_region, cache_account_hash) + if cached and cached.get("detail"): + detail = cached["detail"] + date = detail.get("release_date") or detail.get("first_air_date") + if date and len(date) >= 4 and date[:4].isdigit(): + year = int(date[:4]) + log.debug("Using cached TMDB year: %d", year) + return year + + tmdb = get_provider("tmdb") + if not tmdb: + return None + result = tmdb.get_by_id(tmdb_id, kind) + if not result: + return None + + # Cache if possible + if title_cacher and cache_title_id and result.raw: + try: + ext_ids = tmdb.get_external_ids(tmdb_id, kind) + title_cacher.cache_provider( + "tmdb", + cache_title_id, + {"detail": result.raw, "external_ids": _external_ids_to_dict(ext_ids)}, + kind, + cache_region, + cache_account_hash, + ) + except Exception as exc: + log.debug("Failed to cache TMDB data: %s", exc) + + return result.year + + +def fetch_external_ids( + tmdb_id: int, + kind: str, + title_cacher: Optional[TitleCacher] = None, + cache_title_id: Optional[str] = None, + cache_region: Optional[str] = None, + cache_account_hash: Optional[str] = None, +) -> ExternalIds: + """Get external IDs by TMDB ID.""" + # Check cache first + if title_cacher and cache_title_id: + cached = title_cacher.get_cached_provider("tmdb", cache_title_id, kind, cache_region, cache_account_hash) + if cached and cached.get("external_ids"): + log.debug("Using cached TMDB external IDs") + raw = cached["external_ids"] + return ExternalIds( + imdb_id=raw.get("imdb_id"), + tmdb_id=tmdb_id, + tmdb_kind=kind, + tvdb_id=raw.get("tvdb_id"), + ) + + tmdb = get_provider("tmdb") + if not tmdb: + return ExternalIds() + ext = tmdb.get_external_ids(tmdb_id, kind) + + # Cache if possible + if title_cacher and cache_title_id: + try: + detail = None + result = tmdb.get_by_id(tmdb_id, kind) + if result and result.raw: + detail = result.raw + if detail: + title_cacher.cache_provider( + "tmdb", + cache_title_id, + {"detail": detail, "external_ids": _external_ids_to_dict(ext)}, + kind, + cache_region, + cache_account_hash, + ) + except Exception as exc: + log.debug("Failed to cache TMDB data: %s", exc) + + return ext + + +# -- Internal helpers -- + + +# Provider authority ranking for tie-breaking (lower index = more authoritative) +_ENRICHMENT_PROVIDERS = ("tmdb", "simkl") +_ENRICHMENT_AUTHORITY: dict[str, int] = {name: i for i, name in enumerate(_ENRICHMENT_PROVIDERS)} + + +def enrich_ids(result: MetadataResult) -> None: + """Enrich a MetadataResult by cross-referencing IMDB ID with available providers. + + Queries all available providers, cross-validates tmdb_id as anchor. + If a provider returns a different tmdb_id than the authoritative source, + ALL of that provider's data is dropped (likely resolved to wrong title). + """ + ids = result.external_ids + if not ids.imdb_id: + return + if ids.tmdb_id and ids.tvdb_id: + return # already have everything + + kind = result.kind or "movie" + + # Step 1: Collect enrichment results from all available providers + enrichments: list[tuple[str, ExternalIds]] = [] + for provider_name in _ENRICHMENT_PROVIDERS: + p = get_provider(provider_name) + if not p: + continue + try: + enriched = p.find_by_imdb_id(ids.imdb_id, kind) # type: ignore[union-attr] + except Exception as exc: + log.debug("Enrichment via %s failed: %s", provider_name, exc) + continue + if enriched: + enrichments.append((provider_name, enriched)) + + if not enrichments: + return + + # Step 2: Cross-validate using tmdb_id as anchor — drop providers that disagree + validated = _validate_enrichments(enrichments) + + # Step 3: Merge validated data (fill gaps only) + for _provider_name, ext in validated: + if not ids.tmdb_id and ext.tmdb_id: + ids.tmdb_id = ext.tmdb_id + ids.tmdb_kind = ext.tmdb_kind or kind + if not ids.tvdb_id and ext.tvdb_id: + ids.tvdb_id = ext.tvdb_id + + +def _validate_enrichments( + enrichments: list[tuple[str, ExternalIds]], +) -> list[tuple[str, ExternalIds]]: + """Drop providers whose tmdb_id conflicts with the authoritative value. + + If providers disagree on tmdb_id, the more authoritative source wins + and ALL data from disagreeing providers is discarded (different tmdb_id + means the provider likely resolved to a different title entirely). + """ + from collections import Counter + + # Collect tmdb_id votes + tmdb_votes: dict[str, int] = {} + for provider_name, ext in enrichments: + if ext.tmdb_id is not None: + tmdb_votes[provider_name] = ext.tmdb_id + + if len(set(tmdb_votes.values())) <= 1: + return enrichments # all agree or only one voted — no conflict + + # Find the authoritative tmdb_id + value_counts = Counter(tmdb_votes.values()) + most_common_val, most_common_count = value_counts.most_common(1)[0] + + if most_common_count > 1: + anchor_tmdb_id = most_common_val + else: + # No majority — pick the most authoritative provider + best_provider = min( + tmdb_votes.keys(), + key=lambda name: _ENRICHMENT_AUTHORITY.get(name, 99), + ) + anchor_tmdb_id = tmdb_votes[best_provider] + + # Drop any provider that disagrees + validated: list[tuple[str, ExternalIds]] = [] + for provider_name, ext in enrichments: + if ext.tmdb_id is not None and ext.tmdb_id != anchor_tmdb_id: + log.debug( + "Dropping %s enrichment data: tmdb_id %s conflicts with " + "authoritative value %s (likely resolved to wrong title)", + provider_name, + ext.tmdb_id, + anchor_tmdb_id, + ) + continue + validated.append((provider_name, ext)) + + return validated + + +def _external_ids_to_dict(ext: ExternalIds) -> dict: + """Convert ExternalIds to a dict for caching.""" + result: dict = {} + if ext.imdb_id: + result["imdb_id"] = ext.imdb_id + if ext.tmdb_id: + result["tmdb_id"] = ext.tmdb_id + if ext.tmdb_kind: + result["tmdb_kind"] = ext.tmdb_kind + if ext.tvdb_id: + result["tvdb_id"] = ext.tvdb_id + return result + + +def _cached_to_result(cached: dict, provider_name: str, kind: str) -> Optional[MetadataResult]: + """Convert a cached provider dict back to a MetadataResult.""" + if provider_name == "tmdb": + detail = cached.get("detail", {}) + ext_raw = cached.get("external_ids", {}) + title = detail.get("title") or detail.get("name") + date = detail.get("release_date") or detail.get("first_air_date") + year = int(date[:4]) if date and len(date) >= 4 and date[:4].isdigit() else None + tmdb_id = detail.get("id") + return MetadataResult( + title=title, + year=year, + kind=kind, + external_ids=ExternalIds( + imdb_id=ext_raw.get("imdb_id"), + tmdb_id=tmdb_id, + tmdb_kind=kind, + tvdb_id=ext_raw.get("tvdb_id"), + ), + source="tmdb", + raw=cached, + ) + elif provider_name == "simkl": + response = cached.get("response", cached) + if response.get("type") == "episode" and "show" in response: + info = response["show"] + elif response.get("type") == "movie" and "movie" in response: + info = response["movie"] + else: + return None + ids = info.get("ids", {}) + tmdb_id = ids.get("tmdbtv") or ids.get("tmdb") or ids.get("moviedb") + if tmdb_id: + tmdb_id = int(tmdb_id) + return MetadataResult( + title=info.get("title"), + year=info.get("year"), + kind=kind, + external_ids=ExternalIds( + imdb_id=ids.get("imdb"), + tmdb_id=tmdb_id, + tmdb_kind=kind, + tvdb_id=ids.get("tvdb"), + ), + source="simkl", + raw=cached, + ) + elif provider_name == "imdbapi": + title = cached.get("primaryTitle") or cached.get("originalTitle") + year = cached.get("startYear") + imdb_id = cached.get("id") + # Restore enriched IDs that were saved alongside the raw data + enriched = cached.get("_enriched_ids", {}) + return MetadataResult( + title=title, + year=year, + kind=kind, + external_ids=ExternalIds( + imdb_id=imdb_id, + tmdb_id=enriched.get("tmdb_id"), + tmdb_kind=enriched.get("tmdb_kind"), + tvdb_id=enriched.get("tvdb_id"), + ), + source="imdbapi", + raw=cached, + ) + return None + + +__all__ = [ + "ALL_PROVIDERS", + "ExternalIds", + "MetadataProvider", + "MetadataResult", + "enrich_ids", + "fetch_external_ids", + "fuzzy_match", + "get_available_providers", + "get_provider", + "get_title_by_id", + "get_year_by_id", + "search_metadata", +] diff --git a/packages/envied/src/envied/core/providers/_base.py b/packages/envied/src/envied/core/providers/_base.py new file mode 100644 index 0000000..6222786 --- /dev/null +++ b/packages/envied/src/envied/core/providers/_base.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import logging +import re +from abc import ABCMeta, abstractmethod +from dataclasses import dataclass, field +from difflib import SequenceMatcher +from typing import Optional, Union + +import requests +from requests.adapters import HTTPAdapter, Retry + +log = logging.getLogger("METADATA") + +HEADERS = {"User-Agent": "unshackle-tags/1.0"} + +STRIP_RE = re.compile(r"[^a-z0-9]+", re.I) +YEAR_RE = re.compile(r"\s*\(?[12][0-9]{3}\)?$") + + +@dataclass +class ExternalIds: + """Normalized external IDs across providers.""" + + imdb_id: Optional[str] = None + tmdb_id: Optional[int] = None + tmdb_kind: Optional[str] = None # "movie" or "tv" + tvdb_id: Optional[int] = None + + +@dataclass +class MetadataResult: + """Unified metadata result from any provider.""" + + title: Optional[str] = None + year: Optional[int] = None + kind: Optional[str] = None # "movie" or "tv" + external_ids: ExternalIds = field(default_factory=ExternalIds) + source: str = "" # provider name, e.g. "tmdb", "simkl", "imdbapi" + raw: Optional[dict] = None # original API response for caching + + +class MetadataProvider(metaclass=ABCMeta): + """Abstract base for metadata providers.""" + + NAME: str = "" + REQUIRES_KEY: bool = True + + def __init__(self) -> None: + self.log = logging.getLogger(f"METADATA.{self.NAME.upper()}") + self._session: Optional[requests.Session] = None + + @property + def session(self) -> requests.Session: + if self._session is None: + self._session = requests.Session() + self._session.headers.update(HEADERS) + retry = Retry( + total=3, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["GET", "POST"], + ) + adapter = HTTPAdapter(max_retries=retry) + self._session.mount("https://", adapter) + self._session.mount("http://", adapter) + return self._session + + @abstractmethod + def is_available(self) -> bool: + """Return True if this provider has the credentials/keys it needs.""" + + @abstractmethod + def search(self, title: str, year: Optional[int], kind: str) -> Optional[MetadataResult]: + """Search for a title and return metadata, or None on failure/no match.""" + + @abstractmethod + def get_by_id(self, provider_id: Union[int, str], kind: str) -> Optional[MetadataResult]: + """Fetch metadata by this provider's native ID.""" + + @abstractmethod + def get_external_ids(self, provider_id: Union[int, str], kind: str) -> ExternalIds: + """Fetch external IDs for a title by this provider's native ID.""" + + +def _clean(s: str) -> str: + return STRIP_RE.sub("", s).lower() + + +def _strip_year(s: str) -> str: + return YEAR_RE.sub("", s).strip() + + +def fuzzy_match(a: str, b: str, threshold: float = 0.8) -> bool: + """Return True if ``a`` and ``b`` are a close match.""" + ratio = SequenceMatcher(None, _clean(a), _clean(b)).ratio() + return ratio >= threshold diff --git a/packages/envied/src/envied/core/providers/imdbapi.py b/packages/envied/src/envied/core/providers/imdbapi.py new file mode 100644 index 0000000..929b917 --- /dev/null +++ b/packages/envied/src/envied/core/providers/imdbapi.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from difflib import SequenceMatcher +from typing import Optional, Union + +import requests + +from envied.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, _clean, fuzzy_match + +# Mapping from our kind ("movie"/"tv") to imdbapi.dev title types +KIND_TO_TYPES: dict[str, list[str]] = { + "movie": ["movie"], + "tv": ["tvSeries", "tvMiniSeries"], +} + + +class IMDBApiProvider(MetadataProvider): + """IMDb metadata provider using imdbapi.dev (free, no API key).""" + + NAME = "imdbapi" + REQUIRES_KEY = False + BASE_URL = "https://api.imdbapi.dev" + + def is_available(self) -> bool: + return True # no key needed + + def search(self, title: str, year: Optional[int], kind: str) -> Optional[MetadataResult]: + self.log.debug("Searching IMDBApi for %r (%s, %s)", title, kind, year) + + try: + params: dict[str, str | int] = {"query": title, "limit": 20} + r = self.session.get( + f"{self.BASE_URL}/search/titles", + params=params, + timeout=30, + ) + r.raise_for_status() + data = r.json() + except (requests.RequestException, ValueError) as exc: + self.log.debug("IMDBApi search failed: %s", exc) + return None + + results = data.get("titles") or data.get("results") or [] + if not results: + self.log.debug("IMDBApi returned no results for %r", title) + return None + + # Filter by type if possible + type_filter = KIND_TO_TYPES.get(kind, []) + filtered = [r for r in results if r.get("type") in type_filter] if type_filter else results + candidates = filtered if filtered else results + + # Find best fuzzy match, optionally filtered by year + best_match: Optional[dict] = None + best_ratio = 0.0 + + for candidate in candidates: + primary = candidate.get("primaryTitle") or "" + original = candidate.get("originalTitle") or "" + + for name in [primary, original]: + if not name: + continue + ratio = SequenceMatcher(None, _clean(title), _clean(name)).ratio() + if ratio > best_ratio: + # If year provided, prefer matches within 1 year + candidate_year = candidate.get("startYear") + if year and candidate_year and abs(year - candidate_year) > 1: + continue + best_ratio = ratio + best_match = candidate + + if not best_match: + self.log.debug("No matching result found in IMDBApi for %r", title) + return None + + result_title = best_match.get("primaryTitle") or best_match.get("originalTitle") + if not result_title or not fuzzy_match(result_title, title): + self.log.debug("IMDBApi title mismatch: searched %r, got %r", title, result_title) + return None + + imdb_id = best_match.get("id") + result_year = best_match.get("startYear") + + self.log.debug("IMDBApi -> %s (ID %s)", result_title, imdb_id) + + return MetadataResult( + title=result_title, + year=result_year, + kind=kind, + external_ids=ExternalIds(imdb_id=imdb_id), + source="imdbapi", + raw=best_match, + ) + + def get_by_id(self, provider_id: Union[int, str], kind: str) -> Optional[MetadataResult]: + """Fetch metadata by IMDB ID (e.g. 'tt1375666').""" + imdb_id = str(provider_id) + self.log.debug("Fetching IMDBApi title %s", imdb_id) + + try: + r = self.session.get(f"{self.BASE_URL}/titles/{imdb_id}", timeout=30) + r.raise_for_status() + data = r.json() + except (requests.RequestException, ValueError) as exc: + self.log.debug("IMDBApi get_by_id failed: %s", exc) + return None + + title = data.get("primaryTitle") or data.get("originalTitle") + result_year = data.get("startYear") + + return MetadataResult( + title=title, + year=result_year, + kind=kind, + external_ids=ExternalIds(imdb_id=data.get("id")), + source="imdbapi", + raw=data, + ) + + def get_external_ids(self, provider_id: Union[int, str], kind: str) -> ExternalIds: + """Return external IDs. For IMDB, the provider_id IS the IMDB ID.""" + return ExternalIds(imdb_id=str(provider_id)) diff --git a/packages/envied/src/envied/core/providers/simkl.py b/packages/envied/src/envied/core/providers/simkl.py new file mode 100644 index 0000000..1ab41f3 --- /dev/null +++ b/packages/envied/src/envied/core/providers/simkl.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from typing import Optional, Union + +import requests + +from envied.core.config import config +from envied.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, fuzzy_match + + +class SimklProvider(MetadataProvider): + """SIMKL metadata provider (filename-based search).""" + + NAME = "simkl" + REQUIRES_KEY = True + BASE_URL = "https://api.simkl.com" + + def is_available(self) -> bool: + return bool(config.simkl_client_id) + + def search(self, title: str, year: Optional[int], kind: str) -> Optional[MetadataResult]: + self.log.debug("Searching Simkl for %r (%s, %s)", title, kind, year) + + # Construct appropriate filename based on type + filename = f"{title}" + if year: + filename = f"{title} {year}" + if kind == "tv": + filename += " S01E01.mkv" + else: + filename += " 2160p.mkv" + + try: + headers = {"simkl-api-key": config.simkl_client_id} + resp = self.session.post( + f"{self.BASE_URL}/search/file", json={"file": filename}, headers=headers, timeout=30 + ) + resp.raise_for_status() + data = resp.json() + self.log.debug("Simkl API response received") + except (requests.RequestException, ValueError) as exc: + self.log.debug("Simkl search failed: %s", exc) + return None + + # Handle case where SIMKL returns empty list (no results) + if isinstance(data, list): + self.log.debug("Simkl returned list (no matches) for %r", filename) + return None + + return self._parse_response(data, title, year, kind) + + def get_by_id(self, provider_id: Union[int, str], kind: str) -> Optional[MetadataResult]: + return None # SIMKL has no direct ID lookup used here + + def get_external_ids(self, provider_id: Union[int, str], kind: str) -> ExternalIds: + return ExternalIds() # IDs come from search() response + + def find_by_imdb_id(self, imdb_id: str, kind: str) -> Optional[ExternalIds]: + """Look up TMDB/TVDB IDs from an IMDB ID using SIMKL's /search/id and detail endpoints.""" + self.log.debug("Looking up IMDB ID %s on SIMKL", imdb_id) + headers = {"simkl-api-key": config.simkl_client_id} + + try: + r = self.session.get(f"{self.BASE_URL}/search/id", params={"imdb": imdb_id}, headers=headers, timeout=30) + r.raise_for_status() + data = r.json() + except (requests.RequestException, ValueError) as exc: + self.log.debug("SIMKL search/id failed: %s", exc) + return None + + if not isinstance(data, list) or not data: + self.log.debug("No SIMKL results for IMDB ID %s", imdb_id) + return None + + entry = data[0] + simkl_id = entry.get("ids", {}).get("simkl") + if not simkl_id: + return None + + # Map SIMKL type to endpoint + simkl_type = entry.get("type", "") + endpoint = "tv" if simkl_type in ("tv", "anime") else "movies" + + # Fetch full details to get cross-referenced IDs + try: + r2 = self.session.get( + f"{self.BASE_URL}/{endpoint}/{simkl_id}", + params={"extended": "full"}, + headers=headers, + timeout=30, + ) + r2.raise_for_status() + detail = r2.json() + except (requests.RequestException, ValueError) as exc: + self.log.debug("SIMKL detail fetch failed: %s", exc) + return None + + ids = detail.get("ids", {}) + tmdb_id: Optional[int] = None + raw_tmdb = ids.get("tmdb") + if raw_tmdb: + tmdb_id = int(raw_tmdb) + + tvdb_id: Optional[int] = None + raw_tvdb = ids.get("tvdb") + if raw_tvdb: + tvdb_id = int(raw_tvdb) + + self.log.debug("SIMKL find -> TMDB %s, TVDB %s for IMDB %s", tmdb_id, tvdb_id, imdb_id) + + return ExternalIds( + imdb_id=imdb_id, + tmdb_id=tmdb_id, + tmdb_kind=kind, + tvdb_id=tvdb_id, + ) + + def _parse_response( + self, data: dict, search_title: str, search_year: Optional[int], kind: str + ) -> Optional[MetadataResult]: + """Parse a SIMKL response into a MetadataResult.""" + if data.get("type") == "episode" and "show" in data: + info = data["show"] + content_type = "tv" + elif data.get("type") == "movie" and "movie" in data: + info = data["movie"] + content_type = "movie" + else: + return None + + result_title = info.get("title") + result_year = info.get("year") + + # Verify title matches + if not result_title or not fuzzy_match(result_title, search_title): + self.log.debug("Simkl title mismatch: searched %r, got %r", search_title, result_title) + return None + + # Verify year if provided (allow 1 year difference) + if search_year and result_year and abs(search_year - result_year) > 1: + self.log.debug("Simkl year mismatch: searched %d, got %d", search_year, result_year) + return None + + ids = info.get("ids", {}) + tmdb_id: Optional[int] = None + if content_type == "tv": + raw_tmdb = ids.get("tmdbtv") + else: + raw_tmdb = ids.get("tmdb") or ids.get("moviedb") + if raw_tmdb: + tmdb_id = int(raw_tmdb) + + tvdb_id: Optional[int] = None + raw_tvdb = ids.get("tvdb") + if raw_tvdb: + tvdb_id = int(raw_tvdb) + + self.log.debug("Simkl -> %s (TMDB ID %s)", result_title, tmdb_id) + + return MetadataResult( + title=result_title, + year=result_year, + kind=kind, + external_ids=ExternalIds( + imdb_id=ids.get("imdb"), + tmdb_id=tmdb_id, + tmdb_kind=kind, + tvdb_id=tvdb_id, + ), + source="simkl", + raw=data, + ) diff --git a/packages/envied/src/envied/core/providers/tmdb.py b/packages/envied/src/envied/core/providers/tmdb.py new file mode 100644 index 0000000..e159d4a --- /dev/null +++ b/packages/envied/src/envied/core/providers/tmdb.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +from difflib import SequenceMatcher +from typing import Optional, Union + +import requests + +from envied.core.config import config +from envied.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, _clean, _strip_year + + +class TMDBProvider(MetadataProvider): + """TMDB (The Movie Database) metadata provider.""" + + NAME = "tmdb" + REQUIRES_KEY = True + BASE_URL = "https://api.themoviedb.org/3" + + def is_available(self) -> bool: + return bool(config.tmdb_api_key) + + @property + def _api_key(self) -> str: + return config.tmdb_api_key + + def search(self, title: str, year: Optional[int], kind: str) -> Optional[MetadataResult]: + search_title = _strip_year(title) + self.log.debug("Searching TMDB for %r (%s, %s)", search_title, kind, year) + + params: dict[str, str | int] = {"api_key": self._api_key, "query": search_title} + if year is not None: + params["year" if kind == "movie" else "first_air_date_year"] = year + + try: + r = self.session.get(f"{self.BASE_URL}/search/{kind}", params=params, timeout=30) + r.raise_for_status() + results = r.json().get("results") or [] + self.log.debug("TMDB returned %d results", len(results)) + if not results: + return None + except requests.RequestException as exc: + self.log.warning("Failed to search TMDB for %s: %s", title, exc) + return None + + best_ratio = 0.0 + best_id: Optional[int] = None + best_title: Optional[str] = None + for result in results: + candidates = [ + result.get("title"), + result.get("name"), + result.get("original_title"), + result.get("original_name"), + ] + candidates = [c for c in candidates if c] + + for candidate in candidates: + ratio = SequenceMatcher(None, _clean(search_title), _clean(candidate)).ratio() + if ratio > best_ratio: + best_ratio = ratio + best_id = result.get("id") + best_title = candidate + + self.log.debug("Best candidate ratio %.2f for %r (ID %s)", best_ratio, best_title, best_id) + + if best_id is None: + first = results[0] + best_id = first.get("id") + best_title = first.get("title") or first.get("name") + + if best_id is None: + return None + + # Fetch full detail for caching + detail = self._fetch_detail(best_id, kind) + ext_raw = self._fetch_external_ids_raw(best_id, kind) + + date = (detail or {}).get("release_date") or (detail or {}).get("first_air_date") + result_year = int(date[:4]) if date and len(date) >= 4 and date[:4].isdigit() else None + + ext = ExternalIds( + imdb_id=ext_raw.get("imdb_id") if ext_raw else None, + tmdb_id=best_id, + tmdb_kind=kind, + tvdb_id=ext_raw.get("tvdb_id") if ext_raw else None, + ) + + return MetadataResult( + title=best_title, + year=result_year, + kind=kind, + external_ids=ext, + source="tmdb", + raw={"detail": detail or {}, "external_ids": ext_raw or {}}, + ) + + def get_by_id(self, provider_id: Union[int, str], kind: str) -> Optional[MetadataResult]: + detail = self._fetch_detail(int(provider_id), kind) + if not detail: + return None + + title = detail.get("title") or detail.get("name") + date = detail.get("release_date") or detail.get("first_air_date") + year = int(date[:4]) if date and len(date) >= 4 and date[:4].isdigit() else None + + return MetadataResult( + title=title, + year=year, + kind=kind, + external_ids=ExternalIds(tmdb_id=int(provider_id), tmdb_kind=kind), + source="tmdb", + raw=detail, + ) + + def get_external_ids(self, provider_id: Union[int, str], kind: str) -> ExternalIds: + raw = self._fetch_external_ids_raw(int(provider_id), kind) + if not raw: + return ExternalIds(tmdb_id=int(provider_id), tmdb_kind=kind) + return ExternalIds( + imdb_id=raw.get("imdb_id"), + tmdb_id=int(provider_id), + tmdb_kind=kind, + tvdb_id=raw.get("tvdb_id"), + ) + + def find_by_imdb_id(self, imdb_id: str, kind: str) -> Optional[ExternalIds]: + """Look up TMDB/TVDB IDs from an IMDB ID using TMDB's /find endpoint.""" + self.log.debug("Looking up IMDB ID %s on TMDB", imdb_id) + try: + r = self.session.get( + f"{self.BASE_URL}/find/{imdb_id}", + params={"api_key": self._api_key, "external_source": "imdb_id"}, + timeout=30, + ) + r.raise_for_status() + data = r.json() + except requests.RequestException as exc: + self.log.debug("TMDB find by IMDB ID failed: %s", exc) + return None + + # Check movie_results or tv_results based on kind + if kind == "movie": + results = data.get("movie_results") or [] + else: + results = data.get("tv_results") or [] + + if not results: + # Try the other type as fallback + fallback_key = "tv_results" if kind == "movie" else "movie_results" + results = data.get(fallback_key) or [] + if results: + kind = "tv" if kind == "movie" else "movie" + + if not results: + self.log.debug("No TMDB results found for IMDB ID %s", imdb_id) + return None + + match = results[0] + tmdb_id = match.get("id") + if not tmdb_id: + return None + + self.log.debug("TMDB find -> ID %s (%s) for IMDB %s", tmdb_id, kind, imdb_id) + + # Now fetch the full external IDs from TMDB to get TVDB etc. + ext_raw = self._fetch_external_ids_raw(tmdb_id, kind) + + return ExternalIds( + imdb_id=imdb_id, + tmdb_id=tmdb_id, + tmdb_kind=kind, + tvdb_id=ext_raw.get("tvdb_id") if ext_raw else None, + ) + + def _fetch_detail(self, tmdb_id: int, kind: str) -> Optional[dict]: + try: + r = self.session.get( + f"{self.BASE_URL}/{kind}/{tmdb_id}", + params={"api_key": self._api_key}, + timeout=30, + ) + r.raise_for_status() + return r.json() + except requests.RequestException as exc: + self.log.debug("Failed to fetch TMDB detail: %s", exc) + return None + + def _fetch_external_ids_raw(self, tmdb_id: int, kind: str) -> Optional[dict]: + try: + r = self.session.get( + f"{self.BASE_URL}/{kind}/{tmdb_id}/external_ids", + params={"api_key": self._api_key}, + timeout=30, + ) + r.raise_for_status() + return r.json() + except requests.RequestException as exc: + self.log.debug("Failed to fetch TMDB external IDs: %s", exc) + return None diff --git a/packages/envied/src/envied/core/service.py b/packages/envied/src/envied/core/service.py index 1d7248e..da6cbf4 100644 --- a/packages/envied/src/envied/core/service.py +++ b/packages/envied/src/envied/core/service.py @@ -1,7 +1,8 @@ import base64 import logging from abc import ABCMeta, abstractmethod -from collections.abc import Generator +from collections.abc import Callable, Generator +from dataclasses import dataclass, field from http.cookiejar import CookieJar from pathlib import Path from typing import Optional, Union @@ -24,9 +25,26 @@ from envied.core.search_result import SearchResult from envied.core.title_cacher import TitleCacher, get_account_hash, get_region_from_proxy from envied.core.titles import Title_T, Titles_T from envied.core.tracks import Chapters, Tracks +from envied.core.tracks.video import Video from envied.core.utilities import get_cached_ip_info, get_ip_info +@dataclass +class TrackRequest: + """Holds what the user requested for video codec and range selection. + + Services read from this instead of ctx.parent.params for vcodec/range. + + Attributes: + codecs: Requested codecs from CLI. Empty list means no filter (accept any). + ranges: Requested ranges from CLI. Defaults to [SDR]. + """ + + codecs: list[Video.Codec] = field(default_factory=list) + ranges: list[Video.Range] = field(default_factory=lambda: [Video.Range.SDR]) + best_available: bool = False + + def sanitize_proxy_for_log(uri: Optional[str]) -> Optional[str]: """ Sanitize a proxy URI for logs by redacting any embedded userinfo (username/password). @@ -89,6 +107,16 @@ class Service(metaclass=ABCMeta): self.credential = None # Will be set in authenticate() self.current_region = None # Will be set based on proxy/geolocation + # Set track request from CLI params - services can read/override in their __init__ + vcodec = ctx.parent.params.get("vcodec") if ctx.parent else None + range_ = ctx.parent.params.get("range_") if ctx.parent else None + best_available = ctx.parent.params.get("best_available", False) if ctx.parent else False + self.track_request = TrackRequest( + codecs=list(vcodec) if vcodec else [], + ranges=list(range_) if range_ else [Video.Range.SDR], + best_available=bool(best_available), + ) + if not ctx.parent or not ctx.parent.params.get("no_proxy"): if ctx.parent: proxy = ctx.parent.params["proxy"] @@ -205,6 +233,76 @@ class Service(metaclass=ABCMeta): self.log.debug(f"Failed to get cached IP info: {e}") self.current_region = None + def _get_tracks_for_variants( + self, + title: Title_T, + fetch_fn: Callable[..., Tracks], + ) -> Tracks: + """Call fetch_fn for each codec/range combo in track_request, merge results. + + Services that need separate API calls per codec/range combo can use this + helper from their get_tracks() implementation. + + The fetch_fn signature should be: (title, codec, range_) -> Tracks + + For HYBRID range, fetch_fn is called with HDR10 and DV separately and + the DV video tracks are merged into the HDR10 result. + + Args: + title: The title being processed. + fetch_fn: A callable that fetches tracks for a specific codec/range. + """ + all_tracks = Tracks() + first = True + + codecs = self.track_request.codecs or [None] + ranges = self.track_request.ranges or [Video.Range.SDR] + + for range_val in ranges: + if range_val == Video.Range.HYBRID: + # HYBRID: fetch HDR10 first (full tracks), then DV (video only) + for codec_val in codecs: + try: + hdr_tracks = fetch_fn(title, codec=codec_val, range_=Video.Range.HDR10) + except (ValueError, SystemExit) as e: + if self.track_request.best_available: + self.log.warning(f" - HDR10 not available for HYBRID, skipping ({e})") + continue + raise + if first: + all_tracks.add(hdr_tracks, warn_only=True) + first = False + else: + for video in hdr_tracks.videos: + all_tracks.add(video, warn_only=True) + + try: + dv_tracks = fetch_fn(title, codec=codec_val, range_=Video.Range.DV) + for video in dv_tracks.videos: + all_tracks.add(video, warn_only=True) + except (ValueError, SystemExit): + self.log.info(" - No DolbyVision manifest available for HYBRID") + else: + for codec_val in codecs: + try: + tracks = fetch_fn(title, codec=codec_val, range_=range_val) + except (ValueError, SystemExit) as e: + if self.track_request.best_available: + codec_name = codec_val.name if codec_val else "default" + self.log.warning( + f" - {range_val.name}/{codec_name} not available, skipping ({e})" + ) + continue + raise + if first: + all_tracks.add(tracks, warn_only=True) + first = False + else: + for video in tracks.videos: + all_tracks.add(video, warn_only=True) + + return all_tracks + # Optional Abstract functions # The following functions may be implemented by the Service. # Otherwise, the base service code (if any) of the function will be executed on call. @@ -222,7 +320,7 @@ class Service(metaclass=ABCMeta): session.mount( "https://", HTTPAdapter( - max_retries=Retry(total=15, backoff_factor=0.2, status_forcelist=[429, 500, 502, 503, 504]), + max_retries=Retry(total=5, backoff_factor=0.2, status_forcelist=[429, 500, 502, 503, 504]), pool_block=True, ), ) @@ -296,6 +394,27 @@ class Service(metaclass=ABCMeta): Decode the data, return as is to reduce unnecessary computations. """ + def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]: + """ + Get a PlayReady License message by sending a License Request (challenge). + + This License message contains the encrypted Content Decryption Keys and will be + read by the CDM and decrypted. + + This is a very important request to get correct. A bad, unexpected, or missing + value in the request can cause your key to be detected and promptly banned, + revoked, disabled, or downgraded. + + :param challenge: The license challenge from the PlayReady CDM. + :param title: The current `Title` from get_titles that is being executed. This is provided in + case it has data needed to be used, e.g. for a HTTP request. + :param track: The current `Track` needing decryption. Provided for same reason as `title`. + :return: The License response as Bytes or a Base64 string. Don't Base64 Encode or + Decode the data, return as is to reduce unnecessary computations. + """ + # Delegates license handling to the Widevine license method by default if a service-specific PlayReady implementation is not provided. + return self.get_widevine_license(challenge=challenge, title=title, track=track) + # Required Abstract functions # The following functions *must* be implemented by the Service. # The functions will be executed in shown order. @@ -461,4 +580,4 @@ class Service(metaclass=ABCMeta): """ -__all__ = ("Service",) +__all__ = ("Service", "TrackRequest") diff --git a/packages/envied/src/envied/core/session.py b/packages/envied/src/envied/core/session.py index 12f8185..0d7d04b 100644 --- a/packages/envied/src/envied/core/session.py +++ b/packages/envied/src/envied/core/session.py @@ -44,6 +44,17 @@ FINGERPRINT_PRESETS = { "akamai": "4:16777216|16711681|0|m,p,a,s", "description": "OkHttp 5.x (BoringSSL TLS stack)", }, + "shield_okhttp": { + "ja3": ( + "771," # TLS 1.2 + "4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53," # Ciphers (OkHttp 4.11) + "0-23-65281-10-11-35-16-5-13-51-45-43-21," # Extensions (incl padding ext 21) + "29-23-24," # Named groups (x25519, secp256r1, secp384r1) + "0" # EC point formats + ), + "akamai": "4:16777216|16711681|0|m,p,a,s", + "description": "NVIDIA SHIELD Android TV OkHttp 4.11 (captured JA3)", + }, } @@ -56,7 +67,7 @@ class MaxRetriesError(exceptions.RequestException): class CurlSession(Session): def __init__( self, - max_retries: int = 10, + max_retries: int = 5, backoff_factor: float = 0.2, max_backoff: float = 60.0, status_forcelist: list[int] | None = None, @@ -150,7 +161,7 @@ def session( browser: Browser to impersonate (e.g. "chrome124", "firefox", "safari") OR fingerprint preset name (e.g. "okhttp4"). Uses the configured default from curl_impersonate.browser if not specified. - Available presets: okhttp4 + Available presets: okhttp4, okhttp5 See https://github.com/lexiforest/curl_cffi#sessions for browser options. ja3: Custom JA3 TLS fingerprint string (format: "SSLVersion,Ciphers,Extensions,Curves,PointFormats"). When provided, curl_cffi will use this exact TLS fingerprint instead of the browser's default. @@ -172,7 +183,7 @@ def session( - cert: Client certificate (str or tuple) Extra arguments for retry handler: - - max_retries: Maximum number of retries (int, default 10) + - max_retries: Maximum number of retries (int, default 5) - backoff_factor: Backoff factor (float, default 0.2) - max_backoff: Maximum backoff time (float, default 60.0) - status_forcelist: List of status codes to force retry (list, default [429, 500, 502, 503, 504]) diff --git a/packages/envied/src/envied/core/title_cacher.py b/packages/envied/src/envied/core/title_cacher.py index fc6b17b..919ac30 100644 --- a/packages/envied/src/envied/core/title_cacher.py +++ b/packages/envied/src/envied/core/title_cacher.py @@ -26,6 +26,7 @@ class TitleCacher: self.log = logging.getLogger(f"{service_name}.TitleCache") self.cacher = Cacher(service_name) self.stats = {"hits": 0, "misses": 0, "fallbacks": 0} + self.no_cache = False def _generate_cache_key( self, title_id: str, region: Optional[str] = None, account_hash: Optional[str] = None @@ -59,9 +60,6 @@ class TitleCacher: # Join with underscores cache_key = "_".join(key_parts) - # Log the mapping for debugging - self.log.debug(f"Cache key mapping: {title_id} -> {cache_key}") - return cache_key def get_cached_titles( @@ -89,6 +87,7 @@ class TitleCacher: """ # If caching is globally disabled or no_cache flag is set if not config.title_cache_enabled or no_cache: + self.no_cache = True self.log.debug("Cache bypassed, fetching fresh titles") return fetch_function() @@ -113,7 +112,7 @@ class TitleCacher: # Cache miss or expired, try to fetch fresh data self.stats["misses"] += 1 - self.log.debug(f"Cache miss for {title_id}, fetching fresh data") + self.log.debug(f"Cache miss for {title_id} fetching fresh data") try: # Attempt to fetch fresh titles @@ -180,22 +179,18 @@ class TitleCacher: "hit_rate": f"{hit_rate:.1f}%", } - def get_cached_tmdb( - self, title_id: str, kind: str, region: Optional[str] = None, account_hash: Optional[str] = None + # -- Generic provider cache methods -- + + def get_cached_provider( + self, + provider_name: str, + title_id: str, + kind: Optional[str] = None, + region: Optional[str] = None, + account_hash: Optional[str] = None, ) -> Optional[dict]: - """ - Get cached TMDB data for a title. - - Args: - title_id: The title identifier - kind: "movie" or "tv" - region: The region/proxy identifier - account_hash: Hash of account credentials - - Returns: - Dict with 'detail' and 'external_ids' if cached and valid, None otherwise - """ - if not config.title_cache_enabled: + """Get cached metadata for any provider.""" + if not config.title_cache_enabled or self.no_cache: return None cache_key = self._generate_cache_key(title_id, region, account_hash) @@ -204,142 +199,90 @@ class TitleCacher: if not cache or not cache.data: return None - tmdb_data = getattr(cache.data, "tmdb_data", None) - if not tmdb_data: + provider_data = getattr(cache.data, f"{provider_name}_data", None) + if not provider_data: return None - tmdb_expiration = tmdb_data.get("expires_at") - if not tmdb_expiration or datetime.now() >= tmdb_expiration: - self.log.debug(f"TMDB cache expired for {title_id}") + expiration = provider_data.get("expires_at") + if not expiration or datetime.now() >= expiration: + self.log.debug(f"{provider_name} cache expired for {title_id}") return None - if tmdb_data.get("kind") != kind: - self.log.debug(f"TMDB cache kind mismatch for {title_id}: cached {tmdb_data.get('kind')}, requested {kind}") + if kind and provider_data.get("kind") != kind: + self.log.debug( + f"{provider_name} cache kind mismatch for {title_id}: " + f"cached {provider_data.get('kind')}, requested {kind}" + ) return None - self.log.debug(f"TMDB cache hit for {title_id}") - return { - "detail": tmdb_data.get("detail"), - "external_ids": tmdb_data.get("external_ids"), - "fetched_at": tmdb_data.get("fetched_at"), - } + self.log.debug(f"{provider_name} cache hit for {title_id}") - def cache_tmdb( + # Return the inner data (provider-specific format) + response = provider_data.get("response") + if response is not None: + return response + + # For TMDB-style caches that store detail + external_ids at top level + result: dict = {} + if "detail" in provider_data: + result["detail"] = provider_data["detail"] + if "external_ids" in provider_data: + result["external_ids"] = provider_data["external_ids"] + if "fetched_at" in provider_data: + result["fetched_at"] = provider_data["fetched_at"] + return result if result else provider_data + + def cache_provider( self, + provider_name: str, title_id: str, - detail_response: dict, - external_ids_response: dict, - kind: str, + data: dict, + kind: Optional[str] = None, region: Optional[str] = None, account_hash: Optional[str] = None, + ttl_days: int = 7, ) -> None: - """ - Cache TMDB data for a title. - - Args: - title_id: The title identifier - detail_response: Full TMDB detail API response - external_ids_response: Full TMDB external_ids API response - kind: "movie" or "tv" - region: The region/proxy identifier - account_hash: Hash of account credentials - """ - if not config.title_cache_enabled: + """Cache metadata from any provider.""" + if not config.title_cache_enabled or self.no_cache: return cache_key = self._generate_cache_key(title_id, region, account_hash) cache = self.cacher.get(cache_key, version=1) if not cache or not cache.data: - self.log.debug(f"Cannot cache TMDB data: no title cache exists for {title_id}") + self.log.debug(f"Cannot cache {provider_name} data: no title cache exists for {title_id}") return now = datetime.now() - tmdb_data = { - "detail": detail_response, - "external_ids": external_ids_response, - "kind": kind, - "fetched_at": now, - "expires_at": now + timedelta(days=7), # 7-day expiration - } - cache.data.tmdb_data = tmdb_data + # Build cache entry in a format compatible with legacy methods + if provider_name == "tmdb" and "detail" in data: + # TMDB stores detail + external_ids at top level + cache_entry = { + **data, + "kind": kind, + "fetched_at": now, + "expires_at": now + timedelta(days=ttl_days), + } + elif provider_name == "simkl": + # SIMKL wraps in a "response" key + cache_entry = { + "response": data, + "fetched_at": now, + "expires_at": now + timedelta(days=ttl_days), + } + else: + # Generic format: store data directly with metadata + cache_entry = { + "response": data, + "kind": kind, + "fetched_at": now, + "expires_at": now + timedelta(days=ttl_days), + } + setattr(cache.data, f"{provider_name}_data", cache_entry) cache.set(cache.data, expiration=cache.expiration) - self.log.debug(f"Cached TMDB data for {title_id} (kind={kind})") - - def get_cached_simkl( - self, title_id: str, region: Optional[str] = None, account_hash: Optional[str] = None - ) -> Optional[dict]: - """ - Get cached Simkl data for a title. - - Args: - title_id: The title identifier - region: The region/proxy identifier - account_hash: Hash of account credentials - - Returns: - Simkl response dict if cached and valid, None otherwise - """ - if not config.title_cache_enabled: - return None - - cache_key = self._generate_cache_key(title_id, region, account_hash) - cache = self.cacher.get(cache_key, version=1) - - if not cache or not cache.data: - return None - - simkl_data = getattr(cache.data, "simkl_data", None) - if not simkl_data: - return None - - simkl_expiration = simkl_data.get("expires_at") - if not simkl_expiration or datetime.now() >= simkl_expiration: - self.log.debug(f"Simkl cache expired for {title_id}") - return None - - self.log.debug(f"Simkl cache hit for {title_id}") - return simkl_data.get("response") - - def cache_simkl( - self, - title_id: str, - simkl_response: dict, - region: Optional[str] = None, - account_hash: Optional[str] = None, - ) -> None: - """ - Cache Simkl data for a title. - - Args: - title_id: The title identifier - simkl_response: Full Simkl API response - region: The region/proxy identifier - account_hash: Hash of account credentials - """ - if not config.title_cache_enabled: - return - - cache_key = self._generate_cache_key(title_id, region, account_hash) - cache = self.cacher.get(cache_key, version=1) - - if not cache or not cache.data: - self.log.debug(f"Cannot cache Simkl data: no title cache exists for {title_id}") - return - - now = datetime.now() - simkl_data = { - "response": simkl_response, - "fetched_at": now, - "expires_at": now + timedelta(days=7), - } - - cache.data.simkl_data = simkl_data - - cache.set(cache.data, expiration=cache.expiration) - self.log.debug(f"Cached Simkl data for {title_id}") + self.log.debug(f"Cached {provider_name} data for {title_id}") def get_region_from_proxy(proxy_url: Optional[str]) -> Optional[str]: diff --git a/packages/envied/src/envied/core/titles/episode.py b/packages/envied/src/envied/core/titles/episode.py index 4197b7d..b965833 100644 --- a/packages/envied/src/envied/core/titles/episode.py +++ b/packages/envied/src/envied/core/titles/episode.py @@ -9,9 +9,9 @@ from rich.tree import Tree from sortedcontainers import SortedKeyList from envied.core.config import config -from envied.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP from envied.core.titles.title import Title from envied.core.utilities import sanitize_filename +from envied.core.utils.template_formatter import TemplateFormatter class Episode(Title): @@ -78,173 +78,60 @@ class Episode(Title): self.year = year self.description = description + def _build_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict: + """Build template context dictionary from MediaInfo.""" + context = self._build_base_template_context(media_info, show_service) + context["title"] = self.title.replace("$", "S") + context["year"] = self.year or "" + context["season"] = f"S{self.season:02}" + context["episode"] = f"E{self.number:02}" + context["season_episode"] = f"S{self.season:02}E{self.number:02}" + context["episode_name"] = self.name or "" + return context + def __str__(self) -> str: return "{title}{year} S{season:02}E{number:02} {name}".format( title=self.title, - year=f" {self.year}" if self.year and config.series_year else "", + year=f" {self.year}" if self.year else "", season=self.season, number=self.number, name=self.name or "", ).strip() def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str: - primary_video_track = next(iter(media_info.video_tracks), None) - primary_audio_track = None - if media_info.audio_tracks: - sorted_audio = sorted( - media_info.audio_tracks, - key=lambda x: ( - float(x.bit_rate) if x.bit_rate else 0, - bool(x.format_additionalfeatures and "JOC" in x.format_additionalfeatures), - ), - reverse=True, - ) - primary_audio_track = sorted_audio[0] - unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language}) - - def _get_resolution_token(track: Any) -> str: - if not track or not getattr(track, "height", None): - return "" - resolution = track.height - try: - dar = getattr(track, "other_display_aspect_ratio", None) or [] - if dar and dar[0]: - aspect_ratio = [int(float(plane)) for plane in str(dar[0]).split(":")] - if len(aspect_ratio) == 1: - aspect_ratio.append(1) - if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3): - resolution = int(track.width * (9 / 16)) - except Exception: - pass - - scan_suffix = "p" - scan_type = getattr(track, "scan_type", None) - if scan_type and str(scan_type).lower() == "interlaced": - scan_suffix = "i" - return f"{resolution}{scan_suffix}" - - # Title [Year] SXXEXX Name (or Title [Year] SXX if folder) if folder: - name = f"{self.title}" - if self.year and config.series_year: - name += f" {self.year}" - name += f" S{self.season:02}" - else: - if config.dash_naming: - # Format: Title - SXXEXX - Episode Name - name = self.title.replace("$", "S") # e.g., Arli$$ + series_template = config.output_template.get("series") + if series_template: + folder_template = series_template + folder_template = re.sub(r'\{episode\}', '', folder_template) + folder_template = re.sub(r'\{episode_name\?\}', '', folder_template) + folder_template = re.sub(r'\{episode_name\}', '', folder_template) + folder_template = re.sub(r'\{season_episode\}', '{season}', folder_template) - # Add year if configured - if self.year and config.series_year: - name += f" {self.year}" + folder_template = re.sub(r'\.{2,}', '.', folder_template) + folder_template = re.sub(r'\s{2,}', ' ', folder_template) + folder_template = re.sub(r'^[\.\s]+|[\.\s]+$', '', folder_template) - # Add season and episode - name += f" - S{self.season:02}E{self.number:02}" + formatter = TemplateFormatter(folder_template) + context = self._build_template_context(media_info, show_service) + context['season'] = f"S{self.season:02}" - # Add episode name with dash separator - if self.name: - name += f" - {self.name}" + folder_name = formatter.format(context) - 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 primary_video_track: - resolution_token = _get_resolution_token(primary_video_track) - if resolution_token: - name += f" {resolution_token}" - - # Service (use track source if available) - if show_service: - 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" - - # DUAL - if unique_audio_languages == 2: - name += " DUAL" - - # MULTi - if unique_audio_languages > 2: - name += " MULTi" - - # Audio Codec + Channels (+ feature) - if primary_audio_track: - codec = primary_audio_track.format - channel_layout = primary_audio_track.channel_layout or primary_audio_track.channellayout_original - if channel_layout: - channels = float( - sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" ")) - ) - else: - channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0 - channels = float(channel_count) - - features = primary_audio_track.format_additionalfeatures or "" - name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}" - if "JOC" in features or primary_audio_track.joc: - name += " Atmos" - - # Video (dynamic range + hfr +) Codec - if primary_video_track: - codec = primary_video_track.format - hdr_format = primary_video_track.hdr_format_commercial - hdr_format_full = primary_video_track.hdr_format or "" - trc = ( - primary_video_track.transfer_characteristics - or primary_video_track.transfer_characteristics_original - or "" - ) - frame_rate = float(primary_video_track.frame_rate) - - def _append_token(current: str, token: Optional[str]) -> str: - token = (token or "").strip() - current = current.rstrip() - if not token: - return current - if current.endswith(f" {token}"): - return current - return f"{current} {token}" - - # Primary HDR format detection - if hdr_format: - if hdr_format_full.startswith("Dolby Vision"): - name = _append_token(name, "DV") - if any( - indicator in (hdr_format_full + " " + hdr_format) - for indicator in ["HDR10", "SMPTE ST 2086"] - ): - name = _append_token(name, "HDR") - elif "HDR Vivid" in hdr_format: - name = _append_token(name, "HDR") + if '.' in series_template and ' ' not in series_template: + return sanitize_filename(folder_name, ".") else: - dynamic_range = DYNAMIC_RANGE_MAP.get(hdr_format) or hdr_format or "" - name = _append_token(name, dynamic_range) - elif "HLG" in trc or "Hybrid Log-Gamma" in trc or "ARIB STD-B67" in trc or "arib-std-b67" in trc.lower(): - name += " HLG" - elif any(indicator in trc for indicator in ["PQ", "SMPTE ST 2084", "BT.2100"]) or "smpte2084" in trc.lower() or "bt.2020-10" in trc.lower(): - name += " HDR" - if frame_rate > 30: - name += " HFR" - name += f" {VIDEO_CODEC_MAP.get(codec, codec)}" + return sanitize_filename(folder_name, " ") + else: + name = f"{self.title}" + if self.year: + name += f" {self.year}" + name += f" S{self.season:02}" + return sanitize_filename(name, " ") - if config.tag: - name += f"-{config.tag}" - - return sanitize_filename(name, "." if config.scene_naming else " ") + formatter = TemplateFormatter(config.output_template["series"]) + context = self._build_template_context(media_info, show_service) + return formatter.format(context) class Series(SortedKeyList, ABC): @@ -254,7 +141,7 @@ class Series(SortedKeyList, ABC): def __str__(self) -> str: if not self: return super().__str__() - return self[0].title + (f" ({self[0].year})" if self[0].year and config.series_year else "") + return self[0].title + (f" ({self[0].year})" if self[0].year else "") def tree(self, verbose: bool = False) -> Tree: seasons = Counter(x.season for x in self) diff --git a/packages/envied/src/envied/core/titles/movie.py b/packages/envied/src/envied/core/titles/movie.py index 8ce9ce0..6d260ad 100644 --- a/packages/envied/src/envied/core/titles/movie.py +++ b/packages/envied/src/envied/core/titles/movie.py @@ -7,9 +7,9 @@ from rich.tree import Tree from sortedcontainers import SortedKeyList from envied.core.config import config -from envied.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP from envied.core.titles.title import Title from envied.core.utilities import sanitize_filename +from envied.core.utils.template_formatter import TemplateFormatter class Movie(Title): @@ -45,141 +45,28 @@ class Movie(Title): self.year = year self.description = description + def _build_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict: + """Build template context dictionary from MediaInfo.""" + context = self._build_base_template_context(media_info, show_service) + context["title"] = self.name.replace("$", "S") + context["year"] = self.year or "" + return context + 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 def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str: - primary_video_track = next(iter(media_info.video_tracks), None) - primary_audio_track = None - if media_info.audio_tracks: - sorted_audio = sorted( - media_info.audio_tracks, - key=lambda x: ( - float(x.bit_rate) if x.bit_rate else 0, - bool(x.format_additionalfeatures and "JOC" in x.format_additionalfeatures), - ), - reverse=True, - ) - primary_audio_track = sorted_audio[0] - unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language}) + if folder: + name = f"{self.name}" + if self.year: + name += f" ({self.year})" + return sanitize_filename(name, " ") - def _get_resolution_token(track: Any) -> str: - if not track or not getattr(track, "height", None): - return "" - resolution = track.height - try: - dar = getattr(track, "other_display_aspect_ratio", None) or [] - if dar and dar[0]: - aspect_ratio = [int(float(plane)) for plane in str(dar[0]).split(":")] - if len(aspect_ratio) == 1: - aspect_ratio.append(1) - if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3): - resolution = int(track.width * (9 / 16)) - except Exception: - pass - - scan_suffix = "p" - scan_type = getattr(track, "scan_type", None) - if scan_type and str(scan_type).lower() == "interlaced": - scan_suffix = "i" - return f"{resolution}{scan_suffix}" - - # Name (Year) - name = str(self).replace("$", "S") # e.g., Arli$$ - - if primary_video_track: - resolution_token = _get_resolution_token(primary_video_track) - if resolution_token: - name += f" {resolution_token}" - - # Service (use track source if available) - if show_service: - 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" - - # DUAL - if unique_audio_languages == 2: - name += " DUAL" - - # MULTi - if unique_audio_languages > 2: - name += " MULTi" - - # Audio Codec + Channels (+ feature) - if primary_audio_track: - codec = primary_audio_track.format - channel_layout = primary_audio_track.channel_layout or primary_audio_track.channellayout_original - if channel_layout: - channels = float( - sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" ")) - ) - else: - channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0 - channels = float(channel_count) - - features = primary_audio_track.format_additionalfeatures or "" - name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}" - if "JOC" in features or primary_audio_track.joc: - name += " Atmos" - - # Video (dynamic range + hfr +) Codec - if primary_video_track: - codec = primary_video_track.format - hdr_format = primary_video_track.hdr_format_commercial - hdr_format_full = primary_video_track.hdr_format or "" - trc = ( - primary_video_track.transfer_characteristics - or primary_video_track.transfer_characteristics_original - or "" - ) - frame_rate = float(primary_video_track.frame_rate) - - def _append_token(current: str, token: Optional[str]) -> str: - token = (token or "").strip() - current = current.rstrip() - if not token: - return current - if current.endswith(f" {token}"): - return current - return f"{current} {token}" - - # Primary HDR format detection - if hdr_format: - if hdr_format_full.startswith("Dolby Vision"): - name = _append_token(name, "DV") - if any( - indicator in (hdr_format_full + " " + hdr_format) - for indicator in ["HDR10", "SMPTE ST 2086"] - ): - name = _append_token(name, "HDR") - elif "HDR Vivid" in hdr_format: - name = _append_token(name, "HDR") - else: - dynamic_range = DYNAMIC_RANGE_MAP.get(hdr_format) or hdr_format or "" - name = _append_token(name, dynamic_range) - elif "HLG" in trc or "Hybrid Log-Gamma" in trc or "ARIB STD-B67" in trc or "arib-std-b67" in trc.lower(): - name += " HLG" - elif any(indicator in trc for indicator in ["PQ", "SMPTE ST 2084", "BT.2100"]) or "smpte2084" in trc.lower() or "bt.2020-10" in trc.lower(): - name += " HDR" - if frame_rate > 30: - name += " HFR" - name += f" {VIDEO_CODEC_MAP.get(codec, codec)}" - - if config.tag: - name += f"-{config.tag}" - - return sanitize_filename(name, "." if config.scene_naming else " ") + formatter = TemplateFormatter(config.output_template["movies"]) + context = self._build_template_context(media_info, show_service) + return formatter.format(context) class Movies(SortedKeyList, ABC): diff --git a/packages/envied/src/envied/core/titles/song.py b/packages/envied/src/envied/core/titles/song.py index 6beece8..aac9d0d 100644 --- a/packages/envied/src/envied/core/titles/song.py +++ b/packages/envied/src/envied/core/titles/song.py @@ -7,9 +7,9 @@ from rich.tree import Tree from sortedcontainers import SortedKeyList from envied.core.config import config -from envied.core.constants import AUDIO_CODEC_MAP from envied.core.titles.title import Title from envied.core.utilities import sanitize_filename +from envied.core.utils.template_formatter import TemplateFormatter class Song(Title): @@ -41,7 +41,7 @@ class Song(Title): if not album: raise ValueError("Song album must be provided") if not isinstance(album, str): - raise TypeError(f"Expected album to be a str, not {name!r}") + raise TypeError(f"Expected album to be a str, not {album!r}") if not track: raise ValueError("Song track must be provided") @@ -81,46 +81,27 @@ class Song(Title): artist=self.artist, album=self.album, year=self.year, track=self.track, name=self.name ).strip() + def _build_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict: + """Build template context dictionary from MediaInfo.""" + context = self._build_base_template_context(media_info, show_service) + context["title"] = self.name.replace("$", "S") + context["year"] = self.year or "" + context["track_number"] = f"{self.track:02}" + context["artist"] = self.artist.replace("$", "S") + context["album"] = self.album.replace("$", "S") + context["disc"] = f"{self.disc:02}" if self.disc > 1 else "" + return context + def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str: - audio_track = next(iter(media_info.audio_tracks), None) - codec = audio_track.format - channel_layout = audio_track.channel_layout or audio_track.channellayout_original - if channel_layout: - channels = float(sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" "))) - else: - channel_count = audio_track.channel_s or audio_track.channels or 0 - channels = float(channel_count) - - features = audio_track.format_additionalfeatures or "" - if folder: - # Artist - Album (Year) - name = str(self).split(" / ")[0] - else: - # NN. Song Name - name = str(self).split(" / ")[1] + name = f"{self.artist} - {self.album}" + if self.year: + name += f" ({self.year})" + return sanitize_filename(name, " ") - # Service (use track source if available) - if show_service: - 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" - - # Audio Codec + Channels (+ feature) - name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}" - if "JOC" in features or audio_track.joc: - name += " Atmos" - - if config.tag: - name += f"-{config.tag}" - - return sanitize_filename(name, " ") + formatter = TemplateFormatter(config.output_template["songs"]) + context = self._build_template_context(media_info, show_service) + return formatter.format(context) class Album(SortedKeyList, ABC): diff --git a/packages/envied/src/envied/core/titles/title.py b/packages/envied/src/envied/core/titles/title.py index 3729f25..7e37a92 100644 --- a/packages/envied/src/envied/core/titles/title.py +++ b/packages/envied/src/envied/core/titles/title.py @@ -6,6 +6,8 @@ from typing import Any, Optional, Union from langcodes import Language from pymediainfo import MediaInfo +from envied.core.config import config +from envied.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP from envied.core.tracks import Tracks @@ -51,6 +53,127 @@ class Title: def __eq__(self, other: Title) -> bool: return self.id == other.id + def _build_base_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict: + """Build base template context dictionary from MediaInfo. + + Extracts video, audio, HDR, HFR, and multi-language information shared + across all title types. Subclasses should call this and extend the + returned dict with their specific fields (e.g., season/episode). + """ + primary_video_track = next(iter(media_info.video_tracks), None) + primary_audio_track = next(iter(media_info.audio_tracks), None) + unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language}) + + context: dict[str, Any] = { + "source": self.service.__name__ if show_service else "", + "tag": config.tag or "", + "repack": "REPACK" if getattr(config, "repack", False) else "", + "quality": "", + "resolution": "", + "audio": "", + "audio_channels": "", + "audio_full": "", + "atmos": "", + "dual": "", + "multi": "", + "video": "", + "hdr": "", + "hfr": "", + "edition": "", + "lang_tag": "", + } + + if self.tracks: + first_track = next(iter(self.tracks), None) + if first_track and first_track.edition: + context["edition"] = " ".join(first_track.edition) + + if primary_video_track: + width = getattr(primary_video_track, "width", primary_video_track.height) + resolution = min(width, primary_video_track.height) + try: + dar = getattr(primary_video_track, "other_display_aspect_ratio", None) or [] + if dar and dar[0]: + aspect_ratio = [int(float(plane)) for plane in str(dar[0]).split(":")] + if len(aspect_ratio) == 1: + aspect_ratio.append(1) + ratio = aspect_ratio[0] / aspect_ratio[1] + if ratio not in (16 / 9, 4 / 3, 9 / 16, 3 / 4): + resolution = int(max(width, primary_video_track.height) * (9 / 16)) + except Exception: + pass + + scan_suffix = "i" if str(getattr(primary_video_track, "scan_type", "")).lower() == "interlaced" else "p" + + context.update( + { + "quality": f"{resolution}{scan_suffix}", + "resolution": str(resolution), + "video": VIDEO_CODEC_MAP.get(primary_video_track.format, primary_video_track.format), + } + ) + + hdr_format = primary_video_track.hdr_format_commercial + trc = primary_video_track.transfer_characteristics or primary_video_track.transfer_characteristics_original + if hdr_format: + if (primary_video_track.hdr_format or "").startswith("Dolby Vision"): + context["hdr"] = "DV" + base_layer = DYNAMIC_RANGE_MAP.get(hdr_format) + if base_layer and base_layer != "DV": + context["hdr"] += f".{base_layer}" + elif (primary_video_track.hdr_format or "").startswith("HDR Vivid"): + context["hdr"] = "HDR" + else: + context["hdr"] = DYNAMIC_RANGE_MAP.get(hdr_format, "") + elif trc and "HLG" in trc: + context["hdr"] = "HLG" + else: + context["hdr"] = "" + + frame_rate = float(primary_video_track.frame_rate) if primary_video_track.frame_rate else 0.0 + context["hfr"] = "HFR" if frame_rate > 30 else "" + + if primary_audio_track: + codec = primary_audio_track.format + channel_layout = primary_audio_track.channel_layout or primary_audio_track.channellayout_original + + if channel_layout: + channels = float(sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" "))) + else: + channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0 + channels = float(channel_count) + + features = primary_audio_track.format_additionalfeatures or "" + + context.update( + { + "audio": AUDIO_CODEC_MAP.get(codec, codec), + "audio_channels": f"{channels:.1f}", + "audio_full": f"{AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}", + "atmos": "Atmos" if ("JOC" in features or primary_audio_track.joc) else "", + } + ) + + if unique_audio_languages == 2: + context["dual"] = "DUAL" + context["multi"] = "" + elif unique_audio_languages > 2: + context["dual"] = "" + context["multi"] = "MULTi" + else: + context["dual"] = "" + context["multi"] = "" + + lang_tag_rules = config.language_tags.get("rules") if config.language_tags else None + if lang_tag_rules and self.tracks: + from envied.core.utils.language_tags import evaluate_language_tag + + audio_langs = [a.language for a in self.tracks.audio] + sub_langs = [s.language for s in self.tracks.subtitles] + context["lang_tag"] = evaluate_language_tag(lang_tag_rules, audio_langs, sub_langs) + + return context + @abstractmethod def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str: """ diff --git a/packages/envied/src/envied/core/tracks/attachment.py b/packages/envied/src/envied/core/tracks/attachment.py index b82d6be..82da0ed 100644 --- a/packages/envied/src/envied/core/tracks/attachment.py +++ b/packages/envied/src/envied/core/tracks/attachment.py @@ -2,6 +2,7 @@ from __future__ import annotations import mimetypes import os +import re from pathlib import Path from typing import Optional, Union from urllib.parse import urlparse @@ -56,7 +57,8 @@ class Attachment: # Use provided name for the file if available if name: - file_name = f"{name.replace(' ', '_')}{os.path.splitext(file_name)[1]}" + safe_name = re.sub(r'[<>:"/\\|?*]', "", name).replace(" ", "_") + file_name = f"{safe_name}{os.path.splitext(file_name)[1]}" download_path = config.directories.temp / file_name diff --git a/packages/envied/src/envied/core/tracks/audio.py b/packages/envied/src/envied/core/tracks/audio.py index f58ebab..a682729 100644 --- a/packages/envied/src/envied/core/tracks/audio.py +++ b/packages/envied/src/envied/core/tracks/audio.py @@ -151,7 +151,7 @@ class Audio(Track): ), f"{self.bitrate // 1000} kb/s" if self.bitrate else None, self.get_track_name(), - self.edition, + ", ".join(self.edition) if self.edition else None, ], ) ) diff --git a/packages/envied/src/envied/core/tracks/hybrid.py b/packages/envied/src/envied/core/tracks/hybrid.py index 526a6c0..553548b 100644 --- a/packages/envied/src/envied/core/tracks/hybrid.py +++ b/packages/envied/src/envied/core/tracks/hybrid.py @@ -1,6 +1,8 @@ import json import logging import os +import random +import re import subprocess import sys from pathlib import Path @@ -8,14 +10,16 @@ from pathlib import Path from rich.padding import Padding from rich.rule import Rule -from envied.core.binaries import FFMPEG, DoviTool, HDR10PlusTool +from envied.core.binaries import FFMPEG, DoviTool, FFProbe, HDR10PlusTool from envied.core.config import config from envied.core.console import console +from envied.core.utilities import get_debug_logger class Hybrid: def __init__(self, videos, source) -> None: self.log = logging.getLogger("hybrid") + self.debug_logger = get_debug_logger() """ Takes the Dolby Vision and HDR10(+) streams out of the VideoTracks. @@ -41,6 +45,19 @@ class Hybrid: console.print(Padding(Rule(f"[rule.text]HDR10+DV Hybrid ({self.resolution})"), (1, 2))) + if self.debug_logger: + self.debug_logger.log( + level="DEBUG", + operation="hybrid_init", + message="Starting HDR10+DV hybrid processing", + context={ + "source": source, + "resolution": self.resolution, + "video_count": len(videos), + "video_ranges": [str(v.range) for v in videos], + }, + ) + for video in self.videos: if not video.path or not os.path.exists(video.path): raise ValueError(f"Video track {video.id} was not downloaded before injection.") @@ -50,18 +67,18 @@ class Hybrid: has_hdr10 = any(video.range == Video.Range.HDR10 for video in self.videos) has_hdr10p = any(video.range == Video.Range.HDR10P for video in self.videos) - if not has_hdr10: - raise ValueError("No HDR10 track available for hybrid processing.") + if not has_hdr10 and not has_hdr10p: + raise ValueError("No HDR10 or HDR10+ track available for hybrid processing.") # If we have HDR10+ but no DV, we can convert HDR10+ to DV if not has_dv and has_hdr10p: - self.log.info("✓ No DV track found, but HDR10+ is available. Will convert HDR10+ to DV.") + console.status("No DV track found, but HDR10+ is available. Will convert HDR10+ to DV.") self.hdr10plus_to_dv = True elif not has_dv: raise ValueError("No DV track available and no HDR10+ to convert.") if os.path.isfile(config.directories.temp / self.hevc_file): - self.log.info("✓ Already Injected") + console.status("Already Injected") return for video in videos: @@ -89,14 +106,34 @@ class Hybrid: self.extract_rpu(dv_video) if os.path.isfile(config.directories.temp / "RPU_UNT.bin"): self.rpu_file = "RPU_UNT.bin" - self.level_6() # Mode 3 conversion already done during extraction when not untouched elif os.path.isfile(config.directories.temp / "RPU.bin"): # RPU already extracted with mode 3 pass + # Edit L6 with actual luminance values from RPU, then L5 active area + self.level_6() + base_video = next( + (v for v in videos if v.range in (Video.Range.HDR10, Video.Range.HDR10P)), None + ) + if base_video and base_video.path: + self.level_5(base_video.path) + self.injecting() + if self.debug_logger: + self.debug_logger.log( + level="INFO", + operation="hybrid_complete", + message="Injection Completed", + context={ + "hdr_type": self.hdr_type, + "resolution": self.resolution, + "hdr10plus_to_dv": self.hdr10plus_to_dv, + "rpu_file": self.rpu_file, + "output_file": self.hevc_file, + }, + ) self.log.info("✓ Injection Completed") if self.source == ("itunes" or "appletvplus"): Path.unlink(config.directories.temp / "hdr10.mkv") @@ -104,6 +141,10 @@ class Hybrid: Path.unlink(config.directories.temp / "HDR10.hevc", missing_ok=True) Path.unlink(config.directories.temp / "DV.hevc", missing_ok=True) Path.unlink(config.directories.temp / f"{self.rpu_file}", missing_ok=True) + Path.unlink(config.directories.temp / "RPU_L6.bin", missing_ok=True) + Path.unlink(config.directories.temp / "RPU_L5.bin", missing_ok=True) + Path.unlink(config.directories.temp / "L5.json", missing_ok=True) + Path.unlink(config.directories.temp / "L6.json", missing_ok=True) def ffmpeg_simple(self, save_path, output): """Simple ffmpeg execution without progress tracking""" @@ -121,20 +162,41 @@ class Hybrid: stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) - return p.returncode + return p def extract_stream(self, save_path, type_): output = Path(config.directories.temp / f"{type_}.hevc") with console.status(f"Extracting {type_} stream...", spinner="dots"): - returncode = self.ffmpeg_simple(save_path, output) + result = self.ffmpeg_simple(save_path, output) - if returncode: + if result.returncode: output.unlink(missing_ok=True) + if self.debug_logger: + self.debug_logger.log( + level="ERROR", + operation="hybrid_extract_stream", + message=f"Failed extracting {type_} stream", + context={ + "type": type_, + "input": str(save_path), + "output": str(output), + "returncode": result.returncode, + "stderr": (result.stderr or b"").decode(errors="replace"), + "stdout": (result.stdout or b"").decode(errors="replace"), + }, + ) self.log.error(f"x Failed extracting {type_} stream") sys.exit(1) - self.log.info(f"Extracted {type_} stream") + if self.debug_logger: + self.debug_logger.log( + level="DEBUG", + operation="hybrid_extract_stream", + message=f"Extracted {type_} stream", + context={"type": type_, "input": str(save_path), "output": str(output)}, + success=True, + ) def extract_rpu(self, video, untouched=False): if os.path.isfile(config.directories.temp / "RPU.bin") or os.path.isfile( @@ -161,58 +223,326 @@ class Hybrid: stderr=subprocess.PIPE, ) + rpu_name = "RPU" if not untouched else "RPU_UNT" if rpu_extraction.returncode: - Path.unlink(config.directories.temp / f"{'RPU' if not untouched else 'RPU_UNT'}.bin") + Path.unlink(config.directories.temp / f"{rpu_name}.bin") + stderr_text = rpu_extraction.stderr.decode(errors="replace") if rpu_extraction.stderr else "" + if self.debug_logger: + self.debug_logger.log( + level="ERROR", + operation="hybrid_extract_rpu", + message=f"Failed extracting{' untouched ' if untouched else ' '}RPU", + context={ + "untouched": untouched, + "returncode": rpu_extraction.returncode, + "stderr": stderr_text, + "args": [str(a) for a in extraction_args], + }, + ) if b"MAX_PQ_LUMINANCE" in rpu_extraction.stderr: self.extract_rpu(video, untouched=True) elif b"Invalid PPS index" in rpu_extraction.stderr: raise ValueError("Dolby Vision VideoTrack seems to be corrupt") else: raise ValueError(f"Failed extracting{' untouched ' if untouched else ' '}RPU from Dolby Vision stream") + elif self.debug_logger: + self.debug_logger.log( + level="DEBUG", + operation="hybrid_extract_rpu", + message=f"Extracted{' untouched ' if untouched else ' '}RPU from Dolby Vision stream", + context={"untouched": untouched, "output": f"{rpu_name}.bin"}, + success=True, + ) - self.log.info(f"Extracted{' untouched ' if untouched else ' '}RPU from Dolby Vision stream") + def level_5(self, input_video): + """Generate Level 5 active area metadata via crop detection on the HDR10 stream. - def level_6(self): - """Edit RPU Level 6 values""" - with open(config.directories.temp / "L6.json", "w+") as level6_file: - level6 = { - "cm_version": "V29", - "length": 0, - "level6": { - "max_display_mastering_luminance": 1000, - "min_display_mastering_luminance": 1, - "max_content_light_level": 0, - "max_frame_average_light_level": 0, - }, - } + This resolves mismatches where DV has no black bars but HDR10 does (or vice versa) + by telling the display the correct active area. + """ + if os.path.isfile(config.directories.temp / "RPU_L5.bin"): + return - json.dump(level6, level6_file, indent=3) + ffprobe_bin = str(FFProbe) if FFProbe else "ffprobe" + ffmpeg_bin = str(FFMPEG) if FFMPEG else "ffmpeg" - if not os.path.isfile(config.directories.temp / "RPU_L6.bin"): - with console.status("Editing RPU Level 6 values...", spinner="dots"): - level6 = subprocess.run( + # Get video duration for random sampling + with console.status("Detecting active area (crop detection)...", spinner="dots"): + result_duration = subprocess.run( + [ffprobe_bin, "-v", "error", "-show_entries", "format=duration", "-of", "json", str(input_video)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + if result_duration.returncode != 0: + if self.debug_logger: + self.debug_logger.log( + level="WARNING", + operation="hybrid_level5", + message="Could not probe video duration", + context={"returncode": result_duration.returncode, "stderr": (result_duration.stderr or "")}, + ) + self.log.warning("Could not probe video duration, skipping L5 crop detection") + return + + duration_info = json.loads(result_duration.stdout) + duration = float(duration_info["format"]["duration"]) + + # Get video resolution for proper border calculation + result_streams = subprocess.run( + [ + ffprobe_bin, + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height", + "-of", + "json", + str(input_video), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + if result_streams.returncode != 0: + if self.debug_logger: + self.debug_logger.log( + level="WARNING", + operation="hybrid_level5", + message="Could not probe video resolution", + context={"returncode": result_streams.returncode, "stderr": (result_streams.stderr or "")}, + ) + self.log.warning("Could not probe video resolution, skipping L5 crop detection") + return + + stream_info = json.loads(result_streams.stdout) + original_width = int(stream_info["streams"][0]["width"]) + original_height = int(stream_info["streams"][0]["height"]) + + # Sample 10 random timestamps and run cropdetect on each + random_times = sorted(random.uniform(0, duration) for _ in range(10)) + + crop_results = [] + for t in random_times: + result_cropdetect = subprocess.run( [ - str(DoviTool), - "editor", + ffmpeg_bin, + "-y", + "-nostdin", + "-loglevel", + "info", + "-ss", + f"{t:.2f}", "-i", - config.directories.temp / self.rpu_file, - "-j", - config.directories.temp / "L6.json", - "-o", - config.directories.temp / "RPU_L6.bin", + str(input_video), + "-vf", + "cropdetect=round=2", + "-vframes", + "10", + "-f", + "null", + "-", ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, ) - if level6.returncode: - Path.unlink(config.directories.temp / "RPU_L6.bin") - raise ValueError("Failed editing RPU Level 6 values") + # cropdetect outputs crop=w:h:x:y + crop_match = re.search( + r"crop=(\d+):(\d+):(\d+):(\d+)", + (result_cropdetect.stdout or "") + (result_cropdetect.stderr or ""), + ) + if crop_match: + w, h = int(crop_match.group(1)), int(crop_match.group(2)) + x, y = int(crop_match.group(3)), int(crop_match.group(4)) + # Calculate actual border sizes from crop geometry + left = x + top = y + right = original_width - w - x + bottom = original_height - h - y + crop_results.append((left, top, right, bottom)) - self.log.info("Edited RPU Level 6 values") + if not crop_results: + if self.debug_logger: + self.debug_logger.log( + level="WARNING", + operation="hybrid_level5", + message="No crop data detected, skipping L5", + context={"samples": len(random_times)}, + ) + self.log.warning("No crop data detected, skipping L5") + return - # Update rpu_file to use the edited version - self.rpu_file = "RPU_L6.bin" + # Find the most common crop values + crop_counts = {} + for crop in crop_results: + crop_counts[crop] = crop_counts.get(crop, 0) + 1 + most_common = max(crop_counts, key=crop_counts.get) + left, top, right, bottom = most_common + + # If all borders are 0 there's nothing to correct + if left == 0 and top == 0 and right == 0 and bottom == 0: + return + + l5_json = { + "active_area": { + "crop": False, + "presets": [{"id": 0, "left": left, "right": right, "top": top, "bottom": bottom}], + "edits": {"all": 0}, + } + } + + l5_path = config.directories.temp / "L5.json" + with open(l5_path, "w") as f: + json.dump(l5_json, f, indent=4) + + with console.status("Editing RPU Level 5 active area...", spinner="dots"): + result = subprocess.run( + [ + str(DoviTool), + "editor", + "-i", + str(config.directories.temp / self.rpu_file), + "-j", + str(l5_path), + "-o", + str(config.directories.temp / "RPU_L5.bin"), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + if result.returncode: + if self.debug_logger: + self.debug_logger.log( + level="ERROR", + operation="hybrid_level5", + message="Failed editing RPU Level 5 values", + context={"returncode": result.returncode, "stderr": (result.stderr or b"").decode(errors="replace")}, + ) + Path.unlink(config.directories.temp / "RPU_L5.bin", missing_ok=True) + raise ValueError("Failed editing RPU Level 5 values") + + if self.debug_logger: + self.debug_logger.log( + level="DEBUG", + operation="hybrid_level5", + message="Edited RPU Level 5 active area", + context={"crop": {"left": left, "right": right, "top": top, "bottom": bottom}, "samples": len(crop_results)}, + success=True, + ) + self.rpu_file = "RPU_L5.bin" + + def level_6(self): + """Edit RPU Level 6 values using actual luminance data from the RPU.""" + if os.path.isfile(config.directories.temp / "RPU_L6.bin"): + return + + with console.status("Reading RPU luminance metadata...", spinner="dots"): + result = subprocess.run( + [str(DoviTool), "info", "-i", str(config.directories.temp / self.rpu_file), "-s"], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + if self.debug_logger: + self.debug_logger.log( + level="ERROR", + operation="hybrid_level6", + message="Failed reading RPU metadata for Level 6 values", + context={"returncode": result.returncode, "stderr": (result.stderr or "")}, + ) + raise ValueError("Failed reading RPU metadata for Level 6 values") + + max_cll = None + max_fall = None + max_mdl = None + min_mdl = None + + for line in result.stdout.splitlines(): + if "RPU content light level (L1):" in line: + parts = line.split("MaxCLL:")[1].split(",") + max_cll = int(float(parts[0].strip().split()[0])) + if len(parts) > 1 and "MaxFALL:" in parts[1]: + max_fall = int(float(parts[1].split("MaxFALL:")[1].strip().split()[0])) + elif "RPU mastering display:" in line: + mastering = line.split(":", 1)[1].strip() + min_lum, max_lum = mastering.split("/")[0], mastering.split("/")[1].split(" ")[0] + min_mdl = int(float(min_lum) * 10000) + max_mdl = int(float(max_lum)) + + if any(v is None for v in (max_cll, max_fall, max_mdl, min_mdl)): + if self.debug_logger: + self.debug_logger.log( + level="ERROR", + operation="hybrid_level6", + message="Could not extract Level 6 luminance data from RPU", + context={"max_cll": max_cll, "max_fall": max_fall, "max_mdl": max_mdl, "min_mdl": min_mdl}, + ) + raise ValueError("Could not extract Level 6 luminance data from RPU") + + level6_data = { + "level6": { + "remove_cmv4": False, + "remove_mapping": False, + "max_display_mastering_luminance": max_mdl, + "min_display_mastering_luminance": min_mdl, + "max_content_light_level": max_cll, + "max_frame_average_light_level": max_fall, + } + } + + l6_path = config.directories.temp / "L6.json" + with open(l6_path, "w") as f: + json.dump(level6_data, f, indent=4) + + with console.status("Editing RPU Level 6 values...", spinner="dots"): + result = subprocess.run( + [ + str(DoviTool), + "editor", + "-i", + str(config.directories.temp / self.rpu_file), + "-j", + str(l6_path), + "-o", + str(config.directories.temp / "RPU_L6.bin"), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + if result.returncode: + if self.debug_logger: + self.debug_logger.log( + level="ERROR", + operation="hybrid_level6", + message="Failed editing RPU Level 6 values", + context={"returncode": result.returncode, "stderr": (result.stderr or b"").decode(errors="replace")}, + ) + Path.unlink(config.directories.temp / "RPU_L6.bin", missing_ok=True) + raise ValueError("Failed editing RPU Level 6 values") + + if self.debug_logger: + self.debug_logger.log( + level="DEBUG", + operation="hybrid_level6", + message="Edited RPU Level 6 luminance values", + context={ + "max_cll": max_cll, + "max_fall": max_fall, + "max_mdl": max_mdl, + "min_mdl": min_mdl, + }, + success=True, + ) + self.rpu_file = "RPU_L6.bin" def injecting(self): if os.path.isfile(config.directories.temp / self.hevc_file): @@ -228,12 +558,6 @@ class Hybrid: config.directories.temp / self.rpu_file, ] - # If we converted from HDR10+, optionally remove HDR10+ metadata during injection - # Default to removing HDR10+ metadata since we're converting to DV - if self.hdr10plus_to_dv: - inject_cmd.append("--drop-hdr10plus") - self.log.info(" - Removing HDR10+ metadata during injection") - inject_cmd.extend(["-o", config.directories.temp / self.hevc_file]) inject = subprocess.run( @@ -243,10 +567,29 @@ class Hybrid: ) if inject.returncode: + if self.debug_logger: + self.debug_logger.log( + level="ERROR", + operation="hybrid_inject_rpu", + message="Failed injecting Dolby Vision metadata into HDR10 stream", + context={ + "returncode": inject.returncode, + "stderr": (inject.stderr or b"").decode(errors="replace"), + "stdout": (inject.stdout or b"").decode(errors="replace"), + "cmd": [str(a) for a in inject_cmd], + }, + ) Path.unlink(config.directories.temp / self.hevc_file) raise ValueError("Failed injecting Dolby Vision metadata into HDR10 stream") - self.log.info(f"Injected Dolby Vision metadata into {self.hdr_type} stream") + if self.debug_logger: + self.debug_logger.log( + level="DEBUG", + operation="hybrid_inject_rpu", + message=f"Injected Dolby Vision metadata into {self.hdr_type} stream", + context={"hdr_type": self.hdr_type, "rpu_file": self.rpu_file, "output": self.hevc_file, "drop_hdr10plus": self.hdr10plus_to_dv}, + success=True, + ) def extract_hdr10plus(self, _video): """Extract HDR10+ metadata from the video stream""" @@ -271,13 +614,39 @@ class Hybrid: ) if extraction.returncode: + if self.debug_logger: + self.debug_logger.log( + level="ERROR", + operation="hybrid_extract_hdr10plus", + message="Failed extracting HDR10+ metadata", + context={ + "returncode": extraction.returncode, + "stderr": (extraction.stderr or b"").decode(errors="replace"), + "stdout": (extraction.stdout or b"").decode(errors="replace"), + }, + ) raise ValueError("Failed extracting HDR10+ metadata") # Check if the extracted file has content - if os.path.getsize(config.directories.temp / self.hdr10plus_file) == 0: + file_size = os.path.getsize(config.directories.temp / self.hdr10plus_file) + if file_size == 0: + if self.debug_logger: + self.debug_logger.log( + level="ERROR", + operation="hybrid_extract_hdr10plus", + message="No HDR10+ metadata found in the stream", + context={"file_size": 0}, + ) raise ValueError("No HDR10+ metadata found in the stream") - self.log.info("Extracted HDR10+ metadata") + if self.debug_logger: + self.debug_logger.log( + level="DEBUG", + operation="hybrid_extract_hdr10plus", + message="Extracted HDR10+ metadata", + context={"output": self.hdr10plus_file, "file_size": file_size}, + success=True, + ) def convert_hdr10plus_to_dv(self): """Convert HDR10+ metadata to Dolby Vision RPU""" @@ -317,10 +686,26 @@ class Hybrid: ) if conversion.returncode: + if self.debug_logger: + self.debug_logger.log( + level="ERROR", + operation="hybrid_convert_hdr10plus", + message="Failed converting HDR10+ to Dolby Vision", + context={ + "returncode": conversion.returncode, + "stderr": (conversion.stderr or b"").decode(errors="replace"), + "stdout": (conversion.stdout or b"").decode(errors="replace"), + }, + ) raise ValueError("Failed converting HDR10+ to Dolby Vision") - self.log.info("Converted HDR10+ metadata to Dolby Vision") - self.log.info("✓ HDR10+ successfully converted to Dolby Vision Profile 8") + if self.debug_logger: + self.debug_logger.log( + level="DEBUG", + operation="hybrid_convert_hdr10plus", + message="Converted HDR10+ metadata to Dolby Vision Profile 8", + success=True, + ) # Clean up temporary files Path.unlink(config.directories.temp / "extra.json") diff --git a/packages/envied/src/envied/core/tracks/track.py b/packages/envied/src/envied/core/tracks/track.py index f93917d..b0bd5eb 100644 --- a/packages/envied/src/envied/core/tracks/track.py +++ b/packages/envied/src/envied/core/tracks/track.py @@ -24,7 +24,7 @@ from envied.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY from envied.core.downloaders import aria2c, curl_impersonate, n_m3u8dl_re, requests from envied.core.drm import DRM_T, PlayReady, Widevine from envied.core.events import events -from envied.core.utilities import get_boxes, get_extension, try_ensure_utf8 +from envied.core.utilities import get_boxes, try_ensure_utf8 from envied.core.utils.subprocess import ffprobe @@ -66,8 +66,8 @@ class Track: raise TypeError(f"Expected name to be a {str}, not {type(name)}") if not isinstance(id_, (str, type(None))): raise TypeError(f"Expected id_ to be a {str}, not {type(id_)}") - if not isinstance(edition, (str, type(None))): - raise TypeError(f"Expected edition to be a {str}, not {type(edition)}") + if not isinstance(edition, (str, list, type(None))): + raise TypeError(f"Expected edition to be a {str}, {list}, or None, not {type(edition)}") if not isinstance(downloader, (Callable, type(None))): raise TypeError(f"Expected downloader to be a {Callable}, not {type(downloader)}") if not isinstance(downloader_args, (dict, type(None))): @@ -103,7 +103,7 @@ class Track: self.needs_repack = needs_repack self.name = name self.drm = drm - self.edition: str = edition + self.edition: list[str] = [edition] if isinstance(edition, str) else (edition or []) self.downloader = downloader self.downloader_args = downloader_args self.from_file = from_file @@ -210,23 +210,12 @@ class Track: save_path = config.directories.temp / f"{track_type}_{self.id}.mp4" if track_type == "Subtitle": save_path = save_path.with_suffix(f".{self.codec.extension}") - # n_m3u8dl_re doesn't support directly downloading subtitles from URLs - # or when the subtitle has a direct file extension - if self.downloader.__name__ == "n_m3u8dl_re" and ( - self.descriptor == self.Descriptor.URL - or get_extension(self.url) - in { - ".srt", - ".vtt", - ".ttml", - ".ssa", - ".ass", - ".stpp", - ".wvtt", - ".xml", - } - ): - self.downloader = requests + + if self.downloader.__name__ == "n_m3u8dl_re" and ( + self.descriptor == self.Descriptor.URL + or track_type in ("Subtitle", "Attachment") + ): + self.downloader = requests if self.descriptor != self.Descriptor.URL: save_dir = save_path.with_name(save_path.name + "_segments") diff --git a/packages/envied/src/envied/core/tracks/tracks.py b/packages/envied/src/envied/core/tracks/tracks.py index d42b0c3..5853a96 100644 --- a/packages/envied/src/envied/core/tracks/tracks.py +++ b/packages/envied/src/envied/core/tracks/tracks.py @@ -103,53 +103,78 @@ class Tracks: tree = Tree("", hide_root=True) for track_type in self.TRACK_ORDER_MAP: tracks = list(x for x in all_tracks if isinstance(x, track_type)) - if not tracks: - continue - num_tracks = len(tracks) - track_type_plural = track_type.__name__ + ("s" if track_type != Audio and num_tracks != 1 else "") - tracks_tree = tree.add(f"[repr.number]{num_tracks}[/] {track_type_plural}") - for track in tracks: - if add_progress and track_type not in (Chapter, Attachment): - progress = Progress( - SpinnerColumn(finished_text=""), - BarColumn(), - "•", - TimeRemainingColumn(compact=True, elapsed_when_finished=True), - "•", - TextColumn("[progress.data.speed]{task.fields[downloaded]}"), - console=console, - speed_estimate_period=10, + if tracks: + num_tracks = len(tracks) + track_type_plural = track_type.__name__ + ("s" if track_type != Audio and num_tracks != 1 else "") + tracks_tree = tree.add(f"[repr.number]{num_tracks}[/] {track_type_plural}") + for track in tracks: + if add_progress and track_type not in (Chapter, Attachment): + progress = Progress( + SpinnerColumn(finished_text=""), + BarColumn(), + "•", + TimeRemainingColumn(compact=True, elapsed_when_finished=True), + "•", + TextColumn("[progress.data.speed]{task.fields[downloaded]}"), + console=console, + speed_estimate_period=10, + ) + task = progress.add_task("", downloaded="-") + state = {"total": 100.0} + + def update_track_progress( + task_id: int = task, + _state: dict[str, float] = state, + _progress: Progress = progress, + **kwargs, + ) -> None: + """ + Ensure terminal status states render as a fully completed bar. + + Some downloaders can report completed slightly below total + before emitting the final "Downloaded" state. + """ + if "total" in kwargs and kwargs["total"] is not None: + _state["total"] = kwargs["total"] + + downloaded_state = kwargs.get("downloaded") + if downloaded_state in {"Downloaded", "Decrypted", "[yellow]SKIPPED"}: + kwargs["completed"] = _state["total"] + _progress.update(task_id=task_id, **kwargs) + + progress_callables.append(update_track_progress) + track_table = Table.grid() + track_table.add_row(str(track)[6:], style="text2") + track_table.add_row(progress) + tracks_tree.add(track_table) + else: + tracks_tree.add(str(track)[6:], style="text2") + + # Show Closed Captions right after Subtitles (even if no subtitle tracks exist) + if track_type is Subtitle: + seen_cc: set[str] = set() + unique_cc: list[str] = [] + for video in (x for x in all_tracks if isinstance(x, Video)): + for cc in getattr(video, "closed_captions", []): + lang = cc.get("language", "und") + name = cc.get("name", "") + instream_id = cc.get("instream_id", "") + key = f"{lang}|{instream_id}" + if key in seen_cc: + continue + seen_cc.add(key) + parts = [f"[CC] | {lang}"] + if name: + parts.append(name) + if instream_id: + parts.append(instream_id) + unique_cc.append(" | ".join(parts)) + if unique_cc: + cc_tree = tree.add( + f"[repr.number]{len(unique_cc)}[/] Closed Caption{'s' if len(unique_cc) != 1 else ''}" ) - task = progress.add_task("", downloaded="-") - state = {"total": 100.0} - - def update_track_progress( - task_id: int = task, - _state: dict[str, float] = state, - _progress: Progress = progress, - **kwargs, - ) -> None: - """ - Ensure terminal status states render as a fully completed bar. - - Some downloaders can report completed slightly below total - before emitting the final "Downloaded" state. - """ - if "total" in kwargs and kwargs["total"] is not None: - _state["total"] = kwargs["total"] - - downloaded_state = kwargs.get("downloaded") - if downloaded_state in {"Downloaded", "Decrypted", "[yellow]SKIPPED"}: - kwargs["completed"] = _state["total"] - _progress.update(task_id=task_id, **kwargs) - - progress_callables.append(update_track_progress) - track_table = Table.grid() - track_table.add_row(str(track)[6:], style="text2") - track_table.add_row(progress) - tracks_tree.add(track_table) - else: - tracks_tree.add(str(track)[6:], style="text2") + for cc_str in unique_cc: + cc_tree.add(cc_str, style="text2") return tree, progress_callables @@ -278,23 +303,30 @@ class Tracks: self.subtitles = list(filter(x, self.subtitles)) def select_hybrid(self, tracks, quality): - hdr10_tracks = [ - v - for v in tracks - if v.range == Video.Range.HDR10 and (v.height in quality or int(v.width * 9 / 16) in quality) - ] - hdr10 = [] + # Prefer HDR10+ over HDR10 as the base layer (preserves dynamic metadata) + base_ranges = (Video.Range.HDR10P, Video.Range.HDR10) + base_tracks = [] + for range_type in base_ranges: + base_tracks = [ + v + for v in tracks + if v.range == range_type and (v.height in quality or int(v.width * 9 / 16) in quality) + ] + if base_tracks: + break + + base_selected = [] for res in quality: - candidates = [v for v in hdr10_tracks if v.height == res or int(v.width * 9 / 16) == res] + candidates = [v for v in base_tracks if v.height == res or int(v.width * 9 / 16) == res] if candidates: - best = max(candidates, key=lambda v: v.bitrate) # assumes .bitrate exists - hdr10.append(best) + best = max(candidates, key=lambda v: v.bitrate) + base_selected.append(best) dv_tracks = [v for v in tracks if v.range == Video.Range.DV] lowest_dv = min(dv_tracks, key=lambda v: v.height) if dv_tracks else None def select(x): - if x in hdr10: + if x in base_selected: return True if lowest_dv and x is lowest_dv: return True diff --git a/packages/envied/src/envied/core/tracks/video.py b/packages/envied/src/envied/core/tracks/video.py index 0e3bc6a..17356b9 100644 --- a/packages/envied/src/envied/core/tracks/video.py +++ b/packages/envied/src/envied/core/tracks/video.py @@ -200,6 +200,7 @@ class Video(Track): height: Optional[int] = None, fps: Optional[Union[str, int, float]] = None, scan_type: Optional[Video.ScanType] = None, + closed_captions: Optional[list[dict[str, Any]]] = None, **kwargs: Any, ) -> None: """ @@ -264,6 +265,7 @@ class Video(Track): raise ValueError("Expected fps to be a number, float, or a string as numerator/denominator form, " + str(e)) self.scan_type = scan_type + self.closed_captions: list[dict[str, Any]] = closed_captions or [] self.needs_duration_fix = False def __str__(self) -> str: @@ -291,7 +293,7 @@ class Video(Track): ], ) ), - self.edition, + ", ".join(self.edition) if self.edition else None, ], ) ) @@ -346,22 +348,27 @@ class Video(Track): if not binaries.CCExtractor: raise EnvironmentError("ccextractor executable was not found.") - # ccextractor often fails in weird ways unless we repack - self.repackage() - out_path = Path(out_path) - try: - subprocess.run( - [binaries.CCExtractor, "-trim", "-nobom", "-noru", "-ru1", "-o", out_path, self.path], - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - except subprocess.CalledProcessError as e: - out_path.unlink(missing_ok=True) - if not e.returncode == 10: # No captions found - raise + def _run_ccextractor() -> bool: + try: + subprocess.run( + [binaries.CCExtractor, "-trim", "-nobom", "-noru", "-ru1", "-o", out_path, self.path], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except subprocess.CalledProcessError as e: + out_path.unlink(missing_ok=True) + if e.returncode != 10: # 10 = No captions found + raise + return out_path.exists() + + # Try on the original file first (preserves container-level CC data like c608 boxes), + # then fall back to repacked file (ccextractor can fail on some container formats). + if not _run_ccextractor(): + self.repackage() + _run_ccextractor() if out_path.exists(): cc_track = Subtitle( diff --git a/packages/envied/src/envied/core/utilities.py b/packages/envied/src/envied/core/utilities.py index e727c4e..83c61c1 100644 --- a/packages/envied/src/envied/core/utilities.py +++ b/packages/envied/src/envied/core/utilities.py @@ -136,7 +136,7 @@ def sanitize_filename(filename: str, spacer: str = ".") -> str: if spacer == ".": filename = re.sub(r" - ", spacer, filename) # title separators to spacer (avoids .-. pattern) filename = re.sub(r"[:; ]", spacer, filename) # structural chars to (spacer) - filename = re.sub(r"[\\*!?¿,'\"" "()<>|$#~]", "", filename) # not filename safe chars + filename = re.sub(r"[\\*!?¿,'\"" "<>|$#~]", "", filename) # not filename safe chars filename = re.sub(rf"[{spacer}]{{2,}}", spacer, filename) # remove extra neighbouring (spacer)s return filename diff --git a/packages/envied/src/envied/core/utils/animeapi.py b/packages/envied/src/envied/core/utils/animeapi.py new file mode 100644 index 0000000..d9d5de9 --- /dev/null +++ b/packages/envied/src/envied/core/utils/animeapi.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import logging +from typing import Optional + +from envied.core.providers._base import ExternalIds + +log = logging.getLogger("ANIMEAPI") + +PLATFORM_MAP: dict[str, str] = { + "mal": "myanimelist", + "anilist": "anilist", + "kitsu": "kitsu", + "tmdb": "themoviedb", + "trakt": "trakt", + "tvdb": "thetvdb", +} + + +def resolve_animeapi(value: str) -> tuple[Optional[str], ExternalIds]: + """Resolve an anime database ID via AnimeAPI to a title and external IDs. + + Accepts formats like 'mal:12345', 'anilist:98765', or just '12345' (defaults to MAL). + Returns (anime_title, ExternalIds) with any TMDB/IMDB/TVDB IDs found. + """ + import animeapi + + platform_str, id_str = _parse_animeapi_value(value) + + platform_enum = _get_platform(platform_str) + if platform_enum is None: + log.warning("Unknown AnimeAPI platform: %s (supported: %s)", platform_str, ", ".join(PLATFORM_MAP)) + return None, ExternalIds() + + log.info("Resolving AnimeAPI %s:%s", platform_str, id_str) + + try: + with animeapi.AnimeAPI() as api: + relation = api.get_anime_relations(id_str, platform_enum) + except Exception as exc: + log.warning("AnimeAPI lookup failed for %s:%s: %s", platform_str, id_str, exc) + return None, ExternalIds() + + title = getattr(relation, "title", None) + + tmdb_id = getattr(relation, "themoviedb", None) + tmdb_type = getattr(relation, "themoviedb_type", None) + imdb_id = getattr(relation, "imdb", None) + tvdb_id = getattr(relation, "thetvdb", None) + + tmdb_kind: Optional[str] = None + if tmdb_type is not None: + tmdb_kind = tmdb_type.value if hasattr(tmdb_type, "value") else str(tmdb_type).lower() + if tmdb_kind not in ("movie", "tv"): + tmdb_kind = "tv" + + external_ids = ExternalIds( + tmdb_id=int(tmdb_id) if tmdb_id is not None else None, + tmdb_kind=tmdb_kind, + imdb_id=str(imdb_id) if imdb_id is not None else None, + tvdb_id=int(tvdb_id) if tvdb_id is not None else None, + ) + + log.info( + "AnimeAPI resolved: title=%r, tmdb=%s, imdb=%s, tvdb=%s", + title, + external_ids.tmdb_id, + external_ids.imdb_id, + external_ids.tvdb_id, + ) + + return title, external_ids + + +def _parse_animeapi_value(value: str) -> tuple[str, str]: + """Parse 'platform:id' format. Defaults to 'mal' if no prefix.""" + if ":" in value: + platform, _, id_str = value.partition(":") + return platform.lower().strip(), id_str.strip() + return "mal", value.strip() + + +def _get_platform(platform_str: str) -> object | None: + """Map a platform string to an animeapi.Platform enum value.""" + import animeapi + + canonical = PLATFORM_MAP.get(platform_str) + if canonical is None: + return None + + platform_name = canonical.upper() + return getattr(animeapi.Platform, platform_name, None) diff --git a/packages/envied/src/envied/core/utils/click_types.py b/packages/envied/src/envied/core/utils/click_types.py index d53c50e..4ac0dda 100644 --- a/packages/envied/src/envied/core/utils/click_types.py +++ b/packages/envied/src/envied/core/utils/click_types.py @@ -44,6 +44,33 @@ class VideoCodecChoice(click.Choice): self.fail(f"'{value}' is not a valid video codec", param, ctx) +class MultipleVideoCodecChoice(VideoCodecChoice): + """ + A multiple-value variant of VideoCodecChoice that accepts comma-separated codecs. + + Accepts both enum names and values, e.g.: ``-v hevc,avc`` or ``-v H.264,H.265`` + """ + + name = "multiple_video_codec_choice" + + def convert( + self, value: Any, param: Optional[click.Parameter] = None, ctx: Optional[click.Context] = None + ) -> list[Any]: + if not value: + return [] + if isinstance(value, list): + values = value + elif isinstance(value, str): + values = value.split(",") + else: + self.fail(f"{value!r} is not a supported value.", param, ctx) + + chosen_values: list[Any] = [] + for v in values: + chosen_values.append(super().convert(v.strip(), param, ctx)) + return chosen_values + + class SubtitleCodecChoice(click.Choice): """ A custom Choice type for subtitle codecs that accepts both enum names, values, and common aliases. diff --git a/packages/envied/src/envied/core/utils/language_tags.py b/packages/envied/src/envied/core/utils/language_tags.py new file mode 100644 index 0000000..d827273 --- /dev/null +++ b/packages/envied/src/envied/core/utils/language_tags.py @@ -0,0 +1,79 @@ +"""Language tag rule engine for output filename templates.""" + +from __future__ import annotations + +import logging +from typing import Any, Sequence + +from langcodes import Language + +from envied.core.utilities import is_close_match + +log = logging.getLogger(__name__) + + +def evaluate_language_tag( + rules: list[dict[str, Any]], + audio_languages: Sequence[Language], + subtitle_languages: Sequence[Language], +) -> str: + """Evaluate language tag rules against selected tracks. + + Rules are evaluated in order; the first matching rule's tag is returned. + Returns empty string if no rules match. + + Args: + rules: List of rule dicts from config, each with conditions and a ``tag``. + audio_languages: Languages of the selected audio tracks. + subtitle_languages: Languages of the selected subtitle tracks. + + Returns: + The tag string from the first matching rule, or ``""`` if none match. + """ + for rule in rules: + tag = rule.get("tag") + if not tag: + log.warning("Language tag rule missing 'tag' field, skipping: %s", rule) + continue + + if _rule_matches(rule, audio_languages, subtitle_languages): + log.debug("Language tag rule matched: %s -> %s", rule, tag) + return str(tag) + + return "" + + +def _rule_matches( + rule: dict[str, Any], + audio_languages: Sequence[Language], + subtitle_languages: Sequence[Language], +) -> bool: + """Check if all conditions in a rule are satisfied.""" + has_condition = False + + audio_lang = rule.get("audio") + if audio_lang is not None: + has_condition = True + if not is_close_match(audio_lang, list(audio_languages)): + return False + + subs_contain = rule.get("subs_contain") + if subs_contain is not None: + has_condition = True + if not is_close_match(subs_contain, list(subtitle_languages)): + return False + + subs_contain_all = rule.get("subs_contain_all") + if subs_contain_all is not None: + has_condition = True + if not isinstance(subs_contain_all, list): + subs_contain_all = [subs_contain_all] + for lang in subs_contain_all: + if not is_close_match(lang, list(subtitle_languages)): + return False + + if not has_condition: + log.warning("Language tag rule has no conditions, skipping: %s", rule) + return False + + return True diff --git a/packages/envied/src/envied/core/utils/selector.py b/packages/envied/src/envied/core/utils/selector.py index 44548f4..50fb6e9 100644 --- a/packages/envied/src/envied/core/utils/selector.py +++ b/packages/envied/src/envied/core/utils/selector.py @@ -1,21 +1,25 @@ -import click import sys + +import click from rich.console import Group from rich.live import Live from rich.padding import Padding from rich.table import Table from rich.text import Text + from envied.core.console import console IS_WINDOWS = sys.platform == "win32" if IS_WINDOWS: import msvcrt + class Selector: """ A custom interactive selector class using the Rich library. - Allows for multi-selection of items with pagination. + Allows for multi-selection of items with pagination and collapsible headers. """ + def __init__( self, options: list[str], @@ -24,7 +28,7 @@ class Selector: page_size: int = 8, minimal_count: int = 0, dependencies: dict[int, list[int]] = None, - prefixes: list[str] = None + collapse_on_start: bool = False, ): """ Initialize the Selector. @@ -36,6 +40,7 @@ class Selector: page_size: Number of items to show per page. minimal_count: Minimum number of items that must be selected. dependencies: Dictionary mapping parent index to list of child indices. + collapse_on_start: If True, child items are hidden initially. """ self.options = options self.cursor_style = cursor_style @@ -43,81 +48,164 @@ class Selector: self.page_size = page_size self.minimal_count = minimal_count self.dependencies = dependencies or {} - + + # Parent-Child mapping for quick lookup + self.child_to_parent = {} + for parent, children in self.dependencies.items(): + for child in children: + self.child_to_parent[child] = parent + self.cursor_index = 0 self.selected_indices = set() self.scroll_offset = 0 + # Tree view state + self.expanded_headers = set() + if not collapse_on_start: + # Expand all by default + self.expanded_headers.update(self.dependencies.keys()) + + def get_visible_indices(self) -> list[int]: + """ + Returns a sorted list of indices that should be currently visible. + A child is visible only if its parent is in self.expanded_headers. + """ + visible = [] + for idx in range(len(self.options)): + # If it's a child, check if parent is expanded + if idx in self.child_to_parent: + parent = self.child_to_parent[idx] + if parent in self.expanded_headers: + visible.append(idx) + else: + # It's a header or independent item, always visible + visible.append(idx) + return visible + def get_renderable(self): """ Constructs and returns the renderable object (Table + Info) for the current state. """ + visible_indices = self.get_visible_indices() + + # Adjust scroll offset to ensure cursor is visible + if self.cursor_index not in visible_indices: + # Fallback if cursor got hidden (should be handled in move, but safety check) + self.cursor_index = visible_indices[0] if visible_indices else 0 + + try: + cursor_visual_pos = visible_indices.index(self.cursor_index) + except ValueError: + cursor_visual_pos = 0 + self.cursor_index = visible_indices[0] + + # Calculate logical page start/end based on VISIBLE items + start_idx = self.scroll_offset + end_idx = start_idx + self.page_size + + # Dynamic scroll adjustment + if cursor_visual_pos < start_idx: + self.scroll_offset = cursor_visual_pos + elif cursor_visual_pos >= end_idx: + self.scroll_offset = cursor_visual_pos - self.page_size + 1 + + # Re-calc render range + render_indices = visible_indices[self.scroll_offset : self.scroll_offset + self.page_size] + table = Table(show_header=False, show_edge=False, box=None, pad_edge=False, padding=(0, 1, 0, 0)) table.add_column("Indicator", justify="right", no_wrap=True) table.add_column("Option", overflow="ellipsis", no_wrap=True) - for i in range(self.page_size): - idx = self.scroll_offset + i - - if idx < len(self.options): - option = self.options[idx] - is_cursor = (idx == self.cursor_index) - is_selected = (idx in self.selected_indices) + for idx in render_indices: + option = self.options[idx] + is_cursor = idx == self.cursor_index + is_selected = idx in self.selected_indices - symbol = "[X]" if is_selected else "[ ]" - style = self.cursor_style if is_cursor else self.text_style - indicator_text = Text(f"{symbol}", style=style) + symbol = "[X]" if is_selected else "[ ]" + style = self.cursor_style if is_cursor else self.text_style + indicator_text = Text(f"{symbol}", style=style) - content_text = Text.from_markup(option) - content_text.style = style + content_text = Text.from_markup(f"{option}") + content_text.style = style - table.add_row(indicator_text, content_text) - else: - table.add_row(Text(" "), Text(" ")) + table.add_row(indicator_text, content_text) - total_pages = (len(self.options) + self.page_size - 1) // self.page_size + # Fill empty rows to maintain height + rows_rendered = len(render_indices) + for _ in range(self.page_size - rows_rendered): + table.add_row(Text(" "), Text(" ")) + + total_visible = len(visible_indices) + total_pages = (total_visible + self.page_size - 1) // self.page_size + if total_pages == 0: + total_pages = 1 current_page = (self.scroll_offset // self.page_size) + 1 - - info_text = Text( - f"\n[Space]: Toggle [a]: All [←/→]: Page [Enter]: Confirm (Page {current_page}/{total_pages})", - style="gray" - ) - + + if self.dependencies: + info_text = Text( + f"\n[Space]: Toggle [a]: All [e]: Fold/Unfold [E]: All Fold/Unfold\n[Enter]: Confirm [↑/↓]: Move [←/→]: Page (Page {current_page}/{total_pages})", + style="gray", + ) + else: + info_text = Text( + f"\n[Space]: Toggle [a]: All [←/→]: Page [Enter]: Confirm (Page {current_page}/{total_pages})", + style="gray", + ) + return Padding(Group(table, info_text), (0, 5)) def move_cursor(self, delta: int): """ - Moves the cursor up or down by the specified delta. - Updates the scroll offset if the cursor moves out of the current view. + Moves the cursor up or down through VISIBLE items only. """ - self.cursor_index = (self.cursor_index + delta) % len(self.options) - new_page_idx = self.cursor_index // self.page_size - self.scroll_offset = new_page_idx * self.page_size + visible_indices = self.get_visible_indices() + if not visible_indices: + return + + try: + current_visual_idx = visible_indices.index(self.cursor_index) + except ValueError: + current_visual_idx = 0 + + new_visual_idx = (current_visual_idx + delta) % len(visible_indices) + self.cursor_index = visible_indices[new_visual_idx] def change_page(self, delta: int): """ Changes the current page view by the specified delta (previous/next page). - Also moves the cursor to the first item of the new page. """ + visible_indices = self.get_visible_indices() + if not visible_indices: + return + + total_visible = len(visible_indices) + + # Calculate current logical page current_page = self.scroll_offset // self.page_size - total_pages = (len(self.options) + self.page_size - 1) // self.page_size + total_pages = (total_visible + self.page_size - 1) // self.page_size + new_page = current_page + delta if 0 <= new_page < total_pages: self.scroll_offset = new_page * self.page_size - first_idx_of_page = self.scroll_offset - if first_idx_of_page < len(self.options): - self.cursor_index = first_idx_of_page - else: - self.cursor_index = len(self.options) - 1 + + # Move cursor to top of new page + try: + # Calculate what visual index corresponds to the start of the new page + new_visual_cursor = self.scroll_offset + if new_visual_cursor < len(visible_indices): + self.cursor_index = visible_indices[new_visual_cursor] + else: + self.cursor_index = visible_indices[-1] + except IndexError: + pass def toggle_selection(self): """ Toggles the selection state of the item currently under the cursor. - Propagates selection to children if defined in dependencies. """ target_indices = {self.cursor_index} - + if self.cursor_index in self.dependencies: target_indices.update(self.dependencies[self.cursor_index]) @@ -128,10 +216,33 @@ class Selector: else: self.selected_indices.difference_update(target_indices) + def toggle_expand(self): + """ + Expands or collapses the current header. + """ + if self.cursor_index in self.dependencies: + if self.cursor_index in self.expanded_headers: + self.expanded_headers.remove(self.cursor_index) + else: + self.expanded_headers.add(self.cursor_index) + + def toggle_expand_all(self): + """ + Toggles expansion state of ALL headers. + If all are expanded -> Collapse all. + Otherwise -> Expand all. + """ + if not self.dependencies: + return + all_headers = set(self.dependencies.keys()) + if self.expanded_headers == all_headers: + self.expanded_headers.clear() + else: + self.expanded_headers = all_headers.copy() + def toggle_all(self): """ - Toggles the selection of all items. - If all are selected, clears selection. Otherwise, selects all. + Toggles the selection of all items. """ if len(self.selected_indices) == len(self.options): self.selected_indices.clear() @@ -144,28 +255,49 @@ class Selector: Returns command strings like 'UP', 'DOWN', 'ENTER', etc. """ key = msvcrt.getch() - if key == b'\x03' or key == b'\x1b': - return 'CANCEL' - if key == b'\xe0' or key == b'\x00': + # Ctrl+C (0x03) or ESC (0x1b) + if key == b"\x03" or key == b"\x1b": + return "CANCEL" + # Special keys prefix (Arrow keys, etc., send 0xe0 or 0x00 first) + if key == b"\xe0" or key == b"\x00": try: key = msvcrt.getch() - if key == b'H': return 'UP' - if key == b'P': return 'DOWN' - if key == b'K': return 'LEFT' - if key == b'M': return 'RIGHT' - except: pass - - try: char = key.decode('utf-8', errors='ignore') - except: return None - - if char in ('\r', '\n'): return 'ENTER' - if char == ' ': return 'SPACE' - if char in ('q', 'Q'): return 'QUIT' - if char in ('a', 'A'): return 'ALL' - if char in ('w', 'W', 'k', 'K'): return 'UP' - if char in ('s', 'S', 'j', 'J'): return 'DOWN' - if char in ('h', 'H'): return 'LEFT' - if char in ('d', 'D', 'l', 'L'): return 'RIGHT' + if key == b"H": # Arrow Up + return "UP" + if key == b"P": # Arrow Down + return "DOWN" + if key == b"K": # Arrow Left + return "LEFT" + if key == b"M": # Arrow Right + return "RIGHT" + except Exception: + pass + + try: + char = key.decode("utf-8", errors="ignore") + except Exception: + return None + + if char in ("\r", "\n"): + return "ENTER" + if char == " ": + return "SPACE" + if char in ("q", "Q"): + return "QUIT" + if char in ("a", "A"): + return "ALL" + if char == "e": + return "EXPAND" + if char == "E": + return "EXPAND_ALL" + if char in ("w", "W", "k", "K"): + return "UP" + if char in ("s", "S", "j", "J"): + return "DOWN" + if char in ("h", "H"): + return "LEFT" + if char in ("d", "D", "l", "L"): + return "RIGHT" return None def get_input_unix(self): @@ -174,44 +306,65 @@ class Selector: Returns command strings like 'UP', 'DOWN', 'ENTER', etc. """ char = click.getchar() - if char == '\x03': - return 'CANCEL' + # Ctrl+C + if char == "\x03": + return "CANCEL" + + # ANSI Escape Sequences for Arrow Keys mapping = { - '\x1b[A': 'UP', - '\x1b[B': 'DOWN', - '\x1b[C': 'RIGHT', - '\x1b[D': 'LEFT', + "\x1b[A": "UP", # Escape + [ + A + "\x1b[B": "DOWN", # Escape + [ + B + "\x1b[C": "RIGHT", # Escape + [ + C + "\x1b[D": "LEFT", # Escape + [ + D } if char in mapping: return mapping[char] - if char == '\x1b': + + # Handling manual Escape sequences + if char == "\x1b": # ESC try: next1 = click.getchar() - if next1 in ('[', 'O'): + if next1 in ("[", "O"): # Sequence indicators next2 = click.getchar() - if next2 == 'A': return 'UP' - if next2 == 'B': return 'DOWN' - if next2 == 'C': return 'RIGHT' - if next2 == 'D': return 'LEFT' - return 'CANCEL' - except: - return 'CANCEL' + if next2 == "A": # Arrow Up + return "UP" + if next2 == "B": # Arrow Down + return "DOWN" + if next2 == "C": # Arrow Right + return "RIGHT" + if next2 == "D": # Arrow Left + return "LEFT" + return "CANCEL" + except Exception: + return "CANCEL" - if char in ('\r', '\n'): return 'ENTER' - if char == ' ': return 'SPACE' - if char in ('q', 'Q'): return 'QUIT' - if char in ('a', 'A'): return 'ALL' - if char in ('w', 'W', 'k', 'K'): return 'UP' - if char in ('s', 'S', 'j', 'J'): return 'DOWN' - if char in ('h', 'H'): return 'LEFT' - if char in ('d', 'D', 'l', 'L'): return 'RIGHT' + if char in ("\r", "\n"): + return "ENTER" + if char == " ": + return "SPACE" + if char in ("q", "Q"): + return "QUIT" + if char in ("a", "A"): + return "ALL" + if char == "e": + return "EXPAND" + if char == "E": + return "EXPAND_ALL" + if char in ("w", "W", "k", "K"): + return "UP" + if char in ("s", "S", "j", "J"): + return "DOWN" + if char in ("h", "H"): + return "LEFT" + if char in ("d", "D", "l", "L"): + return "RIGHT" return None def run(self) -> list[int]: """ Starts the main event loop for the selector. Renders the UI and processes input until confirmed or cancelled. - + Returns: list[int]: A sorted list of selected indices. """ @@ -219,39 +372,55 @@ class Selector: with Live(self.get_renderable(), console=console, auto_refresh=False, transient=True) as live: while True: live.update(self.get_renderable(), refresh=True) - if IS_WINDOWS: action = self.get_input_windows() - else: action = self.get_input_unix() - - if action == 'UP': self.move_cursor(-1) - elif action == 'DOWN': self.move_cursor(1) - elif action == 'LEFT': self.change_page(-1) - elif action == 'RIGHT': self.change_page(1) - elif action == 'SPACE': self.toggle_selection() - elif action == 'ALL': self.toggle_all() - elif action in ('ENTER', 'QUIT'): + if IS_WINDOWS: + action = self.get_input_windows() + else: + action = self.get_input_unix() + + if action == "UP": + self.move_cursor(-1) + elif action == "DOWN": + self.move_cursor(1) + elif action == "LEFT": + self.change_page(-1) + elif action == "RIGHT": + self.change_page(1) + elif action == "EXPAND": + self.toggle_expand() + elif action == "EXPAND_ALL": + self.toggle_expand_all() + elif action == "SPACE": + self.toggle_selection() + elif action == "ALL": + self.toggle_all() + elif action in ("ENTER", "QUIT"): if len(self.selected_indices) >= self.minimal_count: return sorted(list(self.selected_indices)) - elif action == 'CANCEL': raise KeyboardInterrupt + elif action == "CANCEL": + raise KeyboardInterrupt except KeyboardInterrupt: return [] + def select_multiple( options: list[str], minimal_count: int = 1, page_size: int = 8, return_indices: bool = True, cursor_style: str = "pink", - **kwargs + collapse_on_start: bool = False, + **kwargs, ) -> list[int]: """ Drop-in replacement using custom Selector with global console. - + Args: options: List of options to display. minimal_count: Minimum number of selections required. page_size: Number of items per page. return_indices: If True, returns indices; otherwise returns the option strings. cursor_style: Style color for the cursor. + collapse_on_start: If True, child items are hidden initially. """ selector = Selector( options=options, @@ -259,11 +428,12 @@ def select_multiple( text_style="text", page_size=page_size, minimal_count=minimal_count, - **kwargs + collapse_on_start=collapse_on_start, + **kwargs, ) - + selected_indices = selector.run() - + if return_indices: return selected_indices return [options[i] for i in selected_indices] diff --git a/packages/envied/src/envied/core/utils/tags.py b/packages/envied/src/envied/core/utils/tags.py index 3624b3b..4b79385 100644 --- a/packages/envied/src/envied/core/utils/tags.py +++ b/packages/envied/src/envied/core/utils/tags.py @@ -1,488 +1,23 @@ from __future__ import annotations import logging -import re import subprocess import tempfile -from difflib import SequenceMatcher from pathlib import Path -from typing import Optional, Tuple +from typing import Optional from xml.sax.saxutils import escape -import requests -from requests.adapters import HTTPAdapter, Retry - from envied.core import binaries from envied.core.config import config +from envied.core.providers import (ExternalIds, MetadataResult, enrich_ids, fetch_external_ids, fuzzy_match, + get_available_providers, get_provider, search_metadata) from envied.core.titles.episode import Episode from envied.core.titles.movie import Movie from envied.core.titles.title import Title -STRIP_RE = re.compile(r"[^a-z0-9]+", re.I) -YEAR_RE = re.compile(r"\s*\(?[12][0-9]{3}\)?$") -HEADERS = {"User-Agent": "unshackle-tags/1.0"} - - log = logging.getLogger("TAGS") -def _get_session() -> requests.Session: - """Create a requests session with retry logic for network failures.""" - session = requests.Session() - session.headers.update(HEADERS) - - retry = Retry( - total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504], allowed_methods=["GET", "POST"] - ) - - adapter = HTTPAdapter(max_retries=retry) - session.mount("https://", adapter) - session.mount("http://", adapter) - - return session - - -def _api_key() -> Optional[str]: - return config.tmdb_api_key - - -def _simkl_client_id() -> Optional[str]: - return config.simkl_client_id - - -def _clean(s: str) -> str: - return STRIP_RE.sub("", s).lower() - - -def _strip_year(s: str) -> str: - return YEAR_RE.sub("", s).strip() - - -def fuzzy_match(a: str, b: str, threshold: float = 0.8) -> bool: - """Return True if ``a`` and ``b`` are a close match.""" - - ratio = SequenceMatcher(None, _clean(a), _clean(b)).ratio() - return ratio >= threshold - - -def search_simkl( - title: str, - year: Optional[int], - kind: str, - title_cacher=None, - cache_title_id: Optional[str] = None, - cache_region: Optional[str] = None, - cache_account_hash: Optional[str] = None, -) -> Tuple[Optional[dict], Optional[str], Optional[int]]: - """Search Simkl API for show information by filename.""" - - if title_cacher and cache_title_id: - cached_simkl = title_cacher.get_cached_simkl(cache_title_id, cache_region, cache_account_hash) - if cached_simkl: - log.debug("Using cached Simkl data") - if cached_simkl.get("type") == "episode" and "show" in cached_simkl: - show_info = cached_simkl["show"] - show_title = show_info.get("title") - tmdb_id = show_info.get("ids", {}).get("tmdbtv") - if tmdb_id: - tmdb_id = int(tmdb_id) - return cached_simkl, show_title, tmdb_id - elif cached_simkl.get("type") == "movie" and "movie" in cached_simkl: - movie_info = cached_simkl["movie"] - movie_title = movie_info.get("title") - ids = movie_info.get("ids", {}) - tmdb_id = ids.get("tmdb") or ids.get("moviedb") - if tmdb_id: - tmdb_id = int(tmdb_id) - return cached_simkl, movie_title, tmdb_id - - log.debug("Searching Simkl for %r (%s, %s)", title, kind, year) - - client_id = _simkl_client_id() - if not client_id: - log.debug("No SIMKL client ID configured; skipping SIMKL search") - return None, None, None - - # Construct appropriate filename based on type - filename = f"{title}" - if year: - filename = f"{title} {year}" - - if kind == "tv": - filename += " S01E01.mkv" - else: # movie - filename += " 2160p.mkv" - - try: - session = _get_session() - headers = {"simkl-api-key": client_id} - resp = session.post("https://api.simkl.com/search/file", json={"file": filename}, headers=headers, timeout=30) - resp.raise_for_status() - data = resp.json() - log.debug("Simkl API response received") - - # Handle case where SIMKL returns empty list (no results) - if isinstance(data, list): - log.debug("Simkl returned list (no matches) for %r", filename) - return None, None, None - - # Handle TV show responses - if data.get("type") == "episode" and "show" in data: - show_info = data["show"] - show_title = show_info.get("title") - show_year = show_info.get("year") - - # Verify title matches and year if provided - if not fuzzy_match(show_title, title): - log.debug("Simkl title mismatch: searched %r, got %r", title, show_title) - return None, None, None - if year and show_year and abs(year - show_year) > 1: # Allow 1 year difference - log.debug("Simkl year mismatch: searched %d, got %d", year, show_year) - return None, None, None - - if title_cacher and cache_title_id: - try: - title_cacher.cache_simkl(cache_title_id, data, cache_region, cache_account_hash) - except Exception as exc: - log.debug("Failed to cache Simkl data: %s", exc) - - tmdb_id = show_info.get("ids", {}).get("tmdbtv") - if tmdb_id: - tmdb_id = int(tmdb_id) - log.debug("Simkl -> %s (TMDB ID %s)", show_title, tmdb_id) - return data, show_title, tmdb_id - - elif data.get("type") == "movie" and "movie" in data: - movie_info = data["movie"] - movie_title = movie_info.get("title") - movie_year = movie_info.get("year") - - if not fuzzy_match(movie_title, title): - log.debug("Simkl title mismatch: searched %r, got %r", title, movie_title) - return None, None, None - if year and movie_year and abs(year - movie_year) > 1: # Allow 1 year difference - log.debug("Simkl year mismatch: searched %d, got %d", year, movie_year) - return None, None, None - - if title_cacher and cache_title_id: - try: - title_cacher.cache_simkl(cache_title_id, data, cache_region, cache_account_hash) - except Exception as exc: - log.debug("Failed to cache Simkl data: %s", exc) - - ids = movie_info.get("ids", {}) - tmdb_id = ids.get("tmdb") or ids.get("moviedb") - if tmdb_id: - tmdb_id = int(tmdb_id) - log.debug("Simkl -> %s (TMDB ID %s)", movie_title, tmdb_id) - return data, movie_title, tmdb_id - - except (requests.RequestException, ValueError, KeyError) as exc: - log.debug("Simkl search failed: %s", exc) - - return None, None, None - - -def search_show_info( - title: str, - year: Optional[int], - kind: str, - title_cacher=None, - cache_title_id: Optional[str] = None, - cache_region: Optional[str] = None, - cache_account_hash: Optional[str] = None, -) -> Tuple[Optional[int], Optional[str], Optional[str]]: - """Search for show information, trying Simkl first, then TMDB fallback. Returns (tmdb_id, title, source).""" - simkl_data, simkl_title, simkl_tmdb_id = search_simkl( - title, year, kind, title_cacher, cache_title_id, cache_region, cache_account_hash - ) - - if simkl_data and simkl_title and fuzzy_match(simkl_title, title): - return simkl_tmdb_id, simkl_title, "simkl" - - tmdb_id, tmdb_title = search_tmdb(title, year, kind, title_cacher, cache_title_id, cache_region, cache_account_hash) - return tmdb_id, tmdb_title, "tmdb" - - -def _fetch_tmdb_detail(tmdb_id: int, kind: str) -> Optional[dict]: - """Fetch full TMDB detail response for caching.""" - api_key = _api_key() - if not api_key: - return None - - try: - session = _get_session() - r = session.get( - f"https://api.themoviedb.org/3/{kind}/{tmdb_id}", - params={"api_key": api_key}, - timeout=30, - ) - r.raise_for_status() - return r.json() - except requests.RequestException as exc: - log.debug("Failed to fetch TMDB detail: %s", exc) - return None - - -def _fetch_tmdb_external_ids(tmdb_id: int, kind: str) -> Optional[dict]: - """Fetch full TMDB external_ids response for caching.""" - api_key = _api_key() - if not api_key: - return None - - try: - session = _get_session() - r = session.get( - f"https://api.themoviedb.org/3/{kind}/{tmdb_id}/external_ids", - params={"api_key": api_key}, - timeout=30, - ) - r.raise_for_status() - return r.json() - except requests.RequestException as exc: - log.debug("Failed to fetch TMDB external IDs: %s", exc) - return None - - -def search_tmdb( - title: str, - year: Optional[int], - kind: str, - title_cacher=None, - cache_title_id: Optional[str] = None, - cache_region: Optional[str] = None, - cache_account_hash: Optional[str] = None, -) -> Tuple[Optional[int], Optional[str]]: - if title_cacher and cache_title_id: - cached_tmdb = title_cacher.get_cached_tmdb(cache_title_id, kind, cache_region, cache_account_hash) - if cached_tmdb and cached_tmdb.get("detail"): - detail = cached_tmdb["detail"] - tmdb_id = detail.get("id") - tmdb_title = detail.get("title") or detail.get("name") - log.debug("Using cached TMDB data: %r (ID %s)", tmdb_title, tmdb_id) - return tmdb_id, tmdb_title - - api_key = _api_key() - if not api_key: - return None, None - - search_title = _strip_year(title) - log.debug("Searching TMDB for %r (%s, %s)", search_title, kind, year) - - params = {"api_key": api_key, "query": search_title} - if year is not None: - params["year" if kind == "movie" else "first_air_date_year"] = year - - try: - session = _get_session() - r = session.get( - f"https://api.themoviedb.org/3/search/{kind}", - params=params, - timeout=30, - ) - r.raise_for_status() - js = r.json() - results = js.get("results") or [] - log.debug("TMDB returned %d results", len(results)) - if not results: - return None, None - except requests.RequestException as exc: - log.warning("Failed to search TMDB for %s: %s", title, exc) - return None, None - - best_ratio = 0.0 - best_id: Optional[int] = None - best_title: Optional[str] = None - for result in results: - candidates = [ - result.get("title"), - result.get("name"), - result.get("original_title"), - result.get("original_name"), - ] - candidates = [c for c in candidates if c] # Filter out None/empty values - - if not candidates: - continue - - # Find the best matching candidate from all available titles - for candidate in candidates: - ratio = SequenceMatcher(None, _clean(search_title), _clean(candidate)).ratio() - if ratio > best_ratio: - best_ratio = ratio - best_id = result.get("id") - best_title = candidate - log.debug( - "Best candidate ratio %.2f for %r (ID %s)", - best_ratio, - best_title, - best_id, - ) - - if best_id is not None: - if title_cacher and cache_title_id: - try: - detail_response = _fetch_tmdb_detail(best_id, kind) - external_ids_response = _fetch_tmdb_external_ids(best_id, kind) - if detail_response and external_ids_response: - title_cacher.cache_tmdb( - cache_title_id, detail_response, external_ids_response, kind, cache_region, cache_account_hash - ) - except Exception as exc: - log.debug("Failed to cache TMDB data: %s", exc) - - return best_id, best_title - - first = results[0] - return first.get("id"), first.get("title") or first.get("name") - - -def get_title( - tmdb_id: int, - kind: str, - title_cacher=None, - cache_title_id: Optional[str] = None, - cache_region: Optional[str] = None, - cache_account_hash: Optional[str] = None, -) -> Optional[str]: - """Fetch the name/title of a TMDB entry by ID.""" - - if title_cacher and cache_title_id: - cached_tmdb = title_cacher.get_cached_tmdb(cache_title_id, kind, cache_region, cache_account_hash) - if cached_tmdb and cached_tmdb.get("detail"): - detail = cached_tmdb["detail"] - tmdb_title = detail.get("title") or detail.get("name") - log.debug("Using cached TMDB title: %r", tmdb_title) - return tmdb_title - - api_key = _api_key() - if not api_key: - return None - - try: - session = _get_session() - r = session.get( - f"https://api.themoviedb.org/3/{kind}/{tmdb_id}", - params={"api_key": api_key}, - timeout=30, - ) - r.raise_for_status() - js = r.json() - - if title_cacher and cache_title_id: - try: - external_ids_response = _fetch_tmdb_external_ids(tmdb_id, kind) - if external_ids_response: - title_cacher.cache_tmdb( - cache_title_id, js, external_ids_response, kind, cache_region, cache_account_hash - ) - except Exception as exc: - log.debug("Failed to cache TMDB data: %s", exc) - - return js.get("title") or js.get("name") - except requests.RequestException as exc: - log.debug("Failed to fetch TMDB title: %s", exc) - return None - - -def get_year( - tmdb_id: int, - kind: str, - title_cacher=None, - cache_title_id: Optional[str] = None, - cache_region: Optional[str] = None, - cache_account_hash: Optional[str] = None, -) -> Optional[int]: - """Fetch the release year of a TMDB entry by ID.""" - - if title_cacher and cache_title_id: - cached_tmdb = title_cacher.get_cached_tmdb(cache_title_id, kind, cache_region, cache_account_hash) - if cached_tmdb and cached_tmdb.get("detail"): - detail = cached_tmdb["detail"] - date = detail.get("release_date") or detail.get("first_air_date") - if date and len(date) >= 4 and date[:4].isdigit(): - year = int(date[:4]) - log.debug("Using cached TMDB year: %d", year) - return year - - api_key = _api_key() - if not api_key: - return None - - try: - session = _get_session() - r = session.get( - f"https://api.themoviedb.org/3/{kind}/{tmdb_id}", - params={"api_key": api_key}, - timeout=30, - ) - r.raise_for_status() - js = r.json() - - if title_cacher and cache_title_id: - try: - external_ids_response = _fetch_tmdb_external_ids(tmdb_id, kind) - if external_ids_response: - title_cacher.cache_tmdb( - cache_title_id, js, external_ids_response, kind, cache_region, cache_account_hash - ) - except Exception as exc: - log.debug("Failed to cache TMDB data: %s", exc) - - date = js.get("release_date") or js.get("first_air_date") - if date and len(date) >= 4 and date[:4].isdigit(): - return int(date[:4]) - return None - except requests.RequestException as exc: - log.debug("Failed to fetch TMDB year: %s", exc) - return None - - -def external_ids( - tmdb_id: int, - kind: str, - title_cacher=None, - cache_title_id: Optional[str] = None, - cache_region: Optional[str] = None, - cache_account_hash: Optional[str] = None, -) -> dict: - if title_cacher and cache_title_id: - cached_tmdb = title_cacher.get_cached_tmdb(cache_title_id, kind, cache_region, cache_account_hash) - if cached_tmdb and cached_tmdb.get("external_ids"): - log.debug("Using cached TMDB external IDs") - return cached_tmdb["external_ids"] - - api_key = _api_key() - if not api_key: - return {} - url = f"https://api.themoviedb.org/3/{kind}/{tmdb_id}/external_ids" - log.debug("Fetching external IDs for %s %s", kind, tmdb_id) - - try: - session = _get_session() - r = session.get( - url, - params={"api_key": api_key}, - timeout=30, - ) - r.raise_for_status() - js = r.json() - log.debug("External IDs response: %s", js) - - if title_cacher and cache_title_id: - try: - detail_response = _fetch_tmdb_detail(tmdb_id, kind) - if detail_response: - title_cacher.cache_tmdb(cache_title_id, detail_response, js, kind, cache_region, cache_account_hash) - except Exception as exc: - log.debug("Failed to cache TMDB data: %s", exc) - - return js - except requests.RequestException as exc: - log.warning("Failed to fetch external IDs for %s %s: %s", kind, tmdb_id, exc) - return {} - - def apply_tags(path: Path, tags: dict[str, str]) -> None: if not tags: return @@ -509,9 +44,26 @@ def apply_tags(path: Path, tags: dict[str, str]) -> None: tmp_path.unlink(missing_ok=True) -def tag_file(path: Path, title: Title, tmdb_id: Optional[int] | None = None) -> None: +def _build_tags_from_ids(ids: ExternalIds, kind: str) -> dict[str, str]: + """Build standard MKV tags from external IDs.""" + tags: dict[str, str] = {} + if ids.imdb_id: + tags["IMDB"] = ids.imdb_id + if ids.tmdb_id and ids.tmdb_kind: + tags["TMDB"] = f"{ids.tmdb_kind}/{ids.tmdb_id}" + if ids.tvdb_id: + prefix = "movies" if kind == "movie" else "series" + tags["TVDB2"] = f"{prefix}/{ids.tvdb_id}" + return tags + + +def tag_file( + path: Path, + title: Title, + tmdb_id: Optional[int] = None, + imdb_id: Optional[str] = None, +) -> None: log.debug("Tagging file %s with title %r", path, title) - standard_tags: dict[str, str] = {} custom_tags: dict[str, str] = {} if config.tag and config.tag_group_name: @@ -537,115 +89,52 @@ def tag_file(path: Path, title: Title, tmdb_id: Optional[int] | None = None) -> apply_tags(path, custom_tags) return - if config.tag_imdb_tmdb: - # Check if we have any API keys available for metadata lookup - api_key = _api_key() - simkl_client = _simkl_client_id() + standard_tags: dict[str, str] = {} - if not api_key and not simkl_client: - log.debug("No TMDB API key or Simkl client ID configured; skipping IMDB/TMDB tag lookup") + if config.tag_imdb_tmdb: + providers = get_available_providers() + if not providers: + log.debug("No metadata providers available; skipping tag lookup") apply_tags(path, custom_tags) return + + result: Optional[MetadataResult] = None + + # Direct ID lookup path + if imdb_id: + imdbapi = get_provider("imdbapi") + if imdbapi: + result = imdbapi.get_by_id(imdb_id, kind) + if result: + result.external_ids.imdb_id = imdb_id + enrich_ids(result) + elif tmdb_id is not None: + tmdb = get_provider("tmdb") + if tmdb: + result = tmdb.get_by_id(tmdb_id, kind) + if result: + ext = tmdb.get_external_ids(tmdb_id, kind) + result.external_ids = ext else: - # If tmdb_id is provided (via --tmdb), skip Simkl and use TMDB directly - if tmdb_id is not None: - log.debug("Using provided TMDB ID %s for tags", tmdb_id) - else: - # Try Simkl first for automatic lookup (only if client ID is available) - if simkl_client: - simkl_data, simkl_title, simkl_tmdb_id = search_simkl(name, year, kind) + # Search across providers in priority order + result = search_metadata(name, year, kind) - if simkl_data and simkl_title and fuzzy_match(simkl_title, name): - log.debug("Using Simkl data for tags") - if simkl_tmdb_id: - tmdb_id = simkl_tmdb_id + # If we got a TMDB ID from search but no full external IDs, fetch them + if result and result.external_ids.tmdb_id and not result.external_ids.imdb_id: + ext = fetch_external_ids(result.external_ids.tmdb_id, kind) + if ext.imdb_id: + result.external_ids.imdb_id = ext.imdb_id + if ext.tvdb_id: + result.external_ids.tvdb_id = ext.tvdb_id - # Handle TV show data from Simkl - if simkl_data.get("type") == "episode" and "show" in simkl_data: - show_ids = simkl_data.get("show", {}).get("ids", {}) - if show_ids.get("imdb"): - standard_tags["IMDB"] = show_ids["imdb"] - if show_ids.get("tvdb"): - standard_tags["TVDB2"] = f"series/{show_ids['tvdb']}" - if show_ids.get("tmdbtv"): - standard_tags["TMDB"] = f"tv/{show_ids['tmdbtv']}" + if result and result.external_ids: + standard_tags = _build_tags_from_ids(result.external_ids, kind) - # Handle movie data from Simkl - elif simkl_data.get("type") == "movie" and "movie" in simkl_data: - movie_ids = simkl_data.get("movie", {}).get("ids", {}) - if movie_ids.get("imdb"): - standard_tags["IMDB"] = movie_ids["imdb"] - if movie_ids.get("tvdb"): - standard_tags["TVDB2"] = f"movies/{movie_ids['tvdb']}" - if movie_ids.get("tmdb"): - standard_tags["TMDB"] = f"movie/{movie_ids['tmdb']}" - - # Use TMDB API for additional metadata (either from provided ID or Simkl lookup) - if api_key: - tmdb_title: Optional[str] = None - if tmdb_id is None: - tmdb_id, tmdb_title = search_tmdb(name, year, kind) - log.debug("TMDB search result: %r (ID %s)", tmdb_title, tmdb_id) - if not tmdb_id or not tmdb_title or not fuzzy_match(tmdb_title, name): - log.debug("TMDB search did not match; skipping external ID lookup") - else: - prefix = "movie" if kind == "movie" else "tv" - standard_tags["TMDB"] = f"{prefix}/{tmdb_id}" - try: - ids = external_ids(tmdb_id, kind) - except requests.RequestException as exc: - log.debug("Failed to fetch external IDs: %s", exc) - ids = {} - else: - log.debug("External IDs found: %s", ids) - - imdb_id = ids.get("imdb_id") - if imdb_id: - standard_tags["IMDB"] = imdb_id - tvdb_id = ids.get("tvdb_id") - if tvdb_id: - if kind == "movie": - standard_tags["TVDB2"] = f"movies/{tvdb_id}" - else: - standard_tags["TVDB2"] = f"series/{tvdb_id}" - elif tmdb_id is not None: - # tmdb_id was provided or found via Simkl - prefix = "movie" if kind == "movie" else "tv" - standard_tags["TMDB"] = f"{prefix}/{tmdb_id}" - try: - ids = external_ids(tmdb_id, kind) - except requests.RequestException as exc: - log.debug("Failed to fetch external IDs: %s", exc) - ids = {} - else: - log.debug("External IDs found: %s", ids) - - imdb_id = ids.get("imdb_id") - if imdb_id: - standard_tags["IMDB"] = imdb_id - tvdb_id = ids.get("tvdb_id") - if tvdb_id: - if kind == "movie": - standard_tags["TVDB2"] = f"movies/{tvdb_id}" - else: - standard_tags["TVDB2"] = f"series/{tvdb_id}" - else: - log.debug("No TMDB API key configured; skipping TMDB external ID lookup") - - merged_tags = { - **custom_tags, - **standard_tags, - } - apply_tags(path, merged_tags) + apply_tags(path, {**custom_tags, **standard_tags}) __all__ = [ - "search_simkl", - "search_show_info", - "search_tmdb", - "get_title", - "get_year", - "external_ids", - "tag_file", + "apply_tags", "fuzzy_match", + "tag_file", ] diff --git a/packages/envied/src/envied/core/utils/template_formatter.py b/packages/envied/src/envied/core/utils/template_formatter.py new file mode 100644 index 0000000..1775dac --- /dev/null +++ b/packages/envied/src/envied/core/utils/template_formatter.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import logging +import re +from typing import Any + +from envied.core.utilities import sanitize_filename + +log = logging.getLogger(__name__) + + +class TemplateFormatter: + """ + Template formatter for custom filename patterns. + + Supports variable substitution and conditional variables. + Example: '{title}.{year}.{quality?}.{source}-{tag}' + """ + + def __init__(self, template: str): + """Initialize the template formatter. + + Args: + template: Template string with variables in {variable} format + """ + self.template = template + self.variables = self._extract_variables() + + def _extract_variables(self) -> list[str]: + """Extract all variables from the template.""" + pattern = r"\{([^}]+)\}" + matches = re.findall(pattern, self.template) + return [match.strip() for match in matches] + + def format(self, context: dict[str, Any]) -> str: + """Format the template with the provided context. + + Args: + context: Dictionary containing variable values + + Returns: + Formatted filename string + + Raises: + ValueError: If required template variables are missing from context + """ + is_valid, missing_vars = self.validate(context) + if not is_valid: + error_msg = f"Missing required template variables: {', '.join(missing_vars)}" + log.error(error_msg) + raise ValueError(error_msg) + + try: + result = self.template + + for variable in self.variables: + placeholder = "{" + variable + "}" + is_conditional = variable.endswith("?") + + if is_conditional: + var_name = variable[:-1] + value = context.get(var_name, "") + + if value: + safe_value = str(value).strip() + result = result.replace(placeholder, safe_value) + else: + # Remove the placeholder and consume the adjacent separator on one side + # e.g. "{disc?}-{track}" → "{track}" when disc is empty + # e.g. "{title}.{edition?}.{quality}" → "{title}.{quality}" when edition is empty + def _remove_conditional(m: re.Match) -> str: + s = m.group(0) + has_left = s[0] in ".- " + has_right = s[-1] in ".- " + if has_left and has_right: + return s[0] # keep left separator + return "" + + result = re.sub( + rf"[\.\s\-]?{re.escape(placeholder)}[\.\s\-]?", + _remove_conditional, + result, + count=1, + ) + else: + value = context.get(variable, "") + if value is None: + log.warning(f"Template variable '{variable}' is None, using empty string") + value = "" + + safe_value = str(value).strip() + result = result.replace(placeholder, safe_value) + + # Clean up multiple consecutive dots/separators and other artifacts + result = re.sub(r"\.{2,}", ".", result) # Multiple dots -> single dot + result = re.sub(r"\s{2,}", " ", result) # Multiple spaces -> single space + result = re.sub(r"-{2,}", "-", result) # Multiple dashes -> single dash + result = re.sub(r"^[\.\s\-]+|[\.\s\-]+$", "", result) # Remove leading/trailing dots, spaces, dashes + result = re.sub(r"\.-", "-", result) # Remove dots before dashes (for dot-based templates) + result = re.sub(r"[\.\s]+\)", ")", result) # Remove dots/spaces before closing parentheses + result = re.sub(r"\(\s*\)", "", result) # Remove empty parentheses (empty conditional) + + # Determine the appropriate separator based on template style + # Count separator characters between variables (between } and {) + between_vars = re.findall(r"\}([^{]*)\{", self.template) + separator_text = "".join(between_vars) + dot_count = separator_text.count(".") + space_count = separator_text.count(" ") + + if space_count > dot_count: + result = sanitize_filename(result, spacer=" ") + else: + result = sanitize_filename(result, spacer=".") + + if not result or result.isspace(): + log.warning("Template formatting resulted in empty filename, using fallback") + return "untitled" + + log.debug(f"Template formatted successfully: '{self.template}' -> '{result}'") + return result + + except (KeyError, ValueError, re.error) as e: + log.error(f"Error formatting template '{self.template}': {e}") + fallback = f"error_formatting_{hash(self.template) % 10000}" + log.warning(f"Using fallback filename: {fallback}") + return fallback + + def validate(self, context: dict[str, Any]) -> tuple[bool, list[str]]: + """Validate that all required variables are present in context. + + Args: + context: Dictionary containing variable values + + Returns: + Tuple of (is_valid, missing_variables) + """ + missing = [] + + for variable in self.variables: + is_conditional = variable.endswith("?") + var_name = variable[:-1] if is_conditional else variable + + if not is_conditional and var_name not in context: + missing.append(var_name) + + return len(missing) == 0, missing + + def get_required_variables(self) -> list[str]: + """Get list of required (non-conditional) variables.""" + required = [] + for variable in self.variables: + if not variable.endswith("?"): + required.append(variable) + return required + + def get_optional_variables(self) -> list[str]: + """Get list of optional (conditional) variables.""" + optional = [] + for variable in self.variables: + if variable.endswith("?"): + optional.append(variable[:-1]) # Remove the ? + return optional diff --git a/packages/envied/src/envied/envied-working-example.yaml b/packages/envied/src/envied/envied-working-example.yaml index 8af8ddd..d8b2f43 100644 --- a/packages/envied/src/envied/envied-working-example.yaml +++ b/packages/envied/src/envied/envied-working-example.yaml @@ -11,8 +11,66 @@ muxing: # shakapackager or mp4decrypt decryption: shakapackager -scene_naming: false -series_year: false +# Custom output templates for filenames +# Configure output_template in your envied.yaml to control filename format. +# If not configured, default scene-style templates are used and a warning is shown. +# Available variables: {title}, {year}, {season}, {episode}, {season_episode}, {episode_name}, +# {quality}, {resolution}, {source}, {audio}, {audio_channels}, {audio_full}, +# {video}, {hdr}, {hfr}, {atmos}, {dual}, {multi}, {tag}, {edition}, {repack}, +# {lang_tag} +# Conditional variables (included only if present): Add ? suffix like {year?}, {episode_name?}, {hdr?} +# Customize the templates below: +# +# Example outputs: +# Scene movies: 'The.Matrix.1999.1080p.SERVICE.WEB-DL.DDP5.1.H.264-EXAMPLE' +# Scene movies (HDR): 'Dune.2021.2160p.SERVICE.WEB-DL.DDP5.1.HDR10.H.265-EXAMPLE' +# Scene movies (REPACK): 'Dune.2021.REPACK.2160p.SERVICE.WEB-DL.DDP5.1.H.265-EXAMPLE' +# Scene series: 'Breaking.Bad.2008.S01E01.Pilot.1080p.SERVICE.WEB-DL.DDP5.1.H.264-EXAMPLE' +# Plex movies: 'The Matrix (1999)' +# Plex series: 'Breaking Bad S01E01 Pilot' +output_template: + # Scene-style naming (dot-separated) + #movies: '{title}.{year}.{repack?}.{edition?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}' + #series: '{title}.{year?}.{season_episode}.{episode_name?}.{repack?}.{edition?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}' + #songs: '{track_number}.{title}.{repack?}.{edition?}.{source?}.WEB-DL.{audio_full}.{atmos?}-{tag}' + # + # Plex-friendly naming (space-separated, clean format) + movies: '{title} ({year})' + series: '{title} {season_episode} {episode_name?}' + songs: '{track_number}. {title}' + # + # Minimal naming (basic info only) + # movies: '{title}.{year}.{quality}' + # series: '{title}.{season_episode}.{episode_name?}' + # + # Custom scene-style with specific elements + # movies: '{title}.{year}.{quality}.{hdr?}.{source}.WEB-DL.{audio_full}.{video}-{tag}' + # series: '{title}.{year?}.{season_episode}.{episode_name?}.{quality}.{hdr?}.{source}.WEB-DL.{audio_full}.{atmos?}.{video}-{tag}' + +# Language-based tagging for output filenames +# Automatically adds language identifiers (e.g., DANiSH, NORDiC, DKsubs) based on +# audio and subtitle track languages. Rules are evaluated in order; first match wins. +# Use {lang_tag?} in your output_template to place the tag in the filename. +# +# Conditions (all conditions in a rule must match): +# audio: - any audio track matches this language +# subs_contain: - any subtitle matches this language +# subs_contain_all: [lang, ...] - subtitles include ALL listed languages +# +# language_tags: +# rules: +# - audio: da +# tag: DANiSH +# - audio: sv +# tag: SWEDiSH +# - audio: nb +# tag: NORWEGiAN +# - audio: en +# subs_contain_all: [da, sv, nb] +# tag: NORDiC +# - audio: en +# subs_contain: da +# tag: DKsubs # Login credentials for each Service credentials: @@ -59,12 +117,12 @@ key_vaults: #- type: SQLite # name: Local vault # path: key_store.db -- type: HTTPAPI - name: drmlab +- type: HTTP + name: DRMLab host: http://api.drmlab.io/vault/ password: gEX75q7I5YVkvgF5SUkcNd41IbGrDtTT - - + api_mode: json + no_push: false # Choose what software to use to download data downloader: n_m3u8dl_re diff --git a/packages/envied/src/envied/services/CR/__init__.py b/packages/envied/src/envied/services/CR/__init__.py index 7096d43..14e7d40 100644 --- a/packages/envied/src/envied/services/CR/__init__.py +++ b/packages/envied/src/envied/services/CR/__init__.py @@ -1,3 +1,4 @@ +from hashlib import md5 import re import time import uuid @@ -9,13 +10,16 @@ import jwt from langcodes import Language from envied.core.manifests import DASH +from envied.core.manifests.hls import HLS from envied.core.search_result import SearchResult from envied.core.service import Service from envied.core.session import session from envied.core.titles import Episode, Series from envied.core.tracks import Attachment, Chapters, Tracks +from envied.core.tracks.audio import Audio from envied.core.tracks.chapter import Chapter from envied.core.tracks.subtitle import Subtitle +from envied.core.tracks.video import Video class CR(Service): @@ -23,18 +27,21 @@ class CR(Service): Service code for Crunchyroll streaming service (https://www.crunchyroll.com). \b - Version: 2.0.0 - Author: sp4rk.y - Date: 2025-11-01 + Version: 2.1.1 + Author: sp4rk.y & Dex + Date: 2026-02-28 Authorization: Credentials Robustness: Widevine: L3: 1080p, AAC2.0 - + PlayReady: + SL3000: 1080p, AAC2.0 + SL2000: 1080p, AAC2.0 \b Tips: - - Input should be complete URL or series ID + - Input should be complete URL or series ID or Episode ID https://www.crunchyroll.com/series/GRMG8ZQZR/series-name OR GRMG8ZQZR + https://www.crunchyroll.com/en-US/watch/G2XU02X75/ryomen-sukuna OR G2XU02X75 - Supports multiple audio and subtitle languages - Device ID is cached for consistent authentication across runs @@ -44,30 +51,46 @@ class CR(Service): - Manages concurrent stream limits automatically """ - TITLE_RE = r"^(?:https?://(?:www\.)?crunchyroll\.com/(?:series|watch)/)?(?P[A-Z0-9]+)" + TITLE_RE = r"^(?:https?://(?:www\.)?crunchyroll\.com/(?:[a-z]{2}(?:-[a-z]{2})?/)?(?:series|watch)/)?(?P[A-Z0-9]+)" LICENSE_LOCK = Lock() MAX_CONCURRENT_STREAMS = 3 ACTIVE_STREAMS: list[tuple[str, str]] = [] - - @staticmethod - def get_session(): - return session("okhttp4") - + + # Reverse map for audio_locale to match 2 chars languages in a_lang param + LANG_MAP: dict = { + "es-LA": "es-419", + "ar-ME": "ar-SA", + "de-DE": "de", + "en-US": "en", + "es-ES": "es", + "fr-FR": "fr", + "hi-IN": "hi", + "it-IT": "it", + "ja-JP": "ja", + "ko-KR": "kr", + "ru-RU": "ru", + "zh-CN": "zh", + "en-GB": "en" + } + @staticmethod @click.command(name="CR", short_help="https://crunchyroll.com") @click.argument("title", type=str, required=True) + @click.option("-hb", "--high-audio-bitrate", is_flag=True, default=False, help="Fetch audio tracks with highest bitrate (192kbps AAC)") + @click.pass_context def cli(ctx, **kwargs) -> "CR": return CR(ctx, **kwargs) - def __init__(self, ctx, title: str): + def __init__(self, ctx, title: str, high_audio_bitrate: bool): + super().__init__(ctx) self.title = title self.account_id: Optional[str] = None self.access_token: Optional[str] = None self.token_expiration: Optional[int] = None self.anonymous_id = str(uuid.uuid4()) - - super().__init__(ctx) + self.audio_lang = ctx.parent.params.get("lang") or ctx.parent.params.get("a_lang") + self.high_audio_bitrate = high_audio_bitrate device_cache_key = "cr_device_id" cached_device = self.cache.get(device_cache_key) @@ -87,6 +110,10 @@ class CR(Service): self.session.headers.update(self.config.get("headers", {})) self.session.headers["etp-anonymous-id"] = self.anonymous_id + @staticmethod + def get_session(): + return session("okhttp4") + @property def auth_header(self) -> dict: """Return authorization header dict.""" @@ -127,11 +154,7 @@ class CR(Service): self.token_expiration = cached.data.get("token_expiration") else: if not credential: - class HardcodedCreds: - username = "akjrtx@gmail.com" - password = "Ariyan@45" - sha1 = "dummy_hash" - credential = HardcodedCreds() + raise ValueError("Username and password credential required for authentication") response = self.session.post( url=self.config["endpoints"]["token"], @@ -203,9 +226,27 @@ class CR(Service): params={"locale": self.config["params"]["locale"]}, ).json() - if "error" in series_response: - raise ValueError(f"Series not found: {series_id}") + if "code" in series_response: + # Fallback to try with episode id + episode_response = self.session.get( + url=self.config["endpoints"]["episodes"].format(episode_id=series_id), + params={"locale": self.config["params"]["locale"]}, + ).json() + if "code" in episode_response: + raise ValueError(f"Series or episode not found: {series_id}") + + # Extract series id from episodes response + series_id = episode_response["data"][0]["episode_metadata"]["series_id"] + + series_response = self.session.get( + url=self.config["endpoints"]["series"].format(series_id=series_id), + params={"locale": self.config["params"]["locale"]}, + ).json() + + if "code" in series_response: + raise ValueError(f"Series not found from episode metadata: {series_id}") + series_data = ( series_response.get("data", [{}])[0] if isinstance(series_response.get("data"), list) else series_response ) @@ -297,14 +338,16 @@ class CR(Service): def set_track_metadata(self, tracks: Tracks, episode_id: str, is_original: bool) -> None: """Set metadata for video and audio tracks.""" - for video in tracks.videos: - video.needs_repack = True - video.data["episode_id"] = episode_id - video.is_original_lang = is_original - for audio in tracks.audio: - audio.data["episode_id"] = episode_id - audio.is_original_lang = is_original + for track in tracks: + if isinstance(track, Video): + track.needs_repack = True + if isinstance(track, (Audio,Video)): + track.data["episode_id"] = episode_id + track.is_original_lang = is_original + if is_original: + track.name = f"{track.name} [Original]" + def get_tracks(self, title: Episode) -> Tracks: """Fetch video, audio, and subtitle tracks for an episode.""" self.ensure_authenticated() @@ -325,106 +368,169 @@ class CR(Service): tracks = None + #endpoints_playback = ["playback"] + + sdh_list = [] for idx, version in enumerate(versions): audio_locale = version.get("audio_locale") version_guid = version.get("guid") is_original = version.get("original", False) - if not audio_locale: + # Verify mapped audio locale to match CR locales + mapped_locale = self.LANG_MAP.get(audio_locale, audio_locale) + if self.audio_lang and ("all" in self.audio_lang or "best" in self.audio_lang or ("orig" in self.audio_lang and is_original)): + pass + elif not audio_locale or (audio_locale not in self.audio_lang and mapped_locale not in self.audio_lang): continue request_episode_id = version_guid if version_guid else episode_id - - if idx == 0 and not version_guid: - version_response = initial_response - version_token = version_response.get("token") - else: - if idx == 1 and not versions[0].get("guid"): - initial_token = initial_response.get("token") - if initial_token: - self.close_stream(episode_id, initial_token) - - try: - version_response = self.get_playback_data(request_episode_id, track_stream=False) - except ValueError as e: - self.log.warning(f"Could not get playback info for audio {audio_locale}: {e}") - continue - - version_token = version_response.get("token") - - hard_subs = version_response.get("hardSubs", {}) - dash_url = None - - if "none" in hard_subs: - dash_url = hard_subs["none"].get("url") - elif hard_subs: - first_key = list(hard_subs.keys())[0] - dash_url = hard_subs[first_key].get("url") - - if not dash_url: - self.log.warning(f"No DASH manifest found for audio {audio_locale}, skipping") - if version_token: - self.close_stream(request_episode_id, version_token) - continue - + + #for endpoint in endpoints_playback: try: - version_tracks = DASH.from_url( - url=dash_url, - session=self.session, - ).to_tracks(language=audio_locale) - - if tracks is None: - tracks = version_tracks - self.set_track_metadata(tracks, request_episode_id, is_original) + if idx == 0 and not version_guid: # and endpoint == "playback": + version_response = initial_response + version_token = version_response.get("token") else: - self.set_track_metadata(version_tracks, request_episode_id, is_original) - for video in version_tracks.videos: - tracks.add(video) - for audio in version_tracks.audio: - tracks.add(audio) + if idx == 1 and not versions[0].get("guid"): # and endpoint == "playback": + initial_token = initial_response.get("token") + if initial_token: + self.close_stream(episode_id, initial_token) - except Exception as e: - self.log.warning(f"Failed to parse DASH manifest for audio {audio_locale}: {e}") - if version_token: - self.close_stream(request_episode_id, version_token) - continue - - if is_original: - captions = version_response.get("captions", {}) - subtitles_data = version_response.get("subtitles", {}) - all_subs = {**captions, **subtitles_data} - - for lang_code, sub_data in all_subs.items(): - if lang_code == "none": + try: + version_response = self.get_playback_data(request_episode_id, track_stream=False) + except ValueError as e: + self.log.warning(f"Could not get playback info for audio {audio_locale}: {e}") continue - if isinstance(sub_data, dict) and "url" in sub_data: + version_token = version_response.get("token") + + hard_subs = version_response.get("hardSubs", {}) + dash_url = None + root_url = version_response.get("url") + + if root_url and "/clean/" in root_url: + dash_url = root_url + + elif "none" in hard_subs: + dash_url = hard_subs["none"].get("url") + + elif hard_subs: + first_key = list(hard_subs.keys())[0] + dash_url = hard_subs[first_key].get("url") + + if not dash_url: + self.log.warning(f"No DASH manifest found for audio {audio_locale}, skipping") + if version_token: + self.close_stream(request_episode_id, version_token) + continue + + try: + + version_tracks = (HLS if "m3u8" in dash_url else DASH).from_url( + url=dash_url, + session=self.session, + ).to_tracks(language=audio_locale) + + # Select high audio bitrate replacing 0 and 1 in mpd url + if self.high_audio_bitrate: try: - lang = Language.get(lang_code) - except (ValueError, LookupError): - lang = Language.get("en") + version_tracks.audio.clear(); + + high_bitrate_tracks = (HLS if "m3u8" in dash_url else DASH).from_url( + url=dash_url.replace("/0/", "/1/"), + session=self.session, + ).to_tracks(language=audio_locale) + + version_tracks.audio = high_bitrate_tracks.audio + except Exception as e: + self.log.warning(f"Failed to fetch high bitrate audio for {audio_locale}: {e}") + + if tracks is None: + tracks = version_tracks + self.set_track_metadata(tracks, request_episode_id, is_original) + else: + self.set_track_metadata(version_tracks, request_episode_id, is_original) + # Add videos + for video in version_tracks.videos: + if not any(v.id == video.id for v in tracks.videos): + tracks.add(video) + + # Add audios non-existing in tracks or with higher bitrate + for audio in version_tracks.audio: + if audio.channels == "1.0": + audio.channels = "2.0" # change audio channels for correct channel + + existing_audio = next((a for a in tracks.audio if a.language == audio.language), None) + if existing_audio is None or (hasattr(audio, 'bitrate') and hasattr(existing_audio, 'bitrate') and audio.bitrate > existing_audio.bitrate): + tracks.add(audio) + elif existing_audio is None: + tracks.add(audio) - subtitle_format = sub_data.get("format", "vtt").lower() - if subtitle_format == "ass" or subtitle_format == "ssa": - codec = Subtitle.Codec.SubStationAlphav4 - else: - codec = Subtitle.Codec.WebVTT + except Exception as e: + self.log.warning(f"Failed to parse DASH manifest for audio {audio_locale}: {e}") + if version_token: + self.close_stream(request_episode_id, version_token) + continue + #if endpoint == "playback": + # Handling subtitles code + try: + lang_audio = [x for x in version_response.get("versions", []) if x["guid"] == version_guid][0]["audio_locale"] + except (IndexError, KeyError, TypeError): + lang_audio = title.language + + captions = version_response.get("captions", {}) + subtitles_data = version_response.get("subtitles", {}) + + if captions: + for subtitle in captions.values(): + + if subtitle["language"] == "none": + continue + + lang = subtitle["language"] + subtitle_format = subtitle.get("format", "vtt").lower() + codec = Subtitle.Codec.SubStationAlphav4 if subtitle_format in ["ass", "ssa"] else Subtitle.Codec.WebVTT + sdh_list.append(lang) tracks.add( Subtitle( - id_=f"subtitle-{audio_locale}-{lang_code}", - url=sub_data["url"], + id_=md5(subtitle["url"].encode()).hexdigest()[0:6], + url=subtitle["url"], codec=codec, - language=lang, - forced=False, + language=Language.get(lang), + sdh=True, + ), + warn_only=True, + ) + + if subtitles_data: + for subtitle in subtitles_data.values(): + if subtitle["language"] == "none": + continue + + lang = subtitle["language"] + subtitle_format = subtitle.get("format", "vtt").lower() + codec = Subtitle.Codec.SubStationAlphav4 if subtitle_format in ["ass", "ssa"] else Subtitle.Codec.WebVTT + tracks.add( + Subtitle( + id_=md5(subtitle["url"].encode()).hexdigest()[0:6], + url=subtitle["url"], + codec=codec, + language=Language.get(lang), + forced=False if (str(lang_audio) == str(title.language)) and (lang not in sdh_list) else True, sdh=False, ), warn_only=True, ) - if version_token: - self.close_stream(request_episode_id, version_token) - + if version_token: + self.close_stream(request_episode_id, version_token) + + # continuar si no se pudo procesar el endpoint + except Exception as e: + self.log.warning(f"Error processing version {idx}: {e}") + continue + if versions and versions[0].get("guid"): initial_token = initial_response.get("token") if initial_token: @@ -433,15 +539,6 @@ class CR(Service): if tracks is None: raise ValueError(f"Failed to fetch any tracks for episode: {episode_id}") - for track in tracks.audio + tracks.subtitles: - if track.language: - try: - lang_obj = Language.get(str(track.language)) - base_lang = Language.get(lang_obj.language) - lang_display = base_lang.language_name() - track.name = lang_display - except (ValueError, LookupError): - pass images = title.data.get("images", {}) thumbnails = images.get("thumbnail", []) @@ -451,10 +548,65 @@ class CR(Service): thumb_index = min(7, len(thumb_variants) - 1) thumb = thumb_variants[thumb_index] if isinstance(thumb, dict) and "source" in thumb: - thumbnail_name = f"{title.name or title.title} - S{title.season:02d}E{title.number:02d}" + raw_name = f"{title.name or title.title} - S{title.season:02d}E{title.number:02d}" + thumbnail_name = self.sanitize_filename(raw_name) + tracks.add(Attachment.from_url(url=thumb["source"], name=thumbnail_name)) return tracks + def get_playready_license(self, challenge: bytes, title: Episode, track) -> bytes: + """ + Get PlayReady license for decryption. + + Creates a fresh playback session for each track, gets the license, then immediately + closes the stream. This prevents hitting the 3 concurrent stream limit. + CDN authorization is embedded in the manifest URLs, not tied to active sessions. + """ + self.ensure_authenticated() + + track_episode_id = track.data.get("episode_id", title.id) + + with self.LICENSE_LOCK: + playback_token = None + try: + playback_data = self.get_playback_data(track_episode_id, track_stream=True) + playback_token = playback_data.get("token") + + if not playback_token: + raise ValueError(f"No playback token in response for {track_episode_id}") + + track.data["playback_token"] = playback_token + + license_response = self.session.post( + url=self.config["endpoints"]["license_playready"], + params={"specConform": "true"}, + data=challenge, + headers={ + **self.auth_header, + "content-type": "application/octet-stream", + "accept": "application/octet-stream", + "x-cr-content-id": track_episode_id, + "x-cr-video-token": playback_token, + }, + ) + + if license_response.status_code != 200: + self.log.error(f"License request failed with status {license_response.status_code}") + self.log.error(f"Response: {license_response.text[:500]}") + self.close_stream(track_episode_id, playback_token) + raise ValueError(f"License request failed: {license_response.status_code}") + + self.close_stream(track_episode_id, playback_token) + return license_response.content + + except Exception: + if playback_token: + try: + self.close_stream(track_episode_id, playback_token) + except Exception: + pass + raise + def get_widevine_license(self, challenge: bytes, title: Episode, track) -> bytes: """ Get Widevine license for decryption. @@ -738,9 +890,21 @@ class CR(Service): response = self.session.get( url=self.config["endpoints"]["playback"].format(episode_id=episode_id), params={"queue": "false"}, - ).json() + ) - if "error" in response: + # Handle 429 response status code before json parsing + if response.status_code == 429: + retry_after = int(response.headers.get("Retry-After", 60)) + self.log.warning(f"Rate limited (429). Waiting {retry_after}s before retry... (attempt {attempt + 1}/{max_retries + 1})") + if attempt < max_retries: + cleared = self.clear_all_sessions() + time.sleep(retry_after) + continue + raise ValueError(f"Rate limit exceeded for episode: {episode_id}") + + response = response.json() + + if "code" in response: error_code = response.get("code", "") error_msg = response.get("message", response.get("error", "Unknown error")) @@ -775,3 +939,7 @@ class CR(Service): if not match: raise ValueError(f"Could not parse series ID from: {title_input}") return match.group("id") + + @staticmethod + def sanitize_filename(name): + return re.sub(r'[<>:"/\\|?*¿¡]', '', name).strip() \ No newline at end of file diff --git a/packages/envied/src/envied/services/CR/config.yaml b/packages/envied/src/envied/services/CR/config.yaml index 6651db7..8aee436 100644 --- a/packages/envied/src/envied/services/CR/config.yaml +++ b/packages/envied/src/envied/services/CR/config.yaml @@ -15,14 +15,17 @@ endpoints: # Content Metadata series: "https://www.crunchyroll.com/content/v2/cms/series/{series_id}" seasons: "https://www.crunchyroll.com/content/v2/cms/series/{series_id}/seasons" + episodes: "https://www.crunchyroll.com/content/v2/cms/objects/{episode_id}" season_episodes: "https://www.crunchyroll.com/content/v2/cms/seasons/{season_id}/episodes" skip_events: "https://static.crunchyroll.com/skip-events/production/{episode_id}.json" # Playback playback: "https://www.crunchyroll.com/playback/v2/{episode_id}/tv/android_tv/play" + #playback_download: "https://www.crunchyroll.com/playback/v2/{episode_id}/android/phone/download" playback_delete: "https://www.crunchyroll.com/playback/v1/token/{episode_id}/{token}" playback_sessions: "https://www.crunchyroll.com/playback/v1/sessions/streaming" license_widevine: "https://cr-license-proxy.prd.crunchyrollsvc.com/v1/license/widevine" + license_playready: "https://cr-license-proxy.prd.crunchyrollsvc.com/v1/license/playReady" # Discovery search: "https://www.crunchyroll.com/content/v2/discover/search" @@ -38,7 +41,7 @@ headers: # Query parameters params: - locale: "en-US" + locale: "es-419" # Device parameters for authentication device: diff --git a/packages/envied/src/envied/services/DSNP/README.md b/packages/envied/src/envied/services/DSNP/README.md new file mode 100644 index 0000000..fc56e90 --- /dev/null +++ b/packages/envied/src/envied/services/DSNP/README.md @@ -0,0 +1,43 @@ +# DisneyPlus(디즈니플러스) + +``` +uv run unshackle dl -vl all -al orig -sl ko,en,ja -q 2160 -v h.265 -r HDR10 DSNP entity-4d12671a-f0ad-4c3f-8526-09ae6772390b +``` + +## Information + +- Authorization: Credentials, Web Token +- Security: UHD@L1/SL3000, FHD@L1/SL3000, HD@L3/SL2000 +- Working Client Agent: AndroidTV +- Support Codec + - Video: H264, H265 + - Audio: AAC, AC3, ATMOS, DTS:X(P2:IMAX) + - Range: SDR, HDR10, DV + +## Support Args + +- `-i`, `--imax`: Prefer IMAX Enhanced version if available. +- `-r`, `--remastered-ar`: Prefer Remastered Aspect Ratio if available. +- `-e`, `--extras`: Select a extras video if available. +- `-tu`, `--tier-unlimits`: Remove stream quality restrictions for a specific account. + +## Tips + +- Configure user settings within the `envied.yaml` file. + ``` + services: + DSNP: + # Specifies the index of the profile to use. (0 = first profile, 1 = second profile, etc.) + # Automatically select a profile or language when commenting. + # Language setting is only available in languages supported by Disney+. + preferences: + profile: 0 + # language: "ko" + ``` +- To enable the tier_unlimits command by default, add the following to `envied.yaml`. + ``` + dl: + ... + DSNP: + tier_unlimits: True + ``` diff --git a/packages/envied/src/envied/services/DSNP/__init__.py b/packages/envied/src/envied/services/DSNP/__init__.py index a0aa19e..162ea17 100644 --- a/packages/envied/src/envied/services/DSNP/__init__.py +++ b/packages/envied/src/envied/services/DSNP/__init__.py @@ -11,11 +11,11 @@ from click import Context from collections.abc import Generator from datetime import datetime from http.cookiejar import CookieJar -from langcodes import Language -from pyplayready.cdm import Cdm as PlayReadyCdm -from requests import Request +from langcodes import Language, LanguageTagError from typing import Any, Optional, Union, List +from urllib.parse import urlparse, urlunparse +from envied.core.cdm.detect import is_playready_cdm from envied.core.constants import AnyTrack from envied.core.credential import Credential from envied.core.manifests import HLS @@ -28,35 +28,38 @@ from envied.core.utils.collections import as_list from . import queries + class DSNP(Service): """ - Service code for Disney+ Streaming Service (https://disneyplus.com). + Service code for Disney+ Streaming Service (https://disneyplus.com).\n + Version: 26.02.27 - Author: Made by CodeName393 with Special Thanks to narakama\n - Authorization: Credentials\n + Author: Made by CodeName393 with Special Thanks to narakama, Sam\n + Authorization: Credentials, Web Token\n Security: UHD@L1/SL3000 FHD@L1/SL3000 HD@L3/SL2000 """ ALIASES = ("DSNP", "disneyplus", "disney+") TITLE_RE = ( - r"^(?:https?://(?:www\.)?disneyplus\.com(?:/[a-z0-9-]+)?(?:/[a-z0-9-]+)?/(browse)/(?Pentity-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))(?:\?.*)?$", - r"^(?:https?://(?:www\.)?disneyplus\.com(?:/[a-z0-9-]+)?(?:/[a-z0-9-]+)?/(movies|series)/[a-z0-9-]+/)?(?P[a-zA-Z0-9-]+)(?:\?.*)?$", + r"^(?:https?://(?:www\.)?disneyplus\.com(?:/(?!browse)[a-z0-9-]+)?(?:/(?!browse)[a-z0-9-]+)?/(browse)/(?Pentity-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))(?:\?.*)?$", + r"^(?:https?://(?:www\.)?disneyplus\.com(?:/(?!browse)[a-z0-9-]+){0,2}/(play)/(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))(?:\?.*)?$", ) @staticmethod @click.command(name="DisneyPlus", short_help="https://disneyplus.com", help=__doc__) @click.argument("title", type=str) - @click.option("--imax", is_flag=True, default=False, help="Prefer IMAX Enhanced version if available.") - @click.option("--remastered-ar", is_flag=True, default=False, help="Prefer Remastered Aspect Ratio if available.") - @click.option("-ext", "--extras", is_flag=True, default=False, help="Select a extras video if available.") + @click.option("-i", "--imax", is_flag=True, default=False, help="Prefer IMAX Enhanced version if available.") + @click.option("-r", "--remastered-ar", is_flag=True, default=False, help="Prefer Remastered Aspect Ratio if available.") + @click.option("-e", "--extras", is_flag=True, default=False, help="Select a extras video if available.") + @click.option("-tu", "--tier-unlimits", is_flag=True, default=False, help="Remove stream quality restrictions for a specific account.") @click.pass_context def cli(ctx: Context, **kwargs: Any) -> DSNP: return DSNP(ctx, **kwargs) - def __init__(self, ctx: Context, title: str, imax: bool, remastered_ar: bool, extras: bool): + def __init__(self, ctx: Context, title: str, imax: bool, remastered_ar: bool, extras: bool, tier_unlimits: bool): self.title = title super().__init__(ctx) - + self.title_id = self.title for pattern in self.TITLE_RE: match = re.match(pattern, self.title) @@ -67,21 +70,14 @@ class DSNP(Service): self.prefer_imax = imax self.prefer_remastered_ar = remastered_ar self.extras = extras + self.tier_unlimits = tier_unlimits - self.vcodec = ctx.parent.params.get("vcodec") or Video.Codec.AVC - self.acodec : Audio.Codec = ctx.parent.params.get("acodec") - self.range = ctx.parent.params.get("range_") or [Video.Range.SDR] - self.quality: List[int] = ctx.parent.params.get("quality") or [1080] - self.wanted = ctx.parent.params.get("wanted") - self.audio_only = ctx.parent.params.get("audio_only") - self.subs_only = ctx.parent.params.get("subs_only") - self.chapters_only = ctx.parent.params.get("chapters_only") - + self.acodec: List[Audio.Codec] = ctx.parent.params.get("acodec") or [Audio.Codec.AAC] self.cdm = ctx.obj.cdm - self.playready = isinstance(self.cdm, PlayReadyCdm) if self.cdm else False - self.is_l3 = (self.cdm.security_level < 3000) if self.playready else (self.cdm.security_level == 3) if self.cdm else False + self.is_l3 = ((self.cdm.security_level < 3000) if is_playready_cdm(self.cdm) else (self.cdm.security_level == 3)) if self.cdm else False self.region = None + self.cache_key = None self.prod_config = {} self.account_tokens = {} self.active_session = {} @@ -90,29 +86,23 @@ class DSNP(Service): self.log.info("Preparing...") if self.is_l3: - self.vcodec = Video.Codec.AVC - self.range = [Video.Range.SDR] - self.quality = [720] - self.log.warning(" + Switched video to HD. This CDM only support HD.") + self.log.warning(" + This CDM only support HD.") + self.tier_unlimits = False else: - if self.quality > [1080] and self.range[0] == [Video.Range.SDR]: - self.range = [Video.Range.HDR10] - self.log.info(" + Switched range to HDR10. 4K resolution requires HDR.") - - if (self.range != [Video.Range.SDR] or self.quality > [1080]) and self.vcodec != Video.Codec.HEVC: - self.vcodec = Video.Codec.HEVC - self.log.info(f" + Switched video codec to H265 to be able to get {self.range[0]} dynamic range.") - - if self.acodec == Audio.Codec.DTS and not self.prefer_imax: + if Audio.Codec.DTS in self.acodec and not self.prefer_imax: self.prefer_imax = True self.log.info(" + Switched IMAX prefer. DTS audio can only be get from IMAX prefer.") + if self.tier_unlimits: + self.log.warning(" + Unlock quality limits for restricted streams") - self.session.headers.update({ - "User-Agent": self.config["bamsdk"]["user_agent"], - "Accept-Encoding": "gzip", - "Accept": "application/json", - "Content-Type": "application/json" - }) + self.session.headers.update( + { + "User-Agent": self.config["bamsdk"]["user_agent"], + "Accept-Encoding": "gzip", + "Accept": "application/json", + "Content-Type": "application/json", + } + ) ip_info = get_ip_info(self.session) country_key = None @@ -128,57 +118,65 @@ class DSNP(Service): self.log.warning(f" - The region could not be determined from IP information: {ip_info}") self.region = "US" self.log.info(f" + IP Region: {self.region} (By Default)") + if self.region in ["ID", "IN", "TH", "MY", "PH", "ZA"]: # It's not Global service. + self.log.error(" - It is not currently available in the country.", exc_info=False) + sys.exit(1) self.prod_config = self.session.get(self.config["endpoints"]["config"]).json() - self.session.headers.update({ - "X-Application-Version": self.config["bamsdk"]["application_version"], - "X-BAMSDK-Client-ID": self.config["bamsdk"]["client"], - "X-BAMSDK-Platform": self.config["device"]["platform"], - "X-BAMSDK-Version": self.config["bamsdk"]["sdk_version"], - "X-DSS-Edge-Accept": "vnd.dss.edge+json; version=2", - "X-Request-Yp-Id": self.config["bamsdk"]["yp_service_id"] - }) + self.session.headers.update( + { + "X-Application-Version": self.config["bamsdk"]["application_version"], + "X-BAMSDK-Client-ID": self.config["bamsdk"]["client"], + "X-BAMSDK-Platform": self.config["device"]["platform"], + "X-BAMSDK-Version": self.config["bamsdk"]["sdk_version"], + "X-DSS-Edge-Accept": "vnd.dss.edge+json; version=2", + "X-Request-Yp-Id": self.config["bamsdk"]["yp_service_id"], + } + ) def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: super().authenticate(cookies, credential) self.credentials = credential - if not credential: - raise EnvironmentError("Service requires Credentials for Authentication.") + if self.credentials: + self.cache_key = f"tokens_{self.region}_{self.credentials.sha1}" + else: + self.cache_key = f"tokens_{self.region}_web_session" + self.log.warning(" - Credentials not found. Attempting Web Token login.") self.log.info("Logging into Disney+...") self._login() - if self.config.get("profile") and "index" in self.config["profile"]: + if self.config.get("preferences") and "profile" in self.config["preferences"]: try: - target_profile_index = int(self.config["profile"]["index"]) + target_profile_index = int(self.config["preferences"]["profile"]) except (ValueError, TypeError, KeyError): self.log.error(" - Profile index in configuration is invalid.", exc_info=False) sys.exit(1) - profiles = self.active_session['account']['profiles'] + profiles = self.active_session["account"]["profiles"] if not 0 <= target_profile_index < len(profiles): self.log.error(f" - Invalid profile index: {target_profile_index}. Please choose between 0 and {len(profiles) - 1}.", exc_info=False) sys.exit(1) - - target_profile = profiles[target_profile_index] - active_profile_id = self.active_session['account']['activeProfile']['id'] - if target_profile['id'] != active_profile_id: + target_profile = profiles[target_profile_index] + active_profile_id = self.active_session["account"]["activeProfile"]["id"] + + if target_profile["id"] != active_profile_id: self._perform_switch_profile(target_profile, self.session.headers) self.log.info(" + Refreshing session data after profile switch...") full_account_info = self._get_account_info() self.active_session = full_account_info["activeSession"] - self.active_session['account'] = full_account_info['account'] + self.active_session["account"] = full_account_info["account"] self.log.info("Session data updated successfully.") self.log.debug(self.active_session) - if not self.active_session['isSubscriber']: + if not self.active_session["isSubscriber"]: self.log.error(" - Cannot continue, account is not subscribed to Disney+", exc_info=False) sys.exit(1) - if not self.active_session['inSupportedLocation']: + if not self.active_session["inSupportedLocation"]: self.log.error(" - Cannot continue, Not available in your Region.", exc_info=False) sys.exit(1) @@ -189,10 +187,8 @@ class DSNP(Service): self.log.debug(f" + Detected Location: {self.active_session['location']['countryCode']}") self.log.debug(f" + Supported Location: {self.active_session['inSupportedLocation']}") - active_profile_id = self.active_session['account']['activeProfile']['id'] - full_profile_object = next( - p for p in self.active_session['account']['profiles'] if p['id'] == active_profile_id - ) + active_profile_id = self.active_session["account"]["activeProfile"]["id"] + full_profile_object = next(p for p in self.active_session["account"]["profiles"] if p["id"] == active_profile_id) current_imax_setting = full_profile_object["attributes"]["playbackSettings"]["preferImaxEnhancedVersion"] self.log.info(f" + IMAX Enhanced: {current_imax_setting}") @@ -200,14 +196,27 @@ class DSNP(Service): update_tokens = self._set_imax_preference(self.prefer_imax) self._apply_new_tokens(update_tokens["token"]) - current_133_setting = full_profile_object["attributes"]["playbackSettings"]["prefer133"] # Original Aspect Ratio + current_133_setting = full_profile_object["attributes"]["playbackSettings"]["prefer133"] # Original Aspect Ratio self.log.info(f" + Remastered Aspect Ratio: {not current_133_setting}") if not current_133_setting is not self.prefer_remastered_ar: update_tokens = self._set_remastered_ar_preference(self.prefer_remastered_ar) self._apply_new_tokens(update_tokens["token"]) + current_app_lang = full_profile_object["attributes"]["languagePreferences"]["appLanguage"] + self.log.info(f" + App Language: {Language.get(current_app_lang).display_name()}") + prefe_app_lang = self.config.get("preferences", {}).get("language") + if prefe_app_lang and current_app_lang != prefe_app_lang: + try: + if Language.get(prefe_app_lang).is_valid(): + update_tokens = self._set_language_preference(prefe_app_lang) + self._apply_new_tokens(update_tokens["token"]) + else: + raise LanguageTagError() + except LanguageTagError: + self.log.warning(f" - Invalid language tag '{prefe_app_lang}' in preferences. Skipping update.") + def _login(self) -> None: - cache = self.cache.get(f"tokens_{self.region}_{self.credentials.sha1}") + cache = self.cache.get(self.cache_key) if cache: try: @@ -217,7 +226,7 @@ class DSNP(Service): bearer = self.account_tokens["accessToken"] if not bearer: raise ValueError("accessToken not found in cache") - self.session.headers.update({'Authorization': f'Bearer {bearer}'}) + self.session.headers.update({"Authorization": f"Bearer {bearer}"}) except (KeyError, ValueError, TypeError) as e: self.log.warning(f" - Cached token data is invalid or corrupted ({e}). Getting new tokens...") @@ -239,64 +248,76 @@ class DSNP(Service): self.log.info(" + Fetching session data...") full_account_info = self._get_account_info() self.active_session = full_account_info["activeSession"] - self.active_session['account'] = full_account_info['account'] + self.active_session["account"] = full_account_info["account"] self.log.info("Session data setup successfully.") def _perform_full_login(self) -> None: - android_id = secrets.token_bytes(8).hex() - drm_id = f"{base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8')}\n" - device_token = self._register_device(android_id, drm_id) + if self.credentials: + android_id = secrets.token_bytes(8).hex() + drm_id = f"{base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8')}\n" + device_token = self._register_device(android_id, drm_id) - email_status = self._check_email(self.credentials.username, device_token) + email_status = self._check_email(self.credentials.username, device_token) - if email_status.lower() != "login": - if email_status.lower() == "otp": - self.log.warning(" - Account requires OTP code login.") - self._request_otp(self.credentials.username, device_token) + if email_status.lower() != "login": + if email_status.lower() == "otp": + self.log.warning(" - Account requires OTP code login.") + self._request_otp(self.credentials.username, device_token) - otp_code = None - try: - otp_code = input("Enter a OTP code (Check email): ") - if not otp_code: - self.log.error(" - OTP code is required, but no value was entered.", exc_info=False) + otp_code = None + try: + otp_code = input("Enter a OTP code (Check email): ") + if not otp_code: + self.log.error(" - OTP code is required, but no value was entered.", exc_info=False) + sys.exit(1) + if not otp_code.isdigit(): + self.log.error(" - Invalid OTP code. Please enter only numbers.", exc_info=False) + sys.exit(1) + if len(otp_code) < 6: + self.log.error(" - OTP code is too short. Please enter at least 6 digits.", exc_info=False) + sys.exit(1) + if len(otp_code) > 6: + self.log.warning(" - OTP code is longer than 6 digits. Using the first 6 digits.") + otp_code = otp_code[:6] + except KeyboardInterrupt: + self.log.error("\n - OTP code input cancelled by user.", exc_info=False) sys.exit(1) - if not otp_code.isdigit(): - self.log.error(" - Invalid OTP code. Please enter only numbers.", exc_info=False) - sys.exit(1) - if len(otp_code) < 6: - self.log.error(" - OTP code is too short. Please enter at least 6 digits.", exc_info=False) - sys.exit(1) - if len(otp_code) > 6: - self.log.warning(" - OTP code is longer than 6 digits. Using the first 6 digits.") - otp_code = otp_code[:6] - except KeyboardInterrupt: - self.log.error("\n - OTP code input cancelled by user.", exc_info=False) + + auth_action = self._auth_action_with_otp(self.credentials.username, otp_code, device_token) + login_tokens = self._login_with_auth_action(auth_action, device_token) + + elif email_status.lower() == "register": + self.log.error(" - Account is not registered. Please register first.", exc_info=False) + sys.exit(1) + else: + self.log.error(f" - Email status is '{email_status}'. Account status verification required.", exc_info=False) sys.exit(1) - - auth_action = self._auth_action_with_otp(self.credentials.username, otp_code, device_token) - login_tokens = self._login_with_auth_action(auth_action, device_token) - elif email_status.lower() == "register": - self.log.error(" - Account is not registered. Please register first.", exc_info=False) - sys.exit(1) else: - self.log.error(f" - Email status is '{email_status}'. Account status verification required.", exc_info=False) - sys.exit(1) + login_tokens = self._login_with_password(self.credentials.username, self.credentials.password, device_token) else: - login_tokens = self._login_with_password(self.credentials.username, self.credentials.password, device_token) + try: + web_refresh_token = input("Enter a Web Refresh Token: ").strip("'\"") + login_tokens = self._refresh_token(web_refresh_token) + except KeyboardInterrupt: + self.log.error("\n - Web Refresh Token input cancelled by user.", exc_info=False) + sys.exit(1) + except Exception: + self.log.error(" - Invalid Web Refresh Token.", exc_info=False) + sys.exit(1) - temp_auth_header = {"Authorization": f'Bearer {login_tokens["token"]["accessToken"]}'} + temp_auth_header = {"Authorization": f"Bearer {login_tokens['token']['accessToken']}"} account_info = self._get_account_info(temp_auth_header) profiles = account_info["account"]["profiles"] selected_profile = None - if self.config.get("profile") and "index" in self.config["profile"]: + if self.config.get("preferences") and "profile" in self.config["preferences"]: try: - profile_index = int(self.config["profile"]["index"]) + profile_index = int(self.config["preferences"]["profile"]) if not 0 <= profile_index < len(profiles): - raise ValueError(f"Index out of range (0-{len(profiles)-1})") - + raise ValueError(f"Index out of range (0-{len(profiles) - 1})") + selected_profile = profiles[profile_index] except (ValueError, TypeError): self.log.error(" - Profile index in configuration is invalid.", exc_info=False) @@ -304,7 +325,7 @@ class DSNP(Service): else: selected_profile = next( (p for p in profiles if not p["attributes"]["kidsModeEnabled"] and not p["attributes"]["parentalControls"]["isPinProtected"]), - None + None, ) if not selected_profile: self.log.error(" - Auto-selection failed: No suitable profile found (non-kids, no PIN). Please configure a specific profile.", exc_info=False) @@ -316,39 +337,39 @@ class DSNP(Service): def _perform_switch_profile(self, target_profile: dict, auth_headers: dict) -> None: self.log.info(f" + Switching to profile: {target_profile['name']}({target_profile['id']})") - if target_profile['attributes']['kidsModeEnabled']: - self.log.error(" - Kids Profile and cannot be used.", exc_info=False) + if target_profile["attributes"]["kidsModeEnabled"]: + self.log.error(" - Kids Profile and cannot be used.", exc_info=False) sys.exit(1) profile_pin = None - if target_profile['attributes']['parentalControls']['isPinProtected']: - self.log.warning(" - This profile is PIN protected.") + if target_profile["attributes"]["parentalControls"]["isPinProtected"]: + self.log.warning(" - This profile is PIN protected.") try: profile_pin = input("Enter a profile pin: ") if not profile_pin: - self.log.error(" - PIN is required, but no value was entered.", exc_info=False) + self.log.error(" - PIN is required, but no value was entered.", exc_info=False) sys.exit(1) if not profile_pin.isdigit(): - self.log.error(" - Invalid PIN. Please enter only numbers.", exc_info=False) + self.log.error(" - Invalid PIN. Please enter only numbers.", exc_info=False) sys.exit(1) if len(profile_pin) < 4: - self.log.error(" - PIN is too short. Please enter at least 4 digits.", exc_info=False) + self.log.error(" - PIN is too short. Please enter at least 4 digits.", exc_info=False) sys.exit(1) if len(profile_pin) > 4: - self.log.warning(" - PIN is longer than 4 digits. Using the first 4 digits.") + self.log.warning(" - PIN is longer than 4 digits. Using the first 4 digits.") profile_pin = profile_pin[:4] except KeyboardInterrupt: - self.log.error("\n - PIN input cancelled by user.", exc_info=False) + self.log.error("\n - PIN input cancelled by user.", exc_info=False) sys.exit(1) - switch_profile_data = self._switch_profile(target_profile['id'], auth_headers, profile_pin) + switch_profile_data = self._switch_profile(target_profile["id"], auth_headers, profile_pin) self._apply_new_tokens(switch_profile_data["token"]) - - def _refresh(self) -> str: - cache = self.cache.get(f"tokens_{self.region}_{self.credentials.sha1}") + + def _refresh(self) -> None: + cache = self.cache.get(self.cache_key) if not cache.expired: self.log.debug(f" + Token is valid until: {datetime.fromtimestamp(cache.expiration.timestamp()).strftime('%Y-%m-%d %H:%M:%S')}") - return self.session.headers.get('Authorization', 'Bearer ').split(' ')[1] + return self.log.warning(" + Token expired. Refreshing...") try: @@ -356,31 +377,26 @@ class DSNP(Service): self._apply_new_tokens(refreshed_data["token"]) except Exception as _: raise Exception("Refresh Token Expired") - - def _apply_new_tokens(self, token_data: dict) -> str: + + def _apply_new_tokens(self, token_data: dict) -> None: self.account_tokens = token_data bearer = self.account_tokens["accessToken"] if not bearer: - self.log.error("Invalid token data: accessToken not found.", exc_info=False) - sys.exit(1) - self.session.headers.update({'Authorization': f'Bearer {bearer}'}) + raise ValueError("Invalid token data: accessToken not found.") + self.session.headers.update({"Authorization": f"Bearer {bearer}"}) - expires_in = self.account_tokens["expiresIn"] or 3600 - cache = self.cache.get(f"tokens_{self.region}_{self.credentials.sha1}") + expires_in = self.account_tokens["expiresIn"] + cache = self.cache.get(self.cache_key) cache.set(self.account_tokens, expires_in - 60) - self.log.debug(f" + New Token is valid until: {datetime.fromtimestamp(cache.expiration.timestamp()).strftime('%Y-%m-%d %H:%M:%S')}") + self.log.debug(f" + New Token is valid until: {datetime.fromtimestamp(cache.expiration.timestamp()).strftime('%Y-%m-%d %H:%M:%S')}") + return - return bearer - def search(self) -> Generator[SearchResult, None, None]: - params = {"query": self.title} - endpoint = self._href(self.prod_config["services"]["explore"]["client"]["endpoints"]["search"]["href"]) - data = self._request("GET", endpoint, params=params)["data"]["page"] - if not data.get("containers"): + data = self._get_search(self.title) + if not data["page"].get("containers"): return - - results = data["containers"][0]["items"] + results = data["page"]["containers"][0]["items"] for result in results: entity = "entity-" + result["id"] yield SearchResult( @@ -388,31 +404,31 @@ class DSNP(Service): title=result["visuals"]["title"], description=result["visuals"]["description"]["brief"], label=result["visuals"]["metastringParts"]["releaseYearRange"]["startYear"], - url=f"https://www.disneyplus.com/browse/{entity}", + url=f"https://disneyplus.com/browse/{entity}", ) def get_titles(self) -> Titles_T: - if not self.extras: - try: - content_info = self._get_deeplink(self.title_id) - content_type = content_info["data"]["deeplink"]["actions"][0]["contentType"] - except Exception as e: - try: - actions_info = self._get_deeplink_last(self.title_id) - if actions_info["data"]["deeplink"]["actions"][0]["type"] == "browse": - info_block = base64.b64decode(actions_info["data"]["deeplink"]["actions"][0]["infoBlock"]) - if b"movie" in info_block: - content_type = "movie" - elif b"series" in info_block: - content_type = "series" - else: - content_type = "other" - self.log.warning(" - The content is not standard. however, it tries to look up the data.") - except Exception as e: - self.log.error(f" - Failed to determine content type via deeplink ({e}).", exc_info=False) - sys.exit(1) - else: - content_type = "extras" + try: + if not self.title_id.startswith("entity-"): + actions_info = self._get_deeplink(self.title_id, action="playback") + self.title_id = actions_info["data"]["deeplink"]["actions"][1]["pageId"] + + if not self.extras: + actions_info = self._get_deeplink(self.title_id) + if actions_info["data"]["deeplink"]["actions"][0]["type"] == "browse": + info_block = base64.b64decode(actions_info["data"]["deeplink"]["actions"][0]["infoBlock"]) + if b"movie" in info_block: + content_type = "movie" + elif b"series" in info_block: + content_type = "series" + else: + content_type = "other" + self.log.warning(" - The content is not standard. however, it tries to look up the data.") + else: + content_type = "extras" + except Exception as e: + self.log.error(f" - Failed to determine content type via deeplink ({e}).", exc_info=False) + sys.exit(1) self.log.debug(f" + Content Type: {content_type.upper()}") page = self._get_page(self.title_id) @@ -424,31 +440,34 @@ class DSNP(Service): if content_type != "extras": playback_action = next( (x for x in page["actions"] if x["type"] == "playback"), - None + None, ) if not playback_action: - self.log.error(f" - No content is available. (Playback action not found)", exc_info=False) + self.log.error(" - No content is available. (Playback action not found)", exc_info=False) sys.exit(1) - lang_data = self._get_original_lang(playback_action["availId"]) - player_exp = lang_data["data"]["playerExperience"] + data = self._get_player_experience(playback_action["availId"]) + player_exp = data["data"]["playerExperience"] orig_lang = player_exp.get("originalLanguage") or player_exp.get("targetLanguage") or "en" - self.log.debug(f' + Original Language: {orig_lang}') - + self.log.debug(f" + Original Language: {orig_lang}") + if content_type in ("movie", "other"): - return Movies([ - Movie( - id_=page["id"], - service=self.__class__, - name=page["visuals"]["title"], - year=year, - language=Language.get(orig_lang), - data=page - ) - ]) + return Movies( + [ + Movie( + id_=page["id"], + service=self.__class__, + name=page["visuals"]["title"], + description=page["visuals"]["description"]["full"], + year=year, + language=Language.get(orig_lang), + data=page, + ) + ] + ) elif content_type == "series": return Series(self._get_series(page, year, orig_lang)) - + elif content_type == "extras": return Series(self._get_extras(page, year)) @@ -460,9 +479,9 @@ class DSNP(Service): container = next(x for x in page["containers"] if x["type"] == "episodes") season_ids = [s["id"] for s in container["seasons"]] - episodes : List[Episode] = [] + episodes: List[Episode] = [] for season_id in season_ids: - episodes_data = self._get_episodes_data(season_id) + episodes_data = self._get_episodes_data(season_id) for ep in episodes_data: if ep["type"] != "view": @@ -476,53 +495,58 @@ class DSNP(Service): season=int(ep["visuals"]["seasonNumber"]), number=int(ep["visuals"]["episodeNumber"]), name=ep["visuals"]["episodeTitle"], + description=ep["visuals"]["description"]["full"], year=year, language=Language.get(orig_lang), - data=ep + data=ep, ) ) return episodes - + def _get_extras(self, page: dict, year: int) -> Series: - extras_containers = [ - x for x in page["containers"] - if x["type"] == "set" and x["style"]["name"] == "standard_compact_list" - ] + extras_containers = [x for x in page["containers"] if x["type"] == "set" and x["style"]["name"] == "standard_compact_list"] if not extras_containers: self.log.error(" - No extras found.", exc_info=False) sys.exit(1) - extras_episodes : List[Episode] = [] + extras_episodes: List[Episode] = [] ep_count = 1 first_item = extras_containers[0]["items"][0] - first_action = next((x for x in first_item["actions"] if x["type"] in ("playback", "trailer")), None) + first_action = next( + (x for x in first_item["actions"] if x["type"] in ("playback", "trailer")), + None, + ) if first_action: - lang_data = self._get_original_lang(first_action["availId"]) - player_exp = lang_data["data"]["playerExperience"] + data = self._get_player_experience(first_action["availId"]) + player_exp = data["data"]["playerExperience"] orig_lang = player_exp.get("originalLanguage") or player_exp.get("targetLanguage") or "en" - self.log.debug(f' + Original Language: {orig_lang}') + self.log.debug(f" + Original Language: {orig_lang}") for container in extras_containers: items = container["items"] for item in items: if item["type"] == "view": - action = next((x for x in item["actions"] if x["type"] in ("playback", "trailer")), None) - + action = next( + (x for x in item["actions"] if x["type"] in ("playback", "trailer")), + None, + ) + if action: extras_episodes.append( Episode( id_=item["id"], service=self.__class__, title=page["visuals"]["title"], - season=0, # Special + season=0, # Special number=ep_count, name=item["visuals"]["title"], + description=item["visuals"]["description"]["full"], year=year, language=Language.get(orig_lang), - data=item + data=item, ) ) ep_count += 1 @@ -534,141 +558,91 @@ class DSNP(Service): return extras_episodes def get_tracks(self, title: Title_T) -> Tracks: - playback = next(x for x in title.data["actions"] if x.get("type") == "playback") + playback = next(x for x in title.data["actions"] if x["type"] == "playback") media_id = playback["resourceId"] or None if not media_id: - self.log.error(" - Failed to get media ID for playback info", exc_info=False) + self.log.error("Failed to get media ID for playback info", exc_info=False) sys.exit(1) + scenario = "ctr-regular" if self.is_l3 else "ctr-high" # cbcs-high - scenario = "ctr-regular" if self.is_l3 else "ctr-high" # cbcs-high + self._refresh() # Safe Access - self.log.debug(f"Playback Scenario: {scenario}") - self.log.debug(f"Media ID: {media_id}") + self.log.debug(f" + Playback Scenario: {scenario}") + self.log.debug(f" + Media ID: {media_id}") - self._refresh() # Safe Access - - if Video.Range.HYBRID in self.range[0] and not self.is_l3: - self.log.warning("DV+HDR Multi-range requested.") - - self.log.info(" + Fetching Dolby Vision tracks...") - tracks = self._fetch_manifest_tracks(title, media_id, scenario, ["DOLBY_VISION"]) - - self.log.info(" + Fetching HDR10 tracks...") - hdr_tracks_temp = self._fetch_manifest_tracks(title, media_id, scenario, ["HDR10"]) # HDR10PLUS - - tracks.add(hdr_tracks_temp, warn_only=True) - else: - video_ranges = [] - if not self.is_l3: - if Video.Range.DV in self.range[0]: - video_ranges = ["DOLBY_VISION"] - elif Video.Range.HDR10 in self.range[0] or Video.Range.HDR10P in self.range[0]: - video_ranges = ["HDR10"] # HDR10PLUS - - tracks = self._fetch_manifest_tracks(title, media_id, scenario, video_ranges or None) - - tracks.add(self._get_thumbnail(title)) - return self._post_process_tracks(tracks) - - def _fetch_manifest_tracks(self, title: Title_T, media_id: str, scenario: str, video_ranges: List[str] = None) -> Tracks: - attributes = { - "codecs": { - "supportsMultiCodecMaster": False, - "video": ["h.264"] - }, - "protocol": "HTTPS", - "frameRates": [60], - "assetInsertionStrategies": { - "point": "SGAI", # Server-Guided Ad Insertion - "range": "SGAI" # Server-Guided Ad Insertion - }, - "playbackInitiationContext": "ONLINE", - "slugDuration": "SLUG_500_MS", # SLUG_1000_MS, SLUG_750_MS ? - "maxSlideDuration": "4_HOUR" # 15_MIN ? - } - - if self.is_l3: - attributes["resolution"] = {"max": ["1280x720"]} - else: - attributes["resolution"] = {"max": ["3840x2160"]} - - if self.vcodec == Video.Codec.HEVC: - attributes["codecs"]["video"] = ["h.264", "h.265"] - - attributes["audioTypes"] = ["ATMOS", "DTS_X"] - - if video_ranges: - attributes["videoRanges"] = video_ranges - - payload = { - "playbackId": media_id, - "playback": { - "attributes": attributes - } - } - self.playback_data[title.id] = self._get_playback(scenario, payload) - manifest_url = self.playback_data[title.id]["sources"][0]['complete']['url'] + self.playback_data[title.id] = self._get_playback(scenario, media_id) + manifest_url = self.playback_data[title.id]["sources"][0]["complete"]["url"] + if self.tier_unlimits: + parsed_url = urlparse(manifest_url) + manifest_url = urlunparse(parsed_url._replace(query="")) # Delete tier params self.log.debug(f" + Manifest URL: {manifest_url}") - return HLS.from_url(url=manifest_url, session=self.session).to_tracks(title.language) - - def _get_thumbnail(self, title: Title_T) -> Attachment: - if type(title) == Movie: - thumbnail_id = title.data["visuals"]["artwork"]["standard"]["background"]["1.78"]["imageId"] - elif type(title) == Episode: - thumbnail_id = title.data["visuals"]["artwork"]["standard"]["thumbnail"]["1.78"]["imageId"] + tracks = HLS.from_url(url=manifest_url, session=self.session).to_tracks(title.language) + + artwork_type = "background" if isinstance(title, Movie) else "thumbnail" + thumbnail_id = title.data["visuals"]["artwork"]["standard"][artwork_type]["1.78"]["imageId"] thumbnail_url = self._href( self.prod_config["services"]["ripcut"]["client"]["endpoints"]["mainCompose"]["href"], version="v2", partnerId="disney", - imageId=thumbnail_id + imageId=thumbnail_id, ) - return Attachment.from_url(url=thumbnail_url, name=thumbnail_id, mime_type="image/png") + tracks.add(Attachment.from_url(url=thumbnail_url, name="thumbnail", description=thumbnail_id, mime_type="image/png", session=self.session)) - def _post_process_tracks(self, tracks: Tracks) -> Tracks: + return self._post_process_tracks(title, tracks) + + def _post_process_tracks(self, title: Title_T, tracks: Tracks) -> Tracks: for track in tracks: + if isinstance(track, Video) and isinstance(title, Movie): + is_imax_content = any(flag["value"] == "imax_enhanced" for flag in title.data["visuals"]["metastringParts"]["audioVisual"]["flags"]) + is_imax_video = float(self.playback_data[title.id]["attributes"]["imageAspectRatio"]) == 1.9 + if is_imax_content and is_imax_video: + track.edition = ["IMAX"] if isinstance(track, (Audio, Subtitle)): - track.name = "[Original]" if track.is_original_lang else None + track.name = track.language.display_name() + track.name += " [Original]" if track.is_original_lang else "" for audio in tracks.audio: bitrate_match = re.search(r"(?<=composite_)\d+|\d+(?=_(?:hdri|complete))|(?<=-)\d+(?=K/)", as_list(audio.url)[0]) if bitrate_match: audio.bitrate = int(bitrate_match.group()) * 1000 if audio.bitrate == 1_000_000: - audio.bitrate = 768_000 # DSNP lies about the Atmos bitrate + audio.bitrate = 768_000 # DSNP lies about the Atmos bitrate if audio.channels == 6.0: audio.channels = 5.1 - if audio.channels == 10.0: # DTS-UHD - audio.channels = "5.1.4" # Unshackle does not recommend + if audio.channels == 10.0: # DTS-UHD + audio.channels = "5.1.4" # Unshackle does not recommend audio.codec = Audio.Codec.DTS - audio.drm = None + audio.drm = None # It need HW decording - for subtitle in tracks.subtitles: - subtitle.codec = Subtitle.Codec.WebVTT + # No longer supported + tracks.audio = [audio for audio in tracks.audio if not (audio.codec == Audio.Codec.EC3 and audio.channels == 2.0)] return tracks - + def get_chapters(self, title: Title_T) -> Chapters: try: editorial = self.playback_data[title.id]["editorial"] if not editorial: return Chapters() - + LABEL_MAP = { "intro_start": "intro_start", "intro_end": "intro_end", "recap_start": "recap_start", "recap_end": "recap_end", - "FFER": "recap_start", # First Frame Episode Recap - "LFER": "recap_end", # Last Frame Episode Recap - "FFEI": "intro_start", # First Frame Episode Intro - "LFEI": "intro_end", # Last Frame Episode Intro - "FFEC": "credits_start", # First Frame End Credits - "LFEC": "lfec_marker", # Last Frame End Credits + "FFER": "recap_start", # First Frame Episode Recap + "LFER": "recap_end", # Last Frame Episode Recap + "FFEI": "intro_start", # First Frame Episode Intro + "LFEI": "intro_end", # Last Frame Episode Intro + "FFEC": "credits_start", # First Frame End Credits + "LFEC": "lfec_marker", # Last Frame End Credits + "FFCB": None, # First Frame Credits Bumper + "LFCB": None, # Last Frame Credits Bumper "up_next": None, "tag_start": None, "tag_end": None, } - + NAME_MAP = { "recap_start": "Recap", "recap_end": "Scene", @@ -688,11 +662,12 @@ class DSNP(Service): total_runtime = title.data["visuals"]["metastringParts"]["runtime"]["runtimeMs"] for group, times in grouped.items(): - if not times: continue + if not times: + continue timestamp = min(times) if "start" in group else max(times) if "end" in group else times[0] name = NAME_MAP.get(group) - + if group == "lfec_marker" and (total_runtime - timestamp) > 5000: name = "Scene" @@ -702,7 +677,7 @@ class DSNP(Service): raw_chapters.sort(key=lambda x: x[0]) unique_chapters = [] seen_ms = set() - + for ms, name in raw_chapters: if ms not in seen_ms: unique_chapters.append({"ms": ms, "name": name}) @@ -725,10 +700,10 @@ class DSNP(Service): chapters.append( Chapter( timestamp=float(time_sec), - name=chapter_title if chapter_title != "Scene" else None + name=chapter_title if chapter_title != "Scene" else None, ) ) - + return chapters except Exception as e: @@ -741,84 +716,110 @@ class DSNP(Service): return self.config["certificate"] def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]: - self._refresh() # Safe Access + self._refresh() # Safe Access endpoint = self.prod_config["services"]["drm"]["client"]["endpoints"]["widevineLicense"]["href"] headers = {"Content-Type": "application/octet-stream"} - - try: - res = self.session.post(endpoint, headers=headers, data=challenge) - res.raise_for_status() - except Exception as e: - self.log.error(f"License request failed: {e}", exc_info=False) - sys.exit(1) + res = self.session.post(endpoint, headers=headers, data=challenge) + if not res.ok: + try: + error = (d := res.json()).get("errors", [d])[0] + raise ConnectionError(error) + except (ValueError, TypeError, KeyError): + res.raise_for_status() return res.content - def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]: - self._refresh() # Safe Access + def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]: + self._refresh() # Safe Access endpoint = self.prod_config["services"]["drm"]["client"]["endpoints"]["playReadyLicense"]["href"] headers = { "Accept": "application/xml, application/vnd.media-service+json; version=2", "Content-Type": "text/xml; charset=utf-8", - "SOAPAction": "http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense" + "SOAPAction": "http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense", } - try: - res = self.session.post(endpoint, headers=headers, data=challenge) - res.raise_for_status() - except Exception as e: - self.log.error(f"License request failed: {e}", exc_info=False) - sys.exit(1) + res = self.session.post(endpoint, headers=headers, data=challenge) + if not res.ok: + try: + error = (d := res.json()).get("errors", [d])[0] + raise ConnectionError(error) + except (ValueError, TypeError, KeyError): + res.raise_for_status() return res.content - - def _get_deeplink(self, ref_id: str) -> dict: - endpoint = self._href( - self.prod_config["services"]["content"]["client"]["endpoints"]["getDeeplink"]["href"], - refIdType="deeplinkId", - refId=ref_id - ) - data = self._request("GET", endpoint) - return data - - def _get_deeplink_last(self, ref_id: str) -> dict: + + def _get_search(self, title: str): + params = {"query": title} + endpoint = self._href(self.prod_config["services"]["explore"]["client"]["endpoints"]["search"]["href"]) + data = self._request("GET", endpoint, params=params) + return data["data"] + + def _get_deeplink(self, ref_id: str, action: str = None) -> dict: endpoint = self._href(self.prod_config["services"]["explore"]["client"]["endpoints"]["getDeeplink"]["href"]) params = { - "refIdType" : "deeplinkId", - "refId" : ref_id + "refIdType": "deeplinkId", + "refId": ref_id, } - data = self._request("GET", endpoint, params=params) + if action: + params["action"] = action + + data = self._request("GET", endpoint, params=params) return data def _get_page(self, title_id: str) -> dict: - endpoint = self._href( - self.prod_config["services"]["explore"]["client"]["endpoints"]["getPage"]["href"], - pageId=title_id - ) - data = self._request("GET", endpoint, params={"disableSmartFocus": "true", "limit": 999}) + endpoint = self._href(self.prod_config["services"]["explore"]["client"]["endpoints"]["getPage"]["href"], pageId=title_id) + params = { + "disableSmartFocus": "true", + "limit": 999, + } + data = self._request("GET", endpoint, params=params) return data["data"]["page"] - def _get_original_lang(self, availId: str) -> dict: - endpoint = self._href( - self.prod_config["services"]["explore"]["client"]["endpoints"]["getPlayerExperience"]["href"], - availId=availId - ) - data = self._request("GET", endpoint) - return data - - def _get_episodes_data(self, season_id: str) -> List[dict]: - endpoint = self._href( - self.prod_config["services"]["explore"]["client"]["endpoints"]["getSeason"]["href"], - seasonId=season_id - ) - data = self._request("GET", endpoint, params={'limit': 999})["data"]["season"]["items"] + def _get_player_experience(self, availId: str) -> dict: + endpoint = self._href(self.prod_config["services"]["explore"]["client"]["endpoints"]["getPlayerExperience"]["href"], availId=availId) + data = self._request("GET", endpoint) return data - def _get_playback(self, scenario: str, payload: dict) -> dict: - endpoint = self._href( - self.prod_config["services"]["media"]["client"]["endpoints"]["mediaPayload"]["href"], - scenario=scenario - ) + def _get_episodes_data(self, season_id: str) -> List[dict]: + endpoint = self._href(self.prod_config["services"]["explore"]["client"]["endpoints"]["getSeason"]["href"], seasonId=season_id) + params = {"limit": 999} + data = self._request("GET", endpoint, params=params)["data"]["season"]["items"] + return data + + def _get_playback(self, scenario: str, media_id: str) -> dict: + attributes = { + "codecs": { + "supportsMultiCodecMaster": not self.is_l3, + "video": ["h.264"] if self.is_l3 else ["h.264", "h.265"], + }, + "protocol": "HTTPS", + "frameRates": [60], + "assetInsertionStrategies": { + "point": "SGAI", # Server-Guided Ad Insertion + "range": "SGAI", # Server-Guided Ad Insertion + }, + "playbackInitiationContext": "ONLINE", + "slugDuration": "SLUG_500_MS", # SLUG_1000_MS, SLUG_750_MS ? + "maxSlideDuration": "4_HOUR", # 15_MIN ? + "resolution": { + "max": ["1280x720"] if self.is_l3 else ["3840x2160"], + }, + **( + { + "videoRanges": ["DOLBY_VISION", "HDR10"], + "audioTypes": ["ATMOS", "DTS_X"], + } + if not self.is_l3 + else {} + ), + } + endpoint = self._href(self.prod_config["services"]["media"]["client"]["endpoints"]["mediaPayload"]["href"], scenario=scenario) headers = { "Accept": "application/vnd.media-service+json", - "X-DSS-Feature-Filtering": "true" + "X-DSS-Feature-Filtering": "true", + } + payload = { + "playbackId": media_id, + "playback": { + "attributes": attributes, + }, } data = self._request("POST", endpoint, headers=headers, payload=payload) return data["stream"] @@ -827,33 +828,33 @@ class DSNP(Service): endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["registerDevice"]["href"] headers = { "Authorization": self.config["bamsdk"]["api_key"], - "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"] + "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"], } payload = { "variables": { "registerDevice": { - "applicationRuntime": self.config["device"]["applicationRuntime"], + "applicationRuntime": self.config["device"]["applicationRuntime"], "attributes": { "osDeviceIds": [ { "identifier": android_id, - "type": "android.vendor.id" + "type": "android.vendor.id", }, { "identifier": drm_id, - "type": "android.drm.id" - } + "type": "android.drm.id", + }, ], "operatingSystem": self.config["device"]["operatingSystem"], - "operatingSystemVersion": self.config["device"]["operatingSystemVersion"] + "operatingSystemVersion": self.config["device"]["operatingSystemVersion"], }, - "deviceFamily": self.config["device"]["family"], - "deviceLanguage": self.config["device"]["deviceLanguage"], + "deviceFamily": self.config["device"]["family"], + "deviceLanguage": self.config.get("preferences", {}).get("language", "en"), "deviceProfile": self.config["device"]["profile"], "devicePlatformId": self.config["device"]["platform_id"], } }, - "query": queries.REGISTER_DEVICE + "query": queries.REGISTER_DEVICE, } data = self._request("POST", endpoint, payload=payload, headers=headers) return data["extensions"]["sdk"]["token"]["accessToken"] @@ -862,54 +863,54 @@ class DSNP(Service): endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] headers = { "Authorization": token, - "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"] + "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"], } payload = { "operationName": "check", "variables": { - "email": email + "email": email, }, - "query": queries.CHECK_EMAIL + "query": queries.CHECK_EMAIL, } data = self._request("POST", endpoint, payload=payload, headers=headers) return data["data"]["check"]["operations"][0] - + def _login_with_password(self, email: str, password: str, token: str) -> str: endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] headers = { "Authorization": token, - "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"] + "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"], } payload = { "operationName": "login", "variables": { "input": { "email": email, - "password": password + "password": password, }, "includeIdentity": True, - "includeAccountConsentToken": True + "includeAccountConsentToken": True, }, - "query": queries.LOGIN + "query": queries.LOGIN, } data = self._request("POST", endpoint, payload=payload, headers=headers) return data["extensions"]["sdk"] - + def _request_otp(self, email: str, token: str) -> dict: endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] headers = { "Authorization": token, - "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"] + "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"], } payload = { "operationName": "requestOtp", "variables": { "input": { "email": email, - "reason": "Login" - } + "reason": "Login", + }, }, - "query": queries.REQUESET_OTP + "query": queries.REQUESET_OTP, } data = self._request("POST", endpoint, payload=payload, headers=headers) if not data["data"]["requestOtp"]["accepted"]: @@ -920,56 +921,57 @@ class DSNP(Service): endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] headers = { "Authorization": token, - "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"] + "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"], } payload = { "operationName": "authenticateWithOtp", "variables": { "input": { "email": email, - "passcode": otp - } + "passcode": otp, + }, }, - "query": queries.LOGIN_OTP + "query": queries.LOGIN_OTP, } data = self._request("POST", endpoint, payload=payload, headers=headers) return data["data"]["authenticateWithOtp"]["actionGrant"] - + def _login_with_auth_action(self, auth_action: str, token: str) -> dict: endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] headers = { "Authorization": token, - "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"] + "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"], } payload = { "operationName": "loginWithActionGrant", "variables": { "input": { - "actionGrant": auth_action + "actionGrant": auth_action, }, - "includeAccountConsentToken": True + "includeAccountConsentToken": True, }, - "query": queries.LOGIN_ACTION_GRANT + "query": queries.LOGIN_ACTION_GRANT, } data = self._request("POST", endpoint, payload=payload, headers=headers) return data["extensions"]["sdk"] - + def _get_account_info(self, headers: dict = {}) -> dict: endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] headers.update({"X-BAMSDK-Platform-Id": self.config["device"]["platform_id"]}) payload = { "operationName": "me", "variables": { - "includeAccountConsentToken": True + "includeAccountConsentToken": True, }, - "query": queries.ME + "query": queries.ME, } data = self._request("POST", endpoint, payload=payload, headers=headers) return data["data"]["me"] def _switch_profile(self, profile_id: str, headers: dict, pin: str = None): profile_input = {"profileId": profile_id} - if pin: profile_input["entryPin"] = pin + if pin: + profile_input["entryPin"] = pin endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] headers.update({"X-BAMSDK-Platform-Id": self.config["device"]["platform_id"]}) @@ -978,9 +980,9 @@ class DSNP(Service): "variables": { "input": profile_input, "includeIdentity": True, - "includeAccountConsentToken": True + "includeAccountConsentToken": True, }, - "query": queries.SWITCH_PROFILE + "query": queries.SWITCH_PROFILE, } data = self._request("POST", endpoint, payload=payload, headers=headers) return data["extensions"]["sdk"] @@ -989,16 +991,16 @@ class DSNP(Service): endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["refreshToken"]["href"] headers = { "Authorization": self.config["bamsdk"]["api_key"], - "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"] + "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"], } payload = { "operationName": "refreshToken", "variables": { "refreshToken": { - "refreshToken": refresh_token - } + "refreshToken": refresh_token, + }, }, - "query": queries.REFRESH_TOKEN + "query": queries.REFRESH_TOKEN, } data = self._request("POST", endpoint, payload=payload, headers=headers) return data["extensions"]["sdk"] @@ -1015,23 +1017,23 @@ class DSNP(Service): "osDeviceIds": [ { "identifier": android_id, - "type": "android.vendor.id" + "type": "android.vendor.id", }, { "identifier": drm_id, - "type": "android.drm.id" - } - ] + "type": "android.drm.id", + }, + ], } }, - "query": queries.UPDATE_DEVICE + "query": queries.UPDATE_DEVICE, } data = self._request("POST", endpoint, payload=payload, headers=headers) if data["data"]["updateDeviceOperatingSystem"]["accepted"]: return data["extensions"]["sdk"] else: - self.log.warning(" - Failed to update Device Operating System.") + self.log.warning(" - Failed to update Device Operating System.") def _set_imax_preference(self, enabled: bool) -> str: endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] @@ -1042,17 +1044,17 @@ class DSNP(Service): "input": { "imaxEnhancedVersion": enabled, }, - "includeProfile": True + "includeProfile": True, }, "query": queries.SET_IMAX, } data = self._request("POST", endpoint, payload=payload, headers=headers) - + if data["data"]["updateProfileImaxEnhancedVersion"]["accepted"]: self.log.info(f" + Updated IMAX Enhanced preference: {enabled}") return data["extensions"]["sdk"] else: - self.log.warning(" - Failed to set IMAX preference.") + self.log.warning(" - Failed to update IMAX preference.") def _set_remastered_ar_preference(self, enabled: bool) -> str: endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] @@ -1063,35 +1065,58 @@ class DSNP(Service): "input": { "remasteredAspectRatio": enabled, }, - "includeProfile": True + "includeProfile": True, }, "query": queries.SET_REMASTERED_AR, } data = self._request("POST", endpoint, payload=payload, headers=headers) - + if data["data"]["updateProfileRemasteredAspectRatio"]["accepted"]: self.log.info(f" + Updated Remastered Aspect Ratio preference: {enabled}") return data["extensions"]["sdk"] else: - self.log.warning(" - Failed to set Remastered Aspect Ratio preference.") + self.log.warning(" - Failed to update Remastered Aspect Ratio preference.") + + def _set_language_preference(self, lang: str) -> str: + endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] + headers = {"X-BAMSDK-Platform-Id": self.config["device"]["platform_id"]} + payload = { + "operationName": "updateProfileAppLanguage", + "variables": { + "input": { + "profileId": self.active_session["account"]["activeProfile"]["id"], + "appLanguage": lang, + }, + "includeProfile": True, + }, + "query": queries.SET_APP_LANGUAGE, + } + data = self._request("POST", endpoint, payload=payload, headers=headers) + + if data["data"]["updateProfileAppLanguage"]["accepted"]: + self.log.info(f" + Updated App Language preference: {Language.get(lang).display_name()}") + return data["extensions"]["sdk"] + else: + self.log.warning(" - Failed to update App Language preference") def _href(self, href: str, **kwargs: Any) -> str: _args = {"version": self.config["bamsdk"]["explore_version"]} _args.update(**kwargs) return href.format(**_args) - + def _request(self, method: str, endpoint: str, params: dict = None, headers: dict = None, payload: dict = None) -> Any[dict | str]: _headers = self.session.headers.copy() - if headers: _headers.update(headers) - _headers.update({ - "X-BAMSDK-Transaction-ID": str(uuid.uuid4()), - "X-Request-ID": str(uuid.uuid4()) - }) + if headers: + _headers.update(headers) + _headers.update( + { + "X-BAMSDK-Transaction-ID": str(uuid.uuid4()), + "X-Request-ID": str(uuid.uuid4()), + } + ) - req = Request(method, endpoint, headers=_headers, params=params, json=payload) - prepped = self.session.prepare_request(req) try: - res = self.session.send(prepped) + res = self.session.request(method=method, url=endpoint, headers=_headers, params=params, json=payload) res.raise_for_status() data = res.json() if data.get("errors"): @@ -1113,4 +1138,4 @@ class DSNP(Service): raise e else: self.log.error(f"API Request failed: {e}", exc_info=False) - sys.exit(1) \ No newline at end of file + sys.exit(1) diff --git a/packages/envied/src/envied/services/DSNP/config.yaml b/packages/envied/src/envied/services/DSNP/config.yaml index 9540ddc..8a98da0 100644 --- a/packages/envied/src/envied/services/DSNP/config.yaml +++ b/packages/envied/src/envied/services/DSNP/config.yaml @@ -9,17 +9,17 @@ certificate: | Fp3jPq7R0qyt5P6fU5NshbLh5VKcnQvwg62BuKsdwV9u4NV36b2a546hGRl/GBneQ+QDA7NRrgITR33Sz02Oq8yJr3sy24GfZRTbtLJ4qiWkjtw== ## config ( {configVersion}/{clientId}/{deviceFamily}/{sdkVersion}/{applicationRuntime}/{deviceProfile}/{environment} ) ## -# Browser (windows, chrome) : /browser/v34.2/windows/chrome/prod.json -# Android Phone : /android/v15.0.0/google/handset/prod.json -# Android TV : /android/v15.0.0/google/tv/prod.json -# Amazon Fire TV : /android/v15.0.0/amazon/tv/prod.json +# Browser (windows, chrome) : /browser/v34.4/windows/chrome/prod.json +# Android Phone : /android/v16.0.0/google/handset/prod.json +# Android TV : /android/v16.0.0/google/tv/prod.json +# Amazon Fire TV : /android/v16.0.0/amazon/tv/prod.json endpoints: - config: "https://client-sdk-configs.bamgrid.com/bam-sdk/v7.0/disney-svod-3d9324fc/android/v15.0.0/google/tv/prod.json" + config: "https://client-sdk-configs.bamgrid.com/bam-sdk/v7.0/disney-svod-3d9324fc/android/v16.0.0/google/tv/prod.json" ## user_agent (okhttp/5.0.0-alpha.14) ## -# android-phone : BAMSDK/v15.0.1 (disney-svod-3d9324fc 4.21.1+rc3-2026.01.06.0; v7.0/v15.0.0; android; phone) -# android-tv : BAMSDK/v15.0.1 (disney-svod-3d9324fc 4.21.1+rc3-2026.01.06.0; v7.0/v15.0.0; android; tv) +# android-phone : BAMSDK/v16.0.0 (disney-svod-3d9324fc 26.0.2+rc1-2026.01.29.0; v7.0/v16.0.0; android; phone) +# android-tv : BAMSDK/v16.0.0 (disney-svod-3d9324fc 26.0.2+rc1-2026.01.29.0; v7.0/v16.0.0; android; tv) ## api_key ## # browser : ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84 @@ -30,11 +30,11 @@ endpoints: # android : 624b805dafc5c73635b1a216 bamsdk: - sdk_version: "15.0.1" - application_version: "4.21.1+rc3-2026.01.06.0" + sdk_version: "16.0.0" + application_version: "26.0.2+rc1-2026.01.29.0" explore_version: "v1.13" client: "disney-svod-3d9324fc" - user_agent: "BAMSDK/v15.0.1 (disney-svod-3d9324fc 4.21.1+rc3-2026.01.06.0; v7.0/v15.0.0; android; tv)" + user_agent: "BAMSDK/v16.0.0 (disney-svod-3d9324fc 26.0.2+rc1-2026.01.29.0; v7.0/v16.0.0; android; tv)" api_key: "ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA" yp_service_id: "624b805dafc5c73635b1a216" @@ -46,9 +46,10 @@ device: applicationRuntime: "android" operatingSystem: "Android" operatingSystemVersion: "16" - deviceLanguage: "ko" # Device language data independent of account language data # Specifies the index of the profile to use. (0 = first profile, 1 = second profile, etc.) -# Automatically select a profile when commenting. -# profile: -# index: 0 +# Automatically select a profile or language when commenting. +# Language setting is only available in languages supported by Disney+. +# preferences: +# profile: 0 +# language: "ko" diff --git a/packages/envied/src/envied/services/DSNP/queries.py b/packages/envied/src/envied/services/DSNP/queries.py index f8ec5ba..4ebf957 100644 --- a/packages/envied/src/envied/services/DSNP/queries.py +++ b/packages/envied/src/envied/services/DSNP/queries.py @@ -9,5 +9,6 @@ REGISTER_DEVICE = """mutation ($registerDevice: RegisterDeviceInput!) { register REQUESET_OTP = """mutation requestOtp($input: RequestOtpInput!) { requestOtp(requestOtp: $input) { accepted } }""" SET_IMAX = """mutation updateProfileImaxEnhancedVersion($input: UpdateProfileImaxEnhancedVersionInput!, $includeProfile: Boolean!) { updateProfileImaxEnhancedVersion(updateProfileImaxEnhancedVersion: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } }""" SET_REMASTERED_AR = """mutation updateProfileRemasteredAspectRatio($input: UpdateProfileRemasteredAspectRatioInput!, $includeProfile: Boolean!) { updateProfileRemasteredAspectRatio(updateProfileRemasteredAspectRatio: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } }""" +SET_APP_LANGUAGE = """mutation updateProfileAppLanguage($input: UpdateProfileAppLanguageInput!, $includeProfile: Boolean!) { updateProfileAppLanguage(updateProfileAppLanguage: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled isGeminiOnboarded profileLinked languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } linkedProfile { pinProtected } } }""" SWITCH_PROFILE = """mutation switchProfile($input: SwitchProfileInput!, $includeIdentity: Boolean!, $includeAccountConsentToken: Boolean!) { switchProfile(switchProfile: $input) { account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } identity @include(if: $includeIdentity) { __typename ...identityGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }""" -UPDATE_DEVICE = """mutation updateDeviceOperatingSystem($updateDeviceOperatingSystem: UpdateDeviceOperatingSystemInput!) {updateDeviceOperatingSystem(updateDeviceOperatingSystem: $updateDeviceOperatingSystem) {accepted}}""" \ No newline at end of file +UPDATE_DEVICE = """mutation updateDeviceOperatingSystem($updateDeviceOperatingSystem: UpdateDeviceOperatingSystemInput!) {updateDeviceOperatingSystem(updateDeviceOperatingSystem: $updateDeviceOperatingSystem) {accepted}}""" diff --git a/packages/envied/src/envied/services/README.md b/packages/envied/src/envied/services/README.md index 1393480..bf651bd 100644 --- a/packages/envied/src/envied/services/README.md +++ b/packages/envied/src/envied/services/README.md @@ -11,7 +11,7 @@ directories: ``` See help text for each service: -`unshackle dl SERVICE --help` +`envied dl SERVICE --help` ## Notes: Some versions of the dependencies work better than others. These are the recommended versions as of 25/11/11: diff --git a/packages/envied/src/envied/services/RTDE/__init__.py b/packages/envied/src/envied/services/RTDE/__init__.py new file mode 100644 index 0000000..789baf2 --- /dev/null +++ b/packages/envied/src/envied/services/RTDE/__init__.py @@ -0,0 +1,472 @@ +import base64 +import hashlib +import json +import os +import re +import secrets +import time +import uuid +from http.cookiejar import CookieJar +from pathlib import Path +from typing import Optional, Union +from urllib.parse import parse_qs, urlparse + +import click +from langcodes import Language + +from envied.core.config import config +from envied.core.constants import AnyTrack +from envied.core.credential import Credential +from envied.core.manifests import DASH +from envied.core.service import Service +from envied.core.session import session as create_curl_session +from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T +from envied.core.tracks import Chapter, Chapters, Subtitle, Tracks + + +class RTDE(Service): + """ + Service for RTL+ Germany (https://plus.rtl.de) + + \b + Author: Claude + Authorization: None (free) / Credential (premium) + Robustness: HD@L3 + \b + """ + + ALIASES = ("RTDE", "RTLPlus", "rtl+", "rtlplus") + GEOFENCE = ("de",) + + TITLE_RE = r"https?://(?:www\.)?plus\.rtl\.de/video-tv/(?:shows|filme|serien)/.*?-(\d+)" + + GRAPHQL_URL = "https://cdn.gateway.now-plus-prod.aws-cbc.cloud/graphql" + PLAYOUT_URL = "https://stus.player.streamingtech.de/watch-playout-variants" + AUTH_URL = "https://auth.rtl.de/auth/realms/rtlplus/protocol/openid-connect/token" + + GRAPHQL_HASHES = { + "Format": "d112638c0184ab5698af7b69532dfe2f12973f7af9cb137b9f70278130b1eafa", + "SeasonWithFormatAndEpisodes": "cc0fbbe17143f549a35efa6f8665ceb9b1cfae44b590f0b2381a9a304304c584", + "Episode": "87dbde15a0d269b11606f5ff458d555e98eb493bb4fb6ddc150d812d5e9a9cf8", + "EpisodeDetail": "2e5ef142c79f8620e8e93c8f21b31a463b16d89a557f7f5f0c4a7e063be96a8a", + } + + @staticmethod + def get_session(): + return create_curl_session() + + @staticmethod + @click.command(name="RTLPlus", short_help="https://plus.rtl.de") + @click.argument("title", type=str) + @click.option("-m", "--movie", is_flag=True, default=False, help="Specify if content is a movie.") + @click.pass_context + def cli(ctx, **kwargs): + return RTDE(ctx, **kwargs) + + def __init__(self, ctx, title: str, movie: bool = False): + self.title = title + self.movie = movie + super().__init__(ctx) + + m = re.search(self.TITLE_RE, title) + if m: + self.format_id = m.group(1) + else: + # Assume raw ID was passed + self.format_id = title.rstrip("/").split("-")[-1] + if not self.format_id.isdigit(): + self.format_id = title + + self.device_id = str(uuid.uuid4()) + self.auth_token = None + self.license_url = None + self.pr_license_url = None + + self.cache_file = Path(config.directories.cache / "RTDE" / "token.json") + + def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: + super().authenticate(cookies, credential) + + self.cache_file.parent.mkdir(parents=True, exist_ok=True) + + if credential: + self.auth_token = self._login(credential) + self.log.info("Authenticated with credentials") + else: + cached = self._load_cached_token() + if cached: + self.auth_token = cached + self.log.info("Using cached auth token") + else: + self.auth_token = self._get_anonymous_token() + self.log.info("Using anonymous token (free content only)") + + self.session.headers.update({ + "Authorization": f"Bearer {self.auth_token}", + "x-auth-token": self.auth_token, + "x-device-id": self.device_id, + "x-device-name": "Mac OS Chrome", + "x-device-type": "web", + "rtlplus-client-id": "rci:rtlplus:web", + "rtlplus-client-version": "2026.2.17.0", + }) + + def _load_cached_token(self) -> Optional[str]: + if not self.cache_file.exists(): + return None + try: + data = json.loads(self.cache_file.read_text()) + token = data.get("token") + if token and self._is_token_valid(token): + return token + except (json.JSONDecodeError, KeyError): + pass + return None + + def _save_token(self, token: str): + self.cache_file.write_text(json.dumps({"token": token}, indent=2)) + + @staticmethod + def _is_token_valid(token: str) -> bool: + try: + payload = token.split(".")[1] + "==" + data = json.loads(base64.urlsafe_b64decode(payload)) + return data.get("exp", 0) > time.time() + 60 + except Exception: + return False + + def _get_anonymous_token(self) -> str: + r = self.session.post( + self.AUTH_URL, + data={ + "grant_type": "client_credentials", + "client_id": "anonymous-user", + "client_secret": "4bfeb73f-1c4a-4e9f-a7fa-96aa1ad3d94c", + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + r.raise_for_status() + token = r.json()["access_token"] + self._save_token(token) + return token + + def _login(self, credential: Credential) -> str: + # Authorization Code Flow with PKCE + code_verifier = secrets.token_urlsafe(64) + code_challenge = base64.urlsafe_b64encode( + hashlib.sha256(code_verifier.encode()).digest() + ).rstrip(b"=").decode() + + state = str(uuid.uuid4()) + nonce = str(uuid.uuid4()) + + # Step 1: GET authorization page + auth_endpoint = "https://auth.rtl.de/auth/realms/rtlplus/protocol/openid-connect/auth" + r = self.session.get(auth_endpoint, params={ + "client_id": "rtlplus-web", + "redirect_uri": "https://plus.rtl.de/", + "state": state, + "response_mode": "query", + "response_type": "code", + "scope": "openid email profile", + "nonce": nonce, + "prompt": "login", + "code_challenge": code_challenge, + "code_challenge_method": "S256", + }, allow_redirects=True) + if not r.ok: + raise ConnectionError(f"Auth page failed: {r.status_code}") + + # Parse form action URL and hidden fields + html = r.text + action_match = re.search(r'action="([^"]+)"', html) + if not action_match: + raise ConnectionError("Could not find login form action URL") + action_url = action_match.group(1).replace("&", "&") + + # Step 2: POST login credentials + r = self.session.post( + action_url, + data={ + "username": credential.username, + "password": credential.password, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + allow_redirects=False, + ) + + # Follow redirects manually to capture the authorization code + while r.status_code in (301, 302, 303): + location = r.headers.get("Location", "") + parsed = urlparse(location) + params = parse_qs(parsed.query) + + if "code" in params: + auth_code = params["code"][0] + break + + r = self.session.get(location, allow_redirects=False) + else: + raise ConnectionError(f"Login failed: no authorization code in redirect (HTTP {r.status_code})") + + # Step 3: Exchange code for tokens + r = self.session.post( + self.AUTH_URL, + data={ + "code": auth_code, + "grant_type": "authorization_code", + "client_id": "rtlplus-web", + "redirect_uri": "https://plus.rtl.de/", + "code_verifier": code_verifier, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if not r.ok: + raise ConnectionError(f"Token exchange failed: {r.status_code} {r.text[:200]}") + + token = r.json()["access_token"] + self._save_token(token) + return token + + def _graphql(self, operation: str, variables: dict) -> dict: + r = self.session.get( + self.GRAPHQL_URL, + params={ + "operationName": operation, + "variables": json.dumps(variables), + "extensions": json.dumps({ + "persistedQuery": { + "version": 1, + "sha256Hash": self.GRAPHQL_HASHES[operation], + } + }), + }, + headers={ + "accept": "application/json", + "content-type": "application/json", + "referer": "https://plus.rtl.de/", + "origin": "https://plus.rtl.de", + }, + ) + r.raise_for_status() + data = r.json() + if "errors" in data: + raise ValueError(f"GraphQL error: {data['errors']}") + return data["data"] + + def get_titles(self) -> Titles_T: + rrn = f"rrn:watch:videohub:format:{self.format_id}" + data = self._graphql("Format", {"id": rrn}) + fmt = data["format"] + + title_name = fmt["title"] + year = fmt.get("productionYear", "") + + if self.movie: + # For movies, the format itself contains a single episode + episodes = fmt.get("episodes", []) + if episodes: + ep = episodes[0] + ep_id = ep["id"].split(":")[-1] + else: + ep_id = self.format_id + + return Movies([ + Movie( + id_=ep_id, + name=title_name, + year=year, + language="de", + service=self.__class__, + data={"rrn": f"rrn:watch:videohub:episode:{ep_id}", "format": fmt}, + ) + ]) + + # Series: iterate all seasons + seasons = fmt.get("seasons", []) + all_episodes = [] + + for season in seasons: + season_id = season["id"] + season_ordinal = season.get("ordinal", 0) + season_title_override = season.get("titleOverride") + num_episodes = season.get("numberOfEpisodes", 0) + + offset = 0 + while offset < num_episodes: + result = self._graphql("SeasonWithFormatAndEpisodes", { + "seasonId": season_id, + "offset": offset, + "limit": 50, + }) + eps = result.get("season", {}).get("episodes", []) + if not eps: + break + + for ep in eps: + ep_id = ep["id"].split(":")[-1] + ep_number = ep.get("number", 0) + ep_title = ep.get("title", "") + + all_episodes.append(Episode( + id_=ep_id, + service=self.__class__, + title=title_name, + year=year, + season=season_ordinal, + number=ep_number, + name=ep_title, + language="de", + data={ + "rrn": ep["id"], + "tier": ep.get("tier", "FREE"), + "season_title": season_title_override, + }, + )) + + offset += len(eps) + + return Series(all_episodes) + + def get_tracks(self, title: Title_T) -> Tracks: + rrn = title.data.get("rrn", f"rrn:watch:videohub:episode:{title.id}") + + r = self.session.get( + f"{self.PLAYOUT_URL}/{rrn}", + params={"platform": "web"}, + headers={ + "accept": "application/json", + "content-type": "application/json", + "origin": "https://plus.rtl.de", + "referer": "https://plus.rtl.de/", + }, + ) + r.raise_for_status() + variants = r.json() + + # Prefer HD DASH variant, fall back to SD + dash_variant = None + for name in ("dashhd", "dashsd"): + dash_variant = next((v for v in variants if v["name"] == name), None) + if dash_variant: + break + + if not dash_variant: + raise ValueError("No DASH variant found in playout response") + + # Get MPD URL (prefer MAIN source) + sources = dash_variant.get("sources", []) + mpd_url = None + subtitles_data = [] + for src in sources: + if src.get("priority") == "MAIN" or not mpd_url: + mpd_url = src["url"] + subtitles_data = src.get("subtitles", []) + + if not mpd_url: + raise ValueError("No MPD URL found in DASH variant") + + self.log.debug(f"MPD URL: {mpd_url}") + + # Store license URLs + for lic in dash_variant.get("licenses", []): + lic_type = lic.get("type", "") + lic_url = lic.get("uri", {}).get("href", "") + if lic_type == "WIDEVINE": + self.license_url = lic_url + elif lic_type == "PLAYREADY": + self.pr_license_url = lic_url + + # Parse DASH manifest + tracks = DASH.from_url(url=mpd_url, session=self.session).to_tracks(language=Language.get("de")) + + # Add subtitles from playout response + for sub in (subtitles_data or []): + sub_url = sub.get("url", "") + lang_code = sub.get("languageCode", "deu") + output_set = sub.get("outputSet", "") + + if output_set == "WEBVTT_TEXT": + codec = Subtitle.Codec.WebVTT + elif output_set == "TTML_XML": + codec = Subtitle.Codec.TimedTextMarkupLang + else: + continue + + tracks.add(Subtitle( + id_=hashlib.md5(sub_url.encode()).hexdigest()[:6], + url=sub_url, + codec=codec, + language=lang_code, + is_original_lang=True, + )) + + return tracks + + def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Union[bytes, str]: + if not self.license_url: + raise ValueError("No Widevine license URL available") + + r = self.session.post( + self.license_url, + data=challenge, + headers={ + "x-auth-token": self.auth_token, + "x-device-id": self.device_id, + "x-device-name": "Mac OS Chrome", + "origin": "https://plus.rtl.de", + "referer": "https://plus.rtl.de/", + }, + ) + if not r.ok: + raise ConnectionError(f"License request failed: HTTP {r.status_code} {r.text[:200]}") + return r.content + + def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Union[bytes, str]: + if not self.pr_license_url: + raise ValueError("No PlayReady license URL available") + + if isinstance(challenge, bytes): + challenge = challenge.decode("utf-8", errors="replace") + # Strip anything before the SOAP envelope + soap_idx = challenge.find("= 0: + challenge = challenge[soap_idx:] + + r = self.session.post( + self.pr_license_url, + data=challenge, + headers={ + "Content-Type": "text/xml; charset=utf-8", + "x-auth-token": self.auth_token, + "x-device-id": self.device_id, + "x-device-name": "Mac OS Chrome", + "origin": "https://plus.rtl.de", + "referer": "https://plus.rtl.de/", + }, + ) + if not r.ok: + raise ConnectionError(f"PlayReady license request failed: HTTP {r.status_code} {r.text[:200]}") + return r.content + + def get_widevine_service_certificate(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]: + if not self.license_url: + return None + + r = self.session.post( + self.license_url, + data=challenge, + headers={ + "x-auth-token": self.auth_token, + "x-device-id": self.device_id, + "x-device-name": "Mac OS Chrome", + "origin": "https://plus.rtl.de", + "referer": "https://plus.rtl.de/", + }, + ) + if not r.ok: + return None + return r.content + + def get_chapters(self, title: Title_T) -> Chapters: + return Chapters() diff --git a/packages/envied/src/envied/services/RTDE/config.yaml b/packages/envied/src/envied/services/RTDE/config.yaml new file mode 100644 index 0000000..0240f6a --- /dev/null +++ b/packages/envied/src/envied/services/RTDE/config.yaml @@ -0,0 +1,12 @@ +headers: + user-agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + +graphql_url: "https://cdn.gateway.now-plus-prod.aws-cbc.cloud/graphql" +playout_url: "https://stus.player.streamingtech.de/watch-playout-variants" +auth_url: "https://auth.rtl.de/auth/realms/rtlplus" + +graphql_hashes: + Format: "d112638c0184ab5698af7b69532dfe2f12973f7af9cb137b9f70278130b1eafa" + SeasonWithFormatAndEpisodes: "cc0fbbe17143f549a35efa6f8665ceb9b1cfae44b590f0b2381a9a304304c584" + Episode: "87dbde15a0d269b11606f5ff458d555e98eb493bb4fb6ddc150d812d5e9a9cf8" + EpisodeDetail: "2e5ef142c79f8620e8e93c8f21b31a463b16d89a557f7f5f0c4a7e063be96a8a" diff --git a/packages/envied/src/envied/services/UNXT/README.md b/packages/envied/src/envied/services/UNXT/README.md new file mode 100644 index 0000000..1f2a089 --- /dev/null +++ b/packages/envied/src/envied/services/UNXT/README.md @@ -0,0 +1,19 @@ +# U-NEXT(유넥스트) + +``` +uv run unshackle dl --list -vl all -al orig -sl all -q 2160 -v h.265 -r HDR10 UNXT SID0087607 +``` + +## Information + +- Authorization: Credentials +- Security: UHD@L3/SL2000, FHD@L3/SL2000 +- Working Client Agent: AndroidTV +- Support Codec + - Video: H264, H265 + - Audio: AAC, AC3, ATMOS + - Range: SDR, HDR10, DV + +## Support Args + +- `-t`, `--type`: Prefer subtitle or dubbing version for content type if available. diff --git a/packages/envied/src/envied/services/UNXT/__init__.py b/packages/envied/src/envied/services/UNXT/__init__.py new file mode 100644 index 0000000..1932d27 --- /dev/null +++ b/packages/envied/src/envied/services/UNXT/__init__.py @@ -0,0 +1,662 @@ +from __future__ import annotations + +import click +import re +import sys +import time +import uuid + +from click import Context +from collections.abc import Generator +from datetime import datetime +from http.cookiejar import CookieJar +from langcodes import Language +from pathlib import Path +from requests import Request +from typing import Any, Optional, Union, List, Literal +from urllib.parse import urlparse, parse_qs, urlencode + +from envied.core.cdm.detect import is_playready_cdm +from envied.core.constants import AnyTrack +from envied.core.credential import Credential +from envied.core.manifests import DASH, ISM +from envied.core.search_result import SearchResult +from envied.core.service import Service +from envied.core.titles import Title_T, Titles_T, Episode, Movie, Movies, Series +from envied.core.tracks import Chapters, Tracks, Video, Audio, Subtitle, Attachment, Chapter +from envied.core.utilities import get_ip_info + + +class UNXT(Service): + """ + Service code for U-NEXT Streaming Service (https://video.unext.jp).\n + Version: 26.03.01 + + Author: Made by CodeName393 with Special Thanks to narakama\n + Authorization: Credentials\n + Security: UHD@L3/SL2000, FHD@L3/SL2000 + """ + + ALIASES = ("UNXT", "UNEXT", "U-NEXT") + GEOFENCE = ("JP",) + TITLE_RE = (r"https?://(?:[\w-]+\.)?unext\.jp/.*?(?:/|td=)(?PSID[0-9]+)",) + + @staticmethod + @click.command(name="U-NEXT", short_help="https://video.unext.jp", help=__doc__) + @click.argument("title", type=str) + @click.option( + "-t", + "--type", + type=click.Choice(["sub", "dub"], case_sensitive=False), + default="sub", + help="Prefer subtitle or dubbing version for content type if available.", + ) + @click.pass_context + def cli(ctx: Context, **kwargs: Any) -> UNXT: + return UNXT(ctx, **kwargs) + + def __init__(self, ctx: Context, title: str, type: Literal["sub", "dub"]): + self.title = title + super().__init__(ctx) + + self.title_id = self.title + for pattern in self.TITLE_RE: + match = re.match(pattern, self.title) + if match: + self.title_id = match.group("id") + break + + self.dub_type = type + self.cdm = ctx.obj.cdm + + self.device_id = None + self.account_tokens = {} + self.active_session = {} + self.playback_data = {} + + self.log.info("Preparing...") + + ip_info = get_ip_info(self.session) + country_key = None + possible_keys = ["countryCode", "country", "country_code", "country-code"] + for key in possible_keys: + if key in ip_info: + country_key = key + break + if country_key: + region = str(ip_info[country_key]).upper() + self.log.info(f" + IP Region: {region}") + else: + self.log.warning(f" - The region could not be determined from IP information: {ip_info}") + region = "US" + self.log.info(f" + IP Region: {region} (By Default)") + if region != "JP": + self.log.error(" - It is not currently available in the country.", exc_info=False) + sys.exit(1) + + self.session.headers.update( + { + "User-Agent": self.config["device"]["user_agent"], + "Content-Type": "application/json; charset=utf-8", + "Accept-Encoding": "gzip", + } + ) + + def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: + super().authenticate(cookies, credential) + self.credentials = credential + if not credential: + raise EnvironmentError("Service requires Credentials for Authentication.") + + self.log.info("Logging into U-NEXT...") + self._login() + + self.log.debug(self.active_session) + self.log.info(f" + Account ID: {self.active_session['cuid']}") + self.log.info(f" + Platform ID: {self.active_session['pfid']}") + self.log.info(f" + Subscribed: {self.active_session['contractStatusCode'] == 'ACTIVE'}") + + def _login(self) -> None: + cache = self.cache.get(f"tokens_{self.credentials.sha1}") + + if cache: + try: + self.log.info(" + Using cached tokens...") + self.account_tokens = cache.data.get("tokens", {}) + self.device_id = cache.data.get("device_id") + + required_keys = ["userToken", "userTokenExpire", "securityToken"] + if not self.account_tokens or not all(k in self.account_tokens for k in required_keys) or not self.device_id: + raise ValueError("Invalid token data in cache") + + except (KeyError, ValueError, TypeError) as e: + self.log.warning(f" - Cached token data is invalid or corrupted ({e}). Getting new tokens...") + self._perform_full_login() + + try: + self._refresh() + except Exception as e: + self.log.warning(f" - Failed to refresh token ({e}). Getting new tokens...") + self._perform_full_login() + + else: + self.log.info(" + Getting new tokens...") + self._perform_full_login() + + self.log.info(" + Fetching session data...") + self.active_session = self._get_account_info() + self.log.info("Session data setup successfully.") + + def _perform_full_login(self) -> None: + if not self.device_id: + self.device_id = str(uuid.uuid4()) + + user_info = self._login_with_password(self.credentials.username, self.credentials.password) + token_data = { + "userToken": user_info["userToken"], + "userTokenExpire": user_info["userTokenExpire"], + "securityToken": user_info["securityToken"], + } + self._apply_new_tokens(token_data) + + def _refresh(self) -> None: + cache = self.cache.get(f"tokens_{self.credentials.sha1}") + if not cache.expired: + self.log.debug(f" + Token is valid until: {datetime.fromtimestamp(cache.expiration.timestamp()).strftime('%Y-%m-%d %H:%M:%S')}") + return + + self.log.warning(" + Token expired. Refreshing...") + try: + user_info = self._refresh_token() + token_data = { + "userToken": user_info["userToken"], + "userTokenExpire": user_info["userTokenExpire"], + "securityToken": user_info["securityToken"], + } + self._apply_new_tokens(token_data) + + except Exception as e: + self.log.error(f"Refresh failed: {e}.", exc_info=False) + raise e + + def _apply_new_tokens(self, token_data: dict) -> None: + self.account_tokens = token_data + + expire_time = token_data["userTokenExpire"] + valid_duration = max(0, expire_time - int(time.time())) + + cache_data = { + "tokens": self.account_tokens, + "device_id": self.device_id, + } + cache = self.cache.get(f"tokens_{self.credentials.sha1}") + cache.set(cache_data, valid_duration - 60) + self.log.debug(f" + New Token is valid until: {datetime.fromtimestamp(cache.expiration.timestamp()).strftime('%Y-%m-%d %H:%M:%S')}") + + def search(self) -> Generator[SearchResult, None, None]: + data = self._get_search(self.title) + if not data["title_list"]: + return + for result in data["title_list"]: + year = result["production_year"] + if not year: + year = result["since_year"] + + yield SearchResult( + id_=result["title_code"], + title=result["title_name"], + description=result["catchphrase"], + label=year, + url=f"https://video.unext.jp/title/{result['title_code']}", + ) + + def get_titles(self) -> Titles_T: + stage_data = self._get_stage_data(self.title_id) + + content_type = stage_data["media_type_code"] + self.log.debug(f" + Content Type: {content_type.upper()}") + + if content_type in ["MOVIE", "VIDEO", "OV"]: + year = stage_data["production_year"] + if not year: + year = stage_data["since_year"] + + movie_data = self._get_movie_data(self.title_id) + return Movies( + [ + Movie( + id_=movie_data["episode"]["episode_code"], + service=self.__class__, + name=stage_data["title_name"], + description=stage_data["story"], + year=year, + language=Language.get("en"), + data=movie_data["episode"], + ) + ] + ) + + elif content_type == "TV": + return Series(self._get_series(stage_data)) + + else: + self.log.error(f" - Unsupported content type: {content_type}", exc_info=False) + sys.exit(1) + + def _get_series(self, stage_data: dict) -> Series: + title_relation = self._get_title_relation(self.title_id) + + all_seasons = {} + special_keywords = ["メイキング", "making", "special", "特典", "映像"] + + current_year = stage_data["production_year"] + if not current_year: + current_year = stage_data["since_year"] + + title_name_main = stage_data["title_name"] + is_special_main = any(k in title_name_main.lower() for k in special_keywords) + + raw_order_main = int(stage_data["series_in_order"]) + if is_special_main: + order_main = 0 + else: + order_main = raw_order_main if raw_order_main > 0 else 1 + + series_name = stage_data["series_name"] + if not series_name: + series_name = title_name_main + + all_seasons[self.title_id] = { + "order": order_main, + "name": series_name, + "year": current_year, + } + + if "relation" in title_relation: + for rel_group in title_relation["relation"]: + if rel_group["groupcode"] != stage_data["series_code"]: + continue + + for item in rel_group["grouplist"]: + if item["media_type_code"] != "TV": + continue + + item_year = item["production_year"] + if not item_year: + item_year = item["since_year"] + + title_name_sub = item["title_name"] + is_special_sub = any(k in title_name_sub.lower() for k in special_keywords) + + raw_order_sub = int(item["series_in_order"]) + if is_special_sub: + order_sub = 0 + else: + order_sub = raw_order_sub if raw_order_sub > 0 else 1 + + all_seasons[item["title_code"]] = { + "order": order_sub, + "name": item["series_name"], + "year": item_year, + } + + sorted_season_ids = sorted(all_seasons.keys(), key=lambda x: all_seasons[x]["order"]) + + episodes: List[Episode] = [] + regular_season_idx = 1 + special_episode_idx = 1 + + for season_id in sorted_season_ids: + season_info = all_seasons[season_id] + + if season_info["order"] == 0: + current_season_number = 0 + else: + current_season_number = regular_season_idx + regular_season_idx += 1 + + episode_list_data = self._get_episode_list(season_id) + for idx, episode in enumerate(episode_list_data["items"], start=1): + if current_season_number == 0: + episode_number = special_episode_idx + special_episode_idx += 1 + else: + episode_number = idx + + episodes.append( + Episode( + id_=episode["episode_code"], + service=self.__class__, + title=season_info["name"], + season=current_season_number, + number=episode_number, + name=episode["episode_name"], + description=episode["introduction"], + year=season_info["year"], + language=Language.get("en"), + data=episode, + ) + ) + + return episodes + + def get_tracks(self, title: Title_T) -> Tracks: + self._refresh() # Safe Access + + play_mode = "caption" + if title.data["has_dub"] and self.dub_type == "dub": + play_mode = "dub" + title.language = Language.get("ja") + + fetched_ranges = set() + + def _fetch_variant(title: Title_T, codec: Optional[Video.Codec], range_: Video.Range) -> Tracks: + if codec == Video.Codec.AVC and range_ in (Video.Range.DV, Video.Range.HDR10, Video.Range.HDR10P): + return Tracks() + nonlocal fetched_ranges + + video_ranges = ["SDR"] + if range_ == Video.Range.DV: + video_ranges = ["VISION"] + elif range_ in (Video.Range.HDR10, Video.Range.HDR10P): + video_ranges = ["HDR10"] + + range_key = video_ranges[0] + if range_key in fetched_ranges: + return Tracks() + fetched_ranges.add(range_key) + + self.log.debug(f"Fetching {range_.name} manifest...") + return self._fetch_manifest_tracks(title, video_ranges, play_mode) + + tracks = self._get_tracks_for_variants(title, _fetch_variant) + + if thumb := title.data.get("thumbnail"): + url = f"https://{thumb['standard']}" + description = Path(url).stem + tracks.add(Attachment.from_url(url=url, name="thumbnail", description=description, session=self.session)) + + for track in tracks: + if isinstance(track, (Audio, Subtitle)): + track.name = track.language.display_name() + track.name += " [Original]" if track.is_original_lang else "" + + for audio in tracks.audio: + if audio.codec == Audio.Codec.EC3 and audio.bitrate > 448_000: + audio.joc = 16 # U-NEXT Bug... + + return tracks + + def _fetch_manifest_tracks(self, title: Title_T, video_ranges: List[str], play_mode: str) -> Tracks: + self.playback_data[title.id] = self._get_playlist(title.id, video_ranges, play_mode) + + if (res_code := self.playback_data[title.id]["result_status"]) != 200: + self.log.debug(f" - Get stream error({res_code})") + return Tracks() + + movie_profile = self.playback_data[title.id]["url_info"][0]["movie_profile"] + target_drm_key = "playready" if is_playready_cdm(self.cdm) else "widevine" + priority_protocols = ["dash", "smooth"] + + selected_protocol = next( + (proto for proto in priority_protocols if proto in movie_profile and target_drm_key in movie_profile[proto].get("license_url_list", {})), + None, + ) + + if not selected_protocol: + self.log.debug(f" - Unable to handle with invalid DRM type. {target_drm_key} info not found or license_url is empty in response.") + return Tracks() + + self.playback_data[title.id]["protocol"] = selected_protocol + base_url = movie_profile[selected_protocol].get("playlist_url") + u = urlparse(base_url) + query = parse_qs(u.query) + query["play_token"] = self.playback_data[title.id]["play_token"] + new_query = urlencode(query, doseq=True) + manifest_url = u._replace(query=new_query).geturl() + + self.log.debug(f" + Manifest URL: {manifest_url}") + if ".mpd/" in manifest_url: + tracks = DASH.from_url(url=manifest_url, session=self.session).to_tracks(title.language) + elif ".ism/manifest" in manifest_url: + tracks = ISM.from_url(url=manifest_url, session=self.session).to_tracks(title.language) + else: + self.log.debug(" - Manifest type cannot be handled.") + return Tracks() + + for video in tracks.videos: + if video.codec == Video.Codec.HEVC and "HDR10" in video_ranges: + video.range = Video.Range.HDR10 # U-NEXT Bug... + + return tracks + + def get_chapters(self, title: Titles_T) -> Chapters: + url_info = self.playback_data[title.id]["url_info"][0] + endroll_start_position = float(url_info["endroll_start_position"]) + movie_parts_list = url_info["movie_parts_position_list"] + + pre_chapter = [] + + has_intro = False + has_credits = False + + if movie_parts_list: + for part in movie_parts_list: + part_type = part.get("movie_parts_type") + start = float(part.get("from", 0)) + end = float(part.get("to", 0)) + + chapter_name = "Scene" + if part_type == "Opening": + if not has_intro: + chapter_name = "Intro" + has_intro = True + elif part_type == "Ending": + if not has_credits: + chapter_name = "Credits" + has_credits = True + + pre_chapter.append((chapter_name, start)) + + if end > start: + pre_chapter.append(("Scene", end)) + + if endroll_start_position > 0: + is_duplicate = any(abs(t - endroll_start_position) < 1.0 for _, t in pre_chapter) + + if not is_duplicate: + if has_credits: + pre_chapter.append(("Scene", endroll_start_position)) + else: + pre_chapter.append(("Credits", endroll_start_position)) + + pre_chapter.sort(key=lambda x: x[1]) + + if not pre_chapter or pre_chapter[0][1] > 0: + pre_chapter.insert(0, ("Scene", 0.0)) + + if len(pre_chapter) == 1 and pre_chapter[0] == ("Scene", 0): + return [] + + chapters: List[Chapter] = [] + for i, (chapter_title, time_sec) in enumerate(pre_chapter): + chapters.append( + Chapter( + timestamp=float(time_sec), + name=chapter_title if chapter_title != "Scene" else None, + ) + ) + + return chapters + + def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]: + protocol = self.playback_data[title.id]["protocol"] + license_url = self.playback_data[title.id]["url_info"][0]["movie_profile"][protocol]["license_url_list"]["widevine"] + params = {"play_token": self.playback_data[title.id]["play_token"]} + headers = {"Content-Type": "application/octet-stream"} + res = self.session.post(license_url, params=params, headers=headers, data=challenge) + res.raise_for_status() + return res.content + + def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]: + protocol = self.playback_data[title.id]["protocol"] + license_url = self.playback_data[title.id]["url_info"][0]["movie_profile"][protocol]["license_url_list"]["playready"] + params = {"play_token": self.playback_data[title.id]["play_token"]} + headers = { + "Accept": "application/xml", + "Content-Type": "text/xml; charset=utf-8", + } + res = self.session.post(license_url, params=params, headers=headers, data=challenge) + res.raise_for_status() + return res.content + + def _get_search(self, title: str) -> dict: + endpoint = self.config["endpoints"]["search"] + data_payload = { + "searchString": title, + "numParPage": 32, + "pageNo": 1, + "order": "recommend", + "film_rating_code": "R15", + "need_fulldata": 1, + } + payload = self._build_payload( + data_payload=data_payload, + user_token=self.account_tokens["userToken"], + ) + data = self._request("POST", endpoint, payload=payload) + return data["data"] + + def _get_stage_data(self, title_id: str) -> dict: + endpoint = self.config["endpoints"]["stage"] + data_payload = {"title_code": title_id} + payload = self._build_payload( + data_payload=data_payload, + user_token=self.account_tokens["userToken"], + ) + data = self._request("POST", endpoint, payload=payload) + return data["data"] + + def _get_movie_data(self, title_id: str) -> dict: + endpoint = self.config["endpoints"]["movie"] + data_payload = {"title_code": title_id} + payload = self._build_payload( + data_payload=data_payload, + user_token=self.account_tokens["userToken"], + ) + data = self._request("POST", endpoint, payload=payload) + return data["data"] + + def _get_title_relation(self, title_id: str) -> dict: + endpoint = self.config["endpoints"]["relation"] + data_payload = {"title_code": title_id} + payload = self._build_payload( + data_payload=data_payload, + user_token=self.account_tokens["userToken"], + ) + data = self._request("POST", endpoint, payload=payload) + return data["data"] + + def _get_episode_list(self, season_id: str) -> dict: + endpoint = self.config["endpoints"]["episode"] + data_payload = { + "title_code": season_id, + "page_size": 1000, + "page_number": 1, + } + payload = self._build_payload( + data_payload=data_payload, + user_token=self.account_tokens["userToken"], + ) + data = self._request("POST", endpoint, payload=payload) + return data["data"] + + def _get_playlist(self, title_id: str, video_ranges: List[str], play_mode: str) -> dict: + data_payload = { + "code": title_id, + "bitrate_low": 192, + "play_type": 2, + "play_mode": play_mode, + "keyonly_flg": 0, + "validation_flg": 0, + "codec": ["H264", "H265"], + "dynamic_range_list": video_ranges, + "audio_type_list": ["ac-3", "ec-3", "mp4a"], + } + endpoint = self.config["endpoints"]["playlist"] + payload = self._build_payload( + data_payload=data_payload, + user_token=self.account_tokens["userToken"], + ) + data = self._request("POST", endpoint, payload=payload) + return data["data"] + + def _login_with_password(self, username: str, password: str) -> dict: + endpoint = self.config["endpoints"]["auth"] + data_payload = { + "loginId": username, + "password": password, + } + payload = self._build_payload(data_payload=data_payload) + data = self._request("POST", endpoint, payload=payload) + return data["common"]["userInfo"] + + def _get_account_info(self) -> dict: + endpoint = self.config["endpoints"]["account"] + payload = self._build_payload( + data_payload={}, + user_token=self.account_tokens["userToken"], + security_token=self.account_tokens["securityToken"], + ) + data = self._request("POST", endpoint, payload=payload) + return data["common"]["userInfo"] + + def _refresh_token(self) -> dict: + endpoint = self.config["endpoints"]["auth"] + data_payload = {"securityToken": self.account_tokens["securityToken"]} + payload = self._build_payload( + data_payload=data_payload, + user_token=self.account_tokens["userToken"], + ) + data = self._request("POST", endpoint, payload=payload) + return data["common"]["userInfo"] + + def _build_payload(self, data_payload: dict, user_token: str = "", security_token: str = None) -> dict: + user_info = { + "userToken": user_token, + "service_name": "unext", + } + if security_token: + user_info["securityToken"] = security_token + device_info = { + "deviceType": "980", + "appVersion": "1", + "deviceUuid": self.device_id, + } + payload = { + "common": { + "userInfo": user_info, + "deviceInfo": device_info, + }, + "data": data_payload, + } + return payload + + def _request(self, method: str, endpoint: str, params: dict = None, headers: dict = None, payload: dict = None) -> Any[dict | str]: + _headers = self.session.headers.copy() + if headers: + _headers.update(headers) + + req = Request(method, endpoint, headers=_headers, params=params, json=payload) + prepped = self.session.prepare_request(req) + + try: + res = self.session.send(prepped) + res.raise_for_status() + data = res.json() + if error_code := data["common"]["result"]["errorCode"]: + raise ConnectionError(f"{error_code}: {data['common']['result']['errorMessage']}") + return data + except Exception as e: + self.log.error(f"API Request failed: {e}", exc_info=False) + sys.exit(1) diff --git a/packages/envied/src/envied/services/UNXT/config.yaml b/packages/envied/src/envied/services/UNXT/config.yaml new file mode 100644 index 0000000..6006dc6 --- /dev/null +++ b/packages/envied/src/envied/services/UNXT/config.yaml @@ -0,0 +1,12 @@ +endpoints: + auth: "https://napi.unext.jp/1/auth/login" + account: "https://napi.unext.jp/2/user/account/get" + search: "https://napi.unext.jp/1/cmscommon/freeword/video" + stage: "https://napi.unext.jp/1/cmsuser/stage" + movie: "https://napi.unext.jp/1/cmsuser/episode/current" + relation: "https://napi.unext.jp/1/cmsuser/titledetail/relation" + episode: "https://napi.unext.jp/1/cmsuser/episode" + playlist: "https://napi.unext.jp/3/cmsuser/playlisturl/file" + +device: + user_agent: "U-NEXT TV App Android16 5.61.0 AOSP TV" diff --git a/packages/vinefeeder/src/vinefeeder/services/BBC/config.yaml.save b/packages/vinefeeder/src/vinefeeder/services/BBC/config.yaml.save new file mode 100644 index 0000000..a478802 --- /dev/null +++ b/packages/vinefeeder/src/vinefeeder/services/BBC/config.yaml.save @@ -0,0 +1,22 @@ +service: BBC +loader_class: BbcLoader + +description: A UK Public Sevice broadcaster + +hlg_status: True + +options: -q 720 + +media_dict: + Film: 'https://www.bbc.co.uk/iplayer/categories/films/featured' + Documentary: 'https://www.bbc.co.uk/iplayer/categories/documentaries/featured' + Comedy: 'https://www.bbc.co.uk/iplayer/categories/comedy/featured' + Drama: 'https://www.bbc.co.uk/iplayer/categories/drama-and-soaps/featured' + Entertainment: 'https://www.bbc.co.uk/iplayer/categories/entertainment/featured' + Lifestyle: 'https://www.channel4.com/categories/lifestyle' + News: 'https://www.bbc.co.uk/iplayer/categories/news/featured' + Sport: 'https://www.bbc.co.uk/iplayer/categories/sport/featured' + Lifestyle: 'https://www.bbc.co.uk/iplayer/categories/lifestyle/featured' + History: 'https://www.bbc.co.uk/iplayer/categories/history/featured' + Science/Nature: 'https://www.bbc.co.uk/iplayer/categories/science-and-nature/featured' + diff --git a/uv.lock b/uv.lock index cec7fe2..e370008 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.10, <3.13" +requires-python = ">=3.10, <=3.13" [manifest] members = [ @@ -93,6 +93,23 @@ wheels = [ { 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" }, + { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, + { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, + { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, ] [[package]] @@ -117,13 +134,27 @@ version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] 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, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "animeapi-py" +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "dacite" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/a2/f868bba3b6399e2c953f8796fa90c8de91f797ad0fe19c7e2c536cd0092f/animeapi_py-3.8.1.tar.gz", hash = "sha256:9ed10f8207f1ccd7949ee3728285f4aced9a5ec57f71d211594ca5dc356fdea5", size = 25663, upload-time = "2026-02-25T15:29:17.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/98/6775d71cf7d88d554e8394f5ce5cda90041c99fdf1b2b60af02001e8c790/animeapi_py-3.8.1-py3-none-any.whl", hash = "sha256:c29f6e633d17bb613f459aa6514c0baab7ae325881f8a109eb6e4b3be5c22827", size = 26983, upload-time = "2026-02-25T15:29:16.685Z" }, +] + [[package]] name = "anyio" version = "4.11.0" @@ -132,7 +163,7 @@ dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "sniffio" }, - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } wheels = [ @@ -259,6 +290,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/63/1c1585b2aa554fe6dbce30f0c18bdbc877fa9a1bf5ff17677d9cca0ac122/Brotli-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839", size = 2930029, upload-time = "2024-10-18T12:32:33.293Z" }, { url = "https://files.pythonhosted.org/packages/5f/3b/4e3fd1893eb3bbfef8e5a80d4508bec17a57bb92d586c85c12d28666bb13/Brotli-1.1.0-cp312-cp312-win32.whl", hash = "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0", size = 333276, upload-time = "2023-09-07T14:04:16.49Z" }, { url = "https://files.pythonhosted.org/packages/3d/d5/942051b45a9e883b5b6e98c041698b1eb2012d25e5948c58d6bf85b1bb43/Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951", size = 357255, upload-time = "2023-09-07T14:04:17.83Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9f/fb37bb8ffc52a8da37b1c03c459a8cd55df7a57bdccd8831d500e994a0ca/Brotli-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5", size = 815681, upload-time = "2024-10-18T12:32:34.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/b3/dbd332a988586fefb0aa49c779f59f47cae76855c2d00f450364bb574cac/Brotli-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8", size = 422475, upload-time = "2024-10-18T12:32:36.485Z" }, + { url = "https://files.pythonhosted.org/packages/bb/80/6aaddc2f63dbcf2d93c2d204e49c11a9ec93a8c7c63261e2b4bd35198283/Brotli-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f", size = 2906173, upload-time = "2024-10-18T12:32:37.978Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1d/e6ca79c96ff5b641df6097d299347507d39a9604bde8915e76bf026d6c77/Brotli-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648", size = 2943803, upload-time = "2024-10-18T12:32:39.606Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a3/d98d2472e0130b7dd3acdbb7f390d478123dbf62b7d32bda5c830a96116d/Brotli-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0", size = 2918946, upload-time = "2024-10-18T12:32:41.679Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a5/c69e6d272aee3e1423ed005d8915a7eaa0384c7de503da987f2d224d0721/Brotli-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089", size = 2845707, upload-time = "2024-10-18T12:32:43.478Z" }, + { url = "https://files.pythonhosted.org/packages/58/9f/4149d38b52725afa39067350696c09526de0125ebfbaab5acc5af28b42ea/Brotli-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368", size = 2936231, upload-time = "2024-10-18T12:32:45.224Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/145de884285611838a16bebfdb060c231c52b8f84dfbe52b852a15780386/Brotli-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c", size = 2848157, upload-time = "2024-10-18T12:32:46.894Z" }, + { url = "https://files.pythonhosted.org/packages/50/ae/408b6bfb8525dadebd3b3dd5b19d631da4f7d46420321db44cd99dcf2f2c/Brotli-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284", size = 3035122, upload-time = "2024-10-18T12:32:48.844Z" }, + { url = "https://files.pythonhosted.org/packages/af/85/a94e5cfaa0ca449d8f91c3d6f78313ebf919a0dbd55a100c711c6e9655bc/Brotli-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7", size = 2930206, upload-time = "2024-10-18T12:32:51.198Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f0/a61d9262cd01351df22e57ad7c34f66794709acab13f34be2675f45bf89d/Brotli-1.1.0-cp313-cp313-win32.whl", hash = "sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0", size = 333804, upload-time = "2024-10-18T12:32:52.661Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c1/ec214e9c94000d1c1974ec67ced1c970c148aa6b8d8373066123fc3dbf06/Brotli-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b", size = 358517, upload-time = "2024-10-18T12:32:54.066Z" }, ] [[package]] @@ -316,6 +359,18 @@ wheels = [ { 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" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, ] [[package]] @@ -375,6 +430,17 @@ wheels = [ { 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, upload-time = "2025-08-09T07:56:20.289Z" }, { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, ] @@ -493,25 +559,32 @@ wheels = [ [[package]] name = "curl-cffi" -version = "0.14.0" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/c9/0067d9a25ed4592b022d4558157fcdb6e123516083700786d38091688767/curl_cffi-0.14.0.tar.gz", hash = "sha256:5ffbc82e59f05008ec08ea432f0e535418823cda44178ee518906a54f27a5f0f", size = 162633, upload-time = "2025-12-16T03:25:07.931Z" } +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/aa/f0/0f21e9688eaac85e705537b3a87a5588d0cefb2f09d83e83e0e8be93aa99/curl_cffi-0.14.0-cp39-abi3-macosx_14_0_arm64.whl", hash = "sha256:e35e89c6a69872f9749d6d5fda642ed4fc159619329e99d577d0104c9aad5893", size = 3087277, upload-time = "2025-12-16T03:24:49.607Z" }, - { url = "https://files.pythonhosted.org/packages/ba/a3/0419bd48fce5b145cb6a2344c6ac17efa588f5b0061f212c88e0723da026/curl_cffi-0.14.0-cp39-abi3-macosx_15_0_x86_64.whl", hash = "sha256:5945478cd28ad7dfb5c54473bcfb6743ee1d66554d57951fdf8fc0e7d8cf4e45", size = 5804650, upload-time = "2025-12-16T03:24:51.518Z" }, - { url = "https://files.pythonhosted.org/packages/e2/07/a238dd062b7841b8caa2fa8a359eb997147ff3161288f0dd46654d898b4d/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c42e8fa3c667db9ccd2e696ee47adcd3cd5b0838d7282f3fc45f6c0ef3cfdfa7", size = 8231918, upload-time = "2025-12-16T03:24:52.862Z" }, - { url = "https://files.pythonhosted.org/packages/7c/d2/ce907c9b37b5caf76ac08db40cc4ce3d9f94c5500db68a195af3513eacbc/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:060fe2c99c41d3cb7f894de318ddf4b0301b08dca70453d769bd4e74b36b8483", size = 8654624, upload-time = "2025-12-16T03:24:54.579Z" }, - { url = "https://files.pythonhosted.org/packages/f2/ae/6256995b18c75e6ef76b30753a5109e786813aa79088b27c8eabb1ef85c9/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b158c41a25388690dd0d40b5bc38d1e0f512135f17fdb8029868cbc1993d2e5b", size = 8010654, upload-time = "2025-12-16T03:24:56.507Z" }, - { url = "https://files.pythonhosted.org/packages/fb/10/ff64249e516b103cb762e0a9dca3ee0f04cf25e2a1d5d9838e0f1273d071/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_i686.whl", hash = "sha256:1439fbef3500fb723333c826adf0efb0e2e5065a703fb5eccce637a2250db34a", size = 7781969, upload-time = "2025-12-16T03:24:57.885Z" }, - { url = "https://files.pythonhosted.org/packages/51/76/d6f7bb76c2d12811aa7ff16f5e17b678abdd1b357b9a8ac56310ceccabd5/curl_cffi-0.14.0-cp39-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e7176f2c2d22b542e3cf261072a81deb018cfa7688930f95dddef215caddb469", size = 7969133, upload-time = "2025-12-16T03:24:59.261Z" }, - { url = "https://files.pythonhosted.org/packages/23/7c/cca39c0ed4e1772613d3cba13091c0e9d3b89365e84b9bf9838259a3cd8f/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:03f21ade2d72978c2bb8670e9b6de5260e2755092b02d94b70b906813662998d", size = 9080167, upload-time = "2025-12-16T03:25:00.946Z" }, - { url = "https://files.pythonhosted.org/packages/75/03/a942d7119d3e8911094d157598ae0169b1c6ca1bd3f27d7991b279bcc45b/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:58ebf02de64ee5c95613209ddacb014c2d2f86298d7080c0a1c12ed876ee0690", size = 9520464, upload-time = "2025-12-16T03:25:02.922Z" }, - { url = "https://files.pythonhosted.org/packages/a2/77/78900e9b0833066d2274bda75cba426fdb4cef7fbf6a4f6a6ca447607bec/curl_cffi-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:6e503f9a103f6ae7acfb3890c843b53ec030785a22ae7682a22cc43afb94123e", size = 1677416, upload-time = "2025-12-16T03:25:04.902Z" }, - { url = "https://files.pythonhosted.org/packages/5c/7c/d2ba86b0b3e1e2830bd94163d047de122c69a8df03c5c7c36326c456ad82/curl_cffi-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:2eed50a969201605c863c4c31269dfc3e0da52916086ac54553cfa353022425c", size = 1425067, upload-time = "2025-12-16T03:25:06.454Z" }, + { 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 = "dacite" +version = "1.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/55/a0/7ca79796e799a3e782045d29bf052b5cde7439a2bbb17f15ff44f7aacc63/dacite-1.9.2.tar.gz", hash = "sha256:6ccc3b299727c7aa17582f0021f6ae14d5de47c7227932c47fec4cdfefd26f09", size = 22420, upload-time = "2025-02-05T09:27:29.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/35/386550fd60316d1e37eccdda609b074113298f23cef5bddb2049823fe666/dacite-1.9.2-py3-none-any.whl", hash = "sha256:053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0", size = 16600, upload-time = "2025-02-05T09:27:24.345Z" }, ] [[package]] @@ -552,10 +625,12 @@ wheels = [ [[package]] name = "envied" -version = "3.0.0" +version = "4.0.0" source = { editable = "packages/envied" } dependencies = [ + { name = "aiohttp" }, { name = "aiohttp-swagger3" }, + { name = "animeapi-py" }, { name = "appdirs" }, { name = "brotli" }, { name = "chardet" }, @@ -564,6 +639,7 @@ dependencies = [ { name = "crccheck" }, { name = "cryptography" }, { name = "curl-cffi" }, + { name = "filelock" }, { name = "fonttools" }, { name = "httpx" }, { name = "isodate" }, @@ -614,47 +690,50 @@ dev = [ [package.metadata] requires-dist = [ - { name = "aiohttp-swagger3", specifier = ">=0.10.0,<1" }, + { name = "aiohttp", specifier = ">=3.13.3,<4" }, + { name = "aiohttp-swagger3", specifier = ">=0.9.0,<1" }, + { name = "animeapi-py", specifier = ">=0.6.0" }, { name = "appdirs", specifier = ">=1.4.4,<2" }, { name = "brotli", specifier = ">=1.1.0,<2" }, { name = "chardet", specifier = ">=5.2.0,<6" }, { name = "click", specifier = ">=8.1.8,<9" }, { name = "construct", specifier = ">=2.8.8,<3" }, { name = "crccheck", specifier = ">=1.3.0,<2" }, - { name = "cryptography", specifier = ">=45.0.6,<46.0.0" }, - { name = "curl-cffi", specifier = ">=0.14.0,<0.15" }, - { name = "fonttools", specifier = ">=4.61.1,<5" }, + { name = "cryptography", specifier = ">=45.0.0,<47" }, + { name = "curl-cffi", specifier = ">=0.7.0b4,<0.14" }, + { name = "filelock", specifier = ">=3.20.3,<4" }, + { name = "fonttools", specifier = ">=4.60.2,<5" }, { name = "httpx", specifier = ">=0.28.1,<0.29" }, { name = "isodate", specifier = ">=0.7.2,<0.8" }, - { name = "jsonpickle", specifier = ">=4.1.1,<5" }, - { name = "langcodes", specifier = ">=3.5.0,<4" }, + { name = "jsonpickle", specifier = ">=3.0.4,<5" }, + { name = "langcodes", specifier = ">=3.4.0,<4" }, { name = "language-data", specifier = ">=1.4.0" }, - { name = "lxml", specifier = ">=6.0.2,<7" }, + { name = "lxml", specifier = ">=5.2.1,<7" }, { name = "mypy", specifier = ">=1.19.1,<2" }, { name = "pproxy", specifier = ">=2.7.9,<3" }, - { name = "protobuf", specifier = ">=6.33.3,<7" }, + { name = "protobuf", specifier = ">=4.25.3,<7" }, { name = "pycaption", specifier = ">=2.2.6,<3" }, { name = "pycountry", specifier = ">=24.6.1" }, - { name = "pycryptodomex", specifier = ">=3.23.0,<4" }, + { name = "pycryptodomex", specifier = ">=3.20.0,<4" }, { name = "pyexecjs", specifier = ">=1.5.1,<2" }, - { name = "pyjwt", specifier = ">=2.10.1,<3" }, + { name = "pyjwt", specifier = ">=2.8.0,<3" }, { name = "pymediainfo", specifier = ">=6.1.0,<8" }, { name = "pymp4", specifier = ">=1.4.0,<2" }, { name = "pymysql", specifier = ">=1.1.0,<2" }, - { name = "pyplayready", specifier = ">=0.8.2,<0.9" }, + { name = "pyplayready", specifier = ">=0.8.3,<0.9" }, { name = "pysubs2", specifier = ">=1.7.0,<2" }, - { name = "pywidevine", extras = ["serve"], specifier = ">=1.9.0,<2" }, + { name = "pywidevine", extras = ["serve"], specifier = ">=1.8.0,<2" }, { name = "pyyaml", specifier = ">=6.0.1,<7" }, { name = "requests", extras = ["socks"], specifier = ">=2.32.5,<3" }, - { name = "rich", specifier = ">=14.2.0,<15" }, + { name = "rich", specifier = ">=13.7.1,<15" }, { name = "rlaphoenix-m3u8", specifier = ">=3.4.0,<4" }, - { name = "ruamel-yaml", specifier = ">=0.19.1,<0.20" }, + { name = "ruamel-yaml", specifier = ">=0.18.6,<0.19" }, { name = "scrapy", specifier = ">=2.14.0,<3" }, { name = "sortedcontainers", specifier = ">=2.4.0,<3" }, { name = "subby", git = "https://github.com/vevv/subby.git?rev=5a925c367ffb3f5e53fd114ae222d3be1fdff35d" }, { name = "subtitle-filter", specifier = ">=1.4.9,<2" }, { name = "unidecode", specifier = ">=1.3.8,<2" }, - { name = "urllib3", specifier = ">=2.6.2,<3" }, + { name = "urllib3", specifier = ">=2.6.3,<3" }, { name = "wasmtime", specifier = ">=41.0.0" }, { name = "webvtt-py", specifier = ">=0.5.1,<0.6" }, ] @@ -676,7 +755,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -694,11 +773,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.19.1" +version = "3.25.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, ] [[package]] @@ -731,6 +810,14 @@ wheels = [ { 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/4b/cf/00ba28b0990982530addb8dc3e9e6f2fa9cb5c20df2abdda7baa755e8fe1/fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c", size = 2846454, upload-time = "2025-12-12T17:30:24.938Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ca/468c9a8446a2103ae645d14fee3f610567b7042aba85031c1c65e3ef7471/fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e", size = 2398191, upload-time = "2025-12-12T17:30:27.343Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/d67eedaed19def5967fade3297fed8161b25ba94699efc124b14fb68cdbc/fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5", size = 4928410, upload-time = "2025-12-12T17:30:29.771Z" }, + { url = "https://files.pythonhosted.org/packages/b0/8d/6fb3494dfe61a46258cd93d979cf4725ded4eb46c2a4ca35e4490d84daea/fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd", size = 4984460, upload-time = "2025-12-12T17:30:32.073Z" }, + { url = "https://files.pythonhosted.org/packages/f7/f1/a47f1d30b3dc00d75e7af762652d4cbc3dff5c2697a0dbd5203c81afd9c3/fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3", size = 4925800, upload-time = "2025-12-12T17:30:34.339Z" }, + { url = "https://files.pythonhosted.org/packages/a7/01/e6ae64a0981076e8a66906fab01539799546181e32a37a0257b77e4aa88b/fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d", size = 5067859, upload-time = "2025-12-12T17:30:36.593Z" }, + { url = "https://files.pythonhosted.org/packages/73/aa/28e40b8d6809a9b5075350a86779163f074d2b617c15d22343fce81918db/fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c", size = 2267821, upload-time = "2025-12-12T17:30:38.478Z" }, + { url = "https://files.pythonhosted.org/packages/1a/59/453c06d1d83dc0951b69ef692d6b9f1846680342927df54e9a1ca91c6f90/fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b", size = 2318169, upload-time = "2025-12-12T17:30:40.951Z" }, { 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" }, ] @@ -791,6 +878,40 @@ wheels = [ { 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, upload-time = "2025-06-09T23:01:05.095Z" }, { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487, upload-time = "2025-06-09T23:01:06.54Z" }, { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874, upload-time = "2025-06-09T23:01:07.752Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" }, + { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" }, + { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" }, + { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" }, + { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" }, + { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" }, + { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" }, + { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" }, + { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" }, + { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" }, + { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" }, + { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" }, + { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" }, + { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" }, { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, ] @@ -1013,6 +1134,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/87/8202c9bd0968bdddc188ec3811985f47f58ed161b3749299f2c0dd0f63fb/librt-0.7.7-cp312-cp312-win32.whl", hash = "sha256:1bcd79be209313b270b0e1a51c67ae1af28adad0e0c7e84c3ad4b5cb57aaa75b", size = 43189, upload-time = "2026-01-01T23:51:26.799Z" }, { url = "https://files.pythonhosted.org/packages/61/8d/80244b267b585e7aa79ffdac19f66c4861effc3a24598e77909ecdd0850e/librt-0.7.7-cp312-cp312-win_amd64.whl", hash = "sha256:4353ee891a1834567e0302d4bd5e60f531912179578c36f3d0430f8c5e16b456", size = 49462, upload-time = "2026-01-01T23:51:27.813Z" }, { url = "https://files.pythonhosted.org/packages/2d/1f/75db802d6a4992d95e8a889682601af9b49d5a13bbfa246d414eede1b56c/librt-0.7.7-cp312-cp312-win_arm64.whl", hash = "sha256:a76f1d679beccccdf8c1958e732a1dfcd6e749f8821ee59d7bec009ac308c029", size = 42828, upload-time = "2026-01-01T23:51:28.804Z" }, + { url = "https://files.pythonhosted.org/packages/8d/5e/d979ccb0a81407ec47c14ea68fb217ff4315521730033e1dd9faa4f3e2c1/librt-0.7.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f4a0b0a3c86ba9193a8e23bb18f100d647bf192390ae195d84dfa0a10fb6244", size = 55746, upload-time = "2026-01-01T23:51:29.828Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/3b65861fb32f802c3783d6ac66fc5589564d07452a47a8cf9980d531cad3/librt-0.7.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5335890fea9f9e6c4fdf8683061b9ccdcbe47c6dc03ab8e9b68c10acf78be78d", size = 57174, upload-time = "2026-01-01T23:51:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/030b50614b29e443607220097ebaf438531ea218c7a9a3e21ea862a919cd/librt-0.7.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b4346b1225be26def3ccc6c965751c74868f0578cbcba293c8ae9168483d811", size = 165834, upload-time = "2026-01-01T23:51:32.278Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e1/bd8d1eacacb24be26a47f157719553bbd1b3fe812c30dddf121c0436fd0b/librt-0.7.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a10b8eebdaca6e9fdbaf88b5aefc0e324b763a5f40b1266532590d5afb268a4c", size = 174819, upload-time = "2026-01-01T23:51:33.461Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/91d6c3372acf54a019c1ad8da4c9ecf4fc27d039708880bf95f48dbe426a/librt-0.7.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:067be973d90d9e319e6eb4ee2a9b9307f0ecd648b8a9002fa237289a4a07a9e7", size = 189607, upload-time = "2026-01-01T23:51:34.604Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ac/44604d6d3886f791fbd1c6ae12d5a782a8f4aca927484731979f5e92c200/librt-0.7.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:23d2299ed007812cccc1ecef018db7d922733382561230de1f3954db28433977", size = 184586, upload-time = "2026-01-01T23:51:35.845Z" }, + { url = "https://files.pythonhosted.org/packages/5c/26/d8a6e4c17117b7f9b83301319d9a9de862ae56b133efb4bad8b3aa0808c9/librt-0.7.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6b6f8ea465524aa4c7420c7cc4ca7d46fe00981de8debc67b1cc2e9957bb5b9d", size = 178251, upload-time = "2026-01-01T23:51:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/99/ab/98d857e254376f8e2f668e807daccc1f445e4b4fc2f6f9c1cc08866b0227/librt-0.7.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8df32a99cc46eb0ee90afd9ada113ae2cafe7e8d673686cf03ec53e49635439", size = 199853, upload-time = "2026-01-01T23:51:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/7c/55/4523210d6ae5134a5da959900be43ad8bab2e4206687b6620befddb5b5fd/librt-0.7.7-cp313-cp313-win32.whl", hash = "sha256:86f86b3b785487c7760247bcdac0b11aa8bf13245a13ed05206286135877564b", size = 43247, upload-time = "2026-01-01T23:51:39.629Z" }, + { url = "https://files.pythonhosted.org/packages/25/40/3ec0fed5e8e9297b1cf1a3836fb589d3de55f9930e3aba988d379e8ef67c/librt-0.7.7-cp313-cp313-win_amd64.whl", hash = "sha256:4862cb2c702b1f905c0503b72d9d4daf65a7fdf5a9e84560e563471e57a56949", size = 49419, upload-time = "2026-01-01T23:51:40.674Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7a/aab5f0fb122822e2acbc776addf8b9abfb4944a9056c00c393e46e543177/librt-0.7.7-cp313-cp313-win_arm64.whl", hash = "sha256:0996c83b1cb43c00e8c87835a284f9057bc647abd42b5871e5f941d30010c832", size = 42828, upload-time = "2026-01-01T23:51:41.731Z" }, ] [[package]] @@ -1071,6 +1203,24 @@ wheels = [ { 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/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, { 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" }, @@ -1115,6 +1265,22 @@ wheels = [ { 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" }, + { url = "https://files.pythonhosted.org/packages/46/a2/8331b995c1b3eee83aa745f4a6502d737ec523d5955a48f167d4177db105/marisa_trie-1.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9de573d933db4753a50af891bcb3ffbfe14e200406214c223aa5dfe2163f316d", size = 172272, upload-time = "2025-08-26T15:12:24.016Z" }, + { url = "https://files.pythonhosted.org/packages/97/b8/7b9681b5c0ea1bb950f907a4e3919eb7f7b7b3febafaae346f3b3f199f6f/marisa_trie-1.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4bae4f920f2a1082eaf766c1883df7da84abdf333bafa15b8717c10416a615e", size = 154671, upload-time = "2025-08-26T15:12:25.013Z" }, + { url = "https://files.pythonhosted.org/packages/ca/16/929c1f83fdcff13f8d08500f434aaa18c21c8168d16cf81585d69085e980/marisa_trie-1.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf9f2b97fcfd5e2dbb0090d0664023872dcde990df0b545eca8d0ce95795a409", size = 1238754, upload-time = "2025-08-26T15:12:26.217Z" }, + { url = "https://files.pythonhosted.org/packages/0f/0a/b0e04d3ef91a87d4c7ea0b66c004fdfc6e65c9ed83edaebecfb482dfe0ed/marisa_trie-1.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecdb19d33b26738a32602ef432b06cc6deeca4b498ce67ba8e5e39c8a7c19745", size = 1262653, upload-time = "2025-08-26T15:12:27.422Z" }, + { url = "https://files.pythonhosted.org/packages/de/1f/0ecf610ddc9a209ee63116baabb47584d5b8ecd01610091a593d9429537e/marisa_trie-1.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a7416f1a084eb889c5792c57317875aeaa86abfe0bdc6f167712cebcec1d36ee", size = 2172399, upload-time = "2025-08-26T15:12:28.926Z" }, + { url = "https://files.pythonhosted.org/packages/ac/74/6b47deff3b3920449c135b9187c80f0d656adcdc5d41463745a61b012ea1/marisa_trie-1.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee428575377e29c636f2b4b3b0488875dcea310c6c5b3412ec4ef997f7bb37cc", size = 2255138, upload-time = "2025-08-26T15:12:30.271Z" }, + { url = "https://files.pythonhosted.org/packages/bd/fa/3dbcbe93dfaa626a5b3e741e7bcf3d7389aa5777175213bd8d9a9d3c992d/marisa_trie-1.3.1-cp313-cp313-win32.whl", hash = "sha256:d0f87bdf660f01e88ab3a507955697b2e3284065afa0b94fc9e77d6ad153ed5e", size = 115391, upload-time = "2025-08-26T15:12:31.465Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ce/ddfab303646b21aef07ff9dbc83fba92e5d493f49d3bc03d899ffd45c86f/marisa_trie-1.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:a83f5f7ae3494e0cc25211296252b1b86901c788ed82c83adda19d0c98f828d6", size = 139130, upload-time = "2025-08-26T15:12:32.4Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1e/734b618048ad05c50cb1673ce2c6e836dc38ddeeeb011ed1804af07327a4/marisa_trie-1.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a850b151bd1e3a5d9afef113adc22727d696603659d575d7e84f994bd8d04bf1", size = 175131, upload-time = "2025-08-26T15:12:33.728Z" }, + { url = "https://files.pythonhosted.org/packages/d3/78/c7051147cc918cb8ff4a2920e11a9b17d9dcb4d8fc122122694b486e2bfe/marisa_trie-1.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9dc61fb8f8993589544f6df268229c6cf0a56ad4ed3e8585a9cd23c5ad79527b", size = 163094, upload-time = "2025-08-26T15:12:35.312Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b8/3b904178d7878319aacaabae5131c1f281519aaac0f8c68c8ed312912ccf/marisa_trie-1.3.1-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4bd41a6e73c0d0adafe4de449b6d35530a4ce6a836a6ee839baf117785ecfd7", size = 1279812, upload-time = "2025-08-26T15:12:36.831Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bf/e77a1284247b980560b4104bbdd5d06ed2c2ae3d56ab954f97293b6dbbcd/marisa_trie-1.3.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c8b2386d2d22c57880ed20a913ceca86363765623175671137484a7d223f07a", size = 1285690, upload-time = "2025-08-26T15:12:38.754Z" }, + { url = "https://files.pythonhosted.org/packages/48/82/f6f10db5ec72de2642499f3a6e4e8607bbd2cfb28269ea08d0d8ddac3313/marisa_trie-1.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9c56001badaf1779afae5c24b7ab85938644ab8ef3c5fd438ab5d49621b84482", size = 2197943, upload-time = "2025-08-26T15:12:40.584Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d0/74b6c3011b1ebf4a8131430156b14c3af694082cf34c392fff766096fd4b/marisa_trie-1.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83a3748088d117a9b15d8981c947df9e4f56eb2e4b5456ae34fe1f83666c9185", size = 2280132, upload-time = "2025-08-26T15:12:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/28/b2/b8b0cb738fa3ab07309ed92025c6e1b278f84c7255e976921a52b30d8d1b/marisa_trie-1.3.1-cp313-cp313t-win32.whl", hash = "sha256:137010598d8cebc53dbfb7caf59bde96c33a6af555e3e1bdbf30269b6a157e1e", size = 126446, upload-time = "2025-08-26T15:12:43.339Z" }, + { url = "https://files.pythonhosted.org/packages/b6/c6/2381648d0c946556ef51c673397cea40712d945444ceed0a0a0b51a174d2/marisa_trie-1.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:ec633e108f277f2b7f4671d933a909f39bba549910bf103e2940b87a14da2783", size = 153885, upload-time = "2025-08-26T15:12:44.309Z" }, ] [[package]] @@ -1210,6 +1376,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/86/a2/8022f78f041dfe6d71e364001a5cf987c30edfc83c8a5fb7a3f0974cff39/multidict-6.6.4-cp312-cp312-win32.whl", hash = "sha256:8c2fcb12136530ed19572bbba61b407f655e3953ba669b96a35036a11a485793", size = 41888, upload-time = "2025-08-11T12:07:15.904Z" }, { url = "https://files.pythonhosted.org/packages/c7/eb/d88b1780d43a56db2cba24289fa744a9d216c1a8546a0dc3956563fd53ea/multidict-6.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:047d9425860a8c9544fed1b9584f0c8bcd31bcde9568b047c5e567a1025ecd6e", size = 46072, upload-time = "2025-08-11T12:07:17.045Z" }, { url = "https://files.pythonhosted.org/packages/9f/16/b929320bf5750e2d9d4931835a4c638a19d2494a5b519caaaa7492ebe105/multidict-6.6.4-cp312-cp312-win_arm64.whl", hash = "sha256:14754eb72feaa1e8ae528468f24250dd997b8e2188c3d2f593f9eba259e4b364", size = 43222, upload-time = "2025-08-11T12:07:18.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5d/e1db626f64f60008320aab00fbe4f23fc3300d75892a3381275b3d284580/multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e", size = 75848, upload-time = "2025-08-11T12:07:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/4c/aa/8b6f548d839b6c13887253af4e29c939af22a18591bfb5d0ee6f1931dae8/multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657", size = 45060, upload-time = "2025-08-11T12:07:21.163Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c6/f5e97e5d99a729bc2aa58eb3ebfa9f1e56a9b517cc38c60537c81834a73f/multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da", size = 43269, upload-time = "2025-08-11T12:07:22.392Z" }, + { url = "https://files.pythonhosted.org/packages/dc/31/d54eb0c62516776f36fe67f84a732f97e0b0e12f98d5685bebcc6d396910/multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa", size = 237158, upload-time = "2025-08-11T12:07:23.636Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1c/8a10c1c25b23156e63b12165a929d8eb49a6ed769fdbefb06e6f07c1e50d/multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f", size = 257076, upload-time = "2025-08-11T12:07:25.049Z" }, + { url = "https://files.pythonhosted.org/packages/ad/86/90e20b5771d6805a119e483fd3d1e8393e745a11511aebca41f0da38c3e2/multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0", size = 240694, upload-time = "2025-08-11T12:07:26.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/49/484d3e6b535bc0555b52a0a26ba86e4d8d03fd5587d4936dc59ba7583221/multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879", size = 266350, upload-time = "2025-08-11T12:07:27.94Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b4/aa4c5c379b11895083d50021e229e90c408d7d875471cb3abf721e4670d6/multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a", size = 267250, upload-time = "2025-08-11T12:07:29.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/e5/5e22c5bf96a64bdd43518b1834c6d95a4922cc2066b7d8e467dae9b6cee6/multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f", size = 254900, upload-time = "2025-08-11T12:07:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/17/38/58b27fed927c07035abc02befacab42491e7388ca105e087e6e0215ead64/multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5", size = 252355, upload-time = "2025-08-11T12:07:32.205Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a1/dad75d23a90c29c02b5d6f3d7c10ab36c3197613be5d07ec49c7791e186c/multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438", size = 250061, upload-time = "2025-08-11T12:07:33.623Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1a/ac2216b61c7f116edab6dc3378cca6c70dc019c9a457ff0d754067c58b20/multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e", size = 249675, upload-time = "2025-08-11T12:07:34.958Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/1916af833b800d13883e452e8e0977c065c4ee3ab7a26941fbfdebc11895/multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7", size = 261247, upload-time = "2025-08-11T12:07:36.588Z" }, + { url = "https://files.pythonhosted.org/packages/c5/65/d1f84fe08ac44a5fc7391cbc20a7cedc433ea616b266284413fd86062f8c/multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812", size = 257960, upload-time = "2025-08-11T12:07:39.735Z" }, + { url = "https://files.pythonhosted.org/packages/13/b5/29ec78057d377b195ac2c5248c773703a6b602e132a763e20ec0457e7440/multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a", size = 250078, upload-time = "2025-08-11T12:07:41.525Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0e/7e79d38f70a872cae32e29b0d77024bef7834b0afb406ddae6558d9e2414/multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69", size = 41708, upload-time = "2025-08-11T12:07:43.405Z" }, + { url = "https://files.pythonhosted.org/packages/9d/34/746696dffff742e97cd6a23da953e55d0ea51fa601fa2ff387b3edcfaa2c/multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf", size = 45912, upload-time = "2025-08-11T12:07:45.082Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/3bac136181e271e29170d8d71929cdeddeb77f3e8b6a0c08da3a8e9da114/multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605", size = 43076, upload-time = "2025-08-11T12:07:46.746Z" }, + { url = "https://files.pythonhosted.org/packages/64/94/0a8e63e36c049b571c9ae41ee301ada29c3fee9643d9c2548d7d558a1d99/multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb", size = 82812, upload-time = "2025-08-11T12:07:48.402Z" }, + { url = "https://files.pythonhosted.org/packages/25/1a/be8e369dfcd260d2070a67e65dd3990dd635cbd735b98da31e00ea84cd4e/multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e", size = 48313, upload-time = "2025-08-11T12:07:49.679Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/dd4ade298674b2f9a7b06a32c94ffbc0497354df8285f27317c66433ce3b/multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f", size = 46777, upload-time = "2025-08-11T12:07:51.318Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/98aa28bc7e071bfba611ac2ae803c24e96dd3a452b4118c587d3d872c64c/multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773", size = 229321, upload-time = "2025-08-11T12:07:52.965Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bc/01ddda2a73dd9d167bd85d0e8ef4293836a8f82b786c63fb1a429bc3e678/multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e", size = 249954, upload-time = "2025-08-11T12:07:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/06/78/6b7c0f020f9aa0acf66d0ab4eb9f08375bac9a50ff5e3edb1c4ccd59eafc/multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0", size = 228612, upload-time = "2025-08-11T12:07:55.914Z" }, + { url = "https://files.pythonhosted.org/packages/00/44/3faa416f89b2d5d76e9d447296a81521e1c832ad6e40b92f990697b43192/multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395", size = 257528, upload-time = "2025-08-11T12:07:57.371Z" }, + { url = "https://files.pythonhosted.org/packages/05/5f/77c03b89af0fcb16f018f668207768191fb9dcfb5e3361a5e706a11db2c9/multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45", size = 256329, upload-time = "2025-08-11T12:07:58.844Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e9/ed750a2a9afb4f8dc6f13dc5b67b514832101b95714f1211cd42e0aafc26/multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb", size = 247928, upload-time = "2025-08-11T12:08:01.037Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b5/e0571bc13cda277db7e6e8a532791d4403dacc9850006cb66d2556e649c0/multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5", size = 245228, upload-time = "2025-08-11T12:08:02.96Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a3/69a84b0eccb9824491f06368f5b86e72e4af54c3067c37c39099b6687109/multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141", size = 235869, upload-time = "2025-08-11T12:08:04.746Z" }, + { url = "https://files.pythonhosted.org/packages/a9/9d/28802e8f9121a6a0804fa009debf4e753d0a59969ea9f70be5f5fdfcb18f/multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d", size = 243446, upload-time = "2025-08-11T12:08:06.332Z" }, + { url = "https://files.pythonhosted.org/packages/38/ea/6c98add069b4878c1d66428a5f5149ddb6d32b1f9836a826ac764b9940be/multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d", size = 252299, upload-time = "2025-08-11T12:08:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/3a/09/8fe02d204473e14c0af3affd50af9078839dfca1742f025cca765435d6b4/multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0", size = 246926, upload-time = "2025-08-11T12:08:09.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/3d/7b1e10d774a6df5175ecd3c92bff069e77bed9ec2a927fdd4ff5fe182f67/multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92", size = 243383, upload-time = "2025-08-11T12:08:10.981Z" }, + { url = "https://files.pythonhosted.org/packages/50/b0/a6fae46071b645ae98786ab738447de1ef53742eaad949f27e960864bb49/multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e", size = 47775, upload-time = "2025-08-11T12:08:12.439Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0a/2436550b1520091af0600dff547913cb2d66fbac27a8c33bc1b1bccd8d98/multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4", size = 53100, upload-time = "2025-08-11T12:08:13.823Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/43ac51faff934086db9c072a94d327d71b7d8b40cd5dcb47311330929ef0/multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad", size = 45501, upload-time = "2025-08-11T12:08:15.173Z" }, { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" }, ] @@ -1244,6 +1446,12 @@ wheels = [ { 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/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, { 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" }, ] @@ -1408,6 +1616,38 @@ wheels = [ { 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, upload-time = "2025-06-09T22:54:50.424Z" }, { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711, upload-time = "2025-06-09T22:54:52.072Z" }, { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479, upload-time = "2025-06-09T22:54:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" }, + { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" }, + { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" }, + { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" }, + { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" }, + { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" }, + { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" }, { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, ] @@ -1494,6 +1734,17 @@ version = "3.23.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, @@ -1518,6 +1769,17 @@ 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, upload-time = "2025-05-17T17:23:41.434Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764, upload-time = "2025-05-17T17:22:21.453Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012, upload-time = "2025-05-17T17:22:23.702Z" }, + { url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643, upload-time = "2025-05-17T17:22:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762, upload-time = "2025-05-17T17:22:28.313Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012, upload-time = "2025-05-17T17:22:30.57Z" }, + { url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856, upload-time = "2025-05-17T17:22:32.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523, upload-time = "2025-05-17T17:22:35.386Z" }, + { url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825, upload-time = "2025-05-17T17:22:37.632Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078, upload-time = "2025-05-17T17:22:40Z" }, + { url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656, upload-time = "2025-05-17T17:22:42.139Z" }, + { url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172, upload-time = "2025-05-17T17:22:44.704Z" }, { 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" }, @@ -1610,7 +1872,7 @@ version = "25.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size = 184073, upload-time = "2025-09-17T00:32:21.037Z" } wheels = [ @@ -1619,7 +1881,7 @@ wheels = [ [[package]] name = "pyplayready" -version = "0.8.2" +version = "0.8.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -1632,9 +1894,9 @@ dependencies = [ { name = "pyyaml" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/15/981d49d82a1d1bea9d1bdc334bfe31c0f920fc2481eb0311ef812882e1ef/pyplayready-0.8.2.tar.gz", hash = "sha256:8f58364fcba64e0c57dfb170888e0067db45da9e131a220ab9c8415e65d14aed", size = 67385, upload-time = "2026-01-04T08:32:28.966Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/7b/19939e4075c6a54058cea2451240e07ff13dee1561aadad03bd8b6170df1/pyplayready-0.8.4.tar.gz", hash = "sha256:ea3110c5179e96d271bebcf6c9e6ee221024dd1e6a0ca1070add429a1a9eba92", size = 67519, upload-time = "2026-03-12T16:31:51.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/6d/bb8c0cdaa8dcce3097269fb8182723d6d8446b3aacffef248f9ad1cd9890/pyplayready-0.8.2-py3-none-any.whl", hash = "sha256:d5b43170b1aee9eaf057b1b0a1be97e8678e2fba5d4f25964210fb4ceaf8d10f", size = 77891, upload-time = "2026-01-04T08:32:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/3a/69/40d03b8e4f0b9a84f2c8d96ba0d03c3af837a061e4f6215b2d68469c399e/pyplayready-0.8.4-py3-none-any.whl", hash = "sha256:b8a64b1e4e9e70da6fe40dede485a7e3010d584cf8d37f1d19444143ee68e441", size = 78021, upload-time = "2026-03-12T16:31:50.565Z" }, ] [[package]] @@ -1693,6 +1955,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/21/40/de9491213f480a27199690616959a17a0f234962b86aa1dd4ca2584e922d/pyqt6_sip-13.10.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:37af463dcce39285e686d49523d376994d8a2508b9acccb7616c4b117c9c4ed7", size = 304251, upload-time = "2025-05-23T12:26:35.66Z" }, { url = "https://files.pythonhosted.org/packages/02/21/cc80e03f1052408c62c341e9fe9b81454c94184f4bd8a95d29d2ec86df92/pyqt6_sip-13.10.2-cp312-cp312-win_amd64.whl", hash = "sha256:c7b34a495b92790c70eae690d9e816b53d3b625b45eeed6ae2c0fe24075a237e", size = 53519, upload-time = "2025-05-23T12:26:36.797Z" }, { url = "https://files.pythonhosted.org/packages/77/cf/53bd0863252b260a502659cb3124d9c9fe38047df9360e529b437b4ac890/pyqt6_sip-13.10.2-cp312-cp312-win_arm64.whl", hash = "sha256:c80cc059d772c632f5319632f183e7578cd0976b9498682833035b18a3483e92", size = 45349, upload-time = "2025-05-23T12:26:37.729Z" }, + { url = "https://files.pythonhosted.org/packages/a1/1e/979ea64c98ca26979d8ce11e9a36579e17d22a71f51d7366d6eec3c82c13/pyqt6_sip-13.10.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8b5d06a0eac36038fa8734657d99b5fe92263ae7a0cd0a67be6acfe220a063e1", size = 112227, upload-time = "2025-05-23T12:26:38.758Z" }, + { url = "https://files.pythonhosted.org/packages/d9/21/84c230048e3bfef4a9209d16e56dcd2ae10590d03a31556ae8b5f1dcc724/pyqt6_sip-13.10.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad376a6078da37b049fdf9d6637d71b52727e65c4496a80b753ddc8d27526aca", size = 322920, upload-time = "2025-05-23T12:26:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/c6a28a142f14e735088534cc92951c3f48cccd77cdd4f3b10d7996be420f/pyqt6_sip-13.10.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:3dde8024d055f496eba7d44061c5a1ba4eb72fc95e5a9d7a0dbc908317e0888b", size = 303833, upload-time = "2025-05-23T12:26:41.075Z" }, + { url = "https://files.pythonhosted.org/packages/89/63/e5adf350c1c3123d4865c013f164c5265512fa79f09ad464fb2fdf9f9e61/pyqt6_sip-13.10.2-cp313-cp313-win_amd64.whl", hash = "sha256:0b097eb58b4df936c4a2a88a2f367c8bb5c20ff049a45a7917ad75d698e3b277", size = 53527, upload-time = "2025-05-23T12:26:42.625Z" }, + { url = "https://files.pythonhosted.org/packages/58/74/2df4195306d050fbf4963fb5636108a66e5afa6dc05fd9e81e51ec96c384/pyqt6_sip-13.10.2-cp313-cp313-win_arm64.whl", hash = "sha256:cc6a1dfdf324efaac6e7b890a608385205e652845c62130de919fd73a6326244", size = 45373, upload-time = "2025-05-23T12:26:43.536Z" }, ] [[package]] @@ -1797,6 +2064,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, ] [[package]] @@ -1892,11 +2169,62 @@ wheels = [ [[package]] name = "ruamel-yaml" -version = "0.19.1" +version = "0.18.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } +dependencies = [ + { name = "ruamel-yaml-clib", marker = "platform_python_implementation == 'CPython'" }, +] +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/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, + { 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.15" +source = { registry = "https://pypi.org/simple" } +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/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" }, + { url = "https://files.pythonhosted.org/packages/17/5e/2f970ce4c573dc30c2f95825f2691c96d55560268ddc67603dc6ea2dd08e/ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dcec721fddbb62e60c2801ba08c87010bd6b700054a09998c4d09c08147b8fb", size = 147450, upload-time = "2025-11-16T16:13:33.542Z" }, + { url = "https://files.pythonhosted.org/packages/d6/03/a1baa5b94f71383913f21b96172fb3a2eb5576a4637729adbf7cd9f797f8/ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:65f48245279f9bb301d1276f9679b82e4c080a1ae25e679f682ac62446fac471", size = 133139, upload-time = "2025-11-16T16:13:34.587Z" }, + { url = "https://files.pythonhosted.org/packages/dc/19/40d676802390f85784235a05788fd28940923382e3f8b943d25febbb98b7/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46895c17ead5e22bea5e576f1db7e41cb273e8d062c04a6a49013d9f60996c25", size = 731474, upload-time = "2025-11-16T20:22:49.934Z" }, + { url = "https://files.pythonhosted.org/packages/ce/bb/6ef5abfa43b48dd55c30d53e997f8f978722f02add61efba31380d73e42e/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3eb199178b08956e5be6288ee0b05b2fb0b5c1f309725ad25d9c6ea7e27f962a", size = 748047, upload-time = "2025-11-16T16:13:35.633Z" }, + { url = "https://files.pythonhosted.org/packages/ff/5d/e4f84c9c448613e12bd62e90b23aa127ea4c46b697f3d760acc32cb94f25/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d1032919280ebc04a80e4fb1e93f7a738129857eaec9448310e638c8bccefcf", size = 782129, upload-time = "2025-11-16T16:13:36.781Z" }, + { url = "https://files.pythonhosted.org/packages/de/4b/e98086e88f76c00c88a6bcf15eae27a1454f661a9eb72b111e6bbb69024d/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab0df0648d86a7ecbd9c632e8f8d6b21bb21b5fc9d9e095c796cacf32a728d2d", size = 736848, upload-time = "2025-11-16T16:13:37.952Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5c/5964fcd1fd9acc53b7a3a5d9a05ea4f95ead9495d980003a557deb9769c7/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:331fb180858dd8534f0e61aa243b944f25e73a4dae9962bd44c46d1761126bbf", size = 741630, upload-time = "2025-11-16T20:22:51.718Z" }, + { url = "https://files.pythonhosted.org/packages/07/1e/99660f5a30fceb58494598e7d15df883a07292346ef5696f0c0ae5dee8c6/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd4c928ddf6bce586285daa6d90680b9c291cfd045fc40aad34e445d57b1bf51", size = 766619, upload-time = "2025-11-16T16:13:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/fa0344a9327b58b54970e56a27b32416ffbcfe4dcc0700605516708579b2/ruamel_yaml_clib-0.2.15-cp313-cp313-win32.whl", hash = "sha256:bf0846d629e160223805db9fe8cc7aec16aaa11a07310c50c8c7164efa440aec", size = 100171, upload-time = "2025-11-16T16:13:40.456Z" }, + { url = "https://files.pythonhosted.org/packages/06/c4/c124fbcef0684fcf3c9b72374c2a8c35c94464d8694c50f37eef27f5a145/ruamel_yaml_clib-0.2.15-cp313-cp313-win_amd64.whl", hash = "sha256:45702dfbea1420ba3450bb3dd9a80b33f0badd57539c6aac09f42584303e0db6", size = 118845, upload-time = "2025-11-16T16:13:41.481Z" }, ] [[package]] @@ -2084,6 +2412,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, ] @@ -2306,6 +2642,40 @@ wheels = [ { 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, upload-time = "2025-06-10T00:44:12.834Z" }, { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249, upload-time = "2025-06-10T00:44:14.731Z" }, { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" }, + { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" }, + { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" }, + { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" }, + { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" }, + { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" }, + { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" }, + { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" }, + { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" }, + { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" }, + { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" }, + { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, + { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, ] @@ -2333,4 +2703,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/29/49effcff64ef30731e35520a152a9dfcafec86cf114b4c2aff942e8264ba/zope_interface-8.0.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35a1565d5244997f2e629c5c68715b3d9d9036e8df23c4068b08d9316dcb2822", size = 264696, upload-time = "2025-09-25T05:58:13.351Z" }, { url = "https://files.pythonhosted.org/packages/c7/39/b947673ec9a258eeaa20208dd2f6127d9fbb3e5071272a674ebe02063a78/zope_interface-8.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:029ea1db7e855a475bf88d9910baab4e94d007a054810e9007ac037a91c67c6f", size = 264229, upload-time = "2025-09-25T06:26:26.226Z" }, { url = "https://files.pythonhosted.org/packages/8f/ee/eed6efd1fc3788d1bef7a814e0592d8173b7fe601c699b935009df035fc2/zope_interface-8.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0beb3e7f7dc153944076fcaf717a935f68d39efa9fce96ec97bafcc0c2ea6cab", size = 212270, upload-time = "2025-09-25T05:58:53.584Z" }, + { url = "https://files.pythonhosted.org/packages/5f/dc/3c12fca01c910c793d636ffe9c0984e0646abaf804e44552070228ed0ede/zope_interface-8.0.1-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:c7cc027fc5c61c5d69e5080c30b66382f454f43dc379c463a38e78a9c6bab71a", size = 208992, upload-time = "2025-09-25T05:58:40.712Z" }, + { url = "https://files.pythonhosted.org/packages/46/71/6127b7282a3e380ca927ab2b40778a9c97935a4a57a2656dadc312db5f30/zope_interface-8.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fcf9097ff3003b7662299f1c25145e15260ec2a27f9a9e69461a585d79ca8552", size = 209051, upload-time = "2025-09-25T05:58:42.182Z" }, + { url = "https://files.pythonhosted.org/packages/56/86/4387a9f951ee18b0e41fda77da77d59c33e59f04660578e2bad688703e64/zope_interface-8.0.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6d965347dd1fb9e9a53aa852d4ded46b41ca670d517fd54e733a6b6a4d0561c2", size = 259223, upload-time = "2025-09-25T05:58:23.191Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/ce60a114466abc067c68ed41e2550c655f551468ae17b4b17ea360090146/zope_interface-8.0.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a3b8bb77a4b89427a87d1e9eb969ab05e38e6b4a338a9de10f6df23c33ec3c2", size = 264690, upload-time = "2025-09-25T05:58:15.052Z" }, + { url = "https://files.pythonhosted.org/packages/36/9a/62a9ba3a919594605a07c34eee3068659bbd648e2fa0c4a86d876810b674/zope_interface-8.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:87e6b089002c43231fb9afec89268391bcc7a3b66e76e269ffde19a8112fb8d5", size = 264201, upload-time = "2025-09-25T06:26:27.797Z" }, + { url = "https://files.pythonhosted.org/packages/da/06/8fe88bd7edef60566d21ef5caca1034e10f6b87441ea85de4bbf9ea74768/zope_interface-8.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:64a43f5280aa770cbafd0307cb3d1ff430e2a1001774e8ceb40787abe4bb6658", size = 212273, upload-time = "2025-09-25T06:00:25.398Z" }, ]