mirror of
https://github.com/vinefeeder/TwinVine.git
synced 2026-07-15 18:10:04 +02:00
upstream updates
This commit is contained in:
@@ -17,12 +17,17 @@ TwinVine is the easy way to handle your download tasks.
|
|||||||
|
|
||||||
|
|
||||||
**usage**
|
**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
|
||||||
uv run vinefeeder - to open the interactive GUI for search, browse, etc.
|
uv run envied dl --select-titles <service> <url>
|
||||||
To go to the command line use of 'envied' after using the GUI: close the GUI directly, or ctrl+c in terminal.
|
|
||||||
|
|
||||||
|
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.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
**Updates**
|
**Updates**
|
||||||
|
|
||||||
**16 Feb 2026**
|
|
||||||
|
|
||||||
- upstream envied to 3.0.0
|
|
||||||
envied's --select-titles option now follows dev branch of unshackle.
|
|
||||||
|
|
||||||
**19 Jan 2026**
|
**19 Jan 2026**
|
||||||
|
|
||||||
- upstream envied to 2.3.0
|
- upstream envied to 2.3.0
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "envied"
|
name = "envied"
|
||||||
version = "3.0.0"
|
version = "4.0.0"
|
||||||
description = "Modular Movie, TV, and Music Archival Software."
|
description = "Modular Movie, TV, and Music Archival Software."
|
||||||
authors = [{ name = "rlaphoenix and others" }]
|
authors = [{ name = "rlaphoenix and others" }]
|
||||||
requires-python = ">=3.10,<3.13"
|
requires-python = ">=3.10,<=3.14"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
keywords = [
|
keywords = [
|
||||||
@@ -26,57 +26,60 @@ classifiers = [
|
|||||||
"Topic :: Security :: Cryptography",
|
"Topic :: Security :: Cryptography",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"appdirs>=1.4.4,<2",
|
|
||||||
"Brotli>=1.1.0,<2",
|
"appdirs>=1.4.4,<2",
|
||||||
"click>=8.1.8,<9",
|
"Brotli>=1.1.0,<2",
|
||||||
"construct>=2.8.8,<3",
|
"click>=8.1.8,<9",
|
||||||
"crccheck>=1.3.0,<2",
|
"construct>=2.8.8,<3",
|
||||||
"fonttools>=4.61.1,<5",
|
"crccheck>=1.3.0,<2",
|
||||||
"jsonpickle>=4.1.1,<5",
|
"filelock>=3.20.3,<4",
|
||||||
"langcodes>=3.5.0,<4",
|
"fonttools>=4.60.2,<5",
|
||||||
"lxml>=6.0.2,<7",
|
"jsonpickle>=3.0.4,<5",
|
||||||
"pproxy>=2.7.9,<3",
|
"langcodes>=3.4.0,<4",
|
||||||
"protobuf>=6.33.3,<7",
|
"lxml>=5.2.1,<7",
|
||||||
"pycaption>=2.2.6,<3",
|
"pproxy>=2.7.9,<3",
|
||||||
"pycryptodomex>=3.23.0,<4",
|
"protobuf>=4.25.3,<7",
|
||||||
"PyJWT>=2.10.1,<3",
|
"pycaption>=2.2.6,<3",
|
||||||
"pymediainfo>=6.1.0,<8",
|
"pycryptodomex>=3.20.0,<4",
|
||||||
"pymp4>=1.4.0,<2",
|
"pyjwt>=2.8.0,<3",
|
||||||
"pymysql>=1.1.0,<2",
|
"pymediainfo>=6.1.0,<8",
|
||||||
"pywidevine[serve]>=1.9.0,<2",
|
"pymp4>=1.4.0,<2",
|
||||||
"PyYAML>=6.0.1,<7",
|
"pymysql>=1.1.0,<2",
|
||||||
"requests[socks]>=2.32.5,<3",
|
"pywidevine[serve]>=1.8.0,<2",
|
||||||
"rich>=14.2.0,<15",
|
"PyYAML>=6.0.1,<7",
|
||||||
"rlaphoenix.m3u8>=3.4.0,<4",
|
"requests[socks]>=2.32.5,<3",
|
||||||
"ruamel.yaml>=0.19.1,<0.20",
|
"rich>=13.7.1,<15",
|
||||||
"sortedcontainers>=2.4.0,<3",
|
"rlaphoenix.m3u8>=3.4.0,<4",
|
||||||
"subtitle-filter>=1.4.9,<2",
|
"ruamel.yaml>=0.18.6,<0.19",
|
||||||
"Unidecode>=1.3.8,<2",
|
"sortedcontainers>=2.4.0,<3",
|
||||||
"urllib3>=2.6.2,<3",
|
"subtitle-filter>=1.4.9,<2",
|
||||||
"chardet>=5.2.0,<6",
|
"Unidecode>=1.3.8,<2",
|
||||||
|
"urllib3>=2.6.3,<3",
|
||||||
# curl-cffi is 0.x; keep a tighter cap to avoid accidental breaking changes.
|
"chardet>=5.2.0,<6",
|
||||||
"curl-cffi>=0.14.0,<0.15",
|
"curl-cffi>=0.7.0b4,<0.14",
|
||||||
"pyplayready>=0.8.2,<0.9",
|
"pyplayready>=0.8.3,<0.9",
|
||||||
"httpx>=0.28.1,<0.29",
|
"httpx>=0.28.1,<0.29",
|
||||||
"cryptography>=45.0.6,<46.0.0",
|
"cryptography>=45.0.0,<47",
|
||||||
|
"subby",
|
||||||
# These were unpinned in your list; consider adding bounds once you confirm versions you’ve tested with.
|
"aiohttp>=3.13.3,<4",
|
||||||
"subby",
|
"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",
|
"webvtt-py>=0.5.1,<0.6",
|
||||||
"isodate>=0.7.2,<0.8",
|
"isodate>=0.7.2,<0.8",
|
||||||
#"beaupy>=3.10.2,<4",
|
|
||||||
"scrapy>=2.14.0,<3",
|
"scrapy>=2.14.0,<3",
|
||||||
"mypy>=1.19.1,<2",
|
"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.
|
# Keeping your existing cap style here; latest visible is 0.10.0.
|
||||||
"aiohttp-swagger3>=0.10.0,<1",
|
#"aiohttp-swagger3>=0.10.0,<1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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/),
|
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).
|
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
|
## [2.3.0] - 2026-01-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import html
|
import html
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
import os
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
@@ -24,6 +25,7 @@ import click
|
|||||||
import jsonpickle
|
import jsonpickle
|
||||||
import yaml
|
import yaml
|
||||||
from construct import ConstError
|
from construct import ConstError
|
||||||
|
from langcodes import Language
|
||||||
from pymediainfo import MediaInfo
|
from pymediainfo import MediaInfo
|
||||||
from pyplayready.cdm import Cdm as PlayReadyCdm
|
from pyplayready.cdm import Cdm as PlayReadyCdm
|
||||||
from pyplayready.device import Device as PlayReadyDevice
|
from pyplayready.device import Device as PlayReadyDevice
|
||||||
@@ -41,7 +43,7 @@ from rich.table import Table
|
|||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from rich.tree import Tree
|
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 import CustomRemoteCDM, DecryptLabsRemoteCDM
|
||||||
from envied.core.cdm.detect import is_playready_cdm, is_widevine_cdm
|
from envied.core.cdm.detect import is_playready_cdm, is_widevine_cdm
|
||||||
from envied.core.config import config
|
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.attachment import Attachment
|
||||||
from envied.core.tracks.hybrid import Hybrid
|
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,
|
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 import tags
|
||||||
from envied.core.utils.click_types import (AUDIO_CODEC_LIST, LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE,
|
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.collections import merge_dict
|
||||||
from envied.core.utils.selector import select_multiple
|
from envied.core.utils.selector import select_multiple
|
||||||
from envied.core.utils.subprocess import ffprobe
|
from envied.core.utils.subprocess import ffprobe
|
||||||
@@ -195,12 +198,7 @@ class dl:
|
|||||||
sdh_suffix = ".sdh" if (subtitle.sdh or subtitle.cc) else ""
|
sdh_suffix = ".sdh" if (subtitle.sdh or subtitle.cc) else ""
|
||||||
|
|
||||||
extension = (target_codec or subtitle.codec or Subtitle.Codec.SubRip).extension
|
extension = (target_codec or subtitle.codec or Subtitle.Codec.SubRip).extension
|
||||||
if (
|
if not target_codec and not subtitle.codec and source_path and source_path.suffix:
|
||||||
not target_codec
|
|
||||||
and not subtitle.codec
|
|
||||||
and source_path
|
|
||||||
and source_path.suffix
|
|
||||||
):
|
|
||||||
extension = source_path.suffix.lstrip(".")
|
extension = source_path.suffix.lstrip(".")
|
||||||
|
|
||||||
filename = f"{base_filename}.{lang_suffix}{forced_suffix}{sdh_suffix}.{extension}"
|
filename = f"{base_filename}.{lang_suffix}{forced_suffix}{sdh_suffix}.{extension}"
|
||||||
@@ -292,9 +290,9 @@ class dl:
|
|||||||
@click.option(
|
@click.option(
|
||||||
"-v",
|
"-v",
|
||||||
"--vcodec",
|
"--vcodec",
|
||||||
type=VideoCodecChoice(Video.Codec),
|
type=MultipleVideoCodecChoice(Video.Codec),
|
||||||
default=None,
|
default=[],
|
||||||
help="Video Codec to download, defaults to any codec.",
|
help="Video Codec(s) to download, defaults to any codec.",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"-a",
|
"-a",
|
||||||
@@ -410,6 +408,7 @@ class dl:
|
|||||||
@click.option(
|
@click.option(
|
||||||
"--tag", type=str, default=None, help="Set the Group Tag to be used, overriding the one in config if any."
|
"--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(
|
@click.option(
|
||||||
"--tmdb",
|
"--tmdb",
|
||||||
"tmdb_id",
|
"tmdb_id",
|
||||||
@@ -418,18 +417,24 @@ class dl:
|
|||||||
help="Use this TMDB ID for tagging instead of automatic lookup.",
|
help="Use this TMDB ID for tagging instead of automatic lookup.",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--tmdb-name",
|
"--animeapi",
|
||||||
"tmdb_name",
|
"animeapi_id",
|
||||||
is_flag=True,
|
type=str,
|
||||||
default=False,
|
default=None,
|
||||||
help="Rename titles using the name returned from TMDB lookup.",
|
help="Anime database ID via AnimeAPI (e.g. mal:12345, anilist:98765). Defaults to MAL if no prefix.",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--tmdb-year",
|
"--enrich",
|
||||||
"tmdb_year",
|
|
||||||
is_flag=True,
|
is_flag=True,
|
||||||
default=False,
|
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(
|
@click.option(
|
||||||
"--sub-format",
|
"--sub-format",
|
||||||
@@ -489,10 +494,24 @@ class dl:
|
|||||||
help="Max workers/threads to download with per-track. Default depends on the downloader.",
|
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("--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("--no-cache", "no_cache", is_flag=True, default=False, help="Bypass title cache for this download.")
|
||||||
@click.option(
|
@click.option(
|
||||||
"--reset-cache", "reset_cache", is_flag=True, default=False, help="Clear title cache before fetching."
|
"--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(
|
@click.option(
|
||||||
"--best-available",
|
"--best-available",
|
||||||
"best_available",
|
"best_available",
|
||||||
@@ -512,10 +531,13 @@ class dl:
|
|||||||
no_proxy: bool,
|
no_proxy: bool,
|
||||||
profile: Optional[str] = None,
|
profile: Optional[str] = None,
|
||||||
proxy: Optional[str] = None,
|
proxy: Optional[str] = None,
|
||||||
|
repack: bool = False,
|
||||||
tag: Optional[str] = None,
|
tag: Optional[str] = None,
|
||||||
tmdb_id: Optional[int] = None,
|
tmdb_id: Optional[int] = None,
|
||||||
tmdb_name: bool = False,
|
imdb_id: Optional[str] = None,
|
||||||
tmdb_year: bool = False,
|
animeapi_id: Optional[str] = None,
|
||||||
|
enrich: bool = False,
|
||||||
|
output_dir: Optional[Path] = None,
|
||||||
*_: Any,
|
*_: Any,
|
||||||
**__: Any,
|
**__: Any,
|
||||||
):
|
):
|
||||||
@@ -559,8 +581,23 @@ class dl:
|
|||||||
|
|
||||||
self.profile = profile
|
self.profile = profile
|
||||||
self.tmdb_id = tmdb_id
|
self.tmdb_id = tmdb_id
|
||||||
self.tmdb_name = tmdb_name
|
self.imdb_id = imdb_id
|
||||||
self.tmdb_year = tmdb_year
|
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
|
# Initialize debug logger with service name if debug logging is enabled
|
||||||
if config.debug or logging.root.level == logging.DEBUG:
|
if config.debug or logging.root.level == logging.DEBUG:
|
||||||
@@ -583,16 +620,75 @@ class dl:
|
|||||||
"profile": profile,
|
"profile": profile,
|
||||||
"proxy": proxy,
|
"proxy": proxy,
|
||||||
"tag": tag,
|
"tag": tag,
|
||||||
"tmdb_id": tmdb_id,
|
"tmdb_id": self.tmdb_id,
|
||||||
"tmdb_name": tmdb_name,
|
"imdb_id": self.imdb_id,
|
||||||
"tmdb_year": tmdb_year,
|
"animeapi_id": animeapi_id,
|
||||||
|
"enrich": enrich,
|
||||||
"cli_params": {
|
"cli_params": {
|
||||||
k: v
|
k: v
|
||||||
for k, v in ctx.params.items()
|
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 = "<error getting 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:
|
else:
|
||||||
self.debug_logger = None
|
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
|
config=self.service_config, cdm=self.cdm, proxy_providers=self.proxy_providers, profile=self.profile
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if repack:
|
||||||
|
config.repack = True
|
||||||
|
|
||||||
if tag:
|
if tag:
|
||||||
config.tag = tag
|
config.tag = tag
|
||||||
|
|
||||||
@@ -859,7 +958,7 @@ class dl:
|
|||||||
self,
|
self,
|
||||||
service: Service,
|
service: Service,
|
||||||
quality: list[int],
|
quality: list[int],
|
||||||
vcodec: Optional[Video.Codec],
|
vcodec: list[Video.Codec],
|
||||||
acodec: list[Audio.Codec],
|
acodec: list[Audio.Codec],
|
||||||
vbitrate: int,
|
vbitrate: int,
|
||||||
abitrate: int,
|
abitrate: int,
|
||||||
@@ -898,6 +997,7 @@ class dl:
|
|||||||
no_mux: bool,
|
no_mux: bool,
|
||||||
workers: Optional[int],
|
workers: Optional[int],
|
||||||
downloads: int,
|
downloads: int,
|
||||||
|
worst: bool,
|
||||||
best_available: bool,
|
best_available: bool,
|
||||||
split_audio: Optional[bool] = None,
|
split_audio: Optional[bool] = None,
|
||||||
*_: Any,
|
*_: Any,
|
||||||
@@ -923,6 +1023,14 @@ class dl:
|
|||||||
self.log.error("--require-subs and --s-lang cannot be used together")
|
self.log.error("--require-subs and --s-lang cannot be used together")
|
||||||
sys.exit(1)
|
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
|
# Check if dovi_tool is available when hybrid mode is requested
|
||||||
if any(r == Video.Range.HYBRID for r in range_):
|
if any(r == Video.Range.HYBRID for r in range_):
|
||||||
from envied.core.binaries import DoviTool
|
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_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
|
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
|
sample_title = titles[0] if hasattr(titles, "__getitem__") else titles
|
||||||
kind = "tv" if isinstance(sample_title, Episode) else "movie"
|
kind = "tv" if isinstance(sample_title, Episode) else "movie"
|
||||||
|
|
||||||
tmdb_year_val = None
|
enrich_title: Optional[str] = None
|
||||||
tmdb_name_val = None
|
enrich_year: Optional[int] = None
|
||||||
|
|
||||||
if self.tmdb_year:
|
if self.animeapi_title:
|
||||||
tmdb_year_val = tags.get_year(
|
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
|
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:
|
if enrich_title or enrich_year:
|
||||||
tmdb_name_val = tags.get_title(
|
if isinstance(titles, (Series, Movies)):
|
||||||
self.tmdb_id, kind, title_cacher, cache_title_id, cache_region, cache_account_hash
|
for t in titles:
|
||||||
)
|
if enrich_title:
|
||||||
|
if isinstance(t, Episode):
|
||||||
if isinstance(titles, (Series, Movies)):
|
t.title = enrich_title
|
||||||
for t in titles:
|
else:
|
||||||
if tmdb_year_val:
|
t.name = enrich_title
|
||||||
t.year = tmdb_year_val
|
if enrich_year and not t.year:
|
||||||
if tmdb_name_val:
|
t.year = enrich_year
|
||||||
if isinstance(t, Episode):
|
else:
|
||||||
t.title = tmdb_name_val
|
if enrich_title:
|
||||||
|
if isinstance(titles, Episode):
|
||||||
|
titles.title = enrich_title
|
||||||
else:
|
else:
|
||||||
t.name = tmdb_name_val
|
titles.name = enrich_title
|
||||||
else:
|
if enrich_year and not titles.year:
|
||||||
if tmdb_year_val:
|
titles.year = enrich_year
|
||||||
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(Rule(f"[rule.text]{titles.__class__.__name__}: {titles}"), (1, 2)))
|
||||||
|
|
||||||
@@ -1056,13 +1175,13 @@ class dl:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Enables manual selection for Series when --select-titles is set
|
# Enables manual selection for Series when --select-titles is set
|
||||||
if select_titles and type(titles) == Series:
|
if select_titles and isinstance(titles, Series):
|
||||||
console.print(Padding(Rule(f"[rule.text]Select Titles"), (1, 2)))
|
console.print(Padding(Rule("[rule.text]Select Titles"), (1, 2)))
|
||||||
|
|
||||||
selection_titles = []
|
selection_titles = []
|
||||||
dependencies = {}
|
dependencies = {}
|
||||||
original_indices = {}
|
original_indices = {}
|
||||||
|
|
||||||
current_season = None
|
current_season = None
|
||||||
current_season_header_idx = -1
|
current_season_header_idx = -1
|
||||||
|
|
||||||
@@ -1081,37 +1200,40 @@ class dl:
|
|||||||
# Note: Headers are not mapped to actual title indices
|
# Note: Headers are not mapped to actual title indices
|
||||||
|
|
||||||
# Format display name
|
# 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
|
# Apply indentation only for multiple seasons
|
||||||
prefix = " " if multiple_seasons else ""
|
prefix = " " if multiple_seasons else ""
|
||||||
option_text = (
|
option_text = f"{prefix}{t.number}" + (f". {display_name}" if t.name else "")
|
||||||
f"{prefix}{t.number}" + (f". {display_name}" if t.name else "")
|
|
||||||
)
|
|
||||||
|
|
||||||
selection_titles.append(option_text)
|
selection_titles.append(option_text)
|
||||||
current_ui_idx = len(selection_titles) - 1
|
current_ui_idx = len(selection_titles) - 1
|
||||||
|
|
||||||
# Map UI index to actual title index
|
# Map UI index to actual title index
|
||||||
original_indices[current_ui_idx] = i
|
original_indices[current_ui_idx] = i
|
||||||
|
|
||||||
# Link episode to season header for group selection
|
# Link episode to season header for group selection
|
||||||
if current_season_header_idx != -1:
|
if current_season_header_idx != -1:
|
||||||
dependencies[current_season_header_idx].append(current_ui_idx)
|
dependencies[current_season_header_idx].append(current_ui_idx)
|
||||||
|
|
||||||
selection_start = time.time()
|
selection_start = time.time()
|
||||||
|
|
||||||
# Execute selector with dependencies (headers select all children)
|
# Execute selector with dependencies (headers select all children)
|
||||||
selected_ui_idx = select_multiple(
|
selected_ui_idx = select_multiple(
|
||||||
selection_titles,
|
selection_titles,
|
||||||
minimal_count=1,
|
minimal_count=1,
|
||||||
page_size=8,
|
page_size=8,
|
||||||
return_indices=True,
|
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()
|
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)
|
# Map UI indices back to title indices (excluding headers)
|
||||||
selected_idx = []
|
selected_idx = []
|
||||||
@@ -1155,15 +1277,20 @@ class dl:
|
|||||||
|
|
||||||
if isinstance(title, Episode) and not self.tmdb_searched:
|
if isinstance(title, Episode) and not self.tmdb_searched:
|
||||||
kind = "tv"
|
kind = "tv"
|
||||||
|
tmdb_title: Optional[str] = None
|
||||||
if self.tmdb_id:
|
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
|
self.tmdb_id, kind, title_cacher, cache_title_id, cache_region, cache_account_hash
|
||||||
)
|
)
|
||||||
else:
|
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
|
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
|
self.tmdb_id = None
|
||||||
if list_ or list_titles:
|
if list_ or list_titles:
|
||||||
if self.tmdb_id:
|
if self.tmdb_id:
|
||||||
@@ -1178,22 +1305,25 @@ class dl:
|
|||||||
self.tmdb_searched = True
|
self.tmdb_searched = True
|
||||||
|
|
||||||
if isinstance(title, Movie) and (list_ or list_titles) and not self.tmdb_id:
|
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
|
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(
|
console.print(
|
||||||
Padding(
|
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),
|
(0, 5),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
console.print(Padding("Search -> [bright_black]No match found[/]", (0, 5)))
|
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"
|
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:
|
if slow and i != 0:
|
||||||
delay = random.randint(60, 120)
|
delay = random.randint(60, 120)
|
||||||
@@ -1223,6 +1353,15 @@ class dl:
|
|||||||
v_lang = None
|
v_lang = None
|
||||||
title.tracks.videos = []
|
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"):
|
with console.status("Getting tracks...", spinner="dots"):
|
||||||
try:
|
try:
|
||||||
title.tracks.add(service.get_tracks(title), warn_only=True)
|
title.tracks.add(service.get_tracks(title), warn_only=True)
|
||||||
@@ -1336,9 +1475,12 @@ class dl:
|
|||||||
if isinstance(title, (Movie, Episode)):
|
if isinstance(title, (Movie, Episode)):
|
||||||
# filter video tracks
|
# filter video tracks
|
||||||
if vcodec:
|
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:
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
if range_:
|
if range_:
|
||||||
@@ -1350,10 +1492,20 @@ class dl:
|
|||||||
self.log.warning(f"Skipping {color_range.name} video tracks as none are available.")
|
self.log.warning(f"Skipping {color_range.name} video tracks as none are available.")
|
||||||
|
|
||||||
if vbitrate:
|
if vbitrate:
|
||||||
title.tracks.select_video(lambda x: x.bitrate and x.bitrate // 1000 == vbitrate)
|
if any(r == Video.Range.HYBRID for r in range_):
|
||||||
if not title.tracks.videos:
|
# In HYBRID mode, only apply bitrate filter to non-DV tracks
|
||||||
self.log.error(f"There's no {vbitrate}kbps Video Track...")
|
# DV tracks are kept regardless since they're only used for RPU metadata
|
||||||
sys.exit(1)
|
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"]
|
video_languages = [lang for lang in (v_lang or lang) if lang != "best"]
|
||||||
if video_languages and "all" not in video_languages:
|
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...")
|
self.log.error(f"There's no {processed_video_lang} Video Track...")
|
||||||
sys.exit(1)
|
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:
|
if quality:
|
||||||
missing_resolutions = []
|
missing_resolutions = []
|
||||||
if any(r == Video.Range.HYBRID for r in range_):
|
if has_hybrid:
|
||||||
title.tracks.select_video(title.tracks.select_hybrid(title.tracks.videos, quality))
|
# 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:
|
else:
|
||||||
title.tracks.by_resolutions(quality)
|
title.tracks.by_resolutions(quality)
|
||||||
|
|
||||||
@@ -1410,60 +1590,111 @@ class dl:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# choose best track by range and quality
|
# choose best track by range and quality
|
||||||
if any(r == Video.Range.HYBRID for r in range_):
|
if has_hybrid:
|
||||||
# For hybrid mode, always apply hybrid selection
|
# Apply hybrid selection for HYBRID tracks
|
||||||
# If no quality specified, use only the best (highest) resolution
|
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:
|
if not quality:
|
||||||
# Get the highest resolution available
|
best_resolution = max((v.height for v in hybrid_candidate_tracks), default=None)
|
||||||
best_resolution = max((v.height for v in title.tracks.videos), default=None)
|
|
||||||
if best_resolution:
|
if best_resolution:
|
||||||
# Use the hybrid selection logic with only the best resolution
|
hybrid_filter = title.tracks.select_hybrid(hybrid_candidate_tracks, [best_resolution])
|
||||||
title.tracks.select_video(
|
hybrid_selected = list(filter(hybrid_filter, hybrid_candidate_tracks))
|
||||||
title.tracks.select_hybrid(title.tracks.videos, [best_resolution])
|
else:
|
||||||
)
|
hybrid_selected = []
|
||||||
# If quality was specified, hybrid selection was already applied above
|
else:
|
||||||
else:
|
hybrid_filter = title.tracks.select_hybrid(hybrid_candidate_tracks, quality)
|
||||||
selected_videos: list[Video] = []
|
hybrid_selected = list(filter(hybrid_filter, hybrid_candidate_tracks))
|
||||||
for resolution, color_range in product(quality or [None], range_ or [None]):
|
|
||||||
match = next(
|
# 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
|
t
|
||||||
for t in title.tracks.videos
|
for t in non_hybrid_tracks
|
||||||
if (
|
if (
|
||||||
not resolution
|
not resolution
|
||||||
or t.height == resolution
|
or t.height == resolution
|
||||||
or int(t.width * (9 / 16)) == resolution
|
or int(t.width * (9 / 16)) == resolution
|
||||||
)
|
)
|
||||||
and (not color_range or t.range == color_range)
|
and (not color_range or t.range == color_range)
|
||||||
),
|
and (not codec or t.codec == codec)
|
||||||
None,
|
]
|
||||||
)
|
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:
|
if match and match not in selected_videos:
|
||||||
selected_videos.append(match)
|
selected_videos.append(match)
|
||||||
title.tracks.videos = selected_videos
|
title.tracks.videos = selected_videos
|
||||||
|
|
||||||
# validate hybrid mode requirements
|
# validate hybrid mode requirements
|
||||||
if any(r == Video.Range.HYBRID for r in range_):
|
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]
|
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))
|
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")
|
msg = "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but neither is available"
|
||||||
self.log.error(
|
msg_detail = (
|
||||||
f"Available ranges: {', '.join(available_ranges) if available_ranges else 'none'}"
|
f"Available ranges: {', '.join(available_ranges) if available_ranges else 'none'}"
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
hybrid_failed = True
|
||||||
elif not hdr10_tracks:
|
elif not base_tracks:
|
||||||
available_ranges = sorted(set(v.range.name for v in title.tracks.videos))
|
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")
|
msg = "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only DV is available"
|
||||||
self.log.error(f"Available ranges: {', '.join(available_ranges)}")
|
msg_detail = f"Available ranges: {', '.join(available_ranges)}"
|
||||||
sys.exit(1)
|
hybrid_failed = True
|
||||||
elif not dv_tracks:
|
elif not dv_tracks:
|
||||||
available_ranges = sorted(set(v.range.name for v in title.tracks.videos))
|
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")
|
msg = "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only HDR10 is available"
|
||||||
self.log.error(f"Available ranges: {', '.join(available_ranges)}")
|
msg_detail = f"Available ranges: {', '.join(available_ranges)}"
|
||||||
sys.exit(1)
|
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
|
# filter subtitle tracks
|
||||||
if require_subs:
|
if require_subs:
|
||||||
@@ -1481,8 +1712,6 @@ class dl:
|
|||||||
f"Required languages found ({', '.join(require_subs)}), downloading all available subtitles"
|
f"Required languages found ({', '.join(require_subs)}), downloading all available subtitles"
|
||||||
)
|
)
|
||||||
elif s_lang and "all" not in s_lang:
|
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
|
match_func = is_exact_match if exact_lang else is_close_match
|
||||||
|
|
||||||
missing_langs = [
|
missing_langs = [
|
||||||
@@ -1547,7 +1776,7 @@ class dl:
|
|||||||
if language not in processed_lang:
|
if language not in processed_lang:
|
||||||
processed_lang.append(language)
|
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}
|
unique_languages = {track.language for track in title.tracks.audio}
|
||||||
selected_audio = []
|
selected_audio = []
|
||||||
for language in unique_languages:
|
for language in unique_languages:
|
||||||
@@ -1570,7 +1799,7 @@ class dl:
|
|||||||
else:
|
else:
|
||||||
selected_audio.append(max(base_candidates, key=lambda x: x.bitrate or 0))
|
selected_audio.append(max(base_candidates, key=lambda x: x.bitrate or 0))
|
||||||
title.tracks.audio = selected_audio
|
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
|
# If multiple codecs were explicitly requested, pick the best track per codec per
|
||||||
# requested language instead of selecting *all* bitrate variants of a codec.
|
# requested language instead of selecting *all* bitrate variants of a codec.
|
||||||
if acodec and len(acodec) > 1:
|
if acodec and len(acodec) > 1:
|
||||||
@@ -1604,7 +1833,10 @@ class dl:
|
|||||||
if audio_description:
|
if audio_description:
|
||||||
standard_audio = [a for a in title.tracks.audio if not a.descriptive]
|
standard_audio = [a for a in title.tracks.audio if not a.descriptive]
|
||||||
selected_standards = title.tracks.by_language(
|
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]
|
desc_audio = [a for a in title.tracks.audio if a.descriptive]
|
||||||
# Include all descriptive tracks for the requested languages.
|
# Include all descriptive tracks for the requested languages.
|
||||||
@@ -1728,10 +1960,7 @@ class dl:
|
|||||||
),
|
),
|
||||||
licence=partial(
|
licence=partial(
|
||||||
service.get_playready_license
|
service.get_playready_license
|
||||||
if (
|
if is_playready_cdm(self.cdm)
|
||||||
is_playready_cdm(self.cdm)
|
|
||||||
)
|
|
||||||
and hasattr(service, "get_playready_license")
|
|
||||||
else service.get_widevine_license,
|
else service.get_widevine_license,
|
||||||
title=title,
|
title=title,
|
||||||
track=track,
|
track=track,
|
||||||
@@ -1802,55 +2031,10 @@ class dl:
|
|||||||
dl_time = time_elapsed_since(dl_start_time)
|
dl_time = time_elapsed_since(dl_start_time)
|
||||||
console.print(Padding(f"Track downloads finished in [progress.elapsed]{dl_time}[/]", (0, 5)))
|
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 configuration (for sidecar originals)
|
||||||
subtitle_output_mode = config.subtitle.get("output_mode", "mux")
|
subtitle_output_mode = config.subtitle.get("output_mode", "mux")
|
||||||
sidecar_format = config.subtitle.get("sidecar_format", "srt")
|
sidecar_format = config.subtitle.get("sidecar_format", "srt")
|
||||||
skip_subtitle_mux = (
|
skip_subtitle_mux = subtitle_output_mode == "sidecar" and (title.tracks.videos or title.tracks.audio)
|
||||||
subtitle_output_mode == "sidecar" and (title.tracks.videos or title.tracks.audio)
|
|
||||||
)
|
|
||||||
sidecar_subtitles: list[Subtitle] = []
|
sidecar_subtitles: list[Subtitle] = []
|
||||||
sidecar_original_paths: dict[str, Path] = {}
|
sidecar_original_paths: dict[str, Path] = {}
|
||||||
if subtitle_output_mode in ("sidecar", "both") and not no_mux:
|
if subtitle_output_mode in ("sidecar", "both") and not no_mux:
|
||||||
@@ -1912,6 +2096,75 @@ class dl:
|
|||||||
if has_decrypted:
|
if has_decrypted:
|
||||||
self.log.info(f"Decrypted tracks with {decrypt_tool}")
|
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
|
# Now repack the decrypted tracks
|
||||||
with console.status("Repackaging tracks with FFMPEG..."):
|
with console.status("Repackaging tracks with FFMPEG..."):
|
||||||
has_repacked = False
|
has_repacked = False
|
||||||
@@ -1987,12 +2240,14 @@ class dl:
|
|||||||
# Hybrid mode: process DV and HDR10 tracks separately for each resolution
|
# Hybrid mode: process DV and HDR10 tracks separately for each resolution
|
||||||
self.log.info("Processing Hybrid HDR10+DV tracks...")
|
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()
|
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]
|
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
|
resolution = hdr10_track.height
|
||||||
if resolution in resolutions_processed:
|
if resolution in resolutions_processed:
|
||||||
continue
|
continue
|
||||||
@@ -2048,6 +2303,8 @@ class dl:
|
|||||||
task_description += f" {video_track.height}p"
|
task_description += f" {video_track.height}p"
|
||||||
if len(range_) > 1:
|
if len(range_) > 1:
|
||||||
task_description += f" {video_track.range.name}"
|
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
|
task_tracks = Tracks(title.tracks) + title.tracks.chapters + title.tracks.attachments
|
||||||
if video_track:
|
if video_track:
|
||||||
@@ -2099,9 +2356,11 @@ class dl:
|
|||||||
else:
|
else:
|
||||||
base_filename = str(title)
|
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:
|
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)
|
sidecar_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
with console.status("Saving subtitle sidecar files..."):
|
with console.status("Saving subtitle sidecar files..."):
|
||||||
@@ -2151,7 +2410,7 @@ class dl:
|
|||||||
|
|
||||||
if no_mux:
|
if no_mux:
|
||||||
# Handle individual track files without muxing
|
# 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)):
|
if not no_folder and isinstance(title, (Episode, Song)):
|
||||||
# Create folder based on title
|
# Create folder based on title
|
||||||
# Use first available track for filename generation
|
# Use first available track for filename generation
|
||||||
@@ -2201,9 +2460,10 @@ class dl:
|
|||||||
self.log.debug(f"Saved: {final_path.name}")
|
self.log.debug(f"Saved: {final_path.name}")
|
||||||
else:
|
else:
|
||||||
# Handle muxed files
|
# Handle muxed files
|
||||||
|
used_final_paths: set[Path] = set()
|
||||||
for muxed_path in muxed_paths:
|
for muxed_path in muxed_paths:
|
||||||
media_info = MediaInfo.parse(muxed_path)
|
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)
|
final_filename = title.get_filename(media_info, show_service=not no_source)
|
||||||
audio_codec_suffix = muxed_audio_codecs.get(muxed_path)
|
audio_codec_suffix = muxed_audio_codecs.get(muxed_path)
|
||||||
|
|
||||||
@@ -2212,20 +2472,29 @@ class dl:
|
|||||||
|
|
||||||
final_dir.mkdir(parents=True, exist_ok=True)
|
final_dir.mkdir(parents=True, exist_ok=True)
|
||||||
final_path = final_dir / f"{final_filename}{muxed_path.suffix}"
|
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:
|
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_filename = f"{final_filename.rstrip()}{sep}{audio_codec_suffix.name}"
|
||||||
final_path = final_dir / f"{final_filename}{muxed_path.suffix}"
|
final_path = final_dir / f"{final_filename}{muxed_path.suffix}"
|
||||||
|
|
||||||
if final_path.exists():
|
if final_path in used_final_paths:
|
||||||
sep = "." if config.scene_naming else " "
|
|
||||||
i = 2
|
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}"
|
final_path = final_dir / f"{final_filename.rstrip()}{sep}{i}{muxed_path.suffix}"
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
shutil.move(muxed_path, final_path)
|
try:
|
||||||
tags.tag_file(final_path, title, self.tmdb_id)
|
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)
|
title_dl_time = time_elapsed_since(dl_start_time)
|
||||||
console.print(
|
console.print(
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "3.0.0"
|
__version__ = "4.0.0"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import atexit
|
import atexit
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import urllib3
|
import urllib3
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from contextlib import suppress
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
log = logging.getLogger("download_manager")
|
log = logging.getLogger("download_manager")
|
||||||
@@ -105,11 +106,44 @@ def _perform_download(
|
|||||||
from envied.commands.dl import dl
|
from envied.commands.dl import dl
|
||||||
from envied.core.config import config
|
from envied.core.config import config
|
||||||
from envied.core.services import Services
|
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.click_types import ContextData
|
||||||
from envied.core.utils.collections import merge_dict
|
from envied.core.utils.collections import merge_dict
|
||||||
|
|
||||||
log.info(f"Starting sync download for job {job_id}")
|
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
|
# Load service configuration
|
||||||
service_config_path = Services.get_path(service) / config.filenames.config
|
service_config_path = Services.get_path(service) / config.filenames.config
|
||||||
if service_config_path.exists():
|
if service_config_path.exists():
|
||||||
@@ -127,10 +161,15 @@ def _perform_download(
|
|||||||
"proxy": params.get("proxy"),
|
"proxy": params.get("proxy"),
|
||||||
"no_proxy": params.get("no_proxy", False),
|
"no_proxy": params.get("no_proxy", False),
|
||||||
"profile": params.get("profile"),
|
"profile": params.get("profile"),
|
||||||
|
"repack": params.get("repack", False),
|
||||||
"tag": params.get("tag"),
|
"tag": params.get("tag"),
|
||||||
"tmdb_id": params.get("tmdb_id"),
|
"tmdb_id": params.get("tmdb_id"),
|
||||||
"tmdb_name": params.get("tmdb_name", False),
|
"imdb_id": params.get("imdb_id"),
|
||||||
"tmdb_year": params.get("tmdb_year", False),
|
"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(
|
dl_instance = dl(
|
||||||
@@ -138,10 +177,13 @@ def _perform_download(
|
|||||||
no_proxy=params.get("no_proxy", False),
|
no_proxy=params.get("no_proxy", False),
|
||||||
profile=params.get("profile"),
|
profile=params.get("profile"),
|
||||||
proxy=params.get("proxy"),
|
proxy=params.get("proxy"),
|
||||||
|
repack=params.get("repack", False),
|
||||||
tag=params.get("tag"),
|
tag=params.get("tag"),
|
||||||
tmdb_id=params.get("tmdb_id"),
|
tmdb_id=params.get("tmdb_id"),
|
||||||
tmdb_name=params.get("tmdb_name", False),
|
imdb_id=params.get("imdb_id"),
|
||||||
tmdb_year=params.get("tmdb_year", False),
|
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)
|
service_module = Services.load(service)
|
||||||
@@ -220,14 +262,14 @@ def _perform_download(
|
|||||||
dl_instance.result(
|
dl_instance.result(
|
||||||
service=service_instance,
|
service=service_instance,
|
||||||
quality=params.get("quality", []),
|
quality=params.get("quality", []),
|
||||||
vcodec=params.get("vcodec"),
|
vcodec=params.get("vcodec", []),
|
||||||
acodec=params.get("acodec"),
|
acodec=params.get("acodec"),
|
||||||
vbitrate=params.get("vbitrate"),
|
vbitrate=params.get("vbitrate"),
|
||||||
abitrate=params.get("abitrate"),
|
abitrate=params.get("abitrate"),
|
||||||
range_=params.get("range", ["SDR"]),
|
range_=params.get("range", ["SDR"]),
|
||||||
channels=params.get("channels"),
|
channels=params.get("channels"),
|
||||||
no_atmos=params.get("no_atmos", False),
|
no_atmos=params.get("no_atmos", False),
|
||||||
split_audio=params.get("split_audio"),
|
select_titles=False,
|
||||||
wanted=params.get("wanted", []),
|
wanted=params.get("wanted", []),
|
||||||
latest_episode=params.get("latest_episode", False),
|
latest_episode=params.get("latest_episode", False),
|
||||||
lang=params.get("lang", ["orig"]),
|
lang=params.get("lang", ["orig"]),
|
||||||
@@ -245,6 +287,7 @@ def _perform_download(
|
|||||||
no_subs=params.get("no_subs", False),
|
no_subs=params.get("no_subs", False),
|
||||||
no_audio=params.get("no_audio", False),
|
no_audio=params.get("no_audio", False),
|
||||||
no_chapters=params.get("no_chapters", False),
|
no_chapters=params.get("no_chapters", False),
|
||||||
|
no_video=params.get("no_video", False),
|
||||||
audio_description=params.get("audio_description", False),
|
audio_description=params.get("audio_description", False),
|
||||||
slow=params.get("slow", False),
|
slow=params.get("slow", False),
|
||||||
list_=False,
|
list_=False,
|
||||||
@@ -259,6 +302,7 @@ def _perform_download(
|
|||||||
workers=params.get("workers"),
|
workers=params.get("workers"),
|
||||||
downloads=params.get("downloads", 1),
|
downloads=params.get("downloads", 1),
|
||||||
best_available=params.get("best_available", False),
|
best_available=params.get("best_available", False),
|
||||||
|
split_audio=params.get("split_audio"),
|
||||||
)
|
)
|
||||||
|
|
||||||
except SystemExit as exc:
|
except SystemExit as exc:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import enum
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
@@ -42,8 +43,10 @@ DEFAULT_DOWNLOAD_PARAMS = {
|
|||||||
"no_subs": False,
|
"no_subs": False,
|
||||||
"no_audio": False,
|
"no_audio": False,
|
||||||
"no_chapters": False,
|
"no_chapters": False,
|
||||||
|
"no_video": False,
|
||||||
"audio_description": False,
|
"audio_description": False,
|
||||||
"slow": False,
|
"slow": False,
|
||||||
|
"split_audio": None,
|
||||||
"skip_dl": False,
|
"skip_dl": False,
|
||||||
"export": None,
|
"export": None,
|
||||||
"cdm_only": None,
|
"cdm_only": None,
|
||||||
@@ -54,6 +57,11 @@ DEFAULT_DOWNLOAD_PARAMS = {
|
|||||||
"workers": None,
|
"workers": None,
|
||||||
"downloads": 1,
|
"downloads": 1,
|
||||||
"best_available": False,
|
"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
|
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:
|
async def list_titles_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
|
||||||
"""Handle list-titles request."""
|
"""Handle list-titles request."""
|
||||||
service_tag = data.get("service")
|
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:
|
for param in service_module.cli.params:
|
||||||
if hasattr(param, "name") and param.name not in service_kwargs:
|
if hasattr(param, "name") and param.name not in service_kwargs:
|
||||||
# Add default value if parameter is not already provided
|
# 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
|
service_kwargs[param.name] = param.default
|
||||||
|
|
||||||
# Handle required parameters that don't have click defaults
|
# 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:
|
for param in service_module.cli.params:
|
||||||
if hasattr(param, "name") and param.name not in service_kwargs:
|
if hasattr(param, "name") and param.name not in service_kwargs:
|
||||||
# Add default value if parameter is not already provided
|
# 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
|
service_kwargs[param.name] = param.default
|
||||||
|
|
||||||
# Handle required parameters that don't have click defaults
|
# 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:
|
try:
|
||||||
season_range = SeasonRange()
|
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]}...")
|
log.debug(f"Parsed wanted '{wanted_param}' into {len(wanted)} episodes: {wanted[:10]}...")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise APIError(
|
raise APIError(
|
||||||
@@ -760,9 +902,17 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]:
|
|||||||
None if valid, error message string if invalid
|
None if valid, error message string if invalid
|
||||||
"""
|
"""
|
||||||
if "vcodec" in data and data["vcodec"]:
|
if "vcodec" in data and data["vcodec"]:
|
||||||
valid_vcodecs = ["H264", "H265", "VP9", "AV1"]
|
valid_vcodecs = ["H264", "H265", "H.264", "H.265", "AVC", "HEVC", "VC1", "VC-1", "VP8", "VP9", "AV1"]
|
||||||
if data["vcodec"].upper() not in valid_vcodecs:
|
if isinstance(data["vcodec"], str):
|
||||||
return f"Invalid vcodec: {data['vcodec']}. Must be one of: {', '.join(valid_vcodecs)}"
|
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"]:
|
if "acodec" in data and data["acodec"]:
|
||||||
valid_acodecs = ["AAC", "AC3", "EC3", "EAC3", "DD", "DD+", "AC4", "OPUS", "FLAC", "ALAC", "VORBIS", "OGG", "DTS"]
|
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)}"
|
return f"Invalid acodec: {', '.join(invalid)}. Must be one of: {', '.join(valid_acodecs)}"
|
||||||
|
|
||||||
if "sub_format" in data and data["sub_format"]:
|
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:
|
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)}"
|
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
|
# Extract default values from the service's click command
|
||||||
if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"):
|
if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"):
|
||||||
for param in 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)
|
# Store service-specific defaults (e.g., drm_system, hydrate_track, profile for NF)
|
||||||
service_specific_defaults[param.name] = param.default
|
service_specific_defaults[param.name] = param.default
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ from aiohttp_swagger3 import SwaggerDocs, SwaggerInfo, SwaggerUiSettings
|
|||||||
from envied.core import __version__
|
from envied.core import __version__
|
||||||
from envied.core.api.errors import APIError, APIErrorCode, build_error_response, handle_api_exception
|
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,
|
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.services import Services
|
||||||
from envied.core.update_checker import UpdateChecker
|
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)
|
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:
|
async def list_titles(request: web.Request) -> web.Response:
|
||||||
"""
|
"""
|
||||||
List titles for a service and title ID.
|
List titles for a service and title ID.
|
||||||
@@ -409,11 +497,19 @@ async def download(request: web.Request) -> web.Response:
|
|||||||
type: integer
|
type: integer
|
||||||
description: Download resolution(s) (default - best available)
|
description: Download resolution(s) (default - best available)
|
||||||
vcodec:
|
vcodec:
|
||||||
type: string
|
oneOf:
|
||||||
description: Video codec to download (e.g., H264, H265, VP9, AV1) (default - None)
|
- 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:
|
acodec:
|
||||||
type: string
|
oneOf:
|
||||||
description: Audio codec(s) to download (e.g., AAC or AAC,EC3) (default - None)
|
- 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:
|
vbitrate:
|
||||||
type: integer
|
type: integer
|
||||||
description: Video bitrate in kbps (default - None)
|
description: Video bitrate in kbps (default - None)
|
||||||
@@ -424,7 +520,7 @@ async def download(request: web.Request) -> web.Response:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
description: Video color range (SDR, HDR10, DV) (default - ["SDR"])
|
description: Video color range (SDR, HDR10, HDR10+, HLG, DV, HYBRID) (default - ["SDR"])
|
||||||
channels:
|
channels:
|
||||||
type: number
|
type: number
|
||||||
description: Audio channels (e.g., 2.0, 5.1, 7.1) (default - None)
|
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:
|
no_chapters:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Do not download chapters (default - false)
|
description: Do not download chapters (default - false)
|
||||||
|
no_video:
|
||||||
|
type: boolean
|
||||||
|
description: Do not download video tracks (default - false)
|
||||||
audio_description:
|
audio_description:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Download audio description tracks (default - false)
|
description: Download audio description tracks (default - false)
|
||||||
slow:
|
slow:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Add 60-120s delay between downloads (default - false)
|
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:
|
skip_dl:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Skip downloading, only retrieve decryption keys (default - false)
|
description: Skip downloading, only retrieve decryption keys (default - false)
|
||||||
@@ -521,12 +623,12 @@ async def download(request: web.Request) -> web.Response:
|
|||||||
tmdb_id:
|
tmdb_id:
|
||||||
type: integer
|
type: integer
|
||||||
description: Use this TMDB ID for tagging (default - None)
|
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
|
type: boolean
|
||||||
description: Rename titles using TMDB name (default - false)
|
description: Override show title and year from external source (default - false)
|
||||||
tmdb_year:
|
|
||||||
type: boolean
|
|
||||||
description: Use release year from TMDB (default - false)
|
|
||||||
no_folder:
|
no_folder:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Disable folder creation for TV shows (default - false)
|
description: Disable folder creation for TV shows (default - false)
|
||||||
@@ -545,6 +647,21 @@ async def download(request: web.Request) -> web.Response:
|
|||||||
best_available:
|
best_available:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Continue with best available if requested quality unavailable (default - false)
|
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:
|
responses:
|
||||||
'202':
|
'202':
|
||||||
description: Download job created
|
description: Download job created
|
||||||
@@ -723,6 +840,7 @@ def setup_routes(app: web.Application) -> None:
|
|||||||
"""Setup all API routes."""
|
"""Setup all API routes."""
|
||||||
app.router.add_get("/api/health", health)
|
app.router.add_get("/api/health", health)
|
||||||
app.router.add_get("/api/services", services)
|
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-titles", list_titles)
|
||||||
app.router.add_post("/api/list-tracks", list_tracks)
|
app.router.add_post("/api/list-tracks", list_tracks)
|
||||||
app.router.add_post("/api/download", download)
|
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/health", health),
|
||||||
web.get("/api/services", services),
|
web.get("/api/services", services),
|
||||||
|
web.post("/api/search", search),
|
||||||
web.post("/api/list-titles", list_titles),
|
web.post("/api/list-titles", list_titles),
|
||||||
web.post("/api/list-tracks", list_tracks),
|
web.post("/api/list-tracks", list_tracks),
|
||||||
web.post("/api/download", download),
|
web.post("/api/download", download),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import warnings
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
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.decrypt_labs_api_key: str = kwargs.get("decrypt_labs_api_key") or ""
|
||||||
self.update_checks: bool = kwargs.get("update_checks", True)
|
self.update_checks: bool = kwargs.get("update_checks", True)
|
||||||
self.update_check_interval: int = kwargs.get("update_check_interval", 24)
|
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.language_tags: dict = kwargs.get("language_tags") or {}
|
||||||
self.series_year: bool = kwargs.get("series_year", True)
|
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.unicode_filenames: bool = kwargs.get("unicode_filenames", False)
|
||||||
|
|
||||||
self.title_cache_time: int = kwargs.get("title_cache_time", 1800) # 30 minutes default
|
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: bool = kwargs.get("debug", False)
|
||||||
self.debug_keys: bool = kwargs.get("debug_keys", 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
|
@classmethod
|
||||||
def from_yaml(cls, path: Path) -> Config:
|
def from_yaml(cls, path: Path) -> Config:
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
|
|||||||
@@ -431,14 +431,24 @@ def download(
|
|||||||
raise ValueError(error)
|
raise ValueError(error)
|
||||||
|
|
||||||
# Yield aggregate progress for this call's downloads
|
# Yield aggregate progress for this call's downloads
|
||||||
if total_size > 0:
|
progress_data = {"advance": 0}
|
||||||
# Yield both advance (bytes downloaded this iteration) and total for rich progress
|
|
||||||
if dl_speed != -1:
|
if len(gids) > 1:
|
||||||
yield dict(downloaded=f"{filesize.decimal(dl_speed)}/s", advance=0, completed=total_completed, total=total_size)
|
# 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:
|
else:
|
||||||
yield dict(advance=0, completed=total_completed, total=total_size)
|
progress_data["total"] = None
|
||||||
elif dl_speed != -1:
|
|
||||||
yield dict(downloaded=f"{filesize.decimal(dl_speed)}/s")
|
if dl_speed != -1:
|
||||||
|
progress_data["downloaded"] = f"{filesize.decimal(dl_speed)}/s"
|
||||||
|
|
||||||
|
yield progress_data
|
||||||
|
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
|||||||
@@ -191,6 +191,9 @@ def build_download_args(
|
|||||||
args["--skip-merge"] = skip_merge
|
args["--skip-merge"] = skip_merge
|
||||||
if ad_keyword:
|
if ad_keyword:
|
||||||
args["--ad-keyword"] = 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 = []
|
key_args = []
|
||||||
if content_keys:
|
if content_keys:
|
||||||
|
|||||||
@@ -44,16 +44,15 @@ class PlayReady:
|
|||||||
else:
|
else:
|
||||||
kids = []
|
kids = []
|
||||||
|
|
||||||
# Extract KIDs using pyplayready's method (may miss some KIDs)
|
# Extract KIDs using pyplayready's WrmHeader key_ids
|
||||||
if not kids:
|
if not kids:
|
||||||
for header in pssh.wrm_headers:
|
for header in pssh.wrm_headers:
|
||||||
try:
|
for signed_key_id in getattr(header, "key_ids", []):
|
||||||
signed_ids, _, _, _ = header.read_attributes()
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
for signed_id in signed_ids:
|
|
||||||
try:
|
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:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -119,7 +118,33 @@ class PlayReady:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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)
|
individual_kids = root.findall(".//pr:DATA/pr:KID", ns)
|
||||||
for kid_elem in individual_kids:
|
for kid_elem in individual_kids:
|
||||||
if kid_elem.text:
|
if kid_elem.text:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import math
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from copy import copy
|
from copy import copy, deepcopy
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Optional, Union
|
from typing import Any, Callable, Optional, Union
|
||||||
@@ -18,6 +18,7 @@ from zlib import crc32
|
|||||||
import requests
|
import requests
|
||||||
from curl_cffi.requests import Session as CurlSession
|
from curl_cffi.requests import Session as CurlSession
|
||||||
from langcodes import Language, tag_is_valid
|
from langcodes import Language, tag_is_valid
|
||||||
|
from lxml import etree
|
||||||
from lxml.etree import Element, ElementTree
|
from lxml.etree import Element, ElementTree
|
||||||
from pyplayready.system.pssh import PSSH as PR_PSSH
|
from pyplayready.system.pssh import PSSH as PR_PSSH
|
||||||
from pywidevine.cdm import Cdm as WidevineCdm
|
from pywidevine.cdm import Cdm as WidevineCdm
|
||||||
@@ -101,14 +102,22 @@ class DASH:
|
|||||||
"""
|
"""
|
||||||
tracks = Tracks()
|
tracks = Tracks()
|
||||||
|
|
||||||
|
filtered_period_ids: list[str] = []
|
||||||
|
|
||||||
for period in self.manifest.findall("Period"):
|
for period in self.manifest.findall("Period"):
|
||||||
if callable(period_filter) and period_filter(period):
|
if callable(period_filter) and period_filter(period):
|
||||||
|
if period_id := period.get("id"):
|
||||||
|
filtered_period_ids.append(period_id)
|
||||||
continue
|
continue
|
||||||
if next(iter(period.xpath("SegmentType/@value")), "content") != "content":
|
if next(iter(period.xpath("SegmentType/@value")), "content") != "content":
|
||||||
|
if period_id := period.get("id"):
|
||||||
|
filtered_period_ids.append(period_id)
|
||||||
continue
|
continue
|
||||||
if "urn:amazon:primevideo:cachingBreadth" in [
|
if "urn:amazon:primevideo:cachingBreadth" in [
|
||||||
x.get("schemeIdUri") for x in period.findall("SupplementalProperty")
|
x.get("schemeIdUri") for x in period.findall("SupplementalProperty")
|
||||||
]:
|
]:
|
||||||
|
if period_id := period.get("id"):
|
||||||
|
filtered_period_ids.append(period_id)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for adaptation_set in period.findall("AdaptationSet"):
|
for adaptation_set in period.findall("AdaptationSet"):
|
||||||
@@ -235,6 +244,7 @@ class DASH:
|
|||||||
"period": period,
|
"period": period,
|
||||||
"adaptation_set": adaptation_set,
|
"adaptation_set": adaptation_set,
|
||||||
"representation": rep,
|
"representation": rep,
|
||||||
|
"filtered_period_ids": filtered_period_ids,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
**track_args,
|
**track_args,
|
||||||
@@ -541,6 +551,26 @@ class DASH:
|
|||||||
skip_merge = False
|
skip_merge = False
|
||||||
if downloader.__name__ == "n_m3u8dl_re":
|
if downloader.__name__ == "n_m3u8dl_re":
|
||||||
skip_merge = True
|
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(
|
downloader_args.update(
|
||||||
{
|
{
|
||||||
"filename": track.id,
|
"filename": track.id,
|
||||||
@@ -578,6 +608,11 @@ class DASH:
|
|||||||
status_update["downloaded"] = f"DASH {downloaded}"
|
status_update["downloaded"] = f"DASH {downloaded}"
|
||||||
progress(**status_update)
|
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
|
# see https://github.com/devine-dl/devine/issues/71
|
||||||
for control_file in save_dir.glob("*.aria2__temp"):
|
for control_file in save_dir.glob("*.aria2__temp"):
|
||||||
control_file.unlink()
|
control_file.unlink()
|
||||||
|
|||||||
@@ -112,6 +112,15 @@ class HLS:
|
|||||||
session_drm = HLS.get_all_drm(session_keys)
|
session_drm = HLS.get_all_drm(session_keys)
|
||||||
|
|
||||||
audio_codecs_by_group_id: dict[str, Audio.Codec] = {}
|
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()
|
tracks = Tracks()
|
||||||
|
|
||||||
for playlist in self.manifest.playlists:
|
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,
|
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,
|
height=playlist.stream_info.resolution[1] if playlist.stream_info.resolution else None,
|
||||||
fps=playlist.stream_info.frame_rate,
|
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
|
if primary_track_type is Video
|
||||||
else {}
|
else {}
|
||||||
|
|||||||
@@ -145,7 +145,14 @@ class ISM:
|
|||||||
fragment_time += duration_frag
|
fragment_time += duration_frag
|
||||||
|
|
||||||
track_id = hashlib.md5(
|
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()
|
).hexdigest()
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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))
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import base64
|
import base64
|
||||||
import logging
|
import logging
|
||||||
from abc import ABCMeta, abstractmethod
|
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 http.cookiejar import CookieJar
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Union
|
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.title_cacher import TitleCacher, get_account_hash, get_region_from_proxy
|
||||||
from envied.core.titles import Title_T, Titles_T
|
from envied.core.titles import Title_T, Titles_T
|
||||||
from envied.core.tracks import Chapters, Tracks
|
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
|
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]:
|
def sanitize_proxy_for_log(uri: Optional[str]) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Sanitize a proxy URI for logs by redacting any embedded userinfo (username/password).
|
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.credential = None # Will be set in authenticate()
|
||||||
self.current_region = None # Will be set based on proxy/geolocation
|
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 not ctx.parent or not ctx.parent.params.get("no_proxy"):
|
||||||
if ctx.parent:
|
if ctx.parent:
|
||||||
proxy = ctx.parent.params["proxy"]
|
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.log.debug(f"Failed to get cached IP info: {e}")
|
||||||
self.current_region = None
|
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
|
# Optional Abstract functions
|
||||||
# The following functions may be implemented by the Service.
|
# The following functions may be implemented by the Service.
|
||||||
# Otherwise, the base service code (if any) of the function will be executed on call.
|
# 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(
|
session.mount(
|
||||||
"https://",
|
"https://",
|
||||||
HTTPAdapter(
|
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,
|
pool_block=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -296,6 +394,27 @@ class Service(metaclass=ABCMeta):
|
|||||||
Decode the data, return as is to reduce unnecessary computations.
|
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
|
# Required Abstract functions
|
||||||
# The following functions *must* be implemented by the Service.
|
# The following functions *must* be implemented by the Service.
|
||||||
# The functions will be executed in shown order.
|
# The functions will be executed in shown order.
|
||||||
@@ -461,4 +580,4 @@ class Service(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
__all__ = ("Service",)
|
__all__ = ("Service", "TrackRequest")
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ FINGERPRINT_PRESETS = {
|
|||||||
"akamai": "4:16777216|16711681|0|m,p,a,s",
|
"akamai": "4:16777216|16711681|0|m,p,a,s",
|
||||||
"description": "OkHttp 5.x (BoringSSL TLS stack)",
|
"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):
|
class CurlSession(Session):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
max_retries: int = 10,
|
max_retries: int = 5,
|
||||||
backoff_factor: float = 0.2,
|
backoff_factor: float = 0.2,
|
||||||
max_backoff: float = 60.0,
|
max_backoff: float = 60.0,
|
||||||
status_forcelist: list[int] | None = None,
|
status_forcelist: list[int] | None = None,
|
||||||
@@ -150,7 +161,7 @@ def session(
|
|||||||
browser: Browser to impersonate (e.g. "chrome124", "firefox", "safari") OR
|
browser: Browser to impersonate (e.g. "chrome124", "firefox", "safari") OR
|
||||||
fingerprint preset name (e.g. "okhttp4").
|
fingerprint preset name (e.g. "okhttp4").
|
||||||
Uses the configured default from curl_impersonate.browser if not specified.
|
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.
|
See https://github.com/lexiforest/curl_cffi#sessions for browser options.
|
||||||
ja3: Custom JA3 TLS fingerprint string (format: "SSLVersion,Ciphers,Extensions,Curves,PointFormats").
|
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.
|
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)
|
- cert: Client certificate (str or tuple)
|
||||||
|
|
||||||
Extra arguments for retry handler:
|
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)
|
- backoff_factor: Backoff factor (float, default 0.2)
|
||||||
- max_backoff: Maximum backoff time (float, default 60.0)
|
- 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])
|
- status_forcelist: List of status codes to force retry (list, default [429, 500, 502, 503, 504])
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class TitleCacher:
|
|||||||
self.log = logging.getLogger(f"{service_name}.TitleCache")
|
self.log = logging.getLogger(f"{service_name}.TitleCache")
|
||||||
self.cacher = Cacher(service_name)
|
self.cacher = Cacher(service_name)
|
||||||
self.stats = {"hits": 0, "misses": 0, "fallbacks": 0}
|
self.stats = {"hits": 0, "misses": 0, "fallbacks": 0}
|
||||||
|
self.no_cache = False
|
||||||
|
|
||||||
def _generate_cache_key(
|
def _generate_cache_key(
|
||||||
self, title_id: str, region: Optional[str] = None, account_hash: Optional[str] = None
|
self, title_id: str, region: Optional[str] = None, account_hash: Optional[str] = None
|
||||||
@@ -59,9 +60,6 @@ class TitleCacher:
|
|||||||
# Join with underscores
|
# Join with underscores
|
||||||
cache_key = "_".join(key_parts)
|
cache_key = "_".join(key_parts)
|
||||||
|
|
||||||
# Log the mapping for debugging
|
|
||||||
self.log.debug(f"Cache key mapping: {title_id} -> {cache_key}")
|
|
||||||
|
|
||||||
return cache_key
|
return cache_key
|
||||||
|
|
||||||
def get_cached_titles(
|
def get_cached_titles(
|
||||||
@@ -89,6 +87,7 @@ class TitleCacher:
|
|||||||
"""
|
"""
|
||||||
# If caching is globally disabled or no_cache flag is set
|
# If caching is globally disabled or no_cache flag is set
|
||||||
if not config.title_cache_enabled or no_cache:
|
if not config.title_cache_enabled or no_cache:
|
||||||
|
self.no_cache = True
|
||||||
self.log.debug("Cache bypassed, fetching fresh titles")
|
self.log.debug("Cache bypassed, fetching fresh titles")
|
||||||
return fetch_function()
|
return fetch_function()
|
||||||
|
|
||||||
@@ -113,7 +112,7 @@ class TitleCacher:
|
|||||||
|
|
||||||
# Cache miss or expired, try to fetch fresh data
|
# Cache miss or expired, try to fetch fresh data
|
||||||
self.stats["misses"] += 1
|
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:
|
try:
|
||||||
# Attempt to fetch fresh titles
|
# Attempt to fetch fresh titles
|
||||||
@@ -180,22 +179,18 @@ class TitleCacher:
|
|||||||
"hit_rate": f"{hit_rate:.1f}%",
|
"hit_rate": f"{hit_rate:.1f}%",
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_cached_tmdb(
|
# -- Generic provider cache methods --
|
||||||
self, title_id: str, kind: str, region: Optional[str] = None, account_hash: Optional[str] = None
|
|
||||||
|
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]:
|
) -> Optional[dict]:
|
||||||
"""
|
"""Get cached metadata for any provider."""
|
||||||
Get cached TMDB data for a title.
|
if not config.title_cache_enabled or self.no_cache:
|
||||||
|
|
||||||
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:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
cache_key = self._generate_cache_key(title_id, region, account_hash)
|
cache_key = self._generate_cache_key(title_id, region, account_hash)
|
||||||
@@ -204,142 +199,90 @@ class TitleCacher:
|
|||||||
if not cache or not cache.data:
|
if not cache or not cache.data:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
tmdb_data = getattr(cache.data, "tmdb_data", None)
|
provider_data = getattr(cache.data, f"{provider_name}_data", None)
|
||||||
if not tmdb_data:
|
if not provider_data:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
tmdb_expiration = tmdb_data.get("expires_at")
|
expiration = provider_data.get("expires_at")
|
||||||
if not tmdb_expiration or datetime.now() >= tmdb_expiration:
|
if not expiration or datetime.now() >= expiration:
|
||||||
self.log.debug(f"TMDB cache expired for {title_id}")
|
self.log.debug(f"{provider_name} cache expired for {title_id}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if tmdb_data.get("kind") != kind:
|
if kind and provider_data.get("kind") != kind:
|
||||||
self.log.debug(f"TMDB cache kind mismatch for {title_id}: cached {tmdb_data.get('kind')}, requested {kind}")
|
self.log.debug(
|
||||||
|
f"{provider_name} cache kind mismatch for {title_id}: "
|
||||||
|
f"cached {provider_data.get('kind')}, requested {kind}"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
self.log.debug(f"TMDB cache hit for {title_id}")
|
self.log.debug(f"{provider_name} 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"),
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
self,
|
||||||
|
provider_name: str,
|
||||||
title_id: str,
|
title_id: str,
|
||||||
detail_response: dict,
|
data: dict,
|
||||||
external_ids_response: dict,
|
kind: Optional[str] = None,
|
||||||
kind: str,
|
|
||||||
region: Optional[str] = None,
|
region: Optional[str] = None,
|
||||||
account_hash: Optional[str] = None,
|
account_hash: Optional[str] = None,
|
||||||
|
ttl_days: int = 7,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""Cache metadata from any provider."""
|
||||||
Cache TMDB data for a title.
|
if not config.title_cache_enabled or self.no_cache:
|
||||||
|
|
||||||
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:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
cache_key = self._generate_cache_key(title_id, region, account_hash)
|
cache_key = self._generate_cache_key(title_id, region, account_hash)
|
||||||
cache = self.cacher.get(cache_key, version=1)
|
cache = self.cacher.get(cache_key, version=1)
|
||||||
|
|
||||||
if not cache or not cache.data:
|
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
|
return
|
||||||
|
|
||||||
now = datetime.now()
|
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)
|
cache.set(cache.data, expiration=cache.expiration)
|
||||||
self.log.debug(f"Cached TMDB data for {title_id} (kind={kind})")
|
self.log.debug(f"Cached {provider_name} data for {title_id}")
|
||||||
|
|
||||||
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}")
|
|
||||||
|
|
||||||
|
|
||||||
def get_region_from_proxy(proxy_url: Optional[str]) -> Optional[str]:
|
def get_region_from_proxy(proxy_url: Optional[str]) -> Optional[str]:
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ from rich.tree import Tree
|
|||||||
from sortedcontainers import SortedKeyList
|
from sortedcontainers import SortedKeyList
|
||||||
|
|
||||||
from envied.core.config import config
|
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.titles.title import Title
|
||||||
from envied.core.utilities import sanitize_filename
|
from envied.core.utilities import sanitize_filename
|
||||||
|
from envied.core.utils.template_formatter import TemplateFormatter
|
||||||
|
|
||||||
|
|
||||||
class Episode(Title):
|
class Episode(Title):
|
||||||
@@ -78,173 +78,60 @@ class Episode(Title):
|
|||||||
self.year = year
|
self.year = year
|
||||||
self.description = description
|
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:
|
def __str__(self) -> str:
|
||||||
return "{title}{year} S{season:02}E{number:02} {name}".format(
|
return "{title}{year} S{season:02}E{number:02} {name}".format(
|
||||||
title=self.title,
|
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,
|
season=self.season,
|
||||||
number=self.number,
|
number=self.number,
|
||||||
name=self.name or "",
|
name=self.name or "",
|
||||||
).strip()
|
).strip()
|
||||||
|
|
||||||
def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str:
|
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:
|
if folder:
|
||||||
name = f"{self.title}"
|
series_template = config.output_template.get("series")
|
||||||
if self.year and config.series_year:
|
if series_template:
|
||||||
name += f" {self.year}"
|
folder_template = series_template
|
||||||
name += f" S{self.season:02}"
|
folder_template = re.sub(r'\{episode\}', '', folder_template)
|
||||||
else:
|
folder_template = re.sub(r'\{episode_name\?\}', '', folder_template)
|
||||||
if config.dash_naming:
|
folder_template = re.sub(r'\{episode_name\}', '', folder_template)
|
||||||
# Format: Title - SXXEXX - Episode Name
|
folder_template = re.sub(r'\{season_episode\}', '{season}', folder_template)
|
||||||
name = self.title.replace("$", "S") # e.g., Arli$$
|
|
||||||
|
|
||||||
# Add year if configured
|
folder_template = re.sub(r'\.{2,}', '.', folder_template)
|
||||||
if self.year and config.series_year:
|
folder_template = re.sub(r'\s{2,}', ' ', folder_template)
|
||||||
name += f" {self.year}"
|
folder_template = re.sub(r'^[\.\s]+|[\.\s]+$', '', folder_template)
|
||||||
|
|
||||||
# Add season and episode
|
formatter = TemplateFormatter(folder_template)
|
||||||
name += f" - S{self.season:02}E{self.number:02}"
|
context = self._build_template_context(media_info, show_service)
|
||||||
|
context['season'] = f"S{self.season:02}"
|
||||||
|
|
||||||
# Add episode name with dash separator
|
folder_name = formatter.format(context)
|
||||||
if self.name:
|
|
||||||
name += f" - {self.name}"
|
|
||||||
|
|
||||||
name = name.strip()
|
if '.' in series_template and ' ' not in series_template:
|
||||||
else:
|
return sanitize_filename(folder_name, ".")
|
||||||
# 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")
|
|
||||||
else:
|
else:
|
||||||
dynamic_range = DYNAMIC_RANGE_MAP.get(hdr_format) or hdr_format or ""
|
return sanitize_filename(folder_name, " ")
|
||||||
name = _append_token(name, dynamic_range)
|
else:
|
||||||
elif "HLG" in trc or "Hybrid Log-Gamma" in trc or "ARIB STD-B67" in trc or "arib-std-b67" in trc.lower():
|
name = f"{self.title}"
|
||||||
name += " HLG"
|
if self.year:
|
||||||
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 += f" {self.year}"
|
||||||
name += " HDR"
|
name += f" S{self.season:02}"
|
||||||
if frame_rate > 30:
|
return sanitize_filename(name, " ")
|
||||||
name += " HFR"
|
|
||||||
name += f" {VIDEO_CODEC_MAP.get(codec, codec)}"
|
|
||||||
|
|
||||||
if config.tag:
|
formatter = TemplateFormatter(config.output_template["series"])
|
||||||
name += f"-{config.tag}"
|
context = self._build_template_context(media_info, show_service)
|
||||||
|
return formatter.format(context)
|
||||||
return sanitize_filename(name, "." if config.scene_naming else " ")
|
|
||||||
|
|
||||||
|
|
||||||
class Series(SortedKeyList, ABC):
|
class Series(SortedKeyList, ABC):
|
||||||
@@ -254,7 +141,7 @@ class Series(SortedKeyList, ABC):
|
|||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
if not self:
|
if not self:
|
||||||
return super().__str__()
|
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:
|
def tree(self, verbose: bool = False) -> Tree:
|
||||||
seasons = Counter(x.season for x in self)
|
seasons = Counter(x.season for x in self)
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ from rich.tree import Tree
|
|||||||
from sortedcontainers import SortedKeyList
|
from sortedcontainers import SortedKeyList
|
||||||
|
|
||||||
from envied.core.config import config
|
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.titles.title import Title
|
||||||
from envied.core.utilities import sanitize_filename
|
from envied.core.utilities import sanitize_filename
|
||||||
|
from envied.core.utils.template_formatter import TemplateFormatter
|
||||||
|
|
||||||
|
|
||||||
class Movie(Title):
|
class Movie(Title):
|
||||||
@@ -45,141 +45,28 @@ class Movie(Title):
|
|||||||
self.year = year
|
self.year = year
|
||||||
self.description = description
|
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:
|
def __str__(self) -> str:
|
||||||
if self.year:
|
if self.year:
|
||||||
if config.dash_naming:
|
|
||||||
return f"{self.name} - {self.year}"
|
|
||||||
return f"{self.name} ({self.year})"
|
return f"{self.name} ({self.year})"
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str:
|
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)
|
if folder:
|
||||||
primary_audio_track = None
|
name = f"{self.name}"
|
||||||
if media_info.audio_tracks:
|
if self.year:
|
||||||
sorted_audio = sorted(
|
name += f" ({self.year})"
|
||||||
media_info.audio_tracks,
|
return sanitize_filename(name, " ")
|
||||||
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:
|
formatter = TemplateFormatter(config.output_template["movies"])
|
||||||
if not track or not getattr(track, "height", None):
|
context = self._build_template_context(media_info, show_service)
|
||||||
return ""
|
return formatter.format(context)
|
||||||
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 " ")
|
|
||||||
|
|
||||||
|
|
||||||
class Movies(SortedKeyList, ABC):
|
class Movies(SortedKeyList, ABC):
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ from rich.tree import Tree
|
|||||||
from sortedcontainers import SortedKeyList
|
from sortedcontainers import SortedKeyList
|
||||||
|
|
||||||
from envied.core.config import config
|
from envied.core.config import config
|
||||||
from envied.core.constants import AUDIO_CODEC_MAP
|
|
||||||
from envied.core.titles.title import Title
|
from envied.core.titles.title import Title
|
||||||
from envied.core.utilities import sanitize_filename
|
from envied.core.utilities import sanitize_filename
|
||||||
|
from envied.core.utils.template_formatter import TemplateFormatter
|
||||||
|
|
||||||
|
|
||||||
class Song(Title):
|
class Song(Title):
|
||||||
@@ -41,7 +41,7 @@ class Song(Title):
|
|||||||
if not album:
|
if not album:
|
||||||
raise ValueError("Song album must be provided")
|
raise ValueError("Song album must be provided")
|
||||||
if not isinstance(album, str):
|
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:
|
if not track:
|
||||||
raise ValueError("Song track must be provided")
|
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
|
artist=self.artist, album=self.album, year=self.year, track=self.track, name=self.name
|
||||||
).strip()
|
).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:
|
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:
|
if folder:
|
||||||
# Artist - Album (Year)
|
name = f"{self.artist} - {self.album}"
|
||||||
name = str(self).split(" / ")[0]
|
if self.year:
|
||||||
else:
|
name += f" ({self.year})"
|
||||||
# NN. Song Name
|
return sanitize_filename(name, " ")
|
||||||
name = str(self).split(" / ")[1]
|
|
||||||
|
|
||||||
# Service (use track source if available)
|
formatter = TemplateFormatter(config.output_template["songs"])
|
||||||
if show_service:
|
context = self._build_template_context(media_info, show_service)
|
||||||
source_name = None
|
return formatter.format(context)
|
||||||
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, " ")
|
|
||||||
|
|
||||||
|
|
||||||
class Album(SortedKeyList, ABC):
|
class Album(SortedKeyList, ABC):
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ from typing import Any, Optional, Union
|
|||||||
from langcodes import Language
|
from langcodes import Language
|
||||||
from pymediainfo import MediaInfo
|
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
|
from envied.core.tracks import Tracks
|
||||||
|
|
||||||
|
|
||||||
@@ -51,6 +53,127 @@ class Title:
|
|||||||
def __eq__(self, other: Title) -> bool:
|
def __eq__(self, other: Title) -> bool:
|
||||||
return self.id == other.id
|
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
|
@abstractmethod
|
||||||
def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str:
|
def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -56,7 +57,8 @@ class Attachment:
|
|||||||
|
|
||||||
# Use provided name for the file if available
|
# Use provided name for the file if available
|
||||||
if name:
|
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
|
download_path = config.directories.temp / file_name
|
||||||
|
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ class Audio(Track):
|
|||||||
),
|
),
|
||||||
f"{self.bitrate // 1000} kb/s" if self.bitrate else None,
|
f"{self.bitrate // 1000} kb/s" if self.bitrate else None,
|
||||||
self.get_track_name(),
|
self.get_track_name(),
|
||||||
self.edition,
|
", ".join(self.edition) if self.edition else None,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -8,14 +10,16 @@ from pathlib import Path
|
|||||||
from rich.padding import Padding
|
from rich.padding import Padding
|
||||||
from rich.rule import Rule
|
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.config import config
|
||||||
from envied.core.console import console
|
from envied.core.console import console
|
||||||
|
from envied.core.utilities import get_debug_logger
|
||||||
|
|
||||||
|
|
||||||
class Hybrid:
|
class Hybrid:
|
||||||
def __init__(self, videos, source) -> None:
|
def __init__(self, videos, source) -> None:
|
||||||
self.log = logging.getLogger("hybrid")
|
self.log = logging.getLogger("hybrid")
|
||||||
|
self.debug_logger = get_debug_logger()
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Takes the Dolby Vision and HDR10(+) streams out of the VideoTracks.
|
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)))
|
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:
|
for video in self.videos:
|
||||||
if not video.path or not os.path.exists(video.path):
|
if not video.path or not os.path.exists(video.path):
|
||||||
raise ValueError(f"Video track {video.id} was not downloaded before injection.")
|
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_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)
|
has_hdr10p = any(video.range == Video.Range.HDR10P for video in self.videos)
|
||||||
|
|
||||||
if not has_hdr10:
|
if not has_hdr10 and not has_hdr10p:
|
||||||
raise ValueError("No HDR10 track available for hybrid processing.")
|
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 we have HDR10+ but no DV, we can convert HDR10+ to DV
|
||||||
if not has_dv and has_hdr10p:
|
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
|
self.hdr10plus_to_dv = True
|
||||||
elif not has_dv:
|
elif not has_dv:
|
||||||
raise ValueError("No DV track available and no HDR10+ to convert.")
|
raise ValueError("No DV track available and no HDR10+ to convert.")
|
||||||
|
|
||||||
if os.path.isfile(config.directories.temp / self.hevc_file):
|
if os.path.isfile(config.directories.temp / self.hevc_file):
|
||||||
self.log.info("✓ Already Injected")
|
console.status("Already Injected")
|
||||||
return
|
return
|
||||||
|
|
||||||
for video in videos:
|
for video in videos:
|
||||||
@@ -89,14 +106,34 @@ class Hybrid:
|
|||||||
self.extract_rpu(dv_video)
|
self.extract_rpu(dv_video)
|
||||||
if os.path.isfile(config.directories.temp / "RPU_UNT.bin"):
|
if os.path.isfile(config.directories.temp / "RPU_UNT.bin"):
|
||||||
self.rpu_file = "RPU_UNT.bin"
|
self.rpu_file = "RPU_UNT.bin"
|
||||||
self.level_6()
|
|
||||||
# Mode 3 conversion already done during extraction when not untouched
|
# Mode 3 conversion already done during extraction when not untouched
|
||||||
elif os.path.isfile(config.directories.temp / "RPU.bin"):
|
elif os.path.isfile(config.directories.temp / "RPU.bin"):
|
||||||
# RPU already extracted with mode 3
|
# RPU already extracted with mode 3
|
||||||
pass
|
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()
|
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")
|
self.log.info("✓ Injection Completed")
|
||||||
if self.source == ("itunes" or "appletvplus"):
|
if self.source == ("itunes" or "appletvplus"):
|
||||||
Path.unlink(config.directories.temp / "hdr10.mkv")
|
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 / "HDR10.hevc", missing_ok=True)
|
||||||
Path.unlink(config.directories.temp / "DV.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 / 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):
|
def ffmpeg_simple(self, save_path, output):
|
||||||
"""Simple ffmpeg execution without progress tracking"""
|
"""Simple ffmpeg execution without progress tracking"""
|
||||||
@@ -121,20 +162,41 @@ class Hybrid:
|
|||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
)
|
)
|
||||||
return p.returncode
|
return p
|
||||||
|
|
||||||
def extract_stream(self, save_path, type_):
|
def extract_stream(self, save_path, type_):
|
||||||
output = Path(config.directories.temp / f"{type_}.hevc")
|
output = Path(config.directories.temp / f"{type_}.hevc")
|
||||||
|
|
||||||
with console.status(f"Extracting {type_} stream...", spinner="dots"):
|
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)
|
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")
|
self.log.error(f"x Failed extracting {type_} stream")
|
||||||
sys.exit(1)
|
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):
|
def extract_rpu(self, video, untouched=False):
|
||||||
if os.path.isfile(config.directories.temp / "RPU.bin") or os.path.isfile(
|
if os.path.isfile(config.directories.temp / "RPU.bin") or os.path.isfile(
|
||||||
@@ -161,58 +223,326 @@ class Hybrid:
|
|||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
rpu_name = "RPU" if not untouched else "RPU_UNT"
|
||||||
if rpu_extraction.returncode:
|
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:
|
if b"MAX_PQ_LUMINANCE" in rpu_extraction.stderr:
|
||||||
self.extract_rpu(video, untouched=True)
|
self.extract_rpu(video, untouched=True)
|
||||||
elif b"Invalid PPS index" in rpu_extraction.stderr:
|
elif b"Invalid PPS index" in rpu_extraction.stderr:
|
||||||
raise ValueError("Dolby Vision VideoTrack seems to be corrupt")
|
raise ValueError("Dolby Vision VideoTrack seems to be corrupt")
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Failed extracting{' untouched ' if untouched else ' '}RPU from Dolby Vision stream")
|
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):
|
This resolves mismatches where DV has no black bars but HDR10 does (or vice versa)
|
||||||
"""Edit RPU Level 6 values"""
|
by telling the display the correct active area.
|
||||||
with open(config.directories.temp / "L6.json", "w+") as level6_file:
|
"""
|
||||||
level6 = {
|
if os.path.isfile(config.directories.temp / "RPU_L5.bin"):
|
||||||
"cm_version": "V29",
|
return
|
||||||
"length": 0,
|
|
||||||
"level6": {
|
|
||||||
"max_display_mastering_luminance": 1000,
|
|
||||||
"min_display_mastering_luminance": 1,
|
|
||||||
"max_content_light_level": 0,
|
|
||||||
"max_frame_average_light_level": 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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"):
|
# Get video duration for random sampling
|
||||||
with console.status("Editing RPU Level 6 values...", spinner="dots"):
|
with console.status("Detecting active area (crop detection)...", spinner="dots"):
|
||||||
level6 = subprocess.run(
|
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),
|
ffmpeg_bin,
|
||||||
"editor",
|
"-y",
|
||||||
|
"-nostdin",
|
||||||
|
"-loglevel",
|
||||||
|
"info",
|
||||||
|
"-ss",
|
||||||
|
f"{t:.2f}",
|
||||||
"-i",
|
"-i",
|
||||||
config.directories.temp / self.rpu_file,
|
str(input_video),
|
||||||
"-j",
|
"-vf",
|
||||||
config.directories.temp / "L6.json",
|
"cropdetect=round=2",
|
||||||
"-o",
|
"-vframes",
|
||||||
config.directories.temp / "RPU_L6.bin",
|
"10",
|
||||||
|
"-f",
|
||||||
|
"null",
|
||||||
|
"-",
|
||||||
],
|
],
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if level6.returncode:
|
# cropdetect outputs crop=w:h:x:y
|
||||||
Path.unlink(config.directories.temp / "RPU_L6.bin")
|
crop_match = re.search(
|
||||||
raise ValueError("Failed editing RPU Level 6 values")
|
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
|
# Find the most common crop values
|
||||||
self.rpu_file = "RPU_L6.bin"
|
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):
|
def injecting(self):
|
||||||
if os.path.isfile(config.directories.temp / self.hevc_file):
|
if os.path.isfile(config.directories.temp / self.hevc_file):
|
||||||
@@ -228,12 +558,6 @@ class Hybrid:
|
|||||||
config.directories.temp / self.rpu_file,
|
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_cmd.extend(["-o", config.directories.temp / self.hevc_file])
|
||||||
|
|
||||||
inject = subprocess.run(
|
inject = subprocess.run(
|
||||||
@@ -243,10 +567,29 @@ class Hybrid:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if inject.returncode:
|
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)
|
Path.unlink(config.directories.temp / self.hevc_file)
|
||||||
raise ValueError("Failed injecting Dolby Vision metadata into HDR10 stream")
|
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):
|
def extract_hdr10plus(self, _video):
|
||||||
"""Extract HDR10+ metadata from the video stream"""
|
"""Extract HDR10+ metadata from the video stream"""
|
||||||
@@ -271,13 +614,39 @@ class Hybrid:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if extraction.returncode:
|
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")
|
raise ValueError("Failed extracting HDR10+ metadata")
|
||||||
|
|
||||||
# Check if the extracted file has content
|
# 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")
|
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):
|
def convert_hdr10plus_to_dv(self):
|
||||||
"""Convert HDR10+ metadata to Dolby Vision RPU"""
|
"""Convert HDR10+ metadata to Dolby Vision RPU"""
|
||||||
@@ -317,10 +686,26 @@ class Hybrid:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if conversion.returncode:
|
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")
|
raise ValueError("Failed converting HDR10+ to Dolby Vision")
|
||||||
|
|
||||||
self.log.info("Converted HDR10+ metadata to Dolby Vision")
|
if self.debug_logger:
|
||||||
self.log.info("✓ HDR10+ successfully converted to Dolby Vision Profile 8")
|
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
|
# Clean up temporary files
|
||||||
Path.unlink(config.directories.temp / "extra.json")
|
Path.unlink(config.directories.temp / "extra.json")
|
||||||
|
|||||||
@@ -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.downloaders import aria2c, curl_impersonate, n_m3u8dl_re, requests
|
||||||
from envied.core.drm import DRM_T, PlayReady, Widevine
|
from envied.core.drm import DRM_T, PlayReady, Widevine
|
||||||
from envied.core.events import events
|
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
|
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)}")
|
raise TypeError(f"Expected name to be a {str}, not {type(name)}")
|
||||||
if not isinstance(id_, (str, type(None))):
|
if not isinstance(id_, (str, type(None))):
|
||||||
raise TypeError(f"Expected id_ to be a {str}, not {type(id_)}")
|
raise TypeError(f"Expected id_ to be a {str}, not {type(id_)}")
|
||||||
if not isinstance(edition, (str, type(None))):
|
if not isinstance(edition, (str, list, type(None))):
|
||||||
raise TypeError(f"Expected edition to be a {str}, not {type(edition)}")
|
raise TypeError(f"Expected edition to be a {str}, {list}, or None, not {type(edition)}")
|
||||||
if not isinstance(downloader, (Callable, type(None))):
|
if not isinstance(downloader, (Callable, type(None))):
|
||||||
raise TypeError(f"Expected downloader to be a {Callable}, not {type(downloader)}")
|
raise TypeError(f"Expected downloader to be a {Callable}, not {type(downloader)}")
|
||||||
if not isinstance(downloader_args, (dict, type(None))):
|
if not isinstance(downloader_args, (dict, type(None))):
|
||||||
@@ -103,7 +103,7 @@ class Track:
|
|||||||
self.needs_repack = needs_repack
|
self.needs_repack = needs_repack
|
||||||
self.name = name
|
self.name = name
|
||||||
self.drm = drm
|
self.drm = drm
|
||||||
self.edition: str = edition
|
self.edition: list[str] = [edition] if isinstance(edition, str) else (edition or [])
|
||||||
self.downloader = downloader
|
self.downloader = downloader
|
||||||
self.downloader_args = downloader_args
|
self.downloader_args = downloader_args
|
||||||
self.from_file = from_file
|
self.from_file = from_file
|
||||||
@@ -210,23 +210,12 @@ class Track:
|
|||||||
save_path = config.directories.temp / f"{track_type}_{self.id}.mp4"
|
save_path = config.directories.temp / f"{track_type}_{self.id}.mp4"
|
||||||
if track_type == "Subtitle":
|
if track_type == "Subtitle":
|
||||||
save_path = save_path.with_suffix(f".{self.codec.extension}")
|
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 (
|
||||||
if self.downloader.__name__ == "n_m3u8dl_re" and (
|
self.descriptor == self.Descriptor.URL
|
||||||
self.descriptor == self.Descriptor.URL
|
or track_type in ("Subtitle", "Attachment")
|
||||||
or get_extension(self.url)
|
):
|
||||||
in {
|
self.downloader = requests
|
||||||
".srt",
|
|
||||||
".vtt",
|
|
||||||
".ttml",
|
|
||||||
".ssa",
|
|
||||||
".ass",
|
|
||||||
".stpp",
|
|
||||||
".wvtt",
|
|
||||||
".xml",
|
|
||||||
}
|
|
||||||
):
|
|
||||||
self.downloader = requests
|
|
||||||
|
|
||||||
if self.descriptor != self.Descriptor.URL:
|
if self.descriptor != self.Descriptor.URL:
|
||||||
save_dir = save_path.with_name(save_path.name + "_segments")
|
save_dir = save_path.with_name(save_path.name + "_segments")
|
||||||
|
|||||||
@@ -103,53 +103,78 @@ class Tracks:
|
|||||||
tree = Tree("", hide_root=True)
|
tree = Tree("", hide_root=True)
|
||||||
for track_type in self.TRACK_ORDER_MAP:
|
for track_type in self.TRACK_ORDER_MAP:
|
||||||
tracks = list(x for x in all_tracks if isinstance(x, track_type))
|
tracks = list(x for x in all_tracks if isinstance(x, track_type))
|
||||||
if not tracks:
|
if tracks:
|
||||||
continue
|
num_tracks = len(tracks)
|
||||||
num_tracks = len(tracks)
|
track_type_plural = track_type.__name__ + ("s" if track_type != Audio and num_tracks != 1 else "")
|
||||||
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}")
|
||||||
tracks_tree = tree.add(f"[repr.number]{num_tracks}[/] {track_type_plural}")
|
for track in tracks:
|
||||||
for track in tracks:
|
if add_progress and track_type not in (Chapter, Attachment):
|
||||||
if add_progress and track_type not in (Chapter, Attachment):
|
progress = Progress(
|
||||||
progress = Progress(
|
SpinnerColumn(finished_text=""),
|
||||||
SpinnerColumn(finished_text=""),
|
BarColumn(),
|
||||||
BarColumn(),
|
"•",
|
||||||
"•",
|
TimeRemainingColumn(compact=True, elapsed_when_finished=True),
|
||||||
TimeRemainingColumn(compact=True, elapsed_when_finished=True),
|
"•",
|
||||||
"•",
|
TextColumn("[progress.data.speed]{task.fields[downloaded]}"),
|
||||||
TextColumn("[progress.data.speed]{task.fields[downloaded]}"),
|
console=console,
|
||||||
console=console,
|
speed_estimate_period=10,
|
||||||
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="-")
|
for cc_str in unique_cc:
|
||||||
state = {"total": 100.0}
|
cc_tree.add(cc_str, style="text2")
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
return tree, progress_callables
|
return tree, progress_callables
|
||||||
|
|
||||||
@@ -278,23 +303,30 @@ class Tracks:
|
|||||||
self.subtitles = list(filter(x, self.subtitles))
|
self.subtitles = list(filter(x, self.subtitles))
|
||||||
|
|
||||||
def select_hybrid(self, tracks, quality):
|
def select_hybrid(self, tracks, quality):
|
||||||
hdr10_tracks = [
|
# Prefer HDR10+ over HDR10 as the base layer (preserves dynamic metadata)
|
||||||
v
|
base_ranges = (Video.Range.HDR10P, Video.Range.HDR10)
|
||||||
for v in tracks
|
base_tracks = []
|
||||||
if v.range == Video.Range.HDR10 and (v.height in quality or int(v.width * 9 / 16) in quality)
|
for range_type in base_ranges:
|
||||||
]
|
base_tracks = [
|
||||||
hdr10 = []
|
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:
|
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:
|
if candidates:
|
||||||
best = max(candidates, key=lambda v: v.bitrate) # assumes .bitrate exists
|
best = max(candidates, key=lambda v: v.bitrate)
|
||||||
hdr10.append(best)
|
base_selected.append(best)
|
||||||
|
|
||||||
dv_tracks = [v for v in tracks if v.range == Video.Range.DV]
|
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
|
lowest_dv = min(dv_tracks, key=lambda v: v.height) if dv_tracks else None
|
||||||
|
|
||||||
def select(x):
|
def select(x):
|
||||||
if x in hdr10:
|
if x in base_selected:
|
||||||
return True
|
return True
|
||||||
if lowest_dv and x is lowest_dv:
|
if lowest_dv and x is lowest_dv:
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ class Video(Track):
|
|||||||
height: Optional[int] = None,
|
height: Optional[int] = None,
|
||||||
fps: Optional[Union[str, int, float]] = None,
|
fps: Optional[Union[str, int, float]] = None,
|
||||||
scan_type: Optional[Video.ScanType] = None,
|
scan_type: Optional[Video.ScanType] = None,
|
||||||
|
closed_captions: Optional[list[dict[str, Any]]] = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> None:
|
) -> 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))
|
raise ValueError("Expected fps to be a number, float, or a string as numerator/denominator form, " + str(e))
|
||||||
|
|
||||||
self.scan_type = scan_type
|
self.scan_type = scan_type
|
||||||
|
self.closed_captions: list[dict[str, Any]] = closed_captions or []
|
||||||
self.needs_duration_fix = False
|
self.needs_duration_fix = False
|
||||||
|
|
||||||
def __str__(self) -> str:
|
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:
|
if not binaries.CCExtractor:
|
||||||
raise EnvironmentError("ccextractor executable was not found.")
|
raise EnvironmentError("ccextractor executable was not found.")
|
||||||
|
|
||||||
# ccextractor often fails in weird ways unless we repack
|
|
||||||
self.repackage()
|
|
||||||
|
|
||||||
out_path = Path(out_path)
|
out_path = Path(out_path)
|
||||||
|
|
||||||
try:
|
def _run_ccextractor() -> bool:
|
||||||
subprocess.run(
|
try:
|
||||||
[binaries.CCExtractor, "-trim", "-nobom", "-noru", "-ru1", "-o", out_path, self.path],
|
subprocess.run(
|
||||||
check=True,
|
[binaries.CCExtractor, "-trim", "-nobom", "-noru", "-ru1", "-o", out_path, self.path],
|
||||||
stdout=subprocess.PIPE,
|
check=True,
|
||||||
stderr=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
)
|
stderr=subprocess.PIPE,
|
||||||
except subprocess.CalledProcessError as e:
|
)
|
||||||
out_path.unlink(missing_ok=True)
|
except subprocess.CalledProcessError as e:
|
||||||
if not e.returncode == 10: # No captions found
|
out_path.unlink(missing_ok=True)
|
||||||
raise
|
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():
|
if out_path.exists():
|
||||||
cc_track = Subtitle(
|
cc_track = Subtitle(
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ def sanitize_filename(filename: str, spacer: str = ".") -> str:
|
|||||||
if spacer == ".":
|
if spacer == ".":
|
||||||
filename = re.sub(r" - ", spacer, filename) # title separators to spacer (avoids .-. pattern)
|
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"[:; ]", 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
|
filename = re.sub(rf"[{spacer}]{{2,}}", spacer, filename) # remove extra neighbouring (spacer)s
|
||||||
|
|
||||||
return filename
|
return filename
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -44,6 +44,33 @@ class VideoCodecChoice(click.Choice):
|
|||||||
self.fail(f"'{value}' is not a valid video codec", param, ctx)
|
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):
|
class SubtitleCodecChoice(click.Choice):
|
||||||
"""
|
"""
|
||||||
A custom Choice type for subtitle codecs that accepts both enum names, values, and common aliases.
|
A custom Choice type for subtitle codecs that accepts both enum names, values, and common aliases.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,21 +1,25 @@
|
|||||||
import click
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
import click
|
||||||
from rich.console import Group
|
from rich.console import Group
|
||||||
from rich.live import Live
|
from rich.live import Live
|
||||||
from rich.padding import Padding
|
from rich.padding import Padding
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from envied.core.console import console
|
from envied.core.console import console
|
||||||
|
|
||||||
IS_WINDOWS = sys.platform == "win32"
|
IS_WINDOWS = sys.platform == "win32"
|
||||||
if IS_WINDOWS:
|
if IS_WINDOWS:
|
||||||
import msvcrt
|
import msvcrt
|
||||||
|
|
||||||
|
|
||||||
class Selector:
|
class Selector:
|
||||||
"""
|
"""
|
||||||
A custom interactive selector class using the Rich library.
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
options: list[str],
|
options: list[str],
|
||||||
@@ -24,7 +28,7 @@ class Selector:
|
|||||||
page_size: int = 8,
|
page_size: int = 8,
|
||||||
minimal_count: int = 0,
|
minimal_count: int = 0,
|
||||||
dependencies: dict[int, list[int]] = None,
|
dependencies: dict[int, list[int]] = None,
|
||||||
prefixes: list[str] = None
|
collapse_on_start: bool = False,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize the Selector.
|
Initialize the Selector.
|
||||||
@@ -36,6 +40,7 @@ class Selector:
|
|||||||
page_size: Number of items to show per page.
|
page_size: Number of items to show per page.
|
||||||
minimal_count: Minimum number of items that must be selected.
|
minimal_count: Minimum number of items that must be selected.
|
||||||
dependencies: Dictionary mapping parent index to list of child indices.
|
dependencies: Dictionary mapping parent index to list of child indices.
|
||||||
|
collapse_on_start: If True, child items are hidden initially.
|
||||||
"""
|
"""
|
||||||
self.options = options
|
self.options = options
|
||||||
self.cursor_style = cursor_style
|
self.cursor_style = cursor_style
|
||||||
@@ -43,81 +48,164 @@ class Selector:
|
|||||||
self.page_size = page_size
|
self.page_size = page_size
|
||||||
self.minimal_count = minimal_count
|
self.minimal_count = minimal_count
|
||||||
self.dependencies = dependencies or {}
|
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.cursor_index = 0
|
||||||
self.selected_indices = set()
|
self.selected_indices = set()
|
||||||
self.scroll_offset = 0
|
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):
|
def get_renderable(self):
|
||||||
"""
|
"""
|
||||||
Constructs and returns the renderable object (Table + Info) for the current state.
|
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 = 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("Indicator", justify="right", no_wrap=True)
|
||||||
table.add_column("Option", overflow="ellipsis", no_wrap=True)
|
table.add_column("Option", overflow="ellipsis", no_wrap=True)
|
||||||
|
|
||||||
for i in range(self.page_size):
|
for idx in render_indices:
|
||||||
idx = self.scroll_offset + i
|
option = self.options[idx]
|
||||||
|
is_cursor = idx == self.cursor_index
|
||||||
if idx < len(self.options):
|
is_selected = idx in self.selected_indices
|
||||||
option = self.options[idx]
|
|
||||||
is_cursor = (idx == self.cursor_index)
|
|
||||||
is_selected = (idx in self.selected_indices)
|
|
||||||
|
|
||||||
symbol = "[X]" if is_selected else "[ ]"
|
symbol = "[X]" if is_selected else "[ ]"
|
||||||
style = self.cursor_style if is_cursor else self.text_style
|
style = self.cursor_style if is_cursor else self.text_style
|
||||||
indicator_text = Text(f"{symbol}", style=style)
|
indicator_text = Text(f"{symbol}", style=style)
|
||||||
|
|
||||||
content_text = Text.from_markup(option)
|
content_text = Text.from_markup(f"{option}")
|
||||||
content_text.style = style
|
content_text.style = style
|
||||||
|
|
||||||
table.add_row(indicator_text, content_text)
|
table.add_row(indicator_text, content_text)
|
||||||
else:
|
|
||||||
table.add_row(Text(" "), 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
|
current_page = (self.scroll_offset // self.page_size) + 1
|
||||||
|
|
||||||
info_text = Text(
|
if self.dependencies:
|
||||||
f"\n[Space]: Toggle [a]: All [←/→]: Page [Enter]: Confirm (Page {current_page}/{total_pages})",
|
info_text = Text(
|
||||||
style="gray"
|
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))
|
return Padding(Group(table, info_text), (0, 5))
|
||||||
|
|
||||||
def move_cursor(self, delta: int):
|
def move_cursor(self, delta: int):
|
||||||
"""
|
"""
|
||||||
Moves the cursor up or down by the specified delta.
|
Moves the cursor up or down through VISIBLE items only.
|
||||||
Updates the scroll offset if the cursor moves out of the current view.
|
|
||||||
"""
|
"""
|
||||||
self.cursor_index = (self.cursor_index + delta) % len(self.options)
|
visible_indices = self.get_visible_indices()
|
||||||
new_page_idx = self.cursor_index // self.page_size
|
if not visible_indices:
|
||||||
self.scroll_offset = new_page_idx * self.page_size
|
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):
|
def change_page(self, delta: int):
|
||||||
"""
|
"""
|
||||||
Changes the current page view by the specified delta (previous/next page).
|
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
|
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
|
new_page = current_page + delta
|
||||||
|
|
||||||
if 0 <= new_page < total_pages:
|
if 0 <= new_page < total_pages:
|
||||||
self.scroll_offset = new_page * self.page_size
|
self.scroll_offset = new_page * self.page_size
|
||||||
first_idx_of_page = self.scroll_offset
|
|
||||||
if first_idx_of_page < len(self.options):
|
# Move cursor to top of new page
|
||||||
self.cursor_index = first_idx_of_page
|
try:
|
||||||
else:
|
# Calculate what visual index corresponds to the start of the new page
|
||||||
self.cursor_index = len(self.options) - 1
|
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):
|
def toggle_selection(self):
|
||||||
"""
|
"""
|
||||||
Toggles the selection state of the item currently under the cursor.
|
Toggles the selection state of the item currently under the cursor.
|
||||||
Propagates selection to children if defined in dependencies.
|
|
||||||
"""
|
"""
|
||||||
target_indices = {self.cursor_index}
|
target_indices = {self.cursor_index}
|
||||||
|
|
||||||
if self.cursor_index in self.dependencies:
|
if self.cursor_index in self.dependencies:
|
||||||
target_indices.update(self.dependencies[self.cursor_index])
|
target_indices.update(self.dependencies[self.cursor_index])
|
||||||
|
|
||||||
@@ -128,10 +216,33 @@ class Selector:
|
|||||||
else:
|
else:
|
||||||
self.selected_indices.difference_update(target_indices)
|
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):
|
def toggle_all(self):
|
||||||
"""
|
"""
|
||||||
Toggles the selection of all items.
|
Toggles the selection of all items.
|
||||||
If all are selected, clears selection. Otherwise, selects all.
|
|
||||||
"""
|
"""
|
||||||
if len(self.selected_indices) == len(self.options):
|
if len(self.selected_indices) == len(self.options):
|
||||||
self.selected_indices.clear()
|
self.selected_indices.clear()
|
||||||
@@ -144,28 +255,49 @@ class Selector:
|
|||||||
Returns command strings like 'UP', 'DOWN', 'ENTER', etc.
|
Returns command strings like 'UP', 'DOWN', 'ENTER', etc.
|
||||||
"""
|
"""
|
||||||
key = msvcrt.getch()
|
key = msvcrt.getch()
|
||||||
if key == b'\x03' or key == b'\x1b':
|
# Ctrl+C (0x03) or ESC (0x1b)
|
||||||
return 'CANCEL'
|
if key == b"\x03" or key == b"\x1b":
|
||||||
if key == b'\xe0' or key == b'\x00':
|
return "CANCEL"
|
||||||
|
# Special keys prefix (Arrow keys, etc., send 0xe0 or 0x00 first)
|
||||||
|
if key == b"\xe0" or key == b"\x00":
|
||||||
try:
|
try:
|
||||||
key = msvcrt.getch()
|
key = msvcrt.getch()
|
||||||
if key == b'H': return 'UP'
|
if key == b"H": # Arrow Up
|
||||||
if key == b'P': return 'DOWN'
|
return "UP"
|
||||||
if key == b'K': return 'LEFT'
|
if key == b"P": # Arrow Down
|
||||||
if key == b'M': return 'RIGHT'
|
return "DOWN"
|
||||||
except: pass
|
if key == b"K": # Arrow Left
|
||||||
|
return "LEFT"
|
||||||
try: char = key.decode('utf-8', errors='ignore')
|
if key == b"M": # Arrow Right
|
||||||
except: return None
|
return "RIGHT"
|
||||||
|
except Exception:
|
||||||
if char in ('\r', '\n'): return 'ENTER'
|
pass
|
||||||
if char == ' ': return 'SPACE'
|
|
||||||
if char in ('q', 'Q'): return 'QUIT'
|
try:
|
||||||
if char in ('a', 'A'): return 'ALL'
|
char = key.decode("utf-8", errors="ignore")
|
||||||
if char in ('w', 'W', 'k', 'K'): return 'UP'
|
except Exception:
|
||||||
if char in ('s', 'S', 'j', 'J'): return 'DOWN'
|
return None
|
||||||
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
|
return None
|
||||||
|
|
||||||
def get_input_unix(self):
|
def get_input_unix(self):
|
||||||
@@ -174,44 +306,65 @@ class Selector:
|
|||||||
Returns command strings like 'UP', 'DOWN', 'ENTER', etc.
|
Returns command strings like 'UP', 'DOWN', 'ENTER', etc.
|
||||||
"""
|
"""
|
||||||
char = click.getchar()
|
char = click.getchar()
|
||||||
if char == '\x03':
|
# Ctrl+C
|
||||||
return 'CANCEL'
|
if char == "\x03":
|
||||||
|
return "CANCEL"
|
||||||
|
|
||||||
|
# ANSI Escape Sequences for Arrow Keys
|
||||||
mapping = {
|
mapping = {
|
||||||
'\x1b[A': 'UP',
|
"\x1b[A": "UP", # Escape + [ + A
|
||||||
'\x1b[B': 'DOWN',
|
"\x1b[B": "DOWN", # Escape + [ + B
|
||||||
'\x1b[C': 'RIGHT',
|
"\x1b[C": "RIGHT", # Escape + [ + C
|
||||||
'\x1b[D': 'LEFT',
|
"\x1b[D": "LEFT", # Escape + [ + D
|
||||||
}
|
}
|
||||||
if char in mapping:
|
if char in mapping:
|
||||||
return mapping[char]
|
return mapping[char]
|
||||||
if char == '\x1b':
|
|
||||||
|
# Handling manual Escape sequences
|
||||||
|
if char == "\x1b": # ESC
|
||||||
try:
|
try:
|
||||||
next1 = click.getchar()
|
next1 = click.getchar()
|
||||||
if next1 in ('[', 'O'):
|
if next1 in ("[", "O"): # Sequence indicators
|
||||||
next2 = click.getchar()
|
next2 = click.getchar()
|
||||||
if next2 == 'A': return 'UP'
|
if next2 == "A": # Arrow Up
|
||||||
if next2 == 'B': return 'DOWN'
|
return "UP"
|
||||||
if next2 == 'C': return 'RIGHT'
|
if next2 == "B": # Arrow Down
|
||||||
if next2 == 'D': return 'LEFT'
|
return "DOWN"
|
||||||
return 'CANCEL'
|
if next2 == "C": # Arrow Right
|
||||||
except:
|
return "RIGHT"
|
||||||
return 'CANCEL'
|
if next2 == "D": # Arrow Left
|
||||||
|
return "LEFT"
|
||||||
|
return "CANCEL"
|
||||||
|
except Exception:
|
||||||
|
return "CANCEL"
|
||||||
|
|
||||||
if char in ('\r', '\n'): return 'ENTER'
|
if char in ("\r", "\n"):
|
||||||
if char == ' ': return 'SPACE'
|
return "ENTER"
|
||||||
if char in ('q', 'Q'): return 'QUIT'
|
if char == " ":
|
||||||
if char in ('a', 'A'): return 'ALL'
|
return "SPACE"
|
||||||
if char in ('w', 'W', 'k', 'K'): return 'UP'
|
if char in ("q", "Q"):
|
||||||
if char in ('s', 'S', 'j', 'J'): return 'DOWN'
|
return "QUIT"
|
||||||
if char in ('h', 'H'): return 'LEFT'
|
if char in ("a", "A"):
|
||||||
if char in ('d', 'D', 'l', 'L'): return 'RIGHT'
|
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
|
return None
|
||||||
|
|
||||||
def run(self) -> list[int]:
|
def run(self) -> list[int]:
|
||||||
"""
|
"""
|
||||||
Starts the main event loop for the selector.
|
Starts the main event loop for the selector.
|
||||||
Renders the UI and processes input until confirmed or cancelled.
|
Renders the UI and processes input until confirmed or cancelled.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[int]: A sorted list of selected indices.
|
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:
|
with Live(self.get_renderable(), console=console, auto_refresh=False, transient=True) as live:
|
||||||
while True:
|
while True:
|
||||||
live.update(self.get_renderable(), refresh=True)
|
live.update(self.get_renderable(), refresh=True)
|
||||||
if IS_WINDOWS: action = self.get_input_windows()
|
if IS_WINDOWS:
|
||||||
else: action = self.get_input_unix()
|
action = self.get_input_windows()
|
||||||
|
else:
|
||||||
if action == 'UP': self.move_cursor(-1)
|
action = self.get_input_unix()
|
||||||
elif action == 'DOWN': self.move_cursor(1)
|
|
||||||
elif action == 'LEFT': self.change_page(-1)
|
if action == "UP":
|
||||||
elif action == 'RIGHT': self.change_page(1)
|
self.move_cursor(-1)
|
||||||
elif action == 'SPACE': self.toggle_selection()
|
elif action == "DOWN":
|
||||||
elif action == 'ALL': self.toggle_all()
|
self.move_cursor(1)
|
||||||
elif action in ('ENTER', 'QUIT'):
|
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:
|
if len(self.selected_indices) >= self.minimal_count:
|
||||||
return sorted(list(self.selected_indices))
|
return sorted(list(self.selected_indices))
|
||||||
elif action == 'CANCEL': raise KeyboardInterrupt
|
elif action == "CANCEL":
|
||||||
|
raise KeyboardInterrupt
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def select_multiple(
|
def select_multiple(
|
||||||
options: list[str],
|
options: list[str],
|
||||||
minimal_count: int = 1,
|
minimal_count: int = 1,
|
||||||
page_size: int = 8,
|
page_size: int = 8,
|
||||||
return_indices: bool = True,
|
return_indices: bool = True,
|
||||||
cursor_style: str = "pink",
|
cursor_style: str = "pink",
|
||||||
**kwargs
|
collapse_on_start: bool = False,
|
||||||
|
**kwargs,
|
||||||
) -> list[int]:
|
) -> list[int]:
|
||||||
"""
|
"""
|
||||||
Drop-in replacement using custom Selector with global console.
|
Drop-in replacement using custom Selector with global console.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
options: List of options to display.
|
options: List of options to display.
|
||||||
minimal_count: Minimum number of selections required.
|
minimal_count: Minimum number of selections required.
|
||||||
page_size: Number of items per page.
|
page_size: Number of items per page.
|
||||||
return_indices: If True, returns indices; otherwise returns the option strings.
|
return_indices: If True, returns indices; otherwise returns the option strings.
|
||||||
cursor_style: Style color for the cursor.
|
cursor_style: Style color for the cursor.
|
||||||
|
collapse_on_start: If True, child items are hidden initially.
|
||||||
"""
|
"""
|
||||||
selector = Selector(
|
selector = Selector(
|
||||||
options=options,
|
options=options,
|
||||||
@@ -259,11 +428,12 @@ def select_multiple(
|
|||||||
text_style="text",
|
text_style="text",
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
minimal_count=minimal_count,
|
minimal_count=minimal_count,
|
||||||
**kwargs
|
collapse_on_start=collapse_on_start,
|
||||||
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
selected_indices = selector.run()
|
selected_indices = selector.run()
|
||||||
|
|
||||||
if return_indices:
|
if return_indices:
|
||||||
return selected_indices
|
return selected_indices
|
||||||
return [options[i] for i in selected_indices]
|
return [options[i] for i in selected_indices]
|
||||||
|
|||||||
@@ -1,488 +1,23 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
from difflib import SequenceMatcher
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Tuple
|
from typing import Optional
|
||||||
from xml.sax.saxutils import escape
|
from xml.sax.saxutils import escape
|
||||||
|
|
||||||
import requests
|
|
||||||
from requests.adapters import HTTPAdapter, Retry
|
|
||||||
|
|
||||||
from envied.core import binaries
|
from envied.core import binaries
|
||||||
from envied.core.config import config
|
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.episode import Episode
|
||||||
from envied.core.titles.movie import Movie
|
from envied.core.titles.movie import Movie
|
||||||
from envied.core.titles.title import Title
|
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")
|
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:
|
def apply_tags(path: Path, tags: dict[str, str]) -> None:
|
||||||
if not tags:
|
if not tags:
|
||||||
return
|
return
|
||||||
@@ -509,9 +44,26 @@ def apply_tags(path: Path, tags: dict[str, str]) -> None:
|
|||||||
tmp_path.unlink(missing_ok=True)
|
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)
|
log.debug("Tagging file %s with title %r", path, title)
|
||||||
standard_tags: dict[str, str] = {}
|
|
||||||
custom_tags: dict[str, str] = {}
|
custom_tags: dict[str, str] = {}
|
||||||
|
|
||||||
if config.tag and config.tag_group_name:
|
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)
|
apply_tags(path, custom_tags)
|
||||||
return
|
return
|
||||||
|
|
||||||
if config.tag_imdb_tmdb:
|
standard_tags: dict[str, str] = {}
|
||||||
# Check if we have any API keys available for metadata lookup
|
|
||||||
api_key = _api_key()
|
|
||||||
simkl_client = _simkl_client_id()
|
|
||||||
|
|
||||||
if not api_key and not simkl_client:
|
if config.tag_imdb_tmdb:
|
||||||
log.debug("No TMDB API key or Simkl client ID configured; skipping IMDB/TMDB tag lookup")
|
providers = get_available_providers()
|
||||||
|
if not providers:
|
||||||
|
log.debug("No metadata providers available; skipping tag lookup")
|
||||||
apply_tags(path, custom_tags)
|
apply_tags(path, custom_tags)
|
||||||
return
|
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:
|
else:
|
||||||
# If tmdb_id is provided (via --tmdb), skip Simkl and use TMDB directly
|
# Search across providers in priority order
|
||||||
if tmdb_id is not None:
|
result = search_metadata(name, year, kind)
|
||||||
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)
|
|
||||||
|
|
||||||
if simkl_data and simkl_title and fuzzy_match(simkl_title, name):
|
# If we got a TMDB ID from search but no full external IDs, fetch them
|
||||||
log.debug("Using Simkl data for tags")
|
if result and result.external_ids.tmdb_id and not result.external_ids.imdb_id:
|
||||||
if simkl_tmdb_id:
|
ext = fetch_external_ids(result.external_ids.tmdb_id, kind)
|
||||||
tmdb_id = simkl_tmdb_id
|
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 result and result.external_ids:
|
||||||
if simkl_data.get("type") == "episode" and "show" in simkl_data:
|
standard_tags = _build_tags_from_ids(result.external_ids, kind)
|
||||||
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']}"
|
|
||||||
|
|
||||||
# Handle movie data from Simkl
|
apply_tags(path, {**custom_tags, **standard_tags})
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"search_simkl",
|
"apply_tags",
|
||||||
"search_show_info",
|
|
||||||
"search_tmdb",
|
|
||||||
"get_title",
|
|
||||||
"get_year",
|
|
||||||
"external_ids",
|
|
||||||
"tag_file",
|
|
||||||
"fuzzy_match",
|
"fuzzy_match",
|
||||||
|
"tag_file",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -11,8 +11,66 @@ muxing:
|
|||||||
# shakapackager or mp4decrypt
|
# shakapackager or mp4decrypt
|
||||||
decryption: shakapackager
|
decryption: shakapackager
|
||||||
|
|
||||||
scene_naming: false
|
# Custom output templates for filenames
|
||||||
series_year: false
|
# 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: <lang> - any audio track matches this language
|
||||||
|
# subs_contain: <lang> - 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
|
# Login credentials for each Service
|
||||||
credentials:
|
credentials:
|
||||||
@@ -59,12 +117,12 @@ key_vaults:
|
|||||||
#- type: SQLite
|
#- type: SQLite
|
||||||
# name: Local vault
|
# name: Local vault
|
||||||
# path: key_store.db
|
# path: key_store.db
|
||||||
- type: HTTPAPI
|
- type: HTTP
|
||||||
name: drmlab
|
name: DRMLab
|
||||||
host: http://api.drmlab.io/vault/
|
host: http://api.drmlab.io/vault/
|
||||||
password: gEX75q7I5YVkvgF5SUkcNd41IbGrDtTT
|
password: gEX75q7I5YVkvgF5SUkcNd41IbGrDtTT
|
||||||
|
api_mode: json
|
||||||
|
no_push: false
|
||||||
|
|
||||||
# Choose what software to use to download data
|
# Choose what software to use to download data
|
||||||
downloader: n_m3u8dl_re
|
downloader: n_m3u8dl_re
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from hashlib import md5
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -9,13 +10,16 @@ import jwt
|
|||||||
from langcodes import Language
|
from langcodes import Language
|
||||||
|
|
||||||
from envied.core.manifests import DASH
|
from envied.core.manifests import DASH
|
||||||
|
from envied.core.manifests.hls import HLS
|
||||||
from envied.core.search_result import SearchResult
|
from envied.core.search_result import SearchResult
|
||||||
from envied.core.service import Service
|
from envied.core.service import Service
|
||||||
from envied.core.session import session
|
from envied.core.session import session
|
||||||
from envied.core.titles import Episode, Series
|
from envied.core.titles import Episode, Series
|
||||||
from envied.core.tracks import Attachment, Chapters, Tracks
|
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.chapter import Chapter
|
||||||
from envied.core.tracks.subtitle import Subtitle
|
from envied.core.tracks.subtitle import Subtitle
|
||||||
|
from envied.core.tracks.video import Video
|
||||||
|
|
||||||
|
|
||||||
class CR(Service):
|
class CR(Service):
|
||||||
@@ -23,18 +27,21 @@ class CR(Service):
|
|||||||
Service code for Crunchyroll streaming service (https://www.crunchyroll.com).
|
Service code for Crunchyroll streaming service (https://www.crunchyroll.com).
|
||||||
|
|
||||||
\b
|
\b
|
||||||
Version: 2.0.0
|
Version: 2.1.1
|
||||||
Author: sp4rk.y
|
Author: sp4rk.y & Dex
|
||||||
Date: 2025-11-01
|
Date: 2026-02-28
|
||||||
Authorization: Credentials
|
Authorization: Credentials
|
||||||
Robustness:
|
Robustness:
|
||||||
Widevine:
|
Widevine:
|
||||||
L3: 1080p, AAC2.0
|
L3: 1080p, AAC2.0
|
||||||
|
PlayReady:
|
||||||
|
SL3000: 1080p, AAC2.0
|
||||||
|
SL2000: 1080p, AAC2.0
|
||||||
\b
|
\b
|
||||||
Tips:
|
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/series/GRMG8ZQZR/series-name OR GRMG8ZQZR
|
||||||
|
https://www.crunchyroll.com/en-US/watch/G2XU02X75/ryomen-sukuna OR G2XU02X75
|
||||||
- Supports multiple audio and subtitle languages
|
- Supports multiple audio and subtitle languages
|
||||||
- Device ID is cached for consistent authentication across runs
|
- Device ID is cached for consistent authentication across runs
|
||||||
|
|
||||||
@@ -44,30 +51,46 @@ class CR(Service):
|
|||||||
- Manages concurrent stream limits automatically
|
- Manages concurrent stream limits automatically
|
||||||
"""
|
"""
|
||||||
|
|
||||||
TITLE_RE = r"^(?:https?://(?:www\.)?crunchyroll\.com/(?:series|watch)/)?(?P<id>[A-Z0-9]+)"
|
TITLE_RE = r"^(?:https?://(?:www\.)?crunchyroll\.com/(?:[a-z]{2}(?:-[a-z]{2})?/)?(?:series|watch)/)?(?P<id>[A-Z0-9]+)"
|
||||||
LICENSE_LOCK = Lock()
|
LICENSE_LOCK = Lock()
|
||||||
MAX_CONCURRENT_STREAMS = 3
|
MAX_CONCURRENT_STREAMS = 3
|
||||||
ACTIVE_STREAMS: list[tuple[str, str]] = []
|
ACTIVE_STREAMS: list[tuple[str, str]] = []
|
||||||
|
|
||||||
@staticmethod
|
# Reverse map for audio_locale to match 2 chars languages in a_lang param
|
||||||
def get_session():
|
LANG_MAP: dict = {
|
||||||
return session("okhttp4")
|
"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
|
@staticmethod
|
||||||
@click.command(name="CR", short_help="https://crunchyroll.com")
|
@click.command(name="CR", short_help="https://crunchyroll.com")
|
||||||
@click.argument("title", type=str, required=True)
|
@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
|
@click.pass_context
|
||||||
def cli(ctx, **kwargs) -> "CR":
|
def cli(ctx, **kwargs) -> "CR":
|
||||||
return CR(ctx, **kwargs)
|
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.title = title
|
||||||
self.account_id: Optional[str] = None
|
self.account_id: Optional[str] = None
|
||||||
self.access_token: Optional[str] = None
|
self.access_token: Optional[str] = None
|
||||||
self.token_expiration: Optional[int] = None
|
self.token_expiration: Optional[int] = None
|
||||||
self.anonymous_id = str(uuid.uuid4())
|
self.anonymous_id = str(uuid.uuid4())
|
||||||
|
self.audio_lang = ctx.parent.params.get("lang") or ctx.parent.params.get("a_lang")
|
||||||
super().__init__(ctx)
|
self.high_audio_bitrate = high_audio_bitrate
|
||||||
|
|
||||||
device_cache_key = "cr_device_id"
|
device_cache_key = "cr_device_id"
|
||||||
cached_device = self.cache.get(device_cache_key)
|
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.update(self.config.get("headers", {}))
|
||||||
self.session.headers["etp-anonymous-id"] = self.anonymous_id
|
self.session.headers["etp-anonymous-id"] = self.anonymous_id
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_session():
|
||||||
|
return session("okhttp4")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def auth_header(self) -> dict:
|
def auth_header(self) -> dict:
|
||||||
"""Return authorization header dict."""
|
"""Return authorization header dict."""
|
||||||
@@ -127,11 +154,7 @@ class CR(Service):
|
|||||||
self.token_expiration = cached.data.get("token_expiration")
|
self.token_expiration = cached.data.get("token_expiration")
|
||||||
else:
|
else:
|
||||||
if not credential:
|
if not credential:
|
||||||
class HardcodedCreds:
|
raise ValueError("Username and password credential required for authentication")
|
||||||
username = "akjrtx@gmail.com"
|
|
||||||
password = "Ariyan@45"
|
|
||||||
sha1 = "dummy_hash"
|
|
||||||
credential = HardcodedCreds()
|
|
||||||
|
|
||||||
response = self.session.post(
|
response = self.session.post(
|
||||||
url=self.config["endpoints"]["token"],
|
url=self.config["endpoints"]["token"],
|
||||||
@@ -203,9 +226,27 @@ class CR(Service):
|
|||||||
params={"locale": self.config["params"]["locale"]},
|
params={"locale": self.config["params"]["locale"]},
|
||||||
).json()
|
).json()
|
||||||
|
|
||||||
if "error" in series_response:
|
if "code" in series_response:
|
||||||
raise ValueError(f"Series not found: {series_id}")
|
# 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_data = (
|
||||||
series_response.get("data", [{}])[0] if isinstance(series_response.get("data"), list) else series_response
|
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:
|
def set_track_metadata(self, tracks: Tracks, episode_id: str, is_original: bool) -> None:
|
||||||
"""Set metadata for video and audio tracks."""
|
"""Set metadata for video and audio tracks."""
|
||||||
for video in tracks.videos:
|
for track in tracks:
|
||||||
video.needs_repack = True
|
if isinstance(track, Video):
|
||||||
video.data["episode_id"] = episode_id
|
track.needs_repack = True
|
||||||
video.is_original_lang = is_original
|
if isinstance(track, (Audio,Video)):
|
||||||
for audio in tracks.audio:
|
track.data["episode_id"] = episode_id
|
||||||
audio.data["episode_id"] = episode_id
|
track.is_original_lang = is_original
|
||||||
audio.is_original_lang = is_original
|
if is_original:
|
||||||
|
track.name = f"{track.name} [Original]"
|
||||||
|
|
||||||
|
|
||||||
def get_tracks(self, title: Episode) -> Tracks:
|
def get_tracks(self, title: Episode) -> Tracks:
|
||||||
"""Fetch video, audio, and subtitle tracks for an episode."""
|
"""Fetch video, audio, and subtitle tracks for an episode."""
|
||||||
self.ensure_authenticated()
|
self.ensure_authenticated()
|
||||||
@@ -325,106 +368,169 @@ class CR(Service):
|
|||||||
|
|
||||||
tracks = None
|
tracks = None
|
||||||
|
|
||||||
|
#endpoints_playback = ["playback"]
|
||||||
|
|
||||||
|
sdh_list = []
|
||||||
for idx, version in enumerate(versions):
|
for idx, version in enumerate(versions):
|
||||||
audio_locale = version.get("audio_locale")
|
audio_locale = version.get("audio_locale")
|
||||||
version_guid = version.get("guid")
|
version_guid = version.get("guid")
|
||||||
is_original = version.get("original", False)
|
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
|
continue
|
||||||
|
|
||||||
request_episode_id = version_guid if version_guid else episode_id
|
request_episode_id = version_guid if version_guid else episode_id
|
||||||
|
|
||||||
if idx == 0 and not version_guid:
|
#for endpoint in endpoints_playback:
|
||||||
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
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
version_tracks = DASH.from_url(
|
if idx == 0 and not version_guid: # and endpoint == "playback":
|
||||||
url=dash_url,
|
version_response = initial_response
|
||||||
session=self.session,
|
version_token = version_response.get("token")
|
||||||
).to_tracks(language=audio_locale)
|
|
||||||
|
|
||||||
if tracks is None:
|
|
||||||
tracks = version_tracks
|
|
||||||
self.set_track_metadata(tracks, request_episode_id, is_original)
|
|
||||||
else:
|
else:
|
||||||
self.set_track_metadata(version_tracks, request_episode_id, is_original)
|
if idx == 1 and not versions[0].get("guid"): # and endpoint == "playback":
|
||||||
for video in version_tracks.videos:
|
initial_token = initial_response.get("token")
|
||||||
tracks.add(video)
|
if initial_token:
|
||||||
for audio in version_tracks.audio:
|
self.close_stream(episode_id, initial_token)
|
||||||
tracks.add(audio)
|
|
||||||
|
|
||||||
except Exception as e:
|
try:
|
||||||
self.log.warning(f"Failed to parse DASH manifest for audio {audio_locale}: {e}")
|
version_response = self.get_playback_data(request_episode_id, track_stream=False)
|
||||||
if version_token:
|
except ValueError as e:
|
||||||
self.close_stream(request_episode_id, version_token)
|
self.log.warning(f"Could not get playback info for audio {audio_locale}: {e}")
|
||||||
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":
|
|
||||||
continue
|
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:
|
try:
|
||||||
lang = Language.get(lang_code)
|
version_tracks.audio.clear();
|
||||||
except (ValueError, LookupError):
|
|
||||||
lang = Language.get("en")
|
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()
|
except Exception as e:
|
||||||
if subtitle_format == "ass" or subtitle_format == "ssa":
|
self.log.warning(f"Failed to parse DASH manifest for audio {audio_locale}: {e}")
|
||||||
codec = Subtitle.Codec.SubStationAlphav4
|
if version_token:
|
||||||
else:
|
self.close_stream(request_episode_id, version_token)
|
||||||
codec = Subtitle.Codec.WebVTT
|
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(
|
tracks.add(
|
||||||
Subtitle(
|
Subtitle(
|
||||||
id_=f"subtitle-{audio_locale}-{lang_code}",
|
id_=md5(subtitle["url"].encode()).hexdigest()[0:6],
|
||||||
url=sub_data["url"],
|
url=subtitle["url"],
|
||||||
codec=codec,
|
codec=codec,
|
||||||
language=lang,
|
language=Language.get(lang),
|
||||||
forced=False,
|
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,
|
sdh=False,
|
||||||
),
|
),
|
||||||
warn_only=True,
|
warn_only=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if version_token:
|
if version_token:
|
||||||
self.close_stream(request_episode_id, 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"):
|
if versions and versions[0].get("guid"):
|
||||||
initial_token = initial_response.get("token")
|
initial_token = initial_response.get("token")
|
||||||
if initial_token:
|
if initial_token:
|
||||||
@@ -433,15 +539,6 @@ class CR(Service):
|
|||||||
if tracks is None:
|
if tracks is None:
|
||||||
raise ValueError(f"Failed to fetch any tracks for episode: {episode_id}")
|
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", {})
|
images = title.data.get("images", {})
|
||||||
thumbnails = images.get("thumbnail", [])
|
thumbnails = images.get("thumbnail", [])
|
||||||
@@ -451,10 +548,65 @@ class CR(Service):
|
|||||||
thumb_index = min(7, len(thumb_variants) - 1)
|
thumb_index = min(7, len(thumb_variants) - 1)
|
||||||
thumb = thumb_variants[thumb_index]
|
thumb = thumb_variants[thumb_index]
|
||||||
if isinstance(thumb, dict) and "source" in thumb:
|
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
|
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:
|
def get_widevine_license(self, challenge: bytes, title: Episode, track) -> bytes:
|
||||||
"""
|
"""
|
||||||
Get Widevine license for decryption.
|
Get Widevine license for decryption.
|
||||||
@@ -738,9 +890,21 @@ class CR(Service):
|
|||||||
response = self.session.get(
|
response = self.session.get(
|
||||||
url=self.config["endpoints"]["playback"].format(episode_id=episode_id),
|
url=self.config["endpoints"]["playback"].format(episode_id=episode_id),
|
||||||
params={"queue": "false"},
|
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_code = response.get("code", "")
|
||||||
error_msg = response.get("message", response.get("error", "Unknown error"))
|
error_msg = response.get("message", response.get("error", "Unknown error"))
|
||||||
|
|
||||||
@@ -775,3 +939,7 @@ class CR(Service):
|
|||||||
if not match:
|
if not match:
|
||||||
raise ValueError(f"Could not parse series ID from: {title_input}")
|
raise ValueError(f"Could not parse series ID from: {title_input}")
|
||||||
return match.group("id")
|
return match.group("id")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def sanitize_filename(name):
|
||||||
|
return re.sub(r'[<>:"/\\|?*¿¡]', '', name).strip()
|
||||||
@@ -15,14 +15,17 @@ endpoints:
|
|||||||
# Content Metadata
|
# Content Metadata
|
||||||
series: "https://www.crunchyroll.com/content/v2/cms/series/{series_id}"
|
series: "https://www.crunchyroll.com/content/v2/cms/series/{series_id}"
|
||||||
seasons: "https://www.crunchyroll.com/content/v2/cms/series/{series_id}/seasons"
|
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"
|
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"
|
skip_events: "https://static.crunchyroll.com/skip-events/production/{episode_id}.json"
|
||||||
|
|
||||||
# Playback
|
# Playback
|
||||||
playback: "https://www.crunchyroll.com/playback/v2/{episode_id}/tv/android_tv/play"
|
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_delete: "https://www.crunchyroll.com/playback/v1/token/{episode_id}/{token}"
|
||||||
playback_sessions: "https://www.crunchyroll.com/playback/v1/sessions/streaming"
|
playback_sessions: "https://www.crunchyroll.com/playback/v1/sessions/streaming"
|
||||||
license_widevine: "https://cr-license-proxy.prd.crunchyrollsvc.com/v1/license/widevine"
|
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
|
# Discovery
|
||||||
search: "https://www.crunchyroll.com/content/v2/discover/search"
|
search: "https://www.crunchyroll.com/content/v2/discover/search"
|
||||||
@@ -38,7 +41,7 @@ headers:
|
|||||||
|
|
||||||
# Query parameters
|
# Query parameters
|
||||||
params:
|
params:
|
||||||
locale: "en-US"
|
locale: "es-419"
|
||||||
|
|
||||||
# Device parameters for authentication
|
# Device parameters for authentication
|
||||||
device:
|
device:
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -9,17 +9,17 @@ certificate: |
|
|||||||
Fp3jPq7R0qyt5P6fU5NshbLh5VKcnQvwg62BuKsdwV9u4NV36b2a546hGRl/GBneQ+QDA7NRrgITR33Sz02Oq8yJr3sy24GfZRTbtLJ4qiWkjtw==
|
Fp3jPq7R0qyt5P6fU5NshbLh5VKcnQvwg62BuKsdwV9u4NV36b2a546hGRl/GBneQ+QDA7NRrgITR33Sz02Oq8yJr3sy24GfZRTbtLJ4qiWkjtw==
|
||||||
|
|
||||||
## config ( {configVersion}/{clientId}/{deviceFamily}/{sdkVersion}/{applicationRuntime}/{deviceProfile}/{environment} ) ##
|
## config ( {configVersion}/{clientId}/{deviceFamily}/{sdkVersion}/{applicationRuntime}/{deviceProfile}/{environment} ) ##
|
||||||
# Browser (windows, chrome) : /browser/v34.2/windows/chrome/prod.json
|
# Browser (windows, chrome) : /browser/v34.4/windows/chrome/prod.json
|
||||||
# Android Phone : /android/v15.0.0/google/handset/prod.json
|
# Android Phone : /android/v16.0.0/google/handset/prod.json
|
||||||
# Android TV : /android/v15.0.0/google/tv/prod.json
|
# Android TV : /android/v16.0.0/google/tv/prod.json
|
||||||
# Amazon Fire TV : /android/v15.0.0/amazon/tv/prod.json
|
# Amazon Fire TV : /android/v16.0.0/amazon/tv/prod.json
|
||||||
|
|
||||||
endpoints:
|
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) ##
|
## 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-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/v15.0.1 (disney-svod-3d9324fc 4.21.1+rc3-2026.01.06.0; v7.0/v15.0.0; android; tv)
|
# 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 ##
|
## api_key ##
|
||||||
# browser : ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84
|
# browser : ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84
|
||||||
@@ -30,11 +30,11 @@ endpoints:
|
|||||||
# android : 624b805dafc5c73635b1a216
|
# android : 624b805dafc5c73635b1a216
|
||||||
|
|
||||||
bamsdk:
|
bamsdk:
|
||||||
sdk_version: "15.0.1"
|
sdk_version: "16.0.0"
|
||||||
application_version: "4.21.1+rc3-2026.01.06.0"
|
application_version: "26.0.2+rc1-2026.01.29.0"
|
||||||
explore_version: "v1.13"
|
explore_version: "v1.13"
|
||||||
client: "disney-svod-3d9324fc"
|
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"
|
api_key: "ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA"
|
||||||
yp_service_id: "624b805dafc5c73635b1a216"
|
yp_service_id: "624b805dafc5c73635b1a216"
|
||||||
|
|
||||||
@@ -46,9 +46,10 @@ device:
|
|||||||
applicationRuntime: "android"
|
applicationRuntime: "android"
|
||||||
operatingSystem: "Android"
|
operatingSystem: "Android"
|
||||||
operatingSystemVersion: "16"
|
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.)
|
# Specifies the index of the profile to use. (0 = first profile, 1 = second profile, etc.)
|
||||||
# Automatically select a profile when commenting.
|
# Automatically select a profile or language when commenting.
|
||||||
# profile:
|
# Language setting is only available in languages supported by Disney+.
|
||||||
# index: 0
|
# preferences:
|
||||||
|
# profile: 0
|
||||||
|
# language: "ko"
|
||||||
|
|||||||
@@ -9,5 +9,6 @@ REGISTER_DEVICE = """mutation ($registerDevice: RegisterDeviceInput!) { register
|
|||||||
REQUESET_OTP = """mutation requestOtp($input: RequestOtpInput!) { requestOtp(requestOtp: $input) { accepted } }"""
|
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_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_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 } }"""
|
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}}"""
|
UPDATE_DEVICE = """mutation updateDeviceOperatingSystem($updateDeviceOperatingSystem: UpdateDeviceOperatingSystemInput!) {updateDeviceOperatingSystem(updateDeviceOperatingSystem: $updateDeviceOperatingSystem) {accepted}}"""
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ directories:
|
|||||||
```
|
```
|
||||||
See help text for each service:
|
See help text for each service:
|
||||||
|
|
||||||
`unshackle dl SERVICE --help`
|
`envied dl SERVICE --help`
|
||||||
|
|
||||||
## Notes:
|
## Notes:
|
||||||
Some versions of the dependencies work better than others. These are the recommended versions as of 25/11/11:
|
Some versions of the dependencies work better than others. These are the recommended versions as of 25/11/11:
|
||||||
|
|||||||
@@ -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("<soap:Envelope")
|
||||||
|
if soap_idx >= 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()
|
||||||
@@ -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"
|
||||||
@@ -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.
|
||||||
@@ -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=)(?P<id>SID[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)
|
||||||
@@ -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"
|
||||||
@@ -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'
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.10, <3.13"
|
requires-python = ">=3.10, <=3.13"
|
||||||
|
|
||||||
[manifest]
|
[manifest]
|
||||||
members = [
|
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/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/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/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]]
|
[[package]]
|
||||||
@@ -117,13 +134,27 @@ version = "1.4.0"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "frozenlist" },
|
{ 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" }
|
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 = [
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "anyio"
|
name = "anyio"
|
||||||
version = "4.11.0"
|
version = "4.11.0"
|
||||||
@@ -132,7 +163,7 @@ dependencies = [
|
|||||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "sniffio" },
|
{ 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" }
|
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 = [
|
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/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/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/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]]
|
[[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/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/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/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]]
|
[[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/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/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/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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "curl-cffi"
|
name = "curl-cffi"
|
||||||
version = "0.14.0"
|
version = "0.13.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "certifi" },
|
{ name = "certifi" },
|
||||||
{ name = "cffi" },
|
{ 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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
||||||
{ 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" },
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
@@ -552,10 +625,12 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "envied"
|
name = "envied"
|
||||||
version = "3.0.0"
|
version = "4.0.0"
|
||||||
source = { editable = "packages/envied" }
|
source = { editable = "packages/envied" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "aiohttp" },
|
||||||
{ name = "aiohttp-swagger3" },
|
{ name = "aiohttp-swagger3" },
|
||||||
|
{ name = "animeapi-py" },
|
||||||
{ name = "appdirs" },
|
{ name = "appdirs" },
|
||||||
{ name = "brotli" },
|
{ name = "brotli" },
|
||||||
{ name = "chardet" },
|
{ name = "chardet" },
|
||||||
@@ -564,6 +639,7 @@ dependencies = [
|
|||||||
{ name = "crccheck" },
|
{ name = "crccheck" },
|
||||||
{ name = "cryptography" },
|
{ name = "cryptography" },
|
||||||
{ name = "curl-cffi" },
|
{ name = "curl-cffi" },
|
||||||
|
{ name = "filelock" },
|
||||||
{ name = "fonttools" },
|
{ name = "fonttools" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "isodate" },
|
{ name = "isodate" },
|
||||||
@@ -614,47 +690,50 @@ dev = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
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 = "appdirs", specifier = ">=1.4.4,<2" },
|
||||||
{ name = "brotli", specifier = ">=1.1.0,<2" },
|
{ name = "brotli", specifier = ">=1.1.0,<2" },
|
||||||
{ name = "chardet", specifier = ">=5.2.0,<6" },
|
{ name = "chardet", specifier = ">=5.2.0,<6" },
|
||||||
{ name = "click", specifier = ">=8.1.8,<9" },
|
{ name = "click", specifier = ">=8.1.8,<9" },
|
||||||
{ name = "construct", specifier = ">=2.8.8,<3" },
|
{ name = "construct", specifier = ">=2.8.8,<3" },
|
||||||
{ name = "crccheck", specifier = ">=1.3.0,<2" },
|
{ name = "crccheck", specifier = ">=1.3.0,<2" },
|
||||||
{ name = "cryptography", specifier = ">=45.0.6,<46.0.0" },
|
{ name = "cryptography", specifier = ">=45.0.0,<47" },
|
||||||
{ name = "curl-cffi", specifier = ">=0.14.0,<0.15" },
|
{ name = "curl-cffi", specifier = ">=0.7.0b4,<0.14" },
|
||||||
{ name = "fonttools", specifier = ">=4.61.1,<5" },
|
{ name = "filelock", specifier = ">=3.20.3,<4" },
|
||||||
|
{ name = "fonttools", specifier = ">=4.60.2,<5" },
|
||||||
{ name = "httpx", specifier = ">=0.28.1,<0.29" },
|
{ name = "httpx", specifier = ">=0.28.1,<0.29" },
|
||||||
{ name = "isodate", specifier = ">=0.7.2,<0.8" },
|
{ name = "isodate", specifier = ">=0.7.2,<0.8" },
|
||||||
{ name = "jsonpickle", specifier = ">=4.1.1,<5" },
|
{ name = "jsonpickle", specifier = ">=3.0.4,<5" },
|
||||||
{ name = "langcodes", specifier = ">=3.5.0,<4" },
|
{ name = "langcodes", specifier = ">=3.4.0,<4" },
|
||||||
{ name = "language-data", specifier = ">=1.4.0" },
|
{ 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 = "mypy", specifier = ">=1.19.1,<2" },
|
||||||
{ name = "pproxy", specifier = ">=2.7.9,<3" },
|
{ 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 = "pycaption", specifier = ">=2.2.6,<3" },
|
||||||
{ name = "pycountry", specifier = ">=24.6.1" },
|
{ 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 = "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 = "pymediainfo", specifier = ">=6.1.0,<8" },
|
||||||
{ name = "pymp4", specifier = ">=1.4.0,<2" },
|
{ name = "pymp4", specifier = ">=1.4.0,<2" },
|
||||||
{ name = "pymysql", specifier = ">=1.1.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 = "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 = "pyyaml", specifier = ">=6.0.1,<7" },
|
||||||
{ name = "requests", extras = ["socks"], specifier = ">=2.32.5,<3" },
|
{ 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 = "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 = "scrapy", specifier = ">=2.14.0,<3" },
|
||||||
{ name = "sortedcontainers", specifier = ">=2.4.0,<3" },
|
{ name = "sortedcontainers", specifier = ">=2.4.0,<3" },
|
||||||
{ name = "subby", git = "https://github.com/vevv/subby.git?rev=5a925c367ffb3f5e53fd114ae222d3be1fdff35d" },
|
{ name = "subby", git = "https://github.com/vevv/subby.git?rev=5a925c367ffb3f5e53fd114ae222d3be1fdff35d" },
|
||||||
{ name = "subtitle-filter", specifier = ">=1.4.9,<2" },
|
{ name = "subtitle-filter", specifier = ">=1.4.9,<2" },
|
||||||
{ name = "unidecode", specifier = ">=1.3.8,<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 = "wasmtime", specifier = ">=41.0.0" },
|
||||||
{ name = "webvtt-py", specifier = ">=0.5.1,<0.6" },
|
{ name = "webvtt-py", specifier = ">=0.5.1,<0.6" },
|
||||||
]
|
]
|
||||||
@@ -676,7 +755,7 @@ name = "exceptiongroup"
|
|||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
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" }
|
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 = [
|
wheels = [
|
||||||
@@ -694,11 +773,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filelock"
|
name = "filelock"
|
||||||
version = "3.19.1"
|
version = "3.25.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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]]
|
[[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/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/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/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" },
|
{ 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/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/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/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" },
|
{ 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/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/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/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]]
|
[[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/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/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/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/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/f5/5a/1ab260c00adf645d8bf7dec7f920f744b032f69130c681302821d5debea6/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4ddb1049fa0579d0cbd00503ad8c58b9ab34d1254c77bc6a5576d96ec7853dba", size = 4216435, upload-time = "2025-09-22T04:04:34.907Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/37/565f3b3d7ffede22874b6d86be1a1763d00f4ea9fc5b9b6ccb11e4ec8612/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb233f9c95f83707dae461b12b720c1af9c28c2d19208e1be03387222151daf5", size = 4325913, upload-time = "2025-09-22T04:04:37.205Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/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/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/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]]
|
[[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/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/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/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" },
|
{ 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/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/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/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" },
|
{ 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/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/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/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" },
|
{ 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" }
|
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" }
|
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 = [
|
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/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/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
|
{ url = "https://files.pythonhosted.org/packages/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" }
|
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" }
|
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 = [
|
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/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/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" },
|
{ url = "https://files.pythonhosted.org/packages/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" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cryptography" },
|
{ 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" }
|
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 = [
|
wheels = [
|
||||||
@@ -1619,7 +1881,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyplayready"
|
name = "pyplayready"
|
||||||
version = "0.8.2"
|
version = "0.8.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
@@ -1632,9 +1894,9 @@ dependencies = [
|
|||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
{ name = "requests" },
|
{ 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 = [
|
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]]
|
[[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/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/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/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]]
|
[[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/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/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/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]]
|
[[package]]
|
||||||
@@ -1892,11 +2169,62 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruamel-yaml"
|
name = "ruamel-yaml"
|
||||||
version = "0.19.1"
|
version = "0.18.17"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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]]
|
[[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/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/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/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" },
|
{ 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/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/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/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" },
|
{ 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/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/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/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" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user