mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +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
|
||||
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
|
||||
`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
|
||||
|
||||
@@ -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 logging
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import click
|
||||
from aiohttp import web
|
||||
|
||||
import unshackle.core.config as config_module
|
||||
from unshackle.core import binaries
|
||||
from unshackle.core.api import cors_middleware, setup_routes, setup_swagger
|
||||
from unshackle.core.api.compression import compression_middleware
|
||||
@@ -27,13 +32,14 @@ from unshackle.core.api.handlers import (
|
||||
)
|
||||
from unshackle.core.api.stats import (
|
||||
cdm_log_middleware,
|
||||
key_id,
|
||||
key_rate_limit,
|
||||
rate_limit_error,
|
||||
ring,
|
||||
stats,
|
||||
stats_middleware,
|
||||
)
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.config import Config, config
|
||||
from unshackle.core.console import console
|
||||
from unshackle.core.constants import context_settings
|
||||
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)
|
||||
|
||||
|
||||
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(
|
||||
short_help="Serve your Local Widevine/PlayReady Devices and REST API for Remote Access.",
|
||||
context_settings=context_settings,
|
||||
@@ -180,8 +369,6 @@ def serve(
|
||||
from pyplayready.remote import serve as pyplayready_serve
|
||||
from pywidevine import serve as pywidevine_serve
|
||||
|
||||
log = logging.getLogger("serve")
|
||||
|
||||
if quiet:
|
||||
logging.basicConfig(
|
||||
force=True,
|
||||
@@ -213,14 +400,7 @@ def serve(
|
||||
logging.getLogger().addHandler(ring)
|
||||
logging.getLogger("aiohttp.access").addFilter(_MaskAccessKey())
|
||||
|
||||
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
|
||||
if no_key:
|
||||
log.warning("Running with --no-key: Authentication is DISABLED for all API endpoints!")
|
||||
|
||||
if debug_api:
|
||||
@@ -239,13 +419,10 @@ def serve(
|
||||
caddy_p = None
|
||||
|
||||
try:
|
||||
if not config.serve.get("devices"):
|
||||
config.serve["devices"] = []
|
||||
config.serve["devices"].extend(list(config.directories.wvds.glob("*.wvd")))
|
||||
|
||||
if not config.serve.get("playready_devices"):
|
||||
config.serve["playready_devices"] = []
|
||||
config.serve["playready_devices"].extend(list(config.directories.prds.glob("*.prd")))
|
||||
try:
|
||||
api_secret = _prepare_serve(config.serve, no_key)
|
||||
except ValueError as e:
|
||||
raise click.ClickException(str(e))
|
||||
|
||||
remote_only = remote_only or config.serve.get("remote_only", False)
|
||||
if remote_only:
|
||||
@@ -258,10 +435,7 @@ def serve(
|
||||
elif quiet:
|
||||
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"))
|
||||
except ValueError as e:
|
||||
raise click.ClickException(f"serve.global_speed_limit: {e}")
|
||||
global_speed_limit = parse_speed_limit(config.serve.get("global_speed_limit"))
|
||||
if global_speed_limit:
|
||||
set_speed_limit(global_speed_limit, lock=True)
|
||||
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)}")
|
||||
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")
|
||||
users = config.serve.get("users", {})
|
||||
if isinstance(users, dict):
|
||||
# yaml keys can parse as int; hmac.compare_digest and allowlist lookups need str
|
||||
users = {str(k): v for k, v in users.items()}
|
||||
config.serve["users"] = users
|
||||
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")
|
||||
|
||||
serve_widevine = not api_only and not no_widevine
|
||||
serve_playready = not api_only and not no_playready
|
||||
serve_config, playready_config = _build_cdm_config(config.serve, api_secret, serve_widevine, serve_playready)
|
||||
prd_devices = playready_config["devices"]
|
||||
|
||||
if api_only:
|
||||
log.info("Starting REST API server (pywidevine/pyplayready CDM disabled)")
|
||||
@@ -317,7 +465,6 @@ def serve(
|
||||
app = web.Application(
|
||||
middlewares=[cors_middleware, stats_middleware, dashboard_authentication, compression_middleware]
|
||||
)
|
||||
app["config"] = {"users": {}}
|
||||
else:
|
||||
app = web.Application(
|
||||
middlewares=[
|
||||
@@ -328,10 +475,7 @@ def serve(
|
||||
compression_middleware,
|
||||
]
|
||||
)
|
||||
api_users: dict = {api_secret: {"devices": [], "username": "api_user"}}
|
||||
if isinstance(users, dict):
|
||||
api_users.update(users)
|
||||
app["config"] = {"users": api_users}
|
||||
app["config"] = serve_config
|
||||
app["debug_api"] = debug_api
|
||||
|
||||
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_cleanup.append(stop_session_cleanup)
|
||||
_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)
|
||||
if not remote_only:
|
||||
@@ -359,13 +504,6 @@ def serve(
|
||||
log.info("(Press CTRL+C to quit)")
|
||||
web.run_app(app, host=host, port=port, print=None)
|
||||
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 = []
|
||||
if serve_widevine:
|
||||
cdm_parts.append("pywidevine CDM")
|
||||
@@ -373,28 +511,6 @@ def serve(
|
||||
cdm_parts.append("pyplayready CDM")
|
||||
log.info(f"Starting integrated server ({' + '.join(cdm_parts)} + REST API)")
|
||||
|
||||
wvd_device_names = [d.stem if hasattr(d, "stem") else str(d) for d in wvd_devices]
|
||||
prd_device_names = [d.stem if hasattr(d, "stem") else str(d) for d in prd_devices]
|
||||
|
||||
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):
|
||||
@web.middleware
|
||||
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_cleanup.append(stop_session_cleanup)
|
||||
_install_service_refresh(app)
|
||||
_install_config_reload(app, no_key=no_key, serve_widevine=serve_widevine, serve_playready=serve_playready)
|
||||
|
||||
if serve_widevine:
|
||||
app.on_startup.append(pywidevine_serve._startup)
|
||||
@@ -480,20 +597,6 @@ def serve(
|
||||
playready_app = web.Application()
|
||||
else:
|
||||
playready_app = web.Application(middlewares=[pyplayready_serve.authentication])
|
||||
|
||||
# PlayReady subapp config maps playready_devices to "devices" for pyplayready compatibility
|
||||
playready_config = {
|
||||
"devices": prd_devices,
|
||||
"users": {
|
||||
user_key: {
|
||||
"devices": user_cfg.get("playready_devices", []),
|
||||
"username": user_cfg.get("username", "user"),
|
||||
}
|
||||
for user_key, user_cfg in serve_config["users"].items()
|
||||
}
|
||||
if not no_key
|
||||
else {},
|
||||
}
|
||||
playready_app["config"] = playready_config
|
||||
playready_app.on_startup.append(pyplayready_serve._startup)
|
||||
playready_app.on_cleanup.append(pyplayready_serve._cleanup)
|
||||
|
||||
@@ -87,15 +87,21 @@ def key_tier(key: Optional[str]) -> Optional[str]:
|
||||
return str(tier) if tier else None
|
||||
|
||||
|
||||
def key_rate_limit(key: Optional[str]) -> Optional[int]:
|
||||
"""Requests per hour allowed for a key: its own override, else its tier's, else unlimited."""
|
||||
user = _user_config(key)
|
||||
if user is None:
|
||||
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.
|
||||
|
||||
``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
|
||||
own = user.get("rate_limit")
|
||||
if isinstance(own, int) and own > 0:
|
||||
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
|
||||
limit = tier.get("rate_limit") if isinstance(tier, dict) else None
|
||||
return limit if isinstance(limit, int) and limit > 0 else None
|
||||
|
||||
Reference in New Issue
Block a user