mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
feat(serve): reload unshackle.yaml on SIGHUP without a restart
Extract the serve validation and app config builder so startup and reload share them. A bad file or a restart-only change keeps the running config; open CDM sessions survive.
This commit is contained in:
@@ -155,6 +155,16 @@ Each entry under `users` uses that user's API key as its name, and can set its o
|
|||||||
A user with no `playready_devices` config key gets no PlayReady access at all, not the global
|
A user with no `playready_devices` config key gets no PlayReady access at all, not the global
|
||||||
list.
|
list.
|
||||||
|
|
||||||
|
!!! info "Reload `serve` settings without a restart"
|
||||||
|
Send `SIGHUP` to a running server (`systemctl reload` with
|
||||||
|
`ExecReload=kill -HUP $MAINPID` in the unit) after you edit the file. The server reads
|
||||||
|
the whole file again, globs the device directories again, and swaps in the new `users`,
|
||||||
|
`tiers`, `services` and device lists in one step. Open CDM sessions and remote sessions
|
||||||
|
continue; an API key you removed gets `401` on its next request. A file that does not parse
|
||||||
|
or validate keeps the running settings and logs the reason. A change to `remote_only`,
|
||||||
|
`services_refresh_interval`, the first PlayReady device, or any command-line flag needs
|
||||||
|
a restart, and the log says so. Windows has no `SIGHUP`: restart the server there.
|
||||||
|
|
||||||
`server_cdm` decides whether the server runs the CDM licensing for that API key. Set it to
|
`server_cdm` decides whether the server runs the CDM licensing for that API key. Set it to
|
||||||
`true` to enable every service, or to a list of service tags to enable only those. It is `false`
|
`true` to enable every service, or to a list of service tags to enable only those. It is `false`
|
||||||
unless the entry sets it. For a service the API key does not cover, the server tells a remote
|
unless the entry sets it. For a service the API key does not cover, the server tells a remote
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Pins the SIGHUP config reload: a valid file swaps the users in, a bad or restart-only file leaves the old one."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from unshackle.commands import serve as serve_cmd
|
||||||
|
from unshackle.core import config as config_module
|
||||||
|
from unshackle.core.config import config
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
LOG = logging.getLogger("serve")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def running(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> tuple[web.Application, Path]:
|
||||||
|
"""An app serving the key ``old`` with one open CDM session, and a writable config path."""
|
||||||
|
wvds = tmp_path / "wvds"
|
||||||
|
prds = tmp_path / "prds"
|
||||||
|
wvds.mkdir()
|
||||||
|
prds.mkdir()
|
||||||
|
(wvds / "dev.wvd").write_bytes(b"x")
|
||||||
|
monkeypatch.setattr(config.directories, "wvds", wvds)
|
||||||
|
monkeypatch.setattr(config.directories, "prds", prds)
|
||||||
|
monkeypatch.setattr(config, "serve", {"api_secret": "api", "users": {"old": {"devices": ["dev"]}}})
|
||||||
|
path = tmp_path / "unshackle.yaml"
|
||||||
|
monkeypatch.setattr(config_module, "config_path", path)
|
||||||
|
|
||||||
|
app = web.Application()
|
||||||
|
app["config"] = {"users": {"old": {"devices": ["dev"]}, "api": {"devices": ["dev"]}}, "devices": {}}
|
||||||
|
app["cdms"] = {("old", "dev"): object()}
|
||||||
|
return app, path
|
||||||
|
|
||||||
|
|
||||||
|
def _write(path: Path, **serve: object) -> None:
|
||||||
|
path.write_text(yaml.safe_dump({"serve": {"api_secret": "api", **serve}}), encoding="utf8")
|
||||||
|
|
||||||
|
|
||||||
|
def _reload(app: web.Application) -> bool:
|
||||||
|
return serve_cmd.reload_config(app, no_key=False, serve_widevine=True, serve_playready=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_reload_swaps_the_accepted_keys(running: tuple[web.Application, Path]) -> None:
|
||||||
|
app, path = running
|
||||||
|
_write(path, users={"new": {"devices": ["dev"]}})
|
||||||
|
|
||||||
|
assert _reload(app) is True
|
||||||
|
assert set(app["config"]["users"]) == {"new", "api"}
|
||||||
|
assert app["config"]["devices"] == {"dev": path.parent / "wvds" / "dev.wvd"}
|
||||||
|
assert config.serve["users"] == {"new": {"devices": ["dev"]}}
|
||||||
|
assert ("old", "dev") in app["cdms"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_file_keeps_the_running_config(running: tuple[web.Application, Path]) -> None:
|
||||||
|
app, path = running
|
||||||
|
_write(path, users={"new": {"devices": ["dev"], "tier": "gold"}})
|
||||||
|
|
||||||
|
assert _reload(app) is False
|
||||||
|
assert set(app["config"]["users"]) == {"old", "api"}
|
||||||
|
assert config.serve["users"] == {"old": {"devices": ["dev"]}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_only_change_is_reported_not_applied(
|
||||||
|
running: tuple[web.Application, Path], caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
app, path = running
|
||||||
|
_write(path, users={"new": {"devices": ["dev"]}}, remote_only=True)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger=LOG.name):
|
||||||
|
assert _reload(app) is False
|
||||||
|
assert "restart" in caplog.text and "remote_only" in caplog.text
|
||||||
|
assert set(app["config"]["users"]) == {"old", "api"}
|
||||||
+204
-101
@@ -2,14 +2,19 @@ import asyncio
|
|||||||
import hmac
|
import hmac
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from functools import partial
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
|
import unshackle.core.config as config_module
|
||||||
from unshackle.core import binaries
|
from unshackle.core import binaries
|
||||||
from unshackle.core.api import cors_middleware, setup_routes, setup_swagger
|
from unshackle.core.api import cors_middleware, setup_routes, setup_swagger
|
||||||
from unshackle.core.api.compression import compression_middleware
|
from unshackle.core.api.compression import compression_middleware
|
||||||
@@ -27,13 +32,14 @@ from unshackle.core.api.handlers import (
|
|||||||
)
|
)
|
||||||
from unshackle.core.api.stats import (
|
from unshackle.core.api.stats import (
|
||||||
cdm_log_middleware,
|
cdm_log_middleware,
|
||||||
|
key_id,
|
||||||
key_rate_limit,
|
key_rate_limit,
|
||||||
rate_limit_error,
|
rate_limit_error,
|
||||||
ring,
|
ring,
|
||||||
stats,
|
stats,
|
||||||
stats_middleware,
|
stats_middleware,
|
||||||
)
|
)
|
||||||
from unshackle.core.config import config
|
from unshackle.core.config import Config, config
|
||||||
from unshackle.core.console import console
|
from unshackle.core.console import console
|
||||||
from unshackle.core.constants import context_settings
|
from unshackle.core.constants import context_settings
|
||||||
from unshackle.core.downloaders import format_speed, parse_speed_limit, set_speed_limit
|
from unshackle.core.downloaders import format_speed, parse_speed_limit, set_speed_limit
|
||||||
@@ -99,6 +105,189 @@ def _install_service_refresh(app: web.Application) -> None:
|
|||||||
app.on_cleanup.append(stop)
|
app.on_cleanup.append(stop)
|
||||||
|
|
||||||
|
|
||||||
|
RESTART_KEYS = ("remote_only", "services_refresh_interval")
|
||||||
|
"""``serve.*`` keys a reload cannot apply: they decide routes or tasks fixed at startup."""
|
||||||
|
|
||||||
|
log = logging.getLogger("serve")
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_serve(serve: dict, no_key: bool) -> Optional[str]:
|
||||||
|
"""Glob the device directories into ``serve``, validate it in place and return the api_secret.
|
||||||
|
|
||||||
|
Raises ValueError on a bad value. The api_secret is None under ``--no-key``.
|
||||||
|
"""
|
||||||
|
api_secret = None if no_key else serve.get("api_secret")
|
||||||
|
if not no_key and not api_secret:
|
||||||
|
raise ValueError(
|
||||||
|
"API secret key is not configured. Please add 'api_secret' to the 'serve' section in your config."
|
||||||
|
)
|
||||||
|
|
||||||
|
for key, directory, pattern in (
|
||||||
|
("devices", config.directories.wvds, "*.wvd"),
|
||||||
|
("playready_devices", config.directories.prds, "*.prd"),
|
||||||
|
):
|
||||||
|
if not serve.get(key):
|
||||||
|
serve[key] = []
|
||||||
|
serve[key].extend(directory.glob(pattern))
|
||||||
|
for device in serve[key]:
|
||||||
|
if not Path(device).is_file():
|
||||||
|
raise ValueError(f"serve.{key}: device file does not exist: {device}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parse_speed_limit(serve.get("global_speed_limit"))
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f"serve.global_speed_limit: {e}")
|
||||||
|
|
||||||
|
users = serve.get("users", {})
|
||||||
|
if isinstance(users, dict):
|
||||||
|
users = {str(k): v for k, v in users.items()}
|
||||||
|
serve["users"] = users
|
||||||
|
tiers = serve.get("tiers") or {}
|
||||||
|
if not isinstance(tiers, dict):
|
||||||
|
raise ValueError("serve.tiers must be a mapping of tier name to settings")
|
||||||
|
for tier_name, tier_cfg in tiers.items():
|
||||||
|
if not isinstance(tier_cfg, dict):
|
||||||
|
raise ValueError(f"serve.tiers.{tier_name} must be a mapping of setting to value")
|
||||||
|
if problem := rate_limit_error(f"serve.tiers.{tier_name}", tier_cfg.get("rate_limit")):
|
||||||
|
raise ValueError(problem)
|
||||||
|
for user_key, user_cfg in users.items() if isinstance(users, dict) else []:
|
||||||
|
user_services = user_cfg.get("services") if isinstance(user_cfg, dict) else None
|
||||||
|
username = user_cfg.get("username", user_key[:8] + "...") if isinstance(user_cfg, dict) else user_key[:8]
|
||||||
|
if user_services:
|
||||||
|
log.info(f"User '{username}' restricted to services: {', '.join(user_services)}")
|
||||||
|
tier = user_cfg.get("tier") if isinstance(user_cfg, dict) else None
|
||||||
|
if tier and tier not in tiers:
|
||||||
|
raise ValueError(f"serve.users.{username}.tier: no such tier '{tier}' under serve.tiers")
|
||||||
|
if isinstance(user_cfg, dict):
|
||||||
|
if problem := rate_limit_error(f"serve.users.{username}", user_cfg.get("rate_limit")):
|
||||||
|
raise ValueError(problem)
|
||||||
|
if problem := server_cdm_max_height_error(f"serve.users.{username}", user_cfg.get("server_cdm_max_height")):
|
||||||
|
raise ValueError(problem)
|
||||||
|
limit = key_rate_limit(user_key, serve)
|
||||||
|
if limit:
|
||||||
|
log.info(f"User '{username}' rate limited to {limit} requests/hour")
|
||||||
|
return api_secret
|
||||||
|
|
||||||
|
|
||||||
|
def _build_cdm_config(
|
||||||
|
serve: dict, api_secret: Optional[str], serve_widevine: bool, serve_playready: bool
|
||||||
|
) -> tuple[dict, dict]:
|
||||||
|
"""The ``app["config"]`` for the main app and the PlayReady sub-app, derived from a prepared ``serve``.
|
||||||
|
|
||||||
|
The main config carries the api_secret user and a ``playready_devices`` list per user; the
|
||||||
|
sub-app config maps each user's ``playready_devices`` to ``devices`` for pyplayready.
|
||||||
|
"""
|
||||||
|
serve_config = dict(serve)
|
||||||
|
wvd_devices = serve_config.get("devices", []) if serve_widevine else []
|
||||||
|
prd_devices = serve_config.get("playready_devices", []) if serve_playready else []
|
||||||
|
wvd_device_names = [Path(d).stem for d in wvd_devices]
|
||||||
|
prd_device_names = [Path(d).stem for d in prd_devices]
|
||||||
|
|
||||||
|
users_cfg = serve_config.get("users")
|
||||||
|
serve_config["users"] = {k: dict(v) for k, v in users_cfg.items()} if isinstance(users_cfg, dict) else {}
|
||||||
|
if api_secret and api_secret not in serve_config["users"]:
|
||||||
|
serve_config["users"][api_secret] = {
|
||||||
|
"devices": wvd_device_names,
|
||||||
|
"playready_devices": prd_device_names,
|
||||||
|
"username": "api_user",
|
||||||
|
}
|
||||||
|
for user_key, user_config in serve_config["users"].items():
|
||||||
|
if "playready_devices" not in user_config:
|
||||||
|
user_config["playready_devices"] = []
|
||||||
|
if serve_playready:
|
||||||
|
log.warning(
|
||||||
|
f'User "{key_id(user_key)}" has no "playready_devices" configured; PlayReady access disabled '
|
||||||
|
f"for this user. Available PlayReady devices: {prd_device_names}"
|
||||||
|
)
|
||||||
|
serve_config["devices"] = wvd_devices
|
||||||
|
serve_config["playready_devices"] = prd_devices
|
||||||
|
|
||||||
|
playready_config = {
|
||||||
|
"devices": prd_devices,
|
||||||
|
"users": {
|
||||||
|
user_key: {
|
||||||
|
"devices": user_cfg.get("playready_devices", []),
|
||||||
|
"username": user_cfg.get("username", "user"),
|
||||||
|
}
|
||||||
|
for user_key, user_cfg in serve_config["users"].items()
|
||||||
|
}
|
||||||
|
if api_secret
|
||||||
|
else {},
|
||||||
|
}
|
||||||
|
return serve_config, playready_config
|
||||||
|
|
||||||
|
|
||||||
|
def _device_map(devices: list[Any]) -> dict[str, Path]:
|
||||||
|
"""The ``{stem: path}`` form pywidevine and pyplayready rewrite ``app["config"]["devices"]`` into at startup."""
|
||||||
|
return {Path(d).stem: Path(d) for d in devices}
|
||||||
|
|
||||||
|
|
||||||
|
def _device_stems(serve: dict) -> set[str]:
|
||||||
|
return {Path(d).stem for d in serve.get("devices", []) + serve.get("playready_devices", [])}
|
||||||
|
|
||||||
|
|
||||||
|
def reload_config(app: web.Application, *, no_key: bool, serve_widevine: bool, serve_playready: bool) -> bool:
|
||||||
|
"""Re-read the config file and swap it in; return True when the new file is in use.
|
||||||
|
|
||||||
|
The swap replaces every ``Config`` section, so services read new credentials and CDM mappings
|
||||||
|
too. Runs synchronously on the event loop so no request sees a half-updated config. A file
|
||||||
|
that fails to parse or validate, or that changes a startup-only setting, keeps the running
|
||||||
|
config. The reload leaves open CDM sessions alone: a revoked key fails authentication on
|
||||||
|
its next call.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not config_module.config_path:
|
||||||
|
raise ValueError("no config file found")
|
||||||
|
fresh = Config.from_yaml(config_module.config_path)
|
||||||
|
serve = fresh.serve
|
||||||
|
api_secret = _prepare_serve(serve, no_key)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Config reload rejected, keeping the running config: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
changed = [k for k in RESTART_KEYS if serve.get(k) != config.serve.get(k)]
|
||||||
|
if serve_playready and bool(serve.get("playready_devices")) != ("playready_app" in app):
|
||||||
|
changed.append("playready_devices (first device added or last removed)")
|
||||||
|
if changed:
|
||||||
|
log.warning(f"Config reload skipped, restart to apply: {', '.join(changed)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
serve_config, playready_config = _build_cdm_config(serve, api_secret, serve_widevine, serve_playready)
|
||||||
|
old_users, new_users = set(app["config"]["users"]), set(serve_config["users"])
|
||||||
|
old_devices, new_devices = _device_stems(config.serve), _device_stems(serve)
|
||||||
|
vars(config).update(vars(fresh))
|
||||||
|
app["config"]["users"] = serve_config["users"]
|
||||||
|
if serve_widevine:
|
||||||
|
app["config"]["devices"] = _device_map(serve_config["devices"])
|
||||||
|
if playready_app := app.get("playready_app"):
|
||||||
|
playready_app["config"]["users"] = playready_config["users"]
|
||||||
|
playready_app["config"]["devices"] = _device_map(playready_config["devices"])
|
||||||
|
set_speed_limit(parse_speed_limit(serve.get("global_speed_limit")), lock=True)
|
||||||
|
log.info(
|
||||||
|
"Config reloaded: "
|
||||||
|
f"keys added {sorted(key_id(k) for k in new_users - old_users)}, "
|
||||||
|
f"removed {sorted(key_id(k) for k in old_users - new_users)}; "
|
||||||
|
f"devices added {sorted(new_devices - old_devices)}, removed {sorted(old_devices - new_devices)}"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _install_config_reload(app: web.Application, *, no_key: bool, serve_widevine: bool, serve_playready: bool) -> None:
|
||||||
|
"""Reload the config on SIGHUP, so ``systemctl reload`` applies a config edit without a restart."""
|
||||||
|
|
||||||
|
async def start(_app: web.Application) -> None:
|
||||||
|
reload = partial(
|
||||||
|
reload_config, _app, no_key=no_key, serve_widevine=serve_widevine, serve_playready=serve_playready
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
asyncio.get_running_loop().add_signal_handler(signal.SIGHUP, reload)
|
||||||
|
except (AttributeError, NotImplementedError):
|
||||||
|
return
|
||||||
|
log.info("Config hot-reload: send SIGHUP (systemctl reload) after editing unshackle.yaml")
|
||||||
|
|
||||||
|
app.on_startup.append(start)
|
||||||
|
|
||||||
|
|
||||||
@click.command(
|
@click.command(
|
||||||
short_help="Serve your Local Widevine/PlayReady Devices and REST API for Remote Access.",
|
short_help="Serve your Local Widevine/PlayReady Devices and REST API for Remote Access.",
|
||||||
context_settings=context_settings,
|
context_settings=context_settings,
|
||||||
@@ -180,8 +369,6 @@ def serve(
|
|||||||
from pyplayready.remote import serve as pyplayready_serve
|
from pyplayready.remote import serve as pyplayready_serve
|
||||||
from pywidevine import serve as pywidevine_serve
|
from pywidevine import serve as pywidevine_serve
|
||||||
|
|
||||||
log = logging.getLogger("serve")
|
|
||||||
|
|
||||||
if quiet:
|
if quiet:
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
force=True,
|
force=True,
|
||||||
@@ -213,14 +400,7 @@ def serve(
|
|||||||
logging.getLogger().addHandler(ring)
|
logging.getLogger().addHandler(ring)
|
||||||
logging.getLogger("aiohttp.access").addFilter(_MaskAccessKey())
|
logging.getLogger("aiohttp.access").addFilter(_MaskAccessKey())
|
||||||
|
|
||||||
if not no_key:
|
if 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!")
|
log.warning("Running with --no-key: Authentication is DISABLED for all API endpoints!")
|
||||||
|
|
||||||
if debug_api:
|
if debug_api:
|
||||||
@@ -239,13 +419,10 @@ def serve(
|
|||||||
caddy_p = None
|
caddy_p = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not config.serve.get("devices"):
|
try:
|
||||||
config.serve["devices"] = []
|
api_secret = _prepare_serve(config.serve, no_key)
|
||||||
config.serve["devices"].extend(list(config.directories.wvds.glob("*.wvd")))
|
except ValueError as e:
|
||||||
|
raise click.ClickException(str(e))
|
||||||
if not config.serve.get("playready_devices"):
|
|
||||||
config.serve["playready_devices"] = []
|
|
||||||
config.serve["playready_devices"].extend(list(config.directories.prds.glob("*.prd")))
|
|
||||||
|
|
||||||
remote_only = remote_only or config.serve.get("remote_only", False)
|
remote_only = remote_only or config.serve.get("remote_only", False)
|
||||||
if remote_only:
|
if remote_only:
|
||||||
@@ -258,10 +435,7 @@ def serve(
|
|||||||
elif quiet:
|
elif quiet:
|
||||||
log.info("Developer dashboard disabled: set serve.dashboard.key in unshackle.yaml to enable it")
|
log.info("Developer dashboard disabled: set serve.dashboard.key in unshackle.yaml to enable it")
|
||||||
|
|
||||||
try:
|
global_speed_limit = parse_speed_limit(config.serve.get("global_speed_limit"))
|
||||||
global_speed_limit = parse_speed_limit(config.serve.get("global_speed_limit"))
|
|
||||||
except ValueError as e:
|
|
||||||
raise click.ClickException(f"serve.global_speed_limit: {e}")
|
|
||||||
if global_speed_limit:
|
if global_speed_limit:
|
||||||
set_speed_limit(global_speed_limit, lock=True)
|
set_speed_limit(global_speed_limit, lock=True)
|
||||||
log.info(f"Global speed limit: {format_speed(global_speed_limit)} (shared by all jobs)")
|
log.info(f"Global speed limit: {format_speed(global_speed_limit)} (shared by all jobs)")
|
||||||
@@ -279,37 +453,11 @@ def serve(
|
|||||||
log.info(f"Server accounts for {tag}: {', '.join(covered)}")
|
log.info(f"Server accounts for {tag}: {', '.join(covered)}")
|
||||||
if config.key_vaults and not any(v.get("type") == "SQLite" for v in config.key_vaults):
|
if config.key_vaults and not any(v.get("type") == "SQLite" for v in config.key_vaults):
|
||||||
log.warning("No SQLite key vault configured: a content key a remote client proves wrong cannot be flagged")
|
log.warning("No SQLite key vault configured: a content key a remote client proves wrong cannot be flagged")
|
||||||
users = config.serve.get("users", {})
|
|
||||||
if isinstance(users, dict):
|
serve_widevine = not api_only and not no_widevine
|
||||||
# yaml keys can parse as int; hmac.compare_digest and allowlist lookups need str
|
serve_playready = not api_only and not no_playready
|
||||||
users = {str(k): v for k, v in users.items()}
|
serve_config, playready_config = _build_cdm_config(config.serve, api_secret, serve_widevine, serve_playready)
|
||||||
config.serve["users"] = users
|
prd_devices = playready_config["devices"]
|
||||||
tiers = config.serve.get("tiers") or {}
|
|
||||||
if not isinstance(tiers, dict):
|
|
||||||
raise click.ClickException("serve.tiers must be a mapping of tier name to settings")
|
|
||||||
for tier_name, tier_cfg in tiers.items():
|
|
||||||
if not isinstance(tier_cfg, dict):
|
|
||||||
raise click.ClickException(f"serve.tiers.{tier_name} must be a mapping of setting to value")
|
|
||||||
if problem := rate_limit_error(f"serve.tiers.{tier_name}", tier_cfg.get("rate_limit")):
|
|
||||||
raise click.ClickException(problem)
|
|
||||||
for user_key, user_cfg in users.items() if isinstance(users, dict) else []:
|
|
||||||
user_services = user_cfg.get("services") if isinstance(user_cfg, dict) else None
|
|
||||||
username = user_cfg.get("username", user_key[:8] + "...") if isinstance(user_cfg, dict) else user_key[:8]
|
|
||||||
if user_services:
|
|
||||||
log.info(f"User '{username}' restricted to services: {', '.join(user_services)}")
|
|
||||||
tier = user_cfg.get("tier") if isinstance(user_cfg, dict) else None
|
|
||||||
if tier and tier not in tiers:
|
|
||||||
raise click.ClickException(f"serve.users.{username}.tier: no such tier '{tier}' under serve.tiers")
|
|
||||||
if isinstance(user_cfg, dict):
|
|
||||||
if problem := rate_limit_error(f"serve.users.{username}", user_cfg.get("rate_limit")):
|
|
||||||
raise click.ClickException(problem)
|
|
||||||
if problem := server_cdm_max_height_error(
|
|
||||||
f"serve.users.{username}", user_cfg.get("server_cdm_max_height")
|
|
||||||
):
|
|
||||||
raise click.ClickException(problem)
|
|
||||||
limit = key_rate_limit(user_key)
|
|
||||||
if limit:
|
|
||||||
log.info(f"User '{username}' rate limited to {limit} requests/hour")
|
|
||||||
|
|
||||||
if api_only:
|
if api_only:
|
||||||
log.info("Starting REST API server (pywidevine/pyplayready CDM disabled)")
|
log.info("Starting REST API server (pywidevine/pyplayready CDM disabled)")
|
||||||
@@ -317,7 +465,6 @@ def serve(
|
|||||||
app = web.Application(
|
app = web.Application(
|
||||||
middlewares=[cors_middleware, stats_middleware, dashboard_authentication, compression_middleware]
|
middlewares=[cors_middleware, stats_middleware, dashboard_authentication, compression_middleware]
|
||||||
)
|
)
|
||||||
app["config"] = {"users": {}}
|
|
||||||
else:
|
else:
|
||||||
app = web.Application(
|
app = web.Application(
|
||||||
middlewares=[
|
middlewares=[
|
||||||
@@ -328,10 +475,7 @@ def serve(
|
|||||||
compression_middleware,
|
compression_middleware,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
api_users: dict = {api_secret: {"devices": [], "username": "api_user"}}
|
app["config"] = serve_config
|
||||||
if isinstance(users, dict):
|
|
||||||
api_users.update(users)
|
|
||||||
app["config"] = {"users": api_users}
|
|
||||||
app["debug_api"] = debug_api
|
app["debug_api"] = debug_api
|
||||||
|
|
||||||
from unshackle.core.api.session_store import get_session_store
|
from unshackle.core.api.session_store import get_session_store
|
||||||
@@ -348,6 +492,7 @@ def serve(
|
|||||||
app.on_startup.append(start_session_cleanup)
|
app.on_startup.append(start_session_cleanup)
|
||||||
app.on_cleanup.append(stop_session_cleanup)
|
app.on_cleanup.append(stop_session_cleanup)
|
||||||
_install_service_refresh(app)
|
_install_service_refresh(app)
|
||||||
|
_install_config_reload(app, no_key=no_key, serve_widevine=serve_widevine, serve_playready=serve_playready)
|
||||||
|
|
||||||
setup_routes(app, remote_only=remote_only, dashboard=dashboard)
|
setup_routes(app, remote_only=remote_only, dashboard=dashboard)
|
||||||
if not remote_only:
|
if not remote_only:
|
||||||
@@ -359,13 +504,6 @@ def serve(
|
|||||||
log.info("(Press CTRL+C to quit)")
|
log.info("(Press CTRL+C to quit)")
|
||||||
web.run_app(app, host=host, port=port, print=None)
|
web.run_app(app, host=host, port=port, print=None)
|
||||||
else:
|
else:
|
||||||
serve_widevine = not no_widevine
|
|
||||||
serve_playready = not no_playready
|
|
||||||
|
|
||||||
serve_config = dict(config.serve)
|
|
||||||
wvd_devices = serve_config.get("devices", []) if serve_widevine else []
|
|
||||||
prd_devices = serve_config.get("playready_devices", []) if serve_playready else []
|
|
||||||
|
|
||||||
cdm_parts = []
|
cdm_parts = []
|
||||||
if serve_widevine:
|
if serve_widevine:
|
||||||
cdm_parts.append("pywidevine CDM")
|
cdm_parts.append("pywidevine CDM")
|
||||||
@@ -373,28 +511,6 @@ def serve(
|
|||||||
cdm_parts.append("pyplayready CDM")
|
cdm_parts.append("pyplayready CDM")
|
||||||
log.info(f"Starting integrated server ({' + '.join(cdm_parts)} + REST API)")
|
log.info(f"Starting integrated server ({' + '.join(cdm_parts)} + REST API)")
|
||||||
|
|
||||||
wvd_device_names = [d.stem if hasattr(d, "stem") else str(d) for d in wvd_devices]
|
|
||||||
prd_device_names = [d.stem if hasattr(d, "stem") else str(d) for d in prd_devices]
|
|
||||||
|
|
||||||
users_cfg = serve_config.get("users")
|
|
||||||
serve_config["users"] = dict(users_cfg) if isinstance(users_cfg, dict) else {}
|
|
||||||
|
|
||||||
if not no_key and api_secret not in serve_config["users"]:
|
|
||||||
serve_config["users"][api_secret] = {
|
|
||||||
"devices": wvd_device_names,
|
|
||||||
"playready_devices": prd_device_names,
|
|
||||||
"username": "api_user",
|
|
||||||
}
|
|
||||||
|
|
||||||
for user_key, user_config in serve_config["users"].items():
|
|
||||||
if "playready_devices" not in user_config:
|
|
||||||
# Require explicit PlayReady device access per user (default: no access).
|
|
||||||
user_config["playready_devices"] = []
|
|
||||||
log.warning(
|
|
||||||
f'User "{user_key}" has no "playready_devices" configured; PlayReady access disabled for this user. '
|
|
||||||
f"Available PlayReady devices: {prd_device_names}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def create_serve_authentication(serve_playready_flag: bool):
|
def create_serve_authentication(serve_playready_flag: bool):
|
||||||
@web.middleware
|
@web.middleware
|
||||||
async def serve_authentication(request: web.Request, handler) -> web.StreamResponse:
|
async def serve_authentication(request: web.Request, handler) -> web.StreamResponse:
|
||||||
@@ -469,6 +585,7 @@ def serve(
|
|||||||
app.on_startup.append(start_session_cleanup)
|
app.on_startup.append(start_session_cleanup)
|
||||||
app.on_cleanup.append(stop_session_cleanup)
|
app.on_cleanup.append(stop_session_cleanup)
|
||||||
_install_service_refresh(app)
|
_install_service_refresh(app)
|
||||||
|
_install_config_reload(app, no_key=no_key, serve_widevine=serve_widevine, serve_playready=serve_playready)
|
||||||
|
|
||||||
if serve_widevine:
|
if serve_widevine:
|
||||||
app.on_startup.append(pywidevine_serve._startup)
|
app.on_startup.append(pywidevine_serve._startup)
|
||||||
@@ -480,20 +597,6 @@ def serve(
|
|||||||
playready_app = web.Application()
|
playready_app = web.Application()
|
||||||
else:
|
else:
|
||||||
playready_app = web.Application(middlewares=[pyplayready_serve.authentication])
|
playready_app = web.Application(middlewares=[pyplayready_serve.authentication])
|
||||||
|
|
||||||
# PlayReady subapp config maps playready_devices to "devices" for pyplayready compatibility
|
|
||||||
playready_config = {
|
|
||||||
"devices": prd_devices,
|
|
||||||
"users": {
|
|
||||||
user_key: {
|
|
||||||
"devices": user_cfg.get("playready_devices", []),
|
|
||||||
"username": user_cfg.get("username", "user"),
|
|
||||||
}
|
|
||||||
for user_key, user_cfg in serve_config["users"].items()
|
|
||||||
}
|
|
||||||
if not no_key
|
|
||||||
else {},
|
|
||||||
}
|
|
||||||
playready_app["config"] = playready_config
|
playready_app["config"] = playready_config
|
||||||
playready_app.on_startup.append(pyplayready_serve._startup)
|
playready_app.on_startup.append(pyplayready_serve._startup)
|
||||||
playready_app.on_cleanup.append(pyplayready_serve._cleanup)
|
playready_app.on_cleanup.append(pyplayready_serve._cleanup)
|
||||||
|
|||||||
@@ -87,15 +87,21 @@ def key_tier(key: Optional[str]) -> Optional[str]:
|
|||||||
return str(tier) if tier else None
|
return str(tier) if tier else None
|
||||||
|
|
||||||
|
|
||||||
def key_rate_limit(key: Optional[str]) -> Optional[int]:
|
def key_rate_limit(key: Optional[str], serve: Optional[Dict[str, Any]] = None) -> Optional[int]:
|
||||||
"""Requests per hour allowed for a key: its own override, else its tier's, else unlimited."""
|
"""Requests per hour allowed for a key: its own override, else its tier's, else unlimited.
|
||||||
user = _user_config(key)
|
|
||||||
if user is None:
|
``serve`` reads a config block that is not yet the live one, as the reload validator does.
|
||||||
|
"""
|
||||||
|
if serve is None:
|
||||||
|
serve = config.serve or {}
|
||||||
|
users = serve.get("users")
|
||||||
|
user = users.get(key) if isinstance(users, dict) and key else None
|
||||||
|
if not isinstance(user, dict):
|
||||||
return None
|
return None
|
||||||
own = user.get("rate_limit")
|
own = user.get("rate_limit")
|
||||||
if isinstance(own, int) and own > 0:
|
if isinstance(own, int) and own > 0:
|
||||||
return own
|
return own
|
||||||
tiers = (config.serve or {}).get("tiers")
|
tiers = serve.get("tiers")
|
||||||
tier = tiers.get(user.get("tier")) if isinstance(tiers, dict) and user.get("tier") else None
|
tier = tiers.get(user.get("tier")) if isinstance(tiers, dict) and user.get("tier") else None
|
||||||
limit = tier.get("rate_limit") if isinstance(tier, dict) else None
|
limit = tier.get("rate_limit") if isinstance(tier, dict) else None
|
||||||
return limit if isinstance(limit, int) and limit > 0 else None
|
return limit if isinstance(limit, int) and limit > 0 else None
|
||||||
|
|||||||
Reference in New Issue
Block a user