mirror of
https://github.com/vinefeeder/TwinVine.git
synced 2026-07-15 18:10:04 +02:00
envied to 3.0.0
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.8.12,<0.9.0"]
|
||||
requires = ["uv_build>=0.8.12,<0.9.21"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "envied"
|
||||
version = "2.3.0"
|
||||
version = "3.0.0"
|
||||
description = "Modular Movie, TV, and Music Archival Software."
|
||||
authors = [{ name = "rlaphoenix and others" }]
|
||||
requires-python = ">=3.10,<3.13"
|
||||
@@ -66,11 +66,14 @@ dependencies = [
|
||||
|
||||
"webvtt-py>=0.5.1,<0.6",
|
||||
"isodate>=0.7.2,<0.8",
|
||||
"beaupy>=3.10.2,<4",
|
||||
#"beaupy>=3.10.2,<4",
|
||||
"scrapy>=2.14.0,<3",
|
||||
"mypy>=1.19.1,<2",
|
||||
"pysubs2>=1.7.0,<2",
|
||||
"PyExecJS>=1.5.1,<2",
|
||||
"pycountry>=24.6.1",
|
||||
"language-data>=1.4.0",
|
||||
"wasmtime>=41.0.0",
|
||||
|
||||
# Keeping your existing cap style here; latest visible is 0.10.0.
|
||||
"aiohttp-swagger3>=0.10.0,<1",
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.8.12,<0.9.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "envied"
|
||||
version = "1.4.8"
|
||||
description = "Modular Movie, TV, and Music Archival Software."
|
||||
authors = [{ name = "rlaphoenix and others" }]
|
||||
requires-python = ">=3.10,<3.13"
|
||||
readme = "README.md"
|
||||
license = "GPL-3.0-only"
|
||||
keywords = [
|
||||
"python",
|
||||
"downloader",
|
||||
"drm",
|
||||
"widevine",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"Natural Language :: English",
|
||||
"Operating System :: OS Independent",
|
||||
"Topic :: Multimedia :: Video",
|
||||
"Topic :: Security :: Cryptography",
|
||||
]
|
||||
dependencies = [
|
||||
"appdirs>=1.4.4,<2",
|
||||
"Brotli>=1.1.0,<2",
|
||||
"click>=8.1.8,<9",
|
||||
"construct>=2.8.8,<3",
|
||||
"crccheck>=1.3.0,<2",
|
||||
"fonttools>=4.60.1,<5",
|
||||
"jsonpickle>=3.0.4,<4.5",
|
||||
"langcodes>=3.4.0,<4",
|
||||
"lxml>=6.0.2, <7",
|
||||
"pproxy>=2.7.9,<3",
|
||||
"protobuf>=4.25.3,<7",
|
||||
"pycaption>=2.2.6,<3",
|
||||
"pycryptodomex>=3.20.0,<4",
|
||||
"pyjwt>=2.8.0,<3",
|
||||
"pymediainfo>=6.1.0,<8",
|
||||
"pymp4>=1.4.0,<2",
|
||||
"pymysql>=1.1.0,<2",
|
||||
"pywidevine[serve]>=1.8.0,<2",
|
||||
"PyYAML>=6.0.1,<7",
|
||||
"requests[socks]>=2.31.0,<3",
|
||||
"rich>=13.7.1,<15",
|
||||
"rlaphoenix.m3u8>=3.4.0,<4",
|
||||
"ruamel.yaml>=0.18.6,<0.19",
|
||||
"sortedcontainers>=2.4.0,<3",
|
||||
"subtitle-filter>=1.4.9,<2",
|
||||
"Unidecode>=1.3.8,<2",
|
||||
"urllib3>=2.2.1,<3",
|
||||
"chardet>=5.2.0,<6",
|
||||
"curl-cffi>=0.8.0b6,<0.16",
|
||||
"pyplayready>=0.6.3,<0.7",
|
||||
"httpx>=0.28.1,<0.29",
|
||||
"cryptography>=45.0.0,<47",
|
||||
"subby",
|
||||
"webvtt-py>=0.5.1",
|
||||
"isodate>=0.7.2",
|
||||
"beaupy>=3.10.2",
|
||||
"scrapy>=2.13.3",
|
||||
"mypy>=1.18.2,<2",
|
||||
"pysubs2>=1.7.0,<2",
|
||||
"PyExecJS>=1.5.1,<2",
|
||||
"jwt>=1.4.0",
|
||||
"langcodes>=3.5.0",
|
||||
"aiohttp-swagger3>=0.10.0, <1",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/envied-dl/envied"
|
||||
Repository = "https://github.com/envied-dl/envied"
|
||||
Issues = "https://github.com/envied-dl/envied/issues"
|
||||
Discussions = "https://github.com/envied-dl/envied/discussions"
|
||||
Changelog = "https://github.com/envied-dl/envied/blob/master/CHANGELOG.md"
|
||||
|
||||
[project.scripts]
|
||||
envied = "envied.core.__main__:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pre-commit>=3.7.0,<4",
|
||||
"mypy>=1.18.2,<2",
|
||||
"mypy-protobuf>=3.6.0,<4",
|
||||
"types-protobuf>=4.24.0.20240408,<5",
|
||||
"types-PyMySQL>=1.1.0.1,<2",
|
||||
"types-requests>=2.31.0.20240406,<3",
|
||||
"isort>=5.13.2,<6",
|
||||
"ruff~=0.3.7"
|
||||
]
|
||||
|
||||
#[tool.hatch.build.targets.wheel]
|
||||
#packages = ["envied"]
|
||||
|
||||
|
||||
[tool.ruff]
|
||||
force-exclude = true
|
||||
line-length = 120
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E4", "E7", "E9", "F", "W"]
|
||||
|
||||
[tool.isort]
|
||||
line_length = 118
|
||||
|
||||
[tool.mypy]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_defs = true
|
||||
follow_imports = "silent"
|
||||
ignore_missing_imports = true
|
||||
no_implicit_optional = true
|
||||
|
||||
[tool.uv.sources]
|
||||
#subby = { git = "https://github.com/vevv/subby.git" }
|
||||
subby = { git = "https://github.com/vevv/subby.git", rev = "5a925c367ffb3f5e53fd114ae222d3be1fdff35d" }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ from construct import ConstError
|
||||
from pymediainfo import MediaInfo
|
||||
from pyplayready.cdm import Cdm as PlayReadyCdm
|
||||
from pyplayready.device import Device as PlayReadyDevice
|
||||
from pyplayready.remote.remotecdm import RemoteCdm as PlayReadyRemoteCdm
|
||||
from pywidevine.cdm import Cdm as WidevineCdm
|
||||
from pywidevine.device import Device
|
||||
from pywidevine.remotecdm import RemoteCdm
|
||||
@@ -42,13 +43,14 @@ from rich.tree import Tree
|
||||
|
||||
from envied.core import binaries
|
||||
from envied.core.cdm import CustomRemoteCDM, DecryptLabsRemoteCDM
|
||||
from envied.core.cdm.detect import is_playready_cdm, is_widevine_cdm
|
||||
from envied.core.config import config
|
||||
from envied.core.console import console
|
||||
from envied.core.constants import DOWNLOAD_LICENCE_ONLY, AnyTrack, context_settings
|
||||
from envied.core.credential import Credential
|
||||
from envied.core.drm import DRM_T, PlayReady, Widevine
|
||||
from envied.core.drm import DRM_T, MonaLisa, PlayReady, Widevine
|
||||
from envied.core.events import events
|
||||
from envied.core.proxies import Basic, Hola, NordVPN, SurfsharkVPN, WindscribeVPN
|
||||
from envied.core.proxies import Basic, Gluetun, Hola, NordVPN, SurfsharkVPN, WindscribeVPN
|
||||
from envied.core.service import Service
|
||||
from envied.core.services import Services
|
||||
from envied.core.title_cacher import get_account_hash
|
||||
@@ -60,12 +62,13 @@ from envied.core.tracks.hybrid import Hybrid
|
||||
from envied.core.utilities import (find_font_with_fallbacks, get_debug_logger, get_system_fonts, init_debug_logger,
|
||||
is_close_match, suggest_font_packages, time_elapsed_since)
|
||||
from envied.core.utils import tags
|
||||
from envied.core.utils.click_types import (LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, ContextData, MultipleChoice,
|
||||
SubtitleCodecChoice, VideoCodecChoice)
|
||||
from envied.core.utils.click_types import (AUDIO_CODEC_LIST, LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE,
|
||||
ContextData, MultipleChoice, SubtitleCodecChoice, VideoCodecChoice)
|
||||
from envied.core.utils.collections import merge_dict
|
||||
from envied.core.utils.selector import select_multiple
|
||||
from envied.core.utils.subprocess import ffprobe
|
||||
from envied.core.vaults import Vaults
|
||||
from beaupy import select_multiple, Config
|
||||
|
||||
|
||||
class dl:
|
||||
@staticmethod
|
||||
@@ -97,11 +100,7 @@ class dl:
|
||||
return None
|
||||
|
||||
def prepare_temp_font(
|
||||
self,
|
||||
font_name: str,
|
||||
matched_font: Path,
|
||||
system_fonts: dict[str, Path],
|
||||
temp_font_files: list[Path]
|
||||
self, font_name: str, matched_font: Path, system_fonts: dict[str, Path], temp_font_files: list[Path]
|
||||
) -> Path:
|
||||
"""
|
||||
Copy system font to temp and log if using fallback.
|
||||
@@ -116,10 +115,7 @@ class dl:
|
||||
Path to temp font file
|
||||
"""
|
||||
# Find the matched name for logging
|
||||
matched_name = next(
|
||||
(name for name, path in system_fonts.items() if path == matched_font),
|
||||
None
|
||||
)
|
||||
matched_name = next((name for name, path in system_fonts.items() if path == matched_font), None)
|
||||
|
||||
if matched_name and matched_name.lower() != font_name.lower():
|
||||
self.log.info(f"Using '{matched_name}' as fallback for '{font_name}'")
|
||||
@@ -136,10 +132,7 @@ class dl:
|
||||
return temp_path
|
||||
|
||||
def attach_subtitle_fonts(
|
||||
self,
|
||||
font_names: list[str],
|
||||
title: Title_T,
|
||||
temp_font_files: list[Path]
|
||||
self, font_names: list[str], title: Title_T, temp_font_files: list[Path]
|
||||
) -> tuple[int, list[str]]:
|
||||
"""
|
||||
Attach fonts for subtitle rendering.
|
||||
@@ -188,6 +181,99 @@ class dl:
|
||||
self.log.info(f" $ sudo apt install {package_cmd}")
|
||||
self.log.info(f" → Provides: {', '.join(fonts)}")
|
||||
|
||||
def generate_sidecar_subtitle_path(
|
||||
self,
|
||||
subtitle: Subtitle,
|
||||
base_filename: str,
|
||||
output_dir: Path,
|
||||
target_codec: Optional[Subtitle.Codec] = None,
|
||||
source_path: Optional[Path] = None,
|
||||
) -> Path:
|
||||
"""Generate sidecar path: {base}.{lang}[.forced][.sdh].{ext}"""
|
||||
lang_suffix = str(subtitle.language) if subtitle.language else "und"
|
||||
forced_suffix = ".forced" if subtitle.forced else ""
|
||||
sdh_suffix = ".sdh" if (subtitle.sdh or subtitle.cc) else ""
|
||||
|
||||
extension = (target_codec or subtitle.codec or Subtitle.Codec.SubRip).extension
|
||||
if (
|
||||
not target_codec
|
||||
and not subtitle.codec
|
||||
and source_path
|
||||
and source_path.suffix
|
||||
):
|
||||
extension = source_path.suffix.lstrip(".")
|
||||
|
||||
filename = f"{base_filename}.{lang_suffix}{forced_suffix}{sdh_suffix}.{extension}"
|
||||
return output_dir / filename
|
||||
|
||||
def output_subtitle_sidecars(
|
||||
self,
|
||||
subtitles: list[Subtitle],
|
||||
base_filename: str,
|
||||
output_dir: Path,
|
||||
sidecar_format: str,
|
||||
original_paths: Optional[dict[str, Path]] = None,
|
||||
) -> list[Path]:
|
||||
"""Output subtitles as sidecar files, converting if needed."""
|
||||
created_paths: list[Path] = []
|
||||
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for subtitle in subtitles:
|
||||
source_path = subtitle.path
|
||||
if sidecar_format == "original" and original_paths and subtitle.id in original_paths:
|
||||
source_path = original_paths[subtitle.id]
|
||||
|
||||
if not source_path or not source_path.exists():
|
||||
continue
|
||||
|
||||
# Determine target codec
|
||||
if sidecar_format == "original":
|
||||
target_codec = None
|
||||
if source_path.suffix:
|
||||
try:
|
||||
target_codec = Subtitle.Codec.from_mime(source_path.suffix.lstrip("."))
|
||||
except ValueError:
|
||||
target_codec = None
|
||||
else:
|
||||
target_codec = Subtitle.Codec.from_mime(sidecar_format)
|
||||
|
||||
sidecar_path = self.generate_sidecar_subtitle_path(
|
||||
subtitle, base_filename, output_dir, target_codec, source_path=source_path
|
||||
)
|
||||
|
||||
# Copy or convert
|
||||
if not target_codec or subtitle.codec == target_codec:
|
||||
shutil.copy2(source_path, sidecar_path)
|
||||
else:
|
||||
# Create temp copy for conversion to preserve original
|
||||
temp_path = config.directories.temp / f"sidecar_{subtitle.id}{source_path.suffix}"
|
||||
shutil.copy2(source_path, temp_path)
|
||||
|
||||
temp_sub = Subtitle(
|
||||
subtitle.url,
|
||||
subtitle.language,
|
||||
is_original_lang=subtitle.is_original_lang,
|
||||
descriptor=subtitle.descriptor,
|
||||
codec=subtitle.codec,
|
||||
forced=subtitle.forced,
|
||||
sdh=subtitle.sdh,
|
||||
cc=subtitle.cc,
|
||||
id_=f"{subtitle.id}_sc",
|
||||
)
|
||||
temp_sub.path = temp_path
|
||||
try:
|
||||
temp_sub.convert(target_codec)
|
||||
if temp_sub.path and temp_sub.path.exists():
|
||||
shutil.copy2(temp_sub.path, sidecar_path)
|
||||
finally:
|
||||
if temp_sub.path and temp_sub.path.exists():
|
||||
temp_sub.path.unlink(missing_ok=True)
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
created_paths.append(sidecar_path)
|
||||
|
||||
return created_paths
|
||||
|
||||
@click.command(
|
||||
short_help="Download, Decrypt, and Mux tracks for titles from a Service.",
|
||||
cls=Services,
|
||||
@@ -213,9 +299,9 @@ class dl:
|
||||
@click.option(
|
||||
"-a",
|
||||
"--acodec",
|
||||
type=click.Choice(Audio.Codec, case_sensitive=False),
|
||||
default=None,
|
||||
help="Audio Codec to download, defaults to any codec.",
|
||||
type=AUDIO_CODEC_LIST,
|
||||
default=[],
|
||||
help="Audio Codec(s) to download (comma-separated), e.g., 'AAC,EC3'. Defaults to any.",
|
||||
)
|
||||
@click.option(
|
||||
"-vb",
|
||||
@@ -254,13 +340,19 @@ class dl:
|
||||
default=False,
|
||||
help="Exclude Dolby Atmos audio tracks when selecting audio.",
|
||||
)
|
||||
@click.option(
|
||||
"--split-audio",
|
||||
"split_audio",
|
||||
is_flag=True,
|
||||
default=None,
|
||||
help="Create separate output files per audio codec instead of merging all audio.",
|
||||
)
|
||||
@click.option(
|
||||
"--select-titles",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Interactively select downloads from a list. Only use with Series to select Episodes",
|
||||
)
|
||||
|
||||
@click.option(
|
||||
"-w",
|
||||
"--wanted",
|
||||
@@ -268,13 +360,6 @@ class dl:
|
||||
default=None,
|
||||
help="Wanted episodes, e.g. `S01-S05,S07`, `S01E01-S02E03`, `S02-S02E03`, e.t.c, defaults to all.",
|
||||
)
|
||||
@click.option(
|
||||
"-le",
|
||||
"--latest-episode",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Download only the single most recent episode available.",
|
||||
)
|
||||
@click.option(
|
||||
"-l",
|
||||
"--lang",
|
||||
@@ -282,6 +367,12 @@ class dl:
|
||||
default="orig",
|
||||
help="Language wanted for Video and Audio. Use 'orig' to select the original language, e.g. 'orig,en' for both original and English.",
|
||||
)
|
||||
@click.option(
|
||||
"--latest-episode",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Download only the single most recent episode available.",
|
||||
)
|
||||
@click.option(
|
||||
"-vl",
|
||||
"--v-lang",
|
||||
@@ -645,12 +736,17 @@ class dl:
|
||||
"device_type": self.cdm.device_type.name,
|
||||
}
|
||||
else:
|
||||
self.log.info(
|
||||
f"Loaded PlayReady CDM: {self.cdm.certificate_chain.get_name()} (L{self.cdm.security_level})"
|
||||
)
|
||||
# Handle both local PlayReady CDM and RemoteCdm (which has certificate_chain=None)
|
||||
is_remote = self.cdm.certificate_chain is None and hasattr(self.cdm, "device_name")
|
||||
if is_remote:
|
||||
cdm_name = self.cdm.device_name
|
||||
self.log.info(f"Loaded PlayReady Remote CDM: {cdm_name} (L{self.cdm.security_level})")
|
||||
else:
|
||||
cdm_name = self.cdm.certificate_chain.get_name() if self.cdm.certificate_chain else "Unknown"
|
||||
self.log.info(f"Loaded PlayReady CDM: {cdm_name} (L{self.cdm.security_level})")
|
||||
cdm_info = {
|
||||
"type": "PlayReady",
|
||||
"certificate": self.cdm.certificate_chain.get_name(),
|
||||
"certificate": cdm_name,
|
||||
"security_level": self.cdm.security_level,
|
||||
}
|
||||
|
||||
@@ -672,6 +768,8 @@ class dl:
|
||||
self.proxy_providers.append(SurfsharkVPN(**config.proxy_providers["surfsharkvpn"]))
|
||||
if config.proxy_providers.get("windscribevpn"):
|
||||
self.proxy_providers.append(WindscribeVPN(**config.proxy_providers["windscribevpn"]))
|
||||
if config.proxy_providers.get("gluetun"):
|
||||
self.proxy_providers.append(Gluetun(**config.proxy_providers["gluetun"]))
|
||||
if binaries.HolaProxy:
|
||||
self.proxy_providers.append(Hola())
|
||||
for proxy_provider in self.proxy_providers:
|
||||
@@ -682,9 +780,20 @@ class dl:
|
||||
if re.match(r"^[a-z]+:.+$", proxy, re.IGNORECASE):
|
||||
# requesting proxy from a specific proxy provider
|
||||
requested_provider, proxy = proxy.split(":", maxsplit=1)
|
||||
if re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE):
|
||||
# Match simple region codes (us, ca, uk1) or provider:region format (nordvpn:ca, windscribe:us)
|
||||
if re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE) or re.match(
|
||||
r"^[a-z]+:[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE
|
||||
):
|
||||
proxy = proxy.lower()
|
||||
with console.status(f"Getting a Proxy to {proxy}...", spinner="dots"):
|
||||
# Preserve the original user query (region code) for service-specific proxy_map overrides.
|
||||
# NOTE: `proxy` may be overwritten with the resolved proxy URI later.
|
||||
proxy_query = proxy
|
||||
status_msg = (
|
||||
f"Connecting to VPN ({proxy})..."
|
||||
if requested_provider == "gluetun"
|
||||
else f"Getting a Proxy to {proxy}..."
|
||||
)
|
||||
with console.status(status_msg, spinner="dots"):
|
||||
if requested_provider:
|
||||
proxy_provider = next(
|
||||
(x for x in self.proxy_providers if x.__class__.__name__.lower() == requested_provider),
|
||||
@@ -698,16 +807,42 @@ class dl:
|
||||
self.log.error(f"The proxy provider {requested_provider} had no proxy for {proxy}")
|
||||
sys.exit(1)
|
||||
proxy = ctx.params["proxy"] = proxy_uri
|
||||
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
|
||||
# Show connection info for Gluetun (IP, location) instead of proxy URL
|
||||
if hasattr(proxy_provider, "get_connection_info"):
|
||||
conn_info = proxy_provider.get_connection_info(proxy_query)
|
||||
if conn_info and conn_info.get("public_ip"):
|
||||
location_parts = [conn_info.get("city"), conn_info.get("country")]
|
||||
location = ", ".join(p for p in location_parts if p)
|
||||
self.log.info(f"VPN Connected: {conn_info['public_ip']} ({location})")
|
||||
else:
|
||||
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
|
||||
else:
|
||||
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
|
||||
else:
|
||||
for proxy_provider in self.proxy_providers:
|
||||
proxy_uri = proxy_provider.get_proxy(proxy)
|
||||
if proxy_uri:
|
||||
proxy = ctx.params["proxy"] = proxy_uri
|
||||
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
|
||||
# Show connection info for Gluetun (IP, location) instead of proxy URL
|
||||
if hasattr(proxy_provider, "get_connection_info"):
|
||||
conn_info = proxy_provider.get_connection_info(proxy_query)
|
||||
if conn_info and conn_info.get("public_ip"):
|
||||
location_parts = [conn_info.get("city"), conn_info.get("country")]
|
||||
location = ", ".join(p for p in location_parts if p)
|
||||
self.log.info(f"VPN Connected: {conn_info['public_ip']} ({location})")
|
||||
else:
|
||||
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
|
||||
else:
|
||||
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
|
||||
break
|
||||
# Store proxy query info for service-specific overrides
|
||||
ctx.params["proxy_query"] = proxy_query
|
||||
ctx.params["proxy_provider"] = requested_provider
|
||||
else:
|
||||
self.log.info(f"Using explicit Proxy: {proxy}")
|
||||
# For explicit proxies, store None for query/provider
|
||||
ctx.params["proxy_query"] = None
|
||||
ctx.params["proxy_provider"] = None
|
||||
|
||||
ctx.obj = ContextData(
|
||||
config=self.service_config, cdm=self.cdm, proxy_providers=self.proxy_providers, profile=self.profile
|
||||
@@ -725,7 +860,7 @@ class dl:
|
||||
service: Service,
|
||||
quality: list[int],
|
||||
vcodec: Optional[Video.Codec],
|
||||
acodec: Optional[Audio.Codec],
|
||||
acodec: list[Audio.Codec],
|
||||
vbitrate: int,
|
||||
abitrate: int,
|
||||
range_: list[Video.Range],
|
||||
@@ -764,6 +899,7 @@ class dl:
|
||||
workers: Optional[int],
|
||||
downloads: int,
|
||||
best_available: bool,
|
||||
split_audio: Optional[bool] = None,
|
||||
*_: Any,
|
||||
**__: Any,
|
||||
) -> None:
|
||||
@@ -771,6 +907,18 @@ class dl:
|
||||
self.search_source = None
|
||||
start_time = time.time()
|
||||
|
||||
if skip_dl:
|
||||
DOWNLOAD_LICENCE_ONLY.set()
|
||||
|
||||
if not acodec:
|
||||
acodec = []
|
||||
elif isinstance(acodec, Audio.Codec):
|
||||
acodec = [acodec]
|
||||
elif isinstance(acodec, str) or (
|
||||
isinstance(acodec, list) and not all(isinstance(v, Audio.Codec) for v in acodec)
|
||||
):
|
||||
acodec = AUDIO_CODEC_LIST.convert(acodec)
|
||||
|
||||
if require_subs and s_lang != ["all"]:
|
||||
self.log.error("--require-subs and --s-lang cannot be used together")
|
||||
sys.exit(1)
|
||||
@@ -906,41 +1054,85 @@ class dl:
|
||||
console.print(Padding(titles.tree(verbose=list_titles), (0, 5)))
|
||||
if list_titles:
|
||||
return
|
||||
# modification to enable beaupy module to list titles for download for manual selection
|
||||
# use --select-titles after dl in unshackle command
|
||||
|
||||
Config.transient = True
|
||||
# thanks CodeName393 for suggestion to use episode name and full SXXExx format
|
||||
if select_titles and type(titles)==Series:
|
||||
console.print(Padding(Rule(f"[rule.text]\nSelect Titles Option\n"), (1, 2)))
|
||||
|
||||
# Enables manual selection for Series when --select-titles is set
|
||||
if select_titles and type(titles) == Series:
|
||||
console.print(Padding(Rule(f"[rule.text]Select Titles"), (1, 2)))
|
||||
|
||||
beaupy_titles = [
|
||||
f"{i+1}. {t.title} - S{t.season:02}E{t.number:02}"
|
||||
+ (f" - {t.name}" if t.name else "")
|
||||
for i, t in enumerate(titles)
|
||||
]
|
||||
|
||||
selected_idx = select_multiple(
|
||||
beaupy_titles,
|
||||
preprocessor=lambda val: f"[rgb(205,214,244)]{val}[/rgb(205,214,244)]",
|
||||
selection_titles = []
|
||||
dependencies = {}
|
||||
original_indices = {}
|
||||
|
||||
current_season = None
|
||||
current_season_header_idx = -1
|
||||
|
||||
unique_seasons = {t.season for t in titles}
|
||||
multiple_seasons = len(unique_seasons) > 1
|
||||
|
||||
# Build selection options
|
||||
for i, t in enumerate(titles):
|
||||
# Insert season header only if multiple seasons exist
|
||||
if multiple_seasons and t.season != current_season:
|
||||
current_season = t.season
|
||||
header_text = f"Season {t.season}"
|
||||
selection_titles.append(header_text)
|
||||
current_season_header_idx = len(selection_titles) - 1
|
||||
dependencies[current_season_header_idx] = []
|
||||
# Note: Headers are not mapped to actual title indices
|
||||
|
||||
# Format display name
|
||||
display_name = ((t.name[:35].rstrip() + "…") if len(t.name) > 35 else t.name) if t.name else None
|
||||
|
||||
# Apply indentation only for multiple seasons
|
||||
prefix = " " if multiple_seasons else ""
|
||||
option_text = (
|
||||
f"{prefix}{t.number}" + (f". {display_name}" if t.name else "")
|
||||
)
|
||||
|
||||
selection_titles.append(option_text)
|
||||
current_ui_idx = len(selection_titles) - 1
|
||||
|
||||
# Map UI index to actual title index
|
||||
original_indices[current_ui_idx] = i
|
||||
|
||||
# Link episode to season header for group selection
|
||||
if current_season_header_idx != -1:
|
||||
dependencies[current_season_header_idx].append(current_ui_idx)
|
||||
|
||||
selection_start = time.time()
|
||||
|
||||
# Execute selector with dependencies (headers select all children)
|
||||
selected_ui_idx = select_multiple(
|
||||
selection_titles,
|
||||
minimal_count=1,
|
||||
page_size=8,
|
||||
pagination=True,
|
||||
return_indices=True,
|
||||
|
||||
dependencies=dependencies
|
||||
)
|
||||
|
||||
# Keep indices unique & ordered
|
||||
selection_end = time.time()
|
||||
start_time += (selection_end - selection_start)
|
||||
|
||||
# Map UI indices back to title indices (excluding headers)
|
||||
selected_idx = []
|
||||
for idx in selected_ui_idx:
|
||||
if idx in original_indices:
|
||||
selected_idx.append(original_indices[idx])
|
||||
|
||||
# Ensure indices are unique and ordered
|
||||
selected_idx = sorted(set(selected_idx))
|
||||
keep = set(selected_idx)
|
||||
|
||||
# In-place filter: delete everything not selected (walk backwards!)
|
||||
# need to delete from original list to keep super classes meta data
|
||||
# In-place filter: remove unselected items (iterate backwards)
|
||||
for i in range(len(titles) - 1, -1, -1):
|
||||
if i not in keep:
|
||||
del titles[i]
|
||||
console.print(Padding(f"[rgb(205,214,244)]{len(titles)} {'titles' if len(titles) > 1 else 'title'} selected for download[/rgb(205,214,244)]", (0, 5)))
|
||||
# end modification
|
||||
|
||||
# Show selected count
|
||||
if titles:
|
||||
count = len(titles)
|
||||
console.print(Padding(f"[text]Total selected: {count}[/]", (0, 5)))
|
||||
|
||||
# Determine the latest episode if --latest-episode is set
|
||||
latest_episode_id = None
|
||||
if latest_episode and isinstance(titles, Series) and len(titles) > 0:
|
||||
@@ -1101,7 +1293,9 @@ class dl:
|
||||
title.tracks.add(non_sdh_sub)
|
||||
events.subscribe(
|
||||
events.Types.TRACK_MULTIPLEX,
|
||||
lambda track, sub_id=non_sdh_sub.id: (track.strip_hearing_impaired()) if track.id == sub_id else None,
|
||||
lambda track, sub_id=non_sdh_sub.id: (track.strip_hearing_impaired())
|
||||
if track.id == sub_id
|
||||
else None,
|
||||
)
|
||||
|
||||
with console.status("Sorting tracks by language and bitrate...", spinner="dots"):
|
||||
@@ -1314,9 +1508,10 @@ class dl:
|
||||
if not audio_description:
|
||||
title.tracks.select_audio(lambda x: not x.descriptive) # exclude descriptive audio
|
||||
if acodec:
|
||||
title.tracks.select_audio(lambda x: x.codec == acodec)
|
||||
title.tracks.select_audio(lambda x: x.codec in acodec)
|
||||
if not title.tracks.audio:
|
||||
self.log.error(f"There's no {acodec.name} Audio Tracks...")
|
||||
codec_names = ", ".join(c.name for c in acodec)
|
||||
self.log.error(f"No audio tracks matching codecs: {codec_names}")
|
||||
sys.exit(1)
|
||||
if channels:
|
||||
title.tracks.select_audio(lambda x: math.ceil(x.channels) == math.ceil(channels))
|
||||
@@ -1356,22 +1551,88 @@ class dl:
|
||||
unique_languages = {track.language for track in title.tracks.audio}
|
||||
selected_audio = []
|
||||
for language in unique_languages:
|
||||
highest_quality = max(
|
||||
(track for track in title.tracks.audio if track.language == language),
|
||||
key=lambda x: x.bitrate or 0,
|
||||
)
|
||||
selected_audio.append(highest_quality)
|
||||
codecs_to_check = acodec if (acodec and len(acodec) > 1) else [None]
|
||||
for codec in codecs_to_check:
|
||||
base_candidates = [
|
||||
t
|
||||
for t in title.tracks.audio
|
||||
if t.language == language and (codec is None or t.codec == codec)
|
||||
]
|
||||
if not base_candidates:
|
||||
continue
|
||||
if audio_description:
|
||||
standards = [t for t in base_candidates if not t.descriptive]
|
||||
if standards:
|
||||
selected_audio.append(max(standards, key=lambda x: x.bitrate or 0))
|
||||
descs = [t for t in base_candidates if t.descriptive]
|
||||
if descs:
|
||||
selected_audio.append(max(descs, key=lambda x: x.bitrate or 0))
|
||||
else:
|
||||
selected_audio.append(max(base_candidates, key=lambda x: x.bitrate or 0))
|
||||
title.tracks.audio = selected_audio
|
||||
elif "all" not in processed_lang:
|
||||
per_language = 1
|
||||
title.tracks.audio = title.tracks.by_language(
|
||||
title.tracks.audio, processed_lang, per_language=per_language, exact_match=exact_lang
|
||||
)
|
||||
# If multiple codecs were explicitly requested, pick the best track per codec per
|
||||
# requested language instead of selecting *all* bitrate variants of a codec.
|
||||
if acodec and len(acodec) > 1:
|
||||
selected_audio: list[Audio] = []
|
||||
|
||||
for language in processed_lang:
|
||||
for codec in acodec:
|
||||
codec_tracks = [a for a in title.tracks.audio if a.codec == codec]
|
||||
if not codec_tracks:
|
||||
continue
|
||||
|
||||
candidates = title.tracks.by_language(
|
||||
codec_tracks, [language], per_language=0, exact_match=exact_lang
|
||||
)
|
||||
if not candidates:
|
||||
continue
|
||||
|
||||
if audio_description:
|
||||
standards = [t for t in candidates if not t.descriptive]
|
||||
if standards:
|
||||
selected_audio.append(max(standards, key=lambda x: x.bitrate or 0))
|
||||
descs = [t for t in candidates if t.descriptive]
|
||||
if descs:
|
||||
selected_audio.append(max(descs, key=lambda x: x.bitrate or 0))
|
||||
else:
|
||||
selected_audio.append(max(candidates, key=lambda x: x.bitrate or 0))
|
||||
|
||||
title.tracks.audio = selected_audio
|
||||
else:
|
||||
per_language = 1
|
||||
if audio_description:
|
||||
standard_audio = [a for a in title.tracks.audio if not a.descriptive]
|
||||
selected_standards = title.tracks.by_language(
|
||||
standard_audio, processed_lang, per_language=per_language, exact_match=exact_lang
|
||||
)
|
||||
desc_audio = [a for a in title.tracks.audio if a.descriptive]
|
||||
# Include all descriptive tracks for the requested languages.
|
||||
selected_descs = title.tracks.by_language(
|
||||
desc_audio, processed_lang, per_language=0, exact_match=exact_lang
|
||||
)
|
||||
title.tracks.audio = selected_standards + selected_descs
|
||||
else:
|
||||
title.tracks.audio = title.tracks.by_language(
|
||||
title.tracks.audio,
|
||||
processed_lang,
|
||||
per_language=per_language,
|
||||
exact_match=exact_lang,
|
||||
)
|
||||
if not title.tracks.audio:
|
||||
self.log.error(f"There's no {processed_lang} Audio Track, cannot continue...")
|
||||
sys.exit(1)
|
||||
|
||||
if video_only or audio_only or subs_only or chapters_only or no_subs or no_audio or no_chapters or no_video:
|
||||
if (
|
||||
video_only
|
||||
or audio_only
|
||||
or subs_only
|
||||
or chapters_only
|
||||
or no_subs
|
||||
or no_audio
|
||||
or no_chapters
|
||||
or no_video
|
||||
):
|
||||
keep_videos = False
|
||||
keep_audio = False
|
||||
keep_subtitles = False
|
||||
@@ -1427,9 +1688,7 @@ class dl:
|
||||
if video_tracks:
|
||||
highest_quality = max((track.height for track in video_tracks if track.height), default=0)
|
||||
if highest_quality > 0:
|
||||
if isinstance(self.cdm, (WidevineCdm, DecryptLabsRemoteCDM)) and not (
|
||||
isinstance(self.cdm, DecryptLabsRemoteCDM) and self.cdm.is_playready
|
||||
):
|
||||
if is_widevine_cdm(self.cdm):
|
||||
quality_based_cdm = self.get_cdm(
|
||||
self.service, self.profile, drm="widevine", quality=highest_quality
|
||||
)
|
||||
@@ -1438,9 +1697,7 @@ class dl:
|
||||
f"Pre-selecting Widevine CDM based on highest quality {highest_quality}p across all video tracks"
|
||||
)
|
||||
self.cdm = quality_based_cdm
|
||||
elif isinstance(self.cdm, (PlayReadyCdm, DecryptLabsRemoteCDM)) and (
|
||||
isinstance(self.cdm, DecryptLabsRemoteCDM) and self.cdm.is_playready
|
||||
):
|
||||
elif is_playready_cdm(self.cdm):
|
||||
quality_based_cdm = self.get_cdm(
|
||||
self.service, self.profile, drm="playready", quality=highest_quality
|
||||
)
|
||||
@@ -1452,9 +1709,6 @@ class dl:
|
||||
|
||||
dl_start_time = time.time()
|
||||
|
||||
if skip_dl:
|
||||
DOWNLOAD_LICENCE_ONLY.set()
|
||||
|
||||
try:
|
||||
with Live(Padding(download_table, (1, 5)), console=console, refresh_per_second=5):
|
||||
with ThreadPoolExecutor(downloads) as pool:
|
||||
@@ -1475,10 +1729,7 @@ class dl:
|
||||
licence=partial(
|
||||
service.get_playready_license
|
||||
if (
|
||||
isinstance(self.cdm, PlayReadyCdm)
|
||||
or (
|
||||
isinstance(self.cdm, DecryptLabsRemoteCDM) and self.cdm.is_playready
|
||||
)
|
||||
is_playready_cdm(self.cdm)
|
||||
)
|
||||
and hasattr(service, "get_playready_license")
|
||||
else service.get_widevine_license,
|
||||
@@ -1594,6 +1845,25 @@ class dl:
|
||||
break
|
||||
video_track_n += 1
|
||||
|
||||
# Subtitle output mode configuration (for sidecar originals)
|
||||
subtitle_output_mode = config.subtitle.get("output_mode", "mux")
|
||||
sidecar_format = config.subtitle.get("sidecar_format", "srt")
|
||||
skip_subtitle_mux = (
|
||||
subtitle_output_mode == "sidecar" and (title.tracks.videos or title.tracks.audio)
|
||||
)
|
||||
sidecar_subtitles: list[Subtitle] = []
|
||||
sidecar_original_paths: dict[str, Path] = {}
|
||||
if subtitle_output_mode in ("sidecar", "both") and not no_mux:
|
||||
sidecar_subtitles = [s for s in title.tracks.subtitles if s.path and s.path.exists()]
|
||||
if sidecar_format == "original":
|
||||
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
||||
for subtitle in sidecar_subtitles:
|
||||
original_path = (
|
||||
config.directories.temp / f"sidecar_original_{subtitle.id}{subtitle.path.suffix}"
|
||||
)
|
||||
shutil.copy2(subtitle.path, original_path)
|
||||
sidecar_original_paths[subtitle.id] = original_path
|
||||
|
||||
with console.status("Converting Subtitles..."):
|
||||
for subtitle in title.tracks.subtitles:
|
||||
if sub_format:
|
||||
@@ -1609,11 +1879,9 @@ class dl:
|
||||
if subtitle.codec == Subtitle.Codec.SubStationAlphav4:
|
||||
for line in subtitle.path.read_text("utf8").splitlines():
|
||||
if line.startswith("Style: "):
|
||||
font_names.append(line.removesuffix("Style: ").split(",")[1]).strip()
|
||||
font_names.append(line.removeprefix("Style: ").split(",")[1].strip())
|
||||
|
||||
font_count, missing_fonts = self.attach_subtitle_fonts(
|
||||
font_names, title, temp_font_files
|
||||
)
|
||||
font_count, missing_fonts = self.attach_subtitle_fonts(font_names, title, temp_font_files)
|
||||
|
||||
if font_count:
|
||||
self.log.info(f"Attached {font_count} fonts for the Subtitles")
|
||||
@@ -1634,7 +1902,8 @@ class dl:
|
||||
drm = track.get_drm_for_cdm(self.cdm)
|
||||
if drm and hasattr(drm, "decrypt"):
|
||||
drm.decrypt(track.path)
|
||||
has_decrypted = True
|
||||
if not isinstance(drm, MonaLisa):
|
||||
has_decrypted = True
|
||||
events.emit(events.Types.TRACK_REPACKED, track=track)
|
||||
else:
|
||||
self.log.warning(
|
||||
@@ -1656,6 +1925,8 @@ class dl:
|
||||
self.log.info("Repacked one or more tracks with FFMPEG")
|
||||
|
||||
muxed_paths = []
|
||||
muxed_audio_codecs: dict[Path, Optional[Audio.Codec]] = {}
|
||||
append_audio_codec_suffix = True
|
||||
|
||||
if no_mux:
|
||||
# Skip muxing, handle individual track files
|
||||
@@ -1672,7 +1943,44 @@ class dl:
|
||||
console=console,
|
||||
)
|
||||
|
||||
multiplex_tasks: list[tuple[TaskID, Tracks]] = []
|
||||
merge_audio = (
|
||||
(not split_audio) if split_audio is not None else config.muxing.get("merge_audio", True)
|
||||
)
|
||||
# When we split audio (merge_audio=False), multiple outputs may exist per title, so suffix codec.
|
||||
append_audio_codec_suffix = not merge_audio
|
||||
|
||||
multiplex_tasks: list[tuple[TaskID, Tracks, Optional[Audio.Codec]]] = []
|
||||
# Track hybrid-processing outputs explicitly so we can always clean them up,
|
||||
# even if muxing fails early (e.g. SystemExit) before the normal delete loop.
|
||||
hybrid_temp_paths: list[Path] = []
|
||||
|
||||
def clone_tracks_for_audio(base_tracks: Tracks, audio_tracks: list[Audio]) -> Tracks:
|
||||
task_tracks = Tracks()
|
||||
task_tracks.videos = list(base_tracks.videos)
|
||||
task_tracks.audio = audio_tracks
|
||||
task_tracks.subtitles = list(base_tracks.subtitles)
|
||||
task_tracks.chapters = base_tracks.chapters
|
||||
task_tracks.attachments = list(base_tracks.attachments)
|
||||
return task_tracks
|
||||
|
||||
def enqueue_mux_tasks(task_description: str, base_tracks: Tracks) -> None:
|
||||
if merge_audio or not base_tracks.audio:
|
||||
task_id = progress.add_task(f"{task_description}...", total=None, start=False)
|
||||
multiplex_tasks.append((task_id, base_tracks, None))
|
||||
return
|
||||
|
||||
audio_by_codec: dict[Optional[Audio.Codec], list[Audio]] = {}
|
||||
for audio_track in base_tracks.audio:
|
||||
audio_by_codec.setdefault(audio_track.codec, []).append(audio_track)
|
||||
|
||||
for audio_codec, codec_audio_tracks in audio_by_codec.items():
|
||||
description = task_description
|
||||
if audio_codec:
|
||||
description = f"{task_description} {audio_codec.name}"
|
||||
|
||||
task_id = progress.add_task(f"{description}...", total=None, start=False)
|
||||
task_tracks = clone_tracks_for_audio(base_tracks, codec_audio_tracks)
|
||||
multiplex_tasks.append((task_id, task_tracks, audio_codec))
|
||||
|
||||
# Check if we're in hybrid mode
|
||||
if any(r == Video.Range.HYBRID for r in range_) and title.tracks.videos:
|
||||
@@ -1706,27 +2014,29 @@ class dl:
|
||||
# Create unique output filename for this resolution
|
||||
hybrid_filename = f"HDR10-DV-{resolution}p.hevc"
|
||||
hybrid_output_path = config.directories.temp / hybrid_filename
|
||||
hybrid_temp_paths.append(hybrid_output_path)
|
||||
|
||||
# The Hybrid class creates HDR10-DV.hevc, rename it for this resolution
|
||||
default_output = config.directories.temp / "HDR10-DV.hevc"
|
||||
if default_output.exists():
|
||||
# If a previous run left this behind, replace it to avoid move() failures.
|
||||
hybrid_output_path.unlink(missing_ok=True)
|
||||
shutil.move(str(default_output), str(hybrid_output_path))
|
||||
|
||||
# Create a mux task for this resolution
|
||||
task_description = f"Multiplexing Hybrid HDR10+DV {resolution}p"
|
||||
task_id = progress.add_task(f"{task_description}...", total=None, start=False)
|
||||
|
||||
# Create tracks with the hybrid video output for this resolution
|
||||
task_description = f"Multiplexing Hybrid HDR10+DV {resolution}p"
|
||||
task_tracks = Tracks(title.tracks) + title.tracks.chapters + title.tracks.attachments
|
||||
|
||||
# Create a new video track for the hybrid output
|
||||
hybrid_track = deepcopy(hdr10_track)
|
||||
hybrid_track.id = f"hybrid_{hdr10_track.id}_{resolution}"
|
||||
hybrid_track.path = hybrid_output_path
|
||||
hybrid_track.range = Video.Range.DV # It's now a DV track
|
||||
hybrid_track.needs_duration_fix = True
|
||||
title.tracks.add(hybrid_track)
|
||||
task_tracks.videos = [hybrid_track]
|
||||
|
||||
multiplex_tasks.append((task_id, task_tracks))
|
||||
enqueue_mux_tasks(task_description, task_tracks)
|
||||
|
||||
console.print()
|
||||
else:
|
||||
@@ -1739,52 +2049,101 @@ class dl:
|
||||
if len(range_) > 1:
|
||||
task_description += f" {video_track.range.name}"
|
||||
|
||||
task_id = progress.add_task(f"{task_description}...", total=None, start=False)
|
||||
|
||||
task_tracks = Tracks(title.tracks) + title.tracks.chapters + title.tracks.attachments
|
||||
if video_track:
|
||||
task_tracks.videos = [video_track]
|
||||
|
||||
multiplex_tasks.append((task_id, task_tracks))
|
||||
enqueue_mux_tasks(task_description, task_tracks)
|
||||
|
||||
with Live(Padding(progress, (0, 5, 1, 5)), console=console):
|
||||
for task_id, task_tracks in multiplex_tasks:
|
||||
progress.start_task(task_id) # TODO: Needed?
|
||||
audio_expected = not video_only and not no_audio
|
||||
muxed_path, return_code, errors = task_tracks.mux(
|
||||
str(title),
|
||||
progress=partial(progress.update, task_id=task_id),
|
||||
delete=False,
|
||||
audio_expected=audio_expected,
|
||||
title_language=title.language,
|
||||
)
|
||||
muxed_paths.append(muxed_path)
|
||||
if return_code >= 2:
|
||||
self.log.error(f"Failed to Mux video to Matroska file ({return_code}):")
|
||||
elif return_code == 1 or errors:
|
||||
self.log.warning("mkvmerge had at least one warning or error, continuing anyway...")
|
||||
for line in errors:
|
||||
if line.startswith("#GUI#error"):
|
||||
self.log.error(line)
|
||||
try:
|
||||
with Live(Padding(progress, (0, 5, 1, 5)), console=console):
|
||||
mux_index = 0
|
||||
for task_id, task_tracks, audio_codec in multiplex_tasks:
|
||||
progress.start_task(task_id) # TODO: Needed?
|
||||
audio_expected = not video_only and not no_audio
|
||||
muxed_path, return_code, errors = task_tracks.mux(
|
||||
str(title),
|
||||
progress=partial(progress.update, task_id=task_id),
|
||||
delete=False,
|
||||
audio_expected=audio_expected,
|
||||
title_language=title.language,
|
||||
skip_subtitles=skip_subtitle_mux,
|
||||
)
|
||||
if muxed_path.exists():
|
||||
mux_index += 1
|
||||
unique_path = muxed_path.with_name(
|
||||
f"{muxed_path.stem}.{mux_index}{muxed_path.suffix}"
|
||||
)
|
||||
if unique_path != muxed_path:
|
||||
shutil.move(muxed_path, unique_path)
|
||||
muxed_path = unique_path
|
||||
muxed_paths.append(muxed_path)
|
||||
muxed_audio_codecs[muxed_path] = audio_codec
|
||||
if return_code >= 2:
|
||||
self.log.error(f"Failed to Mux video to Matroska file ({return_code}):")
|
||||
elif return_code == 1 or errors:
|
||||
self.log.warning("mkvmerge had at least one warning or error, continuing anyway...")
|
||||
for line in errors:
|
||||
if line.startswith("#GUI#error"):
|
||||
self.log.error(line)
|
||||
else:
|
||||
self.log.warning(line)
|
||||
if return_code >= 2:
|
||||
sys.exit(1)
|
||||
|
||||
# Output sidecar subtitles before deleting track files
|
||||
if sidecar_subtitles and not no_mux:
|
||||
media_info = MediaInfo.parse(muxed_paths[0]) if muxed_paths else None
|
||||
if media_info:
|
||||
base_filename = title.get_filename(media_info, show_service=not no_source)
|
||||
else:
|
||||
self.log.warning(line)
|
||||
if return_code >= 2:
|
||||
sys.exit(1)
|
||||
for video_track in task_tracks.videos:
|
||||
video_track.delete()
|
||||
for track in title.tracks:
|
||||
track.delete()
|
||||
base_filename = str(title)
|
||||
|
||||
# Clear temp font attachment paths and delete other attachments
|
||||
for attachment in title.tracks.attachments:
|
||||
if attachment.path and attachment.path in temp_font_files:
|
||||
attachment.path = None
|
||||
else:
|
||||
attachment.delete()
|
||||
sidecar_dir = config.directories.downloads
|
||||
if not no_folder and isinstance(title, (Episode, Song)) and media_info:
|
||||
sidecar_dir /= title.get_filename(media_info, show_service=not no_source, folder=True)
|
||||
sidecar_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Clean up temp fonts
|
||||
for temp_path in temp_font_files:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
with console.status("Saving subtitle sidecar files..."):
|
||||
created = self.output_subtitle_sidecars(
|
||||
sidecar_subtitles,
|
||||
base_filename,
|
||||
sidecar_dir,
|
||||
sidecar_format,
|
||||
original_paths=sidecar_original_paths or None,
|
||||
)
|
||||
if created:
|
||||
self.log.info(f"Saved {len(created)} sidecar subtitle files")
|
||||
|
||||
for track in title.tracks:
|
||||
track.delete()
|
||||
|
||||
# Clear temp font attachment paths and delete other attachments
|
||||
for attachment in title.tracks.attachments:
|
||||
if attachment.path and attachment.path in temp_font_files:
|
||||
attachment.path = None
|
||||
else:
|
||||
attachment.delete()
|
||||
|
||||
# Clean up temp fonts
|
||||
for temp_path in temp_font_files:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
for temp_path in sidecar_original_paths.values():
|
||||
temp_path.unlink(missing_ok=True)
|
||||
finally:
|
||||
# Hybrid() produces a temp HEVC output we rename; make sure it's never left behind.
|
||||
# Also attempt to remove the default hybrid output name if it still exists.
|
||||
for temp_path in hybrid_temp_paths:
|
||||
try:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
except PermissionError:
|
||||
self.log.warning(f"Failed to delete temp file (in use?): {temp_path}")
|
||||
try:
|
||||
(config.directories.temp / "HDR10-DV.hevc").unlink(missing_ok=True)
|
||||
except PermissionError:
|
||||
self.log.warning(
|
||||
f"Failed to delete temp file (in use?): {config.directories.temp / 'HDR10-DV.hevc'}"
|
||||
)
|
||||
|
||||
else:
|
||||
# dont mux
|
||||
@@ -1846,12 +2205,24 @@ class dl:
|
||||
media_info = MediaInfo.parse(muxed_path)
|
||||
final_dir = config.directories.downloads
|
||||
final_filename = title.get_filename(media_info, show_service=not no_source)
|
||||
audio_codec_suffix = muxed_audio_codecs.get(muxed_path)
|
||||
|
||||
if not no_folder and isinstance(title, (Episode, Song)):
|
||||
final_dir /= title.get_filename(media_info, show_service=not no_source, folder=True)
|
||||
|
||||
final_dir.mkdir(parents=True, exist_ok=True)
|
||||
final_path = final_dir / f"{final_filename}{muxed_path.suffix}"
|
||||
if final_path.exists() and audio_codec_suffix and append_audio_codec_suffix:
|
||||
sep = "." if config.scene_naming else " "
|
||||
final_filename = f"{final_filename.rstrip()}{sep}{audio_codec_suffix.name}"
|
||||
final_path = final_dir / f"{final_filename}{muxed_path.suffix}"
|
||||
|
||||
if final_path.exists():
|
||||
sep = "." if config.scene_naming else " "
|
||||
i = 2
|
||||
while final_path.exists():
|
||||
final_path = final_dir / f"{final_filename.rstrip()}{sep}{i}{muxed_path.suffix}"
|
||||
i += 1
|
||||
|
||||
shutil.move(muxed_path, final_path)
|
||||
tags.tag_file(final_path, title, self.tmdb_id)
|
||||
@@ -1895,9 +2266,7 @@ class dl:
|
||||
track_quality = track.height
|
||||
|
||||
if isinstance(drm, Widevine):
|
||||
if not isinstance(self.cdm, (WidevineCdm, DecryptLabsRemoteCDM)) or (
|
||||
isinstance(self.cdm, DecryptLabsRemoteCDM) and self.cdm.is_playready
|
||||
):
|
||||
if not is_widevine_cdm(self.cdm):
|
||||
widevine_cdm = self.get_cdm(self.service, self.profile, drm="widevine", quality=track_quality)
|
||||
if widevine_cdm:
|
||||
if track_quality:
|
||||
@@ -1907,9 +2276,7 @@ class dl:
|
||||
self.cdm = widevine_cdm
|
||||
|
||||
elif isinstance(drm, PlayReady):
|
||||
if not isinstance(self.cdm, (PlayReadyCdm, DecryptLabsRemoteCDM)) or (
|
||||
isinstance(self.cdm, DecryptLabsRemoteCDM) and not self.cdm.is_playready
|
||||
):
|
||||
if not is_playready_cdm(self.cdm):
|
||||
playready_cdm = self.get_cdm(self.service, self.profile, drm="playready", quality=track_quality)
|
||||
if playready_cdm:
|
||||
if track_quality:
|
||||
@@ -2250,6 +2617,26 @@ class dl:
|
||||
|
||||
export.write_text(jsonpickle.dumps(keys, indent=4), encoding="utf8")
|
||||
|
||||
elif isinstance(drm, MonaLisa):
|
||||
with self.DRM_TABLE_LOCK:
|
||||
display_id = drm.content_id or drm.pssh
|
||||
pssh_display = self.truncate_pssh_for_display(display_id, "MonaLisa")
|
||||
cek_tree = Tree(Text.assemble(("MonaLisa", "cyan"), (f"({pssh_display})", "text"), overflow="fold"))
|
||||
pre_existing_tree = next(
|
||||
(x for x in table.columns[0].cells if isinstance(x, Tree) and x.label == cek_tree.label), None
|
||||
)
|
||||
if pre_existing_tree:
|
||||
cek_tree = pre_existing_tree
|
||||
|
||||
for kid_, key in drm.content_keys.items():
|
||||
label = f"[text2]{kid_.hex}:{key}"
|
||||
if not any(f"{kid_.hex}:{key}" in x.label for x in cek_tree.children):
|
||||
cek_tree.add(label)
|
||||
|
||||
if cek_tree.children and not pre_existing_tree:
|
||||
table.add_row()
|
||||
table.add_row(cek_tree)
|
||||
|
||||
@staticmethod
|
||||
def get_cookie_path(service: str, profile: Optional[str]) -> Optional[Path]:
|
||||
"""Get Service Cookie File Path for Profile."""
|
||||
@@ -2432,14 +2819,23 @@ class dl:
|
||||
return CustomRemoteCDM(service_name=service, vaults=self.vaults, **cdm_api)
|
||||
|
||||
else:
|
||||
return RemoteCdm(
|
||||
device_type=cdm_api["Device Type"],
|
||||
system_id=cdm_api["System ID"],
|
||||
security_level=cdm_api["Security Level"],
|
||||
host=cdm_api["Host"],
|
||||
secret=cdm_api["Secret"],
|
||||
device_name=cdm_api["Device Name"],
|
||||
)
|
||||
device_type = cdm_api.get("Device Type", cdm_api.get("device_type", ""))
|
||||
if str(device_type).upper() == "PLAYREADY":
|
||||
return PlayReadyRemoteCdm(
|
||||
security_level=cdm_api.get("Security Level", cdm_api.get("security_level", 3000)),
|
||||
host=cdm_api.get("Host", cdm_api.get("host")),
|
||||
secret=cdm_api.get("Secret", cdm_api.get("secret")),
|
||||
device_name=cdm_api.get("Device Name", cdm_api.get("device_name")),
|
||||
)
|
||||
else:
|
||||
return RemoteCdm(
|
||||
device_type=cdm_api.get("Device Type", cdm_api.get("device_type", "")),
|
||||
system_id=cdm_api.get("System ID", cdm_api.get("system_id", "")),
|
||||
security_level=cdm_api.get("Security Level", cdm_api.get("security_level", 3000)),
|
||||
host=cdm_api.get("Host", cdm_api.get("host")),
|
||||
secret=cdm_api.get("Secret", cdm_api.get("secret")),
|
||||
device_name=cdm_api.get("Device Name", cdm_api.get("device_name")),
|
||||
)
|
||||
|
||||
prd_path = config.directories.prds / f"{cdm_name}.prd"
|
||||
if not prd_path.is_file():
|
||||
|
||||
@@ -52,6 +52,13 @@ def check() -> None:
|
||||
"desc": "DRM decryption",
|
||||
"cat": "DRM",
|
||||
},
|
||||
{
|
||||
"name": "ML-Worker",
|
||||
"binary": binaries.ML_Worker,
|
||||
"required": False,
|
||||
"desc": "DRM licensing",
|
||||
"cat": "DRM",
|
||||
},
|
||||
# HDR Processing
|
||||
{"name": "dovi_tool", "binary": binaries.DoviTool, "required": False, "desc": "Dolby Vision", "cat": "HDR"},
|
||||
{
|
||||
@@ -97,6 +104,7 @@ def check() -> None:
|
||||
"cat": "Network",
|
||||
},
|
||||
{"name": "Caddy", "binary": binaries.Caddy, "required": False, "desc": "Web server", "cat": "Network"},
|
||||
{"name": "Docker", "binary": binaries.Docker, "required": False, "desc": "Gluetun VPN", "cat": "Network"},
|
||||
]
|
||||
|
||||
# Track overall status
|
||||
|
||||
@@ -16,7 +16,7 @@ from envied.core import binaries
|
||||
from envied.core.config import config
|
||||
from envied.core.console import console
|
||||
from envied.core.constants import context_settings
|
||||
from envied.core.proxies import Basic, Hola, NordVPN, SurfsharkVPN
|
||||
from envied.core.proxies import Basic, Gluetun, Hola, NordVPN, SurfsharkVPN, WindscribeVPN
|
||||
from envied.core.service import Service
|
||||
from envied.core.services import Services
|
||||
from envied.core.utils.click_types import ContextData
|
||||
@@ -71,6 +71,10 @@ def search(ctx: click.Context, no_proxy: bool, profile: Optional[str] = None, pr
|
||||
proxy_providers.append(NordVPN(**config.proxy_providers["nordvpn"]))
|
||||
if config.proxy_providers.get("surfsharkvpn"):
|
||||
proxy_providers.append(SurfsharkVPN(**config.proxy_providers["surfsharkvpn"]))
|
||||
if config.proxy_providers.get("windscribevpn"):
|
||||
proxy_providers.append(WindscribeVPN(**config.proxy_providers["windscribevpn"]))
|
||||
if config.proxy_providers.get("gluetun"):
|
||||
proxy_providers.append(Gluetun(**config.proxy_providers["gluetun"]))
|
||||
if binaries.HolaProxy:
|
||||
proxy_providers.append(Hola())
|
||||
for proxy_provider in proxy_providers:
|
||||
@@ -81,7 +85,8 @@ def search(ctx: click.Context, no_proxy: bool, profile: Optional[str] = None, pr
|
||||
if re.match(r"^[a-z]+:.+$", proxy, re.IGNORECASE):
|
||||
# requesting proxy from a specific proxy provider
|
||||
requested_provider, proxy = proxy.split(":", maxsplit=1)
|
||||
if re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE):
|
||||
# Match simple region codes (us, ca, uk1) or provider:region format (nordvpn:ca, windscribe:us)
|
||||
if re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE) or re.match(r"^[a-z]+:[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE):
|
||||
proxy = proxy.lower()
|
||||
with console.status(f"Getting a Proxy to {proxy}...", spinner="dots"):
|
||||
if requested_provider:
|
||||
|
||||
@@ -11,12 +11,17 @@ from envied.core.constants import context_settings
|
||||
|
||||
|
||||
@click.command(
|
||||
short_help="Serve your Local Widevine Devices and REST API for Remote Access.", context_settings=context_settings
|
||||
short_help="Serve your Local Widevine/PlayReady Devices and REST API for Remote Access.",
|
||||
context_settings=context_settings,
|
||||
)
|
||||
@click.option("-h", "--host", type=str, default="0.0.0.0", help="Host to serve from.")
|
||||
@click.option("-h", "--host", type=str, default="127.0.0.1", help="Host to serve from.")
|
||||
@click.option("-p", "--port", type=int, default=8786, help="Port to serve from.")
|
||||
@click.option("--caddy", is_flag=True, default=False, help="Also serve with Caddy.")
|
||||
@click.option("--api-only", is_flag=True, default=False, help="Serve only the REST API, not pywidevine CDM.")
|
||||
@click.option(
|
||||
"--api-only", is_flag=True, default=False, help="Serve only the REST API, not pywidevine/pyplayready CDM."
|
||||
)
|
||||
@click.option("--no-widevine", is_flag=True, default=False, help="Disable Widevine CDM endpoints.")
|
||||
@click.option("--no-playready", is_flag=True, default=False, help="Disable PlayReady CDM endpoints.")
|
||||
@click.option("--no-key", is_flag=True, default=False, help="Disable API key authentication (allows all requests).")
|
||||
@click.option(
|
||||
"--debug-api",
|
||||
@@ -24,13 +29,30 @@ from envied.core.constants import context_settings
|
||||
default=False,
|
||||
help="Include technical debug information (tracebacks, stderr) in API error responses.",
|
||||
)
|
||||
def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug_api: bool) -> None:
|
||||
@click.option(
|
||||
"--debug",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Enable debug logging for API operations.",
|
||||
)
|
||||
def serve(
|
||||
host: str,
|
||||
port: int,
|
||||
caddy: bool,
|
||||
api_only: bool,
|
||||
no_widevine: bool,
|
||||
no_playready: bool,
|
||||
no_key: bool,
|
||||
debug_api: bool,
|
||||
debug: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Serve your Local Widevine Devices and REST API for Remote Access.
|
||||
Serve your Local Widevine and PlayReady Devices and REST API for Remote Access.
|
||||
|
||||
\b
|
||||
Host as 127.0.0.1 may block remote access even if port-forwarded.
|
||||
Instead, use 0.0.0.0 and ensure the TCP port you choose is forwarded.
|
||||
CDM ENDPOINTS:
|
||||
- Widevine: /{device}/open, /{device}/close/{session_id}, etc.
|
||||
- PlayReady: /playready/{device}/open, /playready/{device}/close/{session_id}, etc.
|
||||
|
||||
\b
|
||||
You may serve with Caddy at the same time with --caddy. You can use Caddy
|
||||
@@ -38,14 +60,31 @@ def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug
|
||||
next to the unshackle config.
|
||||
|
||||
\b
|
||||
The REST API provides programmatic access to unshackle functionality.
|
||||
Configure authentication in your config under serve.users and serve.api_secret.
|
||||
DEVICE CONFIGURATION:
|
||||
WVD files are auto-loaded from the WVDs directory, PRD files from the PRDs directory.
|
||||
Configure user access in envied.yaml:
|
||||
|
||||
\b
|
||||
serve:
|
||||
api_secret: "your-api-secret"
|
||||
users:
|
||||
your-secret-key:
|
||||
devices: ["device_name"] # Widevine devices
|
||||
playready_devices: ["device_name"] # PlayReady devices
|
||||
username: user
|
||||
"""
|
||||
from pyplayready.remote import serve as pyplayready_serve
|
||||
from pywidevine import serve as pywidevine_serve
|
||||
|
||||
log = logging.getLogger("serve")
|
||||
|
||||
# Validate API secret for REST API routes (unless --no-key is used)
|
||||
if debug:
|
||||
logging.basicConfig(level=logging.DEBUG, format="%(name)s - %(levelname)s - %(message)s")
|
||||
log.info("Debug logging enabled for API operations")
|
||||
else:
|
||||
logging.getLogger("api").setLevel(logging.WARNING)
|
||||
logging.getLogger("api.remote").setLevel(logging.WARNING)
|
||||
|
||||
if not no_key:
|
||||
api_secret = config.serve.get("api_secret")
|
||||
if not api_secret:
|
||||
@@ -59,6 +98,9 @@ def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug
|
||||
if debug_api:
|
||||
log.warning("Running with --debug-api: Error responses will include technical debug information!")
|
||||
|
||||
if api_only and (no_widevine or no_playready):
|
||||
raise click.ClickException("Cannot use --api-only with --no-widevine or --no-playready.")
|
||||
|
||||
if caddy:
|
||||
if not binaries.Caddy:
|
||||
raise click.ClickException('Caddy executable "caddy" not found but is required for --caddy.')
|
||||
@@ -73,15 +115,18 @@ def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug
|
||||
config.serve["devices"] = []
|
||||
config.serve["devices"].extend(list(config.directories.wvds.glob("*.wvd")))
|
||||
|
||||
if not config.serve.get("playready_devices"):
|
||||
config.serve["playready_devices"] = []
|
||||
config.serve["playready_devices"].extend(list(config.directories.prds.glob("*.prd")))
|
||||
|
||||
if api_only:
|
||||
# API-only mode: serve just the REST API
|
||||
log.info("Starting REST API server (pywidevine CDM disabled)")
|
||||
log.info("Starting REST API server (pywidevine/pyplayready CDM disabled)")
|
||||
if no_key:
|
||||
app = web.Application(middlewares=[cors_middleware])
|
||||
app["config"] = {"users": []}
|
||||
app["config"] = {"users": {}}
|
||||
else:
|
||||
app = web.Application(middlewares=[cors_middleware, pywidevine_serve.authentication])
|
||||
app["config"] = {"users": [api_secret]}
|
||||
app["config"] = {"users": {api_secret: {"devices": [], "username": "api_user"}}}
|
||||
app["debug_api"] = debug_api
|
||||
setup_routes(app)
|
||||
setup_swagger(app)
|
||||
@@ -90,31 +135,113 @@ def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug
|
||||
log.info("(Press CTRL+C to quit)")
|
||||
web.run_app(app, host=host, port=port, print=None)
|
||||
else:
|
||||
# Integrated mode: serve both pywidevine + REST API
|
||||
log.info("Starting integrated server (pywidevine CDM + REST API)")
|
||||
serve_widevine = not no_widevine
|
||||
serve_playready = not no_playready
|
||||
|
||||
serve_config = dict(config.serve)
|
||||
wvd_devices = serve_config.get("devices", []) if serve_widevine else []
|
||||
prd_devices = serve_config.get("playready_devices", []) if serve_playready else []
|
||||
|
||||
cdm_parts = []
|
||||
if serve_widevine:
|
||||
cdm_parts.append("pywidevine CDM")
|
||||
if serve_playready:
|
||||
cdm_parts.append("pyplayready CDM")
|
||||
log.info(f"Starting integrated server ({' + '.join(cdm_parts)} + REST API)")
|
||||
|
||||
wvd_device_names = [d.stem if hasattr(d, "stem") else str(d) for d in wvd_devices]
|
||||
prd_device_names = [d.stem if hasattr(d, "stem") else str(d) for d in prd_devices]
|
||||
|
||||
if not serve_config.get("users") or not isinstance(serve_config["users"], dict):
|
||||
serve_config["users"] = {}
|
||||
|
||||
if not no_key and api_secret not in serve_config["users"]:
|
||||
serve_config["users"][api_secret] = {
|
||||
"devices": wvd_device_names,
|
||||
"playready_devices": prd_device_names,
|
||||
"username": "api_user",
|
||||
}
|
||||
|
||||
for user_key, user_config in serve_config["users"].items():
|
||||
if "playready_devices" not in user_config:
|
||||
# Require explicit PlayReady device access per user (default: no access).
|
||||
user_config["playready_devices"] = []
|
||||
log.warning(
|
||||
f'User "{user_key}" has no "playready_devices" configured; PlayReady access disabled for this user. '
|
||||
f"Available PlayReady devices: {prd_device_names}"
|
||||
)
|
||||
|
||||
def create_serve_authentication(serve_playready_flag: bool):
|
||||
@web.middleware
|
||||
async def serve_authentication(request: web.Request, handler) -> web.Response:
|
||||
if serve_playready_flag and request.path in ("/playready", "/playready/"):
|
||||
response = await handler(request)
|
||||
else:
|
||||
response = await pywidevine_serve.authentication(request, handler)
|
||||
|
||||
if serve_playready_flag and request.path.startswith("/playready"):
|
||||
from pyplayready import __version__ as pyplayready_version
|
||||
response.headers["Server"] = f"https://git.gay/ready-dl/pyplayready serve v{pyplayready_version}"
|
||||
|
||||
return response
|
||||
return serve_authentication
|
||||
|
||||
# Create integrated app with both pywidevine and API routes
|
||||
if no_key:
|
||||
app = web.Application(middlewares=[cors_middleware])
|
||||
app["config"] = dict(config.serve)
|
||||
app["config"]["users"] = []
|
||||
else:
|
||||
app = web.Application(middlewares=[cors_middleware, pywidevine_serve.authentication])
|
||||
# Setup config - add API secret to users for authentication
|
||||
serve_config = dict(config.serve)
|
||||
if not serve_config.get("users"):
|
||||
serve_config["users"] = []
|
||||
if api_secret not in serve_config["users"]:
|
||||
serve_config["users"].append(api_secret)
|
||||
app["config"] = serve_config
|
||||
serve_auth = create_serve_authentication(serve_playready and bool(prd_devices))
|
||||
app = web.Application(middlewares=[cors_middleware, serve_auth])
|
||||
|
||||
app.on_startup.append(pywidevine_serve._startup)
|
||||
app.on_cleanup.append(pywidevine_serve._cleanup)
|
||||
app.add_routes(pywidevine_serve.routes)
|
||||
app["config"] = serve_config
|
||||
app["debug_api"] = debug_api
|
||||
|
||||
if serve_widevine:
|
||||
app.on_startup.append(pywidevine_serve._startup)
|
||||
app.on_cleanup.append(pywidevine_serve._cleanup)
|
||||
app.add_routes(pywidevine_serve.routes)
|
||||
|
||||
if serve_playready and prd_devices:
|
||||
if no_key:
|
||||
playready_app = web.Application()
|
||||
else:
|
||||
playready_app = web.Application(middlewares=[pyplayready_serve.authentication])
|
||||
|
||||
# PlayReady subapp config maps playready_devices to "devices" for pyplayready compatibility
|
||||
playready_config = {
|
||||
"devices": prd_devices,
|
||||
"users": {
|
||||
user_key: {
|
||||
"devices": user_cfg.get("playready_devices", []),
|
||||
"username": user_cfg.get("username", "user"),
|
||||
}
|
||||
for user_key, user_cfg in serve_config["users"].items()
|
||||
}
|
||||
if not no_key
|
||||
else {},
|
||||
}
|
||||
playready_app["config"] = playready_config
|
||||
playready_app.on_startup.append(pyplayready_serve._startup)
|
||||
playready_app.on_cleanup.append(pyplayready_serve._cleanup)
|
||||
playready_app.add_routes(pyplayready_serve.routes)
|
||||
|
||||
async def playready_ping(_: web.Request) -> web.Response:
|
||||
from pyplayready import __version__ as pyplayready_version
|
||||
response = web.json_response({"message": "OK"})
|
||||
response.headers["Server"] = f"https://git.gay/ready-dl/pyplayready serve v{pyplayready_version}"
|
||||
return response
|
||||
|
||||
app.router.add_route("*", "/playready", playready_ping)
|
||||
|
||||
app.add_subapp("/playready", playready_app)
|
||||
log.info(f"PlayReady CDM endpoints available at http://{host}:{port}/playready/")
|
||||
elif serve_playready:
|
||||
log.info("No PlayReady devices found, skipping PlayReady CDM endpoints")
|
||||
|
||||
setup_routes(app)
|
||||
setup_swagger(app)
|
||||
|
||||
if serve_widevine:
|
||||
log.info(f"Widevine CDM endpoints available at http://{host}:{port}/{{device}}/open")
|
||||
log.info(f"REST API endpoints available at http://{host}:{port}/api/")
|
||||
log.info(f"Swagger UI available at http://{host}:{port}/api/docs/")
|
||||
log.info("(Press CTRL+C to quit)")
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.3.0"
|
||||
__version__ = "3.0.0"
|
||||
|
||||
@@ -227,6 +227,7 @@ def _perform_download(
|
||||
range_=params.get("range", ["SDR"]),
|
||||
channels=params.get("channels"),
|
||||
no_atmos=params.get("no_atmos", False),
|
||||
split_audio=params.get("split_audio"),
|
||||
wanted=params.get("wanted", []),
|
||||
latest_episode=params.get("latest_episode", False),
|
||||
lang=params.get("lang", ["orig"]),
|
||||
|
||||
@@ -191,12 +191,90 @@ def serialize_title(title: Title_T) -> Dict[str, Any]:
|
||||
return result
|
||||
|
||||
|
||||
def serialize_video_track(track: Video) -> Dict[str, Any]:
|
||||
def serialize_drm(drm_list) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Serialize DRM objects to JSON-serializable list."""
|
||||
if not drm_list:
|
||||
return None
|
||||
|
||||
if not isinstance(drm_list, list):
|
||||
drm_list = [drm_list]
|
||||
|
||||
result = []
|
||||
for drm in drm_list:
|
||||
drm_info = {}
|
||||
drm_class = drm.__class__.__name__
|
||||
drm_info["type"] = drm_class.lower()
|
||||
|
||||
# Get PSSH - handle both Widevine and PlayReady
|
||||
if hasattr(drm, "_pssh") and drm._pssh:
|
||||
pssh_obj = None
|
||||
try:
|
||||
pssh_obj = drm._pssh
|
||||
# Try to get base64 representation
|
||||
if hasattr(pssh_obj, "dumps"):
|
||||
# pywidevine PSSH has dumps() method
|
||||
drm_info["pssh"] = pssh_obj.dumps()
|
||||
elif hasattr(pssh_obj, "__bytes__"):
|
||||
# Convert to base64
|
||||
import base64
|
||||
drm_info["pssh"] = base64.b64encode(bytes(pssh_obj)).decode()
|
||||
elif hasattr(pssh_obj, "to_base64"):
|
||||
drm_info["pssh"] = pssh_obj.to_base64()
|
||||
else:
|
||||
# Fallback - str() works for pywidevine PSSH
|
||||
pssh_str = str(pssh_obj)
|
||||
# Check if it's already base64-like or an object repr
|
||||
if not pssh_str.startswith("<"):
|
||||
drm_info["pssh"] = pssh_str
|
||||
except (ValueError, TypeError, KeyError):
|
||||
# Some PSSH implementations can fail to parse/serialize; log and continue.
|
||||
pssh_type = type(pssh_obj).__name__ if pssh_obj is not None else None
|
||||
log.warning(
|
||||
"Failed to extract/serialize PSSH for DRM type=%s pssh_type=%s",
|
||||
drm_class,
|
||||
pssh_type,
|
||||
exc_info=True,
|
||||
)
|
||||
except Exception:
|
||||
# Don't silently swallow unexpected failures; make them visible and propagate.
|
||||
pssh_type = type(pssh_obj).__name__ if pssh_obj is not None else None
|
||||
log.exception(
|
||||
"Unexpected error while extracting/serializing PSSH for DRM type=%s pssh_type=%s",
|
||||
drm_class,
|
||||
pssh_type,
|
||||
)
|
||||
raise
|
||||
|
||||
# Get KIDs
|
||||
if hasattr(drm, "kids") and drm.kids:
|
||||
drm_info["kids"] = [str(kid) for kid in drm.kids]
|
||||
|
||||
# Get content keys if available
|
||||
if hasattr(drm, "content_keys") and drm.content_keys:
|
||||
drm_info["content_keys"] = {str(k): v for k, v in drm.content_keys.items()}
|
||||
|
||||
# Get license URL - essential for remote licensing
|
||||
if hasattr(drm, "license_url") and drm.license_url:
|
||||
drm_info["license_url"] = str(drm.license_url)
|
||||
elif hasattr(drm, "_license_url") and drm._license_url:
|
||||
drm_info["license_url"] = str(drm._license_url)
|
||||
|
||||
result.append(drm_info)
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def serialize_video_track(track: Video, include_url: bool = False) -> Dict[str, Any]:
|
||||
"""Convert video track to JSON-serializable dict."""
|
||||
codec_name = track.codec.name if hasattr(track.codec, "name") else str(track.codec)
|
||||
range_name = track.range.name if hasattr(track.range, "name") else str(track.range)
|
||||
|
||||
return {
|
||||
# Get descriptor for N_m3u8DL-RE compatibility (HLS, DASH, URL, etc.)
|
||||
descriptor_name = None
|
||||
if hasattr(track, "descriptor") and track.descriptor:
|
||||
descriptor_name = track.descriptor.name if hasattr(track.descriptor, "name") else str(track.descriptor)
|
||||
|
||||
result = {
|
||||
"id": str(track.id),
|
||||
"codec": codec_name,
|
||||
"codec_display": VIDEO_CODEC_MAP.get(codec_name, codec_name),
|
||||
@@ -208,15 +286,24 @@ def serialize_video_track(track: Video) -> Dict[str, Any]:
|
||||
"range": range_name,
|
||||
"range_display": DYNAMIC_RANGE_MAP.get(range_name, range_name),
|
||||
"language": str(track.language) if track.language else None,
|
||||
"drm": str(track.drm) if hasattr(track, "drm") and track.drm else None,
|
||||
"drm": serialize_drm(track.drm) if hasattr(track, "drm") and track.drm else None,
|
||||
"descriptor": descriptor_name,
|
||||
}
|
||||
if include_url and hasattr(track, "url") and track.url:
|
||||
result["url"] = str(track.url)
|
||||
return result
|
||||
|
||||
|
||||
def serialize_audio_track(track: Audio) -> Dict[str, Any]:
|
||||
def serialize_audio_track(track: Audio, include_url: bool = False) -> Dict[str, Any]:
|
||||
"""Convert audio track to JSON-serializable dict."""
|
||||
codec_name = track.codec.name if hasattr(track.codec, "name") else str(track.codec)
|
||||
|
||||
return {
|
||||
# Get descriptor for N_m3u8DL-RE compatibility
|
||||
descriptor_name = None
|
||||
if hasattr(track, "descriptor") and track.descriptor:
|
||||
descriptor_name = track.descriptor.name if hasattr(track.descriptor, "name") else str(track.descriptor)
|
||||
|
||||
result = {
|
||||
"id": str(track.id),
|
||||
"codec": codec_name,
|
||||
"codec_display": AUDIO_CODEC_MAP.get(codec_name, codec_name),
|
||||
@@ -225,20 +312,33 @@ def serialize_audio_track(track: Audio) -> Dict[str, Any]:
|
||||
"language": str(track.language) if track.language else None,
|
||||
"atmos": track.atmos if hasattr(track, "atmos") else False,
|
||||
"descriptive": track.descriptive if hasattr(track, "descriptive") else False,
|
||||
"drm": str(track.drm) if hasattr(track, "drm") and track.drm else None,
|
||||
"drm": serialize_drm(track.drm) if hasattr(track, "drm") and track.drm else None,
|
||||
"descriptor": descriptor_name,
|
||||
}
|
||||
if include_url and hasattr(track, "url") and track.url:
|
||||
result["url"] = str(track.url)
|
||||
return result
|
||||
|
||||
|
||||
def serialize_subtitle_track(track: Subtitle) -> Dict[str, Any]:
|
||||
def serialize_subtitle_track(track: Subtitle, include_url: bool = False) -> Dict[str, Any]:
|
||||
"""Convert subtitle track to JSON-serializable dict."""
|
||||
return {
|
||||
# Get descriptor for compatibility
|
||||
descriptor_name = None
|
||||
if hasattr(track, "descriptor") and track.descriptor:
|
||||
descriptor_name = track.descriptor.name if hasattr(track.descriptor, "name") else str(track.descriptor)
|
||||
|
||||
result = {
|
||||
"id": str(track.id),
|
||||
"codec": track.codec.name if hasattr(track.codec, "name") else str(track.codec),
|
||||
"language": str(track.language) if track.language else None,
|
||||
"forced": track.forced if hasattr(track, "forced") else False,
|
||||
"sdh": track.sdh if hasattr(track, "sdh") else False,
|
||||
"cc": track.cc if hasattr(track, "cc") else False,
|
||||
"descriptor": descriptor_name,
|
||||
}
|
||||
if include_url and hasattr(track, "url") and track.url:
|
||||
result["url"] = str(track.url)
|
||||
return result
|
||||
|
||||
|
||||
async def list_titles_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
|
||||
@@ -665,9 +765,17 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]:
|
||||
return f"Invalid vcodec: {data['vcodec']}. Must be one of: {', '.join(valid_vcodecs)}"
|
||||
|
||||
if "acodec" in data and data["acodec"]:
|
||||
valid_acodecs = ["AAC", "AC3", "EAC3", "OPUS", "FLAC", "ALAC", "VORBIS", "DTS"]
|
||||
if data["acodec"].upper() not in valid_acodecs:
|
||||
return f"Invalid acodec: {data['acodec']}. Must be one of: {', '.join(valid_acodecs)}"
|
||||
valid_acodecs = ["AAC", "AC3", "EC3", "EAC3", "DD", "DD+", "AC4", "OPUS", "FLAC", "ALAC", "VORBIS", "OGG", "DTS"]
|
||||
if isinstance(data["acodec"], str):
|
||||
acodec_values = [v.strip() for v in data["acodec"].split(",") if v.strip()]
|
||||
elif isinstance(data["acodec"], list):
|
||||
acodec_values = [str(v).strip() for v in data["acodec"] if str(v).strip()]
|
||||
else:
|
||||
return "acodec must be a string or list"
|
||||
|
||||
invalid = [value for value in acodec_values if value.upper() not in valid_acodecs]
|
||||
if invalid:
|
||||
return f"Invalid acodec: {', '.join(invalid)}. Must be one of: {', '.join(valid_acodecs)}"
|
||||
|
||||
if "sub_format" in data and data["sub_format"]:
|
||||
valid_sub_formats = ["SRT", "VTT", "ASS", "SSA"]
|
||||
|
||||
@@ -413,7 +413,7 @@ async def download(request: web.Request) -> web.Response:
|
||||
description: Video codec to download (e.g., H264, H265, VP9, AV1) (default - None)
|
||||
acodec:
|
||||
type: string
|
||||
description: Audio codec to download (e.g., AAC, AC3, EAC3) (default - None)
|
||||
description: Audio codec(s) to download (e.g., AAC or AAC,EC3) (default - None)
|
||||
vbitrate:
|
||||
type: integer
|
||||
description: Video bitrate in kbps (default - None)
|
||||
|
||||
@@ -17,6 +17,10 @@ def find(*names: str) -> Optional[Path]:
|
||||
if local_binaries_dir.exists():
|
||||
candidate_paths = [local_binaries_dir / f"{name}{ext}", local_binaries_dir / name / f"{name}{ext}"]
|
||||
|
||||
for subdir in local_binaries_dir.iterdir():
|
||||
if subdir.is_dir():
|
||||
candidate_paths.append(subdir / f"{name}{ext}")
|
||||
|
||||
for path in candidate_paths:
|
||||
if path.is_file():
|
||||
# On Unix-like systems, check if file is executable
|
||||
@@ -52,6 +56,8 @@ Mkvpropedit = find("mkvpropedit")
|
||||
DoviTool = find("dovi_tool")
|
||||
HDR10PlusTool = find("hdr10plus_tool", "HDR10Plus_tool")
|
||||
Mp4decrypt = find("mp4decrypt")
|
||||
Docker = find("docker")
|
||||
ML_Worker = find("ML-Worker")
|
||||
|
||||
|
||||
__all__ = (
|
||||
@@ -71,5 +77,7 @@ __all__ = (
|
||||
"DoviTool",
|
||||
"HDR10PlusTool",
|
||||
"Mp4decrypt",
|
||||
"Docker",
|
||||
"ML_Worker",
|
||||
"find",
|
||||
)
|
||||
|
||||
@@ -1,4 +1,57 @@
|
||||
from .custom_remote_cdm import CustomRemoteCDM
|
||||
from .decrypt_labs_remote_cdm import DecryptLabsRemoteCDM
|
||||
"""
|
||||
CDM helpers and implementations.
|
||||
|
||||
__all__ = ["DecryptLabsRemoteCDM", "CustomRemoteCDM"]
|
||||
Keep this module import-light: downstream code frequently imports helpers from
|
||||
`envied.core.cdm.detect`, which requires importing this package first.
|
||||
Some CDM implementations pull in optional/heavy dependencies, so we lazily
|
||||
import them via `__getattr__` (PEP 562).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
__all__ = [
|
||||
"DecryptLabsRemoteCDM",
|
||||
"CustomRemoteCDM",
|
||||
"MonaLisaCDM",
|
||||
"is_remote_cdm",
|
||||
"is_local_cdm",
|
||||
"cdm_location",
|
||||
"is_playready_cdm",
|
||||
"is_widevine_cdm",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name == "DecryptLabsRemoteCDM":
|
||||
from .decrypt_labs_remote_cdm import DecryptLabsRemoteCDM
|
||||
|
||||
return DecryptLabsRemoteCDM
|
||||
if name == "CustomRemoteCDM":
|
||||
from .custom_remote_cdm import CustomRemoteCDM
|
||||
|
||||
return CustomRemoteCDM
|
||||
if name == "MonaLisaCDM":
|
||||
from .monalisa import MonaLisaCDM
|
||||
|
||||
return MonaLisaCDM
|
||||
|
||||
if name in {
|
||||
"is_remote_cdm",
|
||||
"is_local_cdm",
|
||||
"cdm_location",
|
||||
"is_playready_cdm",
|
||||
"is_widevine_cdm",
|
||||
}:
|
||||
from .detect import cdm_location, is_local_cdm, is_playready_cdm, is_remote_cdm, is_widevine_cdm
|
||||
|
||||
return {
|
||||
"is_remote_cdm": is_remote_cdm,
|
||||
"is_local_cdm": is_local_cdm,
|
||||
"cdm_location": cdm_location,
|
||||
"is_playready_cdm": is_playready_cdm,
|
||||
"is_widevine_cdm": is_widevine_cdm,
|
||||
}[name]
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@@ -449,8 +449,8 @@ class DecryptLabsRemoteCDM:
|
||||
error_msg = data.get("message", "Unknown error")
|
||||
if "details" in data:
|
||||
error_msg += f" - Details: {data['details']}"
|
||||
if "error" in data:
|
||||
error_msg += f" - Error: {data['error']}"
|
||||
if "Error" in data:
|
||||
error_msg += f" - Error: {data['Error']}"
|
||||
|
||||
if "service_certificate is required" in str(data) and not session["service_certificate"]:
|
||||
error_msg += " (No service certificate was provided to the CDM session)"
|
||||
@@ -537,8 +537,8 @@ class DecryptLabsRemoteCDM:
|
||||
error_msg = f"API response: {data['message']} - {error_msg}"
|
||||
if "details" in data:
|
||||
error_msg += f" - Details: {data['details']}"
|
||||
if "error" in data:
|
||||
error_msg += f" - Error: {data['error']}"
|
||||
if "Error" in data:
|
||||
error_msg += f" - Error: {data['Error']}"
|
||||
|
||||
if already_tried_cache and data.get("message") == "success":
|
||||
return b""
|
||||
@@ -612,8 +612,8 @@ class DecryptLabsRemoteCDM:
|
||||
|
||||
if data.get("message") != "success":
|
||||
error_msg = data.get("message", "Unknown error")
|
||||
if "error" in data:
|
||||
error_msg += f" - Error: {data['error']}"
|
||||
if "Error" in data:
|
||||
error_msg += f" - Error: {data['Error']}"
|
||||
if "details" in data:
|
||||
error_msg += f" - Details: {data['details']}"
|
||||
raise requests.RequestException(f"License decrypt error: {error_msg}")
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def is_remote_cdm(cdm: Any) -> bool:
|
||||
"""
|
||||
Return True if the CDM instance is backed by a remote/service CDM.
|
||||
|
||||
This is useful for service logic that needs to know whether the CDM runs
|
||||
locally (in-process) vs over HTTP/RPC (remote).
|
||||
"""
|
||||
|
||||
if cdm is None:
|
||||
return False
|
||||
|
||||
if hasattr(cdm, "is_remote_cdm"):
|
||||
try:
|
||||
return bool(getattr(cdm, "is_remote_cdm"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from pyplayready.remote.remotecdm import RemoteCdm as PlayReadyRemoteCdm
|
||||
except Exception:
|
||||
PlayReadyRemoteCdm = None
|
||||
|
||||
if PlayReadyRemoteCdm is not None:
|
||||
try:
|
||||
if isinstance(cdm, PlayReadyRemoteCdm):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from pywidevine.remotecdm import RemoteCdm as WidevineRemoteCdm
|
||||
except Exception:
|
||||
WidevineRemoteCdm = None
|
||||
|
||||
if WidevineRemoteCdm is not None:
|
||||
try:
|
||||
if isinstance(cdm, WidevineRemoteCdm):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cls = getattr(cdm, "__class__", None)
|
||||
mod = getattr(cls, "__module__", "") or ""
|
||||
name = getattr(cls, "__name__", "") or ""
|
||||
|
||||
if mod == "envied.core.cdm.decrypt_labs_remote_cdm" and name == "DecryptLabsRemoteCDM":
|
||||
return True
|
||||
if mod == "envied.core.cdm.custom_remote_cdm" and name == "CustomRemoteCDM":
|
||||
return True
|
||||
|
||||
if mod.startswith("pyplayready.remote") or mod.startswith("pywidevine.remote"):
|
||||
return True
|
||||
if "remote" in mod.lower() and name.lower().endswith("cdm"):
|
||||
return True
|
||||
if name.lower().endswith("remotecdm"):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def is_local_cdm(cdm: Any) -> bool:
|
||||
"""
|
||||
Return True if the CDM instance is local/in-process.
|
||||
|
||||
Unknown CDM types return False (use `cdm_location()` if you need 3-state).
|
||||
"""
|
||||
|
||||
if cdm is None:
|
||||
return False
|
||||
|
||||
if is_remote_cdm(cdm):
|
||||
return False
|
||||
|
||||
if is_playready_cdm(cdm) or is_widevine_cdm(cdm):
|
||||
return True
|
||||
|
||||
cls = getattr(cdm, "__class__", None)
|
||||
mod = getattr(cls, "__module__", "") or ""
|
||||
name = getattr(cls, "__name__", "") or ""
|
||||
if mod == "envied.core.cdm.monalisa.monalisa_cdm" and name == "MonaLisaCDM":
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def cdm_location(cdm: Any) -> str:
|
||||
"""
|
||||
Return one of: "local", "remote", "unknown".
|
||||
"""
|
||||
|
||||
if is_remote_cdm(cdm):
|
||||
return "remote"
|
||||
if is_local_cdm(cdm):
|
||||
return "local"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def is_playready_cdm(cdm: Any) -> bool:
|
||||
"""
|
||||
Return True if the given CDM should be treated as PlayReady.
|
||||
|
||||
This intentionally supports both:
|
||||
- Local PlayReady CDMs (pyplayready.cdm.Cdm)
|
||||
- Remote/wrapper CDMs (e.g. DecryptLabsRemoteCDM) that expose `is_playready`
|
||||
"""
|
||||
|
||||
if cdm is None:
|
||||
return False
|
||||
|
||||
if hasattr(cdm, "is_playready"):
|
||||
try:
|
||||
return bool(getattr(cdm, "is_playready"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from pyplayready.cdm import Cdm as PlayReadyCdm
|
||||
except Exception:
|
||||
PlayReadyCdm = None
|
||||
|
||||
if PlayReadyCdm is not None:
|
||||
try:
|
||||
return isinstance(cdm, PlayReadyCdm)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from pyplayready.remote.remotecdm import RemoteCdm as PlayReadyRemoteCdm
|
||||
except Exception:
|
||||
PlayReadyRemoteCdm = None
|
||||
|
||||
if PlayReadyRemoteCdm is not None:
|
||||
try:
|
||||
return isinstance(cdm, PlayReadyRemoteCdm)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
mod = getattr(getattr(cdm, "__class__", None), "__module__", "") or ""
|
||||
return "pyplayready" in mod
|
||||
|
||||
|
||||
def is_widevine_cdm(cdm: Any) -> bool:
|
||||
"""
|
||||
Return True if the given CDM should be treated as Widevine.
|
||||
|
||||
Note: for remote/wrapper CDMs that expose `is_playready`, Widevine is treated
|
||||
as the logical opposite.
|
||||
"""
|
||||
|
||||
if cdm is None:
|
||||
return False
|
||||
|
||||
if hasattr(cdm, "is_playready"):
|
||||
try:
|
||||
return not bool(getattr(cdm, "is_playready"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from pywidevine.cdm import Cdm as WidevineCdm
|
||||
except Exception:
|
||||
WidevineCdm = None
|
||||
|
||||
if WidevineCdm is not None:
|
||||
try:
|
||||
return isinstance(cdm, WidevineCdm)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from pywidevine.remotecdm import RemoteCdm as WidevineRemoteCdm
|
||||
except Exception:
|
||||
WidevineRemoteCdm = None
|
||||
|
||||
if WidevineRemoteCdm is not None:
|
||||
try:
|
||||
return isinstance(cdm, WidevineRemoteCdm)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
mod = getattr(getattr(cdm, "__class__", None), "__module__", "") or ""
|
||||
return "pywidevine" in mod
|
||||
@@ -0,0 +1,3 @@
|
||||
from .monalisa_cdm import MonaLisaCDM
|
||||
|
||||
__all__ = ["MonaLisaCDM"]
|
||||
@@ -0,0 +1,417 @@
|
||||
"""
|
||||
MonaLisa CDM - WASM-based Content Decryption Module wrapper.
|
||||
|
||||
This module provides key extraction from MonaLisa-protected content using
|
||||
a WebAssembly module that runs locally via wasmtime.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import ctypes
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Union
|
||||
|
||||
import wasmtime
|
||||
|
||||
from envied.core import binaries
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MonaLisaCDM:
|
||||
"""
|
||||
MonaLisa CDM wrapper for WASM-based key extraction.
|
||||
|
||||
This CDM differs from Widevine/PlayReady in that it does not use a
|
||||
challenge/response flow with a license server. Instead, the license
|
||||
(ticket) is provided directly by the service API, and keys are extracted
|
||||
locally via the WASM module.
|
||||
"""
|
||||
|
||||
DYNAMIC_BASE = 6065008
|
||||
DYNAMICTOP_PTR = 821968
|
||||
LICENSE_KEY_OFFSET = 0x5C8C0C
|
||||
LICENSE_KEY_LENGTH = 16
|
||||
|
||||
ENV_STRINGS = (
|
||||
"USER=web_user",
|
||||
"LOGNAME=web_user",
|
||||
"PATH=/",
|
||||
"PWD=/",
|
||||
"HOME=/home/web_user",
|
||||
"LANG=zh_CN.UTF-8",
|
||||
"_=./this.program",
|
||||
)
|
||||
|
||||
def __init__(self, device_path: Path):
|
||||
"""
|
||||
Initialize the MonaLisa CDM.
|
||||
|
||||
Args:
|
||||
device_path: Path to the device file (.mld).
|
||||
"""
|
||||
device_path = Path(device_path)
|
||||
|
||||
self.device_path = device_path
|
||||
self.base_dir = device_path.parent
|
||||
|
||||
if not self.device_path.is_file():
|
||||
raise FileNotFoundError(f"Device file not found at: {self.device_path}")
|
||||
|
||||
try:
|
||||
data = json.loads(self.device_path.read_text(encoding="utf-8", errors="replace"))
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid device file (JSON): {e}")
|
||||
|
||||
wasm_path_str = data.get("wasm_path")
|
||||
if not wasm_path_str:
|
||||
raise ValueError("Device file missing 'wasm_path'")
|
||||
|
||||
wasm_filename = Path(wasm_path_str).name
|
||||
wasm_path = self.base_dir / wasm_filename
|
||||
|
||||
if not wasm_path.exists():
|
||||
raise FileNotFoundError(f"WASM file not found at: {wasm_path}")
|
||||
|
||||
try:
|
||||
self.engine = wasmtime.Engine()
|
||||
if wasm_path.suffix.lower() == ".wat":
|
||||
self.module = wasmtime.Module.from_file(self.engine, str(wasm_path))
|
||||
else:
|
||||
self.module = wasmtime.Module(self.engine, wasm_path.read_bytes())
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to load WASM module: {e}")
|
||||
|
||||
self.store = None
|
||||
self.memory = None
|
||||
self.instance = None
|
||||
self.exports = {}
|
||||
self.ctx = None
|
||||
|
||||
@staticmethod
|
||||
def get_worker_path() -> Optional[Path]:
|
||||
"""Get ML-Worker binary path from the unshackle binaries system."""
|
||||
if binaries.ML_Worker:
|
||||
return Path(binaries.ML_Worker)
|
||||
return None
|
||||
|
||||
def open(self) -> int:
|
||||
"""
|
||||
Open a CDM session.
|
||||
|
||||
Returns:
|
||||
Session ID (always 1 for MonaLisa).
|
||||
|
||||
Raises:
|
||||
RuntimeError: If session initialization fails.
|
||||
"""
|
||||
try:
|
||||
self.store = wasmtime.Store(self.engine)
|
||||
memory_type = wasmtime.MemoryType(wasmtime.Limits(256, 256))
|
||||
self.memory = wasmtime.Memory(self.store, memory_type)
|
||||
|
||||
self._write_i32(self.DYNAMICTOP_PTR, self.DYNAMIC_BASE)
|
||||
imports = self._build_imports()
|
||||
self.instance = wasmtime.Instance(self.store, self.module, imports)
|
||||
|
||||
ex = self.instance.exports(self.store)
|
||||
self.exports = {
|
||||
"___wasm_call_ctors": ex["s"],
|
||||
"_monalisa_context_alloc": ex["D"],
|
||||
"monalisa_set_license": ex["F"],
|
||||
"_monalisa_set_canvas_id": ex["t"],
|
||||
"_monalisa_version_get": ex["A"],
|
||||
"monalisa_get_line_number": ex["v"],
|
||||
"stackAlloc": ex["N"],
|
||||
"stackSave": ex["L"],
|
||||
"stackRestore": ex["M"],
|
||||
}
|
||||
|
||||
self.exports["___wasm_call_ctors"](self.store)
|
||||
ctx = self.exports["_monalisa_context_alloc"](self.store)
|
||||
self.ctx = ctx
|
||||
|
||||
# _monalisa_context_alloc is expected to return a positive pointer/handle.
|
||||
# Treat 0/negative/non-int-like values as allocation failure.
|
||||
try:
|
||||
ctx_int = int(ctx)
|
||||
except Exception:
|
||||
ctx_int = None
|
||||
|
||||
if ctx_int is None or ctx_int <= 0:
|
||||
# Ensure we don't leave a partially-initialized instance around.
|
||||
self.close()
|
||||
raise RuntimeError(f"Failed to allocate MonaLisa context (ctx={ctx!r})")
|
||||
return 1
|
||||
except Exception as e:
|
||||
# Clean up partial state (e.g., store/memory/instance) before propagating failure.
|
||||
self.close()
|
||||
if isinstance(e, RuntimeError):
|
||||
raise
|
||||
raise RuntimeError(f"Failed to initialize session: {e}") from e
|
||||
|
||||
def close(self, session_id: int = 1) -> None:
|
||||
"""
|
||||
Close the CDM session and release resources.
|
||||
|
||||
Args:
|
||||
session_id: The session ID to close (unused, for API compatibility).
|
||||
"""
|
||||
self.store = None
|
||||
self.memory = None
|
||||
self.instance = None
|
||||
self.exports = {}
|
||||
self.ctx = None
|
||||
|
||||
def extract_keys(self, license_data: Union[str, bytes]) -> Dict:
|
||||
"""
|
||||
Extract decryption keys from license/ticket data.
|
||||
|
||||
Args:
|
||||
license_data: The license ticket, either as base64 string or raw bytes.
|
||||
|
||||
Returns:
|
||||
Dictionary with keys: kid (hex), key (hex), type ("CONTENT").
|
||||
|
||||
Raises:
|
||||
RuntimeError: If session not open or license validation fails.
|
||||
ValueError: If license_data is empty.
|
||||
"""
|
||||
if not self.instance or not self.memory or self.ctx is None:
|
||||
raise RuntimeError("Session not open. Call open() first.")
|
||||
|
||||
if not license_data:
|
||||
raise ValueError("license_data is empty")
|
||||
|
||||
if isinstance(license_data, bytes):
|
||||
license_b64 = base64.b64encode(license_data).decode("utf-8")
|
||||
else:
|
||||
license_b64 = license_data
|
||||
|
||||
ret = self._ccall(
|
||||
"monalisa_set_license",
|
||||
int,
|
||||
self.ctx,
|
||||
license_b64,
|
||||
len(license_b64),
|
||||
"0",
|
||||
)
|
||||
|
||||
if ret != 0:
|
||||
raise RuntimeError(f"License validation failed with code: {ret}")
|
||||
|
||||
key_bytes = self._extract_license_key_bytes()
|
||||
|
||||
# Extract DCID from license to generate KID
|
||||
try:
|
||||
decoded = base64.b64decode(license_b64).decode("ascii", errors="ignore")
|
||||
except Exception as e:
|
||||
# Avoid logging raw license content; log only safe metadata.
|
||||
logger.exception("Failed to base64-decode MonaLisa license (len=%s): %s", len(license_b64), e)
|
||||
decoded = ""
|
||||
|
||||
m = re.search(
|
||||
r"DCID-[A-Z0-9]+-[A-Z0-9]+-\d{8}-\d{6}-[A-Z0-9]+-\d{10}-[A-Z0-9]+",
|
||||
decoded,
|
||||
)
|
||||
if m:
|
||||
kid_bytes = uuid.uuid5(uuid.NAMESPACE_DNS, m.group()).bytes
|
||||
else:
|
||||
# No DCID in the license: derive a deterministic per-license KID to avoid collisions.
|
||||
try:
|
||||
license_raw = base64.b64decode(license_b64)
|
||||
except Exception:
|
||||
license_raw = license_b64.encode("utf-8", errors="replace")
|
||||
|
||||
license_hash = hashlib.sha256(license_raw).hexdigest()
|
||||
kid_bytes = uuid.uuid5(uuid.NAMESPACE_DNS, f"monalisa:license:{license_hash}").bytes
|
||||
|
||||
return {"kid": kid_bytes.hex(), "key": key_bytes.hex(), "type": "CONTENT"}
|
||||
|
||||
def _extract_license_key_bytes(self) -> bytes:
|
||||
"""Extract the 16-byte decryption key from WASM memory."""
|
||||
data_ptr = self.memory.data_ptr(self.store)
|
||||
data_len = self.memory.data_len(self.store)
|
||||
|
||||
if self.LICENSE_KEY_OFFSET + self.LICENSE_KEY_LENGTH > data_len:
|
||||
raise RuntimeError("License key offset beyond memory bounds")
|
||||
|
||||
mem_ptr = ctypes.cast(data_ptr, ctypes.POINTER(ctypes.c_ubyte * data_len))
|
||||
start = self.LICENSE_KEY_OFFSET
|
||||
end = self.LICENSE_KEY_OFFSET + self.LICENSE_KEY_LENGTH
|
||||
|
||||
return bytes(mem_ptr.contents[start:end])
|
||||
|
||||
def _ccall(self, func_name: str, return_type: type, *args):
|
||||
"""Call a WASM function with automatic string conversion."""
|
||||
stack = 0
|
||||
converted_args = []
|
||||
|
||||
try:
|
||||
for arg in args:
|
||||
if isinstance(arg, str):
|
||||
if stack == 0:
|
||||
stack = self.exports["stackSave"](self.store)
|
||||
max_length = (len(arg) << 2) + 1
|
||||
ptr = self.exports["stackAlloc"](self.store, max_length)
|
||||
self._string_to_utf8(arg, ptr, max_length)
|
||||
converted_args.append(ptr)
|
||||
else:
|
||||
converted_args.append(arg)
|
||||
|
||||
result = self.exports[func_name](self.store, *converted_args)
|
||||
finally:
|
||||
# stackAlloc pointers live on the WASM stack; always restore even if the call throws.
|
||||
if stack != 0:
|
||||
exc = sys.exc_info()[1]
|
||||
try:
|
||||
self.exports["stackRestore"](self.store, stack)
|
||||
except Exception:
|
||||
# If we're already failing, don't mask the original exception.
|
||||
if exc is None:
|
||||
raise
|
||||
|
||||
if return_type is bool:
|
||||
return bool(result)
|
||||
return result
|
||||
|
||||
def _write_i32(self, addr: int, value: int) -> None:
|
||||
"""Write a 32-bit integer to WASM memory."""
|
||||
if addr % 4 != 0:
|
||||
raise ValueError(f"Unaligned i32 write: addr={addr} (must be 4-byte aligned)")
|
||||
|
||||
data_len = self.memory.data_len(self.store)
|
||||
if addr < 0 or addr + 4 > data_len:
|
||||
raise IndexError(f"i32 write out of bounds: addr={addr}, mem_len={data_len}")
|
||||
|
||||
data = self.memory.data_ptr(self.store)
|
||||
mem_ptr = ctypes.cast(data, ctypes.POINTER(ctypes.c_int32))
|
||||
mem_ptr[addr >> 2] = value
|
||||
|
||||
def _string_to_utf8(self, data: str, ptr: int, max_length: int) -> int:
|
||||
"""Convert string to UTF-8 and write to WASM memory."""
|
||||
encoded = data.encode("utf-8")
|
||||
write_length = min(len(encoded), max_length - 1)
|
||||
|
||||
mem_data = self.memory.data_ptr(self.store)
|
||||
mem_ptr = ctypes.cast(mem_data, ctypes.POINTER(ctypes.c_ubyte))
|
||||
|
||||
for i in range(write_length):
|
||||
mem_ptr[ptr + i] = encoded[i]
|
||||
mem_ptr[ptr + write_length] = 0
|
||||
return write_length
|
||||
|
||||
def _write_ascii_to_memory(self, string: str, buffer: int, dont_add_null: int = 0) -> None:
|
||||
"""Write ASCII string to WASM memory."""
|
||||
mem_data = self.memory.data_ptr(self.store)
|
||||
mem_ptr = ctypes.cast(mem_data, ctypes.POINTER(ctypes.c_ubyte))
|
||||
|
||||
encoded = string.encode("utf-8")
|
||||
for i, byte_val in enumerate(encoded):
|
||||
mem_ptr[buffer + i] = byte_val
|
||||
|
||||
if dont_add_null == 0:
|
||||
mem_ptr[buffer + len(encoded)] = 0
|
||||
|
||||
def _build_imports(self):
|
||||
"""Build the WASM import stubs required by the MonaLisa module."""
|
||||
|
||||
def sys_fcntl64(a, b, c):
|
||||
return 0
|
||||
|
||||
def fd_write(a, b, c, d):
|
||||
return 0
|
||||
|
||||
def fd_close(a):
|
||||
return 0
|
||||
|
||||
def sys_ioctl(a, b, c):
|
||||
return 0
|
||||
|
||||
def sys_open(a, b, c):
|
||||
return 0
|
||||
|
||||
def sys_rmdir(a):
|
||||
return 0
|
||||
|
||||
def sys_unlink(a):
|
||||
return 0
|
||||
|
||||
def clock():
|
||||
return 0
|
||||
|
||||
def time(a):
|
||||
return 0
|
||||
|
||||
def emscripten_run_script(a):
|
||||
return None
|
||||
|
||||
def fd_seek(a, b, c, d, e):
|
||||
return 0
|
||||
|
||||
def emscripten_resize_heap(a):
|
||||
return 0
|
||||
|
||||
def fd_read(a, b, c, d):
|
||||
return 0
|
||||
|
||||
def emscripten_run_script_string(a):
|
||||
return 0
|
||||
|
||||
def emscripten_run_script_int(a):
|
||||
return 1
|
||||
|
||||
def emscripten_memcpy_big(dest, src, num):
|
||||
mem_data = self.memory.data_ptr(self.store)
|
||||
data_len = self.memory.data_len(self.store)
|
||||
if num is None:
|
||||
num = data_len - 1
|
||||
mem_ptr = ctypes.cast(mem_data, ctypes.POINTER(ctypes.c_ubyte))
|
||||
for i in range(num):
|
||||
if dest + i < data_len and src + i < data_len:
|
||||
mem_ptr[dest + i] = mem_ptr[src + i]
|
||||
return dest
|
||||
|
||||
def environ_get(environ_ptr, environ_buf):
|
||||
buf_size = 0
|
||||
for index, string in enumerate(self.ENV_STRINGS):
|
||||
ptr = environ_buf + buf_size
|
||||
self._write_i32(environ_ptr + index * 4, ptr)
|
||||
self._write_ascii_to_memory(string, ptr)
|
||||
buf_size += len(string) + 1
|
||||
return 0
|
||||
|
||||
def environ_sizes_get(penviron_count, penviron_buf_size):
|
||||
self._write_i32(penviron_count, len(self.ENV_STRINGS))
|
||||
buf_size = sum(len(s) + 1 for s in self.ENV_STRINGS)
|
||||
self._write_i32(penviron_buf_size, buf_size)
|
||||
return 0
|
||||
|
||||
i32 = wasmtime.ValType.i32()
|
||||
|
||||
return [
|
||||
wasmtime.Func(self.store, wasmtime.FuncType([i32, i32, i32], [i32]), sys_fcntl64),
|
||||
wasmtime.Func(self.store, wasmtime.FuncType([i32, i32, i32, i32], [i32]), fd_write),
|
||||
wasmtime.Func(self.store, wasmtime.FuncType([i32], [i32]), fd_close),
|
||||
wasmtime.Func(self.store, wasmtime.FuncType([i32, i32, i32], [i32]), sys_ioctl),
|
||||
wasmtime.Func(self.store, wasmtime.FuncType([i32, i32, i32], [i32]), sys_open),
|
||||
wasmtime.Func(self.store, wasmtime.FuncType([i32], [i32]), sys_rmdir),
|
||||
wasmtime.Func(self.store, wasmtime.FuncType([i32], [i32]), sys_unlink),
|
||||
wasmtime.Func(self.store, wasmtime.FuncType([], [i32]), clock),
|
||||
wasmtime.Func(self.store, wasmtime.FuncType([i32], [i32]), time),
|
||||
wasmtime.Func(self.store, wasmtime.FuncType([i32], []), emscripten_run_script),
|
||||
wasmtime.Func(self.store, wasmtime.FuncType([i32, i32, i32, i32, i32], [i32]), fd_seek),
|
||||
wasmtime.Func(self.store, wasmtime.FuncType([i32, i32, i32], [i32]), emscripten_memcpy_big),
|
||||
wasmtime.Func(self.store, wasmtime.FuncType([i32], [i32]), emscripten_resize_heap),
|
||||
wasmtime.Func(self.store, wasmtime.FuncType([i32, i32], [i32]), environ_get),
|
||||
wasmtime.Func(self.store, wasmtime.FuncType([i32, i32], [i32]), environ_sizes_get),
|
||||
wasmtime.Func(self.store, wasmtime.FuncType([i32, i32, i32, i32], [i32]), fd_read),
|
||||
wasmtime.Func(self.store, wasmtime.FuncType([i32], [i32]), emscripten_run_script_string),
|
||||
wasmtime.Func(self.store, wasmtime.FuncType([i32], [i32]), emscripten_run_script_int),
|
||||
self.memory,
|
||||
]
|
||||
@@ -94,6 +94,7 @@ class Config:
|
||||
self.update_checks: bool = kwargs.get("update_checks", True)
|
||||
self.update_check_interval: int = kwargs.get("update_check_interval", 24)
|
||||
self.scene_naming: bool = kwargs.get("scene_naming", True)
|
||||
self.dash_naming: bool = kwargs.get("dash_naming", False)
|
||||
self.series_year: bool = kwargs.get("series_year", True)
|
||||
self.unicode_filenames: bool = kwargs.get("unicode_filenames", False)
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import textwrap
|
||||
import threading
|
||||
import time
|
||||
from functools import partial
|
||||
from http.cookiejar import CookieJar
|
||||
@@ -49,6 +51,196 @@ def rpc(caller: Callable, secret: str, method: str, params: Optional[list[Any]]
|
||||
return
|
||||
|
||||
|
||||
class _Aria2Manager:
|
||||
"""Singleton manager to run one aria2c process and enqueue downloads via RPC."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._logger = logging.getLogger(__name__)
|
||||
self._proc: Optional[subprocess.Popen] = None
|
||||
self._rpc_port: Optional[int] = None
|
||||
self._rpc_secret: Optional[str] = None
|
||||
self._rpc_uri: Optional[str] = None
|
||||
self._session: Session = Session()
|
||||
self._max_workers: Optional[int] = None
|
||||
self._max_concurrent_downloads: int = 0
|
||||
self._max_connection_per_server: int = 1
|
||||
self._split_default: int = 5
|
||||
self._file_allocation: str = "prealloc"
|
||||
self._proxy: Optional[str] = None
|
||||
self._lock: threading.Lock = threading.Lock()
|
||||
|
||||
def _wait_for_rpc_ready(self, timeout_s: float = 8.0, interval_s: float = 0.1) -> None:
|
||||
assert self._proc is not None
|
||||
assert self._rpc_uri is not None
|
||||
assert self._rpc_secret is not None
|
||||
|
||||
deadline = time.monotonic() + timeout_s
|
||||
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": get_random_bytes(16).hex(),
|
||||
"method": "aria2.getVersion",
|
||||
"params": [f"token:{self._rpc_secret}"],
|
||||
}
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
if self._proc.poll() is not None:
|
||||
raise RuntimeError(
|
||||
f"aria2c exited before RPC became ready (exit code {self._proc.returncode})"
|
||||
)
|
||||
try:
|
||||
res = self._session.post(self._rpc_uri, json=payload, timeout=0.25)
|
||||
data = res.json()
|
||||
if isinstance(data, dict) and data.get("result") is not None:
|
||||
return
|
||||
except (requests.exceptions.RequestException, ValueError):
|
||||
# Not ready yet (connection refused / bad response / etc.)
|
||||
pass
|
||||
time.sleep(interval_s)
|
||||
|
||||
# Timed out: ensure we don't leave a zombie/stray aria2c process behind.
|
||||
try:
|
||||
self._proc.terminate()
|
||||
self._proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
self._proc.kill()
|
||||
self._proc.wait(timeout=2)
|
||||
except Exception:
|
||||
pass
|
||||
raise TimeoutError(f"aria2c RPC did not become ready within {timeout_s:.1f}s")
|
||||
|
||||
def _build_args(self) -> list[str]:
|
||||
args = [
|
||||
"--continue=true",
|
||||
f"--max-concurrent-downloads={self._max_concurrent_downloads}",
|
||||
f"--max-connection-per-server={self._max_connection_per_server}",
|
||||
f"--split={self._split_default}",
|
||||
"--max-file-not-found=5",
|
||||
"--max-tries=5",
|
||||
"--retry-wait=2",
|
||||
"--allow-overwrite=true",
|
||||
"--auto-file-renaming=false",
|
||||
"--console-log-level=warn",
|
||||
"--download-result=default",
|
||||
f"--file-allocation={self._file_allocation}",
|
||||
"--summary-interval=0",
|
||||
"--enable-rpc=true",
|
||||
f"--rpc-listen-port={self._rpc_port}",
|
||||
f"--rpc-secret={self._rpc_secret}",
|
||||
]
|
||||
if self._proxy:
|
||||
args.extend(["--all-proxy", self._proxy])
|
||||
return args
|
||||
|
||||
def ensure_started(
|
||||
self,
|
||||
proxy: Optional[str],
|
||||
max_workers: Optional[int],
|
||||
) -> None:
|
||||
with self._lock:
|
||||
if not binaries.Aria2:
|
||||
debug_logger = get_debug_logger()
|
||||
if debug_logger:
|
||||
debug_logger.log(
|
||||
level="ERROR",
|
||||
operation="downloader_aria2c_binary_missing",
|
||||
message="Aria2c executable not found in PATH or local binaries directory",
|
||||
context={"searched_names": ["aria2c", "aria2"]},
|
||||
)
|
||||
raise EnvironmentError("Aria2c executable not found...")
|
||||
|
||||
effective_proxy = proxy or None
|
||||
|
||||
if not max_workers:
|
||||
effective_max_workers = min(32, (os.cpu_count() or 1) + 4)
|
||||
elif not isinstance(max_workers, int):
|
||||
raise TypeError(f"Expected max_workers to be {int}, not {type(max_workers)}")
|
||||
else:
|
||||
effective_max_workers = max_workers
|
||||
|
||||
if self._proc and self._proc.poll() is None:
|
||||
if effective_proxy != self._proxy or effective_max_workers != self._max_workers:
|
||||
self._logger.warning(
|
||||
"aria2c process is already running; requested proxy=%r, max_workers=%r, "
|
||||
"but running process will continue with proxy=%r, max_workers=%r",
|
||||
effective_proxy,
|
||||
effective_max_workers,
|
||||
self._proxy,
|
||||
self._max_workers,
|
||||
)
|
||||
return
|
||||
|
||||
self._rpc_port = get_free_port()
|
||||
self._rpc_secret = get_random_bytes(16).hex()
|
||||
self._rpc_uri = f"http://127.0.0.1:{self._rpc_port}/jsonrpc"
|
||||
|
||||
self._max_workers = effective_max_workers
|
||||
self._max_concurrent_downloads = int(
|
||||
config.aria2c.get("max_concurrent_downloads", effective_max_workers)
|
||||
)
|
||||
self._max_connection_per_server = int(config.aria2c.get("max_connection_per_server", 1))
|
||||
self._split_default = int(config.aria2c.get("split", 5))
|
||||
self._file_allocation = config.aria2c.get("file_allocation", "prealloc")
|
||||
self._proxy = effective_proxy
|
||||
|
||||
args = self._build_args()
|
||||
self._proc = subprocess.Popen(
|
||||
[binaries.Aria2, *args], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||
)
|
||||
self._wait_for_rpc_ready()
|
||||
|
||||
@property
|
||||
def rpc_uri(self) -> str:
|
||||
assert self._rpc_uri
|
||||
return self._rpc_uri
|
||||
|
||||
@property
|
||||
def rpc_secret(self) -> str:
|
||||
assert self._rpc_secret
|
||||
return self._rpc_secret
|
||||
|
||||
@property
|
||||
def session(self) -> Session:
|
||||
return self._session
|
||||
|
||||
def add_uris(self, uris: list[str], options: dict[str, Any]) -> str:
|
||||
"""Add a single download with multiple URIs via RPC."""
|
||||
gid = rpc(
|
||||
caller=partial(self._session.post, url=self.rpc_uri),
|
||||
secret=self.rpc_secret,
|
||||
method="aria2.addUri",
|
||||
params=[uris, options],
|
||||
)
|
||||
return gid or ""
|
||||
|
||||
def get_global_stat(self) -> dict[str, Any]:
|
||||
return rpc(
|
||||
caller=partial(self.session.post, url=self.rpc_uri),
|
||||
secret=self.rpc_secret,
|
||||
method="aria2.getGlobalStat",
|
||||
) or {}
|
||||
|
||||
def tell_status(self, gid: str) -> Optional[dict[str, Any]]:
|
||||
return rpc(
|
||||
caller=partial(self.session.post, url=self.rpc_uri),
|
||||
secret=self.rpc_secret,
|
||||
method="aria2.tellStatus",
|
||||
params=[gid, ["status", "errorCode", "errorMessage", "files", "completedLength", "totalLength"]],
|
||||
)
|
||||
|
||||
def remove(self, gid: str) -> None:
|
||||
rpc(
|
||||
caller=partial(self.session.post, url=self.rpc_uri),
|
||||
secret=self.rpc_secret,
|
||||
method="aria2.forceRemove",
|
||||
params=[gid],
|
||||
)
|
||||
|
||||
|
||||
_manager = _Aria2Manager()
|
||||
|
||||
|
||||
def download(
|
||||
urls: Union[str, list[str], dict[str, Any], list[dict[str, Any]]],
|
||||
output_dir: Path,
|
||||
@@ -58,6 +250,7 @@ def download(
|
||||
proxy: Optional[str] = None,
|
||||
max_workers: Optional[int] = None,
|
||||
) -> Generator[dict[str, Any], None, None]:
|
||||
"""Enqueue downloads to the singleton aria2c instance via stdin and track per-call progress via RPC."""
|
||||
debug_logger = get_debug_logger()
|
||||
|
||||
if not urls:
|
||||
@@ -92,102 +285,10 @@ def download(
|
||||
if not isinstance(urls, list):
|
||||
urls = [urls]
|
||||
|
||||
if not binaries.Aria2:
|
||||
if debug_logger:
|
||||
debug_logger.log(
|
||||
level="ERROR",
|
||||
operation="downloader_aria2c_binary_missing",
|
||||
message="Aria2c executable not found in PATH or local binaries directory",
|
||||
context={"searched_names": ["aria2c", "aria2"]},
|
||||
)
|
||||
raise EnvironmentError("Aria2c executable not found...")
|
||||
|
||||
if proxy and not proxy.lower().startswith("http://"):
|
||||
raise ValueError("Only HTTP proxies are supported by aria2(c)")
|
||||
|
||||
if cookies and not isinstance(cookies, CookieJar):
|
||||
cookies = cookiejar_from_dict(cookies)
|
||||
|
||||
url_files = []
|
||||
for i, url in enumerate(urls):
|
||||
if isinstance(url, str):
|
||||
url_data = {"url": url}
|
||||
else:
|
||||
url_data: dict[str, Any] = url
|
||||
url_filename = filename.format(i=i, ext=get_extension(url_data["url"]))
|
||||
url_text = url_data["url"]
|
||||
url_text += f"\n\tdir={output_dir}"
|
||||
url_text += f"\n\tout={url_filename}"
|
||||
if cookies:
|
||||
mock_request = requests.Request(url=url_data["url"])
|
||||
cookie_header = get_cookie_header(cookies, mock_request)
|
||||
if cookie_header:
|
||||
url_text += f"\n\theader=Cookie: {cookie_header}"
|
||||
for key, value in url_data.items():
|
||||
if key == "url":
|
||||
continue
|
||||
if key == "headers":
|
||||
for header_name, header_value in value.items():
|
||||
url_text += f"\n\theader={header_name}: {header_value}"
|
||||
else:
|
||||
url_text += f"\n\t{key}={value}"
|
||||
url_files.append(url_text)
|
||||
url_file = "\n".join(url_files)
|
||||
|
||||
rpc_port = get_free_port()
|
||||
rpc_secret = get_random_bytes(16).hex()
|
||||
rpc_uri = f"http://127.0.0.1:{rpc_port}/jsonrpc"
|
||||
rpc_session = Session()
|
||||
|
||||
max_concurrent_downloads = int(config.aria2c.get("max_concurrent_downloads", max_workers))
|
||||
max_connection_per_server = int(config.aria2c.get("max_connection_per_server", 1))
|
||||
split = int(config.aria2c.get("split", 5))
|
||||
file_allocation = config.aria2c.get("file_allocation", "prealloc")
|
||||
if len(urls) > 1:
|
||||
split = 1
|
||||
file_allocation = "none"
|
||||
|
||||
arguments = [
|
||||
# [Basic Options]
|
||||
"--input-file",
|
||||
"-",
|
||||
"--all-proxy",
|
||||
proxy or "",
|
||||
"--continue=true",
|
||||
# [Connection Options]
|
||||
f"--max-concurrent-downloads={max_concurrent_downloads}",
|
||||
f"--max-connection-per-server={max_connection_per_server}",
|
||||
f"--split={split}", # each split uses their own connection
|
||||
"--max-file-not-found=5", # counted towards --max-tries
|
||||
"--max-tries=5",
|
||||
"--retry-wait=2",
|
||||
# [Advanced Options]
|
||||
"--allow-overwrite=true",
|
||||
"--auto-file-renaming=false",
|
||||
"--console-log-level=warn",
|
||||
"--download-result=default",
|
||||
f"--file-allocation={file_allocation}",
|
||||
"--summary-interval=0",
|
||||
# [RPC Options]
|
||||
"--enable-rpc=true",
|
||||
f"--rpc-listen-port={rpc_port}",
|
||||
f"--rpc-secret={rpc_secret}",
|
||||
]
|
||||
|
||||
for header, value in (headers or {}).items():
|
||||
if header.lower() == "cookie":
|
||||
raise ValueError("You cannot set Cookies as a header manually, please use the `cookies` param.")
|
||||
if header.lower() == "accept-encoding":
|
||||
# we cannot set an allowed encoding, or it will return compressed
|
||||
# and the code is not set up to uncompress the data
|
||||
continue
|
||||
if header.lower() == "referer":
|
||||
arguments.extend(["--referer", value])
|
||||
continue
|
||||
if header.lower() == "user-agent":
|
||||
arguments.extend(["--user-agent", value])
|
||||
continue
|
||||
arguments.extend(["--header", f"{header}: {value}"])
|
||||
_manager.ensure_started(proxy=proxy, max_workers=max_workers)
|
||||
|
||||
if debug_logger:
|
||||
first_url = urls[0] if isinstance(urls[0], str) else urls[0].get("url", "")
|
||||
@@ -202,128 +303,151 @@ def download(
|
||||
"first_url": url_display,
|
||||
"output_dir": str(output_dir),
|
||||
"filename": filename,
|
||||
"max_concurrent_downloads": max_concurrent_downloads,
|
||||
"max_connection_per_server": max_connection_per_server,
|
||||
"split": split,
|
||||
"file_allocation": file_allocation,
|
||||
"has_proxy": bool(proxy),
|
||||
"rpc_port": rpc_port,
|
||||
},
|
||||
)
|
||||
|
||||
yield dict(total=len(urls))
|
||||
# Build options for each URI and add via RPC
|
||||
gids: list[str] = []
|
||||
|
||||
for i, url in enumerate(urls):
|
||||
if isinstance(url, str):
|
||||
url_data = {"url": url}
|
||||
else:
|
||||
url_data: dict[str, Any] = url
|
||||
|
||||
url_filename = filename.format(i=i, ext=get_extension(url_data["url"]))
|
||||
|
||||
opts: dict[str, Any] = {
|
||||
"dir": str(output_dir),
|
||||
"out": url_filename,
|
||||
"split": str(1 if len(urls) > 1 else int(config.aria2c.get("split", 5))),
|
||||
}
|
||||
|
||||
# Cookies as header
|
||||
if cookies:
|
||||
mock_request = requests.Request(url=url_data["url"])
|
||||
cookie_header = get_cookie_header(cookies, mock_request)
|
||||
if cookie_header:
|
||||
opts.setdefault("header", []).append(f"Cookie: {cookie_header}")
|
||||
|
||||
# Global headers
|
||||
for header, value in (headers or {}).items():
|
||||
if header.lower() == "cookie":
|
||||
raise ValueError("You cannot set Cookies as a header manually, please use the `cookies` param.")
|
||||
if header.lower() == "accept-encoding":
|
||||
continue
|
||||
if header.lower() == "referer":
|
||||
opts["referer"] = str(value)
|
||||
continue
|
||||
if header.lower() == "user-agent":
|
||||
opts["user-agent"] = str(value)
|
||||
continue
|
||||
opts.setdefault("header", []).append(f"{header}: {value}")
|
||||
|
||||
# Per-url extra args
|
||||
for key, value in url_data.items():
|
||||
if key == "url":
|
||||
continue
|
||||
if key == "headers":
|
||||
for header_name, header_value in value.items():
|
||||
opts.setdefault("header", []).append(f"{header_name}: {header_value}")
|
||||
else:
|
||||
opts[key] = str(value)
|
||||
|
||||
# Add via RPC
|
||||
gid = _manager.add_uris([url_data["url"]], opts)
|
||||
if gid:
|
||||
gids.append(gid)
|
||||
|
||||
yield dict(total=len(gids))
|
||||
|
||||
completed: set[str] = set()
|
||||
|
||||
try:
|
||||
p = subprocess.Popen([binaries.Aria2, *arguments], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL)
|
||||
while len(completed) < len(gids):
|
||||
if DOWNLOAD_CANCELLED.is_set():
|
||||
# Remove tracked downloads on cancel
|
||||
for gid in gids:
|
||||
if gid not in completed:
|
||||
_manager.remove(gid)
|
||||
yield dict(downloaded="[yellow]CANCELLED")
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
p.stdin.write(url_file.encode())
|
||||
p.stdin.close()
|
||||
stats = _manager.get_global_stat()
|
||||
dl_speed = int(stats.get("downloadSpeed", -1))
|
||||
|
||||
while p.poll() is None:
|
||||
global_stats: dict[str, Any] = (
|
||||
rpc(caller=partial(rpc_session.post, url=rpc_uri), secret=rpc_secret, method="aria2.getGlobalStat")
|
||||
or {}
|
||||
)
|
||||
# Aggregate progress across all GIDs for this call
|
||||
total_completed = 0
|
||||
total_size = 0
|
||||
|
||||
number_stopped = int(global_stats.get("numStoppedTotal", 0))
|
||||
download_speed = int(global_stats.get("downloadSpeed", -1))
|
||||
# Check each tracked GID
|
||||
for gid in gids:
|
||||
if gid in completed:
|
||||
continue
|
||||
|
||||
if number_stopped:
|
||||
yield dict(completed=number_stopped)
|
||||
if download_speed != -1:
|
||||
yield dict(downloaded=f"{filesize.decimal(download_speed)}/s")
|
||||
status = _manager.tell_status(gid)
|
||||
if not status:
|
||||
continue
|
||||
|
||||
stopped_downloads: list[dict[str, Any]] = (
|
||||
rpc(
|
||||
caller=partial(rpc_session.post, url=rpc_uri),
|
||||
secret=rpc_secret,
|
||||
method="aria2.tellStopped",
|
||||
params=[0, 999999],
|
||||
)
|
||||
or []
|
||||
)
|
||||
completed_length = int(status.get("completedLength", 0))
|
||||
total_length = int(status.get("totalLength", 0))
|
||||
total_completed += completed_length
|
||||
total_size += total_length
|
||||
|
||||
for dl in stopped_downloads:
|
||||
if dl["status"] == "error":
|
||||
used_uri = next(
|
||||
uri["uri"]
|
||||
for file in dl["files"]
|
||||
if file["selected"] == "true"
|
||||
for uri in file["uris"]
|
||||
if uri["status"] == "used"
|
||||
)
|
||||
error = f"Download Error (#{dl['gid']}): {dl['errorMessage']} ({dl['errorCode']}), {used_uri}"
|
||||
error_pretty = "\n ".join(
|
||||
textwrap.wrap(error, width=console.width - 20, initial_indent="")
|
||||
)
|
||||
console.log(Text.from_ansi("\n[Aria2c]: " + error_pretty))
|
||||
if debug_logger:
|
||||
debug_logger.log(
|
||||
level="ERROR",
|
||||
operation="downloader_aria2c_download_error",
|
||||
message=f"Aria2c download failed: {dl['errorMessage']}",
|
||||
context={
|
||||
"gid": dl["gid"],
|
||||
"error_code": dl["errorCode"],
|
||||
"error_message": dl["errorMessage"],
|
||||
"used_uri": used_uri[:200] + "..." if len(used_uri) > 200 else used_uri,
|
||||
"completed_length": dl.get("completedLength"),
|
||||
"total_length": dl.get("totalLength"),
|
||||
},
|
||||
)
|
||||
raise ValueError(error)
|
||||
state = status.get("status")
|
||||
if state in ("complete", "error"):
|
||||
completed.add(gid)
|
||||
yield dict(completed=len(completed))
|
||||
|
||||
if number_stopped == len(urls):
|
||||
rpc(caller=partial(rpc_session.post, url=rpc_uri), secret=rpc_secret, method="aria2.shutdown")
|
||||
break
|
||||
if state == "error":
|
||||
used_uri = None
|
||||
try:
|
||||
used_uri = next(
|
||||
uri["uri"]
|
||||
for file in status.get("files", [])
|
||||
for uri in file.get("uris", [])
|
||||
if uri.get("status") == "used"
|
||||
)
|
||||
except Exception:
|
||||
used_uri = "unknown"
|
||||
error = f"Download Error (#{gid}): {status.get('errorMessage')} ({status.get('errorCode')}), {used_uri}"
|
||||
error_pretty = "\n ".join(textwrap.wrap(error, width=console.width - 20, initial_indent=""))
|
||||
console.log(Text.from_ansi("\n[Aria2c]: " + error_pretty))
|
||||
if debug_logger:
|
||||
debug_logger.log(
|
||||
level="ERROR",
|
||||
operation="downloader_aria2c_download_error",
|
||||
message=f"Aria2c download failed: {status.get('errorMessage')}",
|
||||
context={
|
||||
"gid": gid,
|
||||
"error_code": status.get("errorCode"),
|
||||
"error_message": status.get("errorMessage"),
|
||||
"used_uri": used_uri[:200] + "..." if used_uri and len(used_uri) > 200 else used_uri,
|
||||
"completed_length": status.get("completedLength"),
|
||||
"total_length": status.get("totalLength"),
|
||||
},
|
||||
)
|
||||
raise ValueError(error)
|
||||
|
||||
# Yield aggregate progress for this call's downloads
|
||||
if total_size > 0:
|
||||
# Yield both advance (bytes downloaded this iteration) and total for rich progress
|
||||
if dl_speed != -1:
|
||||
yield dict(downloaded=f"{filesize.decimal(dl_speed)}/s", advance=0, completed=total_completed, total=total_size)
|
||||
else:
|
||||
yield dict(advance=0, completed=total_completed, total=total_size)
|
||||
elif dl_speed != -1:
|
||||
yield dict(downloaded=f"{filesize.decimal(dl_speed)}/s")
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
p.wait()
|
||||
|
||||
if p.returncode != 0:
|
||||
if debug_logger:
|
||||
debug_logger.log(
|
||||
level="ERROR",
|
||||
operation="downloader_aria2c_failed",
|
||||
message=f"Aria2c exited with code {p.returncode}",
|
||||
context={
|
||||
"returncode": p.returncode,
|
||||
"url_count": len(urls),
|
||||
"output_dir": str(output_dir),
|
||||
},
|
||||
)
|
||||
raise subprocess.CalledProcessError(p.returncode, arguments)
|
||||
|
||||
if debug_logger:
|
||||
debug_logger.log(
|
||||
level="DEBUG",
|
||||
operation="downloader_aria2c_complete",
|
||||
message="Aria2c download completed successfully",
|
||||
context={
|
||||
"url_count": len(urls),
|
||||
"output_dir": str(output_dir),
|
||||
"filename": filename,
|
||||
},
|
||||
)
|
||||
|
||||
except ConnectionResetError:
|
||||
# interrupted while passing URI to download
|
||||
raise KeyboardInterrupt()
|
||||
except subprocess.CalledProcessError as e:
|
||||
if e.returncode in (7, 0xC000013A):
|
||||
# 7 is when Aria2(c) handled the CTRL+C
|
||||
# 0xC000013A is when it never got the chance to
|
||||
raise KeyboardInterrupt()
|
||||
raise
|
||||
except KeyboardInterrupt:
|
||||
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||
yield dict(downloaded="[yellow]CANCELLED")
|
||||
DOWNLOAD_CANCELLED.set()
|
||||
raise
|
||||
except Exception as e:
|
||||
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||
DOWNLOAD_CANCELLED.set()
|
||||
yield dict(downloaded="[red]FAILED")
|
||||
if debug_logger and not isinstance(e, (subprocess.CalledProcessError, ValueError)):
|
||||
if debug_logger and not isinstance(e, ValueError):
|
||||
debug_logger.log(
|
||||
level="ERROR",
|
||||
operation="downloader_aria2c_exception",
|
||||
@@ -335,8 +459,6 @@ def download(
|
||||
},
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
rpc(caller=partial(rpc_session.post, url=rpc_uri), secret=rpc_secret, method="aria2.shutdown")
|
||||
|
||||
|
||||
def aria2c(
|
||||
|
||||
@@ -10,6 +10,7 @@ import requests
|
||||
from requests.cookies import cookiejar_from_dict, get_cookie_header
|
||||
|
||||
from envied.core import binaries
|
||||
from envied.core.binaries import FFMPEG, Mp4decrypt, ShakaPackager
|
||||
from envied.core.config import config
|
||||
from envied.core.console import console
|
||||
from envied.core.constants import DOWNLOAD_CANCELLED
|
||||
@@ -19,7 +20,7 @@ PERCENT_RE = re.compile(r"(\d+\.\d+%)")
|
||||
SPEED_RE = re.compile(r"(\d+\.\d+(?:MB|KB)ps)")
|
||||
SIZE_RE = re.compile(r"(\d+\.\d+(?:MB|GB|KB)/\d+\.\d+(?:MB|GB|KB))")
|
||||
WARN_RE = re.compile(r"(WARN : Response.*|WARN : One or more errors occurred.*)")
|
||||
ERROR_RE = re.compile(r"(ERROR.*)")
|
||||
ERROR_RE = re.compile(r"(\bERROR\b.*|\bFAILED\b.*|\bException\b.*)")
|
||||
|
||||
DECRYPTION_ENGINE = {
|
||||
"shaka": "SHAKA_PACKAGER",
|
||||
@@ -67,12 +68,17 @@ def get_track_selection_args(track: Any) -> list[str]:
|
||||
parts = []
|
||||
|
||||
if track_type == "Audio":
|
||||
if track_id := representation.get("id") or adaptation_set.get("audioTrackId"):
|
||||
track_id = representation.get("id") or adaptation_set.get("audioTrackId")
|
||||
lang = representation.get("lang") or adaptation_set.get("lang")
|
||||
|
||||
if track_id:
|
||||
parts.append(rf'"id=\b{track_id}\b"')
|
||||
if lang:
|
||||
parts.append(f"lang={lang}")
|
||||
else:
|
||||
if codecs := representation.get("codecs"):
|
||||
parts.append(f"codecs={codecs}")
|
||||
if lang := representation.get("lang") or adaptation_set.get("lang"):
|
||||
if lang:
|
||||
parts.append(f"lang={lang}")
|
||||
if bw := representation.get("bandwidth"):
|
||||
bitrate = int(bw) // 1000
|
||||
@@ -176,17 +182,35 @@ def build_download_args(
|
||||
"--tmp-dir": output_dir,
|
||||
"--thread-count": thread_count,
|
||||
"--download-retry-count": retry_count,
|
||||
"--write-meta-json": False,
|
||||
}
|
||||
if FFMPEG:
|
||||
args["--ffmpeg-binary-path"] = str(FFMPEG)
|
||||
if proxy:
|
||||
args["--custom-proxy"] = proxy
|
||||
if skip_merge:
|
||||
args["--skip-merge"] = skip_merge
|
||||
if ad_keyword:
|
||||
args["--ad-keyword"] = ad_keyword
|
||||
|
||||
key_args = []
|
||||
if content_keys:
|
||||
args["--key"] = next((f"{kid.hex}:{key.lower()}" for kid, key in content_keys.items()), None)
|
||||
args["--decryption-engine"] = DECRYPTION_ENGINE.get(config.decryption.lower()) or "SHAKA_PACKAGER"
|
||||
for kid, key in content_keys.items():
|
||||
key_args.extend(["--key", f"{kid.hex}:{key.lower()}"])
|
||||
|
||||
decryption_config = config.decryption.lower()
|
||||
engine_name = DECRYPTION_ENGINE.get(decryption_config) or "SHAKA_PACKAGER"
|
||||
args["--decryption-engine"] = engine_name
|
||||
|
||||
binary_path = None
|
||||
if engine_name == "SHAKA_PACKAGER":
|
||||
if ShakaPackager:
|
||||
binary_path = str(ShakaPackager)
|
||||
elif engine_name == "MP4DECRYPT":
|
||||
if Mp4decrypt:
|
||||
binary_path = str(Mp4decrypt)
|
||||
if binary_path:
|
||||
args["--decryption-binary-path"] = binary_path
|
||||
|
||||
if custom_args:
|
||||
args.update(custom_args)
|
||||
|
||||
@@ -199,6 +223,9 @@ def build_download_args(
|
||||
elif value is not False and value is not None:
|
||||
command.extend([flag, str(value)])
|
||||
|
||||
# Append all content keys (multiple --key flags supported by N_m3u8DL-RE)
|
||||
command.extend(key_args)
|
||||
|
||||
if headers:
|
||||
for key, value in headers.items():
|
||||
if key.lower() not in ("accept-encoding", "cookie"):
|
||||
@@ -283,7 +310,10 @@ def download(
|
||||
log_file_path: Path | None = None
|
||||
if debug_logger:
|
||||
log_file_path = output_dir / f".n_m3u8dl_re_{filename}.log"
|
||||
arguments.extend(["--log-file-path", str(log_file_path)])
|
||||
arguments.extend([
|
||||
"--log-file-path", str(log_file_path),
|
||||
"--log-level", "DEBUG",
|
||||
])
|
||||
|
||||
track_url_display = track.url[:200] + "..." if len(track.url) > 200 else track.url
|
||||
debug_logger.log(
|
||||
@@ -371,6 +401,14 @@ def download(
|
||||
raise subprocess.CalledProcessError(process.returncode, arguments)
|
||||
|
||||
if debug_logger:
|
||||
output_dir_exists = output_dir.exists()
|
||||
output_files = []
|
||||
if output_dir_exists:
|
||||
try:
|
||||
output_files = [f.name for f in output_dir.iterdir() if f.is_file()][:20]
|
||||
except Exception:
|
||||
output_files = ["<error listing files>"]
|
||||
|
||||
debug_logger.log(
|
||||
level="DEBUG",
|
||||
operation="downloader_n_m3u8dl_re_complete",
|
||||
@@ -379,10 +417,38 @@ def download(
|
||||
"track_id": getattr(track, "id", None),
|
||||
"track_type": track.__class__.__name__,
|
||||
"output_dir": str(output_dir),
|
||||
"output_dir_exists": output_dir_exists,
|
||||
"output_files_count": len(output_files),
|
||||
"output_files": output_files,
|
||||
"filename": filename,
|
||||
},
|
||||
)
|
||||
|
||||
# Warn if no output was produced - include N_m3u8DL-RE's logs for diagnosis
|
||||
if not output_dir_exists or not output_files:
|
||||
# Read N_m3u8DL-RE's log file for debugging
|
||||
n_m3u8dl_log = ""
|
||||
if log_file_path and log_file_path.exists():
|
||||
try:
|
||||
n_m3u8dl_log = log_file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
n_m3u8dl_log = "<failed to read log file>"
|
||||
|
||||
debug_logger.log(
|
||||
level="WARNING",
|
||||
operation="downloader_n_m3u8dl_re_no_output",
|
||||
message="N_m3u8DL-RE exited successfully but produced no output files",
|
||||
context={
|
||||
"track_id": getattr(track, "id", None),
|
||||
"track_type": track.__class__.__name__,
|
||||
"output_dir": str(output_dir),
|
||||
"output_dir_exists": output_dir_exists,
|
||||
"selection_args": selection_args,
|
||||
"track_url": track.url[:200] + "..." if len(track.url) > 200 else track.url,
|
||||
"n_m3u8dl_re_log": n_m3u8dl_log,
|
||||
},
|
||||
)
|
||||
|
||||
except ConnectionResetError:
|
||||
# interrupted while passing URI to download
|
||||
raise KeyboardInterrupt()
|
||||
@@ -414,6 +480,7 @@ def download(
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
# Clean up temporary debug files
|
||||
if log_file_path and log_file_path.exists():
|
||||
try:
|
||||
log_file_path.unlink()
|
||||
|
||||
@@ -122,7 +122,7 @@ def download(
|
||||
last_speed_refresh = now
|
||||
download_sizes.clear()
|
||||
|
||||
if content_length and written < content_length:
|
||||
if not segmented and content_length and written < content_length:
|
||||
raise IOError(f"Failed to read {content_length} bytes from the track URI.")
|
||||
|
||||
yield dict(file_downloaded=save_path, written=written)
|
||||
@@ -260,11 +260,18 @@ def requests(
|
||||
},
|
||||
)
|
||||
|
||||
yield dict(total=len(urls))
|
||||
# If we're downloading more than one URL, treat them as "segments" for progress purposes.
|
||||
# For single-URL downloads we want per-chunk progress (and the inner `download()` will yield
|
||||
# a chunk-based `total`), so don't set a segment total of 1 here.
|
||||
segmented_batch = len(urls) > 1
|
||||
if segmented_batch:
|
||||
yield dict(total=len(urls))
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
for future in as_completed(pool.submit(download, session=session, segmented=False, **url) for url in urls):
|
||||
for future in as_completed(
|
||||
pool.submit(download, session=session, segmented=segmented_batch, **url) for url in urls
|
||||
):
|
||||
try:
|
||||
yield from future.result()
|
||||
except KeyboardInterrupt:
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from typing import Union
|
||||
|
||||
from envied.core.drm.clearkey import ClearKey
|
||||
from envied.core.drm.monalisa import MonaLisa
|
||||
from envied.core.drm.playready import PlayReady
|
||||
from envied.core.drm.widevine import Widevine
|
||||
|
||||
DRM_T = Union[ClearKey, Widevine, PlayReady]
|
||||
DRM_T = Union[ClearKey, Widevine, PlayReady, MonaLisa]
|
||||
|
||||
|
||||
__all__ = ("ClearKey", "Widevine", "PlayReady", "DRM_T")
|
||||
__all__ = ("ClearKey", "Widevine", "PlayReady", "MonaLisa", "DRM_T")
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
MonaLisa DRM System.
|
||||
|
||||
A WASM-based DRM system that uses local key extraction and two-stage
|
||||
segment decryption (ML-Worker binary + AES-ECB).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Union
|
||||
from uuid import UUID
|
||||
|
||||
from Cryptodome.Cipher import AES
|
||||
from Cryptodome.Util.Padding import unpad
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MonaLisa:
|
||||
"""
|
||||
MonaLisa DRM System.
|
||||
|
||||
Unlike Widevine/PlayReady, MonaLisa does not use a challenge/response flow
|
||||
with a license server. Instead, the PSSH value (ticket) is provided directly
|
||||
by the service API, and keys are extracted locally via a WASM module.
|
||||
|
||||
Decryption is performed in two stages:
|
||||
1. ML-Worker binary: Removes MonaLisa encryption layer (bbts -> ents)
|
||||
2. AES-ECB decryption: Final decryption with service-provided key
|
||||
"""
|
||||
|
||||
class Exceptions:
|
||||
class TicketNotFound(Exception):
|
||||
"""Raised when no PSSH/ticket data is provided."""
|
||||
|
||||
class KeyExtractionFailed(Exception):
|
||||
"""Raised when key extraction from the ticket fails."""
|
||||
|
||||
class WorkerNotFound(Exception):
|
||||
"""Raised when the ML-Worker binary is not found."""
|
||||
|
||||
class DecryptionFailed(Exception):
|
||||
"""Raised when segment decryption fails."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ticket: Union[str, bytes],
|
||||
aes_key: Union[str, bytes],
|
||||
device_path: Path,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
Initialize MonaLisa DRM.
|
||||
|
||||
Args:
|
||||
ticket: PSSH value from service API (base64 string or raw bytes).
|
||||
aes_key: AES-ECB key for second-stage decryption (hex string or bytes).
|
||||
device_path: Path to the CDM device file (.mld).
|
||||
**kwargs: Additional metadata stored in self.data.
|
||||
|
||||
Raises:
|
||||
TicketNotFound: If ticket/PSSH is empty.
|
||||
KeyExtractionFailed: If key extraction fails.
|
||||
"""
|
||||
if not ticket:
|
||||
raise MonaLisa.Exceptions.TicketNotFound("No PSSH/ticket data provided.")
|
||||
|
||||
self._ticket = ticket
|
||||
|
||||
# Store AES key for second-stage decryption
|
||||
if isinstance(aes_key, str):
|
||||
self._aes_key = bytes.fromhex(aes_key)
|
||||
else:
|
||||
self._aes_key = aes_key
|
||||
|
||||
self._device_path = device_path
|
||||
self._kid: Optional[UUID] = None
|
||||
self._key: Optional[str] = None
|
||||
self.data: dict = kwargs or {}
|
||||
|
||||
# Extract keys immediately
|
||||
self._extract_keys()
|
||||
|
||||
def _extract_keys(self) -> None:
|
||||
"""Extract keys from the ticket using the MonaLisa CDM."""
|
||||
# Import here to avoid circular import
|
||||
from envied.core.cdm.monalisa import MonaLisaCDM
|
||||
|
||||
try:
|
||||
cdm = MonaLisaCDM(device_path=self._device_path)
|
||||
session_id = cdm.open()
|
||||
try:
|
||||
keys = cdm.extract_keys(self._ticket)
|
||||
if keys:
|
||||
kid_hex = keys.get("kid")
|
||||
if kid_hex:
|
||||
self._kid = UUID(hex=kid_hex)
|
||||
self._key = keys.get("key")
|
||||
finally:
|
||||
cdm.close(session_id)
|
||||
except Exception as e:
|
||||
raise MonaLisa.Exceptions.KeyExtractionFailed(f"Failed to extract keys: {e}")
|
||||
|
||||
@classmethod
|
||||
def from_ticket(
|
||||
cls,
|
||||
ticket: Union[str, bytes],
|
||||
aes_key: Union[str, bytes],
|
||||
device_path: Path,
|
||||
) -> MonaLisa:
|
||||
"""
|
||||
Create a MonaLisa DRM instance from a PSSH/ticket.
|
||||
|
||||
Args:
|
||||
ticket: PSSH value from service API.
|
||||
aes_key: AES-ECB key for second-stage decryption.
|
||||
device_path: Path to the CDM device file (.mld).
|
||||
|
||||
Returns:
|
||||
MonaLisa DRM instance with extracted keys.
|
||||
"""
|
||||
return cls(ticket=ticket, aes_key=aes_key, device_path=device_path)
|
||||
|
||||
@property
|
||||
def kid(self) -> Optional[UUID]:
|
||||
"""Get the Key ID."""
|
||||
return self._kid
|
||||
|
||||
@property
|
||||
def key(self) -> Optional[str]:
|
||||
"""Get the content key as hex string."""
|
||||
return self._key
|
||||
|
||||
@property
|
||||
def pssh(self) -> str:
|
||||
"""
|
||||
Get the raw PSSH/ticket value as a string.
|
||||
|
||||
Returns:
|
||||
The raw PSSH value as a base64 string.
|
||||
"""
|
||||
if isinstance(self._ticket, bytes):
|
||||
try:
|
||||
return self._ticket.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
# Tickets are typically base64, so ASCII is a reasonable fallback.
|
||||
try:
|
||||
return self._ticket.decode("ascii")
|
||||
except UnicodeDecodeError as e:
|
||||
raise ValueError(
|
||||
f"Ticket bytes must be UTF-8 text or ASCII base64; got undecodable bytes (len={len(self._ticket)})"
|
||||
) from e
|
||||
return self._ticket
|
||||
|
||||
@property
|
||||
def content_id(self) -> Optional[str]:
|
||||
"""
|
||||
Extract the Content ID from the PSSH for display.
|
||||
|
||||
The PSSH contains an embedded Content ID at bytes 21-75 with format:
|
||||
H5DCID-V3-P1-YYYYMMDD-HHMMSS-MEDIAID-TIMESTAMP-SUFFIX
|
||||
|
||||
Returns:
|
||||
The Content ID string if extractable, None otherwise.
|
||||
"""
|
||||
import base64
|
||||
|
||||
try:
|
||||
# Decode base64 PSSH to get raw bytes
|
||||
if isinstance(self._ticket, bytes):
|
||||
data = self._ticket
|
||||
else:
|
||||
data = base64.b64decode(self._ticket)
|
||||
|
||||
# Content ID is at bytes 21-75 (55 bytes)
|
||||
if len(data) >= 76:
|
||||
content_id = data[21:76].decode("ascii")
|
||||
# Validate it looks like a content ID
|
||||
if content_id.startswith("H5DCID-"):
|
||||
return content_id
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def content_keys(self) -> dict[UUID, str]:
|
||||
"""
|
||||
Get content keys in the same format as Widevine/PlayReady.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping KID to key hex string.
|
||||
"""
|
||||
if self._kid and self._key:
|
||||
return {self._kid: self._key}
|
||||
return {}
|
||||
|
||||
def decrypt_segment(self, segment_path: Path) -> None:
|
||||
"""
|
||||
Decrypt a single segment using two-stage decryption.
|
||||
|
||||
Stage 1: ML-Worker binary (bbts -> ents)
|
||||
Stage 2: AES-ECB decryption (ents -> ts)
|
||||
|
||||
Args:
|
||||
segment_path: Path to the encrypted segment file.
|
||||
|
||||
Raises:
|
||||
WorkerNotFound: If ML-Worker binary is not available.
|
||||
DecryptionFailed: If decryption fails at any stage.
|
||||
"""
|
||||
if not self._key:
|
||||
return
|
||||
|
||||
# Import here to avoid circular import
|
||||
from envied.core.cdm.monalisa import MonaLisaCDM
|
||||
|
||||
worker_path = MonaLisaCDM.get_worker_path()
|
||||
if not worker_path or not worker_path.exists():
|
||||
raise MonaLisa.Exceptions.WorkerNotFound("ML-Worker not found.")
|
||||
|
||||
bbts_path = segment_path.with_suffix(".bbts")
|
||||
ents_path = segment_path.with_suffix(".ents")
|
||||
|
||||
try:
|
||||
if segment_path.exists():
|
||||
segment_path.replace(bbts_path)
|
||||
else:
|
||||
raise MonaLisa.Exceptions.DecryptionFailed(f"Segment file does not exist: {segment_path}")
|
||||
|
||||
# Stage 1: ML-Worker decryption
|
||||
cmd = [str(worker_path), str(self._key), str(bbts_path), str(ents_path)]
|
||||
|
||||
startupinfo = None
|
||||
if sys.platform == "win32":
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
|
||||
worker_timeout_s = 60
|
||||
process = subprocess.run(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
startupinfo=startupinfo,
|
||||
timeout=worker_timeout_s,
|
||||
)
|
||||
|
||||
if process.returncode != 0:
|
||||
raise MonaLisa.Exceptions.DecryptionFailed(
|
||||
f"ML-Worker failed for {segment_path.name}: {process.stderr}"
|
||||
)
|
||||
|
||||
if not ents_path.exists():
|
||||
raise MonaLisa.Exceptions.DecryptionFailed(
|
||||
f"Decrypted .ents file was not created for {segment_path.name}"
|
||||
)
|
||||
|
||||
# Stage 2: AES-ECB decryption
|
||||
with open(ents_path, "rb") as f:
|
||||
ents_data = f.read()
|
||||
|
||||
crypto = AES.new(self._aes_key, AES.MODE_ECB)
|
||||
decrypted_data = unpad(crypto.decrypt(ents_data), AES.block_size)
|
||||
|
||||
# Write decrypted segment back to original path
|
||||
with open(segment_path, "wb") as f:
|
||||
f.write(decrypted_data)
|
||||
|
||||
except MonaLisa.Exceptions.DecryptionFailed:
|
||||
raise
|
||||
except subprocess.TimeoutExpired as e:
|
||||
log.error("ML-Worker timed out after %ss for %s", worker_timeout_s, segment_path.name)
|
||||
raise MonaLisa.Exceptions.DecryptionFailed(
|
||||
f"ML-Worker timed out after {worker_timeout_s}s for {segment_path.name}"
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise MonaLisa.Exceptions.DecryptionFailed(f"Failed to decrypt segment {segment_path.name}: {e}")
|
||||
finally:
|
||||
if ents_path.exists():
|
||||
os.remove(ents_path)
|
||||
if bbts_path != segment_path and bbts_path.exists():
|
||||
os.remove(bbts_path)
|
||||
|
||||
def decrypt(self, _path: Path) -> None:
|
||||
"""
|
||||
MonaLisa uses per-segment decryption during download via the
|
||||
on_segment_downloaded callback. By the time this method is called,
|
||||
the content has already been decrypted and muxed into a container.
|
||||
|
||||
Args:
|
||||
path: Path to the file (ignored).
|
||||
"""
|
||||
pass
|
||||
@@ -154,7 +154,9 @@ class PlayReady:
|
||||
pssh_boxes.extend(
|
||||
Box.parse(base64.b64decode(x.uri.split(",")[-1]))
|
||||
for x in (master.session_keys or master.keys)
|
||||
if x and x.keyformat and "playready" in x.keyformat.lower()
|
||||
if x and x.keyformat and x.keyformat.lower() in {
|
||||
f"urn:uuid:{PSSH.SYSTEM_ID}", "com.microsoft.playready"
|
||||
}
|
||||
)
|
||||
|
||||
init_data = track.get_init_segment(session=session)
|
||||
|
||||
@@ -19,12 +19,12 @@ import requests
|
||||
from curl_cffi.requests import Session as CurlSession
|
||||
from langcodes import Language, tag_is_valid
|
||||
from lxml.etree import Element, ElementTree
|
||||
from pyplayready.cdm import Cdm as PlayReadyCdm
|
||||
from pyplayready.system.pssh import PSSH as PR_PSSH
|
||||
from pywidevine.cdm import Cdm as WidevineCdm
|
||||
from pywidevine.pssh import PSSH
|
||||
from requests import Session
|
||||
|
||||
from envied.core.cdm.detect import is_playready_cdm
|
||||
from envied.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack
|
||||
from envied.core.downloaders import requests as requests_downloader
|
||||
from envied.core.drm import DRM_T, PlayReady, Widevine
|
||||
@@ -151,6 +151,11 @@ class DASH:
|
||||
if not track_fps and segment_base is not None:
|
||||
track_fps = segment_base.get("timescale")
|
||||
|
||||
scan_type = None
|
||||
scan_type_str = get("scanType")
|
||||
if scan_type_str and scan_type_str.lower() == "interlaced":
|
||||
scan_type = Video.ScanType.INTERLACED
|
||||
|
||||
track_args = dict(
|
||||
range_=self.get_video_range(
|
||||
codecs, findall("SupplementalProperty"), findall("EssentialProperty")
|
||||
@@ -159,6 +164,7 @@ class DASH:
|
||||
width=get("width") or 0,
|
||||
height=get("height") or 0,
|
||||
fps=track_fps or None,
|
||||
scan_type=scan_type,
|
||||
)
|
||||
elif content_type == "audio":
|
||||
track_type = Audio
|
||||
@@ -366,6 +372,9 @@ class DASH:
|
||||
|
||||
if not end_number:
|
||||
end_number = len(segment_durations)
|
||||
# Handle high startNumber in DVR/catch-up manifests where startNumber > segment count
|
||||
if start_number > end_number:
|
||||
end_number = start_number + len(segment_durations) - 1
|
||||
|
||||
for t, n in zip(segment_durations, range(start_number, end_number + 1)):
|
||||
segments.append(
|
||||
@@ -467,8 +476,9 @@ class DASH:
|
||||
track.data["dash"]["timescale"] = int(segment_timescale)
|
||||
track.data["dash"]["segment_durations"] = segment_durations
|
||||
|
||||
if init_data and isinstance(track, (Video, Audio)):
|
||||
if isinstance(cdm, PlayReadyCdm):
|
||||
if not track.drm and init_data and isinstance(track, (Video, Audio)):
|
||||
prefers_playready = is_playready_cdm(cdm)
|
||||
if prefers_playready:
|
||||
try:
|
||||
track.drm = [PlayReady.from_init_data(init_data)]
|
||||
except PlayReady.Exceptions.PSSHNotFound:
|
||||
@@ -572,8 +582,64 @@ class DASH:
|
||||
for control_file in save_dir.glob("*.aria2__temp"):
|
||||
control_file.unlink()
|
||||
|
||||
# Verify output directory exists and contains files
|
||||
if not save_dir.exists():
|
||||
error_msg = f"Output directory does not exist: {save_dir}"
|
||||
if debug_logger:
|
||||
debug_logger.log(
|
||||
level="ERROR",
|
||||
operation="manifest_dash_download_output_missing",
|
||||
message=error_msg,
|
||||
context={
|
||||
"track_id": getattr(track, "id", None),
|
||||
"track_type": track.__class__.__name__,
|
||||
"save_dir": str(save_dir),
|
||||
"save_path": str(save_path),
|
||||
"downloader": downloader.__name__,
|
||||
"skip_merge": skip_merge,
|
||||
},
|
||||
)
|
||||
raise FileNotFoundError(error_msg)
|
||||
|
||||
segments_to_merge = [x for x in sorted(save_dir.iterdir()) if x.is_file()]
|
||||
|
||||
if debug_logger:
|
||||
debug_logger.log(
|
||||
level="DEBUG",
|
||||
operation="manifest_dash_download_complete",
|
||||
message="DASH download complete, preparing to merge",
|
||||
context={
|
||||
"track_id": getattr(track, "id", None),
|
||||
"track_type": track.__class__.__name__,
|
||||
"save_dir": str(save_dir),
|
||||
"save_dir_exists": save_dir.exists(),
|
||||
"segments_found": len(segments_to_merge),
|
||||
"segment_files": [f.name for f in segments_to_merge[:10]], # Limit to first 10
|
||||
"downloader": downloader.__name__,
|
||||
"skip_merge": skip_merge,
|
||||
},
|
||||
)
|
||||
|
||||
if not segments_to_merge:
|
||||
error_msg = f"No segment files found in output directory: {save_dir}"
|
||||
if debug_logger:
|
||||
# List all contents of the directory for debugging
|
||||
all_contents = list(save_dir.iterdir()) if save_dir.exists() else []
|
||||
debug_logger.log(
|
||||
level="ERROR",
|
||||
operation="manifest_dash_download_no_segments",
|
||||
message=error_msg,
|
||||
context={
|
||||
"track_id": getattr(track, "id", None),
|
||||
"track_type": track.__class__.__name__,
|
||||
"save_dir": str(save_dir),
|
||||
"directory_contents": [str(p) for p in all_contents],
|
||||
"downloader": downloader.__name__,
|
||||
"skip_merge": skip_merge,
|
||||
},
|
||||
)
|
||||
raise FileNotFoundError(error_msg)
|
||||
|
||||
if skip_merge:
|
||||
# N_m3u8DL-RE handles merging and decryption internally
|
||||
shutil.move(segments_to_merge[0], save_path)
|
||||
@@ -800,7 +866,7 @@ class DASH:
|
||||
urn = (protection.get("schemeIdUri") or "").lower()
|
||||
|
||||
if urn == WidevineCdm.urn:
|
||||
pssh_text = protection.findtext("pssh")
|
||||
pssh_text = protection.findtext("pssh") or protection.findtext("{urn:mpeg:cenc:2013}pssh")
|
||||
if not pssh_text:
|
||||
continue
|
||||
pssh = PSSH(pssh_text)
|
||||
@@ -831,6 +897,7 @@ class DASH:
|
||||
elif urn in ("urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95", "urn:microsoft:playready"):
|
||||
pr_pssh_b64 = (
|
||||
protection.findtext("pssh")
|
||||
or protection.findtext("{urn:mpeg:cenc:2013}pssh")
|
||||
or protection.findtext("pro")
|
||||
or protection.findtext("{urn:microsoft:playready}pro")
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional, Union
|
||||
from urllib.parse import urljoin
|
||||
from uuid import UUID
|
||||
from zlib import crc32
|
||||
|
||||
import m3u8
|
||||
@@ -27,12 +28,13 @@ from pywidevine.pssh import PSSH as WV_PSSH
|
||||
from requests import Session
|
||||
|
||||
from envied.core import binaries
|
||||
from envied.core.cdm.detect import is_playready_cdm, is_widevine_cdm
|
||||
from envied.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack
|
||||
from envied.core.downloaders import requests as requests_downloader
|
||||
from envied.core.drm import DRM_T, ClearKey, PlayReady, Widevine
|
||||
from envied.core.drm import DRM_T, ClearKey, MonaLisa, PlayReady, Widevine
|
||||
from envied.core.events import events
|
||||
from envied.core.tracks import Audio, Subtitle, Tracks, Video
|
||||
from envied.core.utilities import get_extension, is_close_match, try_ensure_utf8
|
||||
from envied.core.utilities import get_debug_logger, get_extension, is_close_match, try_ensure_utf8
|
||||
|
||||
|
||||
class HLS:
|
||||
@@ -114,9 +116,14 @@ class HLS:
|
||||
|
||||
for playlist in self.manifest.playlists:
|
||||
audio_group = playlist.stream_info.audio
|
||||
if audio_group:
|
||||
audio_codec = Audio.Codec.from_codecs(playlist.stream_info.codecs)
|
||||
audio_codecs_by_group_id[audio_group] = audio_codec
|
||||
audio_codec: Optional[Audio.Codec] = None
|
||||
if audio_group and playlist.stream_info.codecs:
|
||||
try:
|
||||
audio_codec = Audio.Codec.from_codecs(playlist.stream_info.codecs)
|
||||
except ValueError:
|
||||
audio_codec = None
|
||||
if audio_codec:
|
||||
audio_codecs_by_group_id[audio_group] = audio_codec
|
||||
|
||||
try:
|
||||
# TODO: Any better way to figure out the primary track type?
|
||||
@@ -224,6 +231,39 @@ class HLS:
|
||||
|
||||
return tracks
|
||||
|
||||
@staticmethod
|
||||
def _finalize_n_m3u8dl_re_output(*, track: AnyTrack, save_dir: Path, save_path: Path) -> Path:
|
||||
"""
|
||||
Finalize output from N_m3u8DL-RE.
|
||||
|
||||
We call N_m3u8DL-RE with `--save-name track.id`, so the final file should be `{track.id}.*` under `save_dir`.
|
||||
This moves that output to `save_path` (preserving the real suffix) and, for subtitles, updates `track.codec`
|
||||
to match the produced file extension.
|
||||
"""
|
||||
matches = [p for p in save_dir.rglob(f"{track.id}.*") if p.is_file()]
|
||||
if not matches:
|
||||
raise FileNotFoundError(f"No output files produced by N_m3u8DL-RE for save-name={track.id} in: {save_dir}")
|
||||
|
||||
primary = max(matches, key=lambda p: p.stat().st_size)
|
||||
|
||||
final_save_path = save_path.with_suffix(primary.suffix) if primary.suffix else save_path
|
||||
|
||||
final_save_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if primary.absolute() != final_save_path.absolute():
|
||||
final_save_path.unlink(missing_ok=True)
|
||||
shutil.move(str(primary), str(final_save_path))
|
||||
|
||||
if isinstance(track, Subtitle):
|
||||
ext = final_save_path.suffix.lower().lstrip(".")
|
||||
try:
|
||||
track.codec = Subtitle.Codec.from_mime(ext)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
shutil.rmtree(save_dir, ignore_errors=True)
|
||||
|
||||
return final_save_path
|
||||
|
||||
@staticmethod
|
||||
def download_track(
|
||||
track: AnyTrack,
|
||||
@@ -254,13 +294,15 @@ class HLS:
|
||||
else:
|
||||
# Get the playlist text and handle both session types
|
||||
response = session.get(track.url)
|
||||
if isinstance(response, requests.Response):
|
||||
if isinstance(response, requests.Response) or isinstance(response, CurlResponse):
|
||||
if not response.ok:
|
||||
log.error(f"Failed to request the invariant M3U8 playlist: {response.status_code}")
|
||||
sys.exit(1)
|
||||
playlist_text = response.text
|
||||
else:
|
||||
raise TypeError(f"Expected response to be a requests.Response or curl_cffi.Response, not {type(response)}")
|
||||
raise TypeError(
|
||||
f"Expected response to be a requests.Response or curl_cffi.Response, not {type(response)}"
|
||||
)
|
||||
|
||||
master = m3u8.loads(playlist_text, uri=track.url)
|
||||
|
||||
@@ -268,23 +310,63 @@ class HLS:
|
||||
log.error("Track's HLS playlist has no segments, expecting an invariant M3U8 playlist.")
|
||||
sys.exit(1)
|
||||
|
||||
# Get session DRM as fallback but prefer media playlist keys for accurate KID matching
|
||||
if track.drm:
|
||||
session_drm = track.get_drm_for_cdm(cdm)
|
||||
if isinstance(session_drm, (Widevine, PlayReady)):
|
||||
# license and grab content keys
|
||||
try:
|
||||
if not license_widevine:
|
||||
raise ValueError("license_widevine func must be supplied to use DRM")
|
||||
progress(downloaded="LICENSING")
|
||||
license_widevine(session_drm)
|
||||
progress(downloaded="[yellow]LICENSED")
|
||||
except Exception: # noqa
|
||||
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||
progress(downloaded="[red]FAILED")
|
||||
raise
|
||||
else:
|
||||
session_drm = None
|
||||
|
||||
initial_drm_licensed = False
|
||||
initial_drm_key = None # Track the EXT-X-KEY used for initial licensing
|
||||
media_keys = [k for k in (master.keys or []) if k is not None]
|
||||
if media_keys:
|
||||
cdm_media_keys = HLS.filter_keys_for_cdm(media_keys, cdm)
|
||||
media_playlist_key = HLS.get_supported_key(cdm_media_keys) if cdm_media_keys else None
|
||||
|
||||
if media_playlist_key:
|
||||
media_drm = HLS.get_drm(media_playlist_key, session)
|
||||
if isinstance(media_drm, (Widevine, PlayReady)):
|
||||
track_kid = HLS.get_track_kid_from_init(master, track, session) or media_drm.kid
|
||||
try:
|
||||
if not license_widevine:
|
||||
raise ValueError("license_widevine func must be supplied to use DRM")
|
||||
progress(downloaded="LICENSING")
|
||||
license_widevine(media_drm, track_kid=track_kid)
|
||||
progress(downloaded="[yellow]LICENSED")
|
||||
initial_drm_licensed = True
|
||||
initial_drm_key = media_playlist_key
|
||||
track.drm = [media_drm]
|
||||
session_drm = media_drm
|
||||
except Exception: # noqa
|
||||
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||
progress(downloaded="[red]FAILED")
|
||||
raise
|
||||
|
||||
# Fall back to session DRM if media playlist has no matching keys
|
||||
if not initial_drm_licensed and session_drm and isinstance(session_drm, (Widevine, PlayReady)):
|
||||
try:
|
||||
if not license_widevine:
|
||||
raise ValueError("license_widevine func must be supplied to use DRM")
|
||||
progress(downloaded="LICENSING")
|
||||
license_widevine(session_drm)
|
||||
progress(downloaded="[yellow]LICENSED")
|
||||
except Exception: # noqa
|
||||
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||
progress(downloaded="[red]FAILED")
|
||||
raise
|
||||
|
||||
if not initial_drm_licensed and session_drm and isinstance(session_drm, MonaLisa):
|
||||
try:
|
||||
if not license_widevine:
|
||||
raise ValueError("license_widevine func must be supplied to use DRM")
|
||||
progress(downloaded="LICENSING")
|
||||
license_widevine(session_drm)
|
||||
progress(downloaded="[yellow]LICENSED")
|
||||
except Exception: # noqa
|
||||
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||
progress(downloaded="[red]FAILED")
|
||||
raise
|
||||
|
||||
if DOWNLOAD_LICENCE_ONLY.is_set():
|
||||
progress(downloaded="[yellow]SKIPPED")
|
||||
return
|
||||
@@ -341,15 +423,36 @@ class HLS:
|
||||
|
||||
if downloader.__name__ == "n_m3u8dl_re":
|
||||
skip_merge = True
|
||||
# session_drm already has correct content_keys from initial licensing above
|
||||
n_m3u8dl_content_keys = session_drm.content_keys if session_drm else None
|
||||
|
||||
downloader_args.update(
|
||||
{
|
||||
"output_dir": save_dir,
|
||||
"filename": track.id,
|
||||
"track": track,
|
||||
"content_keys": session_drm.content_keys if session_drm else None,
|
||||
"content_keys": n_m3u8dl_content_keys,
|
||||
}
|
||||
)
|
||||
|
||||
debug_logger = get_debug_logger()
|
||||
if debug_logger:
|
||||
debug_logger.log(
|
||||
level="DEBUG",
|
||||
operation="manifest_hls_download_start",
|
||||
message="Starting HLS manifest download",
|
||||
context={
|
||||
"track_id": getattr(track, "id", None),
|
||||
"track_type": track.__class__.__name__,
|
||||
"total_segments": total_segments,
|
||||
"downloader": downloader.__name__,
|
||||
"has_drm": bool(session_drm),
|
||||
"drm_type": session_drm.__class__.__name__ if session_drm else None,
|
||||
"skip_merge": skip_merge,
|
||||
"save_path": str(save_path),
|
||||
},
|
||||
)
|
||||
|
||||
for status_update in downloader(**downloader_args):
|
||||
file_downloaded = status_update.get("file_downloaded")
|
||||
if file_downloaded:
|
||||
@@ -364,216 +467,228 @@ class HLS:
|
||||
for control_file in segment_save_dir.glob("*.aria2__temp"):
|
||||
control_file.unlink()
|
||||
|
||||
if not skip_merge:
|
||||
progress(total=total_segments, completed=0, downloaded="Merging")
|
||||
if skip_merge:
|
||||
final_save_path = HLS._finalize_n_m3u8dl_re_output(track=track, save_dir=save_dir, save_path=save_path)
|
||||
progress(downloaded="Downloaded")
|
||||
track.path = final_save_path
|
||||
events.emit(events.Types.TRACK_DOWNLOADED, track=track)
|
||||
return
|
||||
|
||||
name_len = len(str(total_segments))
|
||||
discon_i = 0
|
||||
range_offset = 0
|
||||
map_data: Optional[tuple[m3u8.model.InitializationSection, bytes]] = None
|
||||
if session_drm:
|
||||
encryption_data: Optional[tuple[Optional[m3u8.Key], DRM_T]] = (None, session_drm)
|
||||
else:
|
||||
encryption_data: Optional[tuple[Optional[m3u8.Key], DRM_T]] = None
|
||||
progress(total=total_segments, completed=0, downloaded="Merging")
|
||||
|
||||
i = -1
|
||||
for real_i, segment in enumerate(master.segments):
|
||||
if segment not in unwanted_segments:
|
||||
i += 1
|
||||
name_len = len(str(total_segments))
|
||||
discon_i = 0
|
||||
range_offset = 0
|
||||
map_data: Optional[tuple[m3u8.model.InitializationSection, bytes]] = None
|
||||
if session_drm:
|
||||
encryption_data: Optional[tuple[Optional[m3u8.Key], DRM_T]] = (initial_drm_key, session_drm)
|
||||
else:
|
||||
encryption_data: Optional[tuple[Optional[m3u8.Key], DRM_T]] = None
|
||||
|
||||
is_last_segment = (real_i + 1) == len(master.segments)
|
||||
i = -1
|
||||
for real_i, segment in enumerate(master.segments):
|
||||
if segment not in unwanted_segments:
|
||||
i += 1
|
||||
|
||||
def merge(to: Path, via: list[Path], delete: bool = False, include_map_data: bool = False):
|
||||
"""
|
||||
Merge all files to a given path, optionally including map data.
|
||||
is_last_segment = (real_i + 1) == len(master.segments)
|
||||
|
||||
Parameters:
|
||||
to: The output file with all merged data.
|
||||
via: List of files to merge, in sequence.
|
||||
delete: Delete the file once it's been merged.
|
||||
include_map_data: Whether to include the init map data.
|
||||
"""
|
||||
with open(to, "wb") as x:
|
||||
if include_map_data and map_data and map_data[1]:
|
||||
x.write(map_data[1])
|
||||
for file in via:
|
||||
x.write(file.read_bytes())
|
||||
x.flush()
|
||||
if delete:
|
||||
file.unlink()
|
||||
def merge(to: Path, via: list[Path], delete: bool = False, include_map_data: bool = False):
|
||||
"""
|
||||
Merge all files to a given path, optionally including map data.
|
||||
|
||||
def decrypt(include_this_segment: bool) -> Path:
|
||||
"""
|
||||
Decrypt all segments that uses the currently set DRM.
|
||||
Parameters:
|
||||
to: The output file with all merged data.
|
||||
via: List of files to merge, in sequence.
|
||||
delete: Delete the file once it's been merged.
|
||||
include_map_data: Whether to include the init map data.
|
||||
"""
|
||||
with open(to, "wb") as x:
|
||||
if include_map_data and map_data and map_data[1]:
|
||||
x.write(map_data[1])
|
||||
for file in via:
|
||||
x.write(file.read_bytes())
|
||||
x.flush()
|
||||
if delete:
|
||||
file.unlink()
|
||||
|
||||
All segments that will be decrypted with this DRM will be merged together
|
||||
in sequence, prefixed with the init data (if any), and then deleted. Once
|
||||
merged they will be decrypted. The merged and decrypted file names state
|
||||
the range of segments that were used.
|
||||
def decrypt(include_this_segment: bool) -> Path:
|
||||
"""
|
||||
Decrypt all segments that uses the currently set DRM.
|
||||
|
||||
Parameters:
|
||||
include_this_segment: Whether to include the current segment in the
|
||||
list of segments to merge and decrypt. This should be False if
|
||||
decrypting on EXT-X-KEY changes, or True when decrypting on the
|
||||
last segment.
|
||||
All segments that will be decrypted with this DRM will be merged together
|
||||
in sequence, prefixed with the init data (if any), and then deleted. Once
|
||||
merged they will be decrypted. The merged and decrypted file names state
|
||||
the range of segments that were used.
|
||||
|
||||
Returns the decrypted path.
|
||||
"""
|
||||
drm = encryption_data[1]
|
||||
first_segment_i = next(
|
||||
int(file.stem) for file in sorted(segment_save_dir.iterdir()) if file.stem.isdigit()
|
||||
)
|
||||
last_segment_i = max(0, i - int(not include_this_segment))
|
||||
range_len = (last_segment_i - first_segment_i) + 1
|
||||
Parameters:
|
||||
include_this_segment: Whether to include the current segment in the
|
||||
list of segments to merge and decrypt. This should be False if
|
||||
decrypting on EXT-X-KEY changes, or True when decrypting on the
|
||||
last segment.
|
||||
|
||||
segment_range = f"{str(first_segment_i).zfill(name_len)}-{str(last_segment_i).zfill(name_len)}"
|
||||
merged_path = (
|
||||
segment_save_dir / f"{segment_range}{get_extension(master.segments[last_segment_i].uri)}"
|
||||
)
|
||||
decrypted_path = segment_save_dir / f"{merged_path.stem}_decrypted{merged_path.suffix}"
|
||||
Returns the decrypted path.
|
||||
"""
|
||||
drm = encryption_data[1]
|
||||
first_segment_i = next(
|
||||
int(file.stem) for file in sorted(segment_save_dir.iterdir()) if file.stem.isdigit()
|
||||
)
|
||||
last_segment_i = max(0, i - int(not include_this_segment))
|
||||
range_len = (last_segment_i - first_segment_i) + 1
|
||||
|
||||
files = [
|
||||
file
|
||||
for file in sorted(segment_save_dir.iterdir())
|
||||
if file.stem.isdigit() and first_segment_i <= int(file.stem) <= last_segment_i
|
||||
]
|
||||
if not files:
|
||||
raise ValueError(f"None of the segment files for {segment_range} exist...")
|
||||
elif len(files) != range_len:
|
||||
raise ValueError(f"Missing {range_len - len(files)} segment files for {segment_range}...")
|
||||
segment_range = f"{str(first_segment_i).zfill(name_len)}-{str(last_segment_i).zfill(name_len)}"
|
||||
merged_path = segment_save_dir / f"{segment_range}{get_extension(master.segments[last_segment_i].uri)}"
|
||||
decrypted_path = segment_save_dir / f"{merged_path.stem}_decrypted{merged_path.suffix}"
|
||||
|
||||
if isinstance(drm, (Widevine, PlayReady)):
|
||||
# with widevine we can merge all segments and decrypt once
|
||||
merge(to=merged_path, via=files, delete=True, include_map_data=True)
|
||||
drm.decrypt(merged_path)
|
||||
merged_path.rename(decrypted_path)
|
||||
else:
|
||||
# with other drm we must decrypt separately and then merge them
|
||||
# for aes this is because each segment likely has 16-byte padding
|
||||
for file in files:
|
||||
drm.decrypt(file)
|
||||
merge(to=merged_path, via=files, delete=True, include_map_data=True)
|
||||
files = [
|
||||
file
|
||||
for file in sorted(segment_save_dir.iterdir())
|
||||
if file.stem.isdigit() and first_segment_i <= int(file.stem) <= last_segment_i
|
||||
]
|
||||
if not files:
|
||||
raise ValueError(f"None of the segment files for {segment_range} exist...")
|
||||
elif len(files) != range_len:
|
||||
raise ValueError(f"Missing {range_len - len(files)} segment files for {segment_range}...")
|
||||
|
||||
events.emit(events.Types.TRACK_DECRYPTED, track=track, drm=drm, segment=decrypted_path)
|
||||
if isinstance(drm, (Widevine, PlayReady)):
|
||||
# with widevine we can merge all segments and decrypt once
|
||||
merge(to=merged_path, via=files, delete=True, include_map_data=True)
|
||||
drm.decrypt(merged_path)
|
||||
merged_path.rename(decrypted_path)
|
||||
else:
|
||||
# with other drm we must decrypt separately and then merge them
|
||||
# for aes this is because each segment likely has 16-byte padding
|
||||
for file in files:
|
||||
drm.decrypt(file)
|
||||
merge(to=merged_path, via=files, delete=True, include_map_data=True)
|
||||
|
||||
return decrypted_path
|
||||
events.emit(events.Types.TRACK_DECRYPTED, track=track, drm=drm, segment=decrypted_path)
|
||||
|
||||
def merge_discontinuity(include_this_segment: bool, include_map_data: bool = True):
|
||||
"""
|
||||
Merge all segments of the discontinuity.
|
||||
return decrypted_path
|
||||
|
||||
All segment files for this discontinuity must already be downloaded and
|
||||
already decrypted (if it needs to be decrypted).
|
||||
def merge_discontinuity(include_this_segment: bool, include_map_data: bool = True):
|
||||
"""
|
||||
Merge all segments of the discontinuity.
|
||||
|
||||
Parameters:
|
||||
include_this_segment: Whether to include the current segment in the
|
||||
list of segments to merge and decrypt. This should be False if
|
||||
decrypting on EXT-X-KEY changes, or True when decrypting on the
|
||||
last segment.
|
||||
include_map_data: Whether to prepend the init map data before the
|
||||
segment files when merging.
|
||||
"""
|
||||
last_segment_i = max(0, i - int(not include_this_segment))
|
||||
All segment files for this discontinuity must already be downloaded and
|
||||
already decrypted (if it needs to be decrypted).
|
||||
|
||||
files = [
|
||||
file
|
||||
for file in sorted(segment_save_dir.iterdir())
|
||||
if int(file.stem.replace("_decrypted", "").split("-")[-1]) <= last_segment_i
|
||||
]
|
||||
if files:
|
||||
to_dir = segment_save_dir.parent
|
||||
to_path = to_dir / f"{str(discon_i).zfill(name_len)}{files[-1].suffix}"
|
||||
merge(to=to_path, via=files, delete=True, include_map_data=include_map_data)
|
||||
Parameters:
|
||||
include_this_segment: Whether to include the current segment in the
|
||||
list of segments to merge and decrypt. This should be False if
|
||||
decrypting on EXT-X-KEY changes, or True when decrypting on the
|
||||
last segment.
|
||||
include_map_data: Whether to prepend the init map data before the
|
||||
segment files when merging.
|
||||
"""
|
||||
last_segment_i = max(0, i - int(not include_this_segment))
|
||||
|
||||
if segment not in unwanted_segments:
|
||||
if isinstance(track, Subtitle):
|
||||
segment_file_ext = get_extension(segment.uri)
|
||||
segment_file_path = segment_save_dir / f"{str(i).zfill(name_len)}{segment_file_ext}"
|
||||
segment_data = try_ensure_utf8(segment_file_path.read_bytes())
|
||||
if track.codec not in (Subtitle.Codec.fVTT, Subtitle.Codec.fTTML):
|
||||
segment_data = (
|
||||
segment_data.decode("utf8")
|
||||
.replace("‎", html.unescape("‎"))
|
||||
.replace("‏", html.unescape("‏"))
|
||||
.encode("utf8")
|
||||
)
|
||||
segment_file_path.write_bytes(segment_data)
|
||||
files = [
|
||||
file
|
||||
for file in sorted(segment_save_dir.iterdir())
|
||||
if int(file.stem.replace("_decrypted", "").split("-")[-1]) <= last_segment_i
|
||||
]
|
||||
if files:
|
||||
to_dir = segment_save_dir.parent
|
||||
to_path = to_dir / f"{str(discon_i).zfill(name_len)}{files[-1].suffix}"
|
||||
merge(to=to_path, via=files, delete=True, include_map_data=include_map_data)
|
||||
|
||||
if segment.discontinuity and i != 0:
|
||||
if encryption_data:
|
||||
decrypt(include_this_segment=False)
|
||||
merge_discontinuity(
|
||||
include_this_segment=False, include_map_data=not encryption_data or not encryption_data[1]
|
||||
if segment not in unwanted_segments:
|
||||
if isinstance(track, Subtitle):
|
||||
segment_file_ext = get_extension(segment.uri)
|
||||
segment_file_path = segment_save_dir / f"{str(i).zfill(name_len)}{segment_file_ext}"
|
||||
segment_data = try_ensure_utf8(segment_file_path.read_bytes())
|
||||
if track.codec not in (Subtitle.Codec.fVTT, Subtitle.Codec.fTTML):
|
||||
segment_data = (
|
||||
segment_data.decode("utf8")
|
||||
.replace("‎", html.unescape("‎"))
|
||||
.replace("‏", html.unescape("‏"))
|
||||
.encode("utf8")
|
||||
)
|
||||
segment_file_path.write_bytes(segment_data)
|
||||
|
||||
discon_i += 1
|
||||
range_offset = 0 # TODO: Should this be reset or not?
|
||||
map_data = None
|
||||
if encryption_data:
|
||||
encryption_data = (encryption_data[0], encryption_data[1])
|
||||
|
||||
if segment.init_section and (not map_data or segment.init_section != map_data[0]):
|
||||
if segment.init_section.byterange:
|
||||
init_byte_range = HLS.calculate_byte_range(segment.init_section.byterange, range_offset)
|
||||
range_offset = init_byte_range.split("-")[0]
|
||||
init_range_header = {"Range": f"bytes={init_byte_range}"}
|
||||
else:
|
||||
init_range_header = {}
|
||||
|
||||
# Handle both session types for init section request
|
||||
res = session.get(
|
||||
url=urljoin(segment.init_section.base_uri, segment.init_section.uri),
|
||||
headers=init_range_header,
|
||||
)
|
||||
|
||||
# Check response based on session type
|
||||
if isinstance(res, requests.Response):
|
||||
res.raise_for_status()
|
||||
init_content = res.content
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Expected response to be requests.Response or curl_cffi.Response, not {type(res)}"
|
||||
)
|
||||
|
||||
map_data = (segment.init_section, init_content)
|
||||
|
||||
segment_keys = getattr(segment, "keys", None)
|
||||
if segment_keys:
|
||||
key = HLS.get_supported_key(segment_keys)
|
||||
if encryption_data and encryption_data[0] != key and i != 0 and segment not in unwanted_segments:
|
||||
decrypt(include_this_segment=False)
|
||||
|
||||
if key is None:
|
||||
encryption_data = None
|
||||
elif not encryption_data or encryption_data[0] != key:
|
||||
drm = HLS.get_drm(key, session)
|
||||
if isinstance(drm, (Widevine, PlayReady)):
|
||||
try:
|
||||
if map_data:
|
||||
track_kid = track.get_key_id(map_data[1])
|
||||
else:
|
||||
track_kid = None
|
||||
progress(downloaded="LICENSING")
|
||||
license_widevine(drm, track_kid=track_kid)
|
||||
progress(downloaded="[yellow]LICENSED")
|
||||
except Exception: # noqa
|
||||
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||
progress(downloaded="[red]FAILED")
|
||||
raise
|
||||
encryption_data = (key, drm)
|
||||
|
||||
if DOWNLOAD_LICENCE_ONLY.is_set():
|
||||
continue
|
||||
|
||||
if is_last_segment:
|
||||
# required as it won't end with EXT-X-DISCONTINUITY nor a new key
|
||||
if segment.discontinuity and i != 0:
|
||||
if encryption_data:
|
||||
decrypt(include_this_segment=True)
|
||||
decrypt(include_this_segment=False)
|
||||
merge_discontinuity(
|
||||
include_this_segment=True, include_map_data=not encryption_data or not encryption_data[1]
|
||||
include_this_segment=False, include_map_data=not encryption_data or not encryption_data[1]
|
||||
)
|
||||
|
||||
progress(advance=1)
|
||||
discon_i += 1
|
||||
range_offset = 0 # TODO: Should this be reset or not?
|
||||
map_data = None
|
||||
|
||||
if segment.init_section and (not map_data or segment.init_section != map_data[0]):
|
||||
if segment.init_section.byterange:
|
||||
init_byte_range = HLS.calculate_byte_range(segment.init_section.byterange, range_offset)
|
||||
range_offset = int(init_byte_range.split("-")[0])
|
||||
init_range_header = {"Range": f"bytes={init_byte_range}"}
|
||||
else:
|
||||
init_range_header = {}
|
||||
|
||||
# Handle both session types for init section request
|
||||
res = session.get(
|
||||
url=urljoin(segment.init_section.base_uri, segment.init_section.uri),
|
||||
headers=init_range_header,
|
||||
)
|
||||
|
||||
# Check response based on session type
|
||||
if isinstance(res, requests.Response) or isinstance(res, CurlResponse):
|
||||
res.raise_for_status()
|
||||
init_content = res.content
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Expected response to be requests.Response or curl_cffi.Response, not {type(res)}"
|
||||
)
|
||||
|
||||
map_data = (segment.init_section, init_content)
|
||||
|
||||
segment_keys = getattr(segment, "keys", None)
|
||||
if segment_keys:
|
||||
if cdm:
|
||||
cdm_segment_keys = HLS.filter_keys_for_cdm(segment_keys, cdm)
|
||||
key = (
|
||||
HLS.get_supported_key(cdm_segment_keys)
|
||||
if cdm_segment_keys
|
||||
else HLS.get_supported_key(segment_keys)
|
||||
)
|
||||
else:
|
||||
key = HLS.get_supported_key(segment_keys)
|
||||
if encryption_data and encryption_data[0] != key and i != 0 and segment not in unwanted_segments:
|
||||
decrypt(include_this_segment=False)
|
||||
|
||||
if key is None:
|
||||
encryption_data = None
|
||||
elif not encryption_data or encryption_data[0] != key:
|
||||
drm = HLS.get_drm(key, session)
|
||||
if isinstance(drm, (Widevine, PlayReady)):
|
||||
try:
|
||||
if map_data:
|
||||
track_kid = track.get_key_id(map_data[1])
|
||||
else:
|
||||
track_kid = None
|
||||
if not track_kid:
|
||||
track_kid = drm.kid
|
||||
progress(downloaded="LICENSING")
|
||||
license_widevine(drm, track_kid=track_kid)
|
||||
progress(downloaded="[yellow]LICENSED")
|
||||
except Exception: # noqa
|
||||
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||
progress(downloaded="[red]FAILED")
|
||||
raise
|
||||
encryption_data = (key, drm)
|
||||
|
||||
if DOWNLOAD_LICENCE_ONLY.is_set():
|
||||
continue
|
||||
|
||||
if is_last_segment:
|
||||
# required as it won't end with EXT-X-DISCONTINUITY nor a new key
|
||||
if encryption_data:
|
||||
decrypt(include_this_segment=True)
|
||||
merge_discontinuity(
|
||||
include_this_segment=True, include_map_data=not encryption_data or not encryption_data[1]
|
||||
)
|
||||
|
||||
progress(advance=1)
|
||||
|
||||
if DOWNLOAD_LICENCE_ONLY.is_set():
|
||||
return
|
||||
@@ -596,6 +711,44 @@ class HLS:
|
||||
|
||||
# finally merge all the discontinuity save files together to the final path
|
||||
segments_to_merge = find_segments_recursively(save_dir)
|
||||
|
||||
if debug_logger:
|
||||
debug_logger.log(
|
||||
level="DEBUG",
|
||||
operation="manifest_hls_download_complete",
|
||||
message="HLS download complete, preparing to merge",
|
||||
context={
|
||||
"track_id": getattr(track, "id", None),
|
||||
"track_type": track.__class__.__name__,
|
||||
"save_dir": str(save_dir),
|
||||
"save_dir_exists": save_dir.exists(),
|
||||
"segments_found": len(segments_to_merge),
|
||||
"segment_files": [f.name for f in segments_to_merge[:10]], # Limit to first 10
|
||||
"downloader": downloader.__name__,
|
||||
"skip_merge": skip_merge,
|
||||
},
|
||||
)
|
||||
|
||||
if not segments_to_merge:
|
||||
error_msg = f"No segment files found in output directory: {save_dir}"
|
||||
if debug_logger:
|
||||
all_contents = list(save_dir.iterdir()) if save_dir.exists() else []
|
||||
debug_logger.log(
|
||||
level="ERROR",
|
||||
operation="manifest_hls_download_no_segments",
|
||||
message=error_msg,
|
||||
context={
|
||||
"track_id": getattr(track, "id", None),
|
||||
"track_type": track.__class__.__name__,
|
||||
"save_dir": str(save_dir),
|
||||
"save_dir_exists": save_dir.exists(),
|
||||
"directory_contents": [str(p) for p in all_contents],
|
||||
"downloader": downloader.__name__,
|
||||
"skip_merge": skip_merge,
|
||||
},
|
||||
)
|
||||
raise FileNotFoundError(error_msg)
|
||||
|
||||
if len(segments_to_merge) == 1:
|
||||
shutil.move(segments_to_merge[0], save_path)
|
||||
else:
|
||||
@@ -752,6 +905,55 @@ class HLS:
|
||||
|
||||
return keys
|
||||
|
||||
@staticmethod
|
||||
def filter_keys_for_cdm(
|
||||
keys: list[Union[m3u8.model.SessionKey, m3u8.model.Key]],
|
||||
cdm: object,
|
||||
) -> list[Union[m3u8.model.SessionKey, m3u8.model.Key]]:
|
||||
"""
|
||||
Filter EXT-X-KEY entries to only include those matching the CDM type.
|
||||
|
||||
This ensures we select the correct DRM system (Widevine vs PlayReady)
|
||||
based on what CDM is configured, avoiding license request failures.
|
||||
"""
|
||||
playready_urn = f"urn:uuid:{PR_PSSH.SYSTEM_ID}"
|
||||
playready_keyformats = {playready_urn, "com.microsoft.playready"}
|
||||
if is_widevine_cdm(cdm):
|
||||
return [k for k in keys if k.keyformat and k.keyformat.lower() == WidevineCdm.urn]
|
||||
elif is_playready_cdm(cdm):
|
||||
return [k for k in keys if k.keyformat and k.keyformat.lower() in playready_keyformats]
|
||||
return keys
|
||||
|
||||
@staticmethod
|
||||
def get_track_kid_from_init(
|
||||
master: M3U8,
|
||||
track: AnyTrack,
|
||||
session: Union[Session, CurlSession],
|
||||
) -> Optional[UUID]:
|
||||
"""
|
||||
Extract the track's Key ID from its init segment (EXT-X-MAP).
|
||||
|
||||
Returns None if no init segment exists or KID extraction fails.
|
||||
The caller should fall back to drm.kid from the PSSH if this returns None.
|
||||
"""
|
||||
map_section = next((seg.init_section for seg in master.segments if seg.init_section), None)
|
||||
if not map_section:
|
||||
return None
|
||||
|
||||
map_uri = urljoin(map_section.base_uri or master.base_uri or "", map_section.uri)
|
||||
try:
|
||||
if map_section.byterange:
|
||||
byte_range = HLS.calculate_byte_range(map_section.byterange, 0)
|
||||
headers = {"Range": f"bytes={byte_range}"}
|
||||
else:
|
||||
headers = {}
|
||||
map_res = session.get(url=map_uri, headers=headers)
|
||||
if map_res.ok:
|
||||
return track.get_key_id(map_res.content)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_supported_key(keys: list[Union[m3u8.model.SessionKey, m3u8.model.Key]]) -> Optional[m3u8.Key]:
|
||||
"""
|
||||
@@ -780,9 +982,10 @@ class HLS:
|
||||
return key
|
||||
elif key.keyformat and key.keyformat.lower() == WidevineCdm.urn:
|
||||
return key
|
||||
elif key.keyformat and (
|
||||
key.keyformat.lower() == PlayReadyCdm or "com.microsoft.playready" in key.keyformat.lower()
|
||||
):
|
||||
elif key.keyformat and key.keyformat.lower() in {
|
||||
f"urn:uuid:{PR_PSSH.SYSTEM_ID}",
|
||||
"com.microsoft.playready",
|
||||
}:
|
||||
return key
|
||||
else:
|
||||
unsupported_systems.append(key.method + (f" ({key.keyformat})" if key.keyformat else ""))
|
||||
@@ -819,9 +1022,7 @@ class HLS:
|
||||
pssh=WV_PSSH(key.uri.split(",")[-1]),
|
||||
**key._extra_params, # noqa
|
||||
)
|
||||
elif key.keyformat and (
|
||||
key.keyformat.lower() == PlayReadyCdm or "com.microsoft.playready" in key.keyformat.lower()
|
||||
):
|
||||
elif key.keyformat and key.keyformat.lower() in {f"urn:uuid:{PR_PSSH.SYSTEM_ID}", "com.microsoft.playready"}:
|
||||
drm = PlayReady(
|
||||
pssh=PR_PSSH(key.uri.split(",")[-1]),
|
||||
pssh_b64=key.uri.split(",")[-1],
|
||||
|
||||
@@ -21,7 +21,7 @@ from envied.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, Any
|
||||
from envied.core.drm import DRM_T, PlayReady, Widevine
|
||||
from envied.core.events import events
|
||||
from envied.core.tracks import Audio, Subtitle, Track, Tracks, Video
|
||||
from envied.core.utilities import try_ensure_utf8
|
||||
from envied.core.utilities import get_debug_logger, try_ensure_utf8
|
||||
from envied.core.utils.xml import load_xml
|
||||
|
||||
|
||||
@@ -283,6 +283,24 @@ class ISM:
|
||||
}
|
||||
)
|
||||
|
||||
debug_logger = get_debug_logger()
|
||||
if debug_logger:
|
||||
debug_logger.log(
|
||||
level="DEBUG",
|
||||
operation="manifest_ism_download_start",
|
||||
message="Starting ISM manifest download",
|
||||
context={
|
||||
"track_id": getattr(track, "id", None),
|
||||
"track_type": track.__class__.__name__,
|
||||
"total_segments": len(segments),
|
||||
"downloader": downloader.__name__,
|
||||
"has_drm": bool(session_drm),
|
||||
"drm_type": session_drm.__class__.__name__ if session_drm else None,
|
||||
"skip_merge": skip_merge,
|
||||
"save_path": str(save_path),
|
||||
},
|
||||
)
|
||||
|
||||
for status_update in downloader(**downloader_args):
|
||||
file_downloaded = status_update.get("file_downloaded")
|
||||
if file_downloaded:
|
||||
@@ -296,8 +314,63 @@ class ISM:
|
||||
for control_file in save_dir.glob("*.aria2__temp"):
|
||||
control_file.unlink()
|
||||
|
||||
# Verify output directory exists and contains files
|
||||
if not save_dir.exists():
|
||||
error_msg = f"Output directory does not exist: {save_dir}"
|
||||
if debug_logger:
|
||||
debug_logger.log(
|
||||
level="ERROR",
|
||||
operation="manifest_ism_download_output_missing",
|
||||
message=error_msg,
|
||||
context={
|
||||
"track_id": getattr(track, "id", None),
|
||||
"track_type": track.__class__.__name__,
|
||||
"save_dir": str(save_dir),
|
||||
"save_path": str(save_path),
|
||||
"downloader": downloader.__name__,
|
||||
"skip_merge": skip_merge,
|
||||
},
|
||||
)
|
||||
raise FileNotFoundError(error_msg)
|
||||
|
||||
segments_to_merge = [x for x in sorted(save_dir.iterdir()) if x.is_file()]
|
||||
|
||||
if debug_logger:
|
||||
debug_logger.log(
|
||||
level="DEBUG",
|
||||
operation="manifest_ism_download_complete",
|
||||
message="ISM download complete, preparing to merge",
|
||||
context={
|
||||
"track_id": getattr(track, "id", None),
|
||||
"track_type": track.__class__.__name__,
|
||||
"save_dir": str(save_dir),
|
||||
"save_dir_exists": save_dir.exists(),
|
||||
"segments_found": len(segments_to_merge),
|
||||
"segment_files": [f.name for f in segments_to_merge[:10]], # Limit to first 10
|
||||
"downloader": downloader.__name__,
|
||||
"skip_merge": skip_merge,
|
||||
},
|
||||
)
|
||||
|
||||
if not segments_to_merge:
|
||||
error_msg = f"No segment files found in output directory: {save_dir}"
|
||||
if debug_logger:
|
||||
all_contents = list(save_dir.iterdir()) if save_dir.exists() else []
|
||||
debug_logger.log(
|
||||
level="ERROR",
|
||||
operation="manifest_ism_download_no_segments",
|
||||
message=error_msg,
|
||||
context={
|
||||
"track_id": getattr(track, "id", None),
|
||||
"track_type": track.__class__.__name__,
|
||||
"save_dir": str(save_dir),
|
||||
"directory_contents": [str(p) for p in all_contents],
|
||||
"downloader": downloader.__name__,
|
||||
"skip_merge": skip_merge,
|
||||
},
|
||||
)
|
||||
raise FileNotFoundError(error_msg)
|
||||
|
||||
if skip_merge:
|
||||
shutil.move(segments_to_merge[0], save_path)
|
||||
else:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from .basic import Basic
|
||||
from .gluetun import Gluetun
|
||||
from .hola import Hola
|
||||
from .nordvpn import NordVPN
|
||||
from .surfsharkvpn import SurfsharkVPN
|
||||
from .windscribevpn import WindscribeVPN
|
||||
|
||||
__all__ = ("Basic", "Hola", "NordVPN", "SurfsharkVPN", "WindscribeVPN")
|
||||
__all__ = ("Basic", "Gluetun", "Hola", "NordVPN", "SurfsharkVPN", "WindscribeVPN")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
@@ -46,8 +47,21 @@ class NordVPN(Proxy):
|
||||
|
||||
HTTP proxies under port 80 were disabled on the 15th of Feb, 2021:
|
||||
https://nordvpn.com/blog/removing-http-proxies
|
||||
|
||||
Supports:
|
||||
- Country code: "us", "ca", "gb"
|
||||
- Country ID: "228"
|
||||
- Specific server: "us1234"
|
||||
- City selection: "us:seattle", "ca:calgary"
|
||||
"""
|
||||
query = query.lower()
|
||||
city = None
|
||||
|
||||
# Check if query includes city specification (e.g., "ca:calgary")
|
||||
if ":" in query:
|
||||
query, city = query.split(":", maxsplit=1)
|
||||
city = city.strip()
|
||||
|
||||
if re.match(r"^[a-z]{2}\d+$", query):
|
||||
# country and nordvpn server id, e.g., us1, fr1234
|
||||
hostname = f"{query}.nordvpn.com"
|
||||
@@ -64,7 +78,12 @@ class NordVPN(Proxy):
|
||||
# NordVPN doesnt have servers in this region
|
||||
return
|
||||
|
||||
server_mapping = self.server_map.get(country["code"].lower())
|
||||
# Check server_map for pinned servers (can include city)
|
||||
server_map_key = f"{country['code'].lower()}:{city}" if city else country["code"].lower()
|
||||
server_mapping = self.server_map.get(server_map_key) or (
|
||||
self.server_map.get(country["code"].lower()) if not city else None
|
||||
)
|
||||
|
||||
if server_mapping:
|
||||
# country was set to a specific server ID in config
|
||||
hostname = f"{country['code'].lower()}{server_mapping}.nordvpn.com"
|
||||
@@ -76,7 +95,19 @@ class NordVPN(Proxy):
|
||||
f"The NordVPN Country {query} currently has no recommended servers. "
|
||||
"Try again later. If the issue persists, double-check the query."
|
||||
)
|
||||
hostname = recommended_servers[0]["hostname"]
|
||||
|
||||
# Filter by city if specified
|
||||
if city:
|
||||
city_servers = self.filter_servers_by_city(recommended_servers, city)
|
||||
if not city_servers:
|
||||
raise ValueError(
|
||||
f"No servers found in city '{city}' for country '{country['name']}'. "
|
||||
"Try a different city or check the city name spelling."
|
||||
)
|
||||
recommended_servers = city_servers
|
||||
|
||||
# Pick a random server from the filtered list
|
||||
hostname = random.choice(recommended_servers)["hostname"]
|
||||
|
||||
if hostname.startswith("gb"):
|
||||
# NordVPN uses the alpha2 of 'GB' in API responses, but 'UK' in the hostname
|
||||
@@ -95,6 +126,41 @@ class NordVPN(Proxy):
|
||||
):
|
||||
return country
|
||||
|
||||
@staticmethod
|
||||
def filter_servers_by_city(servers: list[dict], city: str) -> list[dict]:
|
||||
"""
|
||||
Filter servers by city name.
|
||||
|
||||
The API returns servers with location data that includes city information.
|
||||
This method filters servers to only those in the specified city.
|
||||
|
||||
Args:
|
||||
servers: List of server dictionaries from the NordVPN API
|
||||
city: City name to filter by (case-insensitive)
|
||||
|
||||
Returns:
|
||||
List of servers in the specified city
|
||||
"""
|
||||
city_lower = city.lower()
|
||||
filtered = []
|
||||
|
||||
for server in servers:
|
||||
# Each server has a 'locations' list with location data
|
||||
locations = server.get("locations", [])
|
||||
for location in locations:
|
||||
# City data can be in different formats:
|
||||
# - {"city": {"name": "Seattle", ...}}
|
||||
# - {"city": "Seattle"}
|
||||
city_data = location.get("city")
|
||||
if city_data:
|
||||
# Handle both dict and string formats
|
||||
city_name = city_data.get("name") if isinstance(city_data, dict) else city_data
|
||||
if city_name and city_name.lower() == city_lower:
|
||||
filtered.append(server)
|
||||
break # Found a match, no need to check other locations for this server
|
||||
|
||||
return filtered
|
||||
|
||||
@staticmethod
|
||||
def get_recommended_servers(country_id: int) -> list[dict]:
|
||||
"""
|
||||
|
||||
@@ -44,8 +44,21 @@ class SurfsharkVPN(Proxy):
|
||||
def get_proxy(self, query: str) -> Optional[str]:
|
||||
"""
|
||||
Get an HTTP(SSL) proxy URI for a SurfsharkVPN server.
|
||||
|
||||
Supports:
|
||||
- Country code: "us", "ca", "gb"
|
||||
- Country ID: "228"
|
||||
- Specific server: "us-bos" (Boston)
|
||||
- City selection: "us:seattle", "ca:toronto"
|
||||
"""
|
||||
query = query.lower()
|
||||
city = None
|
||||
|
||||
# Check if query includes city specification (e.g., "us:seattle")
|
||||
if ":" in query:
|
||||
query, city = query.split(":", maxsplit=1)
|
||||
city = city.strip()
|
||||
|
||||
if re.match(r"^[a-z]{2}\d+$", query):
|
||||
# country and surfsharkvpn server id, e.g., au-per, be-anr, us-bos
|
||||
hostname = f"{query}.prod.surfshark.com"
|
||||
@@ -62,13 +75,18 @@ class SurfsharkVPN(Proxy):
|
||||
# SurfsharkVPN doesnt have servers in this region
|
||||
return
|
||||
|
||||
server_mapping = self.server_map.get(country["countryCode"].lower())
|
||||
# Check server_map for pinned servers (can include city)
|
||||
server_map_key = f"{country['countryCode'].lower()}:{city}" if city else country["countryCode"].lower()
|
||||
server_mapping = self.server_map.get(server_map_key) or (
|
||||
self.server_map.get(country["countryCode"].lower()) if not city else None
|
||||
)
|
||||
|
||||
if server_mapping:
|
||||
# country was set to a specific server ID in config
|
||||
hostname = f"{country['code'].lower()}{server_mapping}.prod.surfshark.com"
|
||||
else:
|
||||
# get the random server ID
|
||||
random_server = self.get_random_server(country["countryCode"])
|
||||
random_server = self.get_random_server(country["countryCode"], city)
|
||||
if not random_server:
|
||||
raise ValueError(
|
||||
f"The SurfsharkVPN Country {query} currently has no random servers. "
|
||||
@@ -92,18 +110,49 @@ class SurfsharkVPN(Proxy):
|
||||
):
|
||||
return country
|
||||
|
||||
def get_random_server(self, country_id: str):
|
||||
def get_random_server(self, country_id: str, city: Optional[str] = None):
|
||||
"""
|
||||
Get the list of random Server for a Country.
|
||||
Get a random server for a Country, optionally filtered by city.
|
||||
|
||||
Note: There may not always be more than one recommended server.
|
||||
Args:
|
||||
country_id: The country code (e.g., "US", "CA")
|
||||
city: Optional city name to filter by (case-insensitive)
|
||||
|
||||
Note: The API may include a 'location' field with city information.
|
||||
If not available, this will return any server from the country.
|
||||
"""
|
||||
country = [x["connectionName"] for x in self.countries if x["countryCode"].lower() == country_id.lower()]
|
||||
try:
|
||||
country = random.choice(country)
|
||||
return country
|
||||
except Exception:
|
||||
raise ValueError("Could not get random countrycode from the countries list.")
|
||||
servers = [x for x in self.countries if x["countryCode"].lower() == country_id.lower()]
|
||||
|
||||
# Filter by city if specified
|
||||
if city:
|
||||
city_lower = city.lower()
|
||||
# Check if servers have a 'location' field for city filtering
|
||||
city_servers = [
|
||||
x
|
||||
for x in servers
|
||||
if x.get("location", "").lower() == city_lower or x.get("city", "").lower() == city_lower
|
||||
]
|
||||
|
||||
if city_servers:
|
||||
servers = city_servers
|
||||
else:
|
||||
raise ValueError(
|
||||
f"No servers found in city '{city}' for country '{country_id}'. "
|
||||
"Try a different city or check the city name spelling."
|
||||
)
|
||||
|
||||
# Get connection names from filtered servers
|
||||
if not servers:
|
||||
raise ValueError(f"Could not get random server for country '{country_id}': no servers found.")
|
||||
|
||||
# Only include servers that actually have a connection name to avoid KeyError.
|
||||
connection_names = [x["connectionName"] for x in servers if "connectionName" in x]
|
||||
if not connection_names:
|
||||
raise ValueError(
|
||||
f"Could not get random server for country '{country_id}': no servers with connectionName found."
|
||||
)
|
||||
|
||||
return random.choice(connection_names)
|
||||
|
||||
@staticmethod
|
||||
def get_countries() -> list[dict]:
|
||||
|
||||
@@ -45,22 +45,38 @@ class WindscribeVPN(Proxy):
|
||||
"""
|
||||
Get an HTTPS proxy URI for a WindscribeVPN server.
|
||||
|
||||
Note: Windscribe's static OpenVPN credentials work reliably on US, AU, and NZ servers.
|
||||
Supports:
|
||||
- Country code: "us", "ca", "gb"
|
||||
- Specific server: "sg007", "us150"
|
||||
- City selection: "us:seattle", "ca:toronto"
|
||||
"""
|
||||
query = query.lower()
|
||||
supported_regions = {"us", "au", "nz"}
|
||||
city = None
|
||||
|
||||
if query not in supported_regions and query not in self.server_map:
|
||||
raise ValueError(
|
||||
f"Windscribe proxy does not currently support the '{query.upper()}' region. "
|
||||
f"Supported regions with reliable credentials: {', '.join(sorted(supported_regions))}. "
|
||||
)
|
||||
# Check if query includes city specification (e.g., "ca:toronto")
|
||||
if ":" in query:
|
||||
query, city = query.split(":", maxsplit=1)
|
||||
city = city.strip()
|
||||
|
||||
if query in self.server_map:
|
||||
# Check server_map for pinned servers (can include city)
|
||||
server_map_key = f"{query}:{city}" if city else query
|
||||
if server_map_key in self.server_map:
|
||||
hostname = self.server_map[server_map_key]
|
||||
elif query in self.server_map:
|
||||
hostname = self.server_map[query]
|
||||
else:
|
||||
if re.match(r"^[a-z]+$", query):
|
||||
hostname = self.get_random_server(query)
|
||||
server_match = re.match(r"^([a-z]{2})(\d+)$", query)
|
||||
if server_match:
|
||||
# Specific server selection, e.g., sg007, us150
|
||||
country_code, server_num = server_match.groups()
|
||||
hostname = self.get_specific_server(country_code, server_num)
|
||||
if not hostname:
|
||||
raise ValueError(
|
||||
f"No WindscribeVPN server found matching '{query}'. "
|
||||
f"Check the server number or use just '{country_code}' for a random server."
|
||||
)
|
||||
elif re.match(r"^[a-z]+$", query):
|
||||
hostname = self.get_random_server(query, city)
|
||||
else:
|
||||
raise ValueError(f"The query provided is unsupported and unrecognized: {query}")
|
||||
|
||||
@@ -70,22 +86,74 @@ class WindscribeVPN(Proxy):
|
||||
hostname = hostname.split(':')[0]
|
||||
return f"https://{self.username}:{self.password}@{hostname}:443"
|
||||
|
||||
def get_random_server(self, country_code: str) -> Optional[str]:
|
||||
def get_specific_server(self, country_code: str, server_num: str) -> Optional[str]:
|
||||
"""
|
||||
Get a random server hostname for a country.
|
||||
Find a specific server by country code and server number.
|
||||
|
||||
Returns None if no servers are available for the country.
|
||||
Matches against hostnames like "sg-007.totallyacdn.com" for query "sg007".
|
||||
Tries both the raw number and zero-padded variants.
|
||||
|
||||
Args:
|
||||
country_code: Two-letter country code (e.g., "sg", "us")
|
||||
server_num: Server number as string (e.g., "007", "7", "150")
|
||||
|
||||
Returns:
|
||||
The matching hostname, or None if not found.
|
||||
"""
|
||||
num_stripped = server_num.lstrip("0") or "0"
|
||||
candidates = {
|
||||
f"{country_code}-{server_num}.",
|
||||
f"{country_code}-{num_stripped}.",
|
||||
f"{country_code}-{server_num.zfill(3)}.",
|
||||
}
|
||||
|
||||
for location in self.countries:
|
||||
if location.get("country_code", "").lower() != country_code:
|
||||
continue
|
||||
for group in location.get("groups", []):
|
||||
for host in group.get("hosts", []):
|
||||
hostname = host.get("hostname", "")
|
||||
if any(hostname.startswith(prefix) for prefix in candidates):
|
||||
return hostname
|
||||
|
||||
return None
|
||||
|
||||
def get_random_server(self, country_code: str, city: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
Get a random server hostname for a country, optionally filtered by city.
|
||||
|
||||
Args:
|
||||
country_code: The country code (e.g., "us", "ca")
|
||||
city: Optional city name to filter by (case-insensitive)
|
||||
|
||||
Returns:
|
||||
A random hostname from matching servers, or None if none available.
|
||||
"""
|
||||
hostnames = []
|
||||
|
||||
# Collect hostnames from ALL locations matching the country code
|
||||
for location in self.countries:
|
||||
if location.get("country_code", "").lower() == country_code.lower():
|
||||
hostnames = []
|
||||
for group in location.get("groups", []):
|
||||
# Filter by city if specified
|
||||
if city:
|
||||
group_city = group.get("city", "")
|
||||
if group_city.lower() != city.lower():
|
||||
continue
|
||||
|
||||
# Collect hostnames from this group
|
||||
for host in group.get("hosts", []):
|
||||
if hostname := host.get("hostname"):
|
||||
hostnames.append(hostname)
|
||||
|
||||
if hostnames:
|
||||
return random.choice(hostnames)
|
||||
if hostnames:
|
||||
return random.choice(hostnames)
|
||||
elif city:
|
||||
# No servers found for the specified city
|
||||
raise ValueError(
|
||||
f"No servers found in city '{city}' for country code '{country_code}'. "
|
||||
"Try a different city or check the city name spelling."
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from collections.abc import Generator
|
||||
from http.cookiejar import CookieJar
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
import click
|
||||
import m3u8
|
||||
@@ -27,6 +27,45 @@ from envied.core.tracks import Chapters, Tracks
|
||||
from envied.core.utilities import get_cached_ip_info, get_ip_info
|
||||
|
||||
|
||||
def sanitize_proxy_for_log(uri: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
Sanitize a proxy URI for logs by redacting any embedded userinfo (username/password).
|
||||
|
||||
Examples:
|
||||
- http://user:pass@host:8080 -> http://REDACTED@host:8080
|
||||
- socks5h://user@host:1080 -> socks5h://REDACTED@host:1080
|
||||
"""
|
||||
if uri is None:
|
||||
return None
|
||||
if not isinstance(uri, str):
|
||||
return str(uri)
|
||||
if not uri:
|
||||
return uri
|
||||
|
||||
try:
|
||||
parsed = urlparse(uri)
|
||||
|
||||
# Handle schemeless proxies like "user:pass@host:port"
|
||||
if not parsed.scheme and not parsed.netloc and "@" in uri and "://" not in uri:
|
||||
# Parse as netloc using a dummy scheme, then strip scheme back out.
|
||||
dummy = urlparse(f"http://{uri}")
|
||||
netloc = dummy.netloc
|
||||
if "@" in netloc:
|
||||
netloc = f"REDACTED@{netloc.split('@', 1)[1]}"
|
||||
# urlparse("http://...") sets path to "" for typical netloc-only strings; keep it just in case.
|
||||
return f"{netloc}{dummy.path}"
|
||||
|
||||
netloc = parsed.netloc
|
||||
if "@" in netloc:
|
||||
netloc = f"REDACTED@{netloc.split('@', 1)[1]}"
|
||||
|
||||
return urlunparse(parsed._replace(netloc=netloc))
|
||||
except Exception:
|
||||
if "@" in uri:
|
||||
return f"REDACTED@{uri.split('@', 1)[1]}"
|
||||
return uri
|
||||
|
||||
|
||||
class Service(metaclass=ABCMeta):
|
||||
"""The Service Base Class."""
|
||||
|
||||
@@ -53,8 +92,65 @@ class Service(metaclass=ABCMeta):
|
||||
if not ctx.parent or not ctx.parent.params.get("no_proxy"):
|
||||
if ctx.parent:
|
||||
proxy = ctx.parent.params["proxy"]
|
||||
proxy_query = ctx.parent.params.get("proxy_query")
|
||||
proxy_provider_name = ctx.parent.params.get("proxy_provider")
|
||||
else:
|
||||
proxy = None
|
||||
proxy_query = None
|
||||
proxy_provider_name = None
|
||||
|
||||
# Check for service-specific proxy mapping
|
||||
service_name = self.__class__.__name__
|
||||
service_config_dict = config.services.get(service_name, {})
|
||||
proxy_map = service_config_dict.get("proxy_map", {})
|
||||
|
||||
if proxy_map and proxy_query:
|
||||
# Build the full proxy query key (e.g., "nordvpn:ca" or "us")
|
||||
if proxy_provider_name:
|
||||
full_proxy_key = f"{proxy_provider_name}:{proxy_query}"
|
||||
else:
|
||||
full_proxy_key = proxy_query
|
||||
|
||||
# Check if there's a mapping for this query
|
||||
mapped_value = proxy_map.get(full_proxy_key)
|
||||
if mapped_value:
|
||||
self.log.info(
|
||||
f"Found service-specific proxy mapping: {full_proxy_key} -> {sanitize_proxy_for_log(mapped_value)}"
|
||||
)
|
||||
# Query the proxy provider with the mapped value
|
||||
if proxy_provider_name:
|
||||
# Specific provider requested
|
||||
proxy_provider = next(
|
||||
(x for x in ctx.obj.proxy_providers if x.__class__.__name__.lower() == proxy_provider_name),
|
||||
None,
|
||||
)
|
||||
if proxy_provider:
|
||||
mapped_proxy_uri = proxy_provider.get_proxy(mapped_value)
|
||||
if mapped_proxy_uri:
|
||||
proxy = mapped_proxy_uri
|
||||
self.log.info(
|
||||
f"Using mapped proxy from {proxy_provider.__class__.__name__}: {sanitize_proxy_for_log(proxy)}"
|
||||
)
|
||||
else:
|
||||
self.log.warning(
|
||||
f"Failed to get proxy for mapped value '{sanitize_proxy_for_log(mapped_value)}', using default"
|
||||
)
|
||||
else:
|
||||
self.log.warning(f"Proxy provider '{proxy_provider_name}' not found, using default proxy")
|
||||
else:
|
||||
# No specific provider, try all providers
|
||||
for proxy_provider in ctx.obj.proxy_providers:
|
||||
mapped_proxy_uri = proxy_provider.get_proxy(mapped_value)
|
||||
if mapped_proxy_uri:
|
||||
proxy = mapped_proxy_uri
|
||||
self.log.info(
|
||||
f"Using mapped proxy from {proxy_provider.__class__.__name__}: {sanitize_proxy_for_log(proxy)}"
|
||||
)
|
||||
break
|
||||
else:
|
||||
self.log.warning(
|
||||
f"No provider could resolve mapped value '{sanitize_proxy_for_log(mapped_value)}', using default"
|
||||
)
|
||||
|
||||
if not proxy:
|
||||
# don't override the explicit proxy set by the user, even if they may be geoblocked
|
||||
|
||||
@@ -58,6 +58,7 @@ class Services(click.MultiCommand):
|
||||
def get_path(name: str) -> Path:
|
||||
"""Get the directory path of a command."""
|
||||
tag = Services.get_tag(name)
|
||||
|
||||
for service in _SERVICES:
|
||||
if service.parent.stem == tag:
|
||||
return service.parent
|
||||
@@ -72,19 +73,22 @@ class Services(click.MultiCommand):
|
||||
"""
|
||||
original_value = value
|
||||
value = value.lower()
|
||||
|
||||
for path in _SERVICES:
|
||||
tag = path.parent.stem
|
||||
if value in (tag.lower(), *_ALIASES.get(tag, [])):
|
||||
return tag
|
||||
|
||||
return original_value
|
||||
|
||||
@staticmethod
|
||||
def load(tag: str) -> Service:
|
||||
"""Load a Service module by Service tag."""
|
||||
module = _MODULES.get(tag)
|
||||
if not module:
|
||||
raise KeyError(f"There is no Service added by the Tag '{tag}'")
|
||||
return module
|
||||
if module:
|
||||
return module
|
||||
|
||||
raise KeyError(f"There is no Service added by the Tag '{tag}'")
|
||||
|
||||
|
||||
__all__ = ("Services",)
|
||||
|
||||
@@ -102,6 +102,27 @@ class Episode(Title):
|
||||
primary_audio_track = sorted_audio[0]
|
||||
unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language})
|
||||
|
||||
def _get_resolution_token(track: Any) -> str:
|
||||
if not track or not getattr(track, "height", None):
|
||||
return ""
|
||||
resolution = track.height
|
||||
try:
|
||||
dar = getattr(track, "other_display_aspect_ratio", None) or []
|
||||
if dar and dar[0]:
|
||||
aspect_ratio = [int(float(plane)) for plane in str(dar[0]).split(":")]
|
||||
if len(aspect_ratio) == 1:
|
||||
aspect_ratio.append(1)
|
||||
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
|
||||
resolution = int(track.width * (9 / 16))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
scan_suffix = "p"
|
||||
scan_type = getattr(track, "scan_type", None)
|
||||
if scan_type and str(scan_type).lower() == "interlaced":
|
||||
scan_suffix = "i"
|
||||
return f"{resolution}{scan_suffix}"
|
||||
|
||||
# Title [Year] SXXEXX Name (or Title [Year] SXX if folder)
|
||||
if folder:
|
||||
name = f"{self.title}"
|
||||
@@ -109,104 +130,121 @@ class Episode(Title):
|
||||
name += f" {self.year}"
|
||||
name += f" S{self.season:02}"
|
||||
else:
|
||||
name = "{title}{year} S{season:02}E{number:02} {name}".format(
|
||||
title=self.title.replace("$", "S"), # e.g., Arli$$
|
||||
year=f" {self.year}" if self.year and config.series_year else "",
|
||||
season=self.season,
|
||||
number=self.number,
|
||||
name=self.name or "",
|
||||
).strip()
|
||||
if config.dash_naming:
|
||||
# Format: Title - SXXEXX - Episode Name
|
||||
name = self.title.replace("$", "S") # e.g., Arli$$
|
||||
|
||||
if config.scene_naming:
|
||||
# Resolution
|
||||
if primary_video_track:
|
||||
resolution = primary_video_track.height
|
||||
aspect_ratio = [
|
||||
int(float(plane)) for plane in primary_video_track.other_display_aspect_ratio[0].split(":")
|
||||
]
|
||||
if len(aspect_ratio) == 1:
|
||||
# e.g., aspect ratio of 2 (2.00:1) would end up as `(2.0,)`, add 1
|
||||
aspect_ratio.append(1)
|
||||
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
|
||||
# We want the resolution represented in a 4:3 or 16:9 canvas.
|
||||
# If it's not 4:3 or 16:9, calculate as if it's inside a 16:9 canvas,
|
||||
# otherwise the track's height value is fine.
|
||||
# We are assuming this title is some weird aspect ratio so most
|
||||
# likely a movie or HD source, so it's most likely widescreen so
|
||||
# 16:9 canvas makes the most sense.
|
||||
resolution = int(primary_video_track.width * (9 / 16))
|
||||
name += f" {resolution}p"
|
||||
# Add year if configured
|
||||
if self.year and config.series_year:
|
||||
name += f" {self.year}"
|
||||
|
||||
# Service
|
||||
if show_service:
|
||||
name += f" {self.service.__name__}"
|
||||
# Add season and episode
|
||||
name += f" - S{self.season:02}E{self.number:02}"
|
||||
|
||||
# 'WEB-DL'
|
||||
name += " WEB-DL"
|
||||
# Add episode name with dash separator
|
||||
if self.name:
|
||||
name += f" - {self.name}"
|
||||
|
||||
# DUAL
|
||||
if unique_audio_languages == 2:
|
||||
name += " DUAL"
|
||||
name = name.strip()
|
||||
else:
|
||||
# Standard format without extra dashes
|
||||
name = "{title}{year} S{season:02}E{number:02} {name}".format(
|
||||
title=self.title.replace("$", "S"), # e.g., Arli$$
|
||||
year=f" {self.year}" if self.year and config.series_year else "",
|
||||
season=self.season,
|
||||
number=self.number,
|
||||
name=self.name or "",
|
||||
).strip()
|
||||
|
||||
# MULTi
|
||||
if unique_audio_languages > 2:
|
||||
name += " MULTi"
|
||||
if primary_video_track:
|
||||
resolution_token = _get_resolution_token(primary_video_track)
|
||||
if resolution_token:
|
||||
name += f" {resolution_token}"
|
||||
|
||||
# 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)
|
||||
# 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__}"
|
||||
|
||||
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"
|
||||
# 'WEB-DL'
|
||||
name += " WEB-DL"
|
||||
|
||||
# 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 ""
|
||||
# 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(" "))
|
||||
)
|
||||
frame_rate = float(primary_video_track.frame_rate)
|
||||
else:
|
||||
channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0
|
||||
channels = float(channel_count)
|
||||
|
||||
# Primary HDR format detection
|
||||
if hdr_format:
|
||||
if hdr_format_full.startswith("Dolby Vision"):
|
||||
name += " DV"
|
||||
if any(
|
||||
indicator in (hdr_format_full + " " + hdr_format)
|
||||
for indicator in ["HDR10", "SMPTE ST 2086"]
|
||||
):
|
||||
name += " HDR"
|
||||
else:
|
||||
name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} "
|
||||
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)}"
|
||||
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"
|
||||
|
||||
if config.tag:
|
||||
name += f"-{config.tag}"
|
||||
# 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)
|
||||
|
||||
return sanitize_filename(name)
|
||||
else:
|
||||
# Simple naming style without technical details - use spaces instead of dots
|
||||
return sanitize_filename(name, " ")
|
||||
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 Series(SortedKeyList, ABC):
|
||||
|
||||
@@ -47,6 +47,8 @@ class Movie(Title):
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.year:
|
||||
if config.dash_naming:
|
||||
return f"{self.name} - {self.year}"
|
||||
return f"{self.name} ({self.year})"
|
||||
return self.name
|
||||
|
||||
@@ -65,99 +67,119 @@ class Movie(Title):
|
||||
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}"
|
||||
|
||||
# Name (Year)
|
||||
name = str(self).replace("$", "S") # e.g., Arli$$
|
||||
|
||||
if config.scene_naming:
|
||||
# Resolution
|
||||
if primary_video_track:
|
||||
resolution = primary_video_track.height
|
||||
aspect_ratio = [
|
||||
int(float(plane)) for plane in primary_video_track.other_display_aspect_ratio[0].split(":")
|
||||
]
|
||||
if len(aspect_ratio) == 1:
|
||||
# e.g., aspect ratio of 2 (2.00:1) would end up as `(2.0,)`, add 1
|
||||
aspect_ratio.append(1)
|
||||
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
|
||||
# We want the resolution represented in a 4:3 or 16:9 canvas.
|
||||
# If it's not 4:3 or 16:9, calculate as if it's inside a 16:9 canvas,
|
||||
# otherwise the track's height value is fine.
|
||||
# We are assuming this title is some weird aspect ratio so most
|
||||
# likely a movie or HD source, so it's most likely widescreen so
|
||||
# 16:9 canvas makes the most sense.
|
||||
resolution = int(primary_video_track.width * (9 / 16))
|
||||
name += f" {resolution}p"
|
||||
if primary_video_track:
|
||||
resolution_token = _get_resolution_token(primary_video_track)
|
||||
if resolution_token:
|
||||
name += f" {resolution_token}"
|
||||
|
||||
# Service
|
||||
if show_service:
|
||||
name += f" {self.service.__name__}"
|
||||
# Service (use track source if available)
|
||||
if show_service:
|
||||
source_name = None
|
||||
if self.tracks:
|
||||
first_track = next(iter(self.tracks), None)
|
||||
if first_track and hasattr(first_track, "source") and first_track.source:
|
||||
source_name = first_track.source
|
||||
name += f" {source_name or self.service.__name__}"
|
||||
|
||||
# 'WEB-DL'
|
||||
name += " WEB-DL"
|
||||
# 'WEB-DL'
|
||||
name += " WEB-DL"
|
||||
|
||||
# DUAL
|
||||
if unique_audio_languages == 2:
|
||||
name += " DUAL"
|
||||
# DUAL
|
||||
if unique_audio_languages == 2:
|
||||
name += " DUAL"
|
||||
|
||||
# MULTi
|
||||
if unique_audio_languages > 2:
|
||||
name += " MULTi"
|
||||
# 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 ""
|
||||
# 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(" "))
|
||||
)
|
||||
frame_rate = float(primary_video_track.frame_rate)
|
||||
else:
|
||||
channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0
|
||||
channels = float(channel_count)
|
||||
|
||||
# Primary HDR format detection
|
||||
if hdr_format:
|
||||
if hdr_format_full.startswith("Dolby Vision"):
|
||||
name += " DV"
|
||||
if any(
|
||||
indicator in (hdr_format_full + " " + hdr_format)
|
||||
for indicator in ["HDR10", "SMPTE ST 2086"]
|
||||
):
|
||||
name += " HDR"
|
||||
else:
|
||||
name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} "
|
||||
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)}"
|
||||
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"
|
||||
|
||||
if config.tag:
|
||||
name += f"-{config.tag}"
|
||||
# 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)
|
||||
|
||||
return sanitize_filename(name)
|
||||
else:
|
||||
# Simple naming style without technical details - use spaces instead of dots
|
||||
return sanitize_filename(name, " ")
|
||||
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):
|
||||
|
||||
@@ -100,26 +100,27 @@ class Song(Title):
|
||||
# NN. Song Name
|
||||
name = str(self).split(" / ")[1]
|
||||
|
||||
if config.scene_naming:
|
||||
# Service
|
||||
if show_service:
|
||||
name += f" {self.service.__name__}"
|
||||
# Service (use track source if available)
|
||||
if show_service:
|
||||
source_name = None
|
||||
if self.tracks:
|
||||
first_track = next(iter(self.tracks), None)
|
||||
if first_track and hasattr(first_track, "source") and first_track.source:
|
||||
source_name = first_track.source
|
||||
name += f" {source_name or self.service.__name__}"
|
||||
|
||||
# 'WEB-DL'
|
||||
name += " WEB-DL"
|
||||
# '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"
|
||||
# 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}"
|
||||
if config.tag:
|
||||
name += f"-{config.tag}"
|
||||
|
||||
return sanitize_filename(name, " ")
|
||||
else:
|
||||
# Simple naming style without technical details
|
||||
return sanitize_filename(name, " ")
|
||||
return sanitize_filename(name, " ")
|
||||
|
||||
|
||||
class Album(SortedKeyList, ABC):
|
||||
|
||||
@@ -10,6 +10,7 @@ from zlib import crc32
|
||||
import requests
|
||||
|
||||
from envied.core.config import config
|
||||
from envied.core.constants import DOWNLOAD_LICENCE_ONLY
|
||||
|
||||
|
||||
class Attachment:
|
||||
@@ -43,6 +44,8 @@ class Attachment:
|
||||
if path is None and url is None:
|
||||
raise ValueError("Either path or url must be provided.")
|
||||
|
||||
self.url = url
|
||||
|
||||
if url:
|
||||
if not isinstance(url, str):
|
||||
raise ValueError("The attachment URL must be a string.")
|
||||
@@ -57,41 +60,55 @@ class Attachment:
|
||||
|
||||
download_path = config.directories.temp / file_name
|
||||
|
||||
# Download the file
|
||||
try:
|
||||
session = session or requests.Session()
|
||||
response = session.get(url, stream=True)
|
||||
response.raise_for_status()
|
||||
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
||||
download_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Download the file unless we're in license-only mode
|
||||
if DOWNLOAD_LICENCE_ONLY.is_set():
|
||||
path = None
|
||||
else:
|
||||
try:
|
||||
if session is None:
|
||||
with requests.Session() as session:
|
||||
response = session.get(url, stream=True)
|
||||
response.raise_for_status()
|
||||
else:
|
||||
response = session.get(url, stream=True)
|
||||
response.raise_for_status()
|
||||
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
||||
download_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(download_path, "wb") as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
with open(download_path, "wb") as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
path = download_path
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to download attachment from URL: {e}")
|
||||
path = download_path
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to download attachment from URL: {e}")
|
||||
|
||||
if not isinstance(path, (str, Path)):
|
||||
raise ValueError("The attachment path must be provided.")
|
||||
if path is not None and not isinstance(path, (str, Path)):
|
||||
raise ValueError(
|
||||
f"Invalid attachment path type: expected str or Path, got {type(path).__name__}."
|
||||
)
|
||||
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
raise ValueError("The attachment file does not exist.")
|
||||
if path is not None:
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
raise ValueError("The attachment file does not exist.")
|
||||
|
||||
name = (name or path.stem).strip()
|
||||
if path is not None:
|
||||
name = (name or path.stem).strip()
|
||||
else:
|
||||
name = (name or Path(file_name).stem).strip()
|
||||
mime_type = (mime_type or "").strip() or None
|
||||
description = (description or "").strip() or None
|
||||
|
||||
if not mime_type:
|
||||
suffix = path.suffix.lower() if path is not None else Path(file_name).suffix.lower()
|
||||
mime_type = {
|
||||
".ttf": "application/x-truetype-font",
|
||||
".otf": "application/vnd.ms-opentype",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
}.get(path.suffix.lower(), mimetypes.guess_type(path)[0])
|
||||
}.get(suffix, mimetypes.guess_type(file_name if path is None else path)[0])
|
||||
if not mime_type:
|
||||
raise ValueError("The attachment mime-type could not be automatically detected.")
|
||||
|
||||
@@ -111,13 +128,18 @@ class Attachment:
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""Compute an ID from the attachment data."""
|
||||
checksum = crc32(self.path.read_bytes())
|
||||
if self.path and self.path.exists():
|
||||
checksum = crc32(self.path.read_bytes())
|
||||
elif self.url:
|
||||
checksum = crc32(self.url.encode("utf8"))
|
||||
else:
|
||||
checksum = crc32(self.name.encode("utf8"))
|
||||
return hex(checksum)
|
||||
|
||||
def delete(self) -> None:
|
||||
if self.path:
|
||||
if self.path and self.path.exists():
|
||||
self.path.unlink()
|
||||
self.path = None
|
||||
self.path = None
|
||||
|
||||
@classmethod
|
||||
def from_url(
|
||||
|
||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
||||
from rich.padding import Padding
|
||||
from rich.rule import Rule
|
||||
|
||||
from envied.core.binaries import DoviTool, HDR10PlusTool
|
||||
from envied.core.binaries import FFMPEG, DoviTool, HDR10PlusTool
|
||||
from envied.core.config import config
|
||||
from envied.core.console import console
|
||||
|
||||
@@ -109,7 +109,7 @@ class Hybrid:
|
||||
"""Simple ffmpeg execution without progress tracking"""
|
||||
p = subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
str(FFMPEG) if FFMPEG else "ffmpeg",
|
||||
"-nostdin",
|
||||
"-i",
|
||||
str(save_path),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from collections import defaultdict
|
||||
@@ -16,7 +17,7 @@ from construct import Container
|
||||
from pycaption import Caption, CaptionList, CaptionNode, WebVTTReader
|
||||
from pycaption.geometry import Layout
|
||||
from pymp4.parser import MP4
|
||||
from subby import CommonIssuesFixer, SAMIConverter, SDHStripper, WebVTTConverter
|
||||
from subby import CommonIssuesFixer, SAMIConverter, SDHStripper, WebVTTConverter, WVTTConverter
|
||||
from subtitle_filter import Subtitles
|
||||
|
||||
from envied.core import binaries
|
||||
@@ -25,6 +26,9 @@ from envied.core.tracks.track import Track
|
||||
from envied.core.utilities import try_ensure_utf8
|
||||
from envied.core.utils.webvtt import merge_segmented_webvtt
|
||||
|
||||
# silence srt library INFO logging
|
||||
logging.getLogger("srt").setLevel(logging.ERROR)
|
||||
|
||||
|
||||
class Subtitle(Track):
|
||||
class Codec(str, Enum):
|
||||
@@ -595,10 +599,13 @@ class Subtitle(Track):
|
||||
|
||||
if self.codec == Subtitle.Codec.WebVTT:
|
||||
converter = WebVTTConverter()
|
||||
srt_subtitles = converter.from_file(str(self.path))
|
||||
srt_subtitles = converter.from_file(self.path)
|
||||
if self.codec == Subtitle.Codec.fVTT:
|
||||
converter = WVTTConverter()
|
||||
srt_subtitles = converter.from_file(self.path)
|
||||
elif self.codec == Subtitle.Codec.SAMI:
|
||||
converter = SAMIConverter()
|
||||
srt_subtitles = converter.from_file(str(self.path))
|
||||
srt_subtitles = converter.from_file(self.path)
|
||||
|
||||
if srt_subtitles is not None:
|
||||
# Apply common fixes
|
||||
@@ -607,11 +614,11 @@ class Subtitle(Track):
|
||||
|
||||
# If target is SRT, we're done
|
||||
if codec == Subtitle.Codec.SubRip:
|
||||
output_path.write_text(str(fixed_srt), encoding="utf8")
|
||||
fixed_srt.save(output_path, encoding="utf8")
|
||||
else:
|
||||
# Convert from SRT to target format using existing pycaption logic
|
||||
temp_srt_path = self.path.with_suffix(".temp.srt")
|
||||
temp_srt_path.write_text(str(fixed_srt), encoding="utf8")
|
||||
fixed_srt.save(temp_srt_path, encoding="utf8")
|
||||
|
||||
# Parse the SRT and convert to target format
|
||||
caption_set = self.parse(temp_srt_path.read_bytes(), Subtitle.Codec.SubRip)
|
||||
@@ -706,7 +713,8 @@ class Subtitle(Track):
|
||||
Convert this Subtitle to another Format.
|
||||
|
||||
The conversion method is determined by the 'conversion_method' setting in config:
|
||||
- 'auto' (default): Uses subby for WebVTT/SAMI, standard for others
|
||||
- 'auto' (default): Uses subby for WebVTT/fVTT/SAMI; for SSA/ASS/MicroDVD/MPL2/TMP
|
||||
uses SubtitleEdit if available, otherwise pysubs2; standard for others
|
||||
- 'subby': Always uses subby with CommonIssuesFixer
|
||||
- 'subtitleedit': Uses SubtitleEdit when available, falls back to pycaption
|
||||
- 'pycaption': Uses only pycaption library
|
||||
@@ -724,8 +732,19 @@ class Subtitle(Track):
|
||||
elif conversion_method == "pysubs2":
|
||||
return self.convert_with_pysubs2(codec)
|
||||
elif conversion_method == "auto":
|
||||
if self.codec in (Subtitle.Codec.WebVTT, Subtitle.Codec.SAMI):
|
||||
if self.codec in (Subtitle.Codec.WebVTT, Subtitle.Codec.fVTT, Subtitle.Codec.SAMI):
|
||||
return self.convert_with_subby(codec)
|
||||
elif self.codec in (
|
||||
Subtitle.Codec.SubStationAlpha,
|
||||
Subtitle.Codec.SubStationAlphav4,
|
||||
Subtitle.Codec.MicroDVD,
|
||||
Subtitle.Codec.MPL2,
|
||||
Subtitle.Codec.TMP,
|
||||
):
|
||||
if binaries.SubtitleEdit:
|
||||
return self._convert_standard(codec)
|
||||
else:
|
||||
return self.convert_with_pysubs2(codec)
|
||||
else:
|
||||
return self._convert_standard(codec)
|
||||
else:
|
||||
@@ -810,13 +829,18 @@ class Subtitle(Track):
|
||||
|
||||
if binaries.SubtitleEdit and self.codec not in (Subtitle.Codec.fTTML, Subtitle.Codec.fVTT):
|
||||
sub_edit_format = {
|
||||
Subtitle.Codec.SubStationAlphav4: "AdvancedSubStationAlpha",
|
||||
Subtitle.Codec.TimedTextMarkupLang: "TimedText1.0",
|
||||
}.get(codec, codec.name)
|
||||
Subtitle.Codec.SubRip: "subrip",
|
||||
Subtitle.Codec.SubStationAlpha: "substationalpha",
|
||||
Subtitle.Codec.SubStationAlphav4: "advancedsubstationalpha",
|
||||
Subtitle.Codec.TimedTextMarkupLang: "timedtext1.0",
|
||||
Subtitle.Codec.WebVTT: "webvtt",
|
||||
Subtitle.Codec.SAMI: "sami",
|
||||
Subtitle.Codec.MicroDVD: "microdvd",
|
||||
}.get(codec, codec.name.lower())
|
||||
sub_edit_args = [
|
||||
binaries.SubtitleEdit,
|
||||
"/Convert",
|
||||
self.path,
|
||||
str(binaries.SubtitleEdit),
|
||||
"/convert",
|
||||
str(self.path),
|
||||
sub_edit_format,
|
||||
f"/outputfilename:{output_path.name}",
|
||||
"/encoding:utf8",
|
||||
@@ -1172,9 +1196,12 @@ class Subtitle(Track):
|
||||
|
||||
if sdh_method == "subby" and self.codec == Subtitle.Codec.SubRip:
|
||||
# Use subby's SDHStripper directly on the file
|
||||
fixer = CommonIssuesFixer()
|
||||
stripper = SDHStripper()
|
||||
stripped_srt, _ = stripper.from_file(str(self.path))
|
||||
self.path.write_text(str(stripped_srt), encoding="utf8")
|
||||
srt, _ = fixer.from_file(self.path)
|
||||
stripped, status = stripper.from_srt(srt)
|
||||
if status is True:
|
||||
stripped.save(self.path)
|
||||
return
|
||||
elif sdh_method == "subtitleedit" and binaries.SubtitleEdit:
|
||||
# Force use of SubtitleEdit
|
||||
@@ -1200,25 +1227,36 @@ class Subtitle(Track):
|
||||
# Try subby first for SRT files, then fall back
|
||||
if self.codec == Subtitle.Codec.SubRip:
|
||||
try:
|
||||
fixer = CommonIssuesFixer()
|
||||
stripper = SDHStripper()
|
||||
stripped_srt, _ = stripper.from_file(str(self.path))
|
||||
self.path.write_text(str(stripped_srt), encoding="utf8")
|
||||
srt, _ = fixer.from_file(self.path)
|
||||
stripped, status = stripper.from_srt(srt)
|
||||
if status is True:
|
||||
stripped.save(self.path)
|
||||
return
|
||||
except Exception:
|
||||
pass # Fall through to other methods
|
||||
|
||||
if binaries.SubtitleEdit:
|
||||
if self.codec == Subtitle.Codec.SubStationAlphav4:
|
||||
output_format = "AdvancedSubStationAlpha"
|
||||
elif self.codec == Subtitle.Codec.TimedTextMarkupLang:
|
||||
output_format = "TimedText1.0"
|
||||
else:
|
||||
output_format = self.codec.name
|
||||
conversion_method = config.subtitle.get("conversion_method", "auto")
|
||||
use_subtitleedit = sdh_method == "subtitleedit" or (
|
||||
sdh_method == "auto" and conversion_method in ("auto", "subtitleedit")
|
||||
)
|
||||
|
||||
if binaries.SubtitleEdit and use_subtitleedit:
|
||||
output_format = {
|
||||
Subtitle.Codec.SubRip: "subrip",
|
||||
Subtitle.Codec.SubStationAlpha: "substationalpha",
|
||||
Subtitle.Codec.SubStationAlphav4: "advancedsubstationalpha",
|
||||
Subtitle.Codec.TimedTextMarkupLang: "timedtext1.0",
|
||||
Subtitle.Codec.WebVTT: "webvtt",
|
||||
Subtitle.Codec.SAMI: "sami",
|
||||
Subtitle.Codec.MicroDVD: "microdvd",
|
||||
}.get(self.codec, self.codec.name.lower())
|
||||
subprocess.run(
|
||||
[
|
||||
binaries.SubtitleEdit,
|
||||
"/Convert",
|
||||
self.path,
|
||||
str(binaries.SubtitleEdit),
|
||||
"/convert",
|
||||
str(self.path),
|
||||
output_format,
|
||||
"/encoding:utf8",
|
||||
"/overwrite",
|
||||
@@ -1226,6 +1264,7 @@ class Subtitle(Track):
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
else:
|
||||
if config.subtitle.get("convert_before_strip", True) and self.codec != Subtitle.Codec.SubRip:
|
||||
@@ -1267,18 +1306,21 @@ class Subtitle(Track):
|
||||
if not binaries.SubtitleEdit:
|
||||
raise EnvironmentError("SubtitleEdit executable not found...")
|
||||
|
||||
if self.codec == Subtitle.Codec.SubStationAlphav4:
|
||||
output_format = "AdvancedSubStationAlpha"
|
||||
elif self.codec == Subtitle.Codec.TimedTextMarkupLang:
|
||||
output_format = "TimedText1.0"
|
||||
else:
|
||||
output_format = self.codec.name
|
||||
output_format = {
|
||||
Subtitle.Codec.SubRip: "subrip",
|
||||
Subtitle.Codec.SubStationAlpha: "substationalpha",
|
||||
Subtitle.Codec.SubStationAlphav4: "advancedsubstationalpha",
|
||||
Subtitle.Codec.TimedTextMarkupLang: "timedtext1.0",
|
||||
Subtitle.Codec.WebVTT: "webvtt",
|
||||
Subtitle.Codec.SAMI: "sami",
|
||||
Subtitle.Codec.MicroDVD: "microdvd",
|
||||
}.get(self.codec, self.codec.name.lower())
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
binaries.SubtitleEdit,
|
||||
"/Convert",
|
||||
self.path,
|
||||
str(binaries.SubtitleEdit),
|
||||
"/convert",
|
||||
str(self.path),
|
||||
output_format,
|
||||
"/ReverseRtlStartEnd",
|
||||
"/encoding:utf8",
|
||||
@@ -1286,6 +1328,7 @@ class Subtitle(Track):
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -15,11 +15,10 @@ from zlib import crc32
|
||||
|
||||
from curl_cffi.requests import Session as CurlSession
|
||||
from langcodes import Language
|
||||
from pyplayready.cdm import Cdm as PlayReadyCdm
|
||||
from pywidevine.cdm import Cdm as WidevineCdm
|
||||
from requests import Session
|
||||
|
||||
from envied.core import binaries
|
||||
from envied.core.cdm.detect import is_playready_cdm, is_widevine_cdm
|
||||
from envied.core.config import config
|
||||
from envied.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY
|
||||
from envied.core.downloaders import aria2c, curl_impersonate, n_m3u8dl_re, requests
|
||||
@@ -215,7 +214,8 @@ class Track:
|
||||
# or when the subtitle has a direct file extension
|
||||
if self.downloader.__name__ == "n_m3u8dl_re" and (
|
||||
self.descriptor == self.Descriptor.URL
|
||||
or get_extension(self.url) in {
|
||||
or get_extension(self.url)
|
||||
in {
|
||||
".srt",
|
||||
".vtt",
|
||||
".ttml",
|
||||
@@ -296,14 +296,16 @@ class Track:
|
||||
if not self.drm and track_type in ("Video", "Audio"):
|
||||
# the service might not have explicitly defined the `drm` property
|
||||
# try find DRM information from the init data of URL based on CDM type
|
||||
if isinstance(cdm, PlayReadyCdm):
|
||||
if is_playready_cdm(cdm):
|
||||
try:
|
||||
self.drm = [PlayReady.from_track(self, session)]
|
||||
except PlayReady.Exceptions.PSSHNotFound:
|
||||
try:
|
||||
self.drm = [Widevine.from_track(self, session)]
|
||||
except Widevine.Exceptions.PSSHNotFound:
|
||||
log.warning("No PlayReady or Widevine PSSH was found for this track, is it DRM free?")
|
||||
log.warning(
|
||||
"No PlayReady or Widevine PSSH was found for this track, is it DRM free?"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
self.drm = [Widevine.from_track(self, session)]
|
||||
@@ -311,7 +313,9 @@ class Track:
|
||||
try:
|
||||
self.drm = [PlayReady.from_track(self, session)]
|
||||
except PlayReady.Exceptions.PSSHNotFound:
|
||||
log.warning("No Widevine or PlayReady PSSH was found for this track, is it DRM free?")
|
||||
log.warning(
|
||||
"No Widevine or PlayReady PSSH was found for this track, is it DRM free?"
|
||||
)
|
||||
|
||||
if self.drm:
|
||||
track_kid = self.get_key_id(session=session)
|
||||
@@ -446,23 +450,14 @@ class Track:
|
||||
if not self.drm:
|
||||
return None
|
||||
|
||||
if isinstance(cdm, WidevineCdm):
|
||||
if is_widevine_cdm(cdm):
|
||||
for drm in self.drm:
|
||||
if isinstance(drm, Widevine):
|
||||
return drm
|
||||
elif isinstance(cdm, PlayReadyCdm):
|
||||
elif is_playready_cdm(cdm):
|
||||
for drm in self.drm:
|
||||
if isinstance(drm, PlayReady):
|
||||
return drm
|
||||
elif hasattr(cdm, "is_playready"):
|
||||
if cdm.is_playready:
|
||||
for drm in self.drm:
|
||||
if isinstance(drm, PlayReady):
|
||||
return drm
|
||||
else:
|
||||
for drm in self.drm:
|
||||
if isinstance(drm, Widevine):
|
||||
return drm
|
||||
|
||||
return self.drm[0]
|
||||
|
||||
@@ -548,7 +543,6 @@ class Track:
|
||||
|
||||
try:
|
||||
import m3u8
|
||||
from pyplayready.cdm import Cdm as PlayReadyCdm
|
||||
from pyplayready.system.pssh import PSSH as PR_PSSH
|
||||
from pywidevine.cdm import Cdm as WidevineCdm
|
||||
from pywidevine.pssh import PSSH as WV_PSSH
|
||||
@@ -569,7 +563,7 @@ class Track:
|
||||
pssh_b64 = key.uri.split(",")[-1]
|
||||
drm = Widevine(pssh=WV_PSSH(pssh_b64))
|
||||
drm_list.append(drm)
|
||||
elif fmt == PlayReadyCdm or "com.microsoft.playready" in fmt:
|
||||
elif fmt in {f"urn:uuid:{PR_PSSH.SYSTEM_ID}", "com.microsoft.playready"}:
|
||||
pssh_b64 = key.uri.split(",")[-1]
|
||||
drm = PlayReady(pssh=PR_PSSH(pssh_b64), pssh_b64=pssh_b64)
|
||||
drm_list.append(drm)
|
||||
|
||||
@@ -95,7 +95,7 @@ class Tracks:
|
||||
|
||||
return rep
|
||||
|
||||
def tree(self, add_progress: bool = False) -> tuple[Tree, list[partial]]:
|
||||
def tree(self, add_progress: bool = False) -> tuple[Tree, list[Callable[..., None]]]:
|
||||
all_tracks = [*list(self), *self.chapters, *self.attachments]
|
||||
|
||||
progress_callables = []
|
||||
@@ -121,7 +121,29 @@ class Tracks:
|
||||
speed_estimate_period=10,
|
||||
)
|
||||
task = progress.add_task("", downloaded="-")
|
||||
progress_callables.append(partial(progress.update, task_id=task))
|
||||
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)
|
||||
@@ -199,13 +221,15 @@ class Tracks:
|
||||
self.videos.sort(key=lambda x: not is_close_match(language, [x.language]))
|
||||
|
||||
def sort_audio(self, by_language: Optional[Sequence[Union[str, Language]]] = None) -> None:
|
||||
"""Sort audio tracks by bitrate, descriptive, and optionally language."""
|
||||
"""Sort audio tracks by bitrate, Atmos, descriptive, and optionally language."""
|
||||
if not self.audio:
|
||||
return
|
||||
# descriptive
|
||||
self.audio.sort(key=lambda x: x.descriptive)
|
||||
# bitrate (within each descriptive group)
|
||||
# bitrate (highest first)
|
||||
self.audio.sort(key=lambda x: float(x.bitrate or 0.0), reverse=True)
|
||||
# Atmos tracks first (prioritize over higher bitrate non-Atmos)
|
||||
self.audio.sort(key=lambda x: not x.atmos)
|
||||
# descriptive tracks last
|
||||
self.audio.sort(key=lambda x: x.descriptive)
|
||||
# language
|
||||
for language in reversed(by_language or []):
|
||||
if str(language) in ("all", "best"):
|
||||
@@ -314,6 +338,7 @@ class Tracks:
|
||||
progress: Optional[partial] = None,
|
||||
audio_expected: bool = True,
|
||||
title_language: Optional[Language] = None,
|
||||
skip_subtitles: bool = False,
|
||||
) -> tuple[Path, int, list[str]]:
|
||||
"""
|
||||
Multiplex all the Tracks into a Matroska Container file.
|
||||
@@ -328,6 +353,7 @@ class Tracks:
|
||||
if embedded audio metadata should be added.
|
||||
title_language: The title's intended language. Used to select the best video track
|
||||
for audio metadata when multiple video tracks exist.
|
||||
skip_subtitles: Skip muxing subtitle tracks into the container.
|
||||
"""
|
||||
if self.videos and not self.audio and audio_expected:
|
||||
video_track = None
|
||||
@@ -439,34 +465,35 @@ class Tracks:
|
||||
]
|
||||
)
|
||||
|
||||
for st in self.subtitles:
|
||||
if not st.path or not st.path.exists():
|
||||
raise ValueError("Text Track must be downloaded before muxing...")
|
||||
events.emit(events.Types.TRACK_MULTIPLEX, track=st)
|
||||
default = bool(self.audio and is_close_match(st.language, [self.audio[0].language]) and st.forced)
|
||||
cl.extend(
|
||||
[
|
||||
"--track-name",
|
||||
f"0:{st.get_track_name() or ''}",
|
||||
"--language",
|
||||
f"0:{st.language}",
|
||||
"--sub-charset",
|
||||
"0:UTF-8",
|
||||
"--forced-track",
|
||||
f"0:{st.forced}",
|
||||
"--default-track",
|
||||
f"0:{default}",
|
||||
"--hearing-impaired-flag",
|
||||
f"0:{st.sdh}",
|
||||
"--original-flag",
|
||||
f"0:{st.is_original_lang}",
|
||||
"--compression",
|
||||
"0:none", # disable extra compression (probably zlib)
|
||||
"(",
|
||||
str(st.path),
|
||||
")",
|
||||
]
|
||||
)
|
||||
if not skip_subtitles:
|
||||
for st in self.subtitles:
|
||||
if not st.path or not st.path.exists():
|
||||
raise ValueError("Text Track must be downloaded before muxing...")
|
||||
events.emit(events.Types.TRACK_MULTIPLEX, track=st)
|
||||
default = bool(self.audio and is_close_match(st.language, [self.audio[0].language]) and st.forced)
|
||||
cl.extend(
|
||||
[
|
||||
"--track-name",
|
||||
f"0:{st.get_track_name() or ''}",
|
||||
"--language",
|
||||
f"0:{st.language}",
|
||||
"--sub-charset",
|
||||
"0:UTF-8",
|
||||
"--forced-track",
|
||||
f"0:{st.forced}",
|
||||
"--default-track",
|
||||
f"0:{default}",
|
||||
"--hearing-impaired-flag",
|
||||
f"0:{st.sdh}",
|
||||
"--original-flag",
|
||||
f"0:{st.is_original_lang}",
|
||||
"--compression",
|
||||
"0:none", # disable extra compression (probably zlib)
|
||||
"(",
|
||||
str(st.path),
|
||||
")",
|
||||
]
|
||||
)
|
||||
|
||||
if self.chapters:
|
||||
chapters_path = config.directories.temp / config.filenames.chapters.format(
|
||||
|
||||
@@ -186,6 +186,10 @@ class Video(Track):
|
||||
# for some reason there's no Dolby Vision info tag
|
||||
raise ValueError(f"The M3U Range Tag '{tag}' is not a supported Video Range")
|
||||
|
||||
class ScanType(str, Enum):
|
||||
PROGRESSIVE = "progressive"
|
||||
INTERLACED = "interlaced"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args: Any,
|
||||
@@ -195,6 +199,7 @@ class Video(Track):
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
fps: Optional[Union[str, int, float]] = None,
|
||||
scan_type: Optional[Video.ScanType] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -232,6 +237,8 @@ class Video(Track):
|
||||
raise TypeError(f"Expected height to be a {int}, not {height!r}")
|
||||
if not isinstance(fps, (str, int, float, type(None))):
|
||||
raise TypeError(f"Expected fps to be a {str}, {int}, or {float}, not {fps!r}")
|
||||
if not isinstance(scan_type, (Video.ScanType, type(None))):
|
||||
raise TypeError(f"Expected scan_type to be a {Video.ScanType}, not {scan_type!r}")
|
||||
|
||||
self.codec = codec
|
||||
self.range = range_ or Video.Range.SDR
|
||||
@@ -256,6 +263,7 @@ class Video(Track):
|
||||
except Exception as e:
|
||||
raise ValueError("Expected fps to be a number, float, or a string as numerator/denominator form, " + str(e))
|
||||
|
||||
self.scan_type = scan_type
|
||||
self.needs_duration_fix = False
|
||||
|
||||
def __str__(self) -> str:
|
||||
|
||||
@@ -19,6 +19,7 @@ from urllib.parse import ParseResult, urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
import chardet
|
||||
import pycountry
|
||||
import requests
|
||||
from construct import ValidationError
|
||||
from fontTools import ttLib
|
||||
@@ -277,6 +278,80 @@ def ap_case(text: str, keep_spaces: bool = False, stop_words: tuple[str] = None)
|
||||
)
|
||||
|
||||
|
||||
# Common country code aliases that differ from ISO 3166-1 alpha-2
|
||||
COUNTRY_CODE_ALIASES = {
|
||||
"uk": "gb", # United Kingdom -> Great Britain
|
||||
}
|
||||
|
||||
|
||||
def get_country_name(code: str) -> Optional[str]:
|
||||
"""
|
||||
Convert a 2-letter country code to full country name.
|
||||
|
||||
Args:
|
||||
code: ISO 3166-1 alpha-2 country code (e.g., 'ca', 'us', 'gb', 'uk')
|
||||
|
||||
Returns:
|
||||
Full country name (e.g., 'Canada', 'United States', 'United Kingdom') or None if not found
|
||||
|
||||
Examples:
|
||||
>>> get_country_name('ca')
|
||||
'Canada'
|
||||
>>> get_country_name('US')
|
||||
'United States'
|
||||
>>> get_country_name('uk')
|
||||
'United Kingdom'
|
||||
"""
|
||||
# Handle common aliases
|
||||
code = COUNTRY_CODE_ALIASES.get(code.lower(), code.lower())
|
||||
|
||||
try:
|
||||
country = pycountry.countries.get(alpha_2=code.upper())
|
||||
if country:
|
||||
return country.name
|
||||
except (KeyError, LookupError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_country_code(name: str) -> Optional[str]:
|
||||
"""
|
||||
Convert a country name to its 2-letter ISO 3166-1 alpha-2 code.
|
||||
|
||||
Args:
|
||||
name: Full country name (e.g., 'Canada', 'United States', 'United Kingdom')
|
||||
|
||||
Returns:
|
||||
2-letter country code in uppercase (e.g., 'CA', 'US', 'GB') or None if not found
|
||||
|
||||
Examples:
|
||||
>>> get_country_code('Canada')
|
||||
'CA'
|
||||
>>> get_country_code('united states')
|
||||
'US'
|
||||
>>> get_country_code('United Kingdom')
|
||||
'GB'
|
||||
"""
|
||||
try:
|
||||
# Try exact name match first
|
||||
country = pycountry.countries.get(name=name.title())
|
||||
if country:
|
||||
return country.alpha_2.upper()
|
||||
|
||||
# Try common name (e.g., "Bolivia" vs "Bolivia, Plurinational State of")
|
||||
country = pycountry.countries.get(common_name=name.title())
|
||||
if country:
|
||||
return country.alpha_2.upper()
|
||||
|
||||
# Try fuzzy search as fallback
|
||||
results = pycountry.countries.search_fuzzy(name)
|
||||
if results:
|
||||
return results[0].alpha_2.upper()
|
||||
except (KeyError, LookupError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_ip_info(session: Optional[requests.Session] = None) -> dict:
|
||||
"""
|
||||
Use ipinfo.io to get IP location information.
|
||||
|
||||
@@ -5,6 +5,8 @@ import click
|
||||
from click.shell_completion import CompletionItem
|
||||
from pywidevine.cdm import Cdm as WidevineCdm
|
||||
|
||||
from envied.core.tracks.audio import Audio
|
||||
|
||||
|
||||
class VideoCodecChoice(click.Choice):
|
||||
"""
|
||||
@@ -241,6 +243,52 @@ class QualityList(click.ParamType):
|
||||
return sorted(resolutions, reverse=True)
|
||||
|
||||
|
||||
class AudioCodecList(click.ParamType):
|
||||
"""Parses comma-separated audio codecs like 'AAC,EC3'."""
|
||||
|
||||
name = "audio_codec_list"
|
||||
|
||||
def __init__(self, codec_enum):
|
||||
self.codec_enum = codec_enum
|
||||
self._name_to_codec: dict[str, Audio.Codec] = {}
|
||||
for codec in codec_enum:
|
||||
self._name_to_codec[codec.name.lower()] = codec
|
||||
self._name_to_codec[codec.value.lower()] = codec
|
||||
|
||||
aliases = {
|
||||
"eac3": "EC3",
|
||||
"ddp": "EC3",
|
||||
"vorbis": "OGG",
|
||||
}
|
||||
for alias, target in aliases.items():
|
||||
if target in codec_enum.__members__:
|
||||
self._name_to_codec[alias] = codec_enum[target]
|
||||
|
||||
def convert(self, value: Any, param: Optional[click.Parameter] = None, ctx: Optional[click.Context] = None) -> list:
|
||||
if not value:
|
||||
return []
|
||||
if isinstance(value, self.codec_enum):
|
||||
return [value]
|
||||
if isinstance(value, list):
|
||||
if all(isinstance(v, self.codec_enum) for v in value):
|
||||
return value
|
||||
values = [str(v).strip() for v in value]
|
||||
else:
|
||||
values = [v.strip() for v in str(value).split(",")]
|
||||
|
||||
codecs = []
|
||||
for val in values:
|
||||
if not val:
|
||||
continue
|
||||
key = val.lower()
|
||||
if key in self._name_to_codec:
|
||||
codecs.append(self._name_to_codec[key])
|
||||
else:
|
||||
valid = sorted(set(self._name_to_codec.keys()))
|
||||
self.fail(f"'{val}' is not valid. Choices: {', '.join(valid)}", param, ctx)
|
||||
return list(dict.fromkeys(codecs)) # Remove duplicates, preserve order
|
||||
|
||||
|
||||
class MultipleChoice(click.Choice):
|
||||
"""
|
||||
The multiple choice type allows multiple values to be checked against
|
||||
@@ -288,5 +336,6 @@ class MultipleChoice(click.Choice):
|
||||
SEASON_RANGE = SeasonRange()
|
||||
LANGUAGE_RANGE = LanguageRange()
|
||||
QUALITY_LIST = QualityList()
|
||||
AUDIO_CODEC_LIST = AudioCodecList(Audio.Codec)
|
||||
|
||||
# VIDEO_CODEC_CHOICE will be created dynamically when imported
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import click
|
||||
import sys
|
||||
from rich.console import Group
|
||||
from rich.live import Live
|
||||
from rich.padding import Padding
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
from envied.core.console import console
|
||||
|
||||
IS_WINDOWS = sys.platform == "win32"
|
||||
if IS_WINDOWS:
|
||||
import msvcrt
|
||||
|
||||
class Selector:
|
||||
"""
|
||||
A custom interactive selector class using the Rich library.
|
||||
Allows for multi-selection of items with pagination.
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
options: list[str],
|
||||
cursor_style: str = "pink",
|
||||
text_style: str = "text",
|
||||
page_size: int = 8,
|
||||
minimal_count: int = 0,
|
||||
dependencies: dict[int, list[int]] = None,
|
||||
prefixes: list[str] = None
|
||||
):
|
||||
"""
|
||||
Initialize the Selector.
|
||||
|
||||
Args:
|
||||
options: List of strings to select from.
|
||||
cursor_style: Rich style for the highlighted cursor item.
|
||||
text_style: Rich style for normal items.
|
||||
page_size: Number of items to show per page.
|
||||
minimal_count: Minimum number of items that must be selected.
|
||||
dependencies: Dictionary mapping parent index to list of child indices.
|
||||
"""
|
||||
self.options = options
|
||||
self.cursor_style = cursor_style
|
||||
self.text_style = text_style
|
||||
self.page_size = page_size
|
||||
self.minimal_count = minimal_count
|
||||
self.dependencies = dependencies or {}
|
||||
|
||||
self.cursor_index = 0
|
||||
self.selected_indices = set()
|
||||
self.scroll_offset = 0
|
||||
|
||||
def get_renderable(self):
|
||||
"""
|
||||
Constructs and returns the renderable object (Table + Info) for the current state.
|
||||
"""
|
||||
table = Table(show_header=False, show_edge=False, box=None, pad_edge=False, padding=(0, 1, 0, 0))
|
||||
table.add_column("Indicator", justify="right", no_wrap=True)
|
||||
table.add_column("Option", overflow="ellipsis", no_wrap=True)
|
||||
|
||||
for i in range(self.page_size):
|
||||
idx = self.scroll_offset + i
|
||||
|
||||
if idx < len(self.options):
|
||||
option = self.options[idx]
|
||||
is_cursor = (idx == self.cursor_index)
|
||||
is_selected = (idx in self.selected_indices)
|
||||
|
||||
symbol = "[X]" if is_selected else "[ ]"
|
||||
style = self.cursor_style if is_cursor else self.text_style
|
||||
indicator_text = Text(f"{symbol}", style=style)
|
||||
|
||||
content_text = Text.from_markup(option)
|
||||
content_text.style = style
|
||||
|
||||
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
|
||||
current_page = (self.scroll_offset // self.page_size) + 1
|
||||
|
||||
info_text = Text(
|
||||
f"\n[Space]: Toggle [a]: All [←/→]: Page [Enter]: Confirm (Page {current_page}/{total_pages})",
|
||||
style="gray"
|
||||
)
|
||||
|
||||
return Padding(Group(table, info_text), (0, 5))
|
||||
|
||||
def move_cursor(self, delta: int):
|
||||
"""
|
||||
Moves the cursor up or down by the specified delta.
|
||||
Updates the scroll offset if the cursor moves out of the current view.
|
||||
"""
|
||||
self.cursor_index = (self.cursor_index + delta) % len(self.options)
|
||||
new_page_idx = self.cursor_index // self.page_size
|
||||
self.scroll_offset = new_page_idx * self.page_size
|
||||
|
||||
def change_page(self, delta: int):
|
||||
"""
|
||||
Changes the current page view by the specified delta (previous/next page).
|
||||
Also moves the cursor to the first item of the new page.
|
||||
"""
|
||||
current_page = self.scroll_offset // self.page_size
|
||||
total_pages = (len(self.options) + self.page_size - 1) // self.page_size
|
||||
new_page = current_page + delta
|
||||
|
||||
if 0 <= new_page < total_pages:
|
||||
self.scroll_offset = new_page * self.page_size
|
||||
first_idx_of_page = self.scroll_offset
|
||||
if first_idx_of_page < len(self.options):
|
||||
self.cursor_index = first_idx_of_page
|
||||
else:
|
||||
self.cursor_index = len(self.options) - 1
|
||||
|
||||
def toggle_selection(self):
|
||||
"""
|
||||
Toggles the selection state of the item currently under the cursor.
|
||||
Propagates selection to children if defined in dependencies.
|
||||
"""
|
||||
target_indices = {self.cursor_index}
|
||||
|
||||
if self.cursor_index in self.dependencies:
|
||||
target_indices.update(self.dependencies[self.cursor_index])
|
||||
|
||||
should_select = self.cursor_index not in self.selected_indices
|
||||
|
||||
if should_select:
|
||||
self.selected_indices.update(target_indices)
|
||||
else:
|
||||
self.selected_indices.difference_update(target_indices)
|
||||
|
||||
def toggle_all(self):
|
||||
"""
|
||||
Toggles the selection of all items.
|
||||
If all are selected, clears selection. Otherwise, selects all.
|
||||
"""
|
||||
if len(self.selected_indices) == len(self.options):
|
||||
self.selected_indices.clear()
|
||||
else:
|
||||
self.selected_indices = set(range(len(self.options)))
|
||||
|
||||
def get_input_windows(self):
|
||||
"""
|
||||
Captures and parses keyboard input on Windows systems using msvcrt.
|
||||
Returns command strings like 'UP', 'DOWN', 'ENTER', etc.
|
||||
"""
|
||||
key = msvcrt.getch()
|
||||
if key == b'\x03' or key == b'\x1b':
|
||||
return 'CANCEL'
|
||||
if key == b'\xe0' or key == b'\x00':
|
||||
try:
|
||||
key = msvcrt.getch()
|
||||
if key == b'H': return 'UP'
|
||||
if key == b'P': return 'DOWN'
|
||||
if key == b'K': return 'LEFT'
|
||||
if key == b'M': return 'RIGHT'
|
||||
except: pass
|
||||
|
||||
try: char = key.decode('utf-8', errors='ignore')
|
||||
except: return None
|
||||
|
||||
if char in ('\r', '\n'): return 'ENTER'
|
||||
if char == ' ': return 'SPACE'
|
||||
if char in ('q', 'Q'): return 'QUIT'
|
||||
if char in ('a', 'A'): return 'ALL'
|
||||
if char in ('w', 'W', 'k', 'K'): return 'UP'
|
||||
if char in ('s', 'S', 'j', 'J'): return 'DOWN'
|
||||
if char in ('h', 'H'): return 'LEFT'
|
||||
if char in ('d', 'D', 'l', 'L'): return 'RIGHT'
|
||||
return None
|
||||
|
||||
def get_input_unix(self):
|
||||
"""
|
||||
Captures and parses keyboard input on Unix/Linux systems using click.getchar().
|
||||
Returns command strings like 'UP', 'DOWN', 'ENTER', etc.
|
||||
"""
|
||||
char = click.getchar()
|
||||
if char == '\x03':
|
||||
return 'CANCEL'
|
||||
mapping = {
|
||||
'\x1b[A': 'UP',
|
||||
'\x1b[B': 'DOWN',
|
||||
'\x1b[C': 'RIGHT',
|
||||
'\x1b[D': 'LEFT',
|
||||
}
|
||||
if char in mapping:
|
||||
return mapping[char]
|
||||
if char == '\x1b':
|
||||
try:
|
||||
next1 = click.getchar()
|
||||
if next1 in ('[', 'O'):
|
||||
next2 = click.getchar()
|
||||
if next2 == 'A': return 'UP'
|
||||
if next2 == 'B': return 'DOWN'
|
||||
if next2 == 'C': return 'RIGHT'
|
||||
if next2 == 'D': return 'LEFT'
|
||||
return 'CANCEL'
|
||||
except:
|
||||
return 'CANCEL'
|
||||
|
||||
if char in ('\r', '\n'): return 'ENTER'
|
||||
if char == ' ': return 'SPACE'
|
||||
if char in ('q', 'Q'): return 'QUIT'
|
||||
if char in ('a', 'A'): return 'ALL'
|
||||
if char in ('w', 'W', 'k', 'K'): return 'UP'
|
||||
if char in ('s', 'S', 'j', 'J'): return 'DOWN'
|
||||
if char in ('h', 'H'): return 'LEFT'
|
||||
if char in ('d', 'D', 'l', 'L'): return 'RIGHT'
|
||||
return None
|
||||
|
||||
def run(self) -> list[int]:
|
||||
"""
|
||||
Starts the main event loop for the selector.
|
||||
Renders the UI and processes input until confirmed or cancelled.
|
||||
|
||||
Returns:
|
||||
list[int]: A sorted list of selected indices.
|
||||
"""
|
||||
try:
|
||||
with Live(self.get_renderable(), console=console, auto_refresh=False, transient=True) as live:
|
||||
while True:
|
||||
live.update(self.get_renderable(), refresh=True)
|
||||
if IS_WINDOWS: action = self.get_input_windows()
|
||||
else: action = self.get_input_unix()
|
||||
|
||||
if action == 'UP': self.move_cursor(-1)
|
||||
elif action == 'DOWN': self.move_cursor(1)
|
||||
elif action == 'LEFT': self.change_page(-1)
|
||||
elif action == 'RIGHT': self.change_page(1)
|
||||
elif action == 'SPACE': self.toggle_selection()
|
||||
elif action == 'ALL': self.toggle_all()
|
||||
elif action in ('ENTER', 'QUIT'):
|
||||
if len(self.selected_indices) >= self.minimal_count:
|
||||
return sorted(list(self.selected_indices))
|
||||
elif action == 'CANCEL': raise KeyboardInterrupt
|
||||
except KeyboardInterrupt:
|
||||
return []
|
||||
|
||||
def select_multiple(
|
||||
options: list[str],
|
||||
minimal_count: int = 1,
|
||||
page_size: int = 8,
|
||||
return_indices: bool = True,
|
||||
cursor_style: str = "pink",
|
||||
**kwargs
|
||||
) -> list[int]:
|
||||
"""
|
||||
Drop-in replacement using custom Selector with global console.
|
||||
|
||||
Args:
|
||||
options: List of options to display.
|
||||
minimal_count: Minimum number of selections required.
|
||||
page_size: Number of items per page.
|
||||
return_indices: If True, returns indices; otherwise returns the option strings.
|
||||
cursor_style: Style color for the cursor.
|
||||
"""
|
||||
selector = Selector(
|
||||
options=options,
|
||||
cursor_style=cursor_style,
|
||||
text_style="text",
|
||||
page_size=page_size,
|
||||
minimal_count=minimal_count,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
selected_indices = selector.run()
|
||||
|
||||
if return_indices:
|
||||
return selected_indices
|
||||
return [options[i] for i in selected_indices]
|
||||
@@ -168,6 +168,16 @@ def merge_segmented_webvtt(vtt_raw: str, segment_durations: Optional[list[int]]
|
||||
duplicate_index: list[int] = []
|
||||
captions = vtt.get_captions(lang)
|
||||
|
||||
# Some providers can produce "segment_index" values that are
|
||||
# outside the provided segment_durations list after normalization/merge.
|
||||
# This used to crash with IndexError and abort the entire download.
|
||||
if segment_durations and captions:
|
||||
max_idx = max(getattr(c, "segment_index", 0) for c in captions)
|
||||
if max_idx >= len(segment_durations):
|
||||
# Pad with the last known duration (or 0 if empty) so indexing is safe.
|
||||
pad_val = segment_durations[-1] if segment_durations else 0
|
||||
segment_durations = segment_durations + [pad_val] * (max_idx - len(segment_durations) + 1)
|
||||
|
||||
if captions[0].segment_index == 0:
|
||||
first_segment_mpegts = captions[0].mpegts
|
||||
else:
|
||||
@@ -179,6 +189,9 @@ def merge_segmented_webvtt(vtt_raw: str, segment_durations: Optional[list[int]]
|
||||
# calculate the timestamp from SegmentTemplate/SegmentList duration.
|
||||
likely_dash = first_segment_mpegts == 0 and caption.mpegts == 0
|
||||
if likely_dash and segment_durations:
|
||||
# Defensive: segment_index can still be out of range if captions are malformed.
|
||||
if caption.segment_index < 0 or caption.segment_index >= len(segment_durations):
|
||||
continue
|
||||
duration = segment_durations[caption.segment_index]
|
||||
caption.mpegts = MPEG_TIMESCALE * (duration / timescale)
|
||||
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import click
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from collections.abc import Generator
|
||||
from http.cookiejar import CookieJar
|
||||
from typing import Any, Optional, Union, List
|
||||
from langcodes import Language
|
||||
|
||||
import click
|
||||
from click import Context
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime
|
||||
from http.cookiejar import CookieJar
|
||||
from langcodes import Language
|
||||
from pyplayready.cdm import Cdm as PlayReadyCdm
|
||||
from requests import Request
|
||||
from typing import Any, Optional, Union, List
|
||||
|
||||
from envied.core.constants import AnyTrack
|
||||
from envied.core.credential import Credential
|
||||
from envied.core.manifests import HLS
|
||||
from envied.core.search_result import SearchResult
|
||||
from envied.core.service import Service
|
||||
from envied.core.manifests import HLS
|
||||
from envied.core.titles import Title_T, Titles_T, Episode, Movie, Movies, Series
|
||||
from envied.core.tracks import Chapter, Chapters, Tracks, Attachment, Video, Audio, Subtitle
|
||||
from envied.core.utils.collections import as_list
|
||||
from envied.core.utilities import get_ip_info
|
||||
from envied.core.utils.collections import as_list
|
||||
|
||||
from . import queries
|
||||
|
||||
@@ -46,11 +48,12 @@ class DSNP(Service):
|
||||
@click.argument("title", type=str)
|
||||
@click.option("--imax", is_flag=True, default=False, help="Prefer IMAX Enhanced version if available.")
|
||||
@click.option("--remastered-ar", is_flag=True, default=False, help="Prefer Remastered Aspect Ratio if available.")
|
||||
@click.option("-ext", "--extras", is_flag=True, default=False, help="Select a extras video if available.")
|
||||
@click.pass_context
|
||||
def cli(ctx: Context, **kwargs: Any) -> DSNP:
|
||||
return DSNP(ctx, **kwargs)
|
||||
|
||||
def __init__(self, ctx: Context, title: str, imax: bool, remastered_ar: bool):
|
||||
def __init__(self, ctx: Context, title: str, imax: bool, remastered_ar: bool, extras: bool):
|
||||
self.title = title
|
||||
super().__init__(ctx)
|
||||
|
||||
@@ -61,8 +64,9 @@ class DSNP(Service):
|
||||
self.title_id = match.group("id")
|
||||
break
|
||||
|
||||
self.prefer_imax = imax or False
|
||||
self.prefer_remastered_ar = remastered_ar or False
|
||||
self.prefer_imax = imax
|
||||
self.prefer_remastered_ar = remastered_ar
|
||||
self.extras = extras
|
||||
|
||||
self.vcodec = ctx.parent.params.get("vcodec") or Video.Codec.AVC
|
||||
self.acodec : Audio.Codec = ctx.parent.params.get("acodec")
|
||||
@@ -74,8 +78,8 @@ class DSNP(Service):
|
||||
self.chapters_only = ctx.parent.params.get("chapters_only")
|
||||
|
||||
self.cdm = ctx.obj.cdm
|
||||
self.playready = isinstance(self.cdm, PlayReadyCdm)
|
||||
self.is_l3 = (self.cdm.security_level < 3000) if self.playready else (self.cdm.security_level == 3)
|
||||
self.playready = isinstance(self.cdm, PlayReadyCdm) if self.cdm else False
|
||||
self.is_l3 = (self.cdm.security_level < 3000) if self.playready else (self.cdm.security_level == 3) if self.cdm else False
|
||||
|
||||
self.region = None
|
||||
self.prod_config = {}
|
||||
@@ -91,13 +95,13 @@ class DSNP(Service):
|
||||
self.quality = [720]
|
||||
self.log.warning(" + Switched video to HD. This CDM only support HD.")
|
||||
else:
|
||||
if self.quality > [1080] and self.range == [Video.Range.SDR]:
|
||||
if self.quality > [1080] and self.range[0] == [Video.Range.SDR]:
|
||||
self.range = [Video.Range.HDR10]
|
||||
self.log.info(" + Switched range to HDR10. 4K resolution requires HDR.")
|
||||
|
||||
if (self.range != [Video.Range.SDR] or self.quality > [1080]) and self.vcodec != Video.Codec.HEVC:
|
||||
self.vcodec = Video.Codec.HEVC
|
||||
self.log.info(f" + Switched video codec to H265 to be able to get {self.range} dynamic range.")
|
||||
self.log.info(f" + Switched video codec to H265 to be able to get {self.range[0]} dynamic range.")
|
||||
|
||||
if self.acodec == Audio.Codec.DTS and not self.prefer_imax:
|
||||
self.prefer_imax = True
|
||||
@@ -164,7 +168,7 @@ class DSNP(Service):
|
||||
self._perform_switch_profile(target_profile, self.session.headers)
|
||||
|
||||
self.log.info(" + Refreshing session data after profile switch...")
|
||||
full_account_info = self._get_account_info_raw()
|
||||
full_account_info = self._get_account_info()
|
||||
self.active_session = full_account_info["activeSession"]
|
||||
self.active_session['account'] = full_account_info['account']
|
||||
self.log.info("Session data updated successfully.")
|
||||
@@ -193,12 +197,14 @@ class DSNP(Service):
|
||||
current_imax_setting = full_profile_object["attributes"]["playbackSettings"]["preferImaxEnhancedVersion"]
|
||||
self.log.info(f" + IMAX Enhanced: {current_imax_setting}")
|
||||
if current_imax_setting is not self.prefer_imax:
|
||||
self._set_imax_preference(self.prefer_imax)
|
||||
update_tokens = self._set_imax_preference(self.prefer_imax)
|
||||
self._apply_new_tokens(update_tokens["token"])
|
||||
|
||||
current_133_setting = full_profile_object["attributes"]["playbackSettings"]["prefer133"] # Original Aspect Ratio
|
||||
self.log.info(f" + Remastered Aspect Ratio: {not current_133_setting}")
|
||||
if not current_133_setting is not self.prefer_remastered_ar:
|
||||
self._set_remastered_ar_preference(self.prefer_remastered_ar)
|
||||
update_tokens = self._set_remastered_ar_preference(self.prefer_remastered_ar)
|
||||
self._apply_new_tokens(update_tokens["token"])
|
||||
|
||||
def _login(self) -> None:
|
||||
cache = self.cache.get(f"tokens_{self.region}_{self.credentials.sha1}")
|
||||
@@ -208,7 +214,7 @@ class DSNP(Service):
|
||||
self.log.info(" + Using cached tokens...")
|
||||
self.account_tokens = cache.data
|
||||
|
||||
bearer = self.account_tokens["token"]["accessToken"]
|
||||
bearer = self.account_tokens["accessToken"]
|
||||
if not bearer:
|
||||
raise ValueError("accessToken not found in cache")
|
||||
self.session.headers.update({'Authorization': f'Bearer {bearer}'})
|
||||
@@ -231,19 +237,45 @@ class DSNP(Service):
|
||||
self._perform_full_login()
|
||||
|
||||
self.log.info(" + Fetching session data...")
|
||||
full_account_info = self._get_account_info_raw()
|
||||
full_account_info = self._get_account_info()
|
||||
self.active_session = full_account_info["activeSession"]
|
||||
self.active_session['account'] = full_account_info['account']
|
||||
self.log.info("Session data setup successfully.")
|
||||
|
||||
def _perform_full_login(self) -> None:
|
||||
device_token = self._register_device()
|
||||
android_id = secrets.token_bytes(8).hex()
|
||||
drm_id = f"{base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8')}\n"
|
||||
device_token = self._register_device(android_id, drm_id)
|
||||
|
||||
email_status = self._check_email(self.credentials.username, device_token)
|
||||
|
||||
if email_status.lower() != "login":
|
||||
if email_status.lower() == "OTP":
|
||||
self.log.error(" - Account requires 2FA passcode.", exc_info=False)
|
||||
sys.exit(1)
|
||||
if email_status.lower() == "otp":
|
||||
self.log.warning(" - Account requires OTP code login.")
|
||||
self._request_otp(self.credentials.username, device_token)
|
||||
|
||||
otp_code = None
|
||||
try:
|
||||
otp_code = input("Enter a OTP code (Check email): ")
|
||||
if not otp_code:
|
||||
self.log.error(" - OTP code is required, but no value was entered.", exc_info=False)
|
||||
sys.exit(1)
|
||||
if not otp_code.isdigit():
|
||||
self.log.error(" - Invalid OTP code. Please enter only numbers.", exc_info=False)
|
||||
sys.exit(1)
|
||||
if len(otp_code) < 6:
|
||||
self.log.error(" - OTP code is too short. Please enter at least 6 digits.", exc_info=False)
|
||||
sys.exit(1)
|
||||
if len(otp_code) > 6:
|
||||
self.log.warning(" - OTP code is longer than 6 digits. Using the first 6 digits.")
|
||||
otp_code = otp_code[:6]
|
||||
except KeyboardInterrupt:
|
||||
self.log.error("\n - OTP code input cancelled by user.", exc_info=False)
|
||||
sys.exit(1)
|
||||
|
||||
auth_action = self._auth_action_with_otp(self.credentials.username, otp_code, device_token)
|
||||
login_tokens = self._login_with_auth_action(auth_action, device_token)
|
||||
|
||||
elif email_status.lower() == "register":
|
||||
self.log.error(" - Account is not registered. Please register first.", exc_info=False)
|
||||
sys.exit(1)
|
||||
@@ -251,10 +283,11 @@ class DSNP(Service):
|
||||
self.log.error(f" - Email status is '{email_status}'. Account status verification required.", exc_info=False)
|
||||
sys.exit(1)
|
||||
|
||||
login_tokens = self._login_with_password(self.credentials.username, self.credentials.password, device_token)
|
||||
else:
|
||||
login_tokens = self._login_with_password(self.credentials.username, self.credentials.password, device_token)
|
||||
|
||||
temp_auth_header = {"Authorization": f'Bearer {login_tokens["accessToken"]}'}
|
||||
account_info = self._get_account_info_raw(temp_auth_header)
|
||||
temp_auth_header = {"Authorization": f'Bearer {login_tokens["token"]["accessToken"]}'}
|
||||
account_info = self._get_account_info(temp_auth_header)
|
||||
profiles = account_info["account"]["profiles"]
|
||||
|
||||
selected_profile = None
|
||||
@@ -308,10 +341,8 @@ class DSNP(Service):
|
||||
self.log.error("\n - PIN input cancelled by user.", exc_info=False)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
switch_profile_data = self._switch_profile(target_profile['id'], auth_headers, profile_pin)
|
||||
final_token_data = self._refresh_token(switch_profile_data["token"]["refreshToken"])
|
||||
self._apply_new_tokens(final_token_data)
|
||||
self._apply_new_tokens(switch_profile_data["token"])
|
||||
|
||||
def _refresh(self) -> str:
|
||||
cache = self.cache.get(f"tokens_{self.region}_{self.credentials.sha1}")
|
||||
@@ -321,23 +352,21 @@ class DSNP(Service):
|
||||
|
||||
self.log.warning(" + Token expired. Refreshing...")
|
||||
try:
|
||||
refreshed_data = self._refresh_token(self.account_tokens["token"]["refreshToken"])
|
||||
bearer = self._apply_new_tokens(refreshed_data)
|
||||
return bearer
|
||||
refreshed_data = self._refresh_token(self.account_tokens["refreshToken"])
|
||||
self._apply_new_tokens(refreshed_data["token"])
|
||||
except Exception as _:
|
||||
self.log.error("Refresh Token Expired", exc_info=False)
|
||||
sys.exit(1)
|
||||
raise Exception("Refresh Token Expired")
|
||||
|
||||
def _apply_new_tokens(self, token_data: dict) -> str:
|
||||
self.account_tokens = token_data
|
||||
|
||||
bearer = self.account_tokens["token"]["accessToken"]
|
||||
bearer = self.account_tokens["accessToken"]
|
||||
if not bearer:
|
||||
self.log.error("Invalid token data: accessToken not found.", exc_info=False)
|
||||
sys.exit(1)
|
||||
self.session.headers.update({'Authorization': f'Bearer {bearer}'})
|
||||
|
||||
expires_in = self.account_tokens["token"]["expiresIn"] or 3600
|
||||
expires_in = self.account_tokens["expiresIn"] or 3600
|
||||
cache = self.cache.get(f"tokens_{self.region}_{self.credentials.sha1}")
|
||||
cache.set(self.account_tokens, expires_in - 60)
|
||||
self.log.debug(f" + New Token is valid until: {datetime.fromtimestamp(cache.expiration.timestamp()).strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
@@ -363,64 +392,71 @@ class DSNP(Service):
|
||||
)
|
||||
|
||||
def get_titles(self) -> Titles_T:
|
||||
try:
|
||||
content_info = self._get_deeplink(self.title_id)
|
||||
content_type = content_info["data"]["deeplink"]["actions"][0]["contentType"]
|
||||
except Exception as e:
|
||||
if not self.extras:
|
||||
try:
|
||||
actions_info = self._get_deeplink_last(self.title_id)
|
||||
if actions_info["data"]["deeplink"]["actions"][0]["type"] == "browse":
|
||||
content_type = "other"
|
||||
self.log.warning(" - The content is not standard. however, it tries to look up the data.")
|
||||
content_info = self._get_deeplink(self.title_id)
|
||||
content_type = content_info["data"]["deeplink"]["actions"][0]["contentType"]
|
||||
except Exception as e:
|
||||
self.log.error(f" - Failed to determine content type via deeplink ({e}).", exc_info=False)
|
||||
sys.exit(1)
|
||||
try:
|
||||
actions_info = self._get_deeplink_last(self.title_id)
|
||||
if actions_info["data"]["deeplink"]["actions"][0]["type"] == "browse":
|
||||
info_block = base64.b64decode(actions_info["data"]["deeplink"]["actions"][0]["infoBlock"])
|
||||
if b"movie" in info_block:
|
||||
content_type = "movie"
|
||||
elif b"series" in info_block:
|
||||
content_type = "series"
|
||||
else:
|
||||
content_type = "other"
|
||||
self.log.warning(" - The content is not standard. however, it tries to look up the data.")
|
||||
except Exception as e:
|
||||
self.log.error(f" - Failed to determine content type via deeplink ({e}).", exc_info=False)
|
||||
sys.exit(1)
|
||||
else:
|
||||
content_type = "extras"
|
||||
self.log.debug(f" + Content Type: {content_type.upper()}")
|
||||
|
||||
page = self._get_page(self.title_id)
|
||||
|
||||
orig_lang = "en"
|
||||
if not content_type == "other":
|
||||
playback_action = next(x for x in page["actions"] if x["type"] == "playback")
|
||||
avail_id = playback_action["availId"]
|
||||
self.log.debug(f" + Avail ID: {avail_id}")
|
||||
lang_data = self._get_original_lang(avail_id)
|
||||
orig_lang = lang_data["data"]["playerExperience"]["originalLanguage"]
|
||||
year = None
|
||||
if year_data := page["visuals"]["metastringParts"].get("releaseYearRange"):
|
||||
year = year_data.get("startYear")
|
||||
|
||||
if content_type != "extras":
|
||||
playback_action = next(
|
||||
(x for x in page["actions"] if x["type"] == "playback"),
|
||||
None
|
||||
)
|
||||
if not playback_action:
|
||||
self.log.error(f" - No content is available. (Playback action not found)", exc_info=False)
|
||||
sys.exit(1)
|
||||
lang_data = self._get_original_lang(playback_action["availId"])
|
||||
player_exp = lang_data["data"]["playerExperience"]
|
||||
orig_lang = player_exp.get("originalLanguage") or player_exp.get("targetLanguage") or "en"
|
||||
self.log.debug(f' + Original Language: {orig_lang}')
|
||||
|
||||
if content_type == "movie":
|
||||
return Movies(
|
||||
[
|
||||
Movie(
|
||||
id_=page["id"],
|
||||
service=self.__class__,
|
||||
name=page["visuals"]["title"],
|
||||
year=page["visuals"]["metastringParts"]["releaseYearRange"]["startYear"],
|
||||
language=Language.get(orig_lang),
|
||||
data=page
|
||||
)
|
||||
]
|
||||
)
|
||||
if content_type in ("movie", "other"):
|
||||
return Movies([
|
||||
Movie(
|
||||
id_=page["id"],
|
||||
service=self.__class__,
|
||||
name=page["visuals"]["title"],
|
||||
year=year,
|
||||
language=Language.get(orig_lang),
|
||||
data=page
|
||||
)
|
||||
])
|
||||
|
||||
elif content_type == "series":
|
||||
return Series(self._get_series(page, orig_lang))
|
||||
return Series(self._get_series(page, year, orig_lang))
|
||||
|
||||
elif content_type == "extras":
|
||||
return Series(self._get_extras(page, year))
|
||||
|
||||
elif content_type == "other":
|
||||
return Movies(
|
||||
[
|
||||
Movie(
|
||||
id_=page["id"],
|
||||
service=self.__class__,
|
||||
name=page["visuals"]["title"],
|
||||
data=page
|
||||
)
|
||||
]
|
||||
)
|
||||
else:
|
||||
self.log.error(f" - Unsupported content type: {content_type}", exc_info=False)
|
||||
sys.exit(1)
|
||||
|
||||
def _get_series(self, page: dict, orig_lang: str) -> Series:
|
||||
def _get_series(self, page: dict, year: int, orig_lang: str) -> Series:
|
||||
container = next(x for x in page["containers"] if x["type"] == "episodes")
|
||||
season_ids = [s["id"] for s in container["seasons"]]
|
||||
|
||||
@@ -440,13 +476,62 @@ class DSNP(Service):
|
||||
season=int(ep["visuals"]["seasonNumber"]),
|
||||
number=int(ep["visuals"]["episodeNumber"]),
|
||||
name=ep["visuals"]["episodeTitle"],
|
||||
year=page["visuals"]["metastringParts"]["releaseYearRange"]["startYear"],
|
||||
year=year,
|
||||
language=Language.get(orig_lang),
|
||||
data=ep
|
||||
)
|
||||
)
|
||||
|
||||
return episodes
|
||||
|
||||
def _get_extras(self, page: dict, year: int) -> Series:
|
||||
extras_containers = [
|
||||
x for x in page["containers"]
|
||||
if x["type"] == "set" and x["style"]["name"] == "standard_compact_list"
|
||||
]
|
||||
|
||||
if not extras_containers:
|
||||
self.log.error(" - No extras found.", exc_info=False)
|
||||
sys.exit(1)
|
||||
|
||||
extras_episodes : List[Episode] = []
|
||||
ep_count = 1
|
||||
|
||||
first_item = extras_containers[0]["items"][0]
|
||||
first_action = next((x for x in first_item["actions"] if x["type"] in ("playback", "trailer")), None)
|
||||
if first_action:
|
||||
lang_data = self._get_original_lang(first_action["availId"])
|
||||
player_exp = lang_data["data"]["playerExperience"]
|
||||
orig_lang = player_exp.get("originalLanguage") or player_exp.get("targetLanguage") or "en"
|
||||
self.log.debug(f' + Original Language: {orig_lang}')
|
||||
|
||||
for container in extras_containers:
|
||||
items = container["items"]
|
||||
for item in items:
|
||||
if item["type"] == "view":
|
||||
action = next((x for x in item["actions"] if x["type"] in ("playback", "trailer")), None)
|
||||
|
||||
if action:
|
||||
extras_episodes.append(
|
||||
Episode(
|
||||
id_=item["id"],
|
||||
service=self.__class__,
|
||||
title=page["visuals"]["title"],
|
||||
season=0, # Special
|
||||
number=ep_count,
|
||||
name=item["visuals"]["title"],
|
||||
year=year,
|
||||
language=Language.get(orig_lang),
|
||||
data=item
|
||||
)
|
||||
)
|
||||
ep_count += 1
|
||||
|
||||
if not extras_episodes:
|
||||
self.log.error(" - No playable extras found.", exc_info=False)
|
||||
sys.exit(1)
|
||||
|
||||
return extras_episodes
|
||||
|
||||
def get_tracks(self, title: Title_T) -> Tracks:
|
||||
playback = next(x for x in title.data["actions"] if x.get("type") == "playback")
|
||||
@@ -462,7 +547,7 @@ class DSNP(Service):
|
||||
|
||||
self._refresh() # Safe Access
|
||||
|
||||
if Video.Range.HYBRID in self.range and not self.is_l3:
|
||||
if Video.Range.HYBRID in self.range[0] and not self.is_l3:
|
||||
self.log.warning("DV+HDR Multi-range requested.")
|
||||
|
||||
self.log.info(" + Fetching Dolby Vision tracks...")
|
||||
@@ -475,15 +560,14 @@ class DSNP(Service):
|
||||
else:
|
||||
video_ranges = []
|
||||
if not self.is_l3:
|
||||
if Video.Range.DV in self.range:
|
||||
if Video.Range.DV in self.range[0]:
|
||||
video_ranges = ["DOLBY_VISION"]
|
||||
elif Video.Range.HDR10 in self.range or Video.Range.HDR10P in self.range:
|
||||
elif Video.Range.HDR10 in self.range[0] or Video.Range.HDR10P in self.range[0]:
|
||||
video_ranges = ["HDR10"] # HDR10PLUS
|
||||
|
||||
tracks = self._fetch_manifest_tracks(title, media_id, scenario, video_ranges or None)
|
||||
|
||||
tracks.add(self._get_thumbnail(title))
|
||||
|
||||
return self._post_process_tracks(tracks)
|
||||
|
||||
def _fetch_manifest_tracks(self, title: Title_T, media_id: str, scenario: str, video_ranges: List[str] = None) -> Tracks:
|
||||
@@ -494,8 +578,13 @@ class DSNP(Service):
|
||||
},
|
||||
"protocol": "HTTPS",
|
||||
"frameRates": [60],
|
||||
"assetInsertionStrategy": "SGAI", # Server-Guided Ad Insertion
|
||||
"playbackInitiationContext": "ONLINE"
|
||||
"assetInsertionStrategies": {
|
||||
"point": "SGAI", # Server-Guided Ad Insertion
|
||||
"range": "SGAI" # Server-Guided Ad Insertion
|
||||
},
|
||||
"playbackInitiationContext": "ONLINE",
|
||||
"slugDuration": "SLUG_500_MS", # SLUG_1000_MS, SLUG_750_MS ?
|
||||
"maxSlideDuration": "4_HOUR" # 15_MIN ?
|
||||
}
|
||||
|
||||
if self.is_l3:
|
||||
@@ -517,9 +606,9 @@ class DSNP(Service):
|
||||
"attributes": attributes
|
||||
}
|
||||
}
|
||||
self.playback_data[title.id] = self._get_video(scenario, payload)
|
||||
|
||||
self.playback_data[title.id] = self._get_playback(scenario, payload)
|
||||
manifest_url = self.playback_data[title.id]["sources"][0]['complete']['url']
|
||||
self.log.debug(f" + Manifest URL: {manifest_url}")
|
||||
return HLS.from_url(url=manifest_url, session=self.session).to_tracks(title.language)
|
||||
|
||||
def _get_thumbnail(self, title: Title_T) -> Attachment:
|
||||
@@ -548,67 +637,39 @@ class DSNP(Service):
|
||||
audio.bitrate = 768_000 # DSNP lies about the Atmos bitrate
|
||||
if audio.channels == 6.0:
|
||||
audio.channels = 5.1
|
||||
if audio.channels == 10.0: # DTS-UHD
|
||||
audio.channels = "5.1.4" # Unshackle does not recommend
|
||||
audio.codec = Audio.Codec.DTS
|
||||
audio.drm = None
|
||||
|
||||
for subtitle in tracks.subtitles:
|
||||
subtitle.codec = Subtitle.Codec.WebVTT
|
||||
|
||||
return tracks
|
||||
|
||||
|
||||
def get_chapters(self, title: Title_T) -> Chapters:
|
||||
try:
|
||||
editorial = self.playback_data[title.id]["editorial"]
|
||||
|
||||
if not editorial:
|
||||
return []
|
||||
return Chapters()
|
||||
|
||||
label_to_group = {
|
||||
LABEL_MAP = {
|
||||
"intro_start": "intro_start",
|
||||
"FFEI": "intro_start", # First Frame Episode Intro
|
||||
"intro_end": "intro_end",
|
||||
"LFEI": "intro_end", # Last Frame Episode Intro
|
||||
"recap_start": "recap_start",
|
||||
"FFER": "recap_start", # First Frame Episode Recap
|
||||
"recap_end": "recap_end",
|
||||
"LFER": "recap_end", # Last Frame Episode Recap
|
||||
"FFER": "recap_start", # First Frame Episode Recap
|
||||
"LFER": "recap_end", # Last Frame Episode Recap
|
||||
"FFEI": "intro_start", # First Frame Episode Intro
|
||||
"LFEI": "intro_end", # Last Frame Episode Intro
|
||||
"FFEC": "credits_start", # First Frame End Credits
|
||||
"LFEC": "lfec_marker", # Last Frame End Credits
|
||||
"FFCB": None, # First Frame Credits Bumper
|
||||
"LFCB": None, # Last Frame Credits Bumper
|
||||
"up_next": None,
|
||||
"tag_start": None,
|
||||
"tag_end": None,
|
||||
}
|
||||
|
||||
# Collision Correction
|
||||
grouped_timestamps = {}
|
||||
for marker in editorial:
|
||||
label = marker.get("label")
|
||||
group = label_to_group.get(label)
|
||||
if group:
|
||||
timestamp = marker.get("offsetMillis")
|
||||
if timestamp is not None:
|
||||
if group not in grouped_timestamps:
|
||||
grouped_timestamps[group] = []
|
||||
grouped_timestamps[group].append(timestamp)
|
||||
|
||||
resolved_markers = []
|
||||
for group, timestamps in grouped_timestamps.items():
|
||||
if not timestamps:
|
||||
continue
|
||||
|
||||
final_timestamp = 0
|
||||
if "start" in group:
|
||||
final_timestamp = min(timestamps)
|
||||
elif "end" in group:
|
||||
final_timestamp = max(timestamps)
|
||||
else:
|
||||
final_timestamp = timestamps[0]
|
||||
|
||||
resolved_markers.append({"group": group, "ms": final_timestamp})
|
||||
|
||||
# Create Chapter Data
|
||||
raw_chapter_data = []
|
||||
group_to_name = {
|
||||
NAME_MAP = {
|
||||
"recap_start": "Recap",
|
||||
"recap_end": "Scene",
|
||||
"intro_start": "Intro",
|
||||
@@ -616,66 +677,63 @@ class DSNP(Service):
|
||||
"credits_start": "Credits",
|
||||
}
|
||||
|
||||
total_runtime_ms = 0
|
||||
if "visuals" in title.data and "metastringParts" in title.data["visuals"]:
|
||||
total_runtime_ms = title.data["visuals"]["metastringParts"]["runtime"]["runtimeMs"]
|
||||
grouped = {}
|
||||
for marker in editorial:
|
||||
group = LABEL_MAP.get(marker["label"])
|
||||
offset = marker["offsetMillis"]
|
||||
if group and offset is not None:
|
||||
grouped.setdefault(group, []).append(offset)
|
||||
|
||||
for marker in resolved_markers:
|
||||
group = marker["group"]
|
||||
timestamp_ms = marker["ms"]
|
||||
name = None
|
||||
raw_chapters = []
|
||||
total_runtime = title.data["visuals"]["metastringParts"]["runtime"]["runtimeMs"]
|
||||
|
||||
if group == "lfec_marker":
|
||||
if total_runtime_ms and (total_runtime_ms - timestamp_ms) > 5000: # 5 sec
|
||||
name = "Scene"
|
||||
else:
|
||||
name = group_to_name.get(group)
|
||||
for group, times in grouped.items():
|
||||
if not times: continue
|
||||
|
||||
timestamp = min(times) if "start" in group else max(times) if "end" in group else times[0]
|
||||
name = NAME_MAP.get(group)
|
||||
|
||||
if group == "lfec_marker" and (total_runtime - timestamp) > 5000:
|
||||
name = "Scene"
|
||||
|
||||
if name:
|
||||
raw_chapter_data.append({"ms": timestamp_ms, "name": name})
|
||||
|
||||
# Sorting and deduplication in chronological order
|
||||
if not raw_chapter_data:
|
||||
return []
|
||||
raw_chapters.append((timestamp, name))
|
||||
|
||||
unique_chapters_data = []
|
||||
raw_chapters.sort(key=lambda x: x[0])
|
||||
unique_chapters = []
|
||||
seen_ms = set()
|
||||
for chap in sorted(raw_chapter_data, key=lambda x: x["ms"]):
|
||||
if chap["ms"] not in seen_ms:
|
||||
unique_chapters_data.append(chap)
|
||||
seen_ms.add(chap["ms"])
|
||||
|
||||
# Processe the First Chapter
|
||||
if not unique_chapters_data:
|
||||
unique_chapters_data.append({"ms": 0, "name": "Scene"})
|
||||
for ms, name in raw_chapters:
|
||||
if ms not in seen_ms:
|
||||
unique_chapters.append({"ms": ms, "name": name})
|
||||
seen_ms.add(ms)
|
||||
|
||||
if not unique_chapters:
|
||||
unique_chapters.append({"ms": 0, "name": "Scene"})
|
||||
else:
|
||||
first_chapter = unique_chapters_data[0]
|
||||
if first_chapter["ms"] > 0:
|
||||
if not (first_chapter["ms"] < 5000 and first_chapter["name"] in ["Intro", "Recap"]):
|
||||
unique_chapters_data.insert(0, {"ms": 0, "name": "Scene"})
|
||||
|
||||
if unique_chapters_data:
|
||||
first_chapter = unique_chapters_data[0]
|
||||
if first_chapter["name"] in ["Intro", "Recap"] and first_chapter["ms"] > 0:
|
||||
first_chapter["ms"] = 0
|
||||
first = unique_chapters[0]
|
||||
if first["ms"] > 0:
|
||||
if first["ms"] < 5000 and first["name"] in ("Intro", "Recap"):
|
||||
first["ms"] = 0
|
||||
else:
|
||||
unique_chapters.insert(0, {"ms": 0, "name": "Scene"})
|
||||
|
||||
# Create Final Chapter List
|
||||
final_chapters = []
|
||||
for i, chap_info in enumerate(unique_chapters_data):
|
||||
name = chap_info["name"]
|
||||
|
||||
final_chapters.append(
|
||||
chapters: List[Chapter] = []
|
||||
for i, chap_data in enumerate(unique_chapters):
|
||||
time_sec = chap_data["ms"] / 1000.000
|
||||
chapter_title = chap_data["name"]
|
||||
chapters.append(
|
||||
Chapter(
|
||||
timestamp=chap_info["ms"] / 1000.000,
|
||||
name=name if name != "Scene" else None
|
||||
timestamp=float(time_sec),
|
||||
name=chapter_title if chapter_title != "Scene" else None
|
||||
)
|
||||
)
|
||||
|
||||
return final_chapters
|
||||
return chapters
|
||||
|
||||
except Exception as e:
|
||||
self.log.warning(f"Failed to extract chapters: {e}")
|
||||
return []
|
||||
return Chapters()
|
||||
|
||||
def get_widevine_service_certificate(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Union[bytes, str]:
|
||||
# endpoint = self.prod_config["services"]["drm"]["client"]["endpoints"]["widevineCertificate"]["href"]
|
||||
@@ -691,7 +749,7 @@ class DSNP(Service):
|
||||
res = self.session.post(endpoint, headers=headers, data=challenge)
|
||||
res.raise_for_status()
|
||||
except Exception as e:
|
||||
self.log.error(f" - License request failed: {e}", exc_info=False)
|
||||
self.log.error(f"License request failed: {e}", exc_info=False)
|
||||
sys.exit(1)
|
||||
return res.content
|
||||
|
||||
@@ -707,7 +765,7 @@ class DSNP(Service):
|
||||
res = self.session.post(endpoint, headers=headers, data=challenge)
|
||||
res.raise_for_status()
|
||||
except Exception as e:
|
||||
self.log.error(f" - License request failed: {e}", exc_info=False)
|
||||
self.log.error(f"License request failed: {e}", exc_info=False)
|
||||
sys.exit(1)
|
||||
return res.content
|
||||
|
||||
@@ -753,7 +811,7 @@ class DSNP(Service):
|
||||
data = self._request("GET", endpoint, params={'limit': 999})["data"]["season"]["items"]
|
||||
return data
|
||||
|
||||
def _get_video(self, scenario: str, payload: dict) -> dict:
|
||||
def _get_playback(self, scenario: str, payload: dict) -> dict:
|
||||
endpoint = self._href(
|
||||
self.prod_config["services"]["media"]["client"]["endpoints"]["mediaPayload"]["href"],
|
||||
scenario=scenario
|
||||
@@ -765,7 +823,7 @@ class DSNP(Service):
|
||||
data = self._request("POST", endpoint, headers=headers, payload=payload)
|
||||
return data["stream"]
|
||||
|
||||
def _register_device(self) -> str:
|
||||
def _register_device(self, android_id: str, drm_id: str) -> str:
|
||||
endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["registerDevice"]["href"]
|
||||
headers = {
|
||||
"Authorization": self.config["bamsdk"]["api_key"],
|
||||
@@ -776,6 +834,16 @@ class DSNP(Service):
|
||||
"registerDevice": {
|
||||
"applicationRuntime": self.config["device"]["applicationRuntime"],
|
||||
"attributes": {
|
||||
"osDeviceIds": [
|
||||
{
|
||||
"identifier": android_id,
|
||||
"type": "android.vendor.id"
|
||||
},
|
||||
{
|
||||
"identifier": drm_id,
|
||||
"type": "android.drm.id"
|
||||
}
|
||||
],
|
||||
"operatingSystem": self.config["device"]["operatingSystem"],
|
||||
"operatingSystemVersion": self.config["device"]["operatingSystemVersion"]
|
||||
},
|
||||
@@ -797,7 +865,7 @@ class DSNP(Service):
|
||||
"X-BAMSDK-Platform-Id": self.config["device"]["platform_id"]
|
||||
}
|
||||
payload = {
|
||||
"operationName": "Check",
|
||||
"operationName": "check",
|
||||
"variables": {
|
||||
"email": email
|
||||
},
|
||||
@@ -805,7 +873,7 @@ class DSNP(Service):
|
||||
}
|
||||
data = self._request("POST", endpoint, payload=payload, headers=headers)
|
||||
return data["data"]["check"]["operations"][0]
|
||||
|
||||
|
||||
def _login_with_password(self, email: str, password: str, token: str) -> str:
|
||||
endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"]
|
||||
headers = {
|
||||
@@ -813,25 +881,88 @@ class DSNP(Service):
|
||||
"X-BAMSDK-Platform-Id": self.config["device"]["platform_id"]
|
||||
}
|
||||
payload = {
|
||||
"operationName": "loginTv",
|
||||
"operationName": "login",
|
||||
"variables": {
|
||||
"input": {
|
||||
"email": email,
|
||||
"password": password
|
||||
}
|
||||
},
|
||||
"includeIdentity": True,
|
||||
"includeAccountConsentToken": True
|
||||
},
|
||||
"query": queries.LOGIN
|
||||
}
|
||||
data = self._request("POST", endpoint, payload=payload, headers=headers)
|
||||
return data["extensions"]["sdk"]["token"]
|
||||
return data["extensions"]["sdk"]
|
||||
|
||||
def _request_otp(self, email: str, token: str) -> dict:
|
||||
endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"]
|
||||
headers = {
|
||||
"Authorization": token,
|
||||
"X-BAMSDK-Platform-Id": self.config["device"]["platform_id"]
|
||||
}
|
||||
payload = {
|
||||
"operationName": "requestOtp",
|
||||
"variables": {
|
||||
"input": {
|
||||
"email": email,
|
||||
"reason": "Login"
|
||||
}
|
||||
},
|
||||
"query": queries.REQUESET_OTP
|
||||
}
|
||||
data = self._request("POST", endpoint, payload=payload, headers=headers)
|
||||
if not data["data"]["requestOtp"]["accepted"]:
|
||||
self.log.error(" - OTP code request failed.", exc_info=False)
|
||||
sys.exit(1)
|
||||
|
||||
def _get_account_info_raw(self, headers: dict = {}) -> dict:
|
||||
def _auth_action_with_otp(self, email: str, otp: str, token: str) -> dict:
|
||||
endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"]
|
||||
headers = {
|
||||
"Authorization": token,
|
||||
"X-BAMSDK-Platform-Id": self.config["device"]["platform_id"]
|
||||
}
|
||||
payload = {
|
||||
"operationName": "authenticateWithOtp",
|
||||
"variables": {
|
||||
"input": {
|
||||
"email": email,
|
||||
"passcode": otp
|
||||
}
|
||||
},
|
||||
"query": queries.LOGIN_OTP
|
||||
}
|
||||
data = self._request("POST", endpoint, payload=payload, headers=headers)
|
||||
return data["data"]["authenticateWithOtp"]["actionGrant"]
|
||||
|
||||
def _login_with_auth_action(self, auth_action: str, token: str) -> dict:
|
||||
endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"]
|
||||
headers = {
|
||||
"Authorization": token,
|
||||
"X-BAMSDK-Platform-Id": self.config["device"]["platform_id"]
|
||||
}
|
||||
payload = {
|
||||
"operationName": "loginWithActionGrant",
|
||||
"variables": {
|
||||
"input": {
|
||||
"actionGrant": auth_action
|
||||
},
|
||||
"includeAccountConsentToken": True
|
||||
},
|
||||
"query": queries.LOGIN_ACTION_GRANT
|
||||
}
|
||||
data = self._request("POST", endpoint, payload=payload, headers=headers)
|
||||
return data["extensions"]["sdk"]
|
||||
|
||||
def _get_account_info(self, headers: dict = {}) -> dict:
|
||||
endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"]
|
||||
headers.update({"X-BAMSDK-Platform-Id": self.config["device"]["platform_id"]})
|
||||
payload = {
|
||||
"operationName": "EntitledGraphMeQuery",
|
||||
"variables": {},
|
||||
"query": queries.ENTITLEMENTS
|
||||
"operationName": "me",
|
||||
"variables": {
|
||||
"includeAccountConsentToken": True
|
||||
},
|
||||
"query": queries.ME
|
||||
}
|
||||
data = self._request("POST", endpoint, payload=payload, headers=headers)
|
||||
return data["data"]["me"]
|
||||
@@ -845,7 +976,9 @@ class DSNP(Service):
|
||||
payload = {
|
||||
"operationName": "switchProfile",
|
||||
"variables": {
|
||||
"input": profile_input
|
||||
"input": profile_input,
|
||||
"includeIdentity": True,
|
||||
"includeAccountConsentToken": True
|
||||
},
|
||||
"query": queries.SWITCH_PROFILE
|
||||
}
|
||||
@@ -861,7 +994,7 @@ class DSNP(Service):
|
||||
payload = {
|
||||
"operationName": "refreshToken",
|
||||
"variables": {
|
||||
"input": {
|
||||
"refreshToken": {
|
||||
"refreshToken": refresh_token
|
||||
}
|
||||
},
|
||||
@@ -870,7 +1003,7 @@ class DSNP(Service):
|
||||
data = self._request("POST", endpoint, payload=payload, headers=headers)
|
||||
return data["extensions"]["sdk"]
|
||||
|
||||
def _update_device(self) -> str:
|
||||
def _update_device(self, android_id: str, drm_id: str) -> str:
|
||||
endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"]
|
||||
headers = {"X-BAMSDK-Platform-Id": self.config["device"]["platform_id"]}
|
||||
payload = {
|
||||
@@ -878,7 +1011,17 @@ class DSNP(Service):
|
||||
"variables": {
|
||||
"updateDeviceOperatingSystem": {
|
||||
"operatingSystem": self.config["device"]["operatingSystem"],
|
||||
"operatingSystemVersion": self.config["device"]["operatingSystemVersion"]
|
||||
"operatingSystemVersion": self.config["device"]["operatingSystemVersion"],
|
||||
"osDeviceIds": [
|
||||
{
|
||||
"identifier": android_id,
|
||||
"type": "android.vendor.id"
|
||||
},
|
||||
{
|
||||
"identifier": drm_id,
|
||||
"type": "android.drm.id"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"query": queries.UPDATE_DEVICE
|
||||
@@ -906,10 +1049,10 @@ class DSNP(Service):
|
||||
data = self._request("POST", endpoint, payload=payload, headers=headers)
|
||||
|
||||
if data["data"]["updateProfileImaxEnhancedVersion"]["accepted"]:
|
||||
self.log.info(f" + Updated IMAX Enhanced preference: {enabled}")
|
||||
self.log.info(f" + Updated IMAX Enhanced preference: {enabled}")
|
||||
return data["extensions"]["sdk"]
|
||||
else:
|
||||
self.log.warning(" - Failed to set IMAX preference.")
|
||||
self.log.warning(" - Failed to set IMAX preference.")
|
||||
|
||||
def _set_remastered_ar_preference(self, enabled: bool) -> str:
|
||||
endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"]
|
||||
@@ -927,10 +1070,10 @@ class DSNP(Service):
|
||||
data = self._request("POST", endpoint, payload=payload, headers=headers)
|
||||
|
||||
if data["data"]["updateProfileRemasteredAspectRatio"]["accepted"]:
|
||||
self.log.info(f" + Updated Remastered Aspect Ratio preference: {enabled}")
|
||||
self.log.info(f" + Updated Remastered Aspect Ratio preference: {enabled}")
|
||||
return data["extensions"]["sdk"]
|
||||
else:
|
||||
self.log.warning(" - Failed to set Remastered Aspect Ratio preference.")
|
||||
self.log.warning(" - Failed to set Remastered Aspect Ratio preference.")
|
||||
|
||||
def _href(self, href: str, **kwargs: Any) -> str:
|
||||
_args = {"version": self.config["bamsdk"]["explore_version"]}
|
||||
@@ -947,7 +1090,6 @@ class DSNP(Service):
|
||||
|
||||
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()
|
||||
@@ -956,13 +1098,15 @@ class DSNP(Service):
|
||||
error_code = data["errors"][0]["extensions"]["code"]
|
||||
if "token.service.invalid.grant" in error_code:
|
||||
raise ConnectionError(f"Refresh Token Expired: {error_code}")
|
||||
if "token.service.unauthorized.client" in error_code:
|
||||
elif "token.service.unauthorized.client" in error_code:
|
||||
raise ConnectionError(f"Unauthorized Client/IP: {error_code}")
|
||||
elif "idp.error.identity.bad-credentials" in error_code:
|
||||
raise ConnectionError(f"Bad Credentials: {error_code}")
|
||||
elif "account.profile.pin.invalid" in error_code:
|
||||
raise ConnectionError(f"Invalid PIN: {error_code}")
|
||||
raise ConnectionError(data["errors"])
|
||||
if data.get("data") and data["data"].get("errors"):
|
||||
raise ConnectionError(data["data"]["errors"])
|
||||
return data
|
||||
except Exception as e:
|
||||
if "Refresh Token Expired" in str(e) or "/deeplink" in endpoint:
|
||||
|
||||
@@ -10,16 +10,16 @@ certificate: |
|
||||
|
||||
## config ( {configVersion}/{clientId}/{deviceFamily}/{sdkVersion}/{applicationRuntime}/{deviceProfile}/{environment} ) ##
|
||||
# Browser (windows, chrome) : /browser/v34.2/windows/chrome/prod.json
|
||||
# Android Phone : /android/v12.0.0/google/handset/prod.json
|
||||
# Android TV : /android/v12.0.0/google/tv/prod.json
|
||||
# Amazon Fire TV : /android/v12.0.0/amazon/tv/prod.json
|
||||
# Android Phone : /android/v15.0.0/google/handset/prod.json
|
||||
# Android TV : /android/v15.0.0/google/tv/prod.json
|
||||
# Amazon Fire TV : /android/v15.0.0/amazon/tv/prod.json
|
||||
|
||||
endpoints:
|
||||
config: "https://client-sdk-configs.bamgrid.com/bam-sdk/v7.0/disney-svod-3d9324fc/android/v13.0.0/google/tv/prod.json"
|
||||
config: "https://client-sdk-configs.bamgrid.com/bam-sdk/v7.0/disney-svod-3d9324fc/android/v15.0.0/google/tv/prod.json"
|
||||
|
||||
## user_agent ##
|
||||
# android-phone : BAMSDK/v13.3.0 (disney-svod-3d9324fc 4.18.1+rc6-2025.11.05.0; v7.0/v13.0.0; android; phone)
|
||||
# android-tv : BAMSDK/v13.3.0 (disney-svod-3d9324fc 4.18.1+rc6-2025.11.05.0; v7.0/v13.0.0; android; tv)
|
||||
## user_agent (okhttp/5.0.0-alpha.14) ##
|
||||
# android-phone : BAMSDK/v15.0.1 (disney-svod-3d9324fc 4.21.1+rc3-2026.01.06.0; v7.0/v15.0.0; android; phone)
|
||||
# android-tv : BAMSDK/v15.0.1 (disney-svod-3d9324fc 4.21.1+rc3-2026.01.06.0; v7.0/v15.0.0; android; tv)
|
||||
|
||||
## api_key ##
|
||||
# browser : ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84
|
||||
@@ -30,11 +30,11 @@ endpoints:
|
||||
# android : 624b805dafc5c73635b1a216
|
||||
|
||||
bamsdk:
|
||||
sdk_version: "13.3.0"
|
||||
application_version: "4.18.1+rc6-2025.11.05.0"
|
||||
explore_version: "v1.11"
|
||||
sdk_version: "15.0.1"
|
||||
application_version: "4.21.1+rc3-2026.01.06.0"
|
||||
explore_version: "v1.13"
|
||||
client: "disney-svod-3d9324fc"
|
||||
user_agent: "BAMSDK/v13.3.0 (disney-svod-3d9324fc 4.18.1+rc6-2025.11.05.0; v7.0/v13.0.0; android; tv)"
|
||||
user_agent: "BAMSDK/v15.0.1 (disney-svod-3d9324fc 4.21.1+rc3-2026.01.06.0; v7.0/v15.0.0; android; tv)"
|
||||
api_key: "ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA"
|
||||
yp_service_id: "624b805dafc5c73635b1a216"
|
||||
|
||||
@@ -46,7 +46,9 @@ device:
|
||||
applicationRuntime: "android"
|
||||
operatingSystem: "Android"
|
||||
operatingSystemVersion: "16"
|
||||
deviceLanguage: "ko" # en
|
||||
deviceLanguage: "ko" # Device language data independent of account language data
|
||||
|
||||
profile:
|
||||
index: 0
|
||||
# Specifies the index of the profile to use. (0 = first profile, 1 = second profile, etc.)
|
||||
# Automatically select a profile when commenting.
|
||||
# profile:
|
||||
# index: 0
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
SWITCH_PROFILE = """mutation switchProfile($input: SwitchProfileInput!) { switchProfile(switchProfile: $input) { __typename account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } } } fragment accountGraphFragment on Account { __typename id activeProfile { __typename id } profiles { __typename ...profileGraphFragment } parentalControls { __typename isProfileCreationProtected } flows { __typename star { __typename isOnboarded } } attributes { __typename email emailVerified userVerified locations { __typename manual { __typename country } purchase { __typename country } registration { __typename geoIp { __typename country } } } } } fragment profileGraphFragment on Profile { __typename id name maturityRating { __typename ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating } isAge21Verified flows { __typename star { __typename eligibleForOnboarding isOnboarded } } attributes { __typename isDefault kidsModeEnabled groupWatch { __typename enabled } languagePreferences { __typename appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { __typename isPinProtected kidProofExitEnabled liveAndUnratedContent { __typename enabled } } playbackSettings { __typename autoplay backgroundVideo prefer133 } avatar { __typename id userSelected } } } fragment sessionGraphFragment on Session { __typename sessionId device { __typename id } entitlements experiments { __typename featureId variantId version } homeLocation { __typename countryCode } inSupportedLocation isSubscriber location { __typename countryCode } portabilityLocation { __typename countryCode } preferredMaturityRating { __typename impliedMaturityRating ratingSystem } }"""
|
||||
ENTITLEMENTS = """query EntitledGraphMeQuery { me { __typename account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } } } fragment accountGraphFragment on Account { __typename id activeProfile { __typename id } profiles { __typename ...profileGraphFragment } parentalControls { __typename isProfileCreationProtected } flows { __typename star { __typename isOnboarded } } attributes { __typename email emailVerified userVerified locations { __typename manual { __typename country } purchase { __typename country } registration { __typename geoIp { __typename country } } } } } fragment profileGraphFragment on Profile { __typename id name maturityRating { __typename ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating } isAge21Verified flows { __typename star { __typename eligibleForOnboarding isOnboarded } } attributes { __typename isDefault kidsModeEnabled groupWatch { __typename enabled } languagePreferences { __typename appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { __typename isPinProtected kidProofExitEnabled liveAndUnratedContent { __typename enabled } } playbackSettings { __typename autoplay backgroundVideo prefer133 preferImaxEnhancedVersion} avatar { __typename id userSelected } } } fragment sessionGraphFragment on Session { __typename sessionId device { __typename id } entitlements experiments { __typename featureId variantId version } homeLocation { __typename countryCode } inSupportedLocation isSubscriber location { __typename countryCode } portabilityLocation { __typename countryCode } preferredMaturityRating { __typename impliedMaturityRating ratingSystem } }"""
|
||||
REGISTER_DEVICE = """mutation ($registerDevice: RegisterDeviceInput!) {registerDevice(registerDevice: $registerDevice) {__typename}}"""
|
||||
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 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 prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } }"""
|
||||
# REQUEST_DEVICE_CODE = ("""mutation requestLicensePlate {requestLicensePlate {__typename licensePlate expirationTime expiresInSeconds}}""")
|
||||
CHECK_EMAIL = """query Check($email: String!) { check(email: $email) { operations nextOperation } }"""
|
||||
# REQUESET_OTP = """mutation requestOtp($input: RequestOtpInput!) { requestOtp(requestOtp: $input) { accepted } }"""
|
||||
LOGIN = """mutation loginTv($input: LoginInput!) { login(login: $input) { __typename account { __typename ...accountGraphFragment } actionGrant activeSession { __typename ...sessionGraphFragment } }} fragment accountGraphFragment on Account { __typename id activeProfile { __typename id } profiles { __typename ...profileGraphFragment } parentalControls { __typename isProfileCreationProtected } flows { __typename star { __typename isOnboarded } } attributes { __typename email emailVerified userVerified locations { __typename manual { __typename country } purchase { __typename country } registration { __typename geoIp { __typename country } } } }}\nfragment profileGraphFragment on Profile { __typename id name maturityRating { __typename ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating } isAge21Verified flows { __typename star { __typename eligibleForOnboarding isOnboarded } } attributes { __typename isDefault kidsModeEnabled groupWatch { __typename enabled } languagePreferences { __typename appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { __typename isPinProtected kidProofExitEnabled liveAndUnratedContent { __typename enabled } } playbackSettings { __typename autoplay backgroundVideo prefer133 } avatar { __typename id userSelected } }}\nfragment sessionGraphFragment on Session { __typename sessionId device { __typename id } entitlements experiments { __typename featureId variantId version } homeLocation { __typename countryCode } inSupportedLocation isSubscriber location { __typename countryCode } portabilityLocation { __typename countryCode } preferredMaturityRating { __typename impliedMaturityRating ratingSystem }}"""
|
||||
# LOGIN_OTP = """mutation authenticateWithOtp($input: AuthenticateWithOtpInput!) { authenticateWithOtp(authenticateWithOtp: $input) { actionGrant securityAction } }"""
|
||||
# LOGIN_ACTION_GRANT = """\n mutation loginWithActionGrant($input: LoginWithActionGrantInput!) {\n loginWithActionGrant(login: $input) {\n account {\n ...account\n\n profiles {\n ...profile\n }\n }\n activeSession {\n ...session\n }\n identity {\n ...identity\n }\n }\n }\n\n \nfragment identity on Identity {\n attributes {\n securityFlagged\n createdAt\n passwordResetRequired\n }\n flows {\n marketingPreferences {\n eligibleForOnboarding\n isOnboarded\n }\n personalInfo {\n eligibleForCollection\n requiresCollection\n }\n }\n personalInfo {\n dateOfBirth\n gender\n }\n subscriber {\n subscriberStatus\n subscriptionAtRisk\n overlappingSubscription\n doubleBilled\n doubleBilledProviders\n subscriptions {\n id\n groupId\n state\n partner\n isEntitled\n source {\n sourceType\n sourceProvider\n sourceRef\n subType\n }\n paymentProvider\n product {\n id\n sku\n offerId\n promotionId\n name\n nextPhase {\n sku\n offerId\n campaignCode\n voucherCode\n }\n entitlements {\n id\n name\n desc\n partner\n }\n categoryCodes\n redeemed {\n campaignCode\n redemptionCode\n voucherCode\n }\n bundle\n bundleType\n subscriptionPeriod\n earlyAccess\n trial {\n duration\n }\n }\n term {\n purchaseDate\n startDate\n expiryDate\n nextRenewalDate\n pausedDate\n churnedDate\n isFreeTrial\n }\n externalSubscriptionId,\n cancellation {\n type\n restartEligible\n }\n stacking {\n status\n overlappingSubscriptionProviders\n previouslyStacked\n previouslyStackedByProvider\n }\n }\n }\n}\n\n \nfragment account on Account {\n id\n attributes {\n blocks {\n expiry\n reason\n }\n consentPreferences {\n dataElements {\n name\n value\n }\n purposes {\n consentDate\n firstTransactionDate\n id\n lastTransactionCollectionPointId\n lastTransactionCollectionPointVersion\n lastTransactionDate\n name\n status\n totalTransactionCount\n version\n }\n }\n dssIdentityCreatedAt\n email\n emailVerified\n lastSecurityFlaggedAt\n locations {\n manual {\n country\n }\n purchase {\n country\n source\n }\n registration {\n geoIp {\n country\n }\n }\n }\n securityFlagged\n tags\n taxId\n userVerified\n }\n parentalControls {\n isProfileCreationProtected\n }\n flows {\n star {\n isOnboarded\n }\n }\n}\n\n \nfragment profile on Profile {\n id\n name\n isAge21Verified\n attributes {\n avatar {\n id\n userSelected\n }\n isDefault\n kidsModeEnabled\n languagePreferences {\n appLanguage\n playbackLanguage\n preferAudioDescription\n preferSDH\n subtitleAppearance {\n backgroundColor\n backgroundOpacity\n description\n font\n size\n textColor\n }\n subtitleLanguage\n subtitlesEnabled\n }\n groupWatch {\n enabled\n }\n parentalControls {\n kidProofExitEnabled\n isPinProtected\n }\n playbackSettings {\n autoplay\n backgroundVideo\n prefer133\n preferImaxEnhancedVersion\n previewAudioOnHome\n previewVideoOnHome\n }\n }\n personalInfo {\n dateOfBirth\n gender\n age\n }\n maturityRating {\n ...maturityRating\n }\n personalInfo {\n dateOfBirth\n age\n gender\n }\n flows {\n personalInfo {\n eligibleForCollection\n requiresCollection\n }\n star {\n eligibleForOnboarding\n isOnboarded\n }\n }\n}\n\n\nfragment maturityRating on MaturityRating {\n ratingSystem\n ratingSystemValues\n contentMaturityRating\n maxRatingSystemValue\n isMaxContentMaturityRating\n}\n\n\n \nfragment session on Session {\n device {\n id\n platform\n }\n entitlements\n features {\n coPlay\n }\n inSupportedLocation\n isSubscriber\n location {\n type\n countryCode\n dma\n asn\n regionName\n connectionType\n zipCode\n }\n sessionId\n experiments {\n featureId\n variantId\n version\n }\n identity {\n id\n }\n account {\n id\n }\n profile {\n id\n parentalControls {\n liveAndUnratedContent {\n enabled\n }\n }\n }\n partnerName\n preferredMaturityRating {\n impliedMaturityRating\n ratingSystem\n }\n homeLocation {\n countryCode\n }\n portabilityLocation {\n countryCode\n type\n }\n}\n\n"""
|
||||
REFRESH_TOKEN = """mutation refreshToken($input:RefreshTokenInput!) { refreshToken(refreshToken:$input) { activeSession{sessionId} } }"""
|
||||
# REQUEST_DEVICE_CODE = """mutation requestLicensePlate($input: RequestLicensePlateInput!) { requestLicensePlate(requestLicensePlate: $input) { licensePlate expirationTime expiresInSeconds } }"""
|
||||
CHECK_EMAIL = """query check($email: String!) { check(email: $email) { operations nextOperation } }"""
|
||||
LOGIN = """mutation login($input: LoginInput!, $includeIdentity: Boolean!, $includeAccountConsentToken: Boolean!) { login(login: $input) { account { __typename ...accountGraphFragment } actionGrant 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 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 } }"""
|
||||
LOGIN_ACTION_GRANT = """mutation loginWithActionGrant($input: LoginWithActionGrantInput!, $includeAccountConsentToken: Boolean!) { loginWithActionGrant(login: $input) { account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } identity { __typename ...identityGraphFragment } actionGrant } } 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 } }"""
|
||||
LOGIN_OTP = """mutation authenticateWithOtp($input: AuthenticateWithOtpInput!) { authenticateWithOtp(authenticateWithOtp: $input) { actionGrant securityAction passwordRules { __typename ...passwordRulesFragment } } } fragment passwordRulesFragment on PasswordRules { minLength charTypes }"""
|
||||
ME = """query me($includeAccountConsentToken: Boolean!) { me { account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } identity { __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 } }"""
|
||||
REFRESH_TOKEN = """mutation refreshToken($refreshToken: RefreshTokenInput!) { refreshToken(refreshToken: $refreshToken) { activeSession { sessionId } } }"""
|
||||
REGISTER_DEVICE = """mutation ($registerDevice: RegisterDeviceInput!) { registerDevice(registerDevice: $registerDevice) { __typename } }"""
|
||||
REQUESET_OTP = """mutation requestOtp($input: RequestOtpInput!) { requestOtp(requestOtp: $input) { accepted } }"""
|
||||
SET_IMAX = """mutation updateProfileImaxEnhancedVersion($input: UpdateProfileImaxEnhancedVersionInput!, $includeProfile: Boolean!) { updateProfileImaxEnhancedVersion(updateProfileImaxEnhancedVersion: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } }"""
|
||||
SET_REMASTERED_AR = """mutation updateProfileRemasteredAspectRatio($input: UpdateProfileRemasteredAspectRatioInput!, $includeProfile: Boolean!) { updateProfileRemasteredAspectRatio(updateProfileRemasteredAspectRatio: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } }"""
|
||||
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}}"""
|
||||
Reference in New Issue
Block a user