mirror of
https://github.com/vinefeeder/TwinVine.git
synced 2026-07-15 18:10:04 +02:00
reflect upstream V2 changes
This commit is contained in:
@@ -65,7 +65,9 @@ dependencies = [
|
|||||||
"mypy>=1.18.2,<2",
|
"mypy>=1.18.2,<2",
|
||||||
"pysubs2>=1.7.0,<2",
|
"pysubs2>=1.7.0,<2",
|
||||||
"jwt>=1.4.0",
|
"jwt>=1.4.0",
|
||||||
"langcodes>=3.5.0"
|
"langcodes>=3.5.0",
|
||||||
|
"fonttools>=4.60.1",
|
||||||
|
"aiohttp-swagger3>=0.10.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,84 +12,113 @@ from envied.core.vault import Vault
|
|||||||
from envied.core.vaults import Vaults
|
from envied.core.vaults import Vaults
|
||||||
|
|
||||||
|
|
||||||
|
def load_vaults(vault_names: list[str]) -> Vaults:
|
||||||
|
"""Load and validate vaults by name."""
|
||||||
|
vaults = Vaults()
|
||||||
|
for vault_name in vault_names:
|
||||||
|
vault_config = next((x for x in config.key_vaults if x["name"] == vault_name), None)
|
||||||
|
if not vault_config:
|
||||||
|
raise click.ClickException(f"Vault ({vault_name}) is not defined in the config.")
|
||||||
|
|
||||||
|
vault_type = vault_config["type"]
|
||||||
|
vault_args = vault_config.copy()
|
||||||
|
del vault_args["type"]
|
||||||
|
|
||||||
|
if not vaults.load(vault_type, **vault_args):
|
||||||
|
raise click.ClickException(f"Failed to load vault ({vault_name}).")
|
||||||
|
|
||||||
|
return vaults
|
||||||
|
|
||||||
|
|
||||||
|
def process_service_keys(from_vault: Vault, service: str, log: logging.Logger) -> dict[str, str]:
|
||||||
|
"""Get and validate keys from a vault for a specific service."""
|
||||||
|
content_keys = list(from_vault.get_keys(service))
|
||||||
|
|
||||||
|
bad_keys = {kid: key for kid, key in content_keys if not key or key.count("0") == len(key)}
|
||||||
|
for kid, key in bad_keys.items():
|
||||||
|
log.warning(f"Skipping NULL key: {kid}:{key}")
|
||||||
|
|
||||||
|
return {kid: key for kid, key in content_keys if kid not in bad_keys}
|
||||||
|
|
||||||
|
|
||||||
|
def copy_service_data(to_vault: Vault, from_vault: Vault, service: str, log: logging.Logger) -> int:
|
||||||
|
"""Copy data for a single service between vaults."""
|
||||||
|
content_keys = process_service_keys(from_vault, service, log)
|
||||||
|
total_count = len(content_keys)
|
||||||
|
|
||||||
|
if total_count == 0:
|
||||||
|
log.info(f"{service}: No keys found in {from_vault}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
added = to_vault.add_keys(service, content_keys)
|
||||||
|
except PermissionError:
|
||||||
|
log.warning(f"{service}: No permission to create table in {to_vault}, skipped")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
existed = total_count - added
|
||||||
|
|
||||||
|
if added > 0 and existed > 0:
|
||||||
|
log.info(f"{service}: {added} added, {existed} skipped ({total_count} total)")
|
||||||
|
elif added > 0:
|
||||||
|
log.info(f"{service}: {added} added ({total_count} total)")
|
||||||
|
else:
|
||||||
|
log.info(f"{service}: {existed} skipped (all existed)")
|
||||||
|
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
@click.group(short_help="Manage and configure Key Vaults.", context_settings=context_settings)
|
@click.group(short_help="Manage and configure Key Vaults.", context_settings=context_settings)
|
||||||
def kv() -> None:
|
def kv() -> None:
|
||||||
"""Manage and configure Key Vaults."""
|
"""Manage and configure Key Vaults."""
|
||||||
|
|
||||||
|
|
||||||
@kv.command()
|
@kv.command()
|
||||||
@click.argument("to_vault", type=str)
|
@click.argument("to_vault_name", type=str)
|
||||||
@click.argument("from_vaults", nargs=-1, type=click.UNPROCESSED)
|
@click.argument("from_vault_names", nargs=-1, type=click.UNPROCESSED)
|
||||||
@click.option("-s", "--service", type=str, default=None, help="Only copy data to and from a specific service.")
|
@click.option("-s", "--service", type=str, default=None, help="Only copy data to and from a specific service.")
|
||||||
def copy(to_vault: str, from_vaults: list[str], service: Optional[str] = None) -> None:
|
def copy(to_vault_name: str, from_vault_names: list[str], service: Optional[str] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Copy data from multiple Key Vaults into a single Key Vault.
|
Copy data from multiple Key Vaults into a single Key Vault.
|
||||||
Rows with matching KIDs are skipped unless there's no KEY set.
|
Rows with matching KIDs are skipped unless there's no KEY set.
|
||||||
Existing data is not deleted or altered.
|
Existing data is not deleted or altered.
|
||||||
|
|
||||||
The `to_vault` argument is the key vault you wish to copy data to.
|
The `to_vault_name` argument is the key vault you wish to copy data to.
|
||||||
It should be the name of a Key Vault defined in the config.
|
It should be the name of a Key Vault defined in the config.
|
||||||
|
|
||||||
The `from_vaults` argument is the key vault(s) you wish to take
|
The `from_vault_names` argument is the key vault(s) you wish to take
|
||||||
data from. You may supply multiple key vaults.
|
data from. You may supply multiple key vaults.
|
||||||
"""
|
"""
|
||||||
if not from_vaults:
|
if not from_vault_names:
|
||||||
raise click.ClickException("No Vaults were specified to copy data from.")
|
raise click.ClickException("No Vaults were specified to copy data from.")
|
||||||
|
|
||||||
log = logging.getLogger("kv")
|
log = logging.getLogger("kv")
|
||||||
|
|
||||||
vaults = Vaults()
|
all_vault_names = [to_vault_name] + list(from_vault_names)
|
||||||
for vault_name in [to_vault] + list(from_vaults):
|
vaults = load_vaults(all_vault_names)
|
||||||
vault = next((x for x in config.key_vaults if x["name"] == vault_name), None)
|
|
||||||
if not vault:
|
|
||||||
raise click.ClickException(f"Vault ({vault_name}) is not defined in the config.")
|
|
||||||
vault_type = vault["type"]
|
|
||||||
vault_args = vault.copy()
|
|
||||||
del vault_args["type"]
|
|
||||||
if not vaults.load(vault_type, **vault_args):
|
|
||||||
raise click.ClickException(f"Failed to load vault ({vault_name}).")
|
|
||||||
|
|
||||||
to_vault: Vault = vaults.vaults[0]
|
to_vault = vaults.vaults[0]
|
||||||
from_vaults: list[Vault] = vaults.vaults[1:]
|
from_vaults = vaults.vaults[1:]
|
||||||
|
|
||||||
|
vault_names = ", ".join([v.name for v in from_vaults])
|
||||||
|
log.info(f"Copying data from {vault_names} → {to_vault.name}")
|
||||||
|
|
||||||
log.info(f"Copying data from {', '.join([x.name for x in from_vaults])}, into {to_vault.name}")
|
|
||||||
if service:
|
if service:
|
||||||
service = Services.get_tag(service)
|
service = Services.get_tag(service)
|
||||||
log.info(f"Only copying data for service {service}")
|
log.info(f"Filtering by service: {service}")
|
||||||
|
|
||||||
total_added = 0
|
total_added = 0
|
||||||
for from_vault in from_vaults:
|
for from_vault in from_vaults:
|
||||||
if service:
|
services_to_copy = [service] if service else from_vault.get_services()
|
||||||
services = [service]
|
|
||||||
else:
|
|
||||||
services = from_vault.get_services()
|
|
||||||
|
|
||||||
for service_ in services:
|
|
||||||
log.info(f"Getting data from {from_vault} for {service_}")
|
|
||||||
content_keys = list(from_vault.get_keys(service_)) # important as it's a generator we iterate twice
|
|
||||||
|
|
||||||
bad_keys = {kid: key for kid, key in content_keys if not key or key.count("0") == len(key)}
|
|
||||||
|
|
||||||
for kid, key in bad_keys.items():
|
|
||||||
log.warning(f"Cannot add a NULL Content Key to a Vault, skipping: {kid}:{key}")
|
|
||||||
|
|
||||||
content_keys = {kid: key for kid, key in content_keys if kid not in bad_keys}
|
|
||||||
|
|
||||||
total_count = len(content_keys)
|
|
||||||
log.info(f"Adding {total_count} Content Keys to {to_vault} for {service_}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
added = to_vault.add_keys(service_, content_keys)
|
|
||||||
except PermissionError:
|
|
||||||
log.warning(f" - No permission to create table ({service_}) in {to_vault}, skipping...")
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
for service_tag in services_to_copy:
|
||||||
|
added = copy_service_data(to_vault, from_vault, service_tag, log)
|
||||||
total_added += added
|
total_added += added
|
||||||
existed = total_count - added
|
|
||||||
|
|
||||||
log.info(f"{to_vault} ({service_}): {added} newly added, {existed} already existed (skipped)")
|
if total_added > 0:
|
||||||
|
log.info(f"Successfully added {total_added} new keys to {to_vault}")
|
||||||
log.info(f"{to_vault}: {total_added} total newly added")
|
else:
|
||||||
|
log.info("Copy completed - no new keys to add")
|
||||||
|
|
||||||
|
|
||||||
@kv.command()
|
@kv.command()
|
||||||
@@ -106,9 +135,9 @@ def sync(ctx: click.Context, vaults: list[str], service: Optional[str] = None) -
|
|||||||
if not len(vaults) > 1:
|
if not len(vaults) > 1:
|
||||||
raise click.ClickException("You must provide more than one Vault to sync.")
|
raise click.ClickException("You must provide more than one Vault to sync.")
|
||||||
|
|
||||||
ctx.invoke(copy, to_vault=vaults[0], from_vaults=vaults[1:], service=service)
|
ctx.invoke(copy, to_vault_name=vaults[0], from_vault_names=vaults[1:], service=service)
|
||||||
for i in range(1, len(vaults)):
|
for i in range(1, len(vaults)):
|
||||||
ctx.invoke(copy, to_vault=vaults[i], from_vaults=[vaults[i - 1]], service=service)
|
ctx.invoke(copy, to_vault_name=vaults[i], from_vault_names=[vaults[i - 1]], service=service)
|
||||||
|
|
||||||
|
|
||||||
@kv.command()
|
@kv.command()
|
||||||
@@ -135,15 +164,7 @@ def add(file: Path, service: str, vaults: list[str]) -> None:
|
|||||||
log = logging.getLogger("kv")
|
log = logging.getLogger("kv")
|
||||||
service = Services.get_tag(service)
|
service = Services.get_tag(service)
|
||||||
|
|
||||||
vaults_ = Vaults()
|
vaults_ = load_vaults(list(vaults))
|
||||||
for vault_name in vaults:
|
|
||||||
vault = next((x for x in config.key_vaults if x["name"] == vault_name), None)
|
|
||||||
if not vault:
|
|
||||||
raise click.ClickException(f"Vault ({vault_name}) is not defined in the config.")
|
|
||||||
vault_type = vault["type"]
|
|
||||||
vault_args = vault.copy()
|
|
||||||
del vault_args["type"]
|
|
||||||
vaults_.load(vault_type, **vault_args)
|
|
||||||
|
|
||||||
data = file.read_text(encoding="utf8")
|
data = file.read_text(encoding="utf8")
|
||||||
kid_keys: dict[str, str] = {}
|
kid_keys: dict[str, str] = {}
|
||||||
@@ -173,15 +194,7 @@ def prepare(vaults: list[str]) -> None:
|
|||||||
"""Create Service Tables on Vaults if not yet created."""
|
"""Create Service Tables on Vaults if not yet created."""
|
||||||
log = logging.getLogger("kv")
|
log = logging.getLogger("kv")
|
||||||
|
|
||||||
vaults_ = Vaults()
|
vaults_ = load_vaults(vaults)
|
||||||
for vault_name in vaults:
|
|
||||||
vault = next((x for x in config.key_vaults if x["name"] == vault_name), None)
|
|
||||||
if not vault:
|
|
||||||
raise click.ClickException(f"Vault ({vault_name}) is not defined in the config.")
|
|
||||||
vault_type = vault["type"]
|
|
||||||
vault_args = vault.copy()
|
|
||||||
del vault_args["type"]
|
|
||||||
vaults_.load(vault_type, **vault_args)
|
|
||||||
|
|
||||||
for vault in vaults_:
|
for vault in vaults_:
|
||||||
if hasattr(vault, "has_table") and hasattr(vault, "create_table"):
|
if hasattr(vault, "has_table") and hasattr(vault, "create_table"):
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
from envied.core import binaries
|
from envied.core import binaries
|
||||||
|
from envied.core.api import cors_middleware, setup_routes, setup_swagger
|
||||||
from envied.core.config import config
|
from envied.core.config import config
|
||||||
from envied.core.constants import context_settings
|
from envied.core.constants import context_settings
|
||||||
|
|
||||||
|
|
||||||
@click.command(short_help="Serve your Local Widevine Devices for Remote Access.", context_settings=context_settings)
|
@click.command(
|
||||||
|
short_help="Serve your Local Widevine 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="0.0.0.0", help="Host to serve from.")
|
||||||
@click.option("-p", "--port", type=int, default=8786, help="Port 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("--caddy", is_flag=True, default=False, help="Also serve with Caddy.")
|
||||||
def serve(host: str, port: int, caddy: bool) -> None:
|
@click.option("--api-only", is_flag=True, default=False, help="Serve only the REST API, not pywidevine CDM.")
|
||||||
|
@click.option("--no-key", is_flag=True, default=False, help="Disable API key authentication (allows all requests).")
|
||||||
|
@click.option(
|
||||||
|
"--debug-api",
|
||||||
|
is_flag=True,
|
||||||
|
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:
|
||||||
"""
|
"""
|
||||||
Serve your Local Widevine Devices for Remote Access.
|
Serve your Local Widevine Devices and REST API for Remote Access.
|
||||||
|
|
||||||
\b
|
\b
|
||||||
Host as 127.0.0.1 may block remote access even if port-forwarded.
|
Host as 127.0.0.1 may block remote access even if port-forwarded.
|
||||||
@@ -22,9 +35,29 @@ def serve(host: str, port: int, caddy: bool) -> None:
|
|||||||
\b
|
\b
|
||||||
You may serve with Caddy at the same time with --caddy. You can use Caddy
|
You may serve with Caddy at the same time with --caddy. You can use Caddy
|
||||||
as a reverse-proxy to serve with HTTPS. The config used will be the Caddyfile
|
as a reverse-proxy to serve with HTTPS. The config used will be the Caddyfile
|
||||||
next to the envied config.
|
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.
|
||||||
"""
|
"""
|
||||||
from pywidevine import 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 not no_key:
|
||||||
|
api_secret = config.serve.get("api_secret")
|
||||||
|
if not api_secret:
|
||||||
|
raise click.ClickException(
|
||||||
|
"API secret key is not configured. Please add 'api_secret' to the 'serve' section in your config."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
api_secret = None
|
||||||
|
log.warning("Running with --no-key: Authentication is DISABLED for all API endpoints!")
|
||||||
|
|
||||||
|
if debug_api:
|
||||||
|
log.warning("Running with --debug-api: Error responses will include technical debug information!")
|
||||||
|
|
||||||
if caddy:
|
if caddy:
|
||||||
if not binaries.Caddy:
|
if not binaries.Caddy:
|
||||||
@@ -39,7 +72,53 @@ def serve(host: str, port: int, caddy: bool) -> None:
|
|||||||
if not config.serve.get("devices"):
|
if not config.serve.get("devices"):
|
||||||
config.serve["devices"] = []
|
config.serve["devices"] = []
|
||||||
config.serve["devices"].extend(list(config.directories.wvds.glob("*.wvd")))
|
config.serve["devices"].extend(list(config.directories.wvds.glob("*.wvd")))
|
||||||
serve.run(config.serve, host, port)
|
|
||||||
|
if api_only:
|
||||||
|
# API-only mode: serve just the REST API
|
||||||
|
log.info("Starting REST API server (pywidevine CDM disabled)")
|
||||||
|
if no_key:
|
||||||
|
app = web.Application(middlewares=[cors_middleware])
|
||||||
|
app["config"] = {"users": []}
|
||||||
|
else:
|
||||||
|
app = web.Application(middlewares=[cors_middleware, pywidevine_serve.authentication])
|
||||||
|
app["config"] = {"users": [api_secret]}
|
||||||
|
app["debug_api"] = debug_api
|
||||||
|
setup_routes(app)
|
||||||
|
setup_swagger(app)
|
||||||
|
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)")
|
||||||
|
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)")
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
app.on_startup.append(pywidevine_serve._startup)
|
||||||
|
app.on_cleanup.append(pywidevine_serve._cleanup)
|
||||||
|
app.add_routes(pywidevine_serve.routes)
|
||||||
|
app["debug_api"] = debug_api
|
||||||
|
setup_routes(app)
|
||||||
|
setup_swagger(app)
|
||||||
|
|
||||||
|
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)")
|
||||||
|
web.run_app(app, host=host, port=port, print=None)
|
||||||
finally:
|
finally:
|
||||||
if caddy_p:
|
if caddy_p:
|
||||||
caddy_p.kill()
|
caddy_p.kill()
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "1.4.8"
|
__version__ = "2.0.0"
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import atexit
|
import atexit
|
||||||
import logging
|
import logging
|
||||||
#import warnings
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import urllib3
|
import urllib3
|
||||||
from rich import traceback
|
from rich import traceback
|
||||||
|
|
||||||
# Suppress SyntaxWarning from unmaintained tinycss package (dependency of subby)
|
|
||||||
#warnings.filterwarnings("ignore", category=SyntaxWarning, module="tinycss")
|
|
||||||
|
|
||||||
from rich.console import Group
|
from rich.console import Group
|
||||||
from rich.padding import Padding
|
from rich.padding import Padding
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
@@ -20,23 +14,17 @@ from envied.core.commands import Commands
|
|||||||
from envied.core.config import config
|
from envied.core.config import config
|
||||||
from envied.core.console import ComfyRichHandler, console
|
from envied.core.console import ComfyRichHandler, console
|
||||||
from envied.core.constants import context_settings
|
from envied.core.constants import context_settings
|
||||||
from envied.core.utilities import rotate_log_file
|
from envied.core.update_checker import UpdateChecker
|
||||||
|
from envied.core.utilities import close_debug_logger, init_debug_logger
|
||||||
LOGGING_PATH = None
|
|
||||||
|
|
||||||
|
|
||||||
@click.command(cls=Commands, invoke_without_command=True, context_settings=context_settings)
|
@click.command(cls=Commands, invoke_without_command=True, context_settings=context_settings)
|
||||||
@click.option("-v", "--version", is_flag=True, default=False, help="Print version information.")
|
@click.option("-v", "--version", is_flag=True, default=False, help="Print version information.")
|
||||||
@click.option("-d", "--debug", is_flag=True, default=False, help="Enable DEBUG level logs.")
|
@click.option("-d", "--debug", is_flag=True, default=False, help="Enable DEBUG level logs and JSON debug logging.")
|
||||||
@click.option(
|
def main(version: bool, debug: bool) -> None:
|
||||||
"--log",
|
"""unshackle—Modular Movie, TV, and Music Archival Software."""
|
||||||
"log_path",
|
debug_logging_enabled = debug or config.debug
|
||||||
type=Path,
|
|
||||||
default=config.directories.logs / config.filenames.log,
|
|
||||||
help="Log path (or filename). Path can contain the following f-string args: {name} {time}.",
|
|
||||||
)
|
|
||||||
def main(version: bool, debug: bool, log_path: Path) -> None:
|
|
||||||
"""envied—Modular Movie, TV, and Music Archival Software."""
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.DEBUG if debug else logging.INFO,
|
level=logging.DEBUG if debug else logging.INFO,
|
||||||
format="%(message)s",
|
format="%(message)s",
|
||||||
@@ -52,11 +40,8 @@ def main(version: bool, debug: bool, log_path: Path) -> None:
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
if log_path:
|
if debug_logging_enabled:
|
||||||
global LOGGING_PATH
|
init_debug_logger(enabled=True)
|
||||||
console.record = True
|
|
||||||
new_log_path = rotate_log_file(log_path)
|
|
||||||
LOGGING_PATH = new_log_path
|
|
||||||
|
|
||||||
urllib3.disable_warnings(InsecureRequestWarning)
|
urllib3.disable_warnings(InsecureRequestWarning)
|
||||||
|
|
||||||
@@ -79,12 +64,12 @@ def main(version: bool, debug: bool, log_path: Path) -> None:
|
|||||||
),
|
),
|
||||||
justify="center",
|
justify="center",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@atexit.register
|
@atexit.register
|
||||||
def save_log():
|
def cleanup():
|
||||||
if console.record and LOGGING_PATH:
|
"""Clean up resources on exit."""
|
||||||
# TODO: Currently semi-bust. Everything that refreshes gets duplicated.
|
close_debug_logger()
|
||||||
console.save_text(LOGGING_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from envied.core.api.routes import cors_middleware, setup_routes, setup_swagger
|
||||||
|
|
||||||
|
__all__ = ["setup_routes", "setup_swagger", "cors_middleware"]
|
||||||
@@ -0,0 +1,660 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from contextlib import suppress
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
log = logging.getLogger("download_manager")
|
||||||
|
|
||||||
|
|
||||||
|
class JobStatus(Enum):
|
||||||
|
QUEUED = "queued"
|
||||||
|
DOWNLOADING = "downloading"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DownloadJob:
|
||||||
|
"""Represents a download job with all its parameters and status."""
|
||||||
|
|
||||||
|
job_id: str
|
||||||
|
status: JobStatus
|
||||||
|
created_time: datetime
|
||||||
|
service: str
|
||||||
|
title_id: str
|
||||||
|
parameters: Dict[str, Any]
|
||||||
|
|
||||||
|
# Progress tracking
|
||||||
|
started_time: Optional[datetime] = None
|
||||||
|
completed_time: Optional[datetime] = None
|
||||||
|
progress: float = 0.0
|
||||||
|
|
||||||
|
# Results and error info
|
||||||
|
output_files: List[str] = field(default_factory=list)
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
error_details: Optional[str] = None
|
||||||
|
error_code: Optional[str] = None
|
||||||
|
error_traceback: Optional[str] = None
|
||||||
|
worker_stderr: Optional[str] = None
|
||||||
|
|
||||||
|
# Cancellation support
|
||||||
|
cancel_event: threading.Event = field(default_factory=threading.Event)
|
||||||
|
|
||||||
|
def to_dict(self, include_full_details: bool = False) -> Dict[str, Any]:
|
||||||
|
"""Convert job to dictionary for JSON response."""
|
||||||
|
result = {
|
||||||
|
"job_id": self.job_id,
|
||||||
|
"status": self.status.value,
|
||||||
|
"created_time": self.created_time.isoformat(),
|
||||||
|
"service": self.service,
|
||||||
|
"title_id": self.title_id,
|
||||||
|
"progress": self.progress,
|
||||||
|
}
|
||||||
|
|
||||||
|
if include_full_details:
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"parameters": self.parameters,
|
||||||
|
"started_time": self.started_time.isoformat() if self.started_time else None,
|
||||||
|
"completed_time": self.completed_time.isoformat() if self.completed_time else None,
|
||||||
|
"output_files": self.output_files,
|
||||||
|
"error_message": self.error_message,
|
||||||
|
"error_details": self.error_details,
|
||||||
|
"error_code": self.error_code,
|
||||||
|
"error_traceback": self.error_traceback,
|
||||||
|
"worker_stderr": self.worker_stderr,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _perform_download(
|
||||||
|
job_id: str,
|
||||||
|
service: str,
|
||||||
|
title_id: str,
|
||||||
|
params: Dict[str, Any],
|
||||||
|
cancel_event: Optional[threading.Event] = None,
|
||||||
|
progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None,
|
||||||
|
) -> List[str]:
|
||||||
|
"""Execute the synchronous download logic for a job."""
|
||||||
|
|
||||||
|
def _check_cancel(stage: str):
|
||||||
|
if cancel_event and cancel_event.is_set():
|
||||||
|
raise Exception(f"Job was cancelled {stage}")
|
||||||
|
|
||||||
|
from contextlib import redirect_stderr, redirect_stdout
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
|
_check_cancel("before execution started")
|
||||||
|
|
||||||
|
# Import dl.py components lazily to avoid circular deps during module import
|
||||||
|
import click
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from envied.commands.dl import dl
|
||||||
|
from envied.core.config import config
|
||||||
|
from envied.core.services import Services
|
||||||
|
from envied.core.utils.click_types import ContextData
|
||||||
|
from envied.core.utils.collections import merge_dict
|
||||||
|
|
||||||
|
log.info(f"Starting sync download for job {job_id}")
|
||||||
|
|
||||||
|
# Load service configuration
|
||||||
|
service_config_path = Services.get_path(service) / config.filenames.config
|
||||||
|
if service_config_path.exists():
|
||||||
|
service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8"))
|
||||||
|
else:
|
||||||
|
service_config = {}
|
||||||
|
merge_dict(config.services.get(service), service_config)
|
||||||
|
|
||||||
|
from envied.commands.dl import dl as dl_command
|
||||||
|
|
||||||
|
ctx = click.Context(dl_command.cli)
|
||||||
|
ctx.invoked_subcommand = service
|
||||||
|
ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=[], profile=params.get("profile"))
|
||||||
|
ctx.params = {
|
||||||
|
"proxy": params.get("proxy"),
|
||||||
|
"no_proxy": params.get("no_proxy", False),
|
||||||
|
"profile": params.get("profile"),
|
||||||
|
"tag": params.get("tag"),
|
||||||
|
"tmdb_id": params.get("tmdb_id"),
|
||||||
|
"tmdb_name": params.get("tmdb_name", False),
|
||||||
|
"tmdb_year": params.get("tmdb_year", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
dl_instance = dl(
|
||||||
|
ctx=ctx,
|
||||||
|
no_proxy=params.get("no_proxy", False),
|
||||||
|
profile=params.get("profile"),
|
||||||
|
proxy=params.get("proxy"),
|
||||||
|
tag=params.get("tag"),
|
||||||
|
tmdb_id=params.get("tmdb_id"),
|
||||||
|
tmdb_name=params.get("tmdb_name", False),
|
||||||
|
tmdb_year=params.get("tmdb_year", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
service_module = Services.load(service)
|
||||||
|
|
||||||
|
_check_cancel("before service instantiation")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
service_init_params = inspect.signature(service_module.__init__).parameters
|
||||||
|
|
||||||
|
service_ctx = click.Context(click.Command(service))
|
||||||
|
service_ctx.parent = ctx
|
||||||
|
service_ctx.obj = ctx.obj
|
||||||
|
|
||||||
|
service_kwargs = {}
|
||||||
|
|
||||||
|
if "title" in service_init_params:
|
||||||
|
service_kwargs["title"] = title_id
|
||||||
|
|
||||||
|
for key, value in params.items():
|
||||||
|
if key in service_init_params and key not in ["service", "title_id"]:
|
||||||
|
service_kwargs[key] = value
|
||||||
|
|
||||||
|
for param_name, param_info in service_init_params.items():
|
||||||
|
if param_name not in service_kwargs and param_name not in ["self", "ctx"]:
|
||||||
|
if param_info.default is inspect.Parameter.empty:
|
||||||
|
if param_name == "movie":
|
||||||
|
service_kwargs[param_name] = "/movies/" in title_id
|
||||||
|
elif param_name == "meta_lang":
|
||||||
|
service_kwargs[param_name] = None
|
||||||
|
else:
|
||||||
|
log.warning(f"Unknown required parameter '{param_name}' for service {service}, using None")
|
||||||
|
service_kwargs[param_name] = None
|
||||||
|
|
||||||
|
service_instance = service_module(service_ctx, **service_kwargs)
|
||||||
|
|
||||||
|
except Exception as exc: # noqa: BLE001 - propagate meaningful failure
|
||||||
|
log.error(f"Failed to create service instance: {exc}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
original_download_dir = config.directories.downloads
|
||||||
|
|
||||||
|
_check_cancel("before download execution")
|
||||||
|
|
||||||
|
stdout_capture = StringIO()
|
||||||
|
stderr_capture = StringIO()
|
||||||
|
|
||||||
|
# Simple progress tracking if callback provided
|
||||||
|
if progress_callback:
|
||||||
|
# Report initial progress
|
||||||
|
progress_callback({"progress": 0.0, "status": "starting"})
|
||||||
|
|
||||||
|
# Simple approach: report progress at key points
|
||||||
|
original_result = dl_instance.result
|
||||||
|
|
||||||
|
def result_with_progress(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
# Report that download started
|
||||||
|
progress_callback({"progress": 5.0, "status": "downloading"})
|
||||||
|
|
||||||
|
# Call original method
|
||||||
|
result = original_result(*args, **kwargs)
|
||||||
|
|
||||||
|
# Report completion
|
||||||
|
progress_callback({"progress": 100.0, "status": "completed"})
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
progress_callback({"progress": 0.0, "status": "failed", "error": str(e)})
|
||||||
|
raise
|
||||||
|
|
||||||
|
dl_instance.result = result_with_progress
|
||||||
|
|
||||||
|
try:
|
||||||
|
with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
|
||||||
|
dl_instance.result(
|
||||||
|
service=service_instance,
|
||||||
|
quality=params.get("quality", []),
|
||||||
|
vcodec=params.get("vcodec"),
|
||||||
|
acodec=params.get("acodec"),
|
||||||
|
vbitrate=params.get("vbitrate"),
|
||||||
|
abitrate=params.get("abitrate"),
|
||||||
|
range_=params.get("range", ["SDR"]),
|
||||||
|
channels=params.get("channels"),
|
||||||
|
no_atmos=params.get("no_atmos", False),
|
||||||
|
wanted=params.get("wanted", []),
|
||||||
|
latest_episode=params.get("latest_episode", False),
|
||||||
|
lang=params.get("lang", ["orig"]),
|
||||||
|
v_lang=params.get("v_lang", []),
|
||||||
|
a_lang=params.get("a_lang", []),
|
||||||
|
s_lang=params.get("s_lang", ["all"]),
|
||||||
|
require_subs=params.get("require_subs", []),
|
||||||
|
forced_subs=params.get("forced_subs", False),
|
||||||
|
exact_lang=params.get("exact_lang", False),
|
||||||
|
sub_format=params.get("sub_format"),
|
||||||
|
video_only=params.get("video_only", False),
|
||||||
|
audio_only=params.get("audio_only", False),
|
||||||
|
subs_only=params.get("subs_only", False),
|
||||||
|
chapters_only=params.get("chapters_only", False),
|
||||||
|
no_subs=params.get("no_subs", False),
|
||||||
|
no_audio=params.get("no_audio", False),
|
||||||
|
no_chapters=params.get("no_chapters", False),
|
||||||
|
audio_description=params.get("audio_description", False),
|
||||||
|
slow=params.get("slow", False),
|
||||||
|
list_=False,
|
||||||
|
list_titles=False,
|
||||||
|
skip_dl=params.get("skip_dl", False),
|
||||||
|
export=params.get("export"),
|
||||||
|
cdm_only=params.get("cdm_only"),
|
||||||
|
no_proxy=params.get("no_proxy", False),
|
||||||
|
no_folder=params.get("no_folder", False),
|
||||||
|
no_source=params.get("no_source", False),
|
||||||
|
no_mux=params.get("no_mux", False),
|
||||||
|
workers=params.get("workers"),
|
||||||
|
downloads=params.get("downloads", 1),
|
||||||
|
best_available=params.get("best_available", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
except SystemExit as exc:
|
||||||
|
if exc.code != 0:
|
||||||
|
stdout_str = stdout_capture.getvalue()
|
||||||
|
stderr_str = stderr_capture.getvalue()
|
||||||
|
log.error(f"Download exited with code {exc.code}")
|
||||||
|
log.error(f"Stdout: {stdout_str}")
|
||||||
|
log.error(f"Stderr: {stderr_str}")
|
||||||
|
raise Exception(f"Download failed with exit code {exc.code}")
|
||||||
|
|
||||||
|
except Exception as exc: # noqa: BLE001 - propagate to caller
|
||||||
|
stdout_str = stdout_capture.getvalue()
|
||||||
|
stderr_str = stderr_capture.getvalue()
|
||||||
|
log.error(f"Download execution failed: {exc}")
|
||||||
|
log.error(f"Stdout: {stdout_str}")
|
||||||
|
log.error(f"Stderr: {stderr_str}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
log.info(f"Download completed for job {job_id}, files in {original_download_dir}")
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadQueueManager:
|
||||||
|
"""Manages download job queue with configurable concurrency limits."""
|
||||||
|
|
||||||
|
def __init__(self, max_concurrent_downloads: int = 2, job_retention_hours: int = 24):
|
||||||
|
self.max_concurrent_downloads = max_concurrent_downloads
|
||||||
|
self.job_retention_hours = job_retention_hours
|
||||||
|
|
||||||
|
self._jobs: Dict[str, DownloadJob] = {}
|
||||||
|
self._job_queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
self._active_downloads: Dict[str, asyncio.Task] = {}
|
||||||
|
self._download_processes: Dict[str, asyncio.subprocess.Process] = {}
|
||||||
|
self._job_temp_files: Dict[str, Dict[str, str]] = {}
|
||||||
|
self._workers_started = False
|
||||||
|
self._shutdown_event = asyncio.Event()
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
f"Initialized download queue manager: max_concurrent={max_concurrent_downloads}, retention_hours={job_retention_hours}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_job(self, service: str, title_id: str, **parameters) -> DownloadJob:
|
||||||
|
"""Create a new download job and add it to the queue."""
|
||||||
|
job_id = str(uuid.uuid4())
|
||||||
|
job = DownloadJob(
|
||||||
|
job_id=job_id,
|
||||||
|
status=JobStatus.QUEUED,
|
||||||
|
created_time=datetime.now(),
|
||||||
|
service=service,
|
||||||
|
title_id=title_id,
|
||||||
|
parameters=parameters,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._jobs[job_id] = job
|
||||||
|
self._job_queue.put_nowait(job)
|
||||||
|
|
||||||
|
log.info(f"Created download job {job_id} for {service}:{title_id}")
|
||||||
|
return job
|
||||||
|
|
||||||
|
def get_job(self, job_id: str) -> Optional[DownloadJob]:
|
||||||
|
"""Get job by ID."""
|
||||||
|
return self._jobs.get(job_id)
|
||||||
|
|
||||||
|
def list_jobs(self) -> List[DownloadJob]:
|
||||||
|
"""List all jobs."""
|
||||||
|
return list(self._jobs.values())
|
||||||
|
|
||||||
|
def cancel_job(self, job_id: str) -> bool:
|
||||||
|
"""Cancel a job if it's queued or downloading."""
|
||||||
|
job = self._jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if job.status == JobStatus.QUEUED:
|
||||||
|
job.status = JobStatus.CANCELLED
|
||||||
|
job.cancel_event.set() # Signal cancellation
|
||||||
|
log.info(f"Cancelled queued job {job_id}")
|
||||||
|
return True
|
||||||
|
elif job.status == JobStatus.DOWNLOADING:
|
||||||
|
# Set the cancellation event first - this will be checked by the download thread
|
||||||
|
job.cancel_event.set()
|
||||||
|
job.status = JobStatus.CANCELLED
|
||||||
|
log.info(f"Signaled cancellation for downloading job {job_id}")
|
||||||
|
|
||||||
|
# Cancel the active download task
|
||||||
|
task = self._active_downloads.get(job_id)
|
||||||
|
if task:
|
||||||
|
task.cancel()
|
||||||
|
log.info(f"Cancelled download task for job {job_id}")
|
||||||
|
|
||||||
|
process = self._download_processes.get(job_id)
|
||||||
|
if process:
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
log.info(f"Terminated worker process for job {job_id}")
|
||||||
|
except ProcessLookupError:
|
||||||
|
log.debug(f"Worker process for job {job_id} already exited")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def cleanup_old_jobs(self) -> int:
|
||||||
|
"""Remove jobs older than retention period."""
|
||||||
|
cutoff_time = datetime.now() - timedelta(hours=self.job_retention_hours)
|
||||||
|
jobs_to_remove = []
|
||||||
|
|
||||||
|
for job_id, job in self._jobs.items():
|
||||||
|
if job.status in [JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED]:
|
||||||
|
if job.completed_time and job.completed_time < cutoff_time:
|
||||||
|
jobs_to_remove.append(job_id)
|
||||||
|
elif not job.completed_time and job.created_time < cutoff_time:
|
||||||
|
jobs_to_remove.append(job_id)
|
||||||
|
|
||||||
|
for job_id in jobs_to_remove:
|
||||||
|
del self._jobs[job_id]
|
||||||
|
|
||||||
|
if jobs_to_remove:
|
||||||
|
log.info(f"Cleaned up {len(jobs_to_remove)} old jobs")
|
||||||
|
|
||||||
|
return len(jobs_to_remove)
|
||||||
|
|
||||||
|
async def start_workers(self):
|
||||||
|
"""Start worker tasks to process the download queue."""
|
||||||
|
if self._workers_started:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._workers_started = True
|
||||||
|
|
||||||
|
# Start worker tasks
|
||||||
|
for i in range(self.max_concurrent_downloads):
|
||||||
|
asyncio.create_task(self._download_worker(f"worker-{i}"))
|
||||||
|
|
||||||
|
# Start cleanup task
|
||||||
|
asyncio.create_task(self._cleanup_worker())
|
||||||
|
|
||||||
|
log.info(f"Started {self.max_concurrent_downloads} download workers")
|
||||||
|
|
||||||
|
async def shutdown(self):
|
||||||
|
"""Shutdown the queue manager and cancel all active downloads."""
|
||||||
|
log.info("Shutting down download queue manager")
|
||||||
|
self._shutdown_event.set()
|
||||||
|
|
||||||
|
# Cancel all active downloads
|
||||||
|
for task in self._active_downloads.values():
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
# Terminate worker processes
|
||||||
|
for job_id, process in list(self._download_processes.items()):
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
except ProcessLookupError:
|
||||||
|
log.debug(f"Worker process for job {job_id} already exited during shutdown")
|
||||||
|
|
||||||
|
for job_id, process in list(self._download_processes.items()):
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(process.wait(), timeout=5)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
log.warning(f"Worker process for job {job_id} did not exit, killing")
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
finally:
|
||||||
|
self._download_processes.pop(job_id, None)
|
||||||
|
|
||||||
|
# Clean up any remaining temp files
|
||||||
|
for paths in self._job_temp_files.values():
|
||||||
|
for path in paths.values():
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self._job_temp_files.clear()
|
||||||
|
|
||||||
|
# Wait for workers to finish
|
||||||
|
if self._active_downloads:
|
||||||
|
await asyncio.gather(*self._active_downloads.values(), return_exceptions=True)
|
||||||
|
|
||||||
|
async def _download_worker(self, worker_name: str):
|
||||||
|
"""Worker task that processes jobs from the queue."""
|
||||||
|
log.debug(f"Download worker {worker_name} started")
|
||||||
|
|
||||||
|
while not self._shutdown_event.is_set():
|
||||||
|
try:
|
||||||
|
# Wait for a job or shutdown signal
|
||||||
|
job = await asyncio.wait_for(self._job_queue.get(), timeout=1.0)
|
||||||
|
|
||||||
|
if job.status == JobStatus.CANCELLED:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Start processing the job
|
||||||
|
job.status = JobStatus.DOWNLOADING
|
||||||
|
job.started_time = datetime.now()
|
||||||
|
|
||||||
|
log.info(f"Worker {worker_name} starting job {job.job_id}")
|
||||||
|
|
||||||
|
# Create download task
|
||||||
|
download_task = asyncio.create_task(self._execute_download(job))
|
||||||
|
self._active_downloads[job.job_id] = download_task
|
||||||
|
|
||||||
|
try:
|
||||||
|
await download_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
job.status = JobStatus.CANCELLED
|
||||||
|
log.info(f"Job {job.job_id} was cancelled")
|
||||||
|
except Exception as e:
|
||||||
|
job.status = JobStatus.FAILED
|
||||||
|
job.error_message = str(e)
|
||||||
|
log.error(f"Job {job.job_id} failed: {e}")
|
||||||
|
finally:
|
||||||
|
job.completed_time = datetime.now()
|
||||||
|
if job.job_id in self._active_downloads:
|
||||||
|
del self._active_downloads[job.job_id]
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Worker {worker_name} error: {e}")
|
||||||
|
|
||||||
|
async def _execute_download(self, job: DownloadJob):
|
||||||
|
"""Execute the actual download for a job."""
|
||||||
|
log.info(f"Executing download for job {job.job_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
output_files = await self._run_download_async(job)
|
||||||
|
job.status = JobStatus.COMPLETED
|
||||||
|
job.output_files = output_files
|
||||||
|
job.progress = 100.0
|
||||||
|
log.info(f"Download completed for job {job.job_id}: {len(output_files)} files")
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from envied.core.api.errors import categorize_exception
|
||||||
|
|
||||||
|
job.status = JobStatus.FAILED
|
||||||
|
job.error_message = str(e)
|
||||||
|
job.error_details = str(e)
|
||||||
|
|
||||||
|
api_error = categorize_exception(
|
||||||
|
e, context={"service": job.service, "title_id": job.title_id, "job_id": job.job_id}
|
||||||
|
)
|
||||||
|
job.error_code = api_error.error_code.value
|
||||||
|
|
||||||
|
job.error_traceback = traceback.format_exc()
|
||||||
|
|
||||||
|
log.error(f"Download failed for job {job.job_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _run_download_async(self, job: DownloadJob) -> List[str]:
|
||||||
|
"""Invoke a worker subprocess to execute the download."""
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"job_id": job.job_id,
|
||||||
|
"service": job.service,
|
||||||
|
"title_id": job.title_id,
|
||||||
|
"parameters": job.parameters,
|
||||||
|
}
|
||||||
|
|
||||||
|
payload_fd, payload_path = tempfile.mkstemp(prefix=f"unshackle_job_{job.job_id}_", suffix="_payload.json")
|
||||||
|
os.close(payload_fd)
|
||||||
|
result_fd, result_path = tempfile.mkstemp(prefix=f"unshackle_job_{job.job_id}_", suffix="_result.json")
|
||||||
|
os.close(result_fd)
|
||||||
|
progress_fd, progress_path = tempfile.mkstemp(prefix=f"unshackle_job_{job.job_id}_", suffix="_progress.json")
|
||||||
|
os.close(progress_fd)
|
||||||
|
|
||||||
|
with open(payload_path, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(payload, handle)
|
||||||
|
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"envied.core.api.download_worker",
|
||||||
|
payload_path,
|
||||||
|
result_path,
|
||||||
|
progress_path,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._download_processes[job.job_id] = process
|
||||||
|
self._job_temp_files[job.job_id] = {"payload": payload_path, "result": result_path, "progress": progress_path}
|
||||||
|
|
||||||
|
communicate_task = asyncio.create_task(process.communicate())
|
||||||
|
|
||||||
|
stdout_bytes = b""
|
||||||
|
stderr_bytes = b""
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
done, _ = await asyncio.wait({communicate_task}, timeout=0.5)
|
||||||
|
if communicate_task in done:
|
||||||
|
stdout_bytes, stderr_bytes = communicate_task.result()
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check for progress updates
|
||||||
|
try:
|
||||||
|
if os.path.exists(progress_path):
|
||||||
|
with open(progress_path, "r", encoding="utf-8") as handle:
|
||||||
|
progress_data = json.load(handle)
|
||||||
|
if "progress" in progress_data:
|
||||||
|
new_progress = float(progress_data["progress"])
|
||||||
|
if new_progress != job.progress:
|
||||||
|
job.progress = new_progress
|
||||||
|
log.info(f"Job {job.job_id} progress updated: {job.progress}%")
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, ValueError) as e:
|
||||||
|
log.debug(f"Could not read progress for job {job.job_id}: {e}")
|
||||||
|
|
||||||
|
if job.cancel_event.is_set() or job.status == JobStatus.CANCELLED:
|
||||||
|
log.info(f"Cancellation detected for job {job.job_id}, terminating worker process")
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(communicate_task, timeout=5)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
log.warning(f"Worker process for job {job.job_id} did not terminate, killing")
|
||||||
|
process.kill()
|
||||||
|
await asyncio.wait_for(communicate_task, timeout=5)
|
||||||
|
raise asyncio.CancelledError("Job was cancelled")
|
||||||
|
|
||||||
|
returncode = process.returncode
|
||||||
|
stdout = stdout_bytes.decode("utf-8", errors="ignore")
|
||||||
|
stderr = stderr_bytes.decode("utf-8", errors="ignore")
|
||||||
|
|
||||||
|
if stdout.strip():
|
||||||
|
log.debug(f"Worker stdout for job {job.job_id}: {stdout.strip()}")
|
||||||
|
if stderr.strip():
|
||||||
|
log.warning(f"Worker stderr for job {job.job_id}: {stderr.strip()}")
|
||||||
|
job.worker_stderr = stderr.strip()
|
||||||
|
|
||||||
|
result_data: Optional[Dict[str, Any]] = None
|
||||||
|
try:
|
||||||
|
with open(result_path, "r", encoding="utf-8") as handle:
|
||||||
|
result_data = json.load(handle)
|
||||||
|
except FileNotFoundError:
|
||||||
|
log.error(f"Result file missing for job {job.job_id}")
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
log.error(f"Failed to parse worker result for job {job.job_id}: {exc}")
|
||||||
|
|
||||||
|
if returncode != 0:
|
||||||
|
message = result_data.get("message") if result_data else "unknown error"
|
||||||
|
if result_data:
|
||||||
|
job.error_details = result_data.get("error_details", message)
|
||||||
|
job.error_code = result_data.get("error_code")
|
||||||
|
raise Exception(f"Worker exited with code {returncode}: {message}")
|
||||||
|
|
||||||
|
if not result_data or result_data.get("status") != "success":
|
||||||
|
message = result_data.get("message") if result_data else "worker did not report success"
|
||||||
|
if result_data:
|
||||||
|
job.error_details = result_data.get("error_details", message)
|
||||||
|
job.error_code = result_data.get("error_code")
|
||||||
|
raise Exception(f"Worker failure: {message}")
|
||||||
|
|
||||||
|
return result_data.get("output_files", [])
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if not communicate_task.done():
|
||||||
|
communicate_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await communicate_task
|
||||||
|
|
||||||
|
self._download_processes.pop(job.job_id, None)
|
||||||
|
|
||||||
|
temp_paths = self._job_temp_files.pop(job.job_id, {})
|
||||||
|
for path in temp_paths.values():
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _execute_download_sync(self, job: DownloadJob) -> List[str]:
|
||||||
|
"""Execute download synchronously using existing dl.py logic."""
|
||||||
|
return _perform_download(job.job_id, job.service, job.title_id, job.parameters.copy(), job.cancel_event)
|
||||||
|
|
||||||
|
async def _cleanup_worker(self):
|
||||||
|
"""Worker that periodically cleans up old jobs."""
|
||||||
|
while not self._shutdown_event.is_set():
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(3600) # Run every hour
|
||||||
|
self.cleanup_old_jobs()
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Cleanup worker error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
download_manager: Optional[DownloadQueueManager] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_download_manager() -> DownloadQueueManager:
|
||||||
|
"""Get the global download manager instance."""
|
||||||
|
global download_manager
|
||||||
|
if download_manager is None:
|
||||||
|
# Load configuration from unshackle config
|
||||||
|
from envied.core.config import config
|
||||||
|
|
||||||
|
max_concurrent = getattr(config, "max_concurrent_downloads", 2)
|
||||||
|
retention_hours = getattr(config, "download_job_retention_hours", 24)
|
||||||
|
|
||||||
|
download_manager = DownloadQueueManager(max_concurrent, retention_hours)
|
||||||
|
|
||||||
|
return download_manager
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Standalone worker process entry point for executing download jobs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from .download_manager import _perform_download
|
||||||
|
|
||||||
|
log = logging.getLogger("download_worker")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_payload(path: Path) -> Dict[str, Any]:
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
return json.load(handle)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_result(path: Path, payload: Dict[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("w", encoding="utf-8") as handle:
|
||||||
|
json.dump(payload, handle)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
if len(argv) not in [3, 4]:
|
||||||
|
print(
|
||||||
|
"Usage: python -m envied.core.api.download_worker <payload_path> <result_path> [progress_path]",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
payload_path = Path(argv[1])
|
||||||
|
result_path = Path(argv[2])
|
||||||
|
progress_path = Path(argv[3]) if len(argv) > 3 else None
|
||||||
|
|
||||||
|
result: Dict[str, Any] = {}
|
||||||
|
exit_code = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = _read_payload(payload_path)
|
||||||
|
job_id = payload["job_id"]
|
||||||
|
service = payload["service"]
|
||||||
|
title_id = payload["title_id"]
|
||||||
|
params = payload.get("parameters", {})
|
||||||
|
|
||||||
|
log.info(f"Worker starting job {job_id} ({service}:{title_id})")
|
||||||
|
|
||||||
|
def progress_callback(progress_data: Dict[str, Any]) -> None:
|
||||||
|
"""Write progress updates to file for main process to read."""
|
||||||
|
if progress_path:
|
||||||
|
try:
|
||||||
|
log.info(f"Writing progress update: {progress_data}")
|
||||||
|
_write_result(progress_path, progress_data)
|
||||||
|
log.info(f"Progress update written to {progress_path}")
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Failed to write progress update: {e}")
|
||||||
|
|
||||||
|
output_files = _perform_download(
|
||||||
|
job_id, service, title_id, params, cancel_event=None, progress_callback=progress_callback
|
||||||
|
)
|
||||||
|
|
||||||
|
result = {"status": "success", "output_files": output_files}
|
||||||
|
|
||||||
|
except Exception as exc: # noqa: BLE001 - capture for parent process
|
||||||
|
from envied.core.api.errors import categorize_exception
|
||||||
|
|
||||||
|
exit_code = 1
|
||||||
|
tb = traceback.format_exc()
|
||||||
|
log.error(f"Worker failed with error: {exc}")
|
||||||
|
|
||||||
|
api_error = categorize_exception(
|
||||||
|
exc,
|
||||||
|
context={
|
||||||
|
"service": payload.get("service") if "payload" in locals() else None,
|
||||||
|
"title_id": payload.get("title_id") if "payload" in locals() else None,
|
||||||
|
"job_id": payload.get("job_id") if "payload" in locals() else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"status": "error",
|
||||||
|
"message": str(exc),
|
||||||
|
"error_details": api_error.message,
|
||||||
|
"error_code": api_error.error_code.value,
|
||||||
|
"traceback": tb,
|
||||||
|
}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
_write_result(result_path, result)
|
||||||
|
except Exception as exc: # noqa: BLE001 - last resort logging
|
||||||
|
log.error(f"Failed to write worker result file: {exc}")
|
||||||
|
|
||||||
|
return exit_code
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv))
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
"""
|
||||||
|
API Error Handling System
|
||||||
|
|
||||||
|
Provides structured error responses with error codes, categorization,
|
||||||
|
and optional debug information for the unshackle REST API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import traceback
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
|
||||||
|
class APIErrorCode(str, Enum):
|
||||||
|
"""Standard API error codes for programmatic error handling."""
|
||||||
|
|
||||||
|
# Client errors (4xx)
|
||||||
|
INVALID_INPUT = "INVALID_INPUT" # Missing or malformed request data
|
||||||
|
INVALID_SERVICE = "INVALID_SERVICE" # Unknown service name
|
||||||
|
INVALID_TITLE_ID = "INVALID_TITLE_ID" # Invalid or malformed title ID
|
||||||
|
INVALID_PROFILE = "INVALID_PROFILE" # Profile doesn't exist
|
||||||
|
INVALID_PROXY = "INVALID_PROXY" # Invalid proxy specification
|
||||||
|
INVALID_LANGUAGE = "INVALID_LANGUAGE" # Invalid language code
|
||||||
|
INVALID_PARAMETERS = "INVALID_PARAMETERS" # Invalid download parameters
|
||||||
|
|
||||||
|
AUTH_FAILED = "AUTH_FAILED" # Authentication failure (invalid credentials/cookies)
|
||||||
|
AUTH_REQUIRED = "AUTH_REQUIRED" # Missing authentication
|
||||||
|
FORBIDDEN = "FORBIDDEN" # Action not allowed
|
||||||
|
GEOFENCE = "GEOFENCE" # Content not available in region
|
||||||
|
|
||||||
|
NOT_FOUND = "NOT_FOUND" # Resource not found (title, job, etc.)
|
||||||
|
NO_CONTENT = "NO_CONTENT" # No titles/tracks/episodes found
|
||||||
|
JOB_NOT_FOUND = "JOB_NOT_FOUND" # Download job doesn't exist
|
||||||
|
|
||||||
|
RATE_LIMITED = "RATE_LIMITED" # Service rate limiting
|
||||||
|
|
||||||
|
# Server errors (5xx)
|
||||||
|
INTERNAL_ERROR = "INTERNAL_ERROR" # Unexpected server error
|
||||||
|
SERVICE_ERROR = "SERVICE_ERROR" # Streaming service API error
|
||||||
|
NETWORK_ERROR = "NETWORK_ERROR" # Network connectivity issue
|
||||||
|
DRM_ERROR = "DRM_ERROR" # DRM/license acquisition failure
|
||||||
|
DOWNLOAD_ERROR = "DOWNLOAD_ERROR" # Download process failure
|
||||||
|
SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE" # Service temporarily unavailable
|
||||||
|
WORKER_ERROR = "WORKER_ERROR" # Download worker process error
|
||||||
|
|
||||||
|
|
||||||
|
class APIError(Exception):
|
||||||
|
"""
|
||||||
|
Structured API error with error code, message, and details.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
error_code: Standardized error code from APIErrorCode enum
|
||||||
|
message: User-friendly error message
|
||||||
|
details: Additional structured error information
|
||||||
|
retryable: Whether the operation can be retried
|
||||||
|
http_status: HTTP status code to return (default based on error_code)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
error_code: APIErrorCode,
|
||||||
|
message: str,
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
retryable: bool = False,
|
||||||
|
http_status: int | None = None,
|
||||||
|
):
|
||||||
|
super().__init__(message)
|
||||||
|
self.error_code = error_code
|
||||||
|
self.message = message
|
||||||
|
self.details = details or {}
|
||||||
|
self.retryable = retryable
|
||||||
|
self.http_status = http_status or self._default_http_status(error_code)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _default_http_status(error_code: APIErrorCode) -> int:
|
||||||
|
"""Map error codes to default HTTP status codes."""
|
||||||
|
status_map = {
|
||||||
|
# 400 Bad Request
|
||||||
|
APIErrorCode.INVALID_INPUT: 400,
|
||||||
|
APIErrorCode.INVALID_SERVICE: 400,
|
||||||
|
APIErrorCode.INVALID_TITLE_ID: 400,
|
||||||
|
APIErrorCode.INVALID_PROFILE: 400,
|
||||||
|
APIErrorCode.INVALID_PROXY: 400,
|
||||||
|
APIErrorCode.INVALID_LANGUAGE: 400,
|
||||||
|
APIErrorCode.INVALID_PARAMETERS: 400,
|
||||||
|
# 401 Unauthorized
|
||||||
|
APIErrorCode.AUTH_REQUIRED: 401,
|
||||||
|
APIErrorCode.AUTH_FAILED: 401,
|
||||||
|
# 403 Forbidden
|
||||||
|
APIErrorCode.FORBIDDEN: 403,
|
||||||
|
APIErrorCode.GEOFENCE: 403,
|
||||||
|
# 404 Not Found
|
||||||
|
APIErrorCode.NOT_FOUND: 404,
|
||||||
|
APIErrorCode.NO_CONTENT: 404,
|
||||||
|
APIErrorCode.JOB_NOT_FOUND: 404,
|
||||||
|
# 429 Too Many Requests
|
||||||
|
APIErrorCode.RATE_LIMITED: 429,
|
||||||
|
# 500 Internal Server Error
|
||||||
|
APIErrorCode.INTERNAL_ERROR: 500,
|
||||||
|
# 502 Bad Gateway
|
||||||
|
APIErrorCode.SERVICE_ERROR: 502,
|
||||||
|
APIErrorCode.DRM_ERROR: 502,
|
||||||
|
# 503 Service Unavailable
|
||||||
|
APIErrorCode.NETWORK_ERROR: 503,
|
||||||
|
APIErrorCode.SERVICE_UNAVAILABLE: 503,
|
||||||
|
APIErrorCode.DOWNLOAD_ERROR: 500,
|
||||||
|
APIErrorCode.WORKER_ERROR: 500,
|
||||||
|
}
|
||||||
|
return status_map.get(error_code, 500)
|
||||||
|
|
||||||
|
|
||||||
|
def build_error_response(
|
||||||
|
error: APIError | Exception,
|
||||||
|
debug_mode: bool = False,
|
||||||
|
extra_debug_info: dict[str, Any] | None = None,
|
||||||
|
) -> web.Response:
|
||||||
|
"""
|
||||||
|
Build a structured JSON error response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error: APIError or generic Exception to convert to response
|
||||||
|
debug_mode: Whether to include technical debug information
|
||||||
|
extra_debug_info: Additional debug info (stderr, stdout, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
aiohttp JSON response with structured error data
|
||||||
|
"""
|
||||||
|
if isinstance(error, APIError):
|
||||||
|
error_code = error.error_code.value
|
||||||
|
message = error.message
|
||||||
|
details = error.details
|
||||||
|
http_status = error.http_status
|
||||||
|
retryable = error.retryable
|
||||||
|
else:
|
||||||
|
# Generic exception - convert to INTERNAL_ERROR
|
||||||
|
error_code = APIErrorCode.INTERNAL_ERROR.value
|
||||||
|
message = str(error) or "An unexpected error occurred"
|
||||||
|
details = {}
|
||||||
|
http_status = 500
|
||||||
|
retryable = False
|
||||||
|
|
||||||
|
response_data: dict[str, Any] = {
|
||||||
|
"status": "error",
|
||||||
|
"error_code": error_code,
|
||||||
|
"message": message,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add details if present
|
||||||
|
if details:
|
||||||
|
response_data["details"] = details
|
||||||
|
|
||||||
|
# Add retryable hint if specified
|
||||||
|
if retryable:
|
||||||
|
response_data["retryable"] = True
|
||||||
|
|
||||||
|
# Add debug information if in debug mode
|
||||||
|
if debug_mode:
|
||||||
|
debug_info: dict[str, Any] = {
|
||||||
|
"exception_type": type(error).__name__,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add traceback for debugging
|
||||||
|
if isinstance(error, Exception):
|
||||||
|
debug_info["traceback"] = traceback.format_exc()
|
||||||
|
|
||||||
|
# Add any extra debug info provided
|
||||||
|
if extra_debug_info:
|
||||||
|
debug_info.update(extra_debug_info)
|
||||||
|
|
||||||
|
response_data["debug_info"] = debug_info
|
||||||
|
|
||||||
|
return web.json_response(response_data, status=http_status)
|
||||||
|
|
||||||
|
|
||||||
|
def categorize_exception(
|
||||||
|
exc: Exception,
|
||||||
|
context: dict[str, Any] | None = None,
|
||||||
|
) -> APIError:
|
||||||
|
"""
|
||||||
|
Categorize a generic exception into a structured APIError.
|
||||||
|
|
||||||
|
This function attempts to identify the type of error based on the exception
|
||||||
|
type, message patterns, and optional context information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
exc: The exception to categorize
|
||||||
|
context: Optional context (service name, operation type, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
APIError with appropriate error code and details
|
||||||
|
"""
|
||||||
|
context = context or {}
|
||||||
|
exc_str = str(exc).lower()
|
||||||
|
exc_type = type(exc).__name__
|
||||||
|
|
||||||
|
# Authentication errors
|
||||||
|
if any(keyword in exc_str for keyword in ["auth", "login", "credential", "unauthorized", "forbidden", "token"]):
|
||||||
|
return APIError(
|
||||||
|
error_code=APIErrorCode.AUTH_FAILED,
|
||||||
|
message=f"Authentication failed: {exc}",
|
||||||
|
details={**context, "reason": "authentication_error"},
|
||||||
|
retryable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Network errors
|
||||||
|
if any(
|
||||||
|
keyword in exc_str
|
||||||
|
for keyword in [
|
||||||
|
"connection",
|
||||||
|
"timeout",
|
||||||
|
"network",
|
||||||
|
"unreachable",
|
||||||
|
"socket",
|
||||||
|
"dns",
|
||||||
|
"resolve",
|
||||||
|
]
|
||||||
|
) or exc_type in ["ConnectionError", "TimeoutError", "URLError", "SSLError"]:
|
||||||
|
return APIError(
|
||||||
|
error_code=APIErrorCode.NETWORK_ERROR,
|
||||||
|
message=f"Network error occurred: {exc}",
|
||||||
|
details={**context, "reason": "network_connectivity"},
|
||||||
|
retryable=True,
|
||||||
|
http_status=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Geofence/region errors
|
||||||
|
if any(keyword in exc_str for keyword in ["geofence", "region", "not available in", "territory"]):
|
||||||
|
return APIError(
|
||||||
|
error_code=APIErrorCode.GEOFENCE,
|
||||||
|
message=f"Content not available in your region: {exc}",
|
||||||
|
details={**context, "reason": "geofence_restriction"},
|
||||||
|
retryable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Not found errors
|
||||||
|
if any(keyword in exc_str for keyword in ["not found", "404", "does not exist", "invalid id"]):
|
||||||
|
return APIError(
|
||||||
|
error_code=APIErrorCode.NOT_FOUND,
|
||||||
|
message=f"Resource not found: {exc}",
|
||||||
|
details={**context, "reason": "not_found"},
|
||||||
|
retryable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
if any(keyword in exc_str for keyword in ["rate limit", "too many requests", "429", "throttle"]):
|
||||||
|
return APIError(
|
||||||
|
error_code=APIErrorCode.RATE_LIMITED,
|
||||||
|
message=f"Rate limit exceeded: {exc}",
|
||||||
|
details={**context, "reason": "rate_limited"},
|
||||||
|
retryable=True,
|
||||||
|
http_status=429,
|
||||||
|
)
|
||||||
|
|
||||||
|
# DRM errors
|
||||||
|
if any(keyword in exc_str for keyword in ["drm", "license", "widevine", "playready", "decrypt"]):
|
||||||
|
return APIError(
|
||||||
|
error_code=APIErrorCode.DRM_ERROR,
|
||||||
|
message=f"DRM error: {exc}",
|
||||||
|
details={**context, "reason": "drm_failure"},
|
||||||
|
retryable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Service unavailable
|
||||||
|
if any(keyword in exc_str for keyword in ["service unavailable", "503", "maintenance", "temporarily unavailable"]):
|
||||||
|
return APIError(
|
||||||
|
error_code=APIErrorCode.SERVICE_UNAVAILABLE,
|
||||||
|
message=f"Service temporarily unavailable: {exc}",
|
||||||
|
details={**context, "reason": "service_unavailable"},
|
||||||
|
retryable=True,
|
||||||
|
http_status=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validation errors
|
||||||
|
if any(keyword in exc_str for keyword in ["invalid", "malformed", "validation"]) or exc_type in [
|
||||||
|
"ValueError",
|
||||||
|
"ValidationError",
|
||||||
|
]:
|
||||||
|
return APIError(
|
||||||
|
error_code=APIErrorCode.INVALID_INPUT,
|
||||||
|
message=f"Invalid input: {exc}",
|
||||||
|
details={**context, "reason": "validation_failed"},
|
||||||
|
retryable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default to internal error for unknown exceptions
|
||||||
|
return APIError(
|
||||||
|
error_code=APIErrorCode.INTERNAL_ERROR,
|
||||||
|
message=f"An unexpected error occurred: {exc}",
|
||||||
|
details={**context, "exception_type": exc_type},
|
||||||
|
retryable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_api_exception(
|
||||||
|
exc: Exception,
|
||||||
|
context: dict[str, Any] | None = None,
|
||||||
|
debug_mode: bool = False,
|
||||||
|
extra_debug_info: dict[str, Any] | None = None,
|
||||||
|
) -> web.Response:
|
||||||
|
"""
|
||||||
|
Convenience function to categorize an exception and build an error response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
exc: The exception to handle
|
||||||
|
context: Optional context information
|
||||||
|
debug_mode: Whether to include debug information
|
||||||
|
extra_debug_info: Additional debug info
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Structured JSON error response
|
||||||
|
"""
|
||||||
|
if isinstance(exc, APIError):
|
||||||
|
api_error = exc
|
||||||
|
else:
|
||||||
|
api_error = categorize_exception(exc, context)
|
||||||
|
|
||||||
|
return build_error_response(api_error, debug_mode, extra_debug_info)
|
||||||
@@ -0,0 +1,936 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from envied.core.api.errors import APIError, APIErrorCode, handle_api_exception
|
||||||
|
from envied.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP
|
||||||
|
from envied.core.proxies.basic import Basic
|
||||||
|
from envied.core.proxies.hola import Hola
|
||||||
|
from envied.core.proxies.nordvpn import NordVPN
|
||||||
|
from envied.core.proxies.surfsharkvpn import SurfsharkVPN
|
||||||
|
from envied.core.services import Services
|
||||||
|
from envied.core.titles import Episode, Movie, Title_T
|
||||||
|
from envied.core.tracks import Audio, Subtitle, Video
|
||||||
|
|
||||||
|
log = logging.getLogger("api")
|
||||||
|
|
||||||
|
DEFAULT_DOWNLOAD_PARAMS = {
|
||||||
|
"profile": None,
|
||||||
|
"quality": [],
|
||||||
|
"vcodec": None,
|
||||||
|
"acodec": None,
|
||||||
|
"vbitrate": None,
|
||||||
|
"abitrate": None,
|
||||||
|
"range": ["SDR"],
|
||||||
|
"channels": None,
|
||||||
|
"no_atmos": False,
|
||||||
|
"wanted": [],
|
||||||
|
"latest_episode": False,
|
||||||
|
"lang": ["orig"],
|
||||||
|
"v_lang": [],
|
||||||
|
"a_lang": [],
|
||||||
|
"s_lang": ["all"],
|
||||||
|
"require_subs": [],
|
||||||
|
"forced_subs": False,
|
||||||
|
"exact_lang": False,
|
||||||
|
"sub_format": None,
|
||||||
|
"video_only": False,
|
||||||
|
"audio_only": False,
|
||||||
|
"subs_only": False,
|
||||||
|
"chapters_only": False,
|
||||||
|
"no_subs": False,
|
||||||
|
"no_audio": False,
|
||||||
|
"no_chapters": False,
|
||||||
|
"audio_description": False,
|
||||||
|
"slow": False,
|
||||||
|
"skip_dl": False,
|
||||||
|
"export": None,
|
||||||
|
"cdm_only": None,
|
||||||
|
"no_proxy": False,
|
||||||
|
"no_folder": False,
|
||||||
|
"no_source": False,
|
||||||
|
"no_mux": False,
|
||||||
|
"workers": None,
|
||||||
|
"downloads": 1,
|
||||||
|
"best_available": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_proxy_providers() -> List[Any]:
|
||||||
|
"""Initialize and return available proxy providers."""
|
||||||
|
proxy_providers = []
|
||||||
|
try:
|
||||||
|
from envied.core import binaries
|
||||||
|
# Load the main unshackle config to get proxy provider settings
|
||||||
|
from envied.core.config import config as main_config
|
||||||
|
|
||||||
|
log.debug(f"Main config proxy providers: {getattr(main_config, 'proxy_providers', {})}")
|
||||||
|
log.debug(f"Available proxy provider configs: {list(getattr(main_config, 'proxy_providers', {}).keys())}")
|
||||||
|
|
||||||
|
# Use main_config instead of the service-specific config for proxy providers
|
||||||
|
proxy_config = getattr(main_config, "proxy_providers", {})
|
||||||
|
|
||||||
|
if proxy_config.get("basic"):
|
||||||
|
log.debug("Loading Basic proxy provider")
|
||||||
|
proxy_providers.append(Basic(**proxy_config["basic"]))
|
||||||
|
if proxy_config.get("nordvpn"):
|
||||||
|
log.debug("Loading NordVPN proxy provider")
|
||||||
|
proxy_providers.append(NordVPN(**proxy_config["nordvpn"]))
|
||||||
|
if proxy_config.get("surfsharkvpn"):
|
||||||
|
log.debug("Loading SurfsharkVPN proxy provider")
|
||||||
|
proxy_providers.append(SurfsharkVPN(**proxy_config["surfsharkvpn"]))
|
||||||
|
if hasattr(binaries, "HolaProxy") and binaries.HolaProxy:
|
||||||
|
log.debug("Loading Hola proxy provider")
|
||||||
|
proxy_providers.append(Hola())
|
||||||
|
|
||||||
|
for proxy_provider in proxy_providers:
|
||||||
|
log.info(f"Loaded {proxy_provider.__class__.__name__}: {proxy_provider}")
|
||||||
|
|
||||||
|
if not proxy_providers:
|
||||||
|
log.warning("No proxy providers were loaded. Check your proxy provider configuration in envied.yaml")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Failed to initialize some proxy providers: {e}")
|
||||||
|
|
||||||
|
return proxy_providers
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_proxy(proxy: str, proxy_providers: List[Any]) -> str:
|
||||||
|
"""Resolve proxy parameter to actual proxy URI."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
if not proxy:
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
# Check if explicit proxy URI
|
||||||
|
if re.match(r"^https?://", proxy):
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
# Handle provider:country format (e.g., "nordvpn:us")
|
||||||
|
requested_provider = None
|
||||||
|
if re.match(r"^[a-z]+:.+$", proxy, re.IGNORECASE):
|
||||||
|
requested_provider, proxy = proxy.split(":", maxsplit=1)
|
||||||
|
|
||||||
|
# Handle country code format (e.g., "us", "uk")
|
||||||
|
if re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE):
|
||||||
|
proxy = proxy.lower()
|
||||||
|
|
||||||
|
if requested_provider:
|
||||||
|
# Find specific provider (case-insensitive matching)
|
||||||
|
proxy_provider = next(
|
||||||
|
(x for x in proxy_providers if x.__class__.__name__.lower() == requested_provider.lower()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not proxy_provider:
|
||||||
|
available_providers = [x.__class__.__name__ for x in proxy_providers]
|
||||||
|
raise ValueError(
|
||||||
|
f"The proxy provider '{requested_provider}' was not recognized. Available providers: {available_providers}"
|
||||||
|
)
|
||||||
|
|
||||||
|
proxy_uri = proxy_provider.get_proxy(proxy)
|
||||||
|
if not proxy_uri:
|
||||||
|
raise ValueError(f"The proxy provider {requested_provider} had no proxy for {proxy}")
|
||||||
|
|
||||||
|
log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy_uri}")
|
||||||
|
return proxy_uri
|
||||||
|
else:
|
||||||
|
# Try all providers
|
||||||
|
for proxy_provider in proxy_providers:
|
||||||
|
proxy_uri = proxy_provider.get_proxy(proxy)
|
||||||
|
if proxy_uri:
|
||||||
|
log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy_uri}")
|
||||||
|
return proxy_uri
|
||||||
|
|
||||||
|
raise ValueError(f"No proxy provider had a proxy for {proxy}")
|
||||||
|
|
||||||
|
# Return as-is if not recognized format
|
||||||
|
log.info(f"Using explicit Proxy: {proxy}")
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
|
||||||
|
def validate_service(service_tag: str) -> Optional[str]:
|
||||||
|
"""Validate and normalize service tag."""
|
||||||
|
try:
|
||||||
|
normalized = Services.get_tag(service_tag)
|
||||||
|
service_path = Services.get_path(normalized)
|
||||||
|
if not service_path.exists():
|
||||||
|
return None
|
||||||
|
return normalized
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_title(title: Title_T) -> Dict[str, Any]:
|
||||||
|
"""Convert a title object to JSON-serializable dict."""
|
||||||
|
if isinstance(title, Episode):
|
||||||
|
episode_name = title.name if title.name else f"Episode {title.number:02d}"
|
||||||
|
result = {
|
||||||
|
"type": "episode",
|
||||||
|
"name": episode_name,
|
||||||
|
"series_title": str(title.title),
|
||||||
|
"season": title.season,
|
||||||
|
"number": title.number,
|
||||||
|
"year": title.year,
|
||||||
|
"id": str(title.id) if hasattr(title, "id") else None,
|
||||||
|
}
|
||||||
|
elif isinstance(title, Movie):
|
||||||
|
result = {
|
||||||
|
"type": "movie",
|
||||||
|
"name": str(title.name) if hasattr(title, "name") else str(title),
|
||||||
|
"year": title.year,
|
||||||
|
"id": str(title.id) if hasattr(title, "id") else None,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
result = {
|
||||||
|
"type": "other",
|
||||||
|
"name": str(title.name) if hasattr(title, "name") else str(title),
|
||||||
|
"id": str(title.id) if hasattr(title, "id") else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_video_track(track: Video) -> 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 {
|
||||||
|
"id": str(track.id),
|
||||||
|
"codec": codec_name,
|
||||||
|
"codec_display": VIDEO_CODEC_MAP.get(codec_name, codec_name),
|
||||||
|
"bitrate": int(track.bitrate / 1000) if track.bitrate else None,
|
||||||
|
"width": track.width,
|
||||||
|
"height": track.height,
|
||||||
|
"resolution": f"{track.width}x{track.height}" if track.width and track.height else None,
|
||||||
|
"fps": track.fps if track.fps else None,
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_audio_track(track: Audio) -> 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 {
|
||||||
|
"id": str(track.id),
|
||||||
|
"codec": codec_name,
|
||||||
|
"codec_display": AUDIO_CODEC_MAP.get(codec_name, codec_name),
|
||||||
|
"bitrate": int(track.bitrate / 1000) if track.bitrate else None,
|
||||||
|
"channels": track.channels if track.channels else None,
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_subtitle_track(track: Subtitle) -> Dict[str, Any]:
|
||||||
|
"""Convert subtitle track to JSON-serializable dict."""
|
||||||
|
return {
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_titles_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
|
||||||
|
"""Handle list-titles request."""
|
||||||
|
service_tag = data.get("service")
|
||||||
|
title_id = data.get("title_id")
|
||||||
|
profile = data.get("profile")
|
||||||
|
|
||||||
|
if not service_tag:
|
||||||
|
raise APIError(
|
||||||
|
APIErrorCode.INVALID_INPUT,
|
||||||
|
"Missing required parameter: service",
|
||||||
|
details={"missing_parameter": "service"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not title_id:
|
||||||
|
raise APIError(
|
||||||
|
APIErrorCode.INVALID_INPUT,
|
||||||
|
"Missing required parameter: title_id",
|
||||||
|
details={"missing_parameter": "title_id"},
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_service = validate_service(service_tag)
|
||||||
|
if not normalized_service:
|
||||||
|
raise APIError(
|
||||||
|
APIErrorCode.INVALID_SERVICE,
|
||||||
|
f"Invalid or unavailable service: {service_tag}",
|
||||||
|
details={"service": service_tag},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import click
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from envied.commands.dl import dl
|
||||||
|
from envied.core.config import config
|
||||||
|
from envied.core.utils.click_types import ContextData
|
||||||
|
from envied.core.utils.collections import merge_dict
|
||||||
|
|
||||||
|
service_config_path = Services.get_path(normalized_service) / config.filenames.config
|
||||||
|
if service_config_path.exists():
|
||||||
|
service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8"))
|
||||||
|
else:
|
||||||
|
service_config = {}
|
||||||
|
merge_dict(config.services.get(normalized_service), service_config)
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.pass_context
|
||||||
|
def dummy_service(ctx: click.Context) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Handle proxy configuration
|
||||||
|
proxy_param = data.get("proxy")
|
||||||
|
no_proxy = data.get("no_proxy", False)
|
||||||
|
proxy_providers = []
|
||||||
|
|
||||||
|
if not no_proxy:
|
||||||
|
proxy_providers = initialize_proxy_providers()
|
||||||
|
|
||||||
|
if proxy_param and not no_proxy:
|
||||||
|
try:
|
||||||
|
resolved_proxy = resolve_proxy(proxy_param, proxy_providers)
|
||||||
|
proxy_param = resolved_proxy
|
||||||
|
except ValueError as e:
|
||||||
|
raise APIError(
|
||||||
|
APIErrorCode.INVALID_PROXY,
|
||||||
|
f"Proxy error: {e}",
|
||||||
|
details={"proxy": proxy_param, "service": normalized_service},
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = click.Context(dummy_service)
|
||||||
|
ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=proxy_providers, profile=profile)
|
||||||
|
ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy}
|
||||||
|
|
||||||
|
service_module = Services.load(normalized_service)
|
||||||
|
|
||||||
|
dummy_service.name = normalized_service
|
||||||
|
dummy_service.params = [click.Argument([title_id], type=str)]
|
||||||
|
ctx.invoked_subcommand = normalized_service
|
||||||
|
|
||||||
|
service_ctx = click.Context(dummy_service, parent=ctx)
|
||||||
|
service_ctx.obj = ctx.obj
|
||||||
|
|
||||||
|
service_kwargs = {"title": title_id}
|
||||||
|
|
||||||
|
# Add additional parameters from request data
|
||||||
|
for key, value in data.items():
|
||||||
|
if key not in ["service", "title_id", "profile", "season", "episode", "wanted", "proxy", "no_proxy"]:
|
||||||
|
service_kwargs[key] = value
|
||||||
|
|
||||||
|
# Get service parameter info and click command defaults
|
||||||
|
service_init_params = inspect.signature(service_module.__init__).parameters
|
||||||
|
|
||||||
|
# Extract default values from the click command
|
||||||
|
if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"):
|
||||||
|
for param in service_module.cli.params:
|
||||||
|
if hasattr(param, "name") and param.name not in service_kwargs:
|
||||||
|
# Add default value if parameter is not already provided
|
||||||
|
if hasattr(param, "default") and param.default is not None:
|
||||||
|
service_kwargs[param.name] = param.default
|
||||||
|
|
||||||
|
# Handle required parameters that don't have click defaults
|
||||||
|
for param_name, param_info in service_init_params.items():
|
||||||
|
if param_name not in service_kwargs and param_name not in ["self", "ctx"]:
|
||||||
|
# Check if parameter is required (no default value in signature)
|
||||||
|
if param_info.default is inspect.Parameter.empty:
|
||||||
|
# Provide sensible defaults for common required parameters
|
||||||
|
if param_name == "meta_lang":
|
||||||
|
service_kwargs[param_name] = None
|
||||||
|
elif param_name == "movie":
|
||||||
|
service_kwargs[param_name] = False
|
||||||
|
else:
|
||||||
|
# Log warning for unknown required parameters
|
||||||
|
log.warning(f"Unknown required parameter '{param_name}' for service {normalized_service}")
|
||||||
|
|
||||||
|
# Filter out any parameters that the service doesn't accept
|
||||||
|
filtered_kwargs = {}
|
||||||
|
for key, value in service_kwargs.items():
|
||||||
|
if key in service_init_params:
|
||||||
|
filtered_kwargs[key] = value
|
||||||
|
|
||||||
|
service_instance = service_module(service_ctx, **filtered_kwargs)
|
||||||
|
|
||||||
|
cookies = dl.get_cookie_jar(normalized_service, profile)
|
||||||
|
credential = dl.get_credentials(normalized_service, profile)
|
||||||
|
service_instance.authenticate(cookies, credential)
|
||||||
|
|
||||||
|
titles = service_instance.get_titles()
|
||||||
|
|
||||||
|
if hasattr(titles, "__iter__") and not isinstance(titles, str):
|
||||||
|
title_list = [serialize_title(t) for t in titles]
|
||||||
|
else:
|
||||||
|
title_list = [serialize_title(titles)]
|
||||||
|
|
||||||
|
return web.json_response({"titles": title_list})
|
||||||
|
|
||||||
|
except APIError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Error listing titles")
|
||||||
|
debug_mode = request.app.get("debug_api", False) if request else False
|
||||||
|
return handle_api_exception(
|
||||||
|
e,
|
||||||
|
context={"operation": "list_titles", "service": normalized_service, "title_id": title_id},
|
||||||
|
debug_mode=debug_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
|
||||||
|
"""Handle list-tracks request."""
|
||||||
|
service_tag = data.get("service")
|
||||||
|
title_id = data.get("title_id")
|
||||||
|
profile = data.get("profile")
|
||||||
|
|
||||||
|
if not service_tag:
|
||||||
|
raise APIError(
|
||||||
|
APIErrorCode.INVALID_INPUT,
|
||||||
|
"Missing required parameter: service",
|
||||||
|
details={"missing_parameter": "service"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not title_id:
|
||||||
|
raise APIError(
|
||||||
|
APIErrorCode.INVALID_INPUT,
|
||||||
|
"Missing required parameter: title_id",
|
||||||
|
details={"missing_parameter": "title_id"},
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_service = validate_service(service_tag)
|
||||||
|
if not normalized_service:
|
||||||
|
raise APIError(
|
||||||
|
APIErrorCode.INVALID_SERVICE,
|
||||||
|
f"Invalid or unavailable service: {service_tag}",
|
||||||
|
details={"service": service_tag},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import click
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from envied.commands.dl import dl
|
||||||
|
from envied.core.config import config
|
||||||
|
from envied.core.utils.click_types import ContextData
|
||||||
|
from envied.core.utils.collections import merge_dict
|
||||||
|
|
||||||
|
service_config_path = Services.get_path(normalized_service) / config.filenames.config
|
||||||
|
if service_config_path.exists():
|
||||||
|
service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8"))
|
||||||
|
else:
|
||||||
|
service_config = {}
|
||||||
|
merge_dict(config.services.get(normalized_service), service_config)
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.pass_context
|
||||||
|
def dummy_service(ctx: click.Context) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Handle proxy configuration
|
||||||
|
proxy_param = data.get("proxy")
|
||||||
|
no_proxy = data.get("no_proxy", False)
|
||||||
|
proxy_providers = []
|
||||||
|
|
||||||
|
if not no_proxy:
|
||||||
|
proxy_providers = initialize_proxy_providers()
|
||||||
|
|
||||||
|
if proxy_param and not no_proxy:
|
||||||
|
try:
|
||||||
|
resolved_proxy = resolve_proxy(proxy_param, proxy_providers)
|
||||||
|
proxy_param = resolved_proxy
|
||||||
|
except ValueError as e:
|
||||||
|
raise APIError(
|
||||||
|
APIErrorCode.INVALID_PROXY,
|
||||||
|
f"Proxy error: {e}",
|
||||||
|
details={"proxy": proxy_param, "service": normalized_service},
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = click.Context(dummy_service)
|
||||||
|
ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=proxy_providers, profile=profile)
|
||||||
|
ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy}
|
||||||
|
|
||||||
|
service_module = Services.load(normalized_service)
|
||||||
|
|
||||||
|
dummy_service.name = normalized_service
|
||||||
|
dummy_service.params = [click.Argument([title_id], type=str)]
|
||||||
|
ctx.invoked_subcommand = normalized_service
|
||||||
|
|
||||||
|
service_ctx = click.Context(dummy_service, parent=ctx)
|
||||||
|
service_ctx.obj = ctx.obj
|
||||||
|
|
||||||
|
service_kwargs = {"title": title_id}
|
||||||
|
|
||||||
|
# Add additional parameters from request data
|
||||||
|
for key, value in data.items():
|
||||||
|
if key not in ["service", "title_id", "profile", "season", "episode", "wanted", "proxy", "no_proxy"]:
|
||||||
|
service_kwargs[key] = value
|
||||||
|
|
||||||
|
# Get service parameter info and click command defaults
|
||||||
|
service_init_params = inspect.signature(service_module.__init__).parameters
|
||||||
|
|
||||||
|
# Extract default values from the click command
|
||||||
|
if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"):
|
||||||
|
for param in service_module.cli.params:
|
||||||
|
if hasattr(param, "name") and param.name not in service_kwargs:
|
||||||
|
# Add default value if parameter is not already provided
|
||||||
|
if hasattr(param, "default") and param.default is not None:
|
||||||
|
service_kwargs[param.name] = param.default
|
||||||
|
|
||||||
|
# Handle required parameters that don't have click defaults
|
||||||
|
for param_name, param_info in service_init_params.items():
|
||||||
|
if param_name not in service_kwargs and param_name not in ["self", "ctx"]:
|
||||||
|
# Check if parameter is required (no default value in signature)
|
||||||
|
if param_info.default is inspect.Parameter.empty:
|
||||||
|
# Provide sensible defaults for common required parameters
|
||||||
|
if param_name == "meta_lang":
|
||||||
|
service_kwargs[param_name] = None
|
||||||
|
elif param_name == "movie":
|
||||||
|
service_kwargs[param_name] = False
|
||||||
|
else:
|
||||||
|
# Log warning for unknown required parameters
|
||||||
|
log.warning(f"Unknown required parameter '{param_name}' for service {normalized_service}")
|
||||||
|
|
||||||
|
# Filter out any parameters that the service doesn't accept
|
||||||
|
filtered_kwargs = {}
|
||||||
|
for key, value in service_kwargs.items():
|
||||||
|
if key in service_init_params:
|
||||||
|
filtered_kwargs[key] = value
|
||||||
|
|
||||||
|
service_instance = service_module(service_ctx, **filtered_kwargs)
|
||||||
|
|
||||||
|
cookies = dl.get_cookie_jar(normalized_service, profile)
|
||||||
|
credential = dl.get_credentials(normalized_service, profile)
|
||||||
|
service_instance.authenticate(cookies, credential)
|
||||||
|
|
||||||
|
titles = service_instance.get_titles()
|
||||||
|
|
||||||
|
wanted_param = data.get("wanted")
|
||||||
|
season = data.get("season")
|
||||||
|
episode = data.get("episode")
|
||||||
|
|
||||||
|
if hasattr(titles, "__iter__") and not isinstance(titles, str):
|
||||||
|
titles_list = list(titles)
|
||||||
|
|
||||||
|
wanted = None
|
||||||
|
if wanted_param:
|
||||||
|
from envied.core.utils.click_types import SeasonRange
|
||||||
|
|
||||||
|
try:
|
||||||
|
season_range = SeasonRange()
|
||||||
|
wanted = season_range.parse_tokens(wanted_param)
|
||||||
|
log.debug(f"Parsed wanted '{wanted_param}' into {len(wanted)} episodes: {wanted[:10]}...")
|
||||||
|
except Exception as e:
|
||||||
|
raise APIError(
|
||||||
|
APIErrorCode.INVALID_PARAMETERS,
|
||||||
|
f"Invalid wanted parameter: {e}",
|
||||||
|
details={"wanted": wanted_param, "service": normalized_service},
|
||||||
|
)
|
||||||
|
elif season is not None and episode is not None:
|
||||||
|
wanted = [f"{season}x{episode}"]
|
||||||
|
|
||||||
|
if wanted:
|
||||||
|
# Filter titles based on wanted episodes, similar to how dl.py does it
|
||||||
|
matching_titles = []
|
||||||
|
log.debug(f"Filtering {len(titles_list)} titles with {len(wanted)} wanted episodes")
|
||||||
|
for title in titles_list:
|
||||||
|
if isinstance(title, Episode):
|
||||||
|
episode_key = f"{title.season}x{title.number}"
|
||||||
|
if episode_key in wanted:
|
||||||
|
log.debug(f"Episode {episode_key} matches wanted list")
|
||||||
|
matching_titles.append(title)
|
||||||
|
else:
|
||||||
|
log.debug(f"Episode {episode_key} not in wanted list")
|
||||||
|
else:
|
||||||
|
matching_titles.append(title)
|
||||||
|
|
||||||
|
log.debug(f"Found {len(matching_titles)} matching titles")
|
||||||
|
|
||||||
|
if not matching_titles:
|
||||||
|
raise APIError(
|
||||||
|
APIErrorCode.NO_CONTENT,
|
||||||
|
"No episodes found matching wanted criteria",
|
||||||
|
details={
|
||||||
|
"service": normalized_service,
|
||||||
|
"title_id": title_id,
|
||||||
|
"wanted": wanted_param or f"{season}x{episode}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# If multiple episodes match, return tracks for all episodes
|
||||||
|
if len(matching_titles) > 1 and all(isinstance(t, Episode) for t in matching_titles):
|
||||||
|
episodes_data = []
|
||||||
|
failed_episodes = []
|
||||||
|
|
||||||
|
# Sort matching titles by season and episode number for consistent ordering
|
||||||
|
sorted_titles = sorted(matching_titles, key=lambda t: (t.season, t.number))
|
||||||
|
|
||||||
|
for title in sorted_titles:
|
||||||
|
try:
|
||||||
|
tracks = service_instance.get_tracks(title)
|
||||||
|
video_tracks = sorted(tracks.videos, key=lambda t: t.bitrate or 0, reverse=True)
|
||||||
|
audio_tracks = sorted(tracks.audio, key=lambda t: t.bitrate or 0, reverse=True)
|
||||||
|
|
||||||
|
episode_data = {
|
||||||
|
"title": serialize_title(title),
|
||||||
|
"video": [serialize_video_track(t) for t in video_tracks],
|
||||||
|
"audio": [serialize_audio_track(t) for t in audio_tracks],
|
||||||
|
"subtitles": [serialize_subtitle_track(t) for t in tracks.subtitles],
|
||||||
|
}
|
||||||
|
episodes_data.append(episode_data)
|
||||||
|
log.debug(f"Successfully got tracks for {title.season}x{title.number}")
|
||||||
|
except SystemExit:
|
||||||
|
# Service calls sys.exit() for unavailable episodes - catch and skip
|
||||||
|
failed_episodes.append(f"S{title.season}E{title.number:02d}")
|
||||||
|
log.debug(f"Episode {title.season}x{title.number} not available, skipping")
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
# Handle other errors gracefully
|
||||||
|
failed_episodes.append(f"S{title.season}E{title.number:02d}")
|
||||||
|
log.debug(f"Error getting tracks for {title.season}x{title.number}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if episodes_data:
|
||||||
|
response = {"episodes": episodes_data}
|
||||||
|
if failed_episodes:
|
||||||
|
response["unavailable_episodes"] = failed_episodes
|
||||||
|
return web.json_response(response)
|
||||||
|
else:
|
||||||
|
raise APIError(
|
||||||
|
APIErrorCode.NO_CONTENT,
|
||||||
|
f"No available episodes found. Unavailable: {', '.join(failed_episodes)}",
|
||||||
|
details={
|
||||||
|
"service": normalized_service,
|
||||||
|
"title_id": title_id,
|
||||||
|
"unavailable_episodes": failed_episodes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Single episode or movie
|
||||||
|
first_title = matching_titles[0]
|
||||||
|
else:
|
||||||
|
first_title = titles_list[0]
|
||||||
|
else:
|
||||||
|
first_title = titles
|
||||||
|
|
||||||
|
tracks = service_instance.get_tracks(first_title)
|
||||||
|
|
||||||
|
video_tracks = sorted(tracks.videos, key=lambda t: t.bitrate or 0, reverse=True)
|
||||||
|
audio_tracks = sorted(tracks.audio, key=lambda t: t.bitrate or 0, reverse=True)
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"title": serialize_title(first_title),
|
||||||
|
"video": [serialize_video_track(t) for t in video_tracks],
|
||||||
|
"audio": [serialize_audio_track(t) for t in audio_tracks],
|
||||||
|
"subtitles": [serialize_subtitle_track(t) for t in tracks.subtitles],
|
||||||
|
}
|
||||||
|
|
||||||
|
return web.json_response(response)
|
||||||
|
|
||||||
|
except APIError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Error listing tracks")
|
||||||
|
debug_mode = request.app.get("debug_api", False) if request else False
|
||||||
|
return handle_api_exception(
|
||||||
|
e,
|
||||||
|
context={"operation": "list_tracks", "service": normalized_service, "title_id": title_id},
|
||||||
|
debug_mode=debug_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Validate download parameters and return error message if invalid.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None if valid, error message string if invalid
|
||||||
|
"""
|
||||||
|
if "vcodec" in data and data["vcodec"]:
|
||||||
|
valid_vcodecs = ["H264", "H265", "VP9", "AV1"]
|
||||||
|
if data["vcodec"].upper() not in valid_vcodecs:
|
||||||
|
return f"Invalid vcodec: {data['vcodec']}. Must be one of: {', '.join(valid_vcodecs)}"
|
||||||
|
|
||||||
|
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)}"
|
||||||
|
|
||||||
|
if "sub_format" in data and data["sub_format"]:
|
||||||
|
valid_sub_formats = ["SRT", "VTT", "ASS", "SSA"]
|
||||||
|
if data["sub_format"].upper() not in valid_sub_formats:
|
||||||
|
return f"Invalid sub_format: {data['sub_format']}. Must be one of: {', '.join(valid_sub_formats)}"
|
||||||
|
|
||||||
|
if "vbitrate" in data and data["vbitrate"] is not None:
|
||||||
|
if not isinstance(data["vbitrate"], int) or data["vbitrate"] <= 0:
|
||||||
|
return "vbitrate must be a positive integer"
|
||||||
|
|
||||||
|
if "abitrate" in data and data["abitrate"] is not None:
|
||||||
|
if not isinstance(data["abitrate"], int) or data["abitrate"] <= 0:
|
||||||
|
return "abitrate must be a positive integer"
|
||||||
|
|
||||||
|
if "channels" in data and data["channels"] is not None:
|
||||||
|
if not isinstance(data["channels"], (int, float)) or data["channels"] <= 0:
|
||||||
|
return "channels must be a positive number"
|
||||||
|
|
||||||
|
if "workers" in data and data["workers"] is not None:
|
||||||
|
if not isinstance(data["workers"], int) or data["workers"] <= 0:
|
||||||
|
return "workers must be a positive integer"
|
||||||
|
|
||||||
|
if "downloads" in data and data["downloads"] is not None:
|
||||||
|
if not isinstance(data["downloads"], int) or data["downloads"] <= 0:
|
||||||
|
return "downloads must be a positive integer"
|
||||||
|
|
||||||
|
exclusive_flags = []
|
||||||
|
if data.get("video_only"):
|
||||||
|
exclusive_flags.append("video_only")
|
||||||
|
if data.get("audio_only"):
|
||||||
|
exclusive_flags.append("audio_only")
|
||||||
|
if data.get("subs_only"):
|
||||||
|
exclusive_flags.append("subs_only")
|
||||||
|
if data.get("chapters_only"):
|
||||||
|
exclusive_flags.append("chapters_only")
|
||||||
|
|
||||||
|
if len(exclusive_flags) > 1:
|
||||||
|
return f"Cannot use multiple exclusive flags: {', '.join(exclusive_flags)}"
|
||||||
|
|
||||||
|
if data.get("no_subs") and data.get("subs_only"):
|
||||||
|
return "Cannot use both no_subs and subs_only"
|
||||||
|
if data.get("no_audio") and data.get("audio_only"):
|
||||||
|
return "Cannot use both no_audio and audio_only"
|
||||||
|
|
||||||
|
if data.get("s_lang") and data.get("require_subs"):
|
||||||
|
return "Cannot use both s_lang and require_subs"
|
||||||
|
|
||||||
|
if "range" in data and data["range"]:
|
||||||
|
valid_ranges = ["SDR", "HDR10", "HDR10+", "DV", "HLG"]
|
||||||
|
if isinstance(data["range"], list):
|
||||||
|
for r in data["range"]:
|
||||||
|
if r.upper() not in valid_ranges:
|
||||||
|
return f"Invalid range value: {r}. Must be one of: {', '.join(valid_ranges)}"
|
||||||
|
elif data["range"].upper() not in valid_ranges:
|
||||||
|
return f"Invalid range value: {data['range']}. Must be one of: {', '.join(valid_ranges)}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def download_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
|
||||||
|
"""Handle download request - create and queue a download job."""
|
||||||
|
from envied.core.api.download_manager import get_download_manager
|
||||||
|
|
||||||
|
service_tag = data.get("service")
|
||||||
|
title_id = data.get("title_id")
|
||||||
|
|
||||||
|
if not service_tag:
|
||||||
|
raise APIError(
|
||||||
|
APIErrorCode.INVALID_INPUT,
|
||||||
|
"Missing required parameter: service",
|
||||||
|
details={"missing_parameter": "service"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not title_id:
|
||||||
|
raise APIError(
|
||||||
|
APIErrorCode.INVALID_INPUT,
|
||||||
|
"Missing required parameter: title_id",
|
||||||
|
details={"missing_parameter": "title_id"},
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_service = validate_service(service_tag)
|
||||||
|
if not normalized_service:
|
||||||
|
raise APIError(
|
||||||
|
APIErrorCode.INVALID_SERVICE,
|
||||||
|
f"Invalid or unavailable service: {service_tag}",
|
||||||
|
details={"service": service_tag},
|
||||||
|
)
|
||||||
|
|
||||||
|
validation_error = validate_download_parameters(data)
|
||||||
|
if validation_error:
|
||||||
|
raise APIError(
|
||||||
|
APIErrorCode.INVALID_PARAMETERS,
|
||||||
|
validation_error,
|
||||||
|
details={"service": normalized_service, "title_id": title_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load service module to extract service-specific parameter defaults
|
||||||
|
service_module = Services.load(normalized_service)
|
||||||
|
service_specific_defaults = {}
|
||||||
|
|
||||||
|
# Extract default values from the service's click command
|
||||||
|
if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"):
|
||||||
|
for param in service_module.cli.params:
|
||||||
|
if hasattr(param, "name") and hasattr(param, "default") and param.default is not None:
|
||||||
|
# Store service-specific defaults (e.g., drm_system, hydrate_track, profile for NF)
|
||||||
|
service_specific_defaults[param.name] = param.default
|
||||||
|
|
||||||
|
# Get download manager and start workers if needed
|
||||||
|
manager = get_download_manager()
|
||||||
|
await manager.start_workers()
|
||||||
|
|
||||||
|
# Create download job with filtered parameters (exclude service and title_id as they're already passed)
|
||||||
|
filtered_params = {k: v for k, v in data.items() if k not in ["service", "title_id"]}
|
||||||
|
# Merge defaults with provided parameters (user params override service defaults, which override global defaults)
|
||||||
|
params_with_defaults = {**DEFAULT_DOWNLOAD_PARAMS, **service_specific_defaults, **filtered_params}
|
||||||
|
job = manager.create_job(normalized_service, title_id, **params_with_defaults)
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{"job_id": job.job_id, "status": job.status.value, "created_time": job.created_time.isoformat()}, status=202
|
||||||
|
)
|
||||||
|
|
||||||
|
except APIError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Error creating download job")
|
||||||
|
debug_mode = request.app.get("debug_api", False) if request else False
|
||||||
|
return handle_api_exception(
|
||||||
|
e,
|
||||||
|
context={"operation": "create_download_job", "service": normalized_service, "title_id": title_id},
|
||||||
|
debug_mode=debug_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_download_jobs_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
|
||||||
|
"""Handle list download jobs request with optional filtering and sorting."""
|
||||||
|
from envied.core.api.download_manager import get_download_manager
|
||||||
|
|
||||||
|
try:
|
||||||
|
manager = get_download_manager()
|
||||||
|
jobs = manager.list_jobs()
|
||||||
|
|
||||||
|
status_filter = data.get("status")
|
||||||
|
if status_filter:
|
||||||
|
jobs = [job for job in jobs if job.status.value == status_filter]
|
||||||
|
|
||||||
|
service_filter = data.get("service")
|
||||||
|
if service_filter:
|
||||||
|
jobs = [job for job in jobs if job.service == service_filter]
|
||||||
|
|
||||||
|
sort_by = data.get("sort_by", "created_time")
|
||||||
|
sort_order = data.get("sort_order", "desc")
|
||||||
|
|
||||||
|
valid_sort_fields = ["created_time", "started_time", "completed_time", "progress", "status", "service"]
|
||||||
|
if sort_by not in valid_sort_fields:
|
||||||
|
raise APIError(
|
||||||
|
APIErrorCode.INVALID_PARAMETERS,
|
||||||
|
f"Invalid sort_by: {sort_by}. Must be one of: {', '.join(valid_sort_fields)}",
|
||||||
|
details={"sort_by": sort_by, "valid_values": valid_sort_fields},
|
||||||
|
)
|
||||||
|
|
||||||
|
if sort_order not in ["asc", "desc"]:
|
||||||
|
raise APIError(
|
||||||
|
APIErrorCode.INVALID_PARAMETERS,
|
||||||
|
"Invalid sort_order: must be 'asc' or 'desc'",
|
||||||
|
details={"sort_order": sort_order, "valid_values": ["asc", "desc"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
reverse = sort_order == "desc"
|
||||||
|
|
||||||
|
def get_sort_key(job):
|
||||||
|
"""Get the sorting key value, handling None values."""
|
||||||
|
value = getattr(job, sort_by, None)
|
||||||
|
if value is None:
|
||||||
|
if sort_by in ["created_time", "started_time", "completed_time"]:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
return datetime.min if not reverse else datetime.max
|
||||||
|
elif sort_by == "progress":
|
||||||
|
return 0
|
||||||
|
elif sort_by in ["status", "service"]:
|
||||||
|
return ""
|
||||||
|
return value
|
||||||
|
|
||||||
|
jobs = sorted(jobs, key=get_sort_key, reverse=reverse)
|
||||||
|
|
||||||
|
job_list = [job.to_dict(include_full_details=False) for job in jobs]
|
||||||
|
|
||||||
|
return web.json_response({"jobs": job_list})
|
||||||
|
|
||||||
|
except APIError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Error listing download jobs")
|
||||||
|
debug_mode = request.app.get("debug_api", False) if request else False
|
||||||
|
return handle_api_exception(
|
||||||
|
e,
|
||||||
|
context={"operation": "list_download_jobs"},
|
||||||
|
debug_mode=debug_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_download_job_handler(job_id: str, request: Optional[web.Request] = None) -> web.Response:
|
||||||
|
"""Handle get specific download job request."""
|
||||||
|
from envied.core.api.download_manager import get_download_manager
|
||||||
|
|
||||||
|
try:
|
||||||
|
manager = get_download_manager()
|
||||||
|
job = manager.get_job(job_id)
|
||||||
|
|
||||||
|
if not job:
|
||||||
|
raise APIError(
|
||||||
|
APIErrorCode.JOB_NOT_FOUND,
|
||||||
|
"Job not found",
|
||||||
|
details={"job_id": job_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
return web.json_response(job.to_dict(include_full_details=True))
|
||||||
|
|
||||||
|
except APIError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(f"Error getting download job {job_id}")
|
||||||
|
debug_mode = request.app.get("debug_api", False) if request else False
|
||||||
|
return handle_api_exception(
|
||||||
|
e,
|
||||||
|
context={"operation": "get_download_job", "job_id": job_id},
|
||||||
|
debug_mode=debug_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_download_job_handler(job_id: str, request: Optional[web.Request] = None) -> web.Response:
|
||||||
|
"""Handle cancel download job request."""
|
||||||
|
from envied.core.api.download_manager import get_download_manager
|
||||||
|
|
||||||
|
try:
|
||||||
|
manager = get_download_manager()
|
||||||
|
|
||||||
|
if not manager.get_job(job_id):
|
||||||
|
raise APIError(
|
||||||
|
APIErrorCode.JOB_NOT_FOUND,
|
||||||
|
"Job not found",
|
||||||
|
details={"job_id": job_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
success = manager.cancel_job(job_id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return web.json_response({"status": "success", "message": "Job cancelled"})
|
||||||
|
else:
|
||||||
|
raise APIError(
|
||||||
|
APIErrorCode.INVALID_PARAMETERS,
|
||||||
|
"Job cannot be cancelled (already completed or failed)",
|
||||||
|
details={"job_id": job_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
except APIError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(f"Error cancelling download job {job_id}")
|
||||||
|
debug_mode = request.app.get("debug_api", False) if request else False
|
||||||
|
return handle_api_exception(
|
||||||
|
e,
|
||||||
|
context={"operation": "cancel_download_job", "job_id": job_id},
|
||||||
|
debug_mode=debug_mode,
|
||||||
|
)
|
||||||
@@ -0,0 +1,758 @@
|
|||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
from aiohttp_swagger3 import SwaggerDocs, SwaggerInfo, SwaggerUiSettings
|
||||||
|
|
||||||
|
from envied.core import __version__
|
||||||
|
from envied.core.api.errors import APIError, APIErrorCode, build_error_response, handle_api_exception
|
||||||
|
from envied.core.api.handlers import (cancel_download_job_handler, download_handler, get_download_job_handler,
|
||||||
|
list_download_jobs_handler, list_titles_handler, list_tracks_handler)
|
||||||
|
from envied.core.services import Services
|
||||||
|
from envied.core.update_checker import UpdateChecker
|
||||||
|
|
||||||
|
|
||||||
|
@web.middleware
|
||||||
|
async def cors_middleware(request: web.Request, handler):
|
||||||
|
"""Add CORS headers to all responses."""
|
||||||
|
# Handle preflight requests
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
response = web.Response()
|
||||||
|
else:
|
||||||
|
response = await handler(request)
|
||||||
|
|
||||||
|
# Add CORS headers
|
||||||
|
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
|
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
|
||||||
|
response.headers["Access-Control-Allow-Headers"] = "Content-Type, X-API-Key, Authorization"
|
||||||
|
response.headers["Access-Control-Max-Age"] = "3600"
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger("api")
|
||||||
|
|
||||||
|
|
||||||
|
async def health(request: web.Request) -> web.Response:
|
||||||
|
"""
|
||||||
|
Health check endpoint.
|
||||||
|
---
|
||||||
|
summary: Health check
|
||||||
|
description: Get server health status, version info, and update availability
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Health status
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: ok
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
example: "2.0.0"
|
||||||
|
update_check:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
update_available:
|
||||||
|
type: boolean
|
||||||
|
nullable: true
|
||||||
|
current_version:
|
||||||
|
type: string
|
||||||
|
latest_version:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
latest_version = await UpdateChecker.check_for_updates(__version__)
|
||||||
|
update_info = {
|
||||||
|
"update_available": latest_version is not None,
|
||||||
|
"current_version": __version__,
|
||||||
|
"latest_version": latest_version,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Failed to check for updates: {e}")
|
||||||
|
update_info = {"update_available": None, "current_version": __version__, "latest_version": None}
|
||||||
|
|
||||||
|
return web.json_response({"status": "ok", "version": __version__, "update_check": update_info})
|
||||||
|
|
||||||
|
|
||||||
|
async def services(request: web.Request) -> web.Response:
|
||||||
|
"""
|
||||||
|
List available services.
|
||||||
|
---
|
||||||
|
summary: List services
|
||||||
|
description: Get all available streaming services with their details
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: List of services
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
services:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
tag:
|
||||||
|
type: string
|
||||||
|
aliases:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
geofence:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
title_regex:
|
||||||
|
oneOf:
|
||||||
|
- type: string
|
||||||
|
- type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Service URL from short_help
|
||||||
|
help:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Full service documentation
|
||||||
|
'500':
|
||||||
|
description: Server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: error
|
||||||
|
error_code:
|
||||||
|
type: string
|
||||||
|
example: INTERNAL_ERROR
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: An unexpected error occurred
|
||||||
|
details:
|
||||||
|
type: object
|
||||||
|
timestamp:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
debug_info:
|
||||||
|
type: object
|
||||||
|
description: Only present when --debug-api flag is enabled
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
service_tags = Services.get_tags()
|
||||||
|
services_info = []
|
||||||
|
|
||||||
|
for tag in service_tags:
|
||||||
|
service_data = {"tag": tag, "aliases": [], "geofence": [], "title_regex": None, "url": None, "help": None}
|
||||||
|
|
||||||
|
try:
|
||||||
|
service_module = Services.load(tag)
|
||||||
|
|
||||||
|
if hasattr(service_module, "ALIASES"):
|
||||||
|
service_data["aliases"] = list(service_module.ALIASES)
|
||||||
|
|
||||||
|
if hasattr(service_module, "GEOFENCE"):
|
||||||
|
service_data["geofence"] = list(service_module.GEOFENCE)
|
||||||
|
|
||||||
|
if hasattr(service_module, "TITLE_RE"):
|
||||||
|
title_re = service_module.TITLE_RE
|
||||||
|
# Handle different types of TITLE_RE
|
||||||
|
if isinstance(title_re, re.Pattern):
|
||||||
|
service_data["title_regex"] = title_re.pattern
|
||||||
|
elif isinstance(title_re, str):
|
||||||
|
service_data["title_regex"] = title_re
|
||||||
|
elif isinstance(title_re, (list, tuple)):
|
||||||
|
# Convert list/tuple of patterns to list of strings
|
||||||
|
patterns = []
|
||||||
|
for item in title_re:
|
||||||
|
if isinstance(item, re.Pattern):
|
||||||
|
patterns.append(item.pattern)
|
||||||
|
elif isinstance(item, str):
|
||||||
|
patterns.append(item)
|
||||||
|
service_data["title_regex"] = patterns if patterns else None
|
||||||
|
|
||||||
|
if hasattr(service_module, "cli") and hasattr(service_module.cli, "short_help"):
|
||||||
|
service_data["url"] = service_module.cli.short_help
|
||||||
|
|
||||||
|
if service_module.__doc__:
|
||||||
|
service_data["help"] = service_module.__doc__.strip()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Could not load details for service {tag}: {e}")
|
||||||
|
|
||||||
|
services_info.append(service_data)
|
||||||
|
|
||||||
|
return web.json_response({"services": services_info})
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Error listing services")
|
||||||
|
debug_mode = request.app.get("debug_api", False)
|
||||||
|
return handle_api_exception(e, context={"operation": "list_services"}, debug_mode=debug_mode)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_titles(request: web.Request) -> web.Response:
|
||||||
|
"""
|
||||||
|
List titles for a service and title ID.
|
||||||
|
---
|
||||||
|
summary: List titles
|
||||||
|
description: Get available titles for a service and title ID
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- service
|
||||||
|
- title_id
|
||||||
|
properties:
|
||||||
|
service:
|
||||||
|
type: string
|
||||||
|
description: Service tag
|
||||||
|
title_id:
|
||||||
|
type: string
|
||||||
|
description: Title identifier
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: List of titles
|
||||||
|
'400':
|
||||||
|
description: Invalid request (missing parameters, invalid service)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: error
|
||||||
|
error_code:
|
||||||
|
type: string
|
||||||
|
example: INVALID_INPUT
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Missing required parameter
|
||||||
|
details:
|
||||||
|
type: object
|
||||||
|
timestamp:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
'401':
|
||||||
|
description: Authentication failed
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: error
|
||||||
|
error_code:
|
||||||
|
type: string
|
||||||
|
example: AUTH_FAILED
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
details:
|
||||||
|
type: object
|
||||||
|
timestamp:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
'404':
|
||||||
|
description: Title not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: error
|
||||||
|
error_code:
|
||||||
|
type: string
|
||||||
|
example: NOT_FOUND
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
details:
|
||||||
|
type: object
|
||||||
|
timestamp:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
'500':
|
||||||
|
description: Server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: error
|
||||||
|
error_code:
|
||||||
|
type: string
|
||||||
|
example: INTERNAL_ERROR
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
details:
|
||||||
|
type: object
|
||||||
|
timestamp:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except Exception as e:
|
||||||
|
return build_error_response(
|
||||||
|
APIError(
|
||||||
|
APIErrorCode.INVALID_INPUT,
|
||||||
|
"Invalid JSON request body",
|
||||||
|
details={"error": str(e)},
|
||||||
|
),
|
||||||
|
request.app.get("debug_api", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await list_titles_handler(data, request)
|
||||||
|
except APIError as e:
|
||||||
|
debug_mode = request.app.get("debug_api", False)
|
||||||
|
return build_error_response(e, debug_mode)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_tracks(request: web.Request) -> web.Response:
|
||||||
|
"""
|
||||||
|
List tracks for a title, separated by type.
|
||||||
|
---
|
||||||
|
summary: List tracks
|
||||||
|
description: Get available video, audio, and subtitle tracks for a title
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- service
|
||||||
|
- title_id
|
||||||
|
properties:
|
||||||
|
service:
|
||||||
|
type: string
|
||||||
|
description: Service tag
|
||||||
|
title_id:
|
||||||
|
type: string
|
||||||
|
description: Title identifier
|
||||||
|
wanted:
|
||||||
|
type: string
|
||||||
|
description: Specific episode/season (optional)
|
||||||
|
proxy:
|
||||||
|
type: string
|
||||||
|
description: Proxy configuration (optional)
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Track information
|
||||||
|
'400':
|
||||||
|
description: Invalid request
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except Exception as e:
|
||||||
|
return build_error_response(
|
||||||
|
APIError(
|
||||||
|
APIErrorCode.INVALID_INPUT,
|
||||||
|
"Invalid JSON request body",
|
||||||
|
details={"error": str(e)},
|
||||||
|
),
|
||||||
|
request.app.get("debug_api", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await list_tracks_handler(data, request)
|
||||||
|
except APIError as e:
|
||||||
|
debug_mode = request.app.get("debug_api", False)
|
||||||
|
return build_error_response(e, debug_mode)
|
||||||
|
|
||||||
|
|
||||||
|
async def download(request: web.Request) -> web.Response:
|
||||||
|
"""
|
||||||
|
Download content based on provided parameters.
|
||||||
|
---
|
||||||
|
summary: Download content
|
||||||
|
description: Download video content based on specified parameters
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- service
|
||||||
|
- title_id
|
||||||
|
properties:
|
||||||
|
service:
|
||||||
|
type: string
|
||||||
|
description: Service tag
|
||||||
|
title_id:
|
||||||
|
type: string
|
||||||
|
description: Title identifier
|
||||||
|
profile:
|
||||||
|
type: string
|
||||||
|
description: Profile to use for credentials and cookies (default - None)
|
||||||
|
quality:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
description: Download resolution(s) (default - best available)
|
||||||
|
vcodec:
|
||||||
|
type: string
|
||||||
|
description: Video codec to download (e.g., H264, H265, VP9, AV1) (default - None)
|
||||||
|
acodec:
|
||||||
|
type: string
|
||||||
|
description: Audio codec to download (e.g., AAC, AC3, EAC3) (default - None)
|
||||||
|
vbitrate:
|
||||||
|
type: integer
|
||||||
|
description: Video bitrate in kbps (default - None)
|
||||||
|
abitrate:
|
||||||
|
type: integer
|
||||||
|
description: Audio bitrate in kbps (default - None)
|
||||||
|
range:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Video color range (SDR, HDR10, DV) (default - ["SDR"])
|
||||||
|
channels:
|
||||||
|
type: number
|
||||||
|
description: Audio channels (e.g., 2.0, 5.1, 7.1) (default - None)
|
||||||
|
no_atmos:
|
||||||
|
type: boolean
|
||||||
|
description: Exclude Dolby Atmos audio tracks (default - false)
|
||||||
|
wanted:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Wanted episodes (e.g., ["S01E01", "S01E02"]) (default - all)
|
||||||
|
latest_episode:
|
||||||
|
type: boolean
|
||||||
|
description: Download only the single most recent episode (default - false)
|
||||||
|
lang:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Language for video and audio (use 'orig' for original) (default - ["orig"])
|
||||||
|
v_lang:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Language for video tracks only (default - [])
|
||||||
|
a_lang:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Language for audio tracks only (default - [])
|
||||||
|
s_lang:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Language for subtitle tracks (default - ["all"])
|
||||||
|
require_subs:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Required subtitle languages (default - [])
|
||||||
|
forced_subs:
|
||||||
|
type: boolean
|
||||||
|
description: Include forced subtitle tracks (default - false)
|
||||||
|
exact_lang:
|
||||||
|
type: boolean
|
||||||
|
description: Use exact language matching (no variants) (default - false)
|
||||||
|
sub_format:
|
||||||
|
type: string
|
||||||
|
description: Output subtitle format (SRT, VTT, etc.) (default - None)
|
||||||
|
video_only:
|
||||||
|
type: boolean
|
||||||
|
description: Only download video tracks (default - false)
|
||||||
|
audio_only:
|
||||||
|
type: boolean
|
||||||
|
description: Only download audio tracks (default - false)
|
||||||
|
subs_only:
|
||||||
|
type: boolean
|
||||||
|
description: Only download subtitle tracks (default - false)
|
||||||
|
chapters_only:
|
||||||
|
type: boolean
|
||||||
|
description: Only download chapters (default - false)
|
||||||
|
no_subs:
|
||||||
|
type: boolean
|
||||||
|
description: Do not download subtitle tracks (default - false)
|
||||||
|
no_audio:
|
||||||
|
type: boolean
|
||||||
|
description: Do not download audio tracks (default - false)
|
||||||
|
no_chapters:
|
||||||
|
type: boolean
|
||||||
|
description: Do not download chapters (default - false)
|
||||||
|
audio_description:
|
||||||
|
type: boolean
|
||||||
|
description: Download audio description tracks (default - false)
|
||||||
|
slow:
|
||||||
|
type: boolean
|
||||||
|
description: Add 60-120s delay between downloads (default - false)
|
||||||
|
skip_dl:
|
||||||
|
type: boolean
|
||||||
|
description: Skip downloading, only retrieve decryption keys (default - false)
|
||||||
|
export:
|
||||||
|
type: string
|
||||||
|
description: Path to export decryption keys as JSON (default - None)
|
||||||
|
cdm_only:
|
||||||
|
type: boolean
|
||||||
|
description: Only use CDM for key retrieval (true) or only vaults (false) (default - None)
|
||||||
|
proxy:
|
||||||
|
type: string
|
||||||
|
description: Proxy URI or country code (default - None)
|
||||||
|
no_proxy:
|
||||||
|
type: boolean
|
||||||
|
description: Force disable all proxy use (default - false)
|
||||||
|
tag:
|
||||||
|
type: string
|
||||||
|
description: Set the group tag to be used (default - None)
|
||||||
|
tmdb_id:
|
||||||
|
type: integer
|
||||||
|
description: Use this TMDB ID for tagging (default - None)
|
||||||
|
tmdb_name:
|
||||||
|
type: boolean
|
||||||
|
description: Rename titles using TMDB name (default - false)
|
||||||
|
tmdb_year:
|
||||||
|
type: boolean
|
||||||
|
description: Use release year from TMDB (default - false)
|
||||||
|
no_folder:
|
||||||
|
type: boolean
|
||||||
|
description: Disable folder creation for TV shows (default - false)
|
||||||
|
no_source:
|
||||||
|
type: boolean
|
||||||
|
description: Disable source tag from output file name (default - false)
|
||||||
|
no_mux:
|
||||||
|
type: boolean
|
||||||
|
description: Do not mux tracks into a container file (default - false)
|
||||||
|
workers:
|
||||||
|
type: integer
|
||||||
|
description: Max workers/threads per track download (default - None)
|
||||||
|
downloads:
|
||||||
|
type: integer
|
||||||
|
description: Amount of tracks to download concurrently (default - 1)
|
||||||
|
best_available:
|
||||||
|
type: boolean
|
||||||
|
description: Continue with best available if requested quality unavailable (default - false)
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: Download job created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
job_id:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
created_time:
|
||||||
|
type: string
|
||||||
|
'400':
|
||||||
|
description: Invalid request
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except Exception as e:
|
||||||
|
return build_error_response(
|
||||||
|
APIError(
|
||||||
|
APIErrorCode.INVALID_INPUT,
|
||||||
|
"Invalid JSON request body",
|
||||||
|
details={"error": str(e)},
|
||||||
|
),
|
||||||
|
request.app.get("debug_api", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await download_handler(data, request)
|
||||||
|
except APIError as e:
|
||||||
|
debug_mode = request.app.get("debug_api", False)
|
||||||
|
return build_error_response(e, debug_mode)
|
||||||
|
|
||||||
|
|
||||||
|
async def download_jobs(request: web.Request) -> web.Response:
|
||||||
|
"""
|
||||||
|
List all download jobs with optional filtering and sorting.
|
||||||
|
---
|
||||||
|
summary: List download jobs
|
||||||
|
description: Get list of all download jobs with their status, with optional filtering by status/service and sorting
|
||||||
|
parameters:
|
||||||
|
- name: status
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [queued, downloading, completed, failed, cancelled]
|
||||||
|
description: Filter jobs by status
|
||||||
|
- name: service
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Filter jobs by service tag
|
||||||
|
- name: sort_by
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [created_time, started_time, completed_time, progress, status, service]
|
||||||
|
default: created_time
|
||||||
|
description: Field to sort by
|
||||||
|
- name: sort_order
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [asc, desc]
|
||||||
|
default: desc
|
||||||
|
description: Sort order (ascending or descending)
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: List of download jobs
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
jobs:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
job_id:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
created_time:
|
||||||
|
type: string
|
||||||
|
service:
|
||||||
|
type: string
|
||||||
|
title_id:
|
||||||
|
type: string
|
||||||
|
progress:
|
||||||
|
type: number
|
||||||
|
'400':
|
||||||
|
description: Invalid query parameters
|
||||||
|
'500':
|
||||||
|
description: Server error
|
||||||
|
"""
|
||||||
|
# Extract query parameters
|
||||||
|
query_params = {
|
||||||
|
"status": request.query.get("status"),
|
||||||
|
"service": request.query.get("service"),
|
||||||
|
"sort_by": request.query.get("sort_by", "created_time"),
|
||||||
|
"sort_order": request.query.get("sort_order", "desc"),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
return await list_download_jobs_handler(query_params, request)
|
||||||
|
except APIError as e:
|
||||||
|
debug_mode = request.app.get("debug_api", False)
|
||||||
|
return build_error_response(e, debug_mode)
|
||||||
|
|
||||||
|
|
||||||
|
async def download_job_detail(request: web.Request) -> web.Response:
|
||||||
|
"""
|
||||||
|
Get download job details.
|
||||||
|
---
|
||||||
|
summary: Get download job
|
||||||
|
description: Get detailed information about a specific download job
|
||||||
|
parameters:
|
||||||
|
- name: job_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Download job details
|
||||||
|
'404':
|
||||||
|
description: Job not found
|
||||||
|
'500':
|
||||||
|
description: Server error
|
||||||
|
"""
|
||||||
|
job_id = request.match_info["job_id"]
|
||||||
|
try:
|
||||||
|
return await get_download_job_handler(job_id, request)
|
||||||
|
except APIError as e:
|
||||||
|
debug_mode = request.app.get("debug_api", False)
|
||||||
|
return build_error_response(e, debug_mode)
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_download_job(request: web.Request) -> web.Response:
|
||||||
|
"""
|
||||||
|
Cancel download job.
|
||||||
|
---
|
||||||
|
summary: Cancel download job
|
||||||
|
description: Cancel a queued or running download job
|
||||||
|
parameters:
|
||||||
|
- name: job_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Job cancelled successfully
|
||||||
|
'400':
|
||||||
|
description: Job cannot be cancelled
|
||||||
|
'404':
|
||||||
|
description: Job not found
|
||||||
|
'500':
|
||||||
|
description: Server error
|
||||||
|
"""
|
||||||
|
job_id = request.match_info["job_id"]
|
||||||
|
try:
|
||||||
|
return await cancel_download_job_handler(job_id, request)
|
||||||
|
except APIError as e:
|
||||||
|
debug_mode = request.app.get("debug_api", False)
|
||||||
|
return build_error_response(e, debug_mode)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_routes(app: web.Application) -> None:
|
||||||
|
"""Setup all API routes."""
|
||||||
|
app.router.add_get("/api/health", health)
|
||||||
|
app.router.add_get("/api/services", services)
|
||||||
|
app.router.add_post("/api/list-titles", list_titles)
|
||||||
|
app.router.add_post("/api/list-tracks", list_tracks)
|
||||||
|
app.router.add_post("/api/download", download)
|
||||||
|
app.router.add_get("/api/download/jobs", download_jobs)
|
||||||
|
app.router.add_get("/api/download/jobs/{job_id}", download_job_detail)
|
||||||
|
app.router.add_delete("/api/download/jobs/{job_id}", cancel_download_job)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_swagger(app: web.Application) -> None:
|
||||||
|
"""Setup Swagger UI documentation."""
|
||||||
|
swagger = SwaggerDocs(
|
||||||
|
app,
|
||||||
|
swagger_ui_settings=SwaggerUiSettings(path="/api/docs/"),
|
||||||
|
info=SwaggerInfo(
|
||||||
|
title="Unshackle REST API",
|
||||||
|
version=__version__,
|
||||||
|
description="REST API for Unshackle - Modular Movie, TV, and Music Archival Software",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add routes with OpenAPI documentation
|
||||||
|
swagger.add_routes(
|
||||||
|
[
|
||||||
|
web.get("/api/health", health),
|
||||||
|
web.get("/api/services", services),
|
||||||
|
web.post("/api/list-titles", list_titles),
|
||||||
|
web.post("/api/list-tracks", list_tracks),
|
||||||
|
web.post("/api/download", download),
|
||||||
|
web.get("/api/download/jobs", download_jobs),
|
||||||
|
web.get("/api/download/jobs/{job_id}", download_job_detail),
|
||||||
|
web.delete("/api/download/jobs/{job_id}", cancel_download_job),
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -3,30 +3,25 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from mypy.types import names
|
|
||||||
|
|
||||||
__shaka_platform = {"win32": "win", "darwin": "osx"}.get(sys.platform, sys.platform)
|
__shaka_platform = {"win32": "win", "darwin": "osx"}.get(sys.platform, sys.platform)
|
||||||
|
|
||||||
|
|
||||||
def find(*names: str) -> Optional[Path]:
|
def find(*names: str) -> Optional[Path]:
|
||||||
"""Find the path of the first found binary name."""
|
"""Find the path of the first found binary name."""
|
||||||
# Get the directory containing this file to find the local binaries folder
|
current_dir = Path(__file__).resolve().parent.parent
|
||||||
current_dir = Path(__file__).parent.parent
|
|
||||||
local_binaries_dir = current_dir / "binaries"
|
local_binaries_dir = current_dir / "binaries"
|
||||||
|
|
||||||
for name in names:
|
ext = ".exe" if sys.platform == "win32" else ""
|
||||||
# First check local binaries folder
|
|
||||||
if local_binaries_dir.exists():
|
|
||||||
# On Windows, check for .exe extension first
|
|
||||||
if sys.platform == "win32":
|
|
||||||
local_path_exe = local_binaries_dir / f"{name}" / f"{name}.exe"
|
|
||||||
if local_path_exe.is_file():
|
|
||||||
return local_path_exe
|
|
||||||
|
|
||||||
# Check for exact name match with executable bit on Unix-like systems
|
for name in names:
|
||||||
local_path = local_binaries_dir / f"{name}" / f"{name}"
|
if local_binaries_dir.exists():
|
||||||
if local_path.is_file() and local_path.stat().st_mode & 0o111: # Check if executable
|
candidate_paths = [local_binaries_dir / f"{name}{ext}", local_binaries_dir / name / f"{name}{ext}"]
|
||||||
return local_path
|
|
||||||
|
for path in candidate_paths:
|
||||||
|
if path.is_file():
|
||||||
|
# On Unix-like systems, check if file is executable
|
||||||
|
if sys.platform == "win32" or (path.stat().st_mode & 0o111):
|
||||||
|
return path
|
||||||
|
|
||||||
# Fall back to system PATH
|
# Fall back to system PATH
|
||||||
path = shutil.which(name)
|
path = shutil.which(name)
|
||||||
@@ -77,4 +72,4 @@ __all__ = (
|
|||||||
"HDR10PlusTool",
|
"HDR10PlusTool",
|
||||||
"Mp4decrypt",
|
"Mp4decrypt",
|
||||||
"find",
|
"find",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,45 +1,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import threading
|
|
||||||
import zlib
|
import zlib
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from os import stat_result
|
from os import stat_result
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional, Union
|
from typing import Any, Optional, Union
|
||||||
|
|
||||||
import jsonpickle
|
import jsonpickle
|
||||||
import jwt
|
import jwt
|
||||||
|
|
||||||
from envied.core.config import config
|
from envied.core.config import config
|
||||||
|
|
||||||
EXP_T = Union[datetime, str, int, float]
|
EXP_T = Union[datetime, str, int, float]
|
||||||
|
|
||||||
|
|
||||||
class Cacher:
|
class Cacher:
|
||||||
"""
|
"""Cacher for Services to get and set arbitrary data with expiration dates."""
|
||||||
Cacher for Services to get and set arbitrary data with expiration dates.
|
|
||||||
|
|
||||||
Multiton: one instance per (service_tag, key, version) to avoid duplicate objects
|
|
||||||
pointing at the same cache file.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# --- Multiton registry ---
|
|
||||||
_instances: dict[tuple[str, Optional[str], Optional[int]], "Cacher"] = {}
|
|
||||||
_lock = threading.RLock()
|
|
||||||
|
|
||||||
def __new__(
|
|
||||||
cls,
|
|
||||||
service_tag: str,
|
|
||||||
key: Optional[str] = None,
|
|
||||||
version: Optional[int] = 1,
|
|
||||||
data: Optional[Any] = None,
|
|
||||||
expiration: Optional[datetime] = None,
|
|
||||||
):
|
|
||||||
ident = (service_tag, key, version)
|
|
||||||
with cls._lock:
|
|
||||||
inst = cls._instances.get(ident)
|
|
||||||
if inst is None:
|
|
||||||
inst = super().__new__(cls)
|
|
||||||
cls._instances[ident] = inst
|
|
||||||
return inst
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -49,26 +25,16 @@ class Cacher:
|
|||||||
data: Optional[Any] = None,
|
data: Optional[Any] = None,
|
||||||
expiration: Optional[datetime] = None,
|
expiration: Optional[datetime] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
# Make __init__ idempotent for Multiton
|
|
||||||
if getattr(self, "_initialized", False):
|
|
||||||
return
|
|
||||||
|
|
||||||
self.service_tag = service_tag
|
self.service_tag = service_tag
|
||||||
self.key = key
|
self.key = key
|
||||||
self.version = version
|
self.version = version
|
||||||
self.data = data or {}
|
self.data = data or {}
|
||||||
self.expiration = expiration
|
self.expiration = expiration
|
||||||
|
|
||||||
self._initialized = True # mark as initialized
|
|
||||||
|
|
||||||
if self.expiration and self.expired:
|
if self.expiration and self.expired:
|
||||||
# if it's expired, remove the data for safety and delete cache file
|
# if its expired, remove the data for safety and delete cache file
|
||||||
self.data = None
|
self.data = None
|
||||||
# Guard: key might be None; only unlink if there is a concrete file path
|
self.path.unlink()
|
||||||
try:
|
|
||||||
self.path.unlink()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def __bool__(self) -> bool:
|
def __bool__(self) -> bool:
|
||||||
return bool(self.data)
|
return bool(self.data)
|
||||||
@@ -76,27 +42,20 @@ class Cacher:
|
|||||||
@property
|
@property
|
||||||
def path(self) -> Path:
|
def path(self) -> Path:
|
||||||
"""Get the path at which the cache will be read and written."""
|
"""Get the path at which the cache will be read and written."""
|
||||||
# Guard against None key (e.g., before 'get' is called)
|
|
||||||
if self.key is None:
|
|
||||||
# Create a directory path to the service cache area (no file yet)
|
|
||||||
return (config.directories.cache / self.service_tag / "__unbound__").with_suffix(".json")
|
|
||||||
return (config.directories.cache / self.service_tag / self.key).with_suffix(".json")
|
return (config.directories.cache / self.service_tag / self.key).with_suffix(".json")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def expired(self) -> bool:
|
def expired(self) -> bool:
|
||||||
return bool(self.expiration and self.expiration < datetime.now())
|
return self.expiration and self.expiration < datetime.now()
|
||||||
|
|
||||||
def get(self, key: str, version: int = 1) -> "Cacher":
|
def get(self, key: str, version: int = 1) -> Cacher:
|
||||||
"""
|
"""
|
||||||
Get Cached data for the Service by Key.
|
Get Cached data for the Service by Key.
|
||||||
:param key: the filename to save the data to, should be url-safe.
|
:param key: the filename to save the data to, should be url-safe.
|
||||||
:param version: the config data version you expect to use.
|
:param version: the config data version you expect to use.
|
||||||
:returns: Cache object containing the cached data or empty if the file does not exist.
|
:returns: Cache object containing the cached data or None if the file does not exist.
|
||||||
"""
|
"""
|
||||||
# Use the Multiton constructor; this will reuse an existing instance
|
cache = Cacher(self.service_tag, key, version)
|
||||||
# for (service_tag, key, version) if created before.
|
|
||||||
cache = type(self)(self.service_tag, key, version)
|
|
||||||
|
|
||||||
if cache.path.is_file():
|
if cache.path.is_file():
|
||||||
data = jsonpickle.loads(cache.path.read_text(encoding="utf8"))
|
data = jsonpickle.loads(cache.path.read_text(encoding="utf8"))
|
||||||
payload = data.copy()
|
payload = data.copy()
|
||||||
@@ -105,23 +64,15 @@ class Cacher:
|
|||||||
calculated = zlib.crc32(jsonpickle.dumps(payload).encode("utf8"))
|
calculated = zlib.crc32(jsonpickle.dumps(payload).encode("utf8"))
|
||||||
if calculated != checksum:
|
if calculated != checksum:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"The checksum of the Cache payload mismatched. "
|
f"The checksum of the Cache payload mismatched. Checksum: {checksum} !== Calculated: {calculated}"
|
||||||
f"Checksum: {checksum} !== Calculated: {calculated}"
|
|
||||||
)
|
)
|
||||||
cache.data = data["data"]
|
cache.data = data["data"]
|
||||||
cache.expiration = data["expiration"]
|
cache.expiration = data["expiration"]
|
||||||
cache.version = data["version"]
|
cache.version = data["version"]
|
||||||
if cache.version != version:
|
if cache.version != version:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"The version of your {self.service_tag} {key} cache is outdated. "
|
f"The version of your {self.service_tag} {key} cache is outdated. Please delete: {cache.path}"
|
||||||
f"Please delete: {cache.path}"
|
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
# Ensure empty state if file absent
|
|
||||||
cache.data = {}
|
|
||||||
cache.expiration = None
|
|
||||||
cache.version = version
|
|
||||||
|
|
||||||
return cache
|
return cache
|
||||||
|
|
||||||
def set(self, data: Any, expiration: Optional[EXP_T] = None) -> Any:
|
def set(self, data: Any, expiration: Optional[EXP_T] = None) -> Any:
|
||||||
@@ -139,11 +90,8 @@ class Cacher:
|
|||||||
expiration = jwt.decode(self.data, options={"verify_signature": False})["exp"]
|
expiration = jwt.decode(self.data, options={"verify_signature": False})["exp"]
|
||||||
except jwt.DecodeError:
|
except jwt.DecodeError:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
|
||||||
# data may not be a JWT-encoded object; ignore
|
|
||||||
pass
|
|
||||||
|
|
||||||
self.expiration = self._resolve_datetime(expiration) if expiration else None
|
self.expiration = self.resolve_datetime(expiration) if expiration else None
|
||||||
|
|
||||||
payload = {"data": self.data, "expiration": self.expiration, "version": self.version}
|
payload = {"data": self.data, "expiration": self.expiration, "version": self.version}
|
||||||
payload["crc32"] = zlib.crc32(jsonpickle.dumps(payload).encode("utf8"))
|
payload["crc32"] = zlib.crc32(jsonpickle.dumps(payload).encode("utf8"))
|
||||||
@@ -161,9 +109,29 @@ class Cacher:
|
|||||||
return self.path.stat()
|
return self.path.stat()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_datetime(timestamp: EXP_T) -> datetime:
|
def resolve_datetime(timestamp: EXP_T) -> datetime:
|
||||||
"""
|
"""
|
||||||
Resolve multiple formats of a Datetime or Timestamp to an absolute Datetime.
|
Resolve multiple formats of a Datetime or Timestamp to an absolute Datetime.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> now = datetime.now()
|
||||||
|
datetime.datetime(2022, 6, 27, 9, 49, 13, 657208)
|
||||||
|
>>> iso8601 = now.isoformat()
|
||||||
|
'2022-06-27T09:49:13.657208'
|
||||||
|
>>> Cacher.resolve_datetime(iso8601)
|
||||||
|
datetime.datetime(2022, 6, 27, 9, 49, 13, 657208)
|
||||||
|
>>> Cacher.resolve_datetime(iso8601 + "Z")
|
||||||
|
datetime.datetime(2022, 6, 27, 9, 49, 13, 657208)
|
||||||
|
>>> Cacher.resolve_datetime(3600)
|
||||||
|
datetime.datetime(2022, 6, 27, 10, 52, 50, 657208)
|
||||||
|
>>> Cacher.resolve_datetime('3600')
|
||||||
|
datetime.datetime(2022, 6, 27, 10, 52, 51, 657208)
|
||||||
|
>>> Cacher.resolve_datetime(7800.113)
|
||||||
|
datetime.datetime(2022, 6, 27, 11, 59, 13, 770208)
|
||||||
|
|
||||||
|
In the int/float examples you may notice that it did not return now + 3600 seconds
|
||||||
|
but rather something a bit more than that. This is because it did not resolve 3600
|
||||||
|
seconds from the `now` variable but from right now as the function was called.
|
||||||
"""
|
"""
|
||||||
if isinstance(timestamp, datetime):
|
if isinstance(timestamp, datetime):
|
||||||
return timestamp
|
return timestamp
|
||||||
@@ -182,6 +150,7 @@ class Cacher:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValueError(f"Unrecognized Timestamp value {timestamp!r}")
|
raise ValueError(f"Unrecognized Timestamp value {timestamp!r}")
|
||||||
if timestamp < datetime.now():
|
if timestamp < datetime.now():
|
||||||
# Likely an amount of seconds until expiration
|
# timestamp is likely an amount of seconds til expiration
|
||||||
|
# or, it's an already expired timestamp which is unlikely
|
||||||
timestamp = timestamp + timedelta(seconds=datetime.now().timestamp())
|
timestamp = timestamp + timedelta(seconds=datetime.now().timestamp())
|
||||||
return timestamp
|
return timestamp
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from .custom_remote_cdm import CustomRemoteCDM
|
||||||
from .decrypt_labs_remote_cdm import DecryptLabsRemoteCDM
|
from .decrypt_labs_remote_cdm import DecryptLabsRemoteCDM
|
||||||
|
|
||||||
__all__ = ["DecryptLabsRemoteCDM"]
|
__all__ = ["DecryptLabsRemoteCDM", "CustomRemoteCDM"]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,9 +6,11 @@ from typing import Any, Dict, List, Optional, Union
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from pywidevine.cdm import Cdm as WidevineCdm
|
||||||
from pywidevine.device import DeviceTypes
|
from pywidevine.device import DeviceTypes
|
||||||
from requests import Session
|
from requests import Session
|
||||||
|
|
||||||
|
from envied.core import __version__
|
||||||
from envied.core.vaults import Vaults
|
from envied.core.vaults import Vaults
|
||||||
|
|
||||||
|
|
||||||
@@ -79,15 +81,17 @@ class DecryptLabsRemoteCDM:
|
|||||||
Key Features:
|
Key Features:
|
||||||
- Compatible with both Widevine and PlayReady DRM schemes
|
- Compatible with both Widevine and PlayReady DRM schemes
|
||||||
- Intelligent caching that compares required vs. available keys
|
- Intelligent caching that compares required vs. available keys
|
||||||
|
- Optimized caching for L1/L2 devices (leverages API auto-optimization)
|
||||||
- Automatic key combination for mixed cache/license scenarios
|
- Automatic key combination for mixed cache/license scenarios
|
||||||
- Seamless fallback to license requests when keys are missing
|
- Seamless fallback to license requests when keys are missing
|
||||||
|
|
||||||
Intelligent Caching System:
|
Intelligent Caching System:
|
||||||
1. DRM classes (PlayReady/Widevine) provide required KIDs via set_required_kids()
|
1. DRM classes (PlayReady/Widevine) provide required KIDs via set_required_kids()
|
||||||
2. get_license_challenge() first checks for cached keys
|
2. get_license_challenge() first checks for cached keys
|
||||||
3. If cached keys satisfy requirements, returns empty challenge (no license needed)
|
3. For L1/L2 devices, always attempts cached keys first (API optimized)
|
||||||
4. If keys are missing, makes targeted license request for remaining keys
|
4. If cached keys satisfy requirements, returns empty challenge (no license needed)
|
||||||
5. parse_license() combines cached and license keys intelligently
|
5. If keys are missing, makes targeted license request for remaining keys
|
||||||
|
6. parse_license() combines cached and license keys intelligently
|
||||||
"""
|
"""
|
||||||
|
|
||||||
service_certificate_challenge = b"\x08\x04"
|
service_certificate_challenge = b"\x08\x04"
|
||||||
@@ -147,7 +151,7 @@ class DecryptLabsRemoteCDM:
|
|||||||
{
|
{
|
||||||
"decrypt-labs-api-key": self.secret,
|
"decrypt-labs-api-key": self.secret,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"User-Agent": "envied-decrypt-labs-cdm/1.0",
|
"User-Agent": f"unshackle-decrypt-labs-cdm/{__version__}",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -250,12 +254,14 @@ class DecryptLabsRemoteCDM:
|
|||||||
"pssh": None,
|
"pssh": None,
|
||||||
"challenge": None,
|
"challenge": None,
|
||||||
"decrypt_labs_session_id": None,
|
"decrypt_labs_session_id": None,
|
||||||
|
"tried_cache": False,
|
||||||
|
"cached_keys": None,
|
||||||
}
|
}
|
||||||
return session_id
|
return session_id
|
||||||
|
|
||||||
def close(self, session_id: bytes) -> None:
|
def close(self, session_id: bytes) -> None:
|
||||||
"""
|
"""
|
||||||
Close a CDM session.
|
Close a CDM session and perform comprehensive cleanup.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session_id: Session identifier
|
session_id: Session identifier
|
||||||
@@ -266,6 +272,8 @@ class DecryptLabsRemoteCDM:
|
|||||||
if session_id not in self._sessions:
|
if session_id not in self._sessions:
|
||||||
raise DecryptLabsRemoteCDMExceptions.InvalidSession(f"Invalid session ID: {session_id.hex()}")
|
raise DecryptLabsRemoteCDMExceptions.InvalidSession(f"Invalid session ID: {session_id.hex()}")
|
||||||
|
|
||||||
|
session = self._sessions[session_id]
|
||||||
|
session.clear()
|
||||||
del self._sessions[session_id]
|
del self._sessions[session_id]
|
||||||
|
|
||||||
def get_service_certificate(self, session_id: bytes) -> Optional[bytes]:
|
def get_service_certificate(self, session_id: bytes) -> Optional[bytes]:
|
||||||
@@ -304,8 +312,13 @@ class DecryptLabsRemoteCDM:
|
|||||||
raise DecryptLabsRemoteCDMExceptions.InvalidSession(f"Invalid session ID: {session_id.hex()}")
|
raise DecryptLabsRemoteCDMExceptions.InvalidSession(f"Invalid session ID: {session_id.hex()}")
|
||||||
|
|
||||||
if certificate is None:
|
if certificate is None:
|
||||||
self._sessions[session_id]["service_certificate"] = None
|
if not self._is_playready and self.device_name == "L1":
|
||||||
return "Removed"
|
certificate = WidevineCdm.common_privacy_cert
|
||||||
|
self._sessions[session_id]["service_certificate"] = base64.b64decode(certificate)
|
||||||
|
return "Using default Widevine common privacy certificate for L1"
|
||||||
|
else:
|
||||||
|
self._sessions[session_id]["service_certificate"] = None
|
||||||
|
return "No certificate set (not required for this device type)"
|
||||||
|
|
||||||
if isinstance(certificate, str):
|
if isinstance(certificate, str):
|
||||||
certificate = base64.b64decode(certificate)
|
certificate = base64.b64decode(certificate)
|
||||||
@@ -340,15 +353,19 @@ class DecryptLabsRemoteCDM:
|
|||||||
Generate a license challenge using Decrypt Labs API with intelligent caching.
|
Generate a license challenge using Decrypt Labs API with intelligent caching.
|
||||||
|
|
||||||
This method implements smart caching logic that:
|
This method implements smart caching logic that:
|
||||||
1. First attempts to retrieve cached keys from the API
|
1. First checks local vaults for required keys
|
||||||
2. If required KIDs are set, compares cached keys against requirements
|
2. Attempts to retrieve cached keys from the API
|
||||||
3. Only makes a license request if keys are missing
|
3. If required KIDs are set, compares available keys (vault + cached) against requirements
|
||||||
4. Returns empty challenge if all required keys are cached
|
4. Only makes a license request if keys are missing
|
||||||
|
5. Returns empty challenge if all required keys are available
|
||||||
|
|
||||||
The intelligent caching works as follows:
|
The intelligent caching works as follows:
|
||||||
|
- Local vaults: Always checked first if available
|
||||||
|
- For L1/L2 devices: Always prioritizes cached keys (API automatically optimizes)
|
||||||
|
- For other devices: Uses cache retry logic based on session state
|
||||||
- With required KIDs set: Only requests license for missing keys
|
- With required KIDs set: Only requests license for missing keys
|
||||||
- Without required KIDs: Returns any available cached keys
|
- Without required KIDs: Returns any available cached keys
|
||||||
- For PlayReady: Combines cached keys with license keys seamlessly
|
- For PlayReady: Combines vault, cached, and license keys seamlessly
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session_id: Session identifier
|
session_id: Session identifier
|
||||||
@@ -357,7 +374,7 @@ class DecryptLabsRemoteCDM:
|
|||||||
privacy_mode: Whether to use privacy mode - for compatibility only
|
privacy_mode: Whether to use privacy mode - for compatibility only
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
License challenge as bytes, or empty bytes if cached keys satisfy requirements
|
License challenge as bytes, or empty bytes if available keys satisfy requirements
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
InvalidSession: If session ID is invalid
|
InvalidSession: If session ID is invalid
|
||||||
@@ -365,6 +382,8 @@ class DecryptLabsRemoteCDM:
|
|||||||
|
|
||||||
Note:
|
Note:
|
||||||
Call set_required_kids() before this method for optimal caching behavior.
|
Call set_required_kids() before this method for optimal caching behavior.
|
||||||
|
L1/L2 devices automatically use cached keys when available per API design.
|
||||||
|
Local vault keys are always checked first when vaults are available.
|
||||||
"""
|
"""
|
||||||
_ = license_type, privacy_mode
|
_ = license_type, privacy_mode
|
||||||
|
|
||||||
@@ -377,13 +396,43 @@ class DecryptLabsRemoteCDM:
|
|||||||
init_data = self._get_init_data_from_pssh(pssh_or_wrm)
|
init_data = self._get_init_data_from_pssh(pssh_or_wrm)
|
||||||
already_tried_cache = session.get("tried_cache", False)
|
already_tried_cache = session.get("tried_cache", False)
|
||||||
|
|
||||||
|
if self.vaults and self._required_kids:
|
||||||
|
vault_keys = []
|
||||||
|
for kid_str in self._required_kids:
|
||||||
|
try:
|
||||||
|
clean_kid = kid_str.replace("-", "")
|
||||||
|
if len(clean_kid) == 32:
|
||||||
|
kid_uuid = UUID(hex=clean_kid)
|
||||||
|
else:
|
||||||
|
kid_uuid = UUID(hex=clean_kid.ljust(32, "0"))
|
||||||
|
key, _ = self.vaults.get_key(kid_uuid)
|
||||||
|
if key and key.count("0") != len(key):
|
||||||
|
vault_keys.append({"kid": kid_str, "key": key, "type": "CONTENT"})
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if vault_keys:
|
||||||
|
vault_kids = set(k["kid"] for k in vault_keys)
|
||||||
|
required_kids = set(self._required_kids)
|
||||||
|
|
||||||
|
if required_kids.issubset(vault_kids):
|
||||||
|
session["keys"] = vault_keys
|
||||||
|
return b""
|
||||||
|
else:
|
||||||
|
session["vault_keys"] = vault_keys
|
||||||
|
|
||||||
|
if self.device_name in ["L1", "L2"]:
|
||||||
|
get_cached_keys = True
|
||||||
|
else:
|
||||||
|
get_cached_keys = not already_tried_cache
|
||||||
|
|
||||||
request_data = {
|
request_data = {
|
||||||
"scheme": self.device_name,
|
"scheme": self.device_name,
|
||||||
"init_data": init_data,
|
"init_data": init_data,
|
||||||
"get_cached_keys_if_exists": not already_tried_cache,
|
"get_cached_keys_if_exists": get_cached_keys,
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.device_name in ["L1", "L2", "SL2", "SL3"] and self.service_name:
|
if self.service_name:
|
||||||
request_data["service"] = self.service_name
|
request_data["service"] = self.service_name
|
||||||
|
|
||||||
if session["service_certificate"]:
|
if session["service_certificate"]:
|
||||||
@@ -420,22 +469,45 @@ class DecryptLabsRemoteCDM:
|
|||||||
"""
|
"""
|
||||||
cached_keys = data.get("cached_keys", [])
|
cached_keys = data.get("cached_keys", [])
|
||||||
parsed_keys = self._parse_cached_keys(cached_keys)
|
parsed_keys = self._parse_cached_keys(cached_keys)
|
||||||
session["keys"] = parsed_keys
|
|
||||||
|
all_available_keys = list(parsed_keys)
|
||||||
|
if "vault_keys" in session:
|
||||||
|
all_available_keys.extend(session["vault_keys"])
|
||||||
|
|
||||||
session["tried_cache"] = True
|
session["tried_cache"] = True
|
||||||
|
|
||||||
if self._required_kids:
|
if self._required_kids:
|
||||||
cached_kids = set()
|
available_kids = set()
|
||||||
for key in parsed_keys:
|
for key in all_available_keys:
|
||||||
if isinstance(key, dict) and "kid" in key:
|
if isinstance(key, dict) and "kid" in key:
|
||||||
cached_kids.add(key["kid"].replace("-", "").lower())
|
available_kids.add(key["kid"].replace("-", "").lower())
|
||||||
|
|
||||||
required_kids = set(self._required_kids)
|
required_kids = set(self._required_kids)
|
||||||
missing_kids = required_kids - cached_kids
|
missing_kids = required_kids - available_kids
|
||||||
|
|
||||||
if missing_kids:
|
if missing_kids:
|
||||||
session["cached_keys"] = parsed_keys
|
session["cached_keys"] = parsed_keys
|
||||||
request_data["get_cached_keys_if_exists"] = False
|
|
||||||
response = self._http_session.post(f"{self.host}/get-request", json=request_data, timeout=30)
|
if self.device_name in ["L1", "L2"]:
|
||||||
|
license_request_data = {
|
||||||
|
"scheme": self.device_name,
|
||||||
|
"init_data": init_data,
|
||||||
|
"get_cached_keys_if_exists": False,
|
||||||
|
}
|
||||||
|
if self.service_name:
|
||||||
|
license_request_data["service"] = self.service_name
|
||||||
|
if session["service_certificate"]:
|
||||||
|
license_request_data["service_certificate"] = base64.b64encode(
|
||||||
|
session["service_certificate"]
|
||||||
|
).decode("utf-8")
|
||||||
|
else:
|
||||||
|
license_request_data = request_data.copy()
|
||||||
|
license_request_data["get_cached_keys_if_exists"] = False
|
||||||
|
|
||||||
|
# Make license request for missing keys
|
||||||
|
response = self._http_session.post(
|
||||||
|
f"{self.host}/get-request", json=license_request_data, timeout=30
|
||||||
|
)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
if data.get("message") == "success" and "challenge" in data:
|
if data.get("message") == "success" and "challenge" in data:
|
||||||
@@ -446,8 +518,12 @@ class DecryptLabsRemoteCDM:
|
|||||||
|
|
||||||
return b""
|
return b""
|
||||||
else:
|
else:
|
||||||
|
# All required keys are available from cache
|
||||||
|
session["keys"] = all_available_keys
|
||||||
return b""
|
return b""
|
||||||
else:
|
else:
|
||||||
|
# No required KIDs specified - return cached keys
|
||||||
|
session["keys"] = all_available_keys
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
if message_type == "license-request" or "challenge" in data:
|
if message_type == "license-request" or "challenge" in data:
|
||||||
@@ -496,7 +572,9 @@ class DecryptLabsRemoteCDM:
|
|||||||
|
|
||||||
session = self._sessions[session_id]
|
session = self._sessions[session_id]
|
||||||
|
|
||||||
if session["keys"] and not (self.is_playready and "cached_keys" in session):
|
# Skip parsing if we already have final keys (no cached keys to combine)
|
||||||
|
# If cached_keys exist (Widevine or PlayReady), we need to combine them with license keys
|
||||||
|
if session["keys"] and "cached_keys" not in session:
|
||||||
return
|
return
|
||||||
|
|
||||||
if not session.get("challenge") or not session.get("decrypt_labs_session_id"):
|
if not session.get("challenge") or not session.get("decrypt_labs_session_id"):
|
||||||
@@ -542,50 +620,63 @@ class DecryptLabsRemoteCDM:
|
|||||||
|
|
||||||
license_keys = self._parse_keys_response(data)
|
license_keys = self._parse_keys_response(data)
|
||||||
|
|
||||||
if self.is_playready and "cached_keys" in session:
|
all_keys = []
|
||||||
"""
|
|
||||||
Combine cached keys with license keys for PlayReady content.
|
|
||||||
|
|
||||||
This ensures we have both the cached keys (obtained earlier) and
|
if "vault_keys" in session:
|
||||||
any additional keys from the license response, without duplicates.
|
all_keys.extend(session["vault_keys"])
|
||||||
"""
|
|
||||||
|
if "cached_keys" in session:
|
||||||
cached_keys = session.get("cached_keys", [])
|
cached_keys = session.get("cached_keys", [])
|
||||||
all_keys = list(cached_keys)
|
if cached_keys:
|
||||||
|
for cached_key in cached_keys:
|
||||||
|
all_keys.append(cached_key)
|
||||||
|
|
||||||
for license_key in license_keys:
|
for license_key in license_keys:
|
||||||
already_exists = False
|
already_exists = False
|
||||||
license_kid = None
|
license_kid = None
|
||||||
if isinstance(license_key, dict) and "kid" in license_key:
|
if isinstance(license_key, dict) and "kid" in license_key:
|
||||||
license_kid = license_key["kid"].replace("-", "").lower()
|
license_kid = license_key["kid"].replace("-", "").lower()
|
||||||
elif hasattr(license_key, "kid"):
|
elif hasattr(license_key, "kid"):
|
||||||
license_kid = str(license_key.kid).replace("-", "").lower()
|
license_kid = str(license_key.kid).replace("-", "").lower()
|
||||||
elif hasattr(license_key, "key_id"):
|
elif hasattr(license_key, "key_id"):
|
||||||
license_kid = str(license_key.key_id).replace("-", "").lower()
|
license_kid = str(license_key.key_id).replace("-", "").lower()
|
||||||
|
|
||||||
if license_kid:
|
if license_kid:
|
||||||
for cached_key in cached_keys:
|
for existing_key in all_keys:
|
||||||
cached_kid = None
|
existing_kid = None
|
||||||
if isinstance(cached_key, dict) and "kid" in cached_key:
|
if isinstance(existing_key, dict) and "kid" in existing_key:
|
||||||
cached_kid = cached_key["kid"].replace("-", "").lower()
|
existing_kid = existing_key["kid"].replace("-", "").lower()
|
||||||
elif hasattr(cached_key, "kid"):
|
elif hasattr(existing_key, "kid"):
|
||||||
cached_kid = str(cached_key.kid).replace("-", "").lower()
|
existing_kid = str(existing_key.kid).replace("-", "").lower()
|
||||||
elif hasattr(cached_key, "key_id"):
|
elif hasattr(existing_key, "key_id"):
|
||||||
cached_kid = str(cached_key.key_id).replace("-", "").lower()
|
existing_kid = str(existing_key.key_id).replace("-", "").lower()
|
||||||
|
|
||||||
if cached_kid == license_kid:
|
if existing_kid == license_kid:
|
||||||
already_exists = True
|
already_exists = True
|
||||||
break
|
break
|
||||||
|
|
||||||
if not already_exists:
|
if not already_exists:
|
||||||
all_keys.append(license_key)
|
all_keys.append(license_key)
|
||||||
|
|
||||||
session["keys"] = all_keys
|
session["keys"] = all_keys
|
||||||
else:
|
session.pop("cached_keys", None)
|
||||||
session["keys"] = license_keys
|
session.pop("vault_keys", None)
|
||||||
|
|
||||||
if self.vaults and session["keys"]:
|
if self.vaults and session["keys"]:
|
||||||
key_dict = {UUID(hex=key["kid"]): key["key"] for key in session["keys"] if key["type"] == "CONTENT"}
|
key_dict = {}
|
||||||
self.vaults.add_keys(key_dict)
|
for key in session["keys"]:
|
||||||
|
if key["type"] == "CONTENT":
|
||||||
|
try:
|
||||||
|
clean_kid = key["kid"].replace("-", "")
|
||||||
|
if len(clean_kid) == 32:
|
||||||
|
kid_uuid = UUID(hex=clean_kid)
|
||||||
|
else:
|
||||||
|
kid_uuid = UUID(hex=clean_kid.ljust(32, "0"))
|
||||||
|
key_dict[kid_uuid] = key["key"]
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if key_dict:
|
||||||
|
self.vaults.add_keys(key_dict)
|
||||||
|
|
||||||
def get_keys(self, session_id: bytes, type_: Optional[str] = None) -> List[Key]:
|
def get_keys(self, session_id: bytes, type_: Optional[str] = None) -> List[Key]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from appdirs import AppDirs
|
|||||||
class Config:
|
class Config:
|
||||||
class _Directories:
|
class _Directories:
|
||||||
# default directories, do not modify here, set via config
|
# default directories, do not modify here, set via config
|
||||||
app_dirs = AppDirs("./", False)
|
app_dirs = AppDirs("unshackle", False)
|
||||||
core_dir = Path(__file__).resolve().parent
|
core_dir = Path(__file__).resolve().parent
|
||||||
namespace_dir = core_dir.parent
|
namespace_dir = core_dir.parent
|
||||||
commands = namespace_dir / "commands"
|
commands = namespace_dir / "commands"
|
||||||
@@ -30,7 +30,8 @@ class Config:
|
|||||||
|
|
||||||
class _Filenames:
|
class _Filenames:
|
||||||
# default filenames, do not modify here, set via config
|
# default filenames, do not modify here, set via config
|
||||||
log = "envied_{name}_{time}.log" # Directories.logs
|
log = "unshackle_{name}_{time}.log" # Directories.logs
|
||||||
|
debug_log = "unshackle_debug_{service}_{time}.jsonl" # Directories.logs
|
||||||
config = "config.yaml" # Directories.services / tag
|
config = "config.yaml" # Directories.services / tag
|
||||||
root_config = "envied.yaml" # Directories.user_configs
|
root_config = "envied.yaml" # Directories.user_configs
|
||||||
chapters = "Chapters_{title}_{random}.txt" # Directories.temp
|
chapters = "Chapters_{title}_{random}.txt" # Directories.temp
|
||||||
@@ -88,16 +89,20 @@ class Config:
|
|||||||
self.tag_group_name: bool = kwargs.get("tag_group_name", True)
|
self.tag_group_name: bool = kwargs.get("tag_group_name", True)
|
||||||
self.tag_imdb_tmdb: bool = kwargs.get("tag_imdb_tmdb", True)
|
self.tag_imdb_tmdb: bool = kwargs.get("tag_imdb_tmdb", True)
|
||||||
self.tmdb_api_key: str = kwargs.get("tmdb_api_key") or ""
|
self.tmdb_api_key: str = kwargs.get("tmdb_api_key") or ""
|
||||||
self.decrypt_labs_api_key: str = ""
|
self.simkl_client_id: str = kwargs.get("simkl_client_id") or ""
|
||||||
|
self.decrypt_labs_api_key: str = kwargs.get("decrypt_labs_api_key") or ""
|
||||||
self.update_checks: bool = kwargs.get("update_checks", True)
|
self.update_checks: bool = kwargs.get("update_checks", True)
|
||||||
self.update_check_interval: int = kwargs.get("update_check_interval", 24)
|
self.update_check_interval: int = kwargs.get("update_check_interval", 24)
|
||||||
self.scene_naming: bool = kwargs.get("scene_naming", True)
|
self.scene_naming: bool = kwargs.get("scene_naming", True)
|
||||||
self.series_year: bool = kwargs.get("series_year", True)
|
self.series_year: bool = kwargs.get("series_year", True)
|
||||||
self.pssh_display: str = kwargs.get("pssh_display") or "fold"
|
|
||||||
self.title_cache_time: int = kwargs.get("title_cache_time", 1800) # 30 minutes default
|
self.title_cache_time: int = kwargs.get("title_cache_time", 1800) # 30 minutes default
|
||||||
self.title_cache_max_retention: int = kwargs.get("title_cache_max_retention", 86400) # 24 hours default
|
self.title_cache_max_retention: int = kwargs.get("title_cache_max_retention", 86400) # 24 hours default
|
||||||
self.title_cache_enabled: bool = kwargs.get("title_cache_enabled", True)
|
self.title_cache_enabled: bool = kwargs.get("title_cache_enabled", True)
|
||||||
|
|
||||||
|
self.debug: bool = kwargs.get("debug", False)
|
||||||
|
self.debug_keys: bool = kwargs.get("debug_keys", False)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_yaml(cls, path: Path) -> Config:
|
def from_yaml(cls, path: Path) -> Config:
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
@@ -109,12 +114,12 @@ class Config:
|
|||||||
|
|
||||||
# noinspection PyProtectedMember
|
# noinspection PyProtectedMember
|
||||||
POSSIBLE_CONFIG_PATHS = (
|
POSSIBLE_CONFIG_PATHS = (
|
||||||
# The envied Namespace Folder (e.g., %appdata%/Python/Python311/site-packages/envied)
|
# The unshackle Namespace Folder (e.g., %appdata%/Python/Python311/site-packages/unshackle)
|
||||||
Config._Directories.namespace_dir / Config._Filenames.root_config,
|
Config._Directories.namespace_dir / Config._Filenames.root_config,
|
||||||
# The Parent Folder to the envied Namespace Folder (e.g., %appdata%/Python/Python311/site-packages)
|
# The Parent Folder to the unshackle Namespace Folder (e.g., %appdata%/Python/Python311/site-packages)
|
||||||
Config._Directories.namespace_dir.parent / Config._Filenames.root_config,
|
Config._Directories.namespace_dir.parent / Config._Filenames.root_config,
|
||||||
# The AppDirs User Config Folder (e.g., %localappdata%/envied)
|
# The AppDirs User Config Folder (e.g., ~/.config/unshackle on Linux, %LOCALAPPDATA%\unshackle on Windows)
|
||||||
Config._Directories.user_configs / Config._Filenames.root_config,
|
Path(Config._Directories.app_dirs.user_config_dir) / Config._Filenames.root_config,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -136,4 +141,4 @@ if config_path:
|
|||||||
else:
|
else:
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
__all__ = ("config",)
|
__all__ = ("config",)
|
||||||
|
|||||||
@@ -167,13 +167,6 @@ class ComfyConsole(Console):
|
|||||||
time.monotonic.
|
time.monotonic.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_instance: Optional["ComfyConsole"] = None
|
|
||||||
|
|
||||||
def __new__(cls, *args, **kwargs):
|
|
||||||
if cls._instance is None:
|
|
||||||
cls._instance = super().__new__(cls)
|
|
||||||
return cls._instance
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -207,10 +200,6 @@ class ComfyConsole(Console):
|
|||||||
_environ: Optional[Mapping[str, str]] = None,
|
_environ: Optional[Mapping[str, str]] = None,
|
||||||
log_renderer: Optional[LogRender] = None,
|
log_renderer: Optional[LogRender] = None,
|
||||||
):
|
):
|
||||||
# prevent re-initialisation on repeated calls
|
|
||||||
if getattr(self, "_initialized", False):
|
|
||||||
return
|
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
color_system=color_system,
|
color_system=color_system,
|
||||||
force_terminal=force_terminal,
|
force_terminal=force_terminal,
|
||||||
@@ -243,7 +232,6 @@ class ComfyConsole(Console):
|
|||||||
)
|
)
|
||||||
if log_renderer:
|
if log_renderer:
|
||||||
self._log_render = log_renderer
|
self._log_render = log_renderer
|
||||||
self._initialized = True
|
|
||||||
|
|
||||||
def status(
|
def status(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ DRM_SORT_MAP = ["ClearKey", "Widevine"]
|
|||||||
LANGUAGE_MAX_DISTANCE = 5 # this is max to be considered "same", e.g., en, en-US, en-AU
|
LANGUAGE_MAX_DISTANCE = 5 # this is max to be considered "same", e.g., en, en-US, en-AU
|
||||||
LANGUAGE_EXACT_DISTANCE = 0 # exact match only, no variants
|
LANGUAGE_EXACT_DISTANCE = 0 # exact match only, no variants
|
||||||
VIDEO_CODEC_MAP = {"AVC": "H.264", "HEVC": "H.265"}
|
VIDEO_CODEC_MAP = {"AVC": "H.264", "HEVC": "H.265"}
|
||||||
DYNAMIC_RANGE_MAP = {"HDR10": "HDR", "HDR10+": "HDR10P", "Dolby Vision": "DV", "HDR10 / HDR10+": "HDR10P", "HDR10 / HDR10": "HDR"}
|
DYNAMIC_RANGE_MAP = {
|
||||||
|
"HDR10": "HDR",
|
||||||
|
"HDR10+": "HDR10P",
|
||||||
|
"Dolby Vision": "DV",
|
||||||
|
"HDR10 / HDR10+": "HDR10P",
|
||||||
|
"HDR10 / HDR10": "HDR",
|
||||||
|
}
|
||||||
AUDIO_CODEC_MAP = {"E-AC-3": "DDP", "AC-3": "DD"}
|
AUDIO_CODEC_MAP = {"E-AC-3": "DDP", "AC-3": "DD"}
|
||||||
|
|
||||||
context_settings = dict(
|
context_settings = dict(
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import warnings
|
import warnings
|
||||||
from http.cookiejar import CookieJar
|
from http.cookiejar import CookieJar
|
||||||
from itertools import chain
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Generator, MutableMapping, Optional, Union
|
from typing import Any, Generator, MutableMapping
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from requests.cookies import cookiejar_from_dict, get_cookie_header
|
from requests.cookies import cookiejar_from_dict, get_cookie_header
|
||||||
@@ -16,251 +14,331 @@ from envied.core.config import config
|
|||||||
from envied.core.console import console
|
from envied.core.console import console
|
||||||
from envied.core.constants import DOWNLOAD_CANCELLED
|
from envied.core.constants import DOWNLOAD_CANCELLED
|
||||||
|
|
||||||
|
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.*)")
|
||||||
|
|
||||||
|
DECRYPTION_ENGINE = {
|
||||||
|
"shaka": "SHAKA_PACKAGER",
|
||||||
|
"mp4decrypt": "MP4DECRYPT",
|
||||||
|
}
|
||||||
|
|
||||||
# Ignore FutureWarnings
|
# Ignore FutureWarnings
|
||||||
warnings.simplefilter(action="ignore", category=FutureWarning)
|
warnings.simplefilter(action="ignore", category=FutureWarning)
|
||||||
|
|
||||||
AUDIO_CODEC_MAP = {"AAC": "mp4a", "AC3": "ac-3", "EC3": "ec-3"}
|
|
||||||
VIDEO_CODEC_MAP = {"AVC": "avc", "HEVC": "hvc", "DV": "dvh", "HLG": "hev"}
|
|
||||||
|
|
||||||
|
def get_track_selection_args(track: Any) -> list[str]:
|
||||||
|
"""
|
||||||
|
Generates track selection arguments for N_m3u8dl_RE.
|
||||||
|
|
||||||
def track_selection(track: object) -> list[str]:
|
Args:
|
||||||
"""Return the N_m3u8DL-RE stream selection arguments for a track."""
|
track: A track object with attributes like descriptor, data, and class name.
|
||||||
|
|
||||||
if "dash" in track.data:
|
Returns:
|
||||||
adaptation_set = track.data["dash"]["adaptation_set"]
|
A list of strings for track selection.
|
||||||
representation = track.data["dash"]["representation"]
|
|
||||||
|
|
||||||
track_type = track.__class__.__name__
|
Raises:
|
||||||
codec = track.codec.name
|
ValueError: If the manifest type is unsupported or track selection fails.
|
||||||
bitrate = track.bitrate // 1000
|
"""
|
||||||
language = track.language
|
descriptor = track.descriptor.name
|
||||||
width = track.width if track_type == "Video" else None
|
track_type = track.__class__.__name__
|
||||||
height = track.height if track_type == "Video" else None
|
|
||||||
range = track.range.name if track_type == "Video" else None
|
|
||||||
|
|
||||||
elif "ism" in track.data:
|
def _create_args(flag: str, parts: list[str], type_str: str, extra_args: list[str] | None = None) -> list[str]:
|
||||||
stream_index = track.data["ism"]["stream_index"]
|
if not parts:
|
||||||
quality_level = track.data["ism"]["quality_level"]
|
raise ValueError(f"[N_m3u8DL-RE]: Unable to select {type_str} track from {descriptor} manifest")
|
||||||
|
|
||||||
track_type = track.__class__.__name__
|
final_args = [flag, ":".join(parts)]
|
||||||
codec = track.codec.name
|
if extra_args:
|
||||||
bitrate = track.bitrate // 1000
|
final_args.extend(extra_args)
|
||||||
language = track.language
|
|
||||||
width = track.width if track_type == "Video" else None
|
|
||||||
height = track.height if track_type == "Video" else None
|
|
||||||
range = track.range.name if track_type == "Video" else None
|
|
||||||
adaptation_set = stream_index
|
|
||||||
representation = quality_level
|
|
||||||
|
|
||||||
else:
|
return final_args
|
||||||
return []
|
|
||||||
|
|
||||||
if track_type == "Audio":
|
match descriptor:
|
||||||
codecs = AUDIO_CODEC_MAP.get(codec)
|
case "HLS":
|
||||||
langs = adaptation_set.findall("lang") + representation.findall("lang")
|
# HLS playlists are direct inputs; no selection arguments needed.
|
||||||
track_ids = list(
|
return []
|
||||||
set(
|
|
||||||
v
|
case "DASH":
|
||||||
for x in chain(adaptation_set, representation)
|
representation = track.data.get("dash", {}).get("representation", {})
|
||||||
for v in (x.get("audioTrackId"), x.get("id"))
|
adaptation_set = track.data.get("dash", {}).get("adaptation_set", {})
|
||||||
if v is not None
|
parts = []
|
||||||
|
|
||||||
|
if track_type == "Audio":
|
||||||
|
if track_id := representation.get("id") or adaptation_set.get("audioTrackId"):
|
||||||
|
parts.append(rf'"id=\b{track_id}\b"')
|
||||||
|
else:
|
||||||
|
if codecs := representation.get("codecs"):
|
||||||
|
parts.append(f"codecs={codecs}")
|
||||||
|
if lang := representation.get("lang") or adaptation_set.get("lang"):
|
||||||
|
parts.append(f"lang={lang}")
|
||||||
|
if bw := representation.get("bandwidth"):
|
||||||
|
bitrate = int(bw) // 1000
|
||||||
|
parts.append(f"bwMin={bitrate}:bwMax={bitrate + 5}")
|
||||||
|
if roles := representation.findall("Role") + adaptation_set.findall("Role"):
|
||||||
|
if role := next((r.get("value") for r in roles if r.get("value", "").lower() == "main"), None):
|
||||||
|
parts.append(f"role={role}")
|
||||||
|
return _create_args("-sa", parts, "audio")
|
||||||
|
|
||||||
|
if track_type == "Video":
|
||||||
|
if track_id := representation.get("id"):
|
||||||
|
parts.append(rf'"id=\b{track_id}\b"')
|
||||||
|
else:
|
||||||
|
if width := representation.get("width"):
|
||||||
|
parts.append(f"res={width}*")
|
||||||
|
if codecs := representation.get("codecs"):
|
||||||
|
parts.append(f"codecs={codecs}")
|
||||||
|
if bw := representation.get("bandwidth"):
|
||||||
|
bitrate = int(bw) // 1000
|
||||||
|
parts.append(f"bwMin={bitrate}:bwMax={bitrate + 5}")
|
||||||
|
return _create_args("-sv", parts, "video")
|
||||||
|
|
||||||
|
if track_type == "Subtitle":
|
||||||
|
if track_id := representation.get("id"):
|
||||||
|
parts.append(rf'"id=\b{track_id}\b"')
|
||||||
|
else:
|
||||||
|
if lang := representation.get("lang"):
|
||||||
|
parts.append(f"lang={lang}")
|
||||||
|
return _create_args("-ss", parts, "subtitle", extra_args=["--auto-subtitle-fix", "false"])
|
||||||
|
|
||||||
|
case "ISM":
|
||||||
|
quality_level = track.data.get("ism", {}).get("quality_level", {})
|
||||||
|
stream_index = track.data.get("ism", {}).get("stream_index", {})
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
if track_type == "Audio":
|
||||||
|
if name := stream_index.get("Name") or quality_level.get("Index"):
|
||||||
|
parts.append(rf'"id=\b{name}\b"')
|
||||||
|
else:
|
||||||
|
if codecs := quality_level.get("FourCC"):
|
||||||
|
parts.append(f"codecs={codecs}")
|
||||||
|
if lang := stream_index.get("Language"):
|
||||||
|
parts.append(f"lang={lang}")
|
||||||
|
if br := quality_level.get("Bitrate"):
|
||||||
|
bitrate = int(br) // 1000
|
||||||
|
parts.append(f"bwMin={bitrate}:bwMax={bitrate + 5}")
|
||||||
|
return _create_args("-sa", parts, "audio")
|
||||||
|
|
||||||
|
if track_type == "Video":
|
||||||
|
if name := stream_index.get("Name") or quality_level.get("Index"):
|
||||||
|
parts.append(rf'"id=\b{name}\b"')
|
||||||
|
else:
|
||||||
|
if width := quality_level.get("MaxWidth"):
|
||||||
|
parts.append(f"res={width}*")
|
||||||
|
if codecs := quality_level.get("FourCC"):
|
||||||
|
parts.append(f"codecs={codecs}")
|
||||||
|
if br := quality_level.get("Bitrate"):
|
||||||
|
bitrate = int(br) // 1000
|
||||||
|
parts.append(f"bwMin={bitrate}:bwMax={bitrate + 5}")
|
||||||
|
return _create_args("-sv", parts, "video")
|
||||||
|
|
||||||
|
# I've yet to encounter a subtitle track in ISM manifests, so this is mostly theoretical.
|
||||||
|
if track_type == "Subtitle":
|
||||||
|
if name := stream_index.get("Name") or quality_level.get("Index"):
|
||||||
|
parts.append(rf'"id=\b{name}\b"')
|
||||||
|
else:
|
||||||
|
if lang := stream_index.get("Language"):
|
||||||
|
parts.append(f"lang={lang}")
|
||||||
|
return _create_args("-ss", parts, "subtitle", extra_args=["--auto-subtitle-fix", "false"])
|
||||||
|
|
||||||
|
case "URL":
|
||||||
|
raise ValueError(
|
||||||
|
f"[N_m3u8DL-RE]: Direct URL downloads are not supported for {track_type} tracks. "
|
||||||
|
f"The track should use a different downloader (e.g., 'requests', 'aria2c')."
|
||||||
)
|
)
|
||||||
)
|
|
||||||
roles = adaptation_set.findall("Role") + representation.findall("Role")
|
|
||||||
role = ":role=main" if next((i for i in roles if i.get("value").lower() == "main"), None) else ""
|
|
||||||
bandwidth = f"bwMin={bitrate}:bwMax={bitrate + 5}"
|
|
||||||
|
|
||||||
if langs:
|
raise ValueError(f"[N_m3u8DL-RE]: Unsupported manifest type: {descriptor}")
|
||||||
track_selection = ["-sa", f"lang={language}:codecs={codecs}:{bandwidth}{role}"]
|
|
||||||
elif len(track_ids) == 1:
|
|
||||||
track_selection = ["-sa", f"id={track_ids[0]}"]
|
|
||||||
else:
|
|
||||||
track_selection = ["-sa", f"for=best{role}"]
|
|
||||||
return track_selection
|
|
||||||
|
|
||||||
if track_type == "Video":
|
|
||||||
# adjust codec based on range
|
|
||||||
codec_adjustments = {("HEVC", "DV"): "DV", ("HEVC", "HLG"): "HLG"}
|
|
||||||
codec = codec_adjustments.get((codec, range), codec)
|
|
||||||
codecs = VIDEO_CODEC_MAP.get(codec)
|
|
||||||
|
|
||||||
bandwidth = f"bwMin={bitrate}:bwMax={bitrate + 5}"
|
def build_download_args(
|
||||||
if width and height:
|
track_url: str,
|
||||||
resolution = f"{width}x{height}"
|
filename: str,
|
||||||
elif width:
|
output_dir: Path,
|
||||||
resolution = f"{width}*"
|
thread_count: int,
|
||||||
else:
|
retry_count: int,
|
||||||
resolution = "for=best"
|
track_from_file: Path | None,
|
||||||
if resolution.startswith("for="):
|
custom_args: dict[str, Any] | None,
|
||||||
track_selection = ["-sv", resolution]
|
headers: dict[str, Any] | None,
|
||||||
track_selection.append(f"codecs={codecs}:{bandwidth}")
|
cookies: CookieJar | None,
|
||||||
else:
|
proxy: str | None,
|
||||||
track_selection = ["-sv", f"res={resolution}:codecs={codecs}:{bandwidth}"]
|
content_keys: dict[str, str] | None,
|
||||||
return track_selection
|
ad_keyword: str | None,
|
||||||
|
skip_merge: bool | None = False,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Constructs the CLI arguments for N_m3u8DL-RE."""
|
||||||
|
|
||||||
|
# Default arguments
|
||||||
|
args = {
|
||||||
|
"--save-name": filename,
|
||||||
|
"--save-dir": output_dir,
|
||||||
|
"--tmp-dir": output_dir,
|
||||||
|
"--thread-count": thread_count,
|
||||||
|
"--download-retry-count": retry_count,
|
||||||
|
"--write-meta-json": False,
|
||||||
|
"--no-log": True,
|
||||||
|
}
|
||||||
|
if proxy:
|
||||||
|
args["--custom-proxy"] = proxy
|
||||||
|
if skip_merge:
|
||||||
|
args["--skip-merge"] = skip_merge
|
||||||
|
if ad_keyword:
|
||||||
|
args["--ad-keyword"] = ad_keyword
|
||||||
|
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"
|
||||||
|
if custom_args:
|
||||||
|
args.update(custom_args)
|
||||||
|
|
||||||
|
command = [track_from_file or track_url]
|
||||||
|
for flag, value in args.items():
|
||||||
|
if value is True:
|
||||||
|
command.append(flag)
|
||||||
|
elif value is False:
|
||||||
|
command.extend([flag, "false"])
|
||||||
|
elif value is not False and value is not None:
|
||||||
|
command.extend([flag, str(value)])
|
||||||
|
|
||||||
|
if headers:
|
||||||
|
for key, value in headers.items():
|
||||||
|
if key.lower() not in ("accept-encoding", "cookie"):
|
||||||
|
command.extend(["--header", f"{key}: {value}"])
|
||||||
|
|
||||||
|
if cookies:
|
||||||
|
req = requests.Request(method="GET", url=track_url)
|
||||||
|
cookie_header = get_cookie_header(cookies, req)
|
||||||
|
command.extend(["--header", f"Cookie: {cookie_header}"])
|
||||||
|
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
def download(
|
def download(
|
||||||
urls: Union[str, dict[str, Any], list[str], list[dict[str, Any]]],
|
urls: str | dict[str, Any] | list[str | dict[str, Any]],
|
||||||
track: object,
|
track: Any,
|
||||||
output_dir: Path,
|
output_dir: Path,
|
||||||
filename: str,
|
filename: str,
|
||||||
headers: Optional[MutableMapping[str, Union[str, bytes]]] = None,
|
headers: MutableMapping[str, str | bytes] | None,
|
||||||
cookies: Optional[Union[MutableMapping[str, str], CookieJar]] = None,
|
cookies: MutableMapping[str, str] | CookieJar | None,
|
||||||
proxy: Optional[str] = None,
|
proxy: str | None,
|
||||||
max_workers: Optional[int] = None,
|
max_workers: int | None,
|
||||||
content_keys: Optional[dict[str, Any]] = None,
|
content_keys: dict[str, Any] | None,
|
||||||
|
skip_merge: bool | None = False,
|
||||||
) -> Generator[dict[str, Any], None, None]:
|
) -> Generator[dict[str, Any], None, None]:
|
||||||
if not urls:
|
if not urls:
|
||||||
raise ValueError("urls must be provided and not empty")
|
raise ValueError("urls must be provided and not empty")
|
||||||
elif not isinstance(urls, (str, dict, list)):
|
if not isinstance(urls, (str, dict, list)):
|
||||||
raise TypeError(f"Expected urls to be {str} or {dict} or a list of one of them, not {type(urls)}")
|
raise TypeError(f"Expected urls to be str, dict, or list, not {type(urls)}")
|
||||||
|
if not isinstance(output_dir, Path):
|
||||||
if not output_dir:
|
raise TypeError(f"Expected output_dir to be Path, not {type(output_dir)}")
|
||||||
raise ValueError("output_dir must be provided")
|
if not isinstance(filename, str) or not filename:
|
||||||
elif not isinstance(output_dir, Path):
|
raise ValueError("filename must be a non-empty string")
|
||||||
raise TypeError(f"Expected output_dir to be {Path}, not {type(output_dir)}")
|
|
||||||
|
|
||||||
if not filename:
|
|
||||||
raise ValueError("filename must be provided")
|
|
||||||
elif not isinstance(filename, str):
|
|
||||||
raise TypeError(f"Expected filename to be {str}, not {type(filename)}")
|
|
||||||
|
|
||||||
if not isinstance(headers, (MutableMapping, type(None))):
|
if not isinstance(headers, (MutableMapping, type(None))):
|
||||||
raise TypeError(f"Expected headers to be {MutableMapping}, not {type(headers)}")
|
raise TypeError(f"Expected headers to be a mapping or None, not {type(headers)}")
|
||||||
|
|
||||||
if not isinstance(cookies, (MutableMapping, CookieJar, type(None))):
|
if not isinstance(cookies, (MutableMapping, CookieJar, type(None))):
|
||||||
raise TypeError(f"Expected cookies to be {MutableMapping} or {CookieJar}, not {type(cookies)}")
|
raise TypeError(f"Expected cookies to be a mapping, CookieJar, or None, not {type(cookies)}")
|
||||||
|
|
||||||
if not isinstance(proxy, (str, type(None))):
|
if not isinstance(proxy, (str, type(None))):
|
||||||
raise TypeError(f"Expected proxy to be {str}, not {type(proxy)}")
|
raise TypeError(f"Expected proxy to be a str or None, not {type(proxy)}")
|
||||||
|
if not isinstance(max_workers, (int, type(None))):
|
||||||
if not max_workers:
|
raise TypeError(f"Expected max_workers to be an int or None, not {type(max_workers)}")
|
||||||
max_workers = min(32, (os.cpu_count() or 1) + 4)
|
if not isinstance(content_keys, (dict, type(None))):
|
||||||
elif not isinstance(max_workers, int):
|
raise TypeError(f"Expected content_keys to be a dict or None, not {type(content_keys)}")
|
||||||
raise TypeError(f"Expected max_workers to be {int}, not {type(max_workers)}")
|
if not isinstance(skip_merge, (bool, type(None))):
|
||||||
|
raise TypeError(f"Expected skip_merge to be a bool or None, not {type(skip_merge)}")
|
||||||
if not isinstance(urls, list):
|
|
||||||
urls = [urls]
|
|
||||||
|
|
||||||
if not binaries.N_m3u8DL_RE:
|
|
||||||
raise EnvironmentError("N_m3u8DL-RE executable not found...")
|
|
||||||
|
|
||||||
if cookies and not isinstance(cookies, CookieJar):
|
if cookies and not isinstance(cookies, CookieJar):
|
||||||
cookies = cookiejar_from_dict(cookies)
|
cookies = cookiejar_from_dict(cookies)
|
||||||
|
|
||||||
track_type = track.__class__.__name__
|
if not binaries.N_m3u8DL_RE:
|
||||||
thread_count = str(config.n_m3u8dl_re.get("thread_count", max_workers))
|
raise EnvironmentError("N_m3u8DL-RE executable not found...")
|
||||||
retry_count = str(config.n_m3u8dl_re.get("retry_count", max_workers))
|
|
||||||
|
effective_max_workers = max_workers or min(32, (os.cpu_count() or 1) + 4)
|
||||||
|
|
||||||
|
if proxy and not config.n_m3u8dl_re.get("use_proxy", True):
|
||||||
|
proxy = None
|
||||||
|
|
||||||
|
thread_count = config.n_m3u8dl_re.get("thread_count", effective_max_workers)
|
||||||
|
retry_count = config.n_m3u8dl_re.get("retry_count", 10)
|
||||||
ad_keyword = config.n_m3u8dl_re.get("ad_keyword")
|
ad_keyword = config.n_m3u8dl_re.get("ad_keyword")
|
||||||
|
|
||||||
arguments = [
|
arguments = build_download_args(
|
||||||
track.url,
|
track_url=track.url,
|
||||||
"--save-dir",
|
track_from_file=track.from_file,
|
||||||
output_dir,
|
filename=filename,
|
||||||
"--tmp-dir",
|
output_dir=output_dir,
|
||||||
output_dir,
|
thread_count=thread_count,
|
||||||
"--thread-count",
|
retry_count=retry_count,
|
||||||
thread_count,
|
custom_args=track.downloader_args,
|
||||||
"--download-retry-count",
|
headers=headers,
|
||||||
retry_count,
|
cookies=cookies,
|
||||||
"--no-log",
|
proxy=proxy,
|
||||||
"--write-meta-json",
|
content_keys=content_keys,
|
||||||
"false",
|
skip_merge=skip_merge,
|
||||||
]
|
ad_keyword=ad_keyword,
|
||||||
|
)
|
||||||
|
arguments.extend(get_track_selection_args(track))
|
||||||
|
|
||||||
for header, value in (headers or {}).items():
|
yield {"total": 100}
|
||||||
if header.lower() in ("accept-encoding", "cookie"):
|
yield {"downloaded": "Parsing streams..."}
|
||||||
continue
|
|
||||||
arguments.extend(["--header", f"{header}: {value}"])
|
|
||||||
|
|
||||||
if cookies:
|
|
||||||
cookie_header = get_cookie_header(cookies, requests.Request(url=track.url))
|
|
||||||
if cookie_header:
|
|
||||||
arguments.extend(["--header", f"Cookie: {cookie_header}"])
|
|
||||||
|
|
||||||
if proxy:
|
|
||||||
arguments.extend(["--custom-proxy", proxy])
|
|
||||||
|
|
||||||
if content_keys:
|
|
||||||
for kid, key in content_keys.items():
|
|
||||||
keys = f"{kid.hex}:{key.lower()}"
|
|
||||||
arguments.extend(["--key", keys])
|
|
||||||
arguments.extend(["--use-shaka-packager"])
|
|
||||||
|
|
||||||
if ad_keyword:
|
|
||||||
arguments.extend(["--ad-keyword", ad_keyword])
|
|
||||||
|
|
||||||
if track.descriptor.name == "URL":
|
|
||||||
error = f"[N_m3u8DL-RE]: {track.descriptor} is currently not supported"
|
|
||||||
raise ValueError(error)
|
|
||||||
elif track.descriptor.name == "DASH":
|
|
||||||
arguments.extend(track_selection(track))
|
|
||||||
|
|
||||||
# TODO: improve this nonsense
|
|
||||||
percent_re = re.compile(r"(\d+\.\d+%)")
|
|
||||||
speed_re = re.compile(r"(?<!/)(\d+\.\d+MB)(?!.*\/)")
|
|
||||||
warn = re.compile(r"(WARN : Response.*)")
|
|
||||||
error = re.compile(r"(ERROR.*)")
|
|
||||||
size_patterns = [
|
|
||||||
re.compile(r"(\d+\.\d+MB/\d+\.\d+GB)"),
|
|
||||||
re.compile(r"(\d+\.\d+GB/\d+\.\d+GB)"),
|
|
||||||
re.compile(r"(\d+\.\d+MB/\d+\.\d+MB)"),
|
|
||||||
]
|
|
||||||
|
|
||||||
yield dict(total=100)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with subprocess.Popen(
|
with subprocess.Popen(
|
||||||
[binaries.N_m3u8DL_RE, *arguments], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
|
[binaries.N_m3u8DL_RE, *arguments],
|
||||||
) as p:
|
stdout=subprocess.PIPE,
|
||||||
for line in p.stdout:
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
) as process:
|
||||||
|
last_line = ""
|
||||||
|
track_type = track.__class__.__name__
|
||||||
|
|
||||||
|
for line in process.stdout:
|
||||||
output = line.strip()
|
output = line.strip()
|
||||||
if output:
|
if not output:
|
||||||
percent = percent_re.search(output)
|
continue
|
||||||
speed = speed_re.search(output)
|
last_line = output
|
||||||
size = next(
|
|
||||||
(pattern.search(output).group(1) for pattern in size_patterns if pattern.search(output)), ""
|
|
||||||
)
|
|
||||||
|
|
||||||
if speed:
|
if warn_match := WARN_RE.search(output):
|
||||||
yield dict(downloaded=f"{speed.group(1)}ps {size}")
|
console.log(f"{track_type} {warn_match.group(1)}")
|
||||||
if percent:
|
continue
|
||||||
progress = int(percent.group(1).split(".")[0])
|
|
||||||
yield dict(completed=progress) if progress < 100 else dict(downloaded="Merging")
|
|
||||||
|
|
||||||
if warn.search(output):
|
if speed_match := SPEED_RE.search(output):
|
||||||
console.log(f"{track_type} " + warn.search(output).group(1))
|
size = size_match.group(1) if (size_match := SIZE_RE.search(output)) else ""
|
||||||
|
yield {"downloaded": f"{speed_match.group(1)} {size}"}
|
||||||
|
|
||||||
p.wait()
|
if percent_match := PERCENT_RE.search(output):
|
||||||
|
progress = int(percent_match.group(1).split(".", 1)[0])
|
||||||
|
yield {"completed": progress} if progress < 100 else {"downloaded": "Merging"}
|
||||||
|
|
||||||
if p.returncode != 0:
|
process.wait()
|
||||||
if error.search(output):
|
if process.returncode != 0:
|
||||||
raise ValueError(f"[N_m3u8DL-RE]: {error.search(output).group(1)}")
|
if error_match := ERROR_RE.search(last_line):
|
||||||
raise subprocess.CalledProcessError(p.returncode, arguments)
|
raise ValueError(f"[N_m3u8DL-RE]: {error_match.group(1)}")
|
||||||
|
raise subprocess.CalledProcessError(process.returncode, arguments)
|
||||||
|
|
||||||
except ConnectionResetError:
|
except ConnectionResetError:
|
||||||
# interrupted while passing URI to download
|
# interrupted while passing URI to download
|
||||||
raise KeyboardInterrupt()
|
raise KeyboardInterrupt()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||||
yield dict(downloaded="[yellow]CANCELLED")
|
yield {"downloaded": "[yellow]CANCELLED"}
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||||
yield dict(downloaded="[red]FAILED")
|
yield {"downloaded": "[red]FAILED"}
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def n_m3u8dl_re(
|
def n_m3u8dl_re(
|
||||||
urls: Union[str, list[str], dict[str, Any], list[dict[str, Any]]],
|
urls: str | list[str] | dict[str, Any] | list[dict[str, Any]],
|
||||||
track: object,
|
track: Any,
|
||||||
output_dir: Path,
|
output_dir: Path,
|
||||||
filename: str,
|
filename: str,
|
||||||
headers: Optional[MutableMapping[str, Union[str, bytes]]] = None,
|
headers: MutableMapping[str, str | bytes] | None = None,
|
||||||
cookies: Optional[Union[MutableMapping[str, str], CookieJar]] = None,
|
cookies: MutableMapping[str, str] | CookieJar | None = None,
|
||||||
proxy: Optional[str] = None,
|
proxy: str | None = None,
|
||||||
max_workers: Optional[int] = None,
|
max_workers: int | None = None,
|
||||||
content_keys: Optional[dict[str, Any]] = None,
|
content_keys: dict[str, Any] | None = None,
|
||||||
|
skip_merge: bool | None = False,
|
||||||
) -> Generator[dict[str, Any], None, None]:
|
) -> Generator[dict[str, Any], None, None]:
|
||||||
"""
|
"""
|
||||||
Download files using N_m3u8DL-RE.
|
Download files using N_m3u8DL-RE.
|
||||||
@@ -275,28 +353,33 @@ def n_m3u8dl_re(
|
|||||||
The data is in the same format accepted by rich's progress.update() function.
|
The data is in the same format accepted by rich's progress.update() function.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
urls: Web URL(s) to file(s) to download. You can use a dictionary with the key
|
urls: Web URL(s) to file(s) to download. NOTE: This parameter is ignored for now.
|
||||||
"url" for the URI, and other keys for extra arguments to use per-URL.
|
|
||||||
track: The track to download. Used to get track attributes for the selection
|
track: The track to download. Used to get track attributes for the selection
|
||||||
process. Note that Track.Descriptor.URL is not supported by N_m3u8DL-RE.
|
process. Note that Track.Descriptor.URL is not supported by N_m3u8DL-RE.
|
||||||
output_dir: The folder to save the file into. If the save path's directory does
|
output_dir: The folder to save the file into. If the save path's directory does
|
||||||
not exist then it will be made automatically.
|
not exist then it will be made automatically.
|
||||||
filename: The filename or filename template to use for each file. The variables
|
filename: The filename or filename template to use for each file.
|
||||||
you can use are `i` for the URL index and `ext` for the URL extension.
|
headers: A mapping of HTTP Header Key/Values to use for all downloads.
|
||||||
headers: A mapping of HTTP Header Key/Values to use for the download.
|
cookies: A mapping of Cookie Key/Values or a Cookie Jar to use for all downloads.
|
||||||
cookies: A mapping of Cookie Key/Values or a Cookie Jar to use for the download.
|
proxy: A proxy to use for all downloads.
|
||||||
max_workers: The maximum amount of threads to use for downloads. Defaults to
|
max_workers: The maximum amount of threads to use for downloads. Defaults to
|
||||||
min(32,(cpu_count+4)). Can be set in config with --thread-count option.
|
min(32,(cpu_count+4)). Can be set in config with --thread-count option.
|
||||||
content_keys: The content keys to use for decryption.
|
content_keys: The content keys to use for decryption.
|
||||||
|
skip_merge: Whether to skip merging the downloaded chunks.
|
||||||
"""
|
"""
|
||||||
track_type = track.__class__.__name__
|
|
||||||
|
|
||||||
log = logging.getLogger("N_m3u8DL-RE")
|
yield from download(
|
||||||
if proxy and not config.n_m3u8dl_re.get("use_proxy", True):
|
urls=urls,
|
||||||
log.warning(f"{track_type}: Ignoring proxy as N_m3u8DL-RE is set to use_proxy=False")
|
track=track,
|
||||||
proxy = None
|
output_dir=output_dir,
|
||||||
|
filename=filename,
|
||||||
yield from download(urls, track, output_dir, filename, headers, cookies, proxy, max_workers, content_keys)
|
headers=headers,
|
||||||
|
cookies=cookies,
|
||||||
|
proxy=proxy,
|
||||||
|
max_workers=max_workers,
|
||||||
|
content_keys=content_keys,
|
||||||
|
skip_merge=skip_merge,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ("n_m3u8dl_re",)
|
__all__ = ("n_m3u8dl_re",)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import shutil
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
from Cryptodome.Cipher import AES
|
from Cryptodome.Cipher import AES
|
||||||
from Cryptodome.Util.Padding import unpad
|
from Cryptodome.Util.Padding import unpad
|
||||||
from curl_cffi.requests import Session as CurlSession
|
from curl_cffi.requests import Session as CurlSession
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ class PlayReady:
|
|||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding='utf-8')
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
error_msg = e.stderr if e.stderr else f"mp4decrypt failed with exit code {e.returncode}"
|
error_msg = e.stderr if e.stderr else f"mp4decrypt failed with exit code {e.returncode}"
|
||||||
raise subprocess.CalledProcessError(e.returncode, cmd, output=e.stdout, stderr=error_msg)
|
raise subprocess.CalledProcessError(e.returncode, cmd, output=e.stdout, stderr=error_msg)
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ class Widevine:
|
|||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding='utf-8')
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
error_msg = e.stderr if e.stderr else f"mp4decrypt failed with exit code {e.returncode}"
|
error_msg = e.stderr if e.stderr else f"mp4decrypt failed with exit code {e.returncode}"
|
||||||
raise subprocess.CalledProcessError(e.returncode, cmd, output=e.stdout, stderr=error_msg)
|
raise subprocess.CalledProcessError(e.returncode, cmd, output=e.stdout, stderr=error_msg)
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ class DASH:
|
|||||||
if not session:
|
if not session:
|
||||||
session = Session()
|
session = Session()
|
||||||
elif not isinstance(session, (Session, CurlSession)):
|
elif not isinstance(session, (Session, CurlSession)):
|
||||||
raise TypeError(f"Expected session to be a {Session}, not {session!r}")
|
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}")
|
||||||
|
|
||||||
if proxy:
|
if proxy:
|
||||||
session.proxies.update({"all": proxy})
|
session.proxies.update({"all": proxy})
|
||||||
@@ -384,7 +384,8 @@ class DASH:
|
|||||||
segment_duration = float(segment_template.get("duration")) or 1
|
segment_duration = float(segment_template.get("duration")) or 1
|
||||||
|
|
||||||
if not end_number:
|
if not end_number:
|
||||||
end_number = math.floor(period_duration / (segment_duration / segment_timescale))
|
segment_count = math.ceil(period_duration / (segment_duration / segment_timescale))
|
||||||
|
end_number = start_number + segment_count - 1
|
||||||
|
|
||||||
for s in range(start_number, end_number + 1):
|
for s in range(start_number, end_number + 1):
|
||||||
segments.append(
|
segments.append(
|
||||||
@@ -485,10 +486,6 @@ class DASH:
|
|||||||
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||||
progress(downloaded="[red]FAILED")
|
progress(downloaded="[red]FAILED")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# --- unchanged above ---
|
|
||||||
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
drm = None
|
drm = None
|
||||||
|
|
||||||
@@ -806,4 +803,4 @@ class DASH:
|
|||||||
return url
|
return url
|
||||||
|
|
||||||
|
|
||||||
__all__ = ("DASH",)
|
__all__ = ("DASH",)
|
||||||
|
|||||||
@@ -249,17 +249,20 @@ class HLS:
|
|||||||
|
|
||||||
log = logging.getLogger("HLS")
|
log = logging.getLogger("HLS")
|
||||||
|
|
||||||
# Get the playlist text and handle both session types
|
if track.from_file:
|
||||||
response = session.get(track.url)
|
master = m3u8.load(str(track.from_file))
|
||||||
if isinstance(response, requests.Response):
|
|
||||||
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:
|
else:
|
||||||
raise TypeError(f"Expected response to be a requests.Response or curl_cffi.Response, not {type(response)}")
|
# Get the playlist text and handle both session types
|
||||||
|
response = session.get(track.url)
|
||||||
|
if isinstance(response, requests.Response):
|
||||||
|
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)}")
|
||||||
|
|
||||||
master = m3u8.loads(playlist_text, uri=track.url)
|
master = m3u8.loads(playlist_text, uri=track.url)
|
||||||
|
|
||||||
if not master.segments:
|
if not master.segments:
|
||||||
log.error("Track's HLS playlist has no segments, expecting an invariant M3U8 playlist.")
|
log.error("Track's HLS playlist has no segments, expecting an invariant M3U8 playlist.")
|
||||||
@@ -439,7 +442,7 @@ class HLS:
|
|||||||
elif len(files) != range_len:
|
elif len(files) != range_len:
|
||||||
raise ValueError(f"Missing {range_len - len(files)} segment files for {segment_range}...")
|
raise ValueError(f"Missing {range_len - len(files)} segment files for {segment_range}...")
|
||||||
|
|
||||||
if isinstance(drm, Widevine):
|
if isinstance(drm, (Widevine, PlayReady)):
|
||||||
# with widevine we can merge all segments and decrypt once
|
# with widevine we can merge all segments and decrypt once
|
||||||
merge(to=merged_path, via=files, delete=True, include_map_data=True)
|
merge(to=merged_path, via=files, delete=True, include_map_data=True)
|
||||||
drm.decrypt(merged_path)
|
drm.decrypt(merged_path)
|
||||||
@@ -876,4 +879,4 @@ class HLS:
|
|||||||
return f"{offset}-{offset + length - 1}"
|
return f"{offset}-{offset + length - 1}"
|
||||||
|
|
||||||
|
|
||||||
__all__ = ("HLS",)
|
__all__ = ("HLS",)
|
||||||
|
|||||||
@@ -335,4 +335,4 @@ class ISM:
|
|||||||
progress(downloaded="Downloaded")
|
progress(downloaded="Downloaded")
|
||||||
|
|
||||||
|
|
||||||
__all__ = ("ISM",)
|
__all__ = ("ISM",)
|
||||||
|
|||||||
@@ -31,4 +31,4 @@ def parse(
|
|||||||
return tracks
|
return tracks
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["parse"]
|
__all__ = ["parse"]
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ from .basic import Basic
|
|||||||
from .hola import Hola
|
from .hola import Hola
|
||||||
from .nordvpn import NordVPN
|
from .nordvpn import NordVPN
|
||||||
from .surfsharkvpn import SurfsharkVPN
|
from .surfsharkvpn import SurfsharkVPN
|
||||||
|
from .windscribevpn import WindscribeVPN
|
||||||
|
|
||||||
__all__ = ("Basic", "Hola", "NordVPN", "SurfsharkVPN")
|
__all__ = ("Basic", "Hola", "NordVPN", "SurfsharkVPN", "WindscribeVPN")
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import json
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from envied.core.proxies.proxy import Proxy
|
||||||
|
|
||||||
|
|
||||||
|
class WindscribeVPN(Proxy):
|
||||||
|
def __init__(self, username: str, password: str, server_map: Optional[dict[str, str]] = None):
|
||||||
|
"""
|
||||||
|
Proxy Service using WindscribeVPN Service Credentials.
|
||||||
|
|
||||||
|
A username and password must be provided. These are Service Credentials, not your Login Credentials.
|
||||||
|
The Service Credentials can be found here: https://windscribe.com/getconfig/openvpn
|
||||||
|
"""
|
||||||
|
if not username:
|
||||||
|
raise ValueError("No Username was provided to the WindscribeVPN Proxy Service.")
|
||||||
|
if not password:
|
||||||
|
raise ValueError("No Password was provided to the WindscribeVPN Proxy Service.")
|
||||||
|
|
||||||
|
if server_map is not None and not isinstance(server_map, dict):
|
||||||
|
raise TypeError(f"Expected server_map to be a dict mapping a region to a hostname, not '{server_map!r}'.")
|
||||||
|
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.server_map = server_map or {}
|
||||||
|
|
||||||
|
self.countries = self.get_countries()
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
countries = len(set(x.get("country_code") for x in self.countries if x.get("country_code")))
|
||||||
|
servers = sum(
|
||||||
|
len(host)
|
||||||
|
for location in self.countries
|
||||||
|
for group in location.get("groups", [])
|
||||||
|
for host in group.get("hosts", [])
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"{countries} Countr{['ies', 'y'][countries == 1]} ({servers} Server{['s', ''][servers == 1]})"
|
||||||
|
|
||||||
|
def get_proxy(self, query: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get an HTTPS proxy URI for a WindscribeVPN server.
|
||||||
|
"""
|
||||||
|
query = query.lower()
|
||||||
|
|
||||||
|
if query in self.server_map:
|
||||||
|
hostname = self.server_map[query]
|
||||||
|
else:
|
||||||
|
if re.match(r"^[a-z]+$", query):
|
||||||
|
hostname = self.get_random_server(query)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"The query provided is unsupported and unrecognized: {query}")
|
||||||
|
|
||||||
|
if not hostname:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return f"https://{self.username}:{self.password}@{hostname}:443"
|
||||||
|
|
||||||
|
def get_random_server(self, country_code: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get a random server hostname for a country.
|
||||||
|
|
||||||
|
Returns None if no servers are available for the country.
|
||||||
|
"""
|
||||||
|
for location in self.countries:
|
||||||
|
if location.get("country_code", "").lower() == country_code.lower():
|
||||||
|
hostnames = []
|
||||||
|
for group in location.get("groups", []):
|
||||||
|
for host in group.get("hosts", []):
|
||||||
|
if hostname := host.get("hostname"):
|
||||||
|
hostnames.append(hostname)
|
||||||
|
|
||||||
|
if hostnames:
|
||||||
|
return random.choice(hostnames)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_countries() -> list[dict]:
|
||||||
|
"""Get a list of available Countries and their metadata."""
|
||||||
|
res = requests.get(
|
||||||
|
url="https://assets.windscribe.com/serverlist/firefox/1/1",
|
||||||
|
headers={
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not res.ok:
|
||||||
|
raise ValueError(f"Failed to get a list of WindscribeVPN locations [{res.status_code}]")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = res.json()
|
||||||
|
return data.get("data", [])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise ValueError("Could not decode list of WindscribeVPN locations, not JSON data.")
|
||||||
@@ -2,9 +2,16 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import time
|
||||||
import warnings
|
import warnings
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from curl_cffi.requests import Session as CurlSession
|
from curl_cffi.requests import Response, Session, exceptions
|
||||||
|
|
||||||
from envied.core.config import config
|
from envied.core.config import config
|
||||||
|
|
||||||
@@ -14,30 +21,145 @@ warnings.filterwarnings(
|
|||||||
"ignore", message="Make sure you are using https over https proxy.*", category=RuntimeWarning, module="curl_cffi.*"
|
"ignore", message="Make sure you are using https over https proxy.*", category=RuntimeWarning, module="curl_cffi.*"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
FINGERPRINT_PRESETS = {
|
||||||
|
"okhttp4": {
|
||||||
|
"ja3": (
|
||||||
|
"771," # TLS 1.2
|
||||||
|
"4865-4866-4867-49195-49196-52393-49199-49200-52392-49171-49172-156-157-47-53," # Ciphers
|
||||||
|
"0-23-65281-10-11-35-16-5-13-51-45-43," # Extensions
|
||||||
|
"29-23-24," # Named groups (x25519, secp256r1, secp384r1)
|
||||||
|
"0" # EC point formats
|
||||||
|
),
|
||||||
|
"akamai": "4:16777216|16711681|0|m,p,a,s",
|
||||||
|
"description": "OkHttp 3.x/4.x (BoringSSL TLS stack)",
|
||||||
|
},
|
||||||
|
"okhttp5": {
|
||||||
|
"ja3": (
|
||||||
|
"771," # TLS 1.2
|
||||||
|
"4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53," # Ciphers
|
||||||
|
"0-23-65281-10-11-35-16-5-13-51-45-43," # Extensions
|
||||||
|
"29-23-24," # Named groups (x25519, secp256r1, secp384r1)
|
||||||
|
"0" # EC point formats
|
||||||
|
),
|
||||||
|
"akamai": "4:16777216|16711681|0|m,p,a,s",
|
||||||
|
"description": "OkHttp 5.x (BoringSSL TLS stack)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
class Session(CurlSession):
|
|
||||||
"""curl_cffi Session with warning suppression."""
|
|
||||||
|
|
||||||
def request(self, method, url, **kwargs):
|
class MaxRetriesError(exceptions.RequestException):
|
||||||
with warnings.catch_warnings():
|
def __init__(self, message, cause=None):
|
||||||
warnings.filterwarnings(
|
super().__init__(message)
|
||||||
"ignore", message="Make sure you are using https over https proxy.*", category=RuntimeWarning
|
self.__cause__ = cause
|
||||||
)
|
|
||||||
|
|
||||||
|
class CurlSession(Session):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
max_retries: int = 10,
|
||||||
|
backoff_factor: float = 0.2,
|
||||||
|
max_backoff: float = 60.0,
|
||||||
|
status_forcelist: list[int] | None = None,
|
||||||
|
allowed_methods: set[str] | None = None,
|
||||||
|
catch_exceptions: tuple[type[Exception], ...] | None = None,
|
||||||
|
**session_kwargs: Any,
|
||||||
|
):
|
||||||
|
super().__init__(**session_kwargs)
|
||||||
|
|
||||||
|
self.max_retries = max_retries
|
||||||
|
self.backoff_factor = backoff_factor
|
||||||
|
self.max_backoff = max_backoff
|
||||||
|
self.status_forcelist = status_forcelist or [429, 500, 502, 503, 504]
|
||||||
|
self.allowed_methods = allowed_methods or {"GET", "POST", "HEAD", "OPTIONS", "PUT", "DELETE", "TRACE"}
|
||||||
|
self.catch_exceptions = catch_exceptions or (
|
||||||
|
exceptions.ConnectionError,
|
||||||
|
exceptions.ProxyError,
|
||||||
|
exceptions.SSLError,
|
||||||
|
exceptions.Timeout,
|
||||||
|
)
|
||||||
|
self.log = logging.getLogger(self.__class__.__name__)
|
||||||
|
|
||||||
|
def get_sleep_time(self, response: Response | None, attempt: int) -> float | None:
|
||||||
|
if response:
|
||||||
|
retry_after = response.headers.get("Retry-After")
|
||||||
|
if retry_after:
|
||||||
|
try:
|
||||||
|
return float(retry_after)
|
||||||
|
except ValueError:
|
||||||
|
if retry_date := parsedate_to_datetime(retry_after):
|
||||||
|
return (retry_date - datetime.now(timezone.utc)).total_seconds()
|
||||||
|
|
||||||
|
if attempt == 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
backoff_value = self.backoff_factor * (2 ** (attempt - 1))
|
||||||
|
jitter = backoff_value * 0.1
|
||||||
|
sleep_time = backoff_value + random.uniform(-jitter, jitter)
|
||||||
|
return min(sleep_time, self.max_backoff)
|
||||||
|
|
||||||
|
def request(self, method: str, url: str, **kwargs: Any) -> Response:
|
||||||
|
if method.upper() not in self.allowed_methods:
|
||||||
return super().request(method, url, **kwargs)
|
return super().request(method, url, **kwargs)
|
||||||
|
|
||||||
|
last_exception = None
|
||||||
|
response = None
|
||||||
|
|
||||||
def session(browser: str | None = None, **kwargs) -> Session:
|
for attempt in range(self.max_retries + 1):
|
||||||
|
try:
|
||||||
|
response = super().request(method, url, **kwargs)
|
||||||
|
if response.status_code not in self.status_forcelist:
|
||||||
|
return response
|
||||||
|
last_exception = exceptions.HTTPError(f"Received status code: {response.status_code}")
|
||||||
|
self.log.warning(
|
||||||
|
f"{response.status_code} {response.reason}({urlparse(url).path}). Retrying... "
|
||||||
|
f"({attempt + 1}/{self.max_retries})"
|
||||||
|
)
|
||||||
|
|
||||||
|
except self.catch_exceptions as e:
|
||||||
|
last_exception = e
|
||||||
|
response = None
|
||||||
|
self.log.warning(
|
||||||
|
f"{e.__class__.__name__}({urlparse(url).path}). Retrying... ({attempt + 1}/{self.max_retries})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if attempt < self.max_retries:
|
||||||
|
if sleep_duration := self.get_sleep_time(response, attempt + 1):
|
||||||
|
if sleep_duration > 0:
|
||||||
|
time.sleep(sleep_duration)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
raise MaxRetriesError(f"Max retries exceeded for {method} {url}", cause=last_exception)
|
||||||
|
|
||||||
|
|
||||||
|
def session(
|
||||||
|
browser: str | None = None,
|
||||||
|
ja3: str | None = None,
|
||||||
|
akamai: str | None = None,
|
||||||
|
extra_fp: dict | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> CurlSession:
|
||||||
"""
|
"""
|
||||||
Create a curl_cffi session that impersonates a browser.
|
Create a curl_cffi session that impersonates a browser or custom TLS/HTTP fingerprint.
|
||||||
|
|
||||||
This is a full replacement for requests.Session with browser impersonation
|
This is a full replacement for requests.Session with browser impersonation
|
||||||
and anti-bot capabilities. The session uses curl-impersonate under the hood
|
and anti-bot capabilities. The session uses curl-impersonate under the hood
|
||||||
to mimic real browser behavior.
|
to mimic real browser behavior.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
browser: Browser to impersonate (e.g. "chrome124", "firefox", "safari").
|
browser: Browser to impersonate (e.g. "chrome124", "firefox", "safari") OR
|
||||||
|
fingerprint preset name (e.g. "okhttp4").
|
||||||
Uses the configured default from curl_impersonate.browser if not specified.
|
Uses the configured default from curl_impersonate.browser if not specified.
|
||||||
See https://github.com/lexiforest/curl_cffi#sessions for available options.
|
Available presets: okhttp4
|
||||||
|
See https://github.com/lexiforest/curl_cffi#sessions for browser options.
|
||||||
|
ja3: Custom JA3 TLS fingerprint string (format: "SSLVersion,Ciphers,Extensions,Curves,PointFormats").
|
||||||
|
When provided, curl_cffi will use this exact TLS fingerprint instead of the browser's default.
|
||||||
|
See https://curl-cffi.readthedocs.io/en/latest/impersonate/customize.html
|
||||||
|
akamai: Custom Akamai HTTP/2 fingerprint string (format: "SETTINGS|WINDOW_UPDATE|PRIORITY|PSEUDO_HEADERS").
|
||||||
|
When provided, curl_cffi will use this exact HTTP/2 fingerprint instead of the browser's default.
|
||||||
|
See https://curl-cffi.readthedocs.io/en/latest/impersonate/customize.html
|
||||||
|
extra_fp: Additional fingerprint parameters dict for advanced customization.
|
||||||
|
See https://curl-cffi.readthedocs.io/en/latest/impersonate/customize.html
|
||||||
**kwargs: Additional arguments passed to CurlSession constructor:
|
**kwargs: Additional arguments passed to CurlSession constructor:
|
||||||
- headers: Additional headers (dict)
|
- headers: Additional headers (dict)
|
||||||
- cookies: Cookie jar or dict
|
- cookies: Cookie jar or dict
|
||||||
@@ -49,31 +171,80 @@ def session(browser: str | None = None, **kwargs) -> Session:
|
|||||||
- max_redirects: Maximum redirect count (int)
|
- max_redirects: Maximum redirect count (int)
|
||||||
- cert: Client certificate (str or tuple)
|
- cert: Client certificate (str or tuple)
|
||||||
|
|
||||||
Returns:
|
Extra arguments for retry handler:
|
||||||
curl_cffi.requests.Session configured with browser impersonation, common headers,
|
- max_retries: Maximum number of retries (int, default 10)
|
||||||
and equivalent retry behavior to requests.Session.
|
- backoff_factor: Backoff factor (float, default 0.2)
|
||||||
|
- max_backoff: Maximum backoff time (float, default 60.0)
|
||||||
|
- status_forcelist: List of status codes to force retry (list, default [429, 500, 502, 503, 504])
|
||||||
|
- allowed_methods: List of allowed HTTP methods (set, default {"GET", "POST", "HEAD", "OPTIONS", "PUT", "DELETE", "TRACE"})
|
||||||
|
- catch_exceptions: List of exceptions to catch (tuple, default (exceptions.ConnectionError, exceptions.ProxyError, exceptions.SSLError, exceptions.Timeout))
|
||||||
|
|
||||||
Example:
|
Returns:
|
||||||
|
curl_cffi.requests.Session configured with browser impersonation or custom fingerprints,
|
||||||
|
common headers, and equivalent retry behavior to requests.Session.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Standard browser impersonation
|
||||||
from envied.core.session import session
|
from envied.core.session import session
|
||||||
|
|
||||||
class MyService(Service):
|
class MyService(Service):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_session():
|
def get_session():
|
||||||
return session() # Uses config default browser
|
return session() # Uses config default browser
|
||||||
"""
|
|
||||||
if browser is None:
|
|
||||||
browser = config.curl_impersonate.get("browser", "chrome124")
|
|
||||||
|
|
||||||
session_config = {
|
# Use OkHttp 4.x preset for Android TV
|
||||||
"impersonate": browser,
|
class AndroidService(Service):
|
||||||
"timeout": 30.0,
|
@staticmethod
|
||||||
"allow_redirects": True,
|
def get_session():
|
||||||
"max_redirects": 15,
|
return session("okhttp4")
|
||||||
"verify": True,
|
|
||||||
}
|
# Custom fingerprint (manual)
|
||||||
|
class CustomService(Service):
|
||||||
|
@staticmethod
|
||||||
|
def get_session():
|
||||||
|
return session(
|
||||||
|
ja3="771,4865-4866-4867-49195...",
|
||||||
|
akamai="1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
|
||||||
|
)
|
||||||
|
|
||||||
|
# With retry configuration
|
||||||
|
class MyService(Service):
|
||||||
|
@staticmethod
|
||||||
|
def get_session():
|
||||||
|
return session(
|
||||||
|
"okhttp4",
|
||||||
|
max_retries=5,
|
||||||
|
status_forcelist=[429, 500],
|
||||||
|
allowed_methods={"GET", "HEAD", "OPTIONS"},
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
if browser and browser in FINGERPRINT_PRESETS:
|
||||||
|
preset = FINGERPRINT_PRESETS[browser]
|
||||||
|
if ja3 is None:
|
||||||
|
ja3 = preset.get("ja3")
|
||||||
|
if akamai is None:
|
||||||
|
akamai = preset.get("akamai")
|
||||||
|
if extra_fp is None:
|
||||||
|
extra_fp = preset.get("extra_fp")
|
||||||
|
browser = None
|
||||||
|
|
||||||
|
if browser is None and ja3 is None and akamai is None:
|
||||||
|
browser = config.curl_impersonate.get("browser", "chrome")
|
||||||
|
|
||||||
|
session_config = {}
|
||||||
|
if browser:
|
||||||
|
session_config["impersonate"] = browser
|
||||||
|
|
||||||
|
if ja3:
|
||||||
|
session_config["ja3"] = ja3
|
||||||
|
if akamai:
|
||||||
|
session_config["akamai"] = akamai
|
||||||
|
if extra_fp:
|
||||||
|
session_config["extra_fp"] = extra_fp
|
||||||
|
|
||||||
session_config.update(kwargs)
|
session_config.update(kwargs)
|
||||||
session_obj = Session(**session_config)
|
|
||||||
session_obj.headers.update(config.headers)
|
|
||||||
|
|
||||||
|
session_obj = CurlSession(**session_config)
|
||||||
|
session_obj.headers.update(config.headers)
|
||||||
return session_obj
|
return session_obj
|
||||||
|
|||||||
@@ -180,6 +180,167 @@ class TitleCacher:
|
|||||||
"hit_rate": f"{hit_rate:.1f}%",
|
"hit_rate": f"{hit_rate:.1f}%",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_cached_tmdb(
|
||||||
|
self, title_id: str, kind: str, region: Optional[str] = None, account_hash: Optional[str] = None
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Get cached TMDB data for a title.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title_id: The title identifier
|
||||||
|
kind: "movie" or "tv"
|
||||||
|
region: The region/proxy identifier
|
||||||
|
account_hash: Hash of account credentials
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'detail' and 'external_ids' if cached and valid, None otherwise
|
||||||
|
"""
|
||||||
|
if not config.title_cache_enabled:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cache_key = self._generate_cache_key(title_id, region, account_hash)
|
||||||
|
cache = self.cacher.get(cache_key, version=1)
|
||||||
|
|
||||||
|
if not cache or not cache.data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tmdb_data = getattr(cache.data, "tmdb_data", None)
|
||||||
|
if not tmdb_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tmdb_expiration = tmdb_data.get("expires_at")
|
||||||
|
if not tmdb_expiration or datetime.now() >= tmdb_expiration:
|
||||||
|
self.log.debug(f"TMDB cache expired for {title_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if tmdb_data.get("kind") != kind:
|
||||||
|
self.log.debug(f"TMDB cache kind mismatch for {title_id}: cached {tmdb_data.get('kind')}, requested {kind}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
self.log.debug(f"TMDB cache hit for {title_id}")
|
||||||
|
return {
|
||||||
|
"detail": tmdb_data.get("detail"),
|
||||||
|
"external_ids": tmdb_data.get("external_ids"),
|
||||||
|
"fetched_at": tmdb_data.get("fetched_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def cache_tmdb(
|
||||||
|
self,
|
||||||
|
title_id: str,
|
||||||
|
detail_response: dict,
|
||||||
|
external_ids_response: dict,
|
||||||
|
kind: str,
|
||||||
|
region: Optional[str] = None,
|
||||||
|
account_hash: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Cache TMDB data for a title.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title_id: The title identifier
|
||||||
|
detail_response: Full TMDB detail API response
|
||||||
|
external_ids_response: Full TMDB external_ids API response
|
||||||
|
kind: "movie" or "tv"
|
||||||
|
region: The region/proxy identifier
|
||||||
|
account_hash: Hash of account credentials
|
||||||
|
"""
|
||||||
|
if not config.title_cache_enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
cache_key = self._generate_cache_key(title_id, region, account_hash)
|
||||||
|
cache = self.cacher.get(cache_key, version=1)
|
||||||
|
|
||||||
|
if not cache or not cache.data:
|
||||||
|
self.log.debug(f"Cannot cache TMDB data: no title cache exists for {title_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
tmdb_data = {
|
||||||
|
"detail": detail_response,
|
||||||
|
"external_ids": external_ids_response,
|
||||||
|
"kind": kind,
|
||||||
|
"fetched_at": now,
|
||||||
|
"expires_at": now + timedelta(days=7), # 7-day expiration
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.data.tmdb_data = tmdb_data
|
||||||
|
|
||||||
|
cache.set(cache.data, expiration=cache.expiration)
|
||||||
|
self.log.debug(f"Cached TMDB data for {title_id} (kind={kind})")
|
||||||
|
|
||||||
|
def get_cached_simkl(
|
||||||
|
self, title_id: str, region: Optional[str] = None, account_hash: Optional[str] = None
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Get cached Simkl data for a title.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title_id: The title identifier
|
||||||
|
region: The region/proxy identifier
|
||||||
|
account_hash: Hash of account credentials
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Simkl response dict if cached and valid, None otherwise
|
||||||
|
"""
|
||||||
|
if not config.title_cache_enabled:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cache_key = self._generate_cache_key(title_id, region, account_hash)
|
||||||
|
cache = self.cacher.get(cache_key, version=1)
|
||||||
|
|
||||||
|
if not cache or not cache.data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
simkl_data = getattr(cache.data, "simkl_data", None)
|
||||||
|
if not simkl_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
simkl_expiration = simkl_data.get("expires_at")
|
||||||
|
if not simkl_expiration or datetime.now() >= simkl_expiration:
|
||||||
|
self.log.debug(f"Simkl cache expired for {title_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
self.log.debug(f"Simkl cache hit for {title_id}")
|
||||||
|
return simkl_data.get("response")
|
||||||
|
|
||||||
|
def cache_simkl(
|
||||||
|
self,
|
||||||
|
title_id: str,
|
||||||
|
simkl_response: dict,
|
||||||
|
region: Optional[str] = None,
|
||||||
|
account_hash: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Cache Simkl data for a title.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title_id: The title identifier
|
||||||
|
simkl_response: Full Simkl API response
|
||||||
|
region: The region/proxy identifier
|
||||||
|
account_hash: Hash of account credentials
|
||||||
|
"""
|
||||||
|
if not config.title_cache_enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
cache_key = self._generate_cache_key(title_id, region, account_hash)
|
||||||
|
cache = self.cacher.get(cache_key, version=1)
|
||||||
|
|
||||||
|
if not cache or not cache.data:
|
||||||
|
self.log.debug(f"Cannot cache Simkl data: no title cache exists for {title_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
simkl_data = {
|
||||||
|
"response": simkl_response,
|
||||||
|
"fetched_at": now,
|
||||||
|
"expires_at": now + timedelta(days=7),
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.data.simkl_data = simkl_data
|
||||||
|
|
||||||
|
cache.set(cache.data, expiration=cache.expiration)
|
||||||
|
self.log.debug(f"Cached Simkl data for {title_id}")
|
||||||
|
|
||||||
|
|
||||||
def get_region_from_proxy(proxy_url: Optional[str]) -> Optional[str]:
|
def get_region_from_proxy(proxy_url: Optional[str]) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -95,9 +95,9 @@ class Episode(Title):
|
|||||||
media_info.audio_tracks,
|
media_info.audio_tracks,
|
||||||
key=lambda x: (
|
key=lambda x: (
|
||||||
float(x.bit_rate) if x.bit_rate else 0,
|
float(x.bit_rate) if x.bit_rate else 0,
|
||||||
bool(x.format_additionalfeatures and "JOC" in x.format_additionalfeatures)
|
bool(x.format_additionalfeatures and "JOC" in x.format_additionalfeatures),
|
||||||
),
|
),
|
||||||
reverse=True
|
reverse=True,
|
||||||
)
|
)
|
||||||
primary_audio_track = sorted_audio[0]
|
primary_audio_track = sorted_audio[0]
|
||||||
unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language})
|
unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language})
|
||||||
@@ -173,20 +173,26 @@ class Episode(Title):
|
|||||||
if primary_video_track:
|
if primary_video_track:
|
||||||
codec = primary_video_track.format
|
codec = primary_video_track.format
|
||||||
hdr_format = primary_video_track.hdr_format_commercial
|
hdr_format = primary_video_track.hdr_format_commercial
|
||||||
|
hdr_format_full = primary_video_track.hdr_format or ""
|
||||||
trc = (
|
trc = (
|
||||||
primary_video_track.transfer_characteristics
|
primary_video_track.transfer_characteristics
|
||||||
or primary_video_track.transfer_characteristics_original
|
or primary_video_track.transfer_characteristics_original
|
||||||
|
or ""
|
||||||
)
|
)
|
||||||
frame_rate = float(primary_video_track.frame_rate)
|
frame_rate = float(primary_video_track.frame_rate)
|
||||||
|
|
||||||
|
# Primary HDR format detection
|
||||||
if hdr_format:
|
if hdr_format:
|
||||||
if (primary_video_track.hdr_format or "").startswith("Dolby Vision"):
|
if hdr_format_full.startswith("Dolby Vision"):
|
||||||
name += " DV"
|
name += " DV"
|
||||||
if DYNAMIC_RANGE_MAP.get(hdr_format) and DYNAMIC_RANGE_MAP.get(hdr_format) != "DV":
|
if any(indicator in hdr_format_full for indicator in ["HDR10", "SMPTE ST 2086"]):
|
||||||
name += " HDR"
|
name += " HDR"
|
||||||
else:
|
else:
|
||||||
name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} "
|
name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} "
|
||||||
elif trc and "HLG" in trc:
|
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"
|
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:
|
if frame_rate > 30:
|
||||||
name += " HFR"
|
name += " HFR"
|
||||||
name += f" {VIDEO_CODEC_MAP.get(codec, codec)}"
|
name += f" {VIDEO_CODEC_MAP.get(codec, codec)}"
|
||||||
@@ -237,4 +243,4 @@ class Series(SortedKeyList, ABC):
|
|||||||
return tree
|
return tree
|
||||||
|
|
||||||
|
|
||||||
__all__ = ("Episode", "Series")
|
__all__ = ("Episode", "Series")
|
||||||
|
|||||||
@@ -52,7 +52,17 @@ class Movie(Title):
|
|||||||
|
|
||||||
def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str:
|
def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str:
|
||||||
primary_video_track = next(iter(media_info.video_tracks), None)
|
primary_video_track = next(iter(media_info.video_tracks), None)
|
||||||
primary_audio_track = next(iter(media_info.audio_tracks), None)
|
primary_audio_track = None
|
||||||
|
if media_info.audio_tracks:
|
||||||
|
sorted_audio = sorted(
|
||||||
|
media_info.audio_tracks,
|
||||||
|
key=lambda x: (
|
||||||
|
float(x.bit_rate) if x.bit_rate else 0,
|
||||||
|
bool(x.format_additionalfeatures and "JOC" in x.format_additionalfeatures),
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
primary_audio_track = sorted_audio[0]
|
||||||
unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language})
|
unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language})
|
||||||
|
|
||||||
# Name (Year)
|
# Name (Year)
|
||||||
@@ -114,20 +124,26 @@ class Movie(Title):
|
|||||||
if primary_video_track:
|
if primary_video_track:
|
||||||
codec = primary_video_track.format
|
codec = primary_video_track.format
|
||||||
hdr_format = primary_video_track.hdr_format_commercial
|
hdr_format = primary_video_track.hdr_format_commercial
|
||||||
|
hdr_format_full = primary_video_track.hdr_format or ""
|
||||||
trc = (
|
trc = (
|
||||||
primary_video_track.transfer_characteristics
|
primary_video_track.transfer_characteristics
|
||||||
or primary_video_track.transfer_characteristics_original
|
or primary_video_track.transfer_characteristics_original
|
||||||
|
or ""
|
||||||
)
|
)
|
||||||
frame_rate = float(primary_video_track.frame_rate)
|
frame_rate = float(primary_video_track.frame_rate)
|
||||||
|
|
||||||
|
# Primary HDR format detection
|
||||||
if hdr_format:
|
if hdr_format:
|
||||||
if (primary_video_track.hdr_format or "").startswith("Dolby Vision"):
|
if hdr_format_full.startswith("Dolby Vision"):
|
||||||
name += " DV"
|
name += " DV"
|
||||||
if DYNAMIC_RANGE_MAP.get(hdr_format) and DYNAMIC_RANGE_MAP.get(hdr_format) != "DV":
|
if any(indicator in hdr_format_full for indicator in ["HDR10", "SMPTE ST 2086"]):
|
||||||
name += " HDR"
|
name += " HDR"
|
||||||
else:
|
else:
|
||||||
name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} "
|
name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} "
|
||||||
elif trc and "HLG" in trc:
|
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"
|
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:
|
if frame_rate > 30:
|
||||||
name += " HFR"
|
name += " HFR"
|
||||||
name += f" {VIDEO_CODEC_MAP.get(codec, codec)}"
|
name += f" {VIDEO_CODEC_MAP.get(codec, codec)}"
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ class Audio(Track):
|
|||||||
class Codec(str, Enum):
|
class Codec(str, Enum):
|
||||||
AAC = "AAC" # https://wikipedia.org/wiki/Advanced_Audio_Coding
|
AAC = "AAC" # https://wikipedia.org/wiki/Advanced_Audio_Coding
|
||||||
AC3 = "DD" # https://wikipedia.org/wiki/Dolby_Digital
|
AC3 = "DD" # https://wikipedia.org/wiki/Dolby_Digital
|
||||||
AC4 = "AC-4" # https://wikipedia.org/wiki/Dolby_AC-4
|
|
||||||
EC3 = "DD+" # https://wikipedia.org/wiki/Dolby_Digital_Plus
|
EC3 = "DD+" # https://wikipedia.org/wiki/Dolby_Digital_Plus
|
||||||
|
AC4 = "AC-4" # https://wikipedia.org/wiki/Dolby_AC-4
|
||||||
OPUS = "OPUS" # https://wikipedia.org/wiki/Opus_(audio_format)
|
OPUS = "OPUS" # https://wikipedia.org/wiki/Opus_(audio_format)
|
||||||
OGG = "VORB" # https://wikipedia.org/wiki/Vorbis
|
OGG = "VORB" # https://wikipedia.org/wiki/Vorbis
|
||||||
DTS = "DTS" # https://en.wikipedia.org/wiki/DTS_(company)#DTS_Digital_Surround
|
DTS = "DTS" # https://en.wikipedia.org/wiki/DTS_(company)#DTS_Digital_Surround
|
||||||
@@ -30,10 +30,10 @@ class Audio(Track):
|
|||||||
return Audio.Codec.AAC
|
return Audio.Codec.AAC
|
||||||
if mime == "ac-3":
|
if mime == "ac-3":
|
||||||
return Audio.Codec.AC3
|
return Audio.Codec.AC3
|
||||||
if mime == "ac-4":
|
|
||||||
return Audio.Codec.AC4
|
|
||||||
if mime == "ec-3":
|
if mime == "ec-3":
|
||||||
return Audio.Codec.EC3
|
return Audio.Codec.EC3
|
||||||
|
if mime == "ac-4":
|
||||||
|
return Audio.Codec.AC4
|
||||||
if mime == "opus":
|
if mime == "opus":
|
||||||
return Audio.Codec.OPUS
|
return Audio.Codec.OPUS
|
||||||
if mime == "dtsc":
|
if mime == "dtsc":
|
||||||
|
|||||||
@@ -239,25 +239,29 @@ class Subtitle(Track):
|
|||||||
|
|
||||||
# Sanitize WebVTT timestamps before parsing
|
# Sanitize WebVTT timestamps before parsing
|
||||||
text = Subtitle.sanitize_webvtt_timestamps(text)
|
text = Subtitle.sanitize_webvtt_timestamps(text)
|
||||||
|
preserve_formatting = config.subtitle.get("preserve_formatting", True)
|
||||||
|
|
||||||
try:
|
if preserve_formatting:
|
||||||
caption_set = pycaption.WebVTTReader().read(text)
|
self.path.write_text(text, encoding="utf8")
|
||||||
Subtitle.merge_same_cues(caption_set)
|
else:
|
||||||
Subtitle.filter_unwanted_cues(caption_set)
|
|
||||||
subtitle_text = pycaption.WebVTTWriter().write(caption_set)
|
|
||||||
self.path.write_text(subtitle_text, encoding="utf8")
|
|
||||||
except pycaption.exceptions.CaptionReadSyntaxError:
|
|
||||||
# If first attempt fails, try more aggressive sanitization
|
|
||||||
text = Subtitle.sanitize_webvtt(text)
|
|
||||||
try:
|
try:
|
||||||
caption_set = pycaption.WebVTTReader().read(text)
|
caption_set = pycaption.WebVTTReader().read(text)
|
||||||
Subtitle.merge_same_cues(caption_set)
|
Subtitle.merge_same_cues(caption_set)
|
||||||
Subtitle.filter_unwanted_cues(caption_set)
|
Subtitle.filter_unwanted_cues(caption_set)
|
||||||
subtitle_text = pycaption.WebVTTWriter().write(caption_set)
|
subtitle_text = pycaption.WebVTTWriter().write(caption_set)
|
||||||
self.path.write_text(subtitle_text, encoding="utf8")
|
self.path.write_text(subtitle_text, encoding="utf8")
|
||||||
except Exception:
|
except pycaption.exceptions.CaptionReadSyntaxError:
|
||||||
# Keep the sanitized version even if parsing failed
|
# If first attempt fails, try more aggressive sanitization
|
||||||
self.path.write_text(text, encoding="utf8")
|
text = Subtitle.sanitize_webvtt(text)
|
||||||
|
try:
|
||||||
|
caption_set = pycaption.WebVTTReader().read(text)
|
||||||
|
Subtitle.merge_same_cues(caption_set)
|
||||||
|
Subtitle.filter_unwanted_cues(caption_set)
|
||||||
|
subtitle_text = pycaption.WebVTTWriter().write(caption_set)
|
||||||
|
self.path.write_text(subtitle_text, encoding="utf8")
|
||||||
|
except Exception:
|
||||||
|
# Keep the sanitized version even if parsing failed
|
||||||
|
self.path.write_text(text, encoding="utf8")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def sanitize_webvtt_timestamps(text: str) -> str:
|
def sanitize_webvtt_timestamps(text: str) -> str:
|
||||||
@@ -979,20 +983,33 @@ class Subtitle(Track):
|
|||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
sub = Subtitles(self.path)
|
if config.subtitle.get("convert_before_strip", True) and self.codec != Subtitle.Codec.SubRip:
|
||||||
|
self.path = self.convert(Subtitle.Codec.SubRip)
|
||||||
|
self.codec = Subtitle.Codec.SubRip
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sub.filter(rm_fonts=True, rm_ast=True, rm_music=True, rm_effects=True, rm_names=True, rm_author=True)
|
sub = Subtitles(self.path)
|
||||||
except ValueError as e:
|
try:
|
||||||
if "too many values to unpack" in str(e):
|
|
||||||
# Retry without name removal if the error is due to multiple colons in time references
|
|
||||||
# This can happen with lines like "at 10:00 and 2:00"
|
|
||||||
sub = Subtitles(self.path)
|
|
||||||
sub.filter(
|
sub.filter(
|
||||||
rm_fonts=True, rm_ast=True, rm_music=True, rm_effects=True, rm_names=False, rm_author=True
|
rm_fonts=True, rm_ast=True, rm_music=True, rm_effects=True, rm_names=True, rm_author=True
|
||||||
)
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
if "too many values to unpack" in str(e):
|
||||||
|
# Retry without name removal if the error is due to multiple colons in time references
|
||||||
|
# This can happen with lines like "at 10:00 and 2:00"
|
||||||
|
sub = Subtitles(self.path)
|
||||||
|
sub.filter(
|
||||||
|
rm_fonts=True, rm_ast=True, rm_music=True, rm_effects=True, rm_names=False, rm_author=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
sub.save()
|
||||||
|
except (IOError, OSError) as e:
|
||||||
|
if "is not valid subtitle file" in str(e):
|
||||||
|
self.log.warning(f"Failed to strip SDH from {self.path.name}: {e}")
|
||||||
|
self.log.warning("Continuing without SDH stripping for this subtitle")
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
sub.save()
|
|
||||||
|
|
||||||
def reverse_rtl(self) -> None:
|
def reverse_rtl(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -1027,4 +1044,4 @@ class Subtitle(Track):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ("Subtitle",)
|
__all__ = ("Subtitle",)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from typing import Any, Callable, Iterable, Optional, Union
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from zlib import crc32
|
from zlib import crc32
|
||||||
|
|
||||||
|
from curl_cffi.requests import Session as CurlSession
|
||||||
from langcodes import Language
|
from langcodes import Language
|
||||||
from pyplayready.cdm import Cdm as PlayReadyCdm
|
from pyplayready.cdm import Cdm as PlayReadyCdm
|
||||||
from pywidevine.cdm import Cdm as WidevineCdm
|
from pywidevine.cdm import Cdm as WidevineCdm
|
||||||
@@ -24,7 +25,7 @@ from envied.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY
|
|||||||
from envied.core.downloaders import aria2c, curl_impersonate, n_m3u8dl_re, requests
|
from envied.core.downloaders import aria2c, curl_impersonate, n_m3u8dl_re, requests
|
||||||
from envied.core.drm import DRM_T, PlayReady, Widevine
|
from envied.core.drm import DRM_T, PlayReady, Widevine
|
||||||
from envied.core.events import events
|
from envied.core.events import events
|
||||||
from envied.core.utilities import get_boxes, try_ensure_utf8
|
from envied.core.utilities import get_boxes, get_extension, try_ensure_utf8
|
||||||
from envied.core.utils.subprocess import ffprobe
|
from envied.core.utils.subprocess import ffprobe
|
||||||
|
|
||||||
|
|
||||||
@@ -46,6 +47,8 @@ class Track:
|
|||||||
drm: Optional[Iterable[DRM_T]] = None,
|
drm: Optional[Iterable[DRM_T]] = None,
|
||||||
edition: Optional[str] = None,
|
edition: Optional[str] = None,
|
||||||
downloader: Optional[Callable] = None,
|
downloader: Optional[Callable] = None,
|
||||||
|
downloader_args: Optional[dict] = None,
|
||||||
|
from_file: Optional[Path] = None,
|
||||||
data: Optional[Union[dict, defaultdict]] = None,
|
data: Optional[Union[dict, defaultdict]] = None,
|
||||||
id_: Optional[str] = None,
|
id_: Optional[str] = None,
|
||||||
extra: Optional[Any] = None,
|
extra: Optional[Any] = None,
|
||||||
@@ -68,6 +71,10 @@ class Track:
|
|||||||
raise TypeError(f"Expected edition to be a {str}, not {type(edition)}")
|
raise TypeError(f"Expected edition to be a {str}, not {type(edition)}")
|
||||||
if not isinstance(downloader, (Callable, type(None))):
|
if not isinstance(downloader, (Callable, type(None))):
|
||||||
raise TypeError(f"Expected downloader to be a {Callable}, not {type(downloader)}")
|
raise TypeError(f"Expected downloader to be a {Callable}, not {type(downloader)}")
|
||||||
|
if not isinstance(downloader_args, (dict, type(None))):
|
||||||
|
raise TypeError(f"Expected downloader_args to be a {dict}, not {type(downloader_args)}")
|
||||||
|
if not isinstance(from_file, (Path, type(None))):
|
||||||
|
raise TypeError(f"Expected from_file to be a {Path}, not {type(from_file)}")
|
||||||
if not isinstance(data, (dict, defaultdict, type(None))):
|
if not isinstance(data, (dict, defaultdict, type(None))):
|
||||||
raise TypeError(f"Expected data to be a {dict} or {defaultdict}, not {type(data)}")
|
raise TypeError(f"Expected data to be a {dict} or {defaultdict}, not {type(data)}")
|
||||||
|
|
||||||
@@ -99,6 +106,8 @@ class Track:
|
|||||||
self.drm = drm
|
self.drm = drm
|
||||||
self.edition: str = edition
|
self.edition: str = edition
|
||||||
self.downloader = downloader
|
self.downloader = downloader
|
||||||
|
self.downloader_args = downloader_args
|
||||||
|
self.from_file = from_file
|
||||||
self._data: defaultdict[Any, Any] = defaultdict(dict)
|
self._data: defaultdict[Any, Any] = defaultdict(dict)
|
||||||
self.data = data or {}
|
self.data = data or {}
|
||||||
self.extra: Any = extra or {} # allow anything for extra, but default to a dict
|
self.extra: Any = extra or {} # allow anything for extra, but default to a dict
|
||||||
@@ -202,7 +211,21 @@ class Track:
|
|||||||
save_path = config.directories.temp / f"{track_type}_{self.id}.mp4"
|
save_path = config.directories.temp / f"{track_type}_{self.id}.mp4"
|
||||||
if track_type == "Subtitle":
|
if track_type == "Subtitle":
|
||||||
save_path = save_path.with_suffix(f".{self.codec.extension}")
|
save_path = save_path.with_suffix(f".{self.codec.extension}")
|
||||||
if self.downloader.__name__ == "n_m3u8dl_re":
|
# n_m3u8dl_re doesn't support directly downloading subtitles from URLs
|
||||||
|
# or when the subtitle has a direct file extension
|
||||||
|
if self.downloader.__name__ == "n_m3u8dl_re" and (
|
||||||
|
self.descriptor == self.Descriptor.URL
|
||||||
|
or get_extension(self.url) in {
|
||||||
|
".srt",
|
||||||
|
".vtt",
|
||||||
|
".ttml",
|
||||||
|
".ssa",
|
||||||
|
".ass",
|
||||||
|
".stpp",
|
||||||
|
".wvtt",
|
||||||
|
".xml",
|
||||||
|
}
|
||||||
|
):
|
||||||
self.downloader = requests
|
self.downloader = requests
|
||||||
|
|
||||||
if self.descriptor != self.Descriptor.URL:
|
if self.descriptor != self.Descriptor.URL:
|
||||||
@@ -420,7 +443,7 @@ class Track:
|
|||||||
for drm in self.drm:
|
for drm in self.drm:
|
||||||
if isinstance(drm, PlayReady):
|
if isinstance(drm, PlayReady):
|
||||||
return drm
|
return drm
|
||||||
elif hasattr(cdm, 'is_playready'):
|
elif hasattr(cdm, "is_playready"):
|
||||||
if cdm.is_playready:
|
if cdm.is_playready:
|
||||||
for drm in self.drm:
|
for drm in self.drm:
|
||||||
if isinstance(drm, PlayReady):
|
if isinstance(drm, PlayReady):
|
||||||
@@ -473,6 +496,83 @@ class Track:
|
|||||||
if tenc.key_ID.int != 0:
|
if tenc.key_ID.int != 0:
|
||||||
return tenc.key_ID
|
return tenc.key_ID
|
||||||
|
|
||||||
|
def load_drm_if_needed(self, service=None) -> bool:
|
||||||
|
"""
|
||||||
|
Load DRM information for this track if it was deferred during parsing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service: Service instance that can fetch track-specific DRM info
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if DRM was loaded or already present, False if failed
|
||||||
|
"""
|
||||||
|
if not getattr(self, "needs_drm_loading", False):
|
||||||
|
return bool(self.drm)
|
||||||
|
|
||||||
|
if self.drm:
|
||||||
|
self.needs_drm_loading = False
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not service or not hasattr(service, "get_track_drm"):
|
||||||
|
return self.load_drm_from_playlist()
|
||||||
|
|
||||||
|
try:
|
||||||
|
track_drm = service.get_track_drm(self)
|
||||||
|
if track_drm:
|
||||||
|
self.drm = track_drm if isinstance(track_drm, list) else [track_drm]
|
||||||
|
self.needs_drm_loading = False
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Failed to load DRM from service for track {self.id}: {e}")
|
||||||
|
|
||||||
|
return self.load_drm_from_playlist()
|
||||||
|
|
||||||
|
def load_drm_from_playlist(self) -> bool:
|
||||||
|
"""
|
||||||
|
Fallback method to load DRM by fetching this track's individual playlist.
|
||||||
|
"""
|
||||||
|
if self.drm:
|
||||||
|
self.needs_drm_loading = False
|
||||||
|
return True
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
session = getattr(self, "session", None) or Session()
|
||||||
|
|
||||||
|
response = session.get(self.url)
|
||||||
|
playlist = m3u8.loads(response.text, self.url)
|
||||||
|
|
||||||
|
drm_list = []
|
||||||
|
|
||||||
|
for key in playlist.keys or []:
|
||||||
|
if not key or not key.keyformat:
|
||||||
|
continue
|
||||||
|
|
||||||
|
fmt = key.keyformat.lower()
|
||||||
|
if fmt == WidevineCdm.urn:
|
||||||
|
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:
|
||||||
|
pssh_b64 = key.uri.split(",")[-1]
|
||||||
|
drm = PlayReady(pssh=PR_PSSH(pssh_b64), pssh_b64=pssh_b64)
|
||||||
|
drm_list.append(drm)
|
||||||
|
|
||||||
|
if drm_list:
|
||||||
|
self.drm = drm_list
|
||||||
|
self.needs_drm_loading = False
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Failed to load DRM from playlist for track {self.id}: {e}")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
def get_init_segment(
|
def get_init_segment(
|
||||||
self,
|
self,
|
||||||
maximum_size: int = 20000,
|
maximum_size: int = 20000,
|
||||||
@@ -508,8 +608,8 @@ class Track:
|
|||||||
raise TypeError(f"Expected url to be a {str}, not {type(url)}")
|
raise TypeError(f"Expected url to be a {str}, not {type(url)}")
|
||||||
if not isinstance(byte_range, (str, type(None))):
|
if not isinstance(byte_range, (str, type(None))):
|
||||||
raise TypeError(f"Expected byte_range to be a {str}, not {type(byte_range)}")
|
raise TypeError(f"Expected byte_range to be a {str}, not {type(byte_range)}")
|
||||||
if not isinstance(session, (Session, type(None))):
|
if not isinstance(session, (Session, CurlSession, type(None))):
|
||||||
raise TypeError(f"Expected session to be a {Session}, not {type(session)}")
|
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {type(session)}")
|
||||||
|
|
||||||
if not url:
|
if not url:
|
||||||
if self.descriptor != self.Descriptor.URL:
|
if self.descriptor != self.Descriptor.URL:
|
||||||
@@ -567,15 +667,32 @@ class Track:
|
|||||||
output_path = original_path.with_stem(f"{original_path.stem}_repack")
|
output_path = original_path.with_stem(f"{original_path.stem}_repack")
|
||||||
|
|
||||||
def _ffmpeg(extra_args: list[str] = None):
|
def _ffmpeg(extra_args: list[str] = None):
|
||||||
subprocess.run(
|
args = [
|
||||||
|
binaries.FFMPEG,
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-i",
|
||||||
|
original_path,
|
||||||
|
*(extra_args or []),
|
||||||
|
]
|
||||||
|
|
||||||
|
if hasattr(self, "data") and self.data.get("audio_language"):
|
||||||
|
audio_lang = self.data["audio_language"]
|
||||||
|
audio_name = self.data.get("audio_language_name", audio_lang)
|
||||||
|
args.extend(
|
||||||
|
[
|
||||||
|
"-metadata:s:a:0",
|
||||||
|
f"language={audio_lang}",
|
||||||
|
"-metadata:s:a:0",
|
||||||
|
f"title={audio_name}",
|
||||||
|
"-metadata:s:a:0",
|
||||||
|
f"handler_name={audio_name}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
args.extend(
|
||||||
[
|
[
|
||||||
binaries.FFMPEG,
|
|
||||||
"-hide_banner",
|
|
||||||
"-loglevel",
|
|
||||||
"error",
|
|
||||||
"-i",
|
|
||||||
original_path,
|
|
||||||
*(extra_args or []),
|
|
||||||
# Following are very important!
|
# Following are very important!
|
||||||
"-map_metadata",
|
"-map_metadata",
|
||||||
"-1", # don't transfer metadata to output file
|
"-1", # don't transfer metadata to output file
|
||||||
@@ -584,7 +701,11 @@ class Track:
|
|||||||
"-codec",
|
"-codec",
|
||||||
"copy",
|
"copy",
|
||||||
str(output_path),
|
str(output_path),
|
||||||
],
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
args,
|
||||||
check=True,
|
check=True,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ class Tracks:
|
|||||||
log = logging.getLogger("Tracks")
|
log = logging.getLogger("Tracks")
|
||||||
|
|
||||||
if duplicates:
|
if duplicates:
|
||||||
log.warning(f" - Found and skipped {duplicates} duplicate tracks...")
|
log.debug(f" - Found and skipped {duplicates} duplicate tracks...")
|
||||||
|
|
||||||
def sort_videos(self, by_language: Optional[Sequence[Union[str, Language]]] = None) -> None:
|
def sort_videos(self, by_language: Optional[Sequence[Union[str, Language]]] = None) -> None:
|
||||||
"""Sort video tracks by bitrate, and optionally language."""
|
"""Sort video tracks by bitrate, and optionally language."""
|
||||||
@@ -202,17 +202,16 @@ class Tracks:
|
|||||||
"""Sort audio tracks by bitrate, descriptive, and optionally language."""
|
"""Sort audio tracks by bitrate, descriptive, and optionally language."""
|
||||||
if not self.audio:
|
if not self.audio:
|
||||||
return
|
return
|
||||||
# bitrate
|
|
||||||
self.audio.sort(key=lambda x: float(x.bitrate or 0.0), reverse=True)
|
|
||||||
# descriptive
|
# descriptive
|
||||||
self.audio.sort(key=lambda x: str(x.language) if x.descriptive else "")
|
self.audio.sort(key=lambda x: x.descriptive)
|
||||||
|
# bitrate (within each descriptive group)
|
||||||
|
self.audio.sort(key=lambda x: float(x.bitrate or 0.0), reverse=True)
|
||||||
# language
|
# language
|
||||||
for language in reversed(by_language or []):
|
for language in reversed(by_language or []):
|
||||||
if str(language) in ("all", "best"):
|
if str(language) in ("all", "best"):
|
||||||
language = next((x.language for x in self.audio if x.is_original_lang), "")
|
language = next((x.language for x in self.audio if x.is_original_lang), "")
|
||||||
if not language:
|
if not language:
|
||||||
continue
|
continue
|
||||||
self.audio.sort(key=lambda x: str(x.language))
|
|
||||||
self.audio.sort(key=lambda x: not is_close_match(language, [x.language]))
|
self.audio.sort(key=lambda x: not is_close_match(language, [x.language]))
|
||||||
|
|
||||||
def sort_subtitles(self, by_language: Optional[Sequence[Union[str, Language]]] = None) -> None:
|
def sort_subtitles(self, by_language: Optional[Sequence[Union[str, Language]]] = None) -> None:
|
||||||
@@ -308,7 +307,14 @@ class Tracks:
|
|||||||
)
|
)
|
||||||
return selected
|
return selected
|
||||||
|
|
||||||
def mux(self, title: str, delete: bool = True, progress: Optional[partial] = None) -> tuple[Path, int, list[str]]:
|
def mux(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
delete: bool = True,
|
||||||
|
progress: Optional[partial] = None,
|
||||||
|
audio_expected: bool = True,
|
||||||
|
title_language: Optional[Language] = None,
|
||||||
|
) -> tuple[Path, int, list[str]]:
|
||||||
"""
|
"""
|
||||||
Multiplex all the Tracks into a Matroska Container file.
|
Multiplex all the Tracks into a Matroska Container file.
|
||||||
|
|
||||||
@@ -318,7 +324,28 @@ class Tracks:
|
|||||||
delete: Delete all track files after multiplexing.
|
delete: Delete all track files after multiplexing.
|
||||||
progress: Update a rich progress bar via `completed=...`. This must be the
|
progress: Update a rich progress bar via `completed=...`. This must be the
|
||||||
progress object's update() func, pre-set with task id via functools.partial.
|
progress object's update() func, pre-set with task id via functools.partial.
|
||||||
|
audio_expected: Whether audio is expected in the output. Used to determine
|
||||||
|
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.
|
||||||
"""
|
"""
|
||||||
|
if self.videos and not self.audio and audio_expected:
|
||||||
|
video_track = None
|
||||||
|
if title_language:
|
||||||
|
video_track = next((v for v in self.videos if v.language == title_language), None)
|
||||||
|
if not video_track:
|
||||||
|
video_track = next((v for v in self.videos if v.is_original_lang), None)
|
||||||
|
|
||||||
|
video_track = video_track or self.videos[0]
|
||||||
|
if video_track.language.is_valid():
|
||||||
|
lang_code = str(video_track.language)
|
||||||
|
lang_name = video_track.language.display_name()
|
||||||
|
|
||||||
|
for video in self.videos:
|
||||||
|
video.needs_repack = True
|
||||||
|
video.data["audio_language"] = lang_code
|
||||||
|
video.data["audio_language_name"] = lang_name
|
||||||
|
|
||||||
if not binaries.MKVToolNix:
|
if not binaries.MKVToolNix:
|
||||||
raise RuntimeError("MKVToolNix (mkvmerge) is required for muxing but was not found")
|
raise RuntimeError("MKVToolNix (mkvmerge) is required for muxing but was not found")
|
||||||
|
|
||||||
@@ -335,12 +362,20 @@ class Tracks:
|
|||||||
raise ValueError("Video Track must be downloaded before muxing...")
|
raise ValueError("Video Track must be downloaded before muxing...")
|
||||||
events.emit(events.Types.TRACK_MULTIPLEX, track=vt)
|
events.emit(events.Types.TRACK_MULTIPLEX, track=vt)
|
||||||
|
|
||||||
|
is_default = False
|
||||||
|
if title_language:
|
||||||
|
is_default = vt.language == title_language
|
||||||
|
if not any(v.language == title_language for v in self.videos):
|
||||||
|
is_default = vt.is_original_lang or i == 0
|
||||||
|
else:
|
||||||
|
is_default = i == 0
|
||||||
|
|
||||||
# Prepare base arguments
|
# Prepare base arguments
|
||||||
video_args = [
|
video_args = [
|
||||||
"--language",
|
"--language",
|
||||||
f"0:{vt.language}",
|
f"0:{vt.language}",
|
||||||
"--default-track",
|
"--default-track",
|
||||||
f"0:{i == 0}",
|
f"0:{is_default}",
|
||||||
"--original-flag",
|
"--original-flag",
|
||||||
f"0:{vt.is_original_lang}",
|
f"0:{vt.is_original_lang}",
|
||||||
"--compression",
|
"--compression",
|
||||||
@@ -366,6 +401,18 @@ class Tracks:
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if hasattr(vt, "data") and vt.data.get("audio_language"):
|
||||||
|
audio_lang = vt.data["audio_language"]
|
||||||
|
audio_name = vt.data.get("audio_language_name", audio_lang)
|
||||||
|
video_args.extend(
|
||||||
|
[
|
||||||
|
"--language",
|
||||||
|
f"1:{audio_lang}",
|
||||||
|
"--track-name",
|
||||||
|
f"1:{audio_name}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
cl.extend(video_args + ["(", str(vt.path), ")"])
|
cl.extend(video_args + ["(", str(vt.path), ")"])
|
||||||
|
|
||||||
for i, at in enumerate(self.audio):
|
for i, at in enumerate(self.audio):
|
||||||
|
|||||||
@@ -23,26 +23,26 @@ class UpdateChecker:
|
|||||||
DEFAULT_CHECK_INTERVAL: Default time between checks in seconds (24 hours)
|
DEFAULT_CHECK_INTERVAL: Default time between checks in seconds (24 hours)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
REPO_URL = "https://api.github.com/repos/envied-dl/envied/releases/latest"
|
REPO_URL = "https://api.github.com/repos/unshackle-dl/unshackle/releases/latest"
|
||||||
TIMEOUT = 5
|
TIMEOUT = 5
|
||||||
DEFAULT_CHECK_INTERVAL = 24 * 60 * 60
|
DEFAULT_CHECK_INTERVAL = 24 * 60 * 60
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _get_cache_file(cls) -> Path:
|
def get_cache_file(cls) -> Path:
|
||||||
"""Get the path to the update check cache file."""
|
"""Get the path to the update check cache file."""
|
||||||
from envied.core.config import config
|
from envied.core.config import config
|
||||||
|
|
||||||
return config.directories.cache / "update_check.json"
|
return config.directories.cache / "update_check.json"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _load_cache_data(cls) -> dict:
|
def load_cache_data(cls) -> dict:
|
||||||
"""
|
"""
|
||||||
Load cache data from file.
|
Load cache data from file.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Cache data dictionary or empty dict if loading fails
|
Cache data dictionary or empty dict if loading fails
|
||||||
"""
|
"""
|
||||||
cache_file = cls._get_cache_file()
|
cache_file = cls.get_cache_file()
|
||||||
|
|
||||||
if not cache_file.exists():
|
if not cache_file.exists():
|
||||||
return {}
|
return {}
|
||||||
@@ -54,7 +54,7 @@ class UpdateChecker:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_version(version_string: str) -> str:
|
def parse_version(version_string: str) -> str:
|
||||||
"""
|
"""
|
||||||
Parse and normalize version string by removing 'v' prefix.
|
Parse and normalize version string by removing 'v' prefix.
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ class UpdateChecker:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
latest_version = cls._parse_version(data.get("tag_name", ""))
|
latest_version = cls.parse_version(data.get("tag_name", ""))
|
||||||
|
|
||||||
return latest_version if cls._is_valid_version(latest_version) else None
|
return latest_version if cls._is_valid_version(latest_version) else None
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ class UpdateChecker:
|
|||||||
Returns:
|
Returns:
|
||||||
True if we should check for updates, False otherwise
|
True if we should check for updates, False otherwise
|
||||||
"""
|
"""
|
||||||
cache_data = cls._load_cache_data()
|
cache_data = cls.load_cache_data()
|
||||||
|
|
||||||
if not cache_data:
|
if not cache_data:
|
||||||
return True
|
return True
|
||||||
@@ -144,7 +144,7 @@ class UpdateChecker:
|
|||||||
latest_version: The latest version found, if any
|
latest_version: The latest version found, if any
|
||||||
current_version: The current version being used
|
current_version: The current version being used
|
||||||
"""
|
"""
|
||||||
cache_file = cls._get_cache_file()
|
cache_file = cls.get_cache_file()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -231,7 +231,7 @@ class UpdateChecker:
|
|||||||
Returns:
|
Returns:
|
||||||
The latest version string if an update is available from cache, None otherwise
|
The latest version string if an update is available from cache, None otherwise
|
||||||
"""
|
"""
|
||||||
cache_data = cls._load_cache_data()
|
cache_data = cls.load_cache_data()
|
||||||
|
|
||||||
if not cache_data:
|
if not cache_data:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
import ast
|
import ast
|
||||||
import contextlib
|
import contextlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import traceback
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
from typing import Optional, Sequence, Union
|
from typing import Any, Optional, Sequence, Union
|
||||||
from urllib.parse import ParseResult, urlparse
|
from urllib.parse import ParseResult, urlparse
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import chardet
|
import chardet
|
||||||
import requests
|
import requests
|
||||||
from construct import ValidationError
|
from construct import ValidationError
|
||||||
|
from fontTools import ttLib
|
||||||
from langcodes import Language, closest_match
|
from langcodes import Language, closest_match
|
||||||
from pymp4.parser import Box
|
from pymp4.parser import Box
|
||||||
from unidecode import unidecode
|
from unidecode import unidecode
|
||||||
@@ -26,6 +30,30 @@ from envied.core.cacher import Cacher
|
|||||||
from envied.core.config import config
|
from envied.core.config import config
|
||||||
from envied.core.constants import LANGUAGE_EXACT_DISTANCE, LANGUAGE_MAX_DISTANCE
|
from envied.core.constants import LANGUAGE_EXACT_DISTANCE, LANGUAGE_MAX_DISTANCE
|
||||||
|
|
||||||
|
"""
|
||||||
|
Utility functions for the unshackle media archival tool.
|
||||||
|
|
||||||
|
This module provides various utility functions including:
|
||||||
|
- Font discovery and fallback system for subtitle rendering
|
||||||
|
- Cross-platform system font scanning with Windows → Linux font family mapping
|
||||||
|
- Log file management and rotation
|
||||||
|
- IP geolocation with caching and provider rotation
|
||||||
|
- Language matching utilities
|
||||||
|
- MP4/ISOBMFF box parsing
|
||||||
|
- File sanitization and path handling
|
||||||
|
- Structured JSON debug logging
|
||||||
|
|
||||||
|
Font System:
|
||||||
|
The font subsystem enables cross-platform font discovery for ASS/SSA subtitles.
|
||||||
|
On Linux, it scans standard font directories and maps Windows font names (Arial,
|
||||||
|
Times New Roman) to their Linux equivalents (Liberation Sans, Liberation Serif).
|
||||||
|
|
||||||
|
Main Font Functions:
|
||||||
|
- get_system_fonts(): Discover installed fonts across platforms
|
||||||
|
- find_font_with_fallbacks(): Match fonts with intelligent fallback strategies
|
||||||
|
- suggest_font_packages(): Recommend packages to install for missing fonts
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def rotate_log_file(log_path: Path, keep: int = 20) -> Path:
|
def rotate_log_file(log_path: Path, keep: int = 20) -> Path:
|
||||||
"""
|
"""
|
||||||
@@ -113,6 +141,7 @@ def is_close_match(language: Union[str, Language], languages: Sequence[Union[str
|
|||||||
return False
|
return False
|
||||||
return closest_match(language, list(map(str, languages)))[1] <= LANGUAGE_MAX_DISTANCE
|
return closest_match(language, list(map(str, languages)))[1] <= LANGUAGE_MAX_DISTANCE
|
||||||
|
|
||||||
|
|
||||||
def is_exact_match(language: Union[str, Language], languages: Sequence[Union[str, Language, None]]) -> bool:
|
def is_exact_match(language: Union[str, Language], languages: Sequence[Union[str, Language, None]]) -> bool:
|
||||||
"""Check if a language is an exact match to any of the provided languages."""
|
"""Check if a language is an exact match to any of the provided languages."""
|
||||||
languages = [x for x in languages if x]
|
languages = [x for x in languages if x]
|
||||||
@@ -121,7 +150,7 @@ def is_exact_match(language: Union[str, Language], languages: Sequence[Union[str
|
|||||||
return closest_match(language, list(map(str, languages)))[1] <= LANGUAGE_EXACT_DISTANCE
|
return closest_match(language, list(map(str, languages)))[1] <= LANGUAGE_EXACT_DISTANCE
|
||||||
|
|
||||||
|
|
||||||
def get_boxes(data: bytes, box_type: bytes, as_bytes: bool = False) -> Box:
|
def get_boxes(data: bytes, box_type: bytes, as_bytes: bool = False) -> Box: # type: ignore
|
||||||
"""
|
"""
|
||||||
Scan a byte array for a wanted MP4/ISOBMFF box, then parse and yield each find.
|
Scan a byte array for a wanted MP4/ISOBMFF box, then parse and yield each find.
|
||||||
|
|
||||||
@@ -424,21 +453,263 @@ def get_extension(value: Union[str, Path, ParseResult]) -> Optional[str]:
|
|||||||
return ext
|
return ext
|
||||||
|
|
||||||
|
|
||||||
def get_system_fonts() -> dict[str, Path]:
|
def extract_font_family(font_path: Path) -> Optional[str]:
|
||||||
if sys.platform == "win32":
|
"""
|
||||||
import winreg
|
Extract font family name from TTF/OTF file using fontTools.
|
||||||
|
|
||||||
with winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) as reg:
|
Args:
|
||||||
key = winreg.OpenKey(reg, r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts", 0, winreg.KEY_READ)
|
font_path: Path to the font file
|
||||||
total_fonts = winreg.QueryInfoKey(key)[1]
|
|
||||||
return {
|
Returns:
|
||||||
name.replace(" (TrueType)", ""): Path(r"C:\Windows\Fonts", filename)
|
Font family name if successfully extracted, None otherwise
|
||||||
for n in range(0, total_fonts)
|
"""
|
||||||
for name, filename, _ in [winreg.EnumValue(key, n)]
|
# Suppress verbose fontTools logging during font table parsing
|
||||||
}
|
import io
|
||||||
else:
|
|
||||||
# TODO: Get System Fonts for Linux and mac OS
|
logging.getLogger("fontTools").setLevel(logging.ERROR)
|
||||||
return {}
|
logging.getLogger("fontTools.ttLib").setLevel(logging.ERROR)
|
||||||
|
logging.getLogger("fontTools.ttLib.tables").setLevel(logging.ERROR)
|
||||||
|
logging.getLogger("fontTools.ttLib.tables._n_a_m_e").setLevel(logging.ERROR)
|
||||||
|
stderr_backup = sys.stderr
|
||||||
|
sys.stderr = io.StringIO()
|
||||||
|
|
||||||
|
try:
|
||||||
|
font = ttLib.TTFont(font_path, lazy=True)
|
||||||
|
name_table = font["name"]
|
||||||
|
|
||||||
|
# Try to get family name (nameID 1) for Windows platform (platformID 3)
|
||||||
|
# This matches the naming convention used in Windows registry
|
||||||
|
for record in name_table.names:
|
||||||
|
if record.nameID == 1 and record.platformID == 3:
|
||||||
|
return record.toUnicode()
|
||||||
|
|
||||||
|
# Fallback to other platforms if Windows name not found
|
||||||
|
for record in name_table.names:
|
||||||
|
if record.nameID == 1:
|
||||||
|
return record.toUnicode()
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
sys.stderr = stderr_backup
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_windows_fonts() -> dict[str, Path]:
|
||||||
|
"""
|
||||||
|
Get fonts from Windows registry.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping font family names to their file paths
|
||||||
|
"""
|
||||||
|
import winreg
|
||||||
|
|
||||||
|
with winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) as reg:
|
||||||
|
key = winreg.OpenKey(reg, r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts", 0, winreg.KEY_READ)
|
||||||
|
total_fonts = winreg.QueryInfoKey(key)[1]
|
||||||
|
return {
|
||||||
|
name.replace(" (TrueType)", ""): Path(r"C:\Windows\Fonts", filename)
|
||||||
|
for n in range(0, total_fonts)
|
||||||
|
for name, filename, _ in [winreg.EnumValue(key, n)]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scan_font_directory(font_dir: Path, fonts: dict[str, Path], log: logging.Logger) -> None:
|
||||||
|
"""
|
||||||
|
Scan a single directory for fonts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
font_dir: Directory to scan
|
||||||
|
fonts: Dictionary to populate with found fonts
|
||||||
|
log: Logger instance for error reporting
|
||||||
|
"""
|
||||||
|
font_files = list(font_dir.rglob("*.ttf")) + list(font_dir.rglob("*.otf"))
|
||||||
|
|
||||||
|
for font_file in font_files:
|
||||||
|
try:
|
||||||
|
if family_name := extract_font_family(font_file):
|
||||||
|
if family_name not in fonts:
|
||||||
|
fonts[family_name] = font_file
|
||||||
|
except Exception as e:
|
||||||
|
log.debug(f"Failed to process {font_file}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_unix_fonts() -> dict[str, Path]:
|
||||||
|
"""
|
||||||
|
Get fonts from Linux/macOS standard directories.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping font family names to their file paths
|
||||||
|
"""
|
||||||
|
log = logging.getLogger("get_system_fonts")
|
||||||
|
fonts = {}
|
||||||
|
|
||||||
|
font_dirs = [
|
||||||
|
Path("/usr/share/fonts"),
|
||||||
|
Path("/usr/local/share/fonts"),
|
||||||
|
Path.home() / ".fonts",
|
||||||
|
Path.home() / ".local/share/fonts",
|
||||||
|
]
|
||||||
|
|
||||||
|
for font_dir in font_dirs:
|
||||||
|
if not font_dir.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
scan_font_directory(font_dir, fonts, log)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Failed to scan {font_dir}: {e}")
|
||||||
|
return fonts
|
||||||
|
|
||||||
|
|
||||||
|
def get_system_fonts() -> dict[str, Path]:
|
||||||
|
"""
|
||||||
|
Get system fonts as a mapping of font family names to font file paths.
|
||||||
|
|
||||||
|
On Windows: Uses registry to get font display names
|
||||||
|
On Linux/macOS: Scans standard font directories and extracts family names using fontTools
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping font family names to their file paths
|
||||||
|
"""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return get_windows_fonts()
|
||||||
|
return get_unix_fonts()
|
||||||
|
|
||||||
|
|
||||||
|
# Common Windows font names mapped to their Linux equivalents
|
||||||
|
# Ordered by preference (first match is used)
|
||||||
|
FONT_ALIASES = {
|
||||||
|
"Arial": ["Liberation Sans", "DejaVu Sans", "Nimbus Sans", "FreeSans"],
|
||||||
|
"Arial Black": ["Liberation Sans", "DejaVu Sans", "Nimbus Sans"],
|
||||||
|
"Arial Bold": ["Liberation Sans", "DejaVu Sans"],
|
||||||
|
"Arial Unicode MS": ["DejaVu Sans", "Noto Sans", "FreeSans"],
|
||||||
|
"Times New Roman": ["Liberation Serif", "DejaVu Serif", "Nimbus Roman", "FreeSerif"],
|
||||||
|
"Courier New": ["Liberation Mono", "DejaVu Sans Mono", "Nimbus Mono PS", "FreeMono"],
|
||||||
|
"Comic Sans MS": ["Comic Neue", "Comic Relief", "DejaVu Sans"],
|
||||||
|
"Georgia": ["Gelasio", "DejaVu Serif", "Liberation Serif"],
|
||||||
|
"Impact": ["Impact", "Anton", "Liberation Sans"],
|
||||||
|
"Trebuchet MS": ["Ubuntu", "DejaVu Sans", "Liberation Sans"],
|
||||||
|
"Verdana": ["DejaVu Sans", "Bitstream Vera Sans", "Liberation Sans"],
|
||||||
|
"Tahoma": ["DejaVu Sans", "Liberation Sans"],
|
||||||
|
"Adobe Arabic": ["Noto Sans Arabic", "DejaVu Sans"],
|
||||||
|
"Noto Sans Thai": ["Noto Sans Thai", "Noto Sans"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_case_insensitive(font_name: str, fonts: dict[str, Path]) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
Find font by case-insensitive name match.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
font_name: Font family name to find
|
||||||
|
fonts: Dictionary of available fonts
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to matched font, or None if not found
|
||||||
|
"""
|
||||||
|
font_lower = font_name.lower()
|
||||||
|
for name, path in fonts.items():
|
||||||
|
if name.lower() == font_lower:
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_font_with_fallbacks(font_name: str, system_fonts: dict[str, Path]) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
Find a font by name with intelligent fallback matching.
|
||||||
|
|
||||||
|
Tries multiple strategies in order:
|
||||||
|
1. Exact match (case-sensitive)
|
||||||
|
2. Case-insensitive match
|
||||||
|
3. Alias lookup (Windows → Linux font equivalents)
|
||||||
|
4. Partial/prefix match
|
||||||
|
|
||||||
|
Args:
|
||||||
|
font_name: The requested font family name (e.g., "Arial", "Times New Roman")
|
||||||
|
system_fonts: Dictionary of available fonts (family name → path)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the matched font file, or None if no match found
|
||||||
|
"""
|
||||||
|
if not system_fonts:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Strategy 1: Exact match (case-sensitive)
|
||||||
|
if font_name in system_fonts:
|
||||||
|
return system_fonts[font_name]
|
||||||
|
|
||||||
|
# Strategy 2: Case-insensitive match
|
||||||
|
if result := find_case_insensitive(font_name, system_fonts):
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Strategy 3: Alias lookup
|
||||||
|
if font_name in FONT_ALIASES:
|
||||||
|
for alias in FONT_ALIASES[font_name]:
|
||||||
|
# Try exact match for alias
|
||||||
|
if alias in system_fonts:
|
||||||
|
return system_fonts[alias]
|
||||||
|
# Try case-insensitive match for alias
|
||||||
|
if result := find_case_insensitive(alias, system_fonts):
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Strategy 4: Partial/prefix match as last resort
|
||||||
|
font_name_lower = font_name.lower()
|
||||||
|
for name, path in system_fonts.items():
|
||||||
|
if name.lower().startswith(font_name_lower):
|
||||||
|
return path
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Mapping of font families to system packages that provide them
|
||||||
|
FONT_PACKAGES = {
|
||||||
|
"liberation": {
|
||||||
|
"debian": "fonts-liberation fonts-liberation2",
|
||||||
|
"fonts": ["Liberation Sans", "Liberation Serif", "Liberation Mono"],
|
||||||
|
},
|
||||||
|
"dejavu": {
|
||||||
|
"debian": "fonts-dejavu fonts-dejavu-core fonts-dejavu-extra",
|
||||||
|
"fonts": ["DejaVu Sans", "DejaVu Serif", "DejaVu Sans Mono"],
|
||||||
|
},
|
||||||
|
"noto": {
|
||||||
|
"debian": "fonts-noto fonts-noto-core",
|
||||||
|
"fonts": ["Noto Sans", "Noto Serif", "Noto Sans Mono", "Noto Sans Arabic", "Noto Sans Thai"],
|
||||||
|
},
|
||||||
|
"ubuntu": {
|
||||||
|
"debian": "fonts-ubuntu",
|
||||||
|
"fonts": ["Ubuntu", "Ubuntu Mono"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def suggest_font_packages(missing_fonts: list[str]) -> dict[str, list[str]]:
|
||||||
|
"""
|
||||||
|
Suggest system packages to install for missing fonts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
missing_fonts: List of font family names that couldn't be found
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping package names to lists of fonts they would provide
|
||||||
|
"""
|
||||||
|
suggestions = {}
|
||||||
|
|
||||||
|
# Check which fonts from aliases would help
|
||||||
|
needed_aliases = set()
|
||||||
|
for font in missing_fonts:
|
||||||
|
if font in FONT_ALIASES:
|
||||||
|
needed_aliases.update(FONT_ALIASES[font])
|
||||||
|
|
||||||
|
# Map needed aliases to packages
|
||||||
|
for package_name, package_info in FONT_PACKAGES.items():
|
||||||
|
provided_fonts = package_info["fonts"]
|
||||||
|
matching_fonts = [f for f in provided_fonts if f in needed_aliases]
|
||||||
|
if matching_fonts:
|
||||||
|
suggestions[package_info["debian"]] = matching_fonts
|
||||||
|
|
||||||
|
return suggestions
|
||||||
|
|
||||||
|
|
||||||
class FPS(ast.NodeVisitor):
|
class FPS(ast.NodeVisitor):
|
||||||
@@ -456,3 +727,334 @@ class FPS(ast.NodeVisitor):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def parse(cls, expr: str) -> float:
|
def parse(cls, expr: str) -> float:
|
||||||
return cls().visit(ast.parse(expr).body[0])
|
return cls().visit(ast.parse(expr).body[0])
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
Structured JSON debug logging for envied.
|
||||||
|
|
||||||
|
Provides comprehensive debugging information for service developers and troubleshooting.
|
||||||
|
When enabled, logs all operations, requests, responses, DRM operations, and errors in JSON format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class DebugLogger:
|
||||||
|
"""
|
||||||
|
Structured JSON debug logger for envied.
|
||||||
|
|
||||||
|
Outputs JSON Lines format where each line is a complete JSON object.
|
||||||
|
This makes it easy to parse, filter, and analyze logs programmatically.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, log_path: Optional[Path] = None, enabled: bool = False, log_keys: bool = False):
|
||||||
|
"""
|
||||||
|
Initialize the debug logger.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
log_path: Path to the log file. If None, logging is disabled.
|
||||||
|
enabled: Whether debug logging is enabled.
|
||||||
|
log_keys: Whether to log decryption keys (for debugging key issues).
|
||||||
|
"""
|
||||||
|
self.enabled = enabled and log_path is not None
|
||||||
|
self.log_path = log_path
|
||||||
|
self.session_id = str(uuid4())[:8]
|
||||||
|
self.file_handle = None
|
||||||
|
self.log_keys = log_keys
|
||||||
|
|
||||||
|
if self.enabled:
|
||||||
|
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.file_handle = open(self.log_path, "a", encoding="utf-8")
|
||||||
|
self.log_session_start()
|
||||||
|
|
||||||
|
def log_session_start(self):
|
||||||
|
"""Log the start of a new session with environment information."""
|
||||||
|
import platform
|
||||||
|
|
||||||
|
from envied.core import __version__
|
||||||
|
|
||||||
|
self.log(
|
||||||
|
level="INFO",
|
||||||
|
operation="session_start",
|
||||||
|
message="Debug logging session started",
|
||||||
|
context={
|
||||||
|
"unshackle_version": __version__,
|
||||||
|
"python_version": sys.version,
|
||||||
|
"platform": platform.platform(),
|
||||||
|
"platform_system": platform.system(),
|
||||||
|
"platform_release": platform.release(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def log(
|
||||||
|
self,
|
||||||
|
level: str = "DEBUG",
|
||||||
|
operation: str = "",
|
||||||
|
message: str = "",
|
||||||
|
context: Optional[dict[str, Any]] = None,
|
||||||
|
service: Optional[str] = None,
|
||||||
|
error: Optional[Exception] = None,
|
||||||
|
request: Optional[dict[str, Any]] = None,
|
||||||
|
response: Optional[dict[str, Any]] = None,
|
||||||
|
duration_ms: Optional[float] = None,
|
||||||
|
success: Optional[bool] = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Log a structured JSON entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
level: Log level (DEBUG, INFO, WARNING, ERROR)
|
||||||
|
operation: Name of the operation being performed
|
||||||
|
message: Human-readable message
|
||||||
|
context: Additional context information
|
||||||
|
service: Service name (e.g., DSNP, NF)
|
||||||
|
error: Exception object if an error occurred
|
||||||
|
request: Request details (URL, method, headers, body)
|
||||||
|
response: Response details (status, headers, body)
|
||||||
|
duration_ms: Operation duration in milliseconds
|
||||||
|
success: Whether the operation succeeded
|
||||||
|
**kwargs: Additional fields to include in the log entry
|
||||||
|
"""
|
||||||
|
if not self.enabled or not self.file_handle:
|
||||||
|
return
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"session_id": self.session_id,
|
||||||
|
"level": level,
|
||||||
|
}
|
||||||
|
|
||||||
|
if operation:
|
||||||
|
entry["operation"] = operation
|
||||||
|
if message:
|
||||||
|
entry["message"] = message
|
||||||
|
if service:
|
||||||
|
entry["service"] = service
|
||||||
|
if context:
|
||||||
|
entry["context"] = self.sanitize_data(context)
|
||||||
|
if request:
|
||||||
|
entry["request"] = self.sanitize_data(request)
|
||||||
|
if response:
|
||||||
|
entry["response"] = self.sanitize_data(response)
|
||||||
|
if duration_ms is not None:
|
||||||
|
entry["duration_ms"] = duration_ms
|
||||||
|
if success is not None:
|
||||||
|
entry["success"] = success
|
||||||
|
|
||||||
|
if error:
|
||||||
|
entry["error"] = {
|
||||||
|
"type": type(error).__name__,
|
||||||
|
"message": str(error),
|
||||||
|
"traceback": traceback.format_exception(type(error), error, error.__traceback__),
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if key not in entry:
|
||||||
|
entry[key] = self.sanitize_data(value)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.file_handle.write(json.dumps(entry, default=str) + "\n")
|
||||||
|
self.file_handle.flush()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to write debug log: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
def sanitize_data(self, data: Any) -> Any:
|
||||||
|
"""
|
||||||
|
Sanitize data for JSON serialization.
|
||||||
|
Handles complex objects and removes sensitive information.
|
||||||
|
"""
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(data, (str, int, float, bool)):
|
||||||
|
return data
|
||||||
|
|
||||||
|
if isinstance(data, (list, tuple)):
|
||||||
|
return [self.sanitize_data(item) for item in data]
|
||||||
|
|
||||||
|
if isinstance(data, dict):
|
||||||
|
sanitized = {}
|
||||||
|
for key, value in data.items():
|
||||||
|
key_lower = str(key).lower()
|
||||||
|
has_prefix = key_lower.startswith("has_")
|
||||||
|
|
||||||
|
is_always_sensitive = not has_prefix and any(
|
||||||
|
sensitive in key_lower for sensitive in ["password", "token", "secret", "auth", "cookie"]
|
||||||
|
)
|
||||||
|
|
||||||
|
is_key_field = (
|
||||||
|
"key" in key_lower
|
||||||
|
and not has_prefix
|
||||||
|
and not any(safe in key_lower for safe in ["_count", "_id", "_type", "kid", "keys_", "key_found"])
|
||||||
|
)
|
||||||
|
|
||||||
|
should_redact = is_always_sensitive or (is_key_field and not self.log_keys)
|
||||||
|
|
||||||
|
if should_redact:
|
||||||
|
sanitized[key] = "[REDACTED]"
|
||||||
|
else:
|
||||||
|
sanitized[key] = self.sanitize_data(value)
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
if isinstance(data, bytes):
|
||||||
|
try:
|
||||||
|
return data.hex()
|
||||||
|
except Exception:
|
||||||
|
return "[BINARY_DATA]"
|
||||||
|
|
||||||
|
if isinstance(data, Path):
|
||||||
|
return str(data)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return str(data)
|
||||||
|
except Exception:
|
||||||
|
return f"[{type(data).__name__}]"
|
||||||
|
|
||||||
|
def log_operation_start(self, operation: str, **kwargs) -> str:
|
||||||
|
"""
|
||||||
|
Log the start of an operation and return an operation ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operation: Name of the operation
|
||||||
|
**kwargs: Additional context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Operation ID that can be used to log the end of the operation
|
||||||
|
"""
|
||||||
|
op_id = str(uuid4())[:8]
|
||||||
|
self.log(
|
||||||
|
level="DEBUG",
|
||||||
|
operation=f"{operation}_start",
|
||||||
|
message=f"Starting operation: {operation}",
|
||||||
|
operation_id=op_id,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
return op_id
|
||||||
|
|
||||||
|
def log_operation_end(
|
||||||
|
self, operation: str, operation_id: str, success: bool = True, duration_ms: Optional[float] = None, **kwargs
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Log the end of an operation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operation: Name of the operation
|
||||||
|
operation_id: Operation ID from log_operation_start
|
||||||
|
success: Whether the operation succeeded
|
||||||
|
duration_ms: Operation duration in milliseconds
|
||||||
|
**kwargs: Additional context
|
||||||
|
"""
|
||||||
|
self.log(
|
||||||
|
level="INFO" if success else "ERROR",
|
||||||
|
operation=f"{operation}_end",
|
||||||
|
message=f"Finished operation: {operation}",
|
||||||
|
operation_id=operation_id,
|
||||||
|
success=success,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def log_service_call(self, method: str, url: str, **kwargs):
|
||||||
|
"""
|
||||||
|
Log a service API call.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method: HTTP method (GET, POST, etc.)
|
||||||
|
url: Request URL
|
||||||
|
**kwargs: Additional request details (headers, body, etc.)
|
||||||
|
"""
|
||||||
|
self.log(level="DEBUG", operation="service_call", request={"method": method, "url": url, **kwargs})
|
||||||
|
|
||||||
|
def log_drm_operation(self, drm_type: str, operation: str, **kwargs):
|
||||||
|
"""
|
||||||
|
Log a DRM operation (PSSH extraction, license request, key retrieval).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
drm_type: DRM type (Widevine, PlayReady, etc.)
|
||||||
|
operation: DRM operation name
|
||||||
|
**kwargs: Additional context (PSSH, KIDs, keys, etc.)
|
||||||
|
"""
|
||||||
|
self.log(
|
||||||
|
level="DEBUG", operation=f"drm_{operation}", message=f"{drm_type} {operation}", drm_type=drm_type, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
def log_vault_query(self, vault_name: str, operation: str, **kwargs):
|
||||||
|
"""
|
||||||
|
Log a vault query operation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vault_name: Name of the vault
|
||||||
|
operation: Vault operation (get_key, add_key, etc.)
|
||||||
|
**kwargs: Additional context (KID, key, success, etc.)
|
||||||
|
"""
|
||||||
|
self.log(
|
||||||
|
level="DEBUG",
|
||||||
|
operation=f"vault_{operation}",
|
||||||
|
message=f"Vault {vault_name}: {operation}",
|
||||||
|
vault=vault_name,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def log_error(self, operation: str, error: Exception, **kwargs):
|
||||||
|
"""
|
||||||
|
Log an error with full context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operation: Operation that failed
|
||||||
|
error: Exception that occurred
|
||||||
|
**kwargs: Additional context
|
||||||
|
"""
|
||||||
|
self.log(
|
||||||
|
level="ERROR",
|
||||||
|
operation=operation,
|
||||||
|
message=f"Error in {operation}: {str(error)}",
|
||||||
|
error=error,
|
||||||
|
success=False,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Close the log file and clean up resources."""
|
||||||
|
if self.file_handle:
|
||||||
|
self.log(level="INFO", operation="session_end", message="Debug logging session ended")
|
||||||
|
self.file_handle.close()
|
||||||
|
self.file_handle = None
|
||||||
|
|
||||||
|
|
||||||
|
# Global debug logger instance
|
||||||
|
_debug_logger: Optional[DebugLogger] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_debug_logger() -> Optional[DebugLogger]:
|
||||||
|
"""Get the global debug logger instance."""
|
||||||
|
return _debug_logger
|
||||||
|
|
||||||
|
|
||||||
|
def init_debug_logger(log_path: Optional[Path] = None, enabled: bool = False, log_keys: bool = False):
|
||||||
|
"""
|
||||||
|
Initialize the global debug logger.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
log_path: Path to the log file
|
||||||
|
enabled: Whether debug logging is enabled
|
||||||
|
log_keys: Whether to log decryption keys (for debugging key issues)
|
||||||
|
"""
|
||||||
|
global _debug_logger
|
||||||
|
if _debug_logger:
|
||||||
|
_debug_logger.close()
|
||||||
|
_debug_logger = DebugLogger(log_path=log_path, enabled=enabled, log_keys=log_keys)
|
||||||
|
|
||||||
|
|
||||||
|
def close_debug_logger():
|
||||||
|
"""Close the global debug logger."""
|
||||||
|
global _debug_logger
|
||||||
|
if _debug_logger:
|
||||||
|
_debug_logger.close()
|
||||||
|
_debug_logger = None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
"DebugLogger",
|
||||||
|
"get_debug_logger",
|
||||||
|
"init_debug_logger",
|
||||||
|
"close_debug_logger",
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -21,7 +20,7 @@ from envied.core.titles.title import Title
|
|||||||
|
|
||||||
STRIP_RE = re.compile(r"[^a-z0-9]+", re.I)
|
STRIP_RE = re.compile(r"[^a-z0-9]+", re.I)
|
||||||
YEAR_RE = re.compile(r"\s*\(?[12][0-9]{3}\)?$")
|
YEAR_RE = re.compile(r"\s*\(?[12][0-9]{3}\)?$")
|
||||||
HEADERS = {"User-Agent": "envied-tags/1.0"}
|
HEADERS = {"User-Agent": "unshackle-tags/1.0"}
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger("TAGS")
|
log = logging.getLogger("TAGS")
|
||||||
@@ -44,7 +43,11 @@ def _get_session() -> requests.Session:
|
|||||||
|
|
||||||
|
|
||||||
def _api_key() -> Optional[str]:
|
def _api_key() -> Optional[str]:
|
||||||
return config.tmdb_api_key or os.getenv("TMDB_API_KEY")
|
return config.tmdb_api_key
|
||||||
|
|
||||||
|
|
||||||
|
def _simkl_client_id() -> Optional[str]:
|
||||||
|
return config.simkl_client_id
|
||||||
|
|
||||||
|
|
||||||
def _clean(s: str) -> str:
|
def _clean(s: str) -> str:
|
||||||
@@ -62,10 +65,44 @@ def fuzzy_match(a: str, b: str, threshold: float = 0.8) -> bool:
|
|||||||
return ratio >= threshold
|
return ratio >= threshold
|
||||||
|
|
||||||
|
|
||||||
def search_simkl(title: str, year: Optional[int], kind: str) -> Tuple[Optional[dict], Optional[str], Optional[int]]:
|
def search_simkl(
|
||||||
"""Search Simkl API for show information by filename (no auth required)."""
|
title: str,
|
||||||
|
year: Optional[int],
|
||||||
|
kind: str,
|
||||||
|
title_cacher=None,
|
||||||
|
cache_title_id: Optional[str] = None,
|
||||||
|
cache_region: Optional[str] = None,
|
||||||
|
cache_account_hash: Optional[str] = None,
|
||||||
|
) -> Tuple[Optional[dict], Optional[str], Optional[int]]:
|
||||||
|
"""Search Simkl API for show information by filename."""
|
||||||
|
|
||||||
|
if title_cacher and cache_title_id:
|
||||||
|
cached_simkl = title_cacher.get_cached_simkl(cache_title_id, cache_region, cache_account_hash)
|
||||||
|
if cached_simkl:
|
||||||
|
log.debug("Using cached Simkl data")
|
||||||
|
if cached_simkl.get("type") == "episode" and "show" in cached_simkl:
|
||||||
|
show_info = cached_simkl["show"]
|
||||||
|
show_title = show_info.get("title")
|
||||||
|
tmdb_id = show_info.get("ids", {}).get("tmdbtv")
|
||||||
|
if tmdb_id:
|
||||||
|
tmdb_id = int(tmdb_id)
|
||||||
|
return cached_simkl, show_title, tmdb_id
|
||||||
|
elif cached_simkl.get("type") == "movie" and "movie" in cached_simkl:
|
||||||
|
movie_info = cached_simkl["movie"]
|
||||||
|
movie_title = movie_info.get("title")
|
||||||
|
ids = movie_info.get("ids", {})
|
||||||
|
tmdb_id = ids.get("tmdb") or ids.get("moviedb")
|
||||||
|
if tmdb_id:
|
||||||
|
tmdb_id = int(tmdb_id)
|
||||||
|
return cached_simkl, movie_title, tmdb_id
|
||||||
|
|
||||||
log.debug("Searching Simkl for %r (%s, %s)", title, kind, year)
|
log.debug("Searching Simkl for %r (%s, %s)", title, kind, year)
|
||||||
|
|
||||||
|
client_id = _simkl_client_id()
|
||||||
|
if not client_id:
|
||||||
|
log.debug("No SIMKL client ID configured; skipping SIMKL search")
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
# Construct appropriate filename based on type
|
# Construct appropriate filename based on type
|
||||||
filename = f"{title}"
|
filename = f"{title}"
|
||||||
if year:
|
if year:
|
||||||
@@ -78,7 +115,8 @@ def search_simkl(title: str, year: Optional[int], kind: str) -> Tuple[Optional[d
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
session = _get_session()
|
session = _get_session()
|
||||||
resp = session.post("https://api.simkl.com/search/file", json={"file": filename}, timeout=30)
|
headers = {"simkl-api-key": client_id}
|
||||||
|
resp = session.post("https://api.simkl.com/search/file", json={"file": filename}, headers=headers, timeout=30)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
log.debug("Simkl API response received")
|
log.debug("Simkl API response received")
|
||||||
@@ -102,19 +140,23 @@ def search_simkl(title: str, year: Optional[int], kind: str) -> Tuple[Optional[d
|
|||||||
log.debug("Simkl year mismatch: searched %d, got %d", year, show_year)
|
log.debug("Simkl year mismatch: searched %d, got %d", year, show_year)
|
||||||
return None, None, None
|
return None, None, None
|
||||||
|
|
||||||
|
if title_cacher and cache_title_id:
|
||||||
|
try:
|
||||||
|
title_cacher.cache_simkl(cache_title_id, data, cache_region, cache_account_hash)
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("Failed to cache Simkl data: %s", exc)
|
||||||
|
|
||||||
tmdb_id = show_info.get("ids", {}).get("tmdbtv")
|
tmdb_id = show_info.get("ids", {}).get("tmdbtv")
|
||||||
if tmdb_id:
|
if tmdb_id:
|
||||||
tmdb_id = int(tmdb_id)
|
tmdb_id = int(tmdb_id)
|
||||||
log.debug("Simkl -> %s (TMDB ID %s)", show_title, tmdb_id)
|
log.debug("Simkl -> %s (TMDB ID %s)", show_title, tmdb_id)
|
||||||
return data, show_title, tmdb_id
|
return data, show_title, tmdb_id
|
||||||
|
|
||||||
# Handle movie responses
|
|
||||||
elif data.get("type") == "movie" and "movie" in data:
|
elif data.get("type") == "movie" and "movie" in data:
|
||||||
movie_info = data["movie"]
|
movie_info = data["movie"]
|
||||||
movie_title = movie_info.get("title")
|
movie_title = movie_info.get("title")
|
||||||
movie_year = movie_info.get("year")
|
movie_year = movie_info.get("year")
|
||||||
|
|
||||||
# Verify title matches and year if provided
|
|
||||||
if not fuzzy_match(movie_title, title):
|
if not fuzzy_match(movie_title, title):
|
||||||
log.debug("Simkl title mismatch: searched %r, got %r", title, movie_title)
|
log.debug("Simkl title mismatch: searched %r, got %r", title, movie_title)
|
||||||
return None, None, None
|
return None, None, None
|
||||||
@@ -122,6 +164,12 @@ def search_simkl(title: str, year: Optional[int], kind: str) -> Tuple[Optional[d
|
|||||||
log.debug("Simkl year mismatch: searched %d, got %d", year, movie_year)
|
log.debug("Simkl year mismatch: searched %d, got %d", year, movie_year)
|
||||||
return None, None, None
|
return None, None, None
|
||||||
|
|
||||||
|
if title_cacher and cache_title_id:
|
||||||
|
try:
|
||||||
|
title_cacher.cache_simkl(cache_title_id, data, cache_region, cache_account_hash)
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("Failed to cache Simkl data: %s", exc)
|
||||||
|
|
||||||
ids = movie_info.get("ids", {})
|
ids = movie_info.get("ids", {})
|
||||||
tmdb_id = ids.get("tmdb") or ids.get("moviedb")
|
tmdb_id = ids.get("tmdb") or ids.get("moviedb")
|
||||||
if tmdb_id:
|
if tmdb_id:
|
||||||
@@ -135,18 +183,85 @@ def search_simkl(title: str, year: Optional[int], kind: str) -> Tuple[Optional[d
|
|||||||
return None, None, None
|
return None, None, None
|
||||||
|
|
||||||
|
|
||||||
def search_show_info(title: str, year: Optional[int], kind: str) -> Tuple[Optional[int], Optional[str], Optional[str]]:
|
def search_show_info(
|
||||||
|
title: str,
|
||||||
|
year: Optional[int],
|
||||||
|
kind: str,
|
||||||
|
title_cacher=None,
|
||||||
|
cache_title_id: Optional[str] = None,
|
||||||
|
cache_region: Optional[str] = None,
|
||||||
|
cache_account_hash: Optional[str] = None,
|
||||||
|
) -> Tuple[Optional[int], Optional[str], Optional[str]]:
|
||||||
"""Search for show information, trying Simkl first, then TMDB fallback. Returns (tmdb_id, title, source)."""
|
"""Search for show information, trying Simkl first, then TMDB fallback. Returns (tmdb_id, title, source)."""
|
||||||
simkl_data, simkl_title, simkl_tmdb_id = search_simkl(title, year, kind)
|
simkl_data, simkl_title, simkl_tmdb_id = search_simkl(
|
||||||
|
title, year, kind, title_cacher, cache_title_id, cache_region, cache_account_hash
|
||||||
|
)
|
||||||
|
|
||||||
if simkl_data and simkl_title and fuzzy_match(simkl_title, title):
|
if simkl_data and simkl_title and fuzzy_match(simkl_title, title):
|
||||||
return simkl_tmdb_id, simkl_title, "simkl"
|
return simkl_tmdb_id, simkl_title, "simkl"
|
||||||
|
|
||||||
tmdb_id, tmdb_title = search_tmdb(title, year, kind)
|
tmdb_id, tmdb_title = search_tmdb(title, year, kind, title_cacher, cache_title_id, cache_region, cache_account_hash)
|
||||||
return tmdb_id, tmdb_title, "tmdb"
|
return tmdb_id, tmdb_title, "tmdb"
|
||||||
|
|
||||||
|
|
||||||
def search_tmdb(title: str, year: Optional[int], kind: str) -> Tuple[Optional[int], Optional[str]]:
|
def _fetch_tmdb_detail(tmdb_id: int, kind: str) -> Optional[dict]:
|
||||||
|
"""Fetch full TMDB detail response for caching."""
|
||||||
|
api_key = _api_key()
|
||||||
|
if not api_key:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = _get_session()
|
||||||
|
r = session.get(
|
||||||
|
f"https://api.themoviedb.org/3/{kind}/{tmdb_id}",
|
||||||
|
params={"api_key": api_key},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
log.debug("Failed to fetch TMDB detail: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_tmdb_external_ids(tmdb_id: int, kind: str) -> Optional[dict]:
|
||||||
|
"""Fetch full TMDB external_ids response for caching."""
|
||||||
|
api_key = _api_key()
|
||||||
|
if not api_key:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = _get_session()
|
||||||
|
r = session.get(
|
||||||
|
f"https://api.themoviedb.org/3/{kind}/{tmdb_id}/external_ids",
|
||||||
|
params={"api_key": api_key},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
log.debug("Failed to fetch TMDB external IDs: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def search_tmdb(
|
||||||
|
title: str,
|
||||||
|
year: Optional[int],
|
||||||
|
kind: str,
|
||||||
|
title_cacher=None,
|
||||||
|
cache_title_id: Optional[str] = None,
|
||||||
|
cache_region: Optional[str] = None,
|
||||||
|
cache_account_hash: Optional[str] = None,
|
||||||
|
) -> Tuple[Optional[int], Optional[str]]:
|
||||||
|
if title_cacher and cache_title_id:
|
||||||
|
cached_tmdb = title_cacher.get_cached_tmdb(cache_title_id, kind, cache_region, cache_account_hash)
|
||||||
|
if cached_tmdb and cached_tmdb.get("detail"):
|
||||||
|
detail = cached_tmdb["detail"]
|
||||||
|
tmdb_id = detail.get("id")
|
||||||
|
tmdb_title = detail.get("title") or detail.get("name")
|
||||||
|
log.debug("Using cached TMDB data: %r (ID %s)", tmdb_title, tmdb_id)
|
||||||
|
return tmdb_id, tmdb_title
|
||||||
|
|
||||||
api_key = _api_key()
|
api_key = _api_key()
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return None, None
|
return None, None
|
||||||
@@ -205,15 +320,41 @@ def search_tmdb(title: str, year: Optional[int], kind: str) -> Tuple[Optional[in
|
|||||||
)
|
)
|
||||||
|
|
||||||
if best_id is not None:
|
if best_id is not None:
|
||||||
|
if title_cacher and cache_title_id:
|
||||||
|
try:
|
||||||
|
detail_response = _fetch_tmdb_detail(best_id, kind)
|
||||||
|
external_ids_response = _fetch_tmdb_external_ids(best_id, kind)
|
||||||
|
if detail_response and external_ids_response:
|
||||||
|
title_cacher.cache_tmdb(
|
||||||
|
cache_title_id, detail_response, external_ids_response, kind, cache_region, cache_account_hash
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("Failed to cache TMDB data: %s", exc)
|
||||||
|
|
||||||
return best_id, best_title
|
return best_id, best_title
|
||||||
|
|
||||||
first = results[0]
|
first = results[0]
|
||||||
return first.get("id"), first.get("title") or first.get("name")
|
return first.get("id"), first.get("title") or first.get("name")
|
||||||
|
|
||||||
|
|
||||||
def get_title(tmdb_id: int, kind: str) -> Optional[str]:
|
def get_title(
|
||||||
|
tmdb_id: int,
|
||||||
|
kind: str,
|
||||||
|
title_cacher=None,
|
||||||
|
cache_title_id: Optional[str] = None,
|
||||||
|
cache_region: Optional[str] = None,
|
||||||
|
cache_account_hash: Optional[str] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
"""Fetch the name/title of a TMDB entry by ID."""
|
"""Fetch the name/title of a TMDB entry by ID."""
|
||||||
|
|
||||||
|
if title_cacher and cache_title_id:
|
||||||
|
cached_tmdb = title_cacher.get_cached_tmdb(cache_title_id, kind, cache_region, cache_account_hash)
|
||||||
|
if cached_tmdb and cached_tmdb.get("detail"):
|
||||||
|
detail = cached_tmdb["detail"]
|
||||||
|
tmdb_title = detail.get("title") or detail.get("name")
|
||||||
|
log.debug("Using cached TMDB title: %r", tmdb_title)
|
||||||
|
return tmdb_title
|
||||||
|
|
||||||
api_key = _api_key()
|
api_key = _api_key()
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return None
|
return None
|
||||||
@@ -226,17 +367,44 @@ def get_title(tmdb_id: int, kind: str) -> Optional[str]:
|
|||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
js = r.json()
|
||||||
|
|
||||||
|
if title_cacher and cache_title_id:
|
||||||
|
try:
|
||||||
|
external_ids_response = _fetch_tmdb_external_ids(tmdb_id, kind)
|
||||||
|
if external_ids_response:
|
||||||
|
title_cacher.cache_tmdb(
|
||||||
|
cache_title_id, js, external_ids_response, kind, cache_region, cache_account_hash
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("Failed to cache TMDB data: %s", exc)
|
||||||
|
|
||||||
|
return js.get("title") or js.get("name")
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
log.debug("Failed to fetch TMDB title: %s", exc)
|
log.debug("Failed to fetch TMDB title: %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
js = r.json()
|
|
||||||
return js.get("title") or js.get("name")
|
|
||||||
|
|
||||||
|
def get_year(
|
||||||
def get_year(tmdb_id: int, kind: str) -> Optional[int]:
|
tmdb_id: int,
|
||||||
|
kind: str,
|
||||||
|
title_cacher=None,
|
||||||
|
cache_title_id: Optional[str] = None,
|
||||||
|
cache_region: Optional[str] = None,
|
||||||
|
cache_account_hash: Optional[str] = None,
|
||||||
|
) -> Optional[int]:
|
||||||
"""Fetch the release year of a TMDB entry by ID."""
|
"""Fetch the release year of a TMDB entry by ID."""
|
||||||
|
|
||||||
|
if title_cacher and cache_title_id:
|
||||||
|
cached_tmdb = title_cacher.get_cached_tmdb(cache_title_id, kind, cache_region, cache_account_hash)
|
||||||
|
if cached_tmdb and cached_tmdb.get("detail"):
|
||||||
|
detail = cached_tmdb["detail"]
|
||||||
|
date = detail.get("release_date") or detail.get("first_air_date")
|
||||||
|
if date and len(date) >= 4 and date[:4].isdigit():
|
||||||
|
year = int(date[:4])
|
||||||
|
log.debug("Using cached TMDB year: %d", year)
|
||||||
|
return year
|
||||||
|
|
||||||
api_key = _api_key()
|
api_key = _api_key()
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return None
|
return None
|
||||||
@@ -249,18 +417,41 @@ def get_year(tmdb_id: int, kind: str) -> Optional[int]:
|
|||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
js = r.json()
|
||||||
|
|
||||||
|
if title_cacher and cache_title_id:
|
||||||
|
try:
|
||||||
|
external_ids_response = _fetch_tmdb_external_ids(tmdb_id, kind)
|
||||||
|
if external_ids_response:
|
||||||
|
title_cacher.cache_tmdb(
|
||||||
|
cache_title_id, js, external_ids_response, kind, cache_region, cache_account_hash
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("Failed to cache TMDB data: %s", exc)
|
||||||
|
|
||||||
|
date = js.get("release_date") or js.get("first_air_date")
|
||||||
|
if date and len(date) >= 4 and date[:4].isdigit():
|
||||||
|
return int(date[:4])
|
||||||
|
return None
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
log.debug("Failed to fetch TMDB year: %s", exc)
|
log.debug("Failed to fetch TMDB year: %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
js = r.json()
|
|
||||||
date = js.get("release_date") or js.get("first_air_date")
|
|
||||||
if date and len(date) >= 4 and date[:4].isdigit():
|
|
||||||
return int(date[:4])
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
def external_ids(
|
||||||
|
tmdb_id: int,
|
||||||
|
kind: str,
|
||||||
|
title_cacher=None,
|
||||||
|
cache_title_id: Optional[str] = None,
|
||||||
|
cache_region: Optional[str] = None,
|
||||||
|
cache_account_hash: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
if title_cacher and cache_title_id:
|
||||||
|
cached_tmdb = title_cacher.get_cached_tmdb(cache_title_id, kind, cache_region, cache_account_hash)
|
||||||
|
if cached_tmdb and cached_tmdb.get("external_ids"):
|
||||||
|
log.debug("Using cached TMDB external IDs")
|
||||||
|
return cached_tmdb["external_ids"]
|
||||||
|
|
||||||
def external_ids(tmdb_id: int, kind: str) -> dict:
|
|
||||||
api_key = _api_key()
|
api_key = _api_key()
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return {}
|
return {}
|
||||||
@@ -277,13 +468,22 @@ def external_ids(tmdb_id: int, kind: str) -> dict:
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
js = r.json()
|
js = r.json()
|
||||||
log.debug("External IDs response: %s", js)
|
log.debug("External IDs response: %s", js)
|
||||||
|
|
||||||
|
if title_cacher and cache_title_id:
|
||||||
|
try:
|
||||||
|
detail_response = _fetch_tmdb_detail(tmdb_id, kind)
|
||||||
|
if detail_response:
|
||||||
|
title_cacher.cache_tmdb(cache_title_id, detail_response, js, kind, cache_region, cache_account_hash)
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("Failed to cache TMDB data: %s", exc)
|
||||||
|
|
||||||
return js
|
return js
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
log.warning("Failed to fetch external IDs for %s %s: %s", kind, tmdb_id, exc)
|
log.warning("Failed to fetch external IDs for %s %s: %s", kind, tmdb_id, exc)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _apply_tags(path: Path, tags: dict[str, str]) -> None:
|
def apply_tags(path: Path, tags: dict[str, str]) -> None:
|
||||||
if not tags:
|
if not tags:
|
||||||
return
|
return
|
||||||
if not binaries.Mkvpropedit:
|
if not binaries.Mkvpropedit:
|
||||||
@@ -334,83 +534,109 @@ def tag_file(path: Path, title: Title, tmdb_id: Optional[int] | None = None) ->
|
|||||||
name = title.title
|
name = title.title
|
||||||
year = title.year
|
year = title.year
|
||||||
else:
|
else:
|
||||||
_apply_tags(path, custom_tags)
|
apply_tags(path, custom_tags)
|
||||||
return
|
return
|
||||||
|
|
||||||
if config.tag_imdb_tmdb:
|
if config.tag_imdb_tmdb:
|
||||||
# If tmdb_id is provided (via --tmdb), skip Simkl and use TMDB directly
|
# Check if we have any API keys available for metadata lookup
|
||||||
if tmdb_id is not None:
|
|
||||||
log.debug("Using provided TMDB ID %s for tags", tmdb_id)
|
|
||||||
else:
|
|
||||||
# Try Simkl first for automatic lookup
|
|
||||||
simkl_data, simkl_title, simkl_tmdb_id = search_simkl(name, year, kind)
|
|
||||||
|
|
||||||
if simkl_data and simkl_title and fuzzy_match(simkl_title, name):
|
|
||||||
log.debug("Using Simkl data for tags")
|
|
||||||
if simkl_tmdb_id:
|
|
||||||
tmdb_id = simkl_tmdb_id
|
|
||||||
|
|
||||||
# Handle TV show data from Simkl
|
|
||||||
if simkl_data.get("type") == "episode" and "show" in simkl_data:
|
|
||||||
show_ids = simkl_data.get("show", {}).get("ids", {})
|
|
||||||
if show_ids.get("imdb"):
|
|
||||||
standard_tags["IMDB"] = show_ids["imdb"]
|
|
||||||
if show_ids.get("tvdb"):
|
|
||||||
standard_tags["TVDB2"] = f"series/{show_ids['tvdb']}"
|
|
||||||
if show_ids.get("tmdbtv"):
|
|
||||||
standard_tags["TMDB"] = f"tv/{show_ids['tmdbtv']}"
|
|
||||||
|
|
||||||
# Handle movie data from Simkl
|
|
||||||
elif simkl_data.get("type") == "movie" and "movie" in simkl_data:
|
|
||||||
movie_ids = simkl_data.get("movie", {}).get("ids", {})
|
|
||||||
if movie_ids.get("imdb"):
|
|
||||||
standard_tags["IMDB"] = movie_ids["imdb"]
|
|
||||||
if movie_ids.get("tvdb"):
|
|
||||||
standard_tags["TVDB2"] = f"movies/{movie_ids['tvdb']}"
|
|
||||||
if movie_ids.get("tmdb"):
|
|
||||||
standard_tags["TMDB"] = f"movie/{movie_ids['tmdb']}"
|
|
||||||
|
|
||||||
# Use TMDB API for additional metadata (either from provided ID or Simkl lookup)
|
|
||||||
api_key = _api_key()
|
api_key = _api_key()
|
||||||
if not api_key:
|
simkl_client = _simkl_client_id()
|
||||||
log.debug("No TMDB API key set; applying basic tags only")
|
|
||||||
_apply_tags(path, custom_tags)
|
if not api_key and not simkl_client:
|
||||||
|
log.debug("No TMDB API key or Simkl client ID configured; skipping IMDB/TMDB tag lookup")
|
||||||
|
apply_tags(path, custom_tags)
|
||||||
return
|
return
|
||||||
|
|
||||||
tmdb_title: Optional[str] = None
|
|
||||||
if tmdb_id is None:
|
|
||||||
tmdb_id, tmdb_title = search_tmdb(name, year, kind)
|
|
||||||
log.debug("TMDB search result: %r (ID %s)", tmdb_title, tmdb_id)
|
|
||||||
if not tmdb_id or not tmdb_title or not fuzzy_match(tmdb_title, name):
|
|
||||||
log.debug("TMDB search did not match; skipping external ID lookup")
|
|
||||||
_apply_tags(path, custom_tags)
|
|
||||||
return
|
|
||||||
|
|
||||||
prefix = "movie" if kind == "movie" else "tv"
|
|
||||||
standard_tags["TMDB"] = f"{prefix}/{tmdb_id}"
|
|
||||||
try:
|
|
||||||
ids = external_ids(tmdb_id, kind)
|
|
||||||
except requests.RequestException as exc:
|
|
||||||
log.debug("Failed to fetch external IDs: %s", exc)
|
|
||||||
ids = {}
|
|
||||||
else:
|
else:
|
||||||
log.debug("External IDs found: %s", ids)
|
# If tmdb_id is provided (via --tmdb), skip Simkl and use TMDB directly
|
||||||
|
if tmdb_id is not None:
|
||||||
imdb_id = ids.get("imdb_id")
|
log.debug("Using provided TMDB ID %s for tags", tmdb_id)
|
||||||
if imdb_id:
|
|
||||||
standard_tags["IMDB"] = imdb_id
|
|
||||||
tvdb_id = ids.get("tvdb_id")
|
|
||||||
if tvdb_id:
|
|
||||||
if kind == "movie":
|
|
||||||
standard_tags["TVDB2"] = f"movies/{tvdb_id}"
|
|
||||||
else:
|
else:
|
||||||
standard_tags["TVDB2"] = f"series/{tvdb_id}"
|
# Try Simkl first for automatic lookup (only if client ID is available)
|
||||||
|
if simkl_client:
|
||||||
|
simkl_data, simkl_title, simkl_tmdb_id = search_simkl(name, year, kind)
|
||||||
|
|
||||||
|
if simkl_data and simkl_title and fuzzy_match(simkl_title, name):
|
||||||
|
log.debug("Using Simkl data for tags")
|
||||||
|
if simkl_tmdb_id:
|
||||||
|
tmdb_id = simkl_tmdb_id
|
||||||
|
|
||||||
|
# Handle TV show data from Simkl
|
||||||
|
if simkl_data.get("type") == "episode" and "show" in simkl_data:
|
||||||
|
show_ids = simkl_data.get("show", {}).get("ids", {})
|
||||||
|
if show_ids.get("imdb"):
|
||||||
|
standard_tags["IMDB"] = show_ids["imdb"]
|
||||||
|
if show_ids.get("tvdb"):
|
||||||
|
standard_tags["TVDB2"] = f"series/{show_ids['tvdb']}"
|
||||||
|
if show_ids.get("tmdbtv"):
|
||||||
|
standard_tags["TMDB"] = f"tv/{show_ids['tmdbtv']}"
|
||||||
|
|
||||||
|
# Handle movie data from Simkl
|
||||||
|
elif simkl_data.get("type") == "movie" and "movie" in simkl_data:
|
||||||
|
movie_ids = simkl_data.get("movie", {}).get("ids", {})
|
||||||
|
if movie_ids.get("imdb"):
|
||||||
|
standard_tags["IMDB"] = movie_ids["imdb"]
|
||||||
|
if movie_ids.get("tvdb"):
|
||||||
|
standard_tags["TVDB2"] = f"movies/{movie_ids['tvdb']}"
|
||||||
|
if movie_ids.get("tmdb"):
|
||||||
|
standard_tags["TMDB"] = f"movie/{movie_ids['tmdb']}"
|
||||||
|
|
||||||
|
# Use TMDB API for additional metadata (either from provided ID or Simkl lookup)
|
||||||
|
if api_key:
|
||||||
|
tmdb_title: Optional[str] = None
|
||||||
|
if tmdb_id is None:
|
||||||
|
tmdb_id, tmdb_title = search_tmdb(name, year, kind)
|
||||||
|
log.debug("TMDB search result: %r (ID %s)", tmdb_title, tmdb_id)
|
||||||
|
if not tmdb_id or not tmdb_title or not fuzzy_match(tmdb_title, name):
|
||||||
|
log.debug("TMDB search did not match; skipping external ID lookup")
|
||||||
|
else:
|
||||||
|
prefix = "movie" if kind == "movie" else "tv"
|
||||||
|
standard_tags["TMDB"] = f"{prefix}/{tmdb_id}"
|
||||||
|
try:
|
||||||
|
ids = external_ids(tmdb_id, kind)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
log.debug("Failed to fetch external IDs: %s", exc)
|
||||||
|
ids = {}
|
||||||
|
else:
|
||||||
|
log.debug("External IDs found: %s", ids)
|
||||||
|
|
||||||
|
imdb_id = ids.get("imdb_id")
|
||||||
|
if imdb_id:
|
||||||
|
standard_tags["IMDB"] = imdb_id
|
||||||
|
tvdb_id = ids.get("tvdb_id")
|
||||||
|
if tvdb_id:
|
||||||
|
if kind == "movie":
|
||||||
|
standard_tags["TVDB2"] = f"movies/{tvdb_id}"
|
||||||
|
else:
|
||||||
|
standard_tags["TVDB2"] = f"series/{tvdb_id}"
|
||||||
|
elif tmdb_id is not None:
|
||||||
|
# tmdb_id was provided or found via Simkl
|
||||||
|
prefix = "movie" if kind == "movie" else "tv"
|
||||||
|
standard_tags["TMDB"] = f"{prefix}/{tmdb_id}"
|
||||||
|
try:
|
||||||
|
ids = external_ids(tmdb_id, kind)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
log.debug("Failed to fetch external IDs: %s", exc)
|
||||||
|
ids = {}
|
||||||
|
else:
|
||||||
|
log.debug("External IDs found: %s", ids)
|
||||||
|
|
||||||
|
imdb_id = ids.get("imdb_id")
|
||||||
|
if imdb_id:
|
||||||
|
standard_tags["IMDB"] = imdb_id
|
||||||
|
tvdb_id = ids.get("tvdb_id")
|
||||||
|
if tvdb_id:
|
||||||
|
if kind == "movie":
|
||||||
|
standard_tags["TVDB2"] = f"movies/{tvdb_id}"
|
||||||
|
else:
|
||||||
|
standard_tags["TVDB2"] = f"series/{tvdb_id}"
|
||||||
|
else:
|
||||||
|
log.debug("No TMDB API key configured; skipping TMDB external ID lookup")
|
||||||
|
|
||||||
merged_tags = {
|
merged_tags = {
|
||||||
**custom_tags,
|
**custom_tags,
|
||||||
**standard_tags,
|
**standard_tags,
|
||||||
}
|
}
|
||||||
_apply_tags(path, merged_tags)
|
apply_tags(path, merged_tags)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import sys
|
|||||||
import typing
|
import typing
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import pysubs2
|
||||||
from pycaption import Caption, CaptionList, CaptionNode, CaptionReadError, WebVTTReader, WebVTTWriter
|
from pycaption import Caption, CaptionList, CaptionNode, CaptionReadError, WebVTTReader, WebVTTWriter
|
||||||
|
|
||||||
|
from envied.core.config import config
|
||||||
|
|
||||||
|
|
||||||
class CaptionListExt(CaptionList):
|
class CaptionListExt(CaptionList):
|
||||||
@typing.no_type_check
|
@typing.no_type_check
|
||||||
@@ -142,7 +145,24 @@ def merge_segmented_webvtt(vtt_raw: str, segment_durations: Optional[list[int]]
|
|||||||
"""
|
"""
|
||||||
MPEG_TIMESCALE = 90_000
|
MPEG_TIMESCALE = 90_000
|
||||||
|
|
||||||
vtt = WebVTTReaderExt().read(vtt_raw)
|
# Check config for conversion method preference
|
||||||
|
conversion_method = config.subtitle.get("conversion_method", "auto")
|
||||||
|
use_pysubs2 = conversion_method in ("pysubs2", "auto")
|
||||||
|
|
||||||
|
if use_pysubs2:
|
||||||
|
# Try using pysubs2 first for more lenient parsing
|
||||||
|
try:
|
||||||
|
# Use pysubs2 to parse and normalize the VTT
|
||||||
|
subs = pysubs2.SSAFile.from_string(vtt_raw)
|
||||||
|
# Convert back to WebVTT string for pycaption processing
|
||||||
|
normalized_vtt = subs.to_string("vtt")
|
||||||
|
vtt = WebVTTReaderExt().read(normalized_vtt)
|
||||||
|
except Exception:
|
||||||
|
# Fall back to direct pycaption parsing
|
||||||
|
vtt = WebVTTReaderExt().read(vtt_raw)
|
||||||
|
else:
|
||||||
|
# Use pycaption directly
|
||||||
|
vtt = WebVTTReaderExt().read(vtt_raw)
|
||||||
for lang in vtt.get_languages():
|
for lang in vtt.get_languages():
|
||||||
prev_caption = None
|
prev_caption = None
|
||||||
duplicate_index: list[int] = []
|
duplicate_index: list[int] = []
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ class Vaults:
|
|||||||
for vault in self.vaults:
|
for vault in self.vaults:
|
||||||
if not vault.no_push:
|
if not vault.no_push:
|
||||||
try:
|
try:
|
||||||
success += bool(vault.add_keys(self.service, kid_keys))
|
# Count each vault that successfully processes the keys (whether new or existing)
|
||||||
|
vault.add_keys(self.service, kid_keys)
|
||||||
|
success += 1
|
||||||
except (PermissionError, NotImplementedError):
|
except (PermissionError, NotImplementedError):
|
||||||
pass
|
pass
|
||||||
return success
|
return success
|
||||||
|
|||||||
@@ -0,0 +1,504 @@
|
|||||||
|
# API key for The Movie Database (TMDB)
|
||||||
|
tmdb_api_key: ""
|
||||||
|
|
||||||
|
# Client ID for SIMKL API (optional, improves metadata matching)
|
||||||
|
# Get your free client ID at: https://simkl.com/settings/developer/
|
||||||
|
simkl_client_id: ""
|
||||||
|
|
||||||
|
# Group or Username to postfix to the end of all download filenames following a dash
|
||||||
|
tag: user_tag
|
||||||
|
|
||||||
|
# Enable/disable tagging with group name (default: true)
|
||||||
|
tag_group_name: true
|
||||||
|
|
||||||
|
# Enable/disable tagging with IMDB/TMDB/TVDB details (default: true)
|
||||||
|
tag_imdb_tmdb: true
|
||||||
|
|
||||||
|
# Set terminal background color (custom option not in CONFIG.md)
|
||||||
|
set_terminal_bg: false
|
||||||
|
|
||||||
|
# Set file naming convention
|
||||||
|
# true for style - Prime.Suspect.S07E01.The.Final.Act.Part.One.1080p.ITV.WEB-DL.AAC2.0.H.264
|
||||||
|
# false for style - Prime Suspect S07E01 The Final Act - Part One
|
||||||
|
scene_naming: true
|
||||||
|
|
||||||
|
# Whether to include the year in series names for episodes and folders (default: true)
|
||||||
|
# true for style - Show Name (2023) S01E01 Episode Name
|
||||||
|
# false for style - Show Name S01E01 Episode Name
|
||||||
|
series_year: true
|
||||||
|
|
||||||
|
# Check for updates from GitHub repository on startup (default: true)
|
||||||
|
update_checks: true
|
||||||
|
|
||||||
|
# How often to check for updates, in hours (default: 24)
|
||||||
|
update_check_interval: 24
|
||||||
|
|
||||||
|
# Title caching configuration
|
||||||
|
# Cache title metadata to reduce redundant API calls
|
||||||
|
title_cache_enabled: true # Enable/disable title caching globally (default: true)
|
||||||
|
title_cache_time: 1800 # Cache duration in seconds (default: 1800 = 30 minutes)
|
||||||
|
title_cache_max_retention: 86400 # Maximum cache retention for fallback when API fails (default: 86400 = 24 hours)
|
||||||
|
|
||||||
|
# Debug logging configuration
|
||||||
|
# Comprehensive JSON-based debug logging for troubleshooting and service development
|
||||||
|
debug:
|
||||||
|
false # Enable structured JSON debug logging (default: false)
|
||||||
|
# When enabled with --debug flag or set to true:
|
||||||
|
# - Creates JSON Lines (.jsonl) log files with complete debugging context
|
||||||
|
# - Logs: session info, CLI params, service config, CDM details, authentication,
|
||||||
|
# titles, tracks metadata, DRM operations, vault queries, errors with stack traces
|
||||||
|
# - File location: logs/unshackle_debug_{service}_{timestamp}.jsonl
|
||||||
|
# - Also creates text log: logs/unshackle_root_{timestamp}.log
|
||||||
|
|
||||||
|
debug_keys:
|
||||||
|
false # Log decryption keys in debug logs (default: false)
|
||||||
|
# Set to true to include actual decryption keys in logs
|
||||||
|
# Useful for debugging key retrieval and decryption issues
|
||||||
|
# SECURITY NOTE: Passwords, tokens, cookies, and session tokens
|
||||||
|
# are ALWAYS redacted regardless of this setting
|
||||||
|
# Only affects: content_key, key fields (the actual CEKs)
|
||||||
|
# Never affects: kid, keys_count, key_id (metadata is always logged)
|
||||||
|
|
||||||
|
# Muxing configuration
|
||||||
|
muxing:
|
||||||
|
set_title: false
|
||||||
|
|
||||||
|
# Login credentials for each Service
|
||||||
|
credentials:
|
||||||
|
# Direct credentials (no profile support)
|
||||||
|
EXAMPLE: email@example.com:password
|
||||||
|
|
||||||
|
# Per-profile credentials with default fallback
|
||||||
|
SERVICE_NAME:
|
||||||
|
default: default@email.com:password # Used when no -p/--profile is specified
|
||||||
|
profile1: user1@email.com:password1
|
||||||
|
profile2: user2@email.com:password2
|
||||||
|
|
||||||
|
# Per-profile credentials without default (requires -p/--profile)
|
||||||
|
SERVICE_NAME2:
|
||||||
|
john: john@example.com:johnspassword
|
||||||
|
jane: jane@example.com:janespassword
|
||||||
|
|
||||||
|
# You can also use list format for passwords with special characters
|
||||||
|
SERVICE_NAME3:
|
||||||
|
default: ["user@email.com", ":PasswordWith:Colons"]
|
||||||
|
|
||||||
|
# Override default directories used across unshackle
|
||||||
|
directories:
|
||||||
|
cache: Cache
|
||||||
|
cookies: Cookies
|
||||||
|
dcsl: DCSL # Device Certificate Status List
|
||||||
|
downloads: Downloads
|
||||||
|
logs: Logs
|
||||||
|
temp: Temp
|
||||||
|
wvds: WVDs
|
||||||
|
prds: PRDs
|
||||||
|
# Additional directories that can be configured:
|
||||||
|
# commands: Commands
|
||||||
|
services:
|
||||||
|
- /path/to/services
|
||||||
|
- /other/path/to/services
|
||||||
|
# vaults: Vaults
|
||||||
|
# fonts: Fonts
|
||||||
|
|
||||||
|
# Pre-define which Widevine or PlayReady device to use for each Service
|
||||||
|
cdm:
|
||||||
|
# Global default CDM device (fallback for all services/profiles)
|
||||||
|
default: WVD_1
|
||||||
|
|
||||||
|
# Direct service-specific CDM
|
||||||
|
DIFFERENT_EXAMPLE: PRD_1
|
||||||
|
|
||||||
|
# Per-profile CDM configuration
|
||||||
|
EXAMPLE:
|
||||||
|
john_sd: chromecdm_903_l3 # Profile 'john_sd' uses Chrome CDM L3
|
||||||
|
jane_uhd: nexus_5_l1 # Profile 'jane_uhd' uses Nexus 5 L1
|
||||||
|
default: generic_android_l3 # Default CDM for this service
|
||||||
|
|
||||||
|
# NEW: Quality-based CDM selection
|
||||||
|
# Use different CDMs based on video resolution
|
||||||
|
# Supports operators: >=, >, <=, <, or exact match
|
||||||
|
EXAMPLE_QUALITY:
|
||||||
|
"<=1080": generic_android_l3 # Use L3 for 1080p and below
|
||||||
|
">1080": nexus_5_l1 # Use L1 for above 1080p (1440p, 2160p)
|
||||||
|
default: generic_android_l3 # Optional: fallback if no quality match
|
||||||
|
|
||||||
|
# You can mix profiles and quality thresholds in the same service
|
||||||
|
NETFLIX:
|
||||||
|
# Profile-based selection (existing functionality)
|
||||||
|
john: netflix_l3_profile
|
||||||
|
jane: netflix_l1_profile
|
||||||
|
# Quality-based selection (new functionality)
|
||||||
|
"<=720": netflix_mobile_l3
|
||||||
|
"1080": netflix_standard_l3
|
||||||
|
">=1440": netflix_premium_l1
|
||||||
|
# Fallback
|
||||||
|
default: netflix_standard_l3
|
||||||
|
|
||||||
|
# Use pywidevine Serve-compliant Remote CDMs
|
||||||
|
|
||||||
|
# Example: Custom CDM API Configuration
|
||||||
|
# This demonstrates the highly configurable custom_api type that can adapt to any CDM API format
|
||||||
|
# - name: "chrome"
|
||||||
|
# type: "custom_api"
|
||||||
|
# host: "http://remotecdm.test/"
|
||||||
|
# timeout: 30
|
||||||
|
# device:
|
||||||
|
# name: "ChromeCDM"
|
||||||
|
# type: "CHROME"
|
||||||
|
# system_id: 34312
|
||||||
|
# security_level: 3
|
||||||
|
# auth:
|
||||||
|
# type: "header"
|
||||||
|
# header_name: "x-api-key"
|
||||||
|
# key: "YOUR_API_KEY_HERE"
|
||||||
|
# custom_headers:
|
||||||
|
# User-Agent: "Unshackle/2.0.0"
|
||||||
|
# endpoints:
|
||||||
|
# get_request:
|
||||||
|
# path: "/get-challenge"
|
||||||
|
# method: "POST"
|
||||||
|
# timeout: 30
|
||||||
|
# decrypt_response:
|
||||||
|
# path: "/get-keys"
|
||||||
|
# method: "POST"
|
||||||
|
# timeout: 30
|
||||||
|
# request_mapping:
|
||||||
|
# get_request:
|
||||||
|
# param_names:
|
||||||
|
# scheme: "device"
|
||||||
|
# init_data: "init_data"
|
||||||
|
# static_params:
|
||||||
|
# scheme: "Widevine"
|
||||||
|
# decrypt_response:
|
||||||
|
# param_names:
|
||||||
|
# scheme: "device"
|
||||||
|
# license_request: "license_request"
|
||||||
|
# license_response: "license_response"
|
||||||
|
# static_params:
|
||||||
|
# scheme: "Widevine"
|
||||||
|
# response_mapping:
|
||||||
|
# get_request:
|
||||||
|
# fields:
|
||||||
|
# challenge: "challenge"
|
||||||
|
# session_id: "session_id"
|
||||||
|
# message: "message"
|
||||||
|
# message_type: "message_type"
|
||||||
|
# response_types:
|
||||||
|
# - condition: "message_type == 'license-request'"
|
||||||
|
# type: "license_request"
|
||||||
|
# success_conditions:
|
||||||
|
# - "message == 'success'"
|
||||||
|
# decrypt_response:
|
||||||
|
# fields:
|
||||||
|
# keys: "keys"
|
||||||
|
# message: "message"
|
||||||
|
# key_fields:
|
||||||
|
# kid: "kid"
|
||||||
|
# key: "key"
|
||||||
|
# type: "type"
|
||||||
|
# success_conditions:
|
||||||
|
# - "message == 'success'"
|
||||||
|
# caching:
|
||||||
|
# enabled: true
|
||||||
|
# use_vaults: true
|
||||||
|
# check_cached_first: true
|
||||||
|
|
||||||
|
remote_cdm:
|
||||||
|
- name: "chrome"
|
||||||
|
device_name: chrome
|
||||||
|
device_type: CHROME
|
||||||
|
system_id: 27175
|
||||||
|
security_level: 3
|
||||||
|
host: https://domain.com/api
|
||||||
|
secret: secret_key
|
||||||
|
- name: "chrome-2"
|
||||||
|
device_name: chrome
|
||||||
|
device_type: CHROME
|
||||||
|
system_id: 26830
|
||||||
|
security_level: 3
|
||||||
|
host: https://domain-2.com/api
|
||||||
|
secret: secret_key
|
||||||
|
|
||||||
|
- name: "decrypt_labs_chrome"
|
||||||
|
type: "decrypt_labs" # Required to identify as DecryptLabs CDM
|
||||||
|
device_name: "ChromeCDM" # Scheme identifier - must match exactly
|
||||||
|
device_type: CHROME
|
||||||
|
system_id: 4464 # Doesn't matter
|
||||||
|
security_level: 3
|
||||||
|
host: "https://keyxtractor.decryptlabs.com"
|
||||||
|
secret: "your_decrypt_labs_api_key_here" # Replace with your API key
|
||||||
|
- name: "decrypt_labs_l1"
|
||||||
|
type: "decrypt_labs"
|
||||||
|
device_name: "L1" # Scheme identifier - must match exactly
|
||||||
|
device_type: ANDROID
|
||||||
|
system_id: 4464
|
||||||
|
security_level: 1
|
||||||
|
host: "https://keyxtractor.decryptlabs.com"
|
||||||
|
secret: "your_decrypt_labs_api_key_here"
|
||||||
|
|
||||||
|
- name: "decrypt_labs_l2"
|
||||||
|
type: "decrypt_labs"
|
||||||
|
device_name: "L2" # Scheme identifier - must match exactly
|
||||||
|
device_type: ANDROID
|
||||||
|
system_id: 4464
|
||||||
|
security_level: 2
|
||||||
|
host: "https://keyxtractor.decryptlabs.com"
|
||||||
|
secret: "your_decrypt_labs_api_key_here"
|
||||||
|
|
||||||
|
- name: "decrypt_labs_playready_sl2"
|
||||||
|
type: "decrypt_labs"
|
||||||
|
device_name: "SL2" # Scheme identifier - must match exactly
|
||||||
|
device_type: PLAYREADY
|
||||||
|
system_id: 0
|
||||||
|
security_level: 2000
|
||||||
|
host: "https://keyxtractor.decryptlabs.com"
|
||||||
|
secret: "your_decrypt_labs_api_key_here"
|
||||||
|
|
||||||
|
- name: "decrypt_labs_playready_sl3"
|
||||||
|
type: "decrypt_labs"
|
||||||
|
device_name: "SL3" # Scheme identifier - must match exactly
|
||||||
|
device_type: PLAYREADY
|
||||||
|
system_id: 0
|
||||||
|
security_level: 3000
|
||||||
|
host: "https://keyxtractor.decryptlabs.com"
|
||||||
|
secret: "your_decrypt_labs_api_key_here"
|
||||||
|
|
||||||
|
# Key Vaults store your obtained Content Encryption Keys (CEKs)
|
||||||
|
# Use 'no_push: true' to prevent a vault from receiving pushed keys
|
||||||
|
# while still allowing it to provide keys when requested
|
||||||
|
key_vaults:
|
||||||
|
- type: SQLite
|
||||||
|
name: Local
|
||||||
|
path: key_store.db
|
||||||
|
# Additional vault types:
|
||||||
|
# - type: API
|
||||||
|
# name: "Remote Vault"
|
||||||
|
# uri: "https://key-vault.example.com"
|
||||||
|
# token: "secret_token"
|
||||||
|
# no_push: true # This vault will only provide keys, not receive them
|
||||||
|
# - type: MySQL
|
||||||
|
# name: "MySQL Vault"
|
||||||
|
# host: "127.0.0.1"
|
||||||
|
# port: 3306
|
||||||
|
# database: vault
|
||||||
|
# username: user
|
||||||
|
# password: pass
|
||||||
|
# no_push: false # Default behavior - vault both provides and receives keys
|
||||||
|
|
||||||
|
# Choose what software to use to download data
|
||||||
|
downloader: aria2c
|
||||||
|
# Options: requests | aria2c | curl_impersonate | n_m3u8dl_re
|
||||||
|
# Can also be a mapping:
|
||||||
|
# downloader:
|
||||||
|
# NF: requests
|
||||||
|
# AMZN: n_m3u8dl_re
|
||||||
|
# DSNP: n_m3u8dl_re
|
||||||
|
# default: requests
|
||||||
|
|
||||||
|
# aria2c downloader configuration
|
||||||
|
aria2c:
|
||||||
|
max_concurrent_downloads: 4
|
||||||
|
max_connection_per_server: 3
|
||||||
|
split: 5
|
||||||
|
file_allocation: falloc # none | prealloc | falloc | trunc
|
||||||
|
|
||||||
|
# N_m3u8DL-RE downloader configuration
|
||||||
|
n_m3u8dl_re:
|
||||||
|
thread_count: 16
|
||||||
|
ad_keyword: "advertisement"
|
||||||
|
use_proxy: true
|
||||||
|
|
||||||
|
# curl_impersonate downloader configuration
|
||||||
|
curl_impersonate:
|
||||||
|
browser: chrome120
|
||||||
|
|
||||||
|
# Pre-define default options and switches of the dl command
|
||||||
|
dl:
|
||||||
|
sub_format: srt
|
||||||
|
downloads: 4
|
||||||
|
workers: 16
|
||||||
|
lang:
|
||||||
|
- en
|
||||||
|
- fr
|
||||||
|
EXAMPLE:
|
||||||
|
bitrate: CBR
|
||||||
|
|
||||||
|
# Chapter Name to use when exporting a Chapter without a Name
|
||||||
|
chapter_fallback_name: "Chapter {j:02}"
|
||||||
|
|
||||||
|
# Case-Insensitive dictionary of headers for all Services
|
||||||
|
headers:
|
||||||
|
Accept-Language: "en-US,en;q=0.8"
|
||||||
|
User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
|
# Override default filenames used across unshackle
|
||||||
|
filenames:
|
||||||
|
debug_log: "unshackle_debug_{service}_{time}.jsonl" # JSON Lines debug log file
|
||||||
|
config: "config.yaml"
|
||||||
|
root_config: "envied.yaml"
|
||||||
|
chapters: "Chapters_{title}_{random}.txt"
|
||||||
|
subtitle: "Subtitle_{id}_{language}.srt"
|
||||||
|
|
||||||
|
# conversion_method:
|
||||||
|
# - auto (default): Smart routing - subby for WebVTT/SAMI, pycaption for others
|
||||||
|
# - subby: Always use subby with advanced processing
|
||||||
|
# - pycaption: Use only pycaption library (no SubtitleEdit, no subby)
|
||||||
|
# - subtitleedit: Prefer SubtitleEdit when available, fall back to pycaption
|
||||||
|
# - pysubs2: Use pysubs2 library (supports SRT/SSA/ASS/WebVTT/TTML/SAMI/MicroDVD/MPL2/TMP)
|
||||||
|
subtitle:
|
||||||
|
conversion_method: auto
|
||||||
|
# sdh_method: Method to use for SDH (hearing impaired) stripping
|
||||||
|
# - auto (default): Try subby (SRT only), then SubtitleEdit (if available), then subtitle-filter
|
||||||
|
# - subby: Use subby library (SRT only)
|
||||||
|
# - subtitleedit: Use SubtitleEdit tool (Windows only, falls back to subtitle-filter)
|
||||||
|
# - filter-subs: Use subtitle-filter library directly
|
||||||
|
sdh_method: auto
|
||||||
|
# strip_sdh: Automatically create stripped (non-SDH) versions of SDH subtitles
|
||||||
|
# Set to false to disable automatic SDH stripping entirely (default: true)
|
||||||
|
strip_sdh: true
|
||||||
|
# convert_before_strip: Auto-convert VTT/other formats to SRT before using subtitle-filter
|
||||||
|
# This ensures compatibility when subtitle-filter is used as fallback (default: true)
|
||||||
|
convert_before_strip: true
|
||||||
|
# preserve_formatting: Preserve original subtitle formatting (tags, positioning, styling)
|
||||||
|
# When true, skips pycaption processing for WebVTT files to keep tags like <i>, <b>, positioning intact
|
||||||
|
# Combined with no sub_format setting, ensures subtitles remain in their original format (default: true)
|
||||||
|
preserve_formatting: true
|
||||||
|
|
||||||
|
# Configuration for pywidevine's serve functionality
|
||||||
|
serve:
|
||||||
|
api_secret: "your-secret-key-here"
|
||||||
|
users:
|
||||||
|
secret_key_for_user:
|
||||||
|
devices:
|
||||||
|
- generic_nexus_4464_l3
|
||||||
|
username: user
|
||||||
|
# devices:
|
||||||
|
# - '/path/to/device.wvd'
|
||||||
|
|
||||||
|
# Configuration data for each Service
|
||||||
|
services:
|
||||||
|
# Service-specific configuration goes here
|
||||||
|
# Profile-specific configurations can be nested under service names
|
||||||
|
|
||||||
|
# You can override ANY global configuration option on a per-service basis
|
||||||
|
# This allows fine-tuned control for services with special requirements
|
||||||
|
# Supported overrides: dl, aria2c, n_m3u8dl_re, curl_impersonate, subtitle, muxing, headers, etc.
|
||||||
|
|
||||||
|
# Example: Comprehensive service configuration showing all features
|
||||||
|
EXAMPLE:
|
||||||
|
# Standard service config
|
||||||
|
api_key: "service_api_key"
|
||||||
|
|
||||||
|
# Service certificate for Widevine L1/L2 (base64 encoded)
|
||||||
|
# This certificate is automatically used when L1/L2 schemes are selected
|
||||||
|
# Services obtain this from their DRM provider or license server
|
||||||
|
certificate: |
|
||||||
|
CAUSwwUKvQIIAxIQ5US6QAvBDzfTtjb4tU/7QxiH8c+TBSKOAjCCAQoCggEBAObzvlu2hZRsapAPx4Aa4GUZj4/GjxgXUtBH4THSkM40x63wQeyVxlEEo
|
||||||
|
# ... (full base64 certificate here)
|
||||||
|
|
||||||
|
# Profile-specific device configurations
|
||||||
|
profiles:
|
||||||
|
john_sd:
|
||||||
|
device:
|
||||||
|
app_name: "AIV"
|
||||||
|
device_model: "SHIELD Android TV"
|
||||||
|
jane_uhd:
|
||||||
|
device:
|
||||||
|
app_name: "AIV"
|
||||||
|
device_model: "Fire TV Stick 4K"
|
||||||
|
|
||||||
|
# NEW: Configuration overrides (can be combined with profiles and certificates)
|
||||||
|
# Override dl command defaults for this service
|
||||||
|
dl:
|
||||||
|
downloads: 4 # Limit concurrent track downloads (global default: 6)
|
||||||
|
workers: 8 # Reduce workers per track (global default: 16)
|
||||||
|
lang: ["en", "es-419"] # Different language priority for this service
|
||||||
|
sub_format: srt # Force SRT subtitle format
|
||||||
|
|
||||||
|
# Override n_m3u8dl_re downloader settings
|
||||||
|
n_m3u8dl_re:
|
||||||
|
thread_count: 8 # Lower thread count for rate-limited service (global default: 16)
|
||||||
|
use_proxy: true # Force proxy usage for this service
|
||||||
|
retry_count: 10 # More retries for unstable connections
|
||||||
|
ad_keyword: "advertisement" # Service-specific ad filtering
|
||||||
|
|
||||||
|
# Override aria2c downloader settings
|
||||||
|
aria2c:
|
||||||
|
max_concurrent_downloads: 2 # Limit concurrent downloads (global default: 4)
|
||||||
|
max_connection_per_server: 1 # Single connection per server
|
||||||
|
split: 3 # Fewer splits (global default: 5)
|
||||||
|
file_allocation: none # Faster allocation for this service
|
||||||
|
|
||||||
|
# Override subtitle processing for this service
|
||||||
|
subtitle:
|
||||||
|
conversion_method: pycaption # Use specific subtitle converter
|
||||||
|
sdh_method: auto
|
||||||
|
|
||||||
|
# Service-specific headers
|
||||||
|
headers:
|
||||||
|
User-Agent: "Service-specific user agent string"
|
||||||
|
Accept-Language: "en-US,en;q=0.9"
|
||||||
|
|
||||||
|
# Override muxing options
|
||||||
|
muxing:
|
||||||
|
set_title: true
|
||||||
|
|
||||||
|
# Example: Service with different regions per profile
|
||||||
|
SERVICE_NAME:
|
||||||
|
profiles:
|
||||||
|
us_account:
|
||||||
|
region: "US"
|
||||||
|
api_endpoint: "https://api.us.service.com"
|
||||||
|
uk_account:
|
||||||
|
region: "GB"
|
||||||
|
api_endpoint: "https://api.uk.service.com"
|
||||||
|
|
||||||
|
# Example: Rate-limited service
|
||||||
|
RATE_LIMITED_SERVICE:
|
||||||
|
dl:
|
||||||
|
downloads: 2 # Limit concurrent downloads
|
||||||
|
workers: 4 # Reduce workers to avoid rate limits
|
||||||
|
n_m3u8dl_re:
|
||||||
|
thread_count: 4 # Very low thread count
|
||||||
|
retry_count: 20 # More retries for flaky service
|
||||||
|
aria2c:
|
||||||
|
max_concurrent_downloads: 1 # Download tracks one at a time
|
||||||
|
max_connection_per_server: 1 # Single connection only
|
||||||
|
|
||||||
|
# Notes on service-specific overrides:
|
||||||
|
# - Overrides are merged with global config, not replaced
|
||||||
|
# - Only specified keys are overridden, others use global defaults
|
||||||
|
# - Reserved keys (profiles, api_key, certificate, etc.) are NOT treated as overrides
|
||||||
|
# - Any dict-type config option can be overridden (dl, aria2c, n_m3u8dl_re, subtitle, etc.)
|
||||||
|
# - CLI arguments always take priority over service-specific config
|
||||||
|
|
||||||
|
# External proxy provider services
|
||||||
|
proxy_providers:
|
||||||
|
nordvpn:
|
||||||
|
username: username_from_service_credentials
|
||||||
|
password: password_from_service_credentials
|
||||||
|
server_map:
|
||||||
|
us: 12 # force US server #12 for US proxies
|
||||||
|
surfsharkvpn:
|
||||||
|
username: your_surfshark_service_username # Service credentials from https://my.surfshark.com/vpn/manual-setup/main/openvpn
|
||||||
|
password: your_surfshark_service_password # Service credentials (not your login password)
|
||||||
|
server_map:
|
||||||
|
us: 3844 # force US server #3844 for US proxies
|
||||||
|
gb: 2697 # force GB server #2697 for GB proxies
|
||||||
|
au: 4621 # force AU server #4621 for AU proxies
|
||||||
|
windscribevpn:
|
||||||
|
username: your_windscribe_username # Service credentials from https://windscribe.com/getconfig/openvpn
|
||||||
|
password: your_windscribe_password # Service credentials (not your login password)
|
||||||
|
server_map:
|
||||||
|
us: "us-central-096.totallyacdn.com" # force US server
|
||||||
|
gb: "uk-london-055.totallyacdn.com" # force GB server
|
||||||
|
basic:
|
||||||
|
GB:
|
||||||
|
- "socks5://username:password@bhx.socks.ipvanish.com:1080" # 1 (Birmingham)
|
||||||
|
- "socks5://username:password@gla.socks.ipvanish.com:1080" # 2 (Glasgow)
|
||||||
|
AU:
|
||||||
|
- "socks5://username:password@syd.socks.ipvanish.com:1080" # 1 (Sydney)
|
||||||
|
- "https://username:password@au-syd.prod.surfshark.com" # 2 (Sydney)
|
||||||
|
- "https://username:password@au-bne.prod.surfshark.com" # 3 (Brisbane)
|
||||||
|
BG: "https://username:password@bg-sof.prod.surfshark.com"
|
||||||
@@ -14,9 +14,6 @@ decryption: shakapackager
|
|||||||
scene_naming: false
|
scene_naming: false
|
||||||
series_year: false
|
series_year: false
|
||||||
|
|
||||||
# Widevine pssh display; fold, crop or ellipsis
|
|
||||||
pssh_display: ellipsis
|
|
||||||
|
|
||||||
# Login credentials for each Service
|
# Login credentials for each Service
|
||||||
credentials:
|
credentials:
|
||||||
ALL4: email:password
|
ALL4: email:password
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from collections.abc import Generator
|
||||||
|
from datetime import datetime
|
||||||
|
from http.cookiejar import CookieJar
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
import click
|
||||||
|
from langcodes import Language
|
||||||
|
|
||||||
|
from envied.core.constants import AnyTrack
|
||||||
|
from envied.core.credential import Credential
|
||||||
|
from envied.core.manifests import DASH
|
||||||
|
from envied.core.search_result import SearchResult
|
||||||
|
from envied.core.service import Service
|
||||||
|
from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
|
||||||
|
from envied.core.tracks import Chapter, Subtitle, Tracks, Video
|
||||||
|
|
||||||
|
|
||||||
|
class EXAMPLE(Service):
|
||||||
|
"""
|
||||||
|
Service code for domain.com
|
||||||
|
Version: 1.0.0
|
||||||
|
|
||||||
|
Authorization: Cookies
|
||||||
|
|
||||||
|
Security: FHD@L3
|
||||||
|
|
||||||
|
Use full URL (for example - https://domain.com/details/20914) or title ID (for example - 20914).
|
||||||
|
"""
|
||||||
|
|
||||||
|
TITLE_RE = r"^(?:https?://?domain\.com/details/)?(?P<title_id>[^/]+)"
|
||||||
|
GEOFENCE = ("US", "UK")
|
||||||
|
NO_SUBTITLES = True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@click.command(name="EXAMPLE", short_help="https://domain.com")
|
||||||
|
@click.argument("title", type=str)
|
||||||
|
@click.option("-m", "--movie", is_flag=True, default=False, help="Specify if it's a movie")
|
||||||
|
@click.option("-d", "--device", type=str, default="android_tv", help="Select device from the config file")
|
||||||
|
@click.pass_context
|
||||||
|
def cli(ctx, **kwargs):
|
||||||
|
return EXAMPLE(ctx, **kwargs)
|
||||||
|
|
||||||
|
def __init__(self, ctx, title, movie, device):
|
||||||
|
super().__init__(ctx)
|
||||||
|
|
||||||
|
self.title = title
|
||||||
|
self.movie = movie
|
||||||
|
self.device = device
|
||||||
|
self.cdm = ctx.obj.cdm
|
||||||
|
|
||||||
|
# Get range parameter for HDR support
|
||||||
|
range_param = ctx.parent.params.get("range_")
|
||||||
|
self.range = range_param[0].name if range_param else "SDR"
|
||||||
|
|
||||||
|
if self.config is None:
|
||||||
|
raise Exception("Config is missing!")
|
||||||
|
else:
|
||||||
|
profile_name = ctx.parent.params.get("profile")
|
||||||
|
if profile_name is None:
|
||||||
|
profile_name = "default"
|
||||||
|
self.profile = profile_name
|
||||||
|
|
||||||
|
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
||||||
|
super().authenticate(cookies, credential)
|
||||||
|
if not cookies:
|
||||||
|
raise EnvironmentError("Service requires Cookies for Authentication.")
|
||||||
|
|
||||||
|
jwt_token = next((cookie.value for cookie in cookies if cookie.name == "streamco_token"), None)
|
||||||
|
payload = json.loads(base64.urlsafe_b64decode(jwt_token.split(".")[1] + "==").decode("utf-8"))
|
||||||
|
profile_id = payload.get("profileId", None)
|
||||||
|
self.session.headers.update({"user-agent": self.config["client"][self.device]["user_agent"]})
|
||||||
|
|
||||||
|
cache = self.cache.get(f"tokens_{self.device}_{self.profile}")
|
||||||
|
|
||||||
|
if cache:
|
||||||
|
if cache.data["expires_in"] > int(datetime.now().timestamp()):
|
||||||
|
self.log.info("Using cached tokens")
|
||||||
|
else:
|
||||||
|
self.log.info("Refreshing tokens")
|
||||||
|
|
||||||
|
refresh = self.session.post(
|
||||||
|
url=self.config["endpoints"]["refresh"], data={"refresh_token": cache.data["refresh_data"]}
|
||||||
|
).json()
|
||||||
|
|
||||||
|
cache.set(data=refresh)
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.log.info("Retrieving new tokens")
|
||||||
|
|
||||||
|
token = self.session.post(
|
||||||
|
url=self.config["endpoints"]["login"],
|
||||||
|
data={
|
||||||
|
"token": jwt_token,
|
||||||
|
"profileId": profile_id,
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
cache.set(data=token)
|
||||||
|
|
||||||
|
self.token = cache.data["token"]
|
||||||
|
self.user_id = cache.data["userId"]
|
||||||
|
|
||||||
|
def search(self) -> Generator[SearchResult, None, None]:
|
||||||
|
search = self.session.get(
|
||||||
|
url=self.config["endpoints"]["search"], params={"q": self.title, "token": self.token}
|
||||||
|
).json()
|
||||||
|
|
||||||
|
for result in search["entries"]:
|
||||||
|
yield SearchResult(
|
||||||
|
id_=result["id"],
|
||||||
|
title=result["title"],
|
||||||
|
label="SERIES" if result["programType"] == "series" else "MOVIE",
|
||||||
|
url=result["url"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_titles(self) -> Titles_T:
|
||||||
|
self.title = re.match(self.TITLE_RE, self.title).group(1)
|
||||||
|
|
||||||
|
metadata = self.session.get(
|
||||||
|
url=self.config["endpoints"]["metadata"].format(title_id=self.title), params={"token": self.token}
|
||||||
|
).json()
|
||||||
|
|
||||||
|
if metadata["programType"] == "movie":
|
||||||
|
self.movie = True
|
||||||
|
|
||||||
|
if self.movie:
|
||||||
|
return Movies(
|
||||||
|
[
|
||||||
|
Movie(
|
||||||
|
id_=metadata["id"],
|
||||||
|
service=self.__class__,
|
||||||
|
name=metadata["title"],
|
||||||
|
description=metadata["description"],
|
||||||
|
year=metadata["releaseYear"] if metadata["releaseYear"] > 0 else None,
|
||||||
|
language=Language.find(metadata["languages"][0]),
|
||||||
|
data=metadata,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
episodes = []
|
||||||
|
|
||||||
|
for season in metadata["seasons"]:
|
||||||
|
if "Trailers" not in season["title"]:
|
||||||
|
season_data = self.session.get(url=season["url"], params={"token": self.token}).json()
|
||||||
|
|
||||||
|
for episode in season_data["entries"]:
|
||||||
|
episodes.append(
|
||||||
|
Episode(
|
||||||
|
id_=episode["id"],
|
||||||
|
service=self.__class__,
|
||||||
|
title=metadata["title"],
|
||||||
|
season=episode["season"],
|
||||||
|
number=episode["episode"],
|
||||||
|
name=episode["title"],
|
||||||
|
description=episode["description"],
|
||||||
|
year=metadata["releaseYear"] if metadata["releaseYear"] > 0 else None,
|
||||||
|
language=Language.find(metadata["languages"][0]),
|
||||||
|
data=episode,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return Series(episodes)
|
||||||
|
|
||||||
|
def get_tracks(self, title: Title_T) -> Tracks:
|
||||||
|
# Handle HYBRID mode by fetching both HDR10 and DV tracks separately
|
||||||
|
if self.range == "HYBRID" and self.cdm.security_level != 3:
|
||||||
|
tracks = Tracks()
|
||||||
|
|
||||||
|
# Get HDR10 tracks
|
||||||
|
hdr10_tracks = self._get_tracks_for_range(title, "HDR10")
|
||||||
|
tracks.add(hdr10_tracks, warn_only=True)
|
||||||
|
|
||||||
|
# Get DV tracks
|
||||||
|
dv_tracks = self._get_tracks_for_range(title, "DV")
|
||||||
|
tracks.add(dv_tracks, warn_only=True)
|
||||||
|
|
||||||
|
return tracks
|
||||||
|
else:
|
||||||
|
# Normal single-range behavior
|
||||||
|
return self._get_tracks_for_range(title, self.range)
|
||||||
|
|
||||||
|
def _get_tracks_for_range(self, title: Title_T, range_override: str = None) -> Tracks:
|
||||||
|
# Use range_override if provided, otherwise use self.range
|
||||||
|
current_range = range_override if range_override else self.range
|
||||||
|
|
||||||
|
# Build API request parameters
|
||||||
|
params = {
|
||||||
|
"token": self.token,
|
||||||
|
"guid": title.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"type": self.config["client"][self.device]["type"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add range-specific parameters
|
||||||
|
if current_range == "HDR10":
|
||||||
|
data["video_format"] = "hdr10"
|
||||||
|
elif current_range == "DV":
|
||||||
|
data["video_format"] = "dolby_vision"
|
||||||
|
else:
|
||||||
|
data["video_format"] = "sdr"
|
||||||
|
|
||||||
|
# Only request high-quality HDR content with L1 CDM
|
||||||
|
if current_range in ("HDR10", "DV") and self.cdm.security_level == 3:
|
||||||
|
# L3 CDM - skip HDR content
|
||||||
|
return Tracks()
|
||||||
|
|
||||||
|
streams = self.session.post(
|
||||||
|
url=self.config["endpoints"]["streams"],
|
||||||
|
params=params,
|
||||||
|
data=data,
|
||||||
|
).json()["media"]
|
||||||
|
|
||||||
|
self.license = {
|
||||||
|
"url": streams["drm"]["url"],
|
||||||
|
"data": streams["drm"]["data"],
|
||||||
|
"session": streams["drm"]["session"],
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest_url = streams["url"].split("?")[0]
|
||||||
|
|
||||||
|
self.log.debug(f"Manifest URL: {manifest_url}")
|
||||||
|
tracks = DASH.from_url(url=manifest_url, session=self.session).to_tracks(language=title.language)
|
||||||
|
|
||||||
|
# Set range attributes on video tracks
|
||||||
|
for video in tracks.videos:
|
||||||
|
if current_range == "HDR10":
|
||||||
|
video.range = Video.Range.HDR10
|
||||||
|
elif current_range == "DV":
|
||||||
|
video.range = Video.Range.DV
|
||||||
|
else:
|
||||||
|
video.range = Video.Range.SDR
|
||||||
|
|
||||||
|
# Remove DRM-free ("clear") audio tracks
|
||||||
|
tracks.audio = [
|
||||||
|
track for track in tracks.audio if "clear" not in track.data["dash"]["representation"].get("id")
|
||||||
|
]
|
||||||
|
|
||||||
|
for track in tracks.audio:
|
||||||
|
if track.channels == 6.0:
|
||||||
|
track.channels = 5.1
|
||||||
|
track_label = track.data["dash"]["adaptation_set"].get("label")
|
||||||
|
if track_label and "Audio Description" in track_label:
|
||||||
|
track.descriptive = True
|
||||||
|
|
||||||
|
tracks.subtitles.clear()
|
||||||
|
if streams.get("captions"):
|
||||||
|
for subtitle in streams["captions"]:
|
||||||
|
tracks.add(
|
||||||
|
Subtitle(
|
||||||
|
id_=hashlib.md5(subtitle["url"].encode()).hexdigest()[0:6],
|
||||||
|
url=subtitle["url"],
|
||||||
|
codec=Subtitle.Codec.from_mime("vtt"),
|
||||||
|
language=Language.get(subtitle["language"]),
|
||||||
|
# cc=True if '(cc)' in subtitle['name'] else False,
|
||||||
|
sdh=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self.movie:
|
||||||
|
title.data["chapters"] = self.session.get(
|
||||||
|
url=self.config["endpoints"]["metadata"].format(title_id=title.id), params={"token": self.token}
|
||||||
|
).json()["chapters"]
|
||||||
|
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
def get_chapters(self, title: Title_T) -> list[Chapter]:
|
||||||
|
chapters = []
|
||||||
|
|
||||||
|
if title.data.get("chapters", []):
|
||||||
|
for chapter in title.data["chapters"]:
|
||||||
|
if chapter["name"] == "Intro":
|
||||||
|
chapters.append(Chapter(timestamp=chapter["start"], name="Opening"))
|
||||||
|
chapters.append(Chapter(timestamp=chapter["end"]))
|
||||||
|
if chapter["name"] == "Credits":
|
||||||
|
chapters.append(Chapter(timestamp=chapter["start"], name="Credits"))
|
||||||
|
|
||||||
|
return chapters
|
||||||
|
|
||||||
|
def get_widevine_service_certificate(self, **_: any) -> str:
|
||||||
|
"""Return the Widevine service certificate from config, if available."""
|
||||||
|
return self.config.get("certificate")
|
||||||
|
|
||||||
|
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]:
|
||||||
|
"""Retrieve a PlayReady license for a given track."""
|
||||||
|
|
||||||
|
license_url = self.config["endpoints"].get("playready_license")
|
||||||
|
if not license_url:
|
||||||
|
raise ValueError("PlayReady license endpoint not configured")
|
||||||
|
|
||||||
|
response = self.session.post(
|
||||||
|
url=license_url,
|
||||||
|
data=challenge,
|
||||||
|
headers={
|
||||||
|
"user-agent": self.config["client"][self.device]["license_user_agent"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
|
||||||
|
license_url = self.license.get("url") or self.config["endpoints"].get("widevine_license")
|
||||||
|
if not license_url:
|
||||||
|
raise ValueError("Widevine license endpoint not configured")
|
||||||
|
|
||||||
|
response = self.session.post(
|
||||||
|
url=license_url,
|
||||||
|
data=challenge,
|
||||||
|
params={
|
||||||
|
"session": self.license.get("session"),
|
||||||
|
"userId": self.user_id,
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"dt-custom-data": self.license.get("data"),
|
||||||
|
"user-agent": self.config["client"][self.device]["license_user_agent"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
try:
|
||||||
|
return response.json().get("license")
|
||||||
|
except ValueError:
|
||||||
|
return response.content
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
endpoints:
|
||||||
|
login: https://api.domain.com/v1/login
|
||||||
|
metadata: https://api.domain.com/v1/metadata/{title_id}.json
|
||||||
|
streams: https://api.domain.com/v1/streams
|
||||||
|
playready_license: https://api.domain.com/v1/license/playready
|
||||||
|
widevine_license: https://api.domain.com/v1/license/widevine
|
||||||
|
|
||||||
|
client:
|
||||||
|
android_tv:
|
||||||
|
user_agent: USER_AGENT
|
||||||
|
license_user_agent: LICENSE_USER_AGENT
|
||||||
|
type: DATA
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
A collection of non-premium services for Unshackle.
|
A collection of non-premium services for envied.
|
||||||
|
|
||||||
## Usage:
|
## Usage:
|
||||||
Clone repository:
|
Clone repository:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class API(Vault):
|
|||||||
super().__init__(name, no_push)
|
super().__init__(name, no_push)
|
||||||
self.uri = uri.rstrip("/")
|
self.uri = uri.rstrip("/")
|
||||||
self.session = Session()
|
self.session = Session()
|
||||||
self.session.headers.update({"User-Agent": f"envied v{__version__}"})
|
self.session.headers.update({"User-Agent": f"unshackle v{__version__}"})
|
||||||
self.session.headers.update({"Authorization": f"Bearer {token}"})
|
self.session.headers.update({"Authorization": f"Bearer {token}"})
|
||||||
|
|
||||||
def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]:
|
def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]:
|
||||||
|
|||||||
@@ -16,13 +16,21 @@ class InsertResult(Enum):
|
|||||||
|
|
||||||
|
|
||||||
class HTTP(Vault):
|
class HTTP(Vault):
|
||||||
"""Key Vault using HTTP API with support for both query parameters and JSON payloads."""
|
"""
|
||||||
|
Key Vault using HTTP API with support for multiple API modes.
|
||||||
|
|
||||||
|
Supported modes:
|
||||||
|
- query: Uses GET requests with query parameters
|
||||||
|
- json: Uses POST requests with JSON payloads
|
||||||
|
- decrypt_labs: Uses DecryptLabs API format (read-only)
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
host: str,
|
host: str,
|
||||||
password: str,
|
password: Optional[str] = None,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
username: Optional[str] = None,
|
username: Optional[str] = None,
|
||||||
api_mode: str = "query",
|
api_mode: str = "query",
|
||||||
no_push: bool = False,
|
no_push: bool = False,
|
||||||
@@ -34,25 +42,33 @@ class HTTP(Vault):
|
|||||||
name: Vault name
|
name: Vault name
|
||||||
host: Host URL
|
host: Host URL
|
||||||
password: Password for query mode or API token for json mode
|
password: Password for query mode or API token for json mode
|
||||||
username: Username (required for query mode, ignored for json mode)
|
api_key: API key (alternative to password, used for decrypt_labs mode)
|
||||||
api_mode: "query" for query parameters or "json" for JSON API
|
username: Username (required for query mode, ignored for json/decrypt_labs mode)
|
||||||
|
api_mode: "query" for query parameters, "json" for JSON API, or "decrypt_labs" for DecryptLabs API
|
||||||
no_push: If True, this vault will not receive pushed keys
|
no_push: If True, this vault will not receive pushed keys
|
||||||
"""
|
"""
|
||||||
super().__init__(name, no_push)
|
super().__init__(name, no_push)
|
||||||
self.url = host
|
self.url = host
|
||||||
self.password = password
|
self.password = api_key or password
|
||||||
|
if not self.password:
|
||||||
|
raise ValueError("Either password or api_key is required")
|
||||||
|
|
||||||
self.username = username
|
self.username = username
|
||||||
self.api_mode = api_mode.lower()
|
self.api_mode = api_mode.lower()
|
||||||
self.current_title = None
|
self.current_title = None
|
||||||
self.session = Session()
|
self.session = Session()
|
||||||
self.session.headers.update({"User-Agent": f"envied v{__version__}"})
|
self.session.headers.update({"User-Agent": f"unshackle v{__version__}"})
|
||||||
self.api_session_id = None
|
self.api_session_id = None
|
||||||
|
|
||||||
|
if self.api_mode == "decrypt_labs":
|
||||||
|
self.session.headers.update({"decrypt-labs-api-key": self.password})
|
||||||
|
self.no_push = True
|
||||||
|
|
||||||
# Validate configuration based on mode
|
# Validate configuration based on mode
|
||||||
if self.api_mode == "query" and not self.username:
|
if self.api_mode == "query" and not self.username:
|
||||||
raise ValueError("Username is required for query mode")
|
raise ValueError("Username is required for query mode")
|
||||||
elif self.api_mode not in ["query", "json"]:
|
elif self.api_mode not in ["query", "json", "decrypt_labs"]:
|
||||||
raise ValueError("api_mode must be either 'query' or 'json'")
|
raise ValueError("api_mode must be either 'query', 'json', or 'decrypt_labs'")
|
||||||
|
|
||||||
def request(self, method: str, params: dict = None) -> dict:
|
def request(self, method: str, params: dict = None) -> dict:
|
||||||
"""Make a request to the JSON API vault."""
|
"""Make a request to the JSON API vault."""
|
||||||
@@ -95,7 +111,51 @@ class HTTP(Vault):
|
|||||||
if isinstance(kid, UUID):
|
if isinstance(kid, UUID):
|
||||||
kid = kid.hex
|
kid = kid.hex
|
||||||
|
|
||||||
if self.api_mode == "json":
|
if self.api_mode == "decrypt_labs":
|
||||||
|
try:
|
||||||
|
request_payload = {"service": service.lower(), "kid": kid}
|
||||||
|
|
||||||
|
response = self.session.post(self.url, json=request_payload)
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("message") != "success":
|
||||||
|
return None
|
||||||
|
|
||||||
|
cached_keys = data.get("cached_keys")
|
||||||
|
if not cached_keys:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(cached_keys, str):
|
||||||
|
try:
|
||||||
|
cached_keys = json.loads(cached_keys)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return cached_keys
|
||||||
|
|
||||||
|
if isinstance(cached_keys, dict):
|
||||||
|
if cached_keys.get("kid") == kid:
|
||||||
|
return cached_keys.get("key")
|
||||||
|
if kid in cached_keys:
|
||||||
|
return cached_keys[kid]
|
||||||
|
elif isinstance(cached_keys, list):
|
||||||
|
for entry in cached_keys:
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
if entry.get("kid") == kid:
|
||||||
|
return entry.get("key")
|
||||||
|
elif isinstance(entry, str) and ":" in entry:
|
||||||
|
entry_kid, entry_key = entry.split(":", 1)
|
||||||
|
if entry_kid == kid:
|
||||||
|
return entry_key
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to get key from DecryptLabs ({e.__class__.__name__}: {e})")
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
elif self.api_mode == "json":
|
||||||
try:
|
try:
|
||||||
params = {
|
params = {
|
||||||
"kid": kid,
|
"kid": kid,
|
||||||
@@ -132,7 +192,9 @@ class HTTP(Vault):
|
|||||||
return data["keys"][0]["key"]
|
return data["keys"][0]["key"]
|
||||||
|
|
||||||
def get_keys(self, service: str) -> Iterator[tuple[str, str]]:
|
def get_keys(self, service: str) -> Iterator[tuple[str, str]]:
|
||||||
if self.api_mode == "json":
|
if self.api_mode == "decrypt_labs":
|
||||||
|
return iter([])
|
||||||
|
elif self.api_mode == "json":
|
||||||
# JSON API doesn't support getting all keys, so return empty iterator
|
# JSON API doesn't support getting all keys, so return empty iterator
|
||||||
# This will cause the copy command to rely on the API's internal duplicate handling
|
# This will cause the copy command to rely on the API's internal duplicate handling
|
||||||
return iter([])
|
return iter([])
|
||||||
@@ -153,6 +215,9 @@ class HTTP(Vault):
|
|||||||
if not key or key.count("0") == len(key):
|
if not key or key.count("0") == len(key):
|
||||||
raise ValueError("You cannot add a NULL Content Key to a Vault.")
|
raise ValueError("You cannot add a NULL Content Key to a Vault.")
|
||||||
|
|
||||||
|
if self.api_mode == "decrypt_labs":
|
||||||
|
return False
|
||||||
|
|
||||||
if isinstance(kid, UUID):
|
if isinstance(kid, UUID):
|
||||||
kid = kid.hex
|
kid = kid.hex
|
||||||
|
|
||||||
@@ -192,6 +257,9 @@ class HTTP(Vault):
|
|||||||
return data.get("status_code") == 200
|
return data.get("status_code") == 200
|
||||||
|
|
||||||
def add_keys(self, service: str, kid_keys: dict[Union[UUID, str], str]) -> int:
|
def add_keys(self, service: str, kid_keys: dict[Union[UUID, str], str]) -> int:
|
||||||
|
if self.api_mode == "decrypt_labs":
|
||||||
|
return 0
|
||||||
|
|
||||||
for kid, key in kid_keys.items():
|
for kid, key in kid_keys.items():
|
||||||
if not key or key.count("0") == len(key):
|
if not key or key.count("0") == len(key):
|
||||||
raise ValueError("You cannot add a NULL Content Key to a Vault.")
|
raise ValueError("You cannot add a NULL Content Key to a Vault.")
|
||||||
@@ -243,7 +311,9 @@ class HTTP(Vault):
|
|||||||
return inserted_count
|
return inserted_count
|
||||||
|
|
||||||
def get_services(self) -> Iterator[str]:
|
def get_services(self) -> Iterator[str]:
|
||||||
if self.api_mode == "json":
|
if self.api_mode == "decrypt_labs":
|
||||||
|
return iter([])
|
||||||
|
elif self.api_mode == "json":
|
||||||
try:
|
try:
|
||||||
response = self.request("GetServices")
|
response = self.request("GetServices")
|
||||||
services = response.get("services", [])
|
services = response.get("services", [])
|
||||||
@@ -283,6 +353,9 @@ class HTTP(Vault):
|
|||||||
if not key or key.count("0") == len(key):
|
if not key or key.count("0") == len(key):
|
||||||
raise ValueError("You cannot add a NULL Content Key to a Vault.")
|
raise ValueError("You cannot add a NULL Content Key to a Vault.")
|
||||||
|
|
||||||
|
if self.api_mode == "decrypt_labs":
|
||||||
|
return InsertResult.FAILURE
|
||||||
|
|
||||||
if isinstance(kid, UUID):
|
if isinstance(kid, UUID):
|
||||||
kid = kid.hex
|
kid = kid.hex
|
||||||
|
|
||||||
|
|||||||
@@ -28,26 +28,33 @@ class MySQL(Vault):
|
|||||||
raise PermissionError(f"MySQL vault {self.slug} has no SELECT permission.")
|
raise PermissionError(f"MySQL vault {self.slug} has no SELECT permission.")
|
||||||
|
|
||||||
def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]:
|
def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]:
|
||||||
if not self.has_table(service):
|
|
||||||
# no table, no key, simple
|
|
||||||
return None
|
|
||||||
|
|
||||||
if isinstance(kid, UUID):
|
if isinstance(kid, UUID):
|
||||||
kid = kid.hex
|
kid = kid.hex
|
||||||
|
|
||||||
|
service_variants = [service]
|
||||||
|
if service != service.lower():
|
||||||
|
service_variants.append(service.lower())
|
||||||
|
if service != service.upper():
|
||||||
|
service_variants.append(service.upper())
|
||||||
|
|
||||||
conn = self.conn_factory.get()
|
conn = self.conn_factory.get()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cursor.execute(
|
for service_name in service_variants:
|
||||||
# TODO: SQL injection risk
|
if not self.has_table(service_name):
|
||||||
f"SELECT `id`, `key_` FROM `{service}` WHERE `kid`=%s AND `key_`!=%s",
|
continue
|
||||||
(kid, "0" * 32),
|
|
||||||
)
|
cursor.execute(
|
||||||
cek = cursor.fetchone()
|
# TODO: SQL injection risk
|
||||||
if not cek:
|
f"SELECT `id`, `key_` FROM `{service_name}` WHERE `kid`=%s AND `key_`!=%s",
|
||||||
return None
|
(kid, "0" * 32),
|
||||||
return cek["key_"]
|
)
|
||||||
|
cek = cursor.fetchone()
|
||||||
|
if cek:
|
||||||
|
return cek["key_"]
|
||||||
|
|
||||||
|
return None
|
||||||
finally:
|
finally:
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
||||||
@@ -131,16 +138,27 @@ class MySQL(Vault):
|
|||||||
if any(isinstance(kid, UUID) for kid, key_ in kid_keys.items()):
|
if any(isinstance(kid, UUID) for kid, key_ in kid_keys.items()):
|
||||||
kid_keys = {kid.hex if isinstance(kid, UUID) else kid: key_ for kid, key_ in kid_keys.items()}
|
kid_keys = {kid.hex if isinstance(kid, UUID) else kid: key_ for kid, key_ in kid_keys.items()}
|
||||||
|
|
||||||
|
if not kid_keys:
|
||||||
|
return 0
|
||||||
|
|
||||||
conn = self.conn_factory.get()
|
conn = self.conn_factory.get()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
placeholders = ",".join(["%s"] * len(kid_keys))
|
||||||
|
cursor.execute(f"SELECT kid FROM `{service}` WHERE kid IN ({placeholders})", list(kid_keys.keys()))
|
||||||
|
existing_kids = {row["kid"] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
new_keys = {kid: key for kid, key in kid_keys.items() if kid not in existing_kids}
|
||||||
|
|
||||||
|
if not new_keys:
|
||||||
|
return 0
|
||||||
|
|
||||||
cursor.executemany(
|
cursor.executemany(
|
||||||
# TODO: SQL injection risk
|
f"INSERT INTO `{service}` (kid, key_) VALUES (%s, %s)",
|
||||||
f"INSERT IGNORE INTO `{service}` (kid, key_) VALUES (%s, %s)",
|
new_keys.items(),
|
||||||
kid_keys.items(),
|
|
||||||
)
|
)
|
||||||
return cursor.rowcount
|
return len(new_keys)
|
||||||
finally:
|
finally:
|
||||||
conn.commit()
|
conn.commit()
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|||||||
@@ -19,22 +19,32 @@ class SQLite(Vault):
|
|||||||
self.conn_factory = ConnectionFactory(self.path)
|
self.conn_factory = ConnectionFactory(self.path)
|
||||||
|
|
||||||
def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]:
|
def get_key(self, kid: Union[UUID, str], service: str) -> Optional[str]:
|
||||||
if not self.has_table(service):
|
|
||||||
# no table, no key, simple
|
|
||||||
return None
|
|
||||||
|
|
||||||
if isinstance(kid, UUID):
|
if isinstance(kid, UUID):
|
||||||
kid = kid.hex
|
kid = kid.hex
|
||||||
|
|
||||||
conn = self.conn_factory.get()
|
conn = self.conn_factory.get()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Try both the original service name and lowercase version to handle case sensitivity issues
|
||||||
|
service_variants = [service]
|
||||||
|
if service != service.lower():
|
||||||
|
service_variants.append(service.lower())
|
||||||
|
if service != service.upper():
|
||||||
|
service_variants.append(service.upper())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cursor.execute(f"SELECT `id`, `key_` FROM `{service}` WHERE `kid`=? AND `key_`!=?", (kid, "0" * 32))
|
for service_name in service_variants:
|
||||||
cek = cursor.fetchone()
|
if not self.has_table(service_name):
|
||||||
if not cek:
|
continue
|
||||||
return None
|
|
||||||
return cek[1]
|
cursor.execute(
|
||||||
|
f"SELECT `id`, `key_` FROM `{service_name}` WHERE `kid`=? AND `key_`!=?", (kid, "0" * 32)
|
||||||
|
)
|
||||||
|
cek = cursor.fetchone()
|
||||||
|
if cek:
|
||||||
|
return cek[1]
|
||||||
|
|
||||||
|
return None
|
||||||
finally:
|
finally:
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
||||||
@@ -102,16 +112,27 @@ class SQLite(Vault):
|
|||||||
if any(isinstance(kid, UUID) for kid, key_ in kid_keys.items()):
|
if any(isinstance(kid, UUID) for kid, key_ in kid_keys.items()):
|
||||||
kid_keys = {kid.hex if isinstance(kid, UUID) else kid: key_ for kid, key_ in kid_keys.items()}
|
kid_keys = {kid.hex if isinstance(kid, UUID) else kid: key_ for kid, key_ in kid_keys.items()}
|
||||||
|
|
||||||
|
if not kid_keys:
|
||||||
|
return 0
|
||||||
|
|
||||||
conn = self.conn_factory.get()
|
conn = self.conn_factory.get()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
placeholders = ",".join(["?"] * len(kid_keys))
|
||||||
|
cursor.execute(f"SELECT kid FROM `{service}` WHERE kid IN ({placeholders})", list(kid_keys.keys()))
|
||||||
|
existing_kids = {row[0] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
new_keys = {kid: key for kid, key in kid_keys.items() if kid not in existing_kids}
|
||||||
|
|
||||||
|
if not new_keys:
|
||||||
|
return 0
|
||||||
|
|
||||||
cursor.executemany(
|
cursor.executemany(
|
||||||
# TODO: SQL injection risk
|
f"INSERT INTO `{service}` (kid, key_) VALUES (?, ?)",
|
||||||
f"INSERT OR IGNORE INTO `{service}` (kid, key_) VALUES (?, ?)",
|
new_keys.items(),
|
||||||
kid_keys.items(),
|
|
||||||
)
|
)
|
||||||
return cursor.rowcount
|
return len(new_keys)
|
||||||
finally:
|
finally:
|
||||||
conn.commit()
|
conn.commit()
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|||||||
@@ -95,6 +95,22 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/2b/d8/fa65d2a349fe938b76d309db1a56a75c4fb8cc7b17a398b698488a939903/aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34", size = 450266, upload-time = "2025-07-29T05:51:17.239Z" },
|
{ url = "https://files.pythonhosted.org/packages/2b/d8/fa65d2a349fe938b76d309db1a56a75c4fb8cc7b17a398b698488a939903/aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34", size = 450266, upload-time = "2025-07-29T05:51:17.239Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aiohttp-swagger3"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "aiohttp" },
|
||||||
|
{ name = "attrs" },
|
||||||
|
{ name = "fastjsonschema" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "rfc3339-validator" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a1/06/00ccb2c8afdde4ca7c3cac424d54715c7d90cdd4e13e1ca71d68f5b2e665/aiohttp_swagger3-0.10.0.tar.gz", hash = "sha256:a333c59328f64dd64587e5f276ee84dc256f587d09f2da6ddaae3812fa4d4f33", size = 1839028, upload-time = "2025-02-11T10:51:26.974Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/8f/db4cb843999a3088846d170f38eda2182b50b5733387be8102fed171c53f/aiohttp_swagger3-0.10.0-py3-none-any.whl", hash = "sha256:0ae2d2ba7dbd8ea8fe1cffe8f0197db5d0aa979eb9679bd699ecd87923912509", size = 1826491, upload-time = "2025-02-11T10:51:25.174Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiosignal"
|
name = "aiosignal"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -538,6 +554,7 @@ name = "envied"
|
|||||||
version = "1.4.8"
|
version = "1.4.8"
|
||||||
source = { editable = "packages/envied" }
|
source = { editable = "packages/envied" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "aiohttp-swagger3" },
|
||||||
{ name = "appdirs" },
|
{ name = "appdirs" },
|
||||||
{ name = "beaupy" },
|
{ name = "beaupy" },
|
||||||
{ name = "brotli" },
|
{ name = "brotli" },
|
||||||
@@ -547,6 +564,7 @@ dependencies = [
|
|||||||
{ name = "crccheck" },
|
{ name = "crccheck" },
|
||||||
{ name = "cryptography" },
|
{ name = "cryptography" },
|
||||||
{ name = "curl-cffi" },
|
{ name = "curl-cffi" },
|
||||||
|
{ name = "fonttools" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "isodate" },
|
{ name = "isodate" },
|
||||||
{ name = "jsonpickle" },
|
{ name = "jsonpickle" },
|
||||||
@@ -593,6 +611,7 @@ dev = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "aiohttp-swagger3", specifier = ">=0.10.0" },
|
||||||
{ name = "appdirs", specifier = ">=1.4.4,<2" },
|
{ name = "appdirs", specifier = ">=1.4.4,<2" },
|
||||||
{ name = "beaupy", specifier = ">=3.10.2" },
|
{ name = "beaupy", specifier = ">=3.10.2" },
|
||||||
{ name = "brotli", specifier = ">=1.1.0,<2" },
|
{ name = "brotli", specifier = ">=1.1.0,<2" },
|
||||||
@@ -602,6 +621,7 @@ requires-dist = [
|
|||||||
{ name = "crccheck", specifier = ">=1.3.0,<2" },
|
{ name = "crccheck", specifier = ">=1.3.0,<2" },
|
||||||
{ name = "cryptography", specifier = ">=45.0.0" },
|
{ name = "cryptography", specifier = ">=45.0.0" },
|
||||||
{ name = "curl-cffi", specifier = ">=0.7.0b4,<0.8" },
|
{ name = "curl-cffi", specifier = ">=0.7.0b4,<0.8" },
|
||||||
|
{ name = "fonttools", specifier = ">=4.60.1" },
|
||||||
{ name = "httpx", specifier = ">=0.28.1,<0.29" },
|
{ name = "httpx", specifier = ">=0.28.1,<0.29" },
|
||||||
{ name = "isodate", specifier = ">=0.7.2" },
|
{ name = "isodate", specifier = ">=0.7.2" },
|
||||||
{ name = "jsonpickle", specifier = ">=3.0.4,<4.5" },
|
{ name = "jsonpickle", specifier = ">=3.0.4,<4.5" },
|
||||||
@@ -659,6 +679,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
|
{ url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastjsonschema"
|
||||||
|
version = "2.19.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ba/7f/cedf77ace50aa60c566deaca9066750f06e1fcf6ad24f254d255bb976dd6/fastjsonschema-2.19.1.tar.gz", hash = "sha256:e3126a94bdc4623d3de4485f8d468a12f02a67921315ddc87836d6e456dc789d", size = 372732, upload-time = "2023-12-28T14:02:06.823Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/b9/79691036d4a8f9857e74d1728b23f34f583b81350a27492edda58d5604e1/fastjsonschema-2.19.1-py3-none-any.whl", hash = "sha256:3672b47bc94178c9f23dbb654bf47440155d4db9df5f7bc47643315f9c405cd0", size = 23388, upload-time = "2023-12-28T14:02:04.512Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filelock"
|
name = "filelock"
|
||||||
version = "3.19.1"
|
version = "3.19.1"
|
||||||
@@ -668,6 +697,39 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" },
|
{ url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fonttools"
|
||||||
|
version = "4.60.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4b/42/97a13e47a1e51a5a7142475bbcf5107fe3a68fc34aef331c897d5fb98ad0/fonttools-4.60.1.tar.gz", hash = "sha256:ef00af0439ebfee806b25f24c8f92109157ff3fac5731dc7867957812e87b8d9", size = 3559823, upload-time = "2025-09-29T21:13:27.129Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/70/03e9d89a053caff6ae46053890eba8e4a5665a7c5638279ed4492e6d4b8b/fonttools-4.60.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9a52f254ce051e196b8fe2af4634c2d2f02c981756c6464dc192f1b6050b4e28", size = 2810747, upload-time = "2025-09-29T21:10:59.653Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/41/449ad5aff9670ab0df0f61ee593906b67a36d7e0b4d0cd7fa41ac0325bf5/fonttools-4.60.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7420a2696a44650120cdd269a5d2e56a477e2bfa9d95e86229059beb1c19e15", size = 2346909, upload-time = "2025-09-29T21:11:02.882Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/18/e5970aa96c8fad1cb19a9479cc3b7602c0c98d250fcdc06a5da994309c50/fonttools-4.60.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee0c0b3b35b34f782afc673d503167157094a16f442ace7c6c5e0ca80b08f50c", size = 4864572, upload-time = "2025-09-29T21:11:05.096Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/20/9b2b4051b6ec6689480787d506b5003f72648f50972a92d04527a456192c/fonttools-4.60.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:282dafa55f9659e8999110bd8ed422ebe1c8aecd0dc396550b038e6c9a08b8ea", size = 4794635, upload-time = "2025-09-29T21:11:08.651Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/52/c791f57347c1be98f8345e3dca4ac483eb97666dd7c47f3059aeffab8b59/fonttools-4.60.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4ba4bd646e86de16160f0fb72e31c3b9b7d0721c3e5b26b9fa2fc931dfdb2652", size = 4843878, upload-time = "2025-09-29T21:11:10.893Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/e9/35c24a8d01644cee8c090a22fad34d5b61d1e0a8ecbc9945ad785ebf2e9e/fonttools-4.60.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0b0835ed15dd5b40d726bb61c846a688f5b4ce2208ec68779bc81860adb5851a", size = 4954555, upload-time = "2025-09-29T21:11:13.24Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/86/fb1e994971be4bdfe3a307de6373ef69a9df83fb66e3faa9c8114893d4cc/fonttools-4.60.1-cp310-cp310-win32.whl", hash = "sha256:1525796c3ffe27bb6268ed2a1bb0dcf214d561dfaf04728abf01489eb5339dce", size = 2232019, upload-time = "2025-09-29T21:11:15.73Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/84/62a19e2bd56f0e9fb347486a5b26376bade4bf6bbba64dda2c103bd08c94/fonttools-4.60.1-cp310-cp310-win_amd64.whl", hash = "sha256:268ecda8ca6cb5c4f044b1fb9b3b376e8cd1b361cef275082429dc4174907038", size = 2276803, upload-time = "2025-09-29T21:11:18.152Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/85/639aa9bface1537e0fb0f643690672dde0695a5bbbc90736bc571b0b1941/fonttools-4.60.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7b4c32e232a71f63a5d00259ca3d88345ce2a43295bb049d21061f338124246f", size = 2831872, upload-time = "2025-09-29T21:11:20.329Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/47/3c63158459c95093be9618794acb1067b3f4d30dcc5c3e8114b70e67a092/fonttools-4.60.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3630e86c484263eaac71d117085d509cbcf7b18f677906824e4bace598fb70d2", size = 2356990, upload-time = "2025-09-29T21:11:22.754Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/dd/1934b537c86fcf99f9761823f1fc37a98fbd54568e8e613f29a90fed95a9/fonttools-4.60.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c1015318e4fec75dd4943ad5f6a206d9727adf97410d58b7e32ab644a807914", size = 5042189, upload-time = "2025-09-29T21:11:25.061Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/d2/9f4e4c4374dd1daa8367784e1bd910f18ba886db1d6b825b12edf6db3edc/fonttools-4.60.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e6c58beb17380f7c2ea181ea11e7db8c0ceb474c9dd45f48e71e2cb577d146a1", size = 4978683, upload-time = "2025-09-29T21:11:27.693Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/c4/0fb2dfd1ecbe9a07954cc13414713ed1eab17b1c0214ef07fc93df234a47/fonttools-4.60.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec3681a0cb34c255d76dd9d865a55f260164adb9fa02628415cdc2d43ee2c05d", size = 5021372, upload-time = "2025-09-29T21:11:30.257Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/d5/495fc7ae2fab20223cc87179a8f50f40f9a6f821f271ba8301ae12bb580f/fonttools-4.60.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f4b5c37a5f40e4d733d3bbaaef082149bee5a5ea3156a785ff64d949bd1353fa", size = 5132562, upload-time = "2025-09-29T21:11:32.737Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/fa/021dab618526323c744e0206b3f5c8596a2e7ae9aa38db5948a131123e83/fonttools-4.60.1-cp311-cp311-win32.whl", hash = "sha256:398447f3d8c0c786cbf1209711e79080a40761eb44b27cdafffb48f52bcec258", size = 2230288, upload-time = "2025-09-29T21:11:35.015Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/78/0e1a6d22b427579ea5c8273e1c07def2f325b977faaf60bb7ddc01456cb1/fonttools-4.60.1-cp311-cp311-win_amd64.whl", hash = "sha256:d066ea419f719ed87bc2c99a4a4bfd77c2e5949cb724588b9dd58f3fd90b92bf", size = 2278184, upload-time = "2025-09-29T21:11:37.434Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/f7/a10b101b7a6f8836a5adb47f2791f2075d044a6ca123f35985c42edc82d8/fonttools-4.60.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7b0c6d57ab00dae9529f3faf187f2254ea0aa1e04215cf2f1a8ec277c96661bc", size = 2832953, upload-time = "2025-09-29T21:11:39.616Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/fe/7bd094b59c926acf2304d2151354ddbeb74b94812f3dc943c231db09cb41/fonttools-4.60.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:839565cbf14645952d933853e8ade66a463684ed6ed6c9345d0faf1f0e868877", size = 2352706, upload-time = "2025-09-29T21:11:41.826Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/ca/4bb48a26ed95a1e7eba175535fe5805887682140ee0a0d10a88e1de84208/fonttools-4.60.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8177ec9676ea6e1793c8a084a90b65a9f778771998eb919d05db6d4b1c0b114c", size = 4923716, upload-time = "2025-09-29T21:11:43.893Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/9f/2cb82999f686c1d1ddf06f6ae1a9117a880adbec113611cc9d22b2fdd465/fonttools-4.60.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:996a4d1834524adbb423385d5a629b868ef9d774670856c63c9a0408a3063401", size = 4968175, upload-time = "2025-09-29T21:11:46.439Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/79/be569699e37d166b78e6218f2cde8c550204f2505038cdd83b42edc469b9/fonttools-4.60.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a46b2f450bc79e06ef3b6394f0c68660529ed51692606ad7f953fc2e448bc903", size = 4911031, upload-time = "2025-09-29T21:11:48.977Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/9f/89411cc116effaec5260ad519162f64f9c150e5522a27cbb05eb62d0c05b/fonttools-4.60.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6ec722ee589e89a89f5b7574f5c45604030aa6ae24cb2c751e2707193b466fed", size = 5062966, upload-time = "2025-09-29T21:11:54.344Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/a1/f888221934b5731d46cb9991c7a71f30cb1f97c0ef5fcf37f8da8fce6c8e/fonttools-4.60.1-cp312-cp312-win32.whl", hash = "sha256:b2cf105cee600d2de04ca3cfa1f74f1127f8455b71dbad02b9da6ec266e116d6", size = 2218750, upload-time = "2025-09-29T21:11:56.601Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/8f/a55b5550cd33cd1028601df41acd057d4be20efa5c958f417b0c0613924d/fonttools-4.60.1-cp312-cp312-win_amd64.whl", hash = "sha256:992775c9fbe2cf794786fa0ffca7f09f564ba3499b8fe9f2f80bd7197db60383", size = 2267026, upload-time = "2025-09-29T21:11:58.852Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/93/0dd45cd283c32dea1545151d8c3637b4b8c53cdb3a625aeb2885b184d74d/fonttools-4.60.1-py3-none-any.whl", hash = "sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb", size = 1143175, upload-time = "2025-09-29T21:13:24.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "frozenlist"
|
name = "frozenlist"
|
||||||
version = "1.7.0"
|
version = "1.7.0"
|
||||||
@@ -1739,6 +1801,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d7/25/dd878a121fcfdf38f52850f11c512e13ec87c2ea72385933818e5b6c15ce/requests_file-2.1.0-py2.py3-none-any.whl", hash = "sha256:cf270de5a4c5874e84599fc5778303d496c10ae5e870bfa378818f35d21bda5c", size = 4244, upload-time = "2024-05-21T16:27:57.733Z" },
|
{ url = "https://files.pythonhosted.org/packages/d7/25/dd878a121fcfdf38f52850f11c512e13ec87c2ea72385933818e5b6c15ce/requests_file-2.1.0-py2.py3-none-any.whl", hash = "sha256:cf270de5a4c5874e84599fc5778303d496c10ae5e870bfa378818f35d21bda5c", size = 4244, upload-time = "2024-05-21T16:27:57.733Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rfc3339-validator"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rich"
|
name = "rich"
|
||||||
version = "13.9.4"
|
version = "13.9.4"
|
||||||
@@ -1892,6 +1966,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
|
{ url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "six"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sniffio"
|
name = "sniffio"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user