diff --git a/README.md b/README.md index 91dc79e8c..ddb8260c2 100644 --- a/README.md +++ b/README.md @@ -434,6 +434,43 @@ The users data file should be located near to the settings. Basic auth, read mor Note: You should enable authentication with -a (--httpauth) TorrServer startup option. +## Playback devices + +TorrServer can send a selected video to a playback agent running on another computer, TV box, or media server. This is useful when the Web UI is opened on a phone but VLC should start on the device connected to the TV. + +The feature is **disabled by default**. Until it is explicitly enabled, every browser opens VLC locally exactly as in previous TorrServer versions. + +Configure it in **Settings → Application → Remote playback**. Three routing modes are available: + +- **Each browser plays locally (legacy)** — preserves the previous behavior. +- **Always use one primary device** — every browser sends playback to one configured TV/media device. +- **Choose a device in each browser** — each browser remembers its own target. It may be marked **Control and playback** or **Control only**. + +Each remote device also has an independent **Open VLC in fullscreen** option. It is off by default, so adding a device does not change normal VLC window behavior unless the user asks for it. + +Device configuration is stored in the versioned `playback_devices.json` file next to the TorrServer settings. Agent URLs and bearer tokens stay on the server; the normal device list returned to browsers contains only device IDs and names. Enable TorrServer authentication when the Web UI is reachable by untrusted users. + +A playback agent implements two endpoints: + +```text +GET /health +POST /play +``` + +TorrServer sends the optional bearer token and the following JSON to `/play`: + +```json +{ + "path": "Movie.mkv", + "hash": "0123456789abcdef0123456789abcdef01234567", + "index": 1, + "stream_url": "http://torrserver:8090/stream/Movie.mkv?link=...&index=1&play=", + "fullscreen": false +} +``` + +Use **TorrServer URL for this device** when the agent reaches TorrServer through a different hostname or address than the browser. A reference [Linux/VLC playback agent](extras/vlc-agent/README.md) is included as an optional component. + ## Retrackers When adding a torrent, TorrServer can modify announce trackers according to **Settings → Additional → Retrackers**: diff --git a/extras/vlc-agent/.gitignore b/extras/vlc-agent/.gitignore new file mode 100644 index 000000000..7a60b85e1 --- /dev/null +++ b/extras/vlc-agent/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/extras/vlc-agent/README.md b/extras/vlc-agent/README.md new file mode 100644 index 000000000..b592eac4c --- /dev/null +++ b/extras/vlc-agent/README.md @@ -0,0 +1,127 @@ +# TorrServer VLC agent for Linux + +This optional agent lets TorrServer start VLC on a Linux computer connected to a TV while the Web UI is controlled from a phone or another computer. + +The agent is intentionally small: + +- Python standard library only; +- opens VLC in a normal window or fullscreen according to the selected device setting in TorrServer; +- replaces only the VLC process started by this agent; +- supports a bearer token; +- can restrict accepted TorrServer hostnames; +- validates the torrent hash, file index, filename, and stream URL before launching VLC; +- never executes a shell command. + +## Requirements + +- Linux with a graphical desktop; +- Python 3.10 or newer; +- VLC; +- systemd user services for the installer below. + +## Install + +Run this as the desktop user who should own the VLC window: + +```bash +cd extras/vlc-agent +./install-user-service.sh --listen-lan --allowed-host 192.168.1.10 +``` + +Replace `192.168.1.10` with the hostname or IP address used in TorrServer stream URLs. Repeat `--allowed-host` when the same TorrServer is reached by several names. + +The installer: + +- copies the agent to `~/.local/lib/torrserver-vlc-agent/`; +- installs a user service in `~/.config/systemd/user/`; +- creates `~/.config/torrserver-vlc-agent.env` with mode `0600`; +- generates a random bearer token; +- enables and starts the service. + +Read the generated token from the environment file and register the device in **TorrServer → Settings → Application → Playback devices**: + +```text +Name: Living room TV +Agent URL: http://PLAYER_IP:8092 +Agent token: value from VLC_AGENT_TOKEN +TorrServer URL for this device: http://TORRSERVER_IP:8090 +``` + +The last field is optional. Set it when the player reaches TorrServer through a different address than the browser, for example when the browser uses a public HTTPS name but the TV computer should stream over the LAN. + +## Configuration + +Edit: + +```text +~/.config/torrserver-vlc-agent.env +``` + +Then restart: + +```bash +systemctl --user restart torrserver-vlc-agent.service +``` + +Useful options: + +```text +VLC_AGENT_HOST=0.0.0.0 +VLC_AGENT_PORT=8092 +VLC_AGENT_ALLOWED_HOSTS=192.168.1.10,movies.example.net +VLC_AGENT_PLAYER=/usr/bin/vlc +VLC_AGENT_PLAYER_ARGS="--no-one-instance --no-video-title-show --network-caching=3000 --http-reconnect" +``` + +TorrServer sends the device's **Open VLC in fullscreen** checkbox with every play request. The agent appends either `--fullscreen` or `--no-fullscreen` after the configured arguments, so the checkbox has an unambiguous result and remains off by default. + +Additional VLC flags may be appended to `VLC_AGENT_PLAYER_ARGS`. Examples: + +- `--qt-dark-palette` for VLC builds that support the dark Qt palette; +- `--aout=pulse --no-spdif --stereo-mode=1` when HDMI passthrough causes audio problems. + +## Security + +The default listener is `127.0.0.1`. Direct LAN access requires `VLC_AGENT_HOST=0.0.0.0` or `--listen-lan`. + +A non-loopback listener refuses to start without a bearer token unless `--allow-unauthenticated-network` is explicitly used. That override is intended only for isolated test networks. + +Also restrict TCP port `8092` with the host firewall so only the TorrServer machine can reach it. `VLC_AGENT_ALLOWED_HOSTS` limits the hostname accepted inside `stream_url`; it does not replace a firewall or bearer token. + +## Desktop session troubleshooting + +The service runs as a systemd **user** service so VLC can connect to that user's display and audio session. On desktops that do not import graphical variables into the user manager, run this once from a terminal inside the desktop session: + +```bash +systemctl --user import-environment DISPLAY WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS PULSE_SERVER +systemctl --user restart torrserver-vlc-agent.service +``` + +Logs and status: + +```bash +systemctl --user status torrserver-vlc-agent.service +journalctl --user -u torrserver-vlc-agent.service -f +``` + +Health check: + +```bash +curl -H "Authorization: Bearer TOKEN_FROM_ENV_FILE" http://127.0.0.1:8092/health +``` + +## Run without installing + +The agent can be started directly: + +```bash +VLC_AGENT_TOKEN="TOKEN" \ +VLC_AGENT_ALLOWED_HOSTS="192.168.1.10" \ +python3 torrserver_vlc_agent.py --host 0.0.0.0 +``` + +## Tests + +```bash +python3 -m unittest -v test_agent.py +``` diff --git a/extras/vlc-agent/install-user-service.sh b/extras/vlc-agent/install-user-service.sh new file mode 100755 index 000000000..453acbd29 --- /dev/null +++ b/extras/vlc-agent/install-user-service.sh @@ -0,0 +1,81 @@ +#!/bin/sh +set -eu + +listen_host=127.0.0.1 +allowed_hosts= + +usage() { + cat <<'EOF' +Usage: ./install-user-service.sh [--listen-lan] [--allowed-host HOST] + + --listen-lan listen on 0.0.0.0 instead of loopback + --allowed-host HOST allow stream URLs from this TorrServer host; repeatable +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --listen-lan) + listen_host=0.0.0.0 + shift + ;; + --allowed-host) + [ "$#" -ge 2 ] || { echo "--allowed-host requires a value" >&2; exit 2; } + if [ -n "$allowed_hosts" ]; then + allowed_hosts="$allowed_hosts,$2" + else + allowed_hosts=$2 + fi + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +command -v python3 >/dev/null 2>&1 || { echo "python3 is required" >&2; exit 1; } +player=$(command -v vlc || true) +[ -n "$player" ] || { echo "VLC is required" >&2; exit 1; } +command -v systemctl >/dev/null 2>&1 || { echo "systemd is required" >&2; exit 1; } + +source_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +agent_dir="$HOME/.local/lib/torrserver-vlc-agent" +unit_dir="$HOME/.config/systemd/user" +env_file="$HOME/.config/torrserver-vlc-agent.env" +unit_file="$unit_dir/torrserver-vlc-agent.service" + +install -d -m 0755 "$agent_dir" "$unit_dir" +install -m 0755 "$source_dir/torrserver_vlc_agent.py" "$agent_dir/torrserver_vlc_agent.py" +install -m 0644 "$source_dir/torrserver-vlc-agent.service" "$unit_file" + +if [ ! -e "$env_file" ]; then + token=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))') + umask 077 + cat >"$env_file" < None: + self.calls: list[tuple[str, bool]] = [] + + def launch(self, stream_url: str, fullscreen: bool) -> None: + self.calls.append((stream_url, fullscreen)) + + def is_running(self) -> bool: + return bool(self.calls) + + +class AgentServerTest(unittest.TestCase): + def setUp(self) -> None: + self.player = RecordingPlayer() + self.config = Config( + host="127.0.0.1", + port=0, + token=TOKEN, + allowed_hosts=frozenset({"movies.local"}), + player="vlc", + player_args=(), + stop_timeout=1, + ) + self.server = AgentHTTPServer((self.config.host, 0), self.config, self.player) # type: ignore[arg-type] + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.base_url = f"http://127.0.0.1:{self.server.server_address[1]}" + + def tearDown(self) -> None: + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=2) + + def request(self, path: str, *, method: str = "GET", payload: dict | None = None, token: str | None = None): + data = None + headers = {} + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + if token is not None: + headers["Authorization"] = f"Bearer {token}" + request = Request(self.base_url + path, data=data, headers=headers, method=method) + return urlopen(request, timeout=3) + + def test_health_requires_configured_token(self) -> None: + with self.assertRaises(HTTPError) as unauthorized: + self.request("/health") + self.assertEqual(unauthorized.exception.code, 401) + + with self.request("/health", token=TOKEN) as response: + self.assertEqual(response.status, 200) + self.assertFalse(json.load(response)["player_running"]) + + def test_valid_play_request_starts_player(self) -> None: + payload = { + "path": "Movie.mkv", + "hash": HASH, + "index": 1, + "stream_url": STREAM_URL, + "fullscreen": True, + } + with self.request("/play", method="POST", payload=payload, token=TOKEN) as response: + self.assertEqual(response.status, 202) + self.assertEqual(self.player.calls, [(STREAM_URL, True)]) + + def test_disallowed_stream_host_is_rejected(self) -> None: + payload = { + "path": "Movie.mkv", + "hash": HASH, + "index": 1, + "stream_url": STREAM_URL.replace("movies.local", "other.local"), + } + with self.assertRaises(HTTPError) as rejected: + self.request("/play", method="POST", payload=payload, token=TOKEN) + self.assertEqual(rejected.exception.code, 400) + self.assertEqual(self.player.calls, []) + + +class ValidationTest(unittest.TestCase): + def test_request_fields_must_match_stream_url(self) -> None: + request = validate_play_request( + {"path": "folder/Movie.mkv", "hash": HASH.upper(), "index": 1, "stream_url": STREAM_URL}, + frozenset({"movies.local"}), + ) + self.assertEqual(request.path, "Movie.mkv") + self.assertEqual(request.hash, HASH) + self.assertFalse(request.fullscreen) + + fullscreen_request = validate_play_request( + { + "path": "Movie.mkv", + "hash": HASH, + "index": 1, + "stream_url": STREAM_URL, + "fullscreen": True, + }, + frozenset({"movies.local"}), + ) + self.assertTrue(fullscreen_request.fullscreen) + + invalid_payloads = [ + {"path": "Movie.mkv", "hash": "bad", "index": 1, "stream_url": STREAM_URL}, + {"path": "Other.mkv", "hash": HASH, "index": 1, "stream_url": STREAM_URL}, + {"path": "Movie.mkv", "hash": HASH, "index": 2, "stream_url": STREAM_URL}, + {"path": "Movie.mkv", "hash": HASH, "index": 1, "stream_url": STREAM_URL, "fullscreen": "yes"}, + { + "path": "Movie.mkv", + "hash": HASH, + "index": 1, + "stream_url": STREAM_URL.replace("play=", "missing="), + }, + ] + for payload in invalid_payloads: + with self.subTest(payload=payload), self.assertRaises(ValueError): + validate_play_request(payload, frozenset({"movies.local"})) + + def test_network_listener_requires_token_by_default(self) -> None: + with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + parse_config(["--host", "0.0.0.0"]) + + config = parse_config(["--host", "0.0.0.0", "--token", TOKEN]) + self.assertEqual(config.token, TOKEN) + + +class PlayerManagerTest(unittest.TestCase): + @patch("torrserver_vlc_agent.subprocess.Popen") + def test_manager_appends_requested_window_mode(self, popen: Mock) -> None: + process = Mock() + process.poll.return_value = 0 + popen.return_value = process + manager = PlayerManager("vlc", ("--no-one-instance", "--fullscreen"), stop_timeout=1) + + manager.launch("http://example/windowed", False) + command = popen.call_args.args[0] + self.assertEqual(command[-2:], ["--no-fullscreen", "http://example/windowed"]) + + manager.launch("http://example/fullscreen", True) + command = popen.call_args.args[0] + self.assertEqual(command[-2:], ["--fullscreen", "http://example/fullscreen"]) + + def test_manager_replaces_only_its_own_process(self) -> None: + manager = PlayerManager( + sys.executable, + ("-c", "import time; time.sleep(30)"), + stop_timeout=1, + ) + try: + manager.launch("http://example/first", False) + first = manager._process # noqa: SLF001 - intentional white-box lifecycle test + self.assertIsNotNone(first) + self.assertTrue(manager.is_running()) + + manager.launch("http://example/second", True) + second = manager._process # noqa: SLF001 + self.assertIsNotNone(second) + self.assertNotEqual(first.pid, second.pid) + self.assertIsNotNone(first.poll()) + self.assertTrue(manager.is_running()) + finally: + manager.stop() + self.assertFalse(manager.is_running()) + + +if __name__ == "__main__": + unittest.main() diff --git a/extras/vlc-agent/torrserver-vlc-agent.service b/extras/vlc-agent/torrserver-vlc-agent.service new file mode 100644 index 000000000..6faf23696 --- /dev/null +++ b/extras/vlc-agent/torrserver-vlc-agent.service @@ -0,0 +1,18 @@ +[Unit] +Description=TorrServer VLC playback agent +Documentation=https://github.com/YouROK/TorrServer +After=network-online.target graphical-session.target +Wants=network-online.target +PartOf=graphical-session.target + +[Service] +Type=simple +EnvironmentFile=%h/.config/torrserver-vlc-agent.env +ExecStart=/usr/bin/python3 %h/.local/lib/torrserver-vlc-agent/torrserver_vlc_agent.py +Restart=on-failure +RestartSec=2 +KillMode=control-group +NoNewPrivileges=true + +[Install] +WantedBy=default.target diff --git a/extras/vlc-agent/torrserver_vlc_agent.py b/extras/vlc-agent/torrserver_vlc_agent.py new file mode 100755 index 000000000..b2718f88f --- /dev/null +++ b/extras/vlc-agent/torrserver_vlc_agent.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +"""Small HTTP agent that opens TorrServer streams in VLC on this Linux desktop.""" + +from __future__ import annotations + +import argparse +import hmac +import ipaddress +import json +import logging +import os +import re +import shlex +import signal +import subprocess +import threading +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import PurePath +from typing import Any, Iterable +from urllib.parse import parse_qs, unquote, urlparse + +MAX_BODY_BYTES = 16 * 1024 +MAX_STREAM_URL_LENGTH = 8192 +HASH_PATTERN = re.compile(r"^[0-9a-fA-F]{40}$") +DEFAULT_PLAYER_ARGS = ( + "--no-one-instance", + "--no-video-title-show", + "--network-caching=3000", + "--http-reconnect", +) + + +@dataclass(frozen=True) +class Config: + host: str + port: int + token: str + allowed_hosts: frozenset[str] + player: str + player_args: tuple[str, ...] + stop_timeout: float + + +@dataclass(frozen=True) +class PlayRequest: + path: str + hash: str + index: int + stream_url: str + fullscreen: bool + + +class PlayerManager: + """Owns one player process and never touches unrelated VLC instances.""" + + def __init__(self, player: str, player_args: Iterable[str], stop_timeout: float) -> None: + self._player = player + self._player_args = tuple(player_args) + self._stop_timeout = stop_timeout + self._process: subprocess.Popen[bytes] | None = None + self._lock = threading.Lock() + + def launch(self, stream_url: str, fullscreen: bool) -> None: + with self._lock: + self._stop_locked() + window_mode = "--fullscreen" if fullscreen else "--no-fullscreen" + command = [self._player, *self._player_args, window_mode, stream_url] + logging.info("Starting player: %s", " ".join(shlex.quote(value) for value in command)) + self._process = subprocess.Popen( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=None, + start_new_session=True, + close_fds=True, + ) + + def stop(self) -> None: + with self._lock: + self._stop_locked() + + def is_running(self) -> bool: + with self._lock: + return self._process is not None and self._process.poll() is None + + def _stop_locked(self) -> None: + process = self._process + self._process = None + if process is None or process.poll() is not None: + return + logging.info("Stopping previous player process group %s", process.pid) + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + return + try: + process.wait(timeout=self._stop_timeout) + except subprocess.TimeoutExpired: + logging.warning("Player did not stop in %.1fs; killing it", self._stop_timeout) + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=self._stop_timeout) + + +class AgentHTTPServer(ThreadingHTTPServer): + daemon_threads = True + allow_reuse_address = True + + def __init__(self, address: tuple[str, int], config: Config, player: PlayerManager) -> None: + super().__init__(address, AgentHandler) + self.config = config + self.player = player + + +class AgentHandler(BaseHTTPRequestHandler): + server: AgentHTTPServer + server_version = "TorrServerVLCAgent/1.0" + + def do_GET(self) -> None: # noqa: N802 + if self.path != "/health": + self._json(404, {"ok": False, "error": "not found"}) + return + if not self._authorized(): + self._unauthorized() + return + self._json(200, {"ok": True, "player_running": self.server.player.is_running()}) + + def do_POST(self) -> None: # noqa: N802 + if self.path != "/play": + self._json(404, {"ok": False, "error": "not found"}) + return + if not self._authorized(): + self._unauthorized() + return + + try: + payload = self._read_json() + request = validate_play_request(payload, self.server.config.allowed_hosts) + self.server.player.launch(request.stream_url, request.fullscreen) + except ValueError as error: + self._json(400, {"ok": False, "error": str(error)}) + return + except OSError as error: + logging.exception("Failed to start player") + self._json(500, {"ok": False, "error": f"failed to start player: {error}"}) + return + + self._json(202, {"ok": True}) + + def _authorized(self) -> bool: + expected = self.server.config.token + if not expected: + return True + header = self.headers.get("Authorization", "") + prefix = "Bearer " + if not header.startswith(prefix): + return False + return hmac.compare_digest(header[len(prefix) :].encode(), expected.encode()) + + def _unauthorized(self) -> None: + body = json.dumps({"ok": False, "error": "unauthorized"}).encode("utf-8") + self.send_response(401) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("WWW-Authenticate", "Bearer") + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def _read_json(self) -> dict[str, Any]: + content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() + if content_type != "application/json": + raise ValueError("content type must be application/json") + try: + length = int(self.headers.get("Content-Length", "0")) + except ValueError as error: + raise ValueError("invalid content length") from error + if length <= 0 or length > MAX_BODY_BYTES: + raise ValueError("invalid request body size") + try: + payload = json.loads(self.rfile.read(length)) + except json.JSONDecodeError as error: + raise ValueError("invalid JSON") from error + if not isinstance(payload, dict): + raise ValueError("request body must be a JSON object") + return payload + + def _json(self, status: int, payload: dict[str, Any]) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def log_message(self, fmt: str, *args: object) -> None: + logging.info("%s - %s", self.client_address[0], fmt % args) + + +def validate_play_request(payload: dict[str, Any], allowed_hosts: frozenset[str]) -> PlayRequest: + raw_path = payload.get("path") + raw_hash = payload.get("hash") + raw_index = payload.get("index") + raw_stream_url = payload.get("stream_url") + raw_fullscreen = payload.get("fullscreen", False) + + if not isinstance(raw_path, str) or not raw_path.strip(): + raise ValueError("path is required") + file_name = PurePath(raw_path.replace("\\", "/")).name + if not file_name or file_name in {".", ".."} or len(file_name) > 1024: + raise ValueError("invalid path") + + if not isinstance(raw_hash, str) or not HASH_PATTERN.fullmatch(raw_hash): + raise ValueError("invalid torrent hash") + normalized_hash = raw_hash.lower() + + if isinstance(raw_index, bool): + raise ValueError("invalid file index") + try: + index = int(raw_index) + except (TypeError, ValueError) as error: + raise ValueError("invalid file index") from error + if index < 0 or index > 100_000: + raise ValueError("invalid file index") + if not isinstance(raw_fullscreen, bool): + raise ValueError("fullscreen must be a boolean") + + if not isinstance(raw_stream_url, str) or not raw_stream_url: + raise ValueError("stream_url is required") + if len(raw_stream_url) > MAX_STREAM_URL_LENGTH: + raise ValueError("stream_url is too long") + + parsed = urlparse(raw_stream_url) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise ValueError("stream_url must use http or https") + if parsed.username is not None or parsed.password is not None or parsed.fragment: + raise ValueError("stream_url must not contain credentials or a fragment") + if allowed_hosts and parsed.hostname.lower() not in allowed_hosts: + raise ValueError("stream_url host is not allowed") + + query = parse_qs(parsed.query, keep_blank_values=True) + if query.get("link", [""])[0].lower() != normalized_hash: + raise ValueError("stream_url torrent hash does not match the request") + if query.get("index", [""])[0] != str(index): + raise ValueError("stream_url file index does not match the request") + if "play" not in query: + raise ValueError("stream_url is missing the play parameter") + + stream_name = unquote(parsed.path.rsplit("/", 1)[-1]) + if stream_name != file_name: + raise ValueError("stream_url filename does not match the request") + + return PlayRequest( + path=file_name, + hash=normalized_hash, + index=index, + stream_url=raw_stream_url, + fullscreen=raw_fullscreen, + ) + + +def parse_config(argv: list[str] | None = None) -> Config: + env_player_args = shlex.split(os.getenv("VLC_AGENT_PLAYER_ARGS", "")) + env_allowed_hosts = split_csv(os.getenv("VLC_AGENT_ALLOWED_HOSTS", "")) + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", default=os.getenv("VLC_AGENT_HOST", "127.0.0.1")) + parser.add_argument("--port", type=int, default=int(os.getenv("VLC_AGENT_PORT", "8092"))) + parser.add_argument("--token", default=os.getenv("VLC_AGENT_TOKEN", "")) + parser.add_argument("--allowed-host", action="append", default=[]) + parser.add_argument("--player", default=os.getenv("VLC_AGENT_PLAYER", "vlc")) + parser.add_argument("--player-arg", action="append", default=[]) + parser.add_argument( + "--stop-timeout", + type=float, + default=float(os.getenv("VLC_AGENT_STOP_TIMEOUT", "3")), + ) + parser.add_argument( + "--allow-unauthenticated-network", + action="store_true", + help="allow a non-loopback listener without a bearer token", + ) + args = parser.parse_args(argv) + + if not 1 <= args.port <= 65535: + parser.error("port must be between 1 and 65535") + if args.stop_timeout <= 0: + parser.error("stop timeout must be positive") + if not args.player.strip(): + parser.error("player command is required") + if not args.token and not is_loopback_host(args.host) and not args.allow_unauthenticated_network: + parser.error("a bearer token is required for a non-loopback listener") + + player_args = tuple(args.player_arg or env_player_args or DEFAULT_PLAYER_ARGS) + allowed_hosts = frozenset(host.lower() for host in [*env_allowed_hosts, *args.allowed_host] if host) + return Config( + host=args.host, + port=args.port, + token=args.token, + allowed_hosts=allowed_hosts, + player=args.player, + player_args=player_args, + stop_timeout=args.stop_timeout, + ) + + +def split_csv(value: str) -> list[str]: + return [part.strip() for part in value.split(",") if part.strip()] + + +def is_loopback_host(host: str) -> bool: + if host.lower() == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +def run(config: Config) -> None: + player = PlayerManager(config.player, config.player_args, config.stop_timeout) + server = AgentHTTPServer((config.host, config.port), config, player) + + def request_shutdown(signum: int, _frame: object) -> None: + logging.info("Received signal %s; shutting down", signum) + threading.Thread(target=server.shutdown, daemon=True).start() + + signal.signal(signal.SIGINT, request_shutdown) + signal.signal(signal.SIGTERM, request_shutdown) + + logging.info("TorrServer VLC agent listening on http://%s:%d", config.host, config.port) + if config.allowed_hosts: + logging.info("Allowed TorrServer hosts: %s", ", ".join(sorted(config.allowed_hosts))) + try: + server.serve_forever(poll_interval=0.25) + finally: + server.server_close() + player.stop() + + +def main() -> None: + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + run(parse_config()) + + +if __name__ == "__main__": + main() diff --git a/extras/vlc-agent/vlc-agent.env.example b/extras/vlc-agent/vlc-agent.env.example new file mode 100644 index 000000000..445cb4bcf --- /dev/null +++ b/extras/vlc-agent/vlc-agent.env.example @@ -0,0 +1,14 @@ +# Listen on 127.0.0.1 for a local reverse proxy, or 0.0.0.0 for direct LAN access. +VLC_AGENT_HOST=127.0.0.1 +VLC_AGENT_PORT=8092 + +# Required when listening outside loopback. The installer generates this value. +VLC_AGENT_TOKEN= + +# Optional comma-separated TorrServer hostnames or IP addresses accepted in stream_url. +# Example: 192.168.1.10,movies.example.net +VLC_AGENT_ALLOWED_HOSTS= + +VLC_AGENT_PLAYER=/usr/bin/vlc +VLC_AGENT_PLAYER_ARGS="--no-one-instance --no-video-title-show --network-caching=3000 --http-reconnect" +VLC_AGENT_STOP_TIMEOUT=3 diff --git a/server/playback/devices.go b/server/playback/devices.go new file mode 100644 index 000000000..aef2fbe41 --- /dev/null +++ b/server/playback/devices.go @@ -0,0 +1,549 @@ +package playback + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" +) + +const ( + configFileName = "playback_devices.json" + configVersion = 1 + + RoutingLocal = "local" + RoutingPrimary = "primary" + RoutingPerBrowser = "per-browser" +) + +var ( + idPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) + ErrRemotePlaybackDisabled = errors.New("remote playback is disabled") + ErrPrimaryDeviceNotSelected = errors.New("primary playback device is not selected") +) + +// Device is the private, persisted playback-device configuration. +// Endpoint and Token are intentionally never exposed by PublicConfig. +type Device struct { + ID string `json:"id"` + Name string `json:"name"` + Endpoint string `json:"endpoint"` + Token string `json:"token,omitempty"` + StreamBaseURL string `json:"stream_base_url,omitempty"` + Fullscreen bool `json:"fullscreen,omitempty"` +} + +// PublicDevice is safe to return to normal web clients. +type PublicDevice struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// ManagedDevice is returned only by the authenticated management endpoint. +// The token itself is never returned. +type ManagedDevice struct { + ID string `json:"id"` + Name string `json:"name"` + Endpoint string `json:"endpoint"` + StreamBaseURL string `json:"stream_base_url,omitempty"` + Fullscreen bool `json:"fullscreen"` + HasToken bool `json:"has_token"` +} + +// DeviceInput is used for create/update operations. An empty token preserves +// the current token. ClearToken explicitly removes it when no replacement is supplied. +type DeviceInput struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Endpoint string `json:"endpoint"` + Token string `json:"token,omitempty"` + ClearToken bool `json:"clear_token,omitempty"` + StreamBaseURL string `json:"stream_base_url,omitempty"` + Fullscreen bool `json:"fullscreen"` +} + +// Settings controls whether the new routing behavior is enabled. Local routing +// preserves the historical behavior where each browser opens its own player. +type Settings struct { + Enabled bool `json:"enabled"` + RoutingMode string `json:"routing_mode"` + PrimaryDeviceID string `json:"primary_device_id,omitempty"` +} + +type PublicConfig struct { + Settings + Devices []PublicDevice `json:"devices"` +} + +type ManagedConfig struct { + Settings + Devices []ManagedDevice `json:"devices"` +} + +type persistedConfig struct { + Version int `json:"version"` + Settings + Devices []Device `json:"devices"` +} + +// AgentPlayRequest is the contract sent by TorrServer to a playback agent. +type AgentPlayRequest struct { + Path string `json:"path"` + Hash string `json:"hash"` + Index int `json:"index"` + StreamURL string `json:"stream_url"` + Fullscreen bool `json:"fullscreen"` +} + +type store struct { + mu sync.RWMutex + path string + readOnly bool + settings Settings + devices map[string]Device + client *http.Client +} + +var global = &store{ + settings: defaultSettings(), + devices: make(map[string]Device), + client: &http.Client{ + Timeout: 12 * time.Second, + }, +} + +func defaultSettings() Settings { + return Settings{Enabled: false, RoutingMode: RoutingLocal} +} + +// Init loads playback devices from the TorrServer configuration directory. +func Init(configDir string, readOnly bool) error { + global.mu.Lock() + defer global.mu.Unlock() + + global.path = filepath.Join(configDir, configFileName) + global.readOnly = readOnly + global.settings = defaultSettings() + global.devices = make(map[string]Device) + + buf, err := os.ReadFile(global.path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("read playback devices: %w", err) + } + + config, err := decodeConfig(buf) + if err != nil { + return err + } + settings, err := normalizeSettings(config.Settings, nil) + if err != nil { + return fmt.Errorf("invalid playback settings: %w", err) + } + + for _, device := range config.Devices { + normalized, err := normalizeDevice(device) + if err != nil { + return fmt.Errorf("invalid playback device %q: %w", device.ID, err) + } + global.devices[normalized.ID] = normalized + } + if settings.PrimaryDeviceID != "" { + if _, ok := global.devices[settings.PrimaryDeviceID]; !ok { + settings = defaultSettings() + } + } + global.settings = settings + return nil +} + +func decodeConfig(buf []byte) (persistedConfig, error) { + trimmed := strings.TrimSpace(string(buf)) + if strings.HasPrefix(trimmed, "[") { + // Backward compatibility with the first prototype, which stored only an array. + var devices []Device + if err := json.Unmarshal(buf, &devices); err != nil { + return persistedConfig{}, fmt.Errorf("decode playback devices: %w", err) + } + return persistedConfig{Version: configVersion, Settings: defaultSettings(), Devices: devices}, nil + } + + var config persistedConfig + if err := json.Unmarshal(buf, &config); err != nil { + return persistedConfig{}, fmt.Errorf("decode playback devices: %w", err) + } + if config.Version == 0 { + config.Version = configVersion + } + if config.RoutingMode == "" { + config.RoutingMode = RoutingLocal + } + return config, nil +} + +func GetPublicConfig() PublicConfig { + global.mu.RLock() + defer global.mu.RUnlock() + return PublicConfig{Settings: global.settings, Devices: publicDevicesLocked()} +} + +func GetManagedConfig() ManagedConfig { + global.mu.RLock() + defer global.mu.RUnlock() + + devices := make([]ManagedDevice, 0, len(global.devices)) + for _, device := range global.devices { + devices = append(devices, ManagedDevice{ + ID: device.ID, + Name: device.Name, + Endpoint: device.Endpoint, + StreamBaseURL: device.StreamBaseURL, + Fullscreen: device.Fullscreen, + HasToken: device.Token != "", + }) + } + sort.Slice(devices, func(i, j int) bool { + return strings.ToLower(devices[i].Name) < strings.ToLower(devices[j].Name) + }) + return ManagedConfig{Settings: global.settings, Devices: devices} +} + +func publicDevicesLocked() []PublicDevice { + devices := make([]PublicDevice, 0, len(global.devices)) + for _, device := range global.devices { + devices = append(devices, PublicDevice{ID: device.ID, Name: device.Name}) + } + sort.Slice(devices, func(i, j int) bool { + return strings.ToLower(devices[i].Name) < strings.ToLower(devices[j].Name) + }) + return devices +} + +// GetPrivate is intended only for server-side playback routing. +// Token is omitted from the returned copy. +func GetPrivate(id string) (Device, bool) { + global.mu.RLock() + defer global.mu.RUnlock() + device, ok := global.devices[id] + if !ok { + return Device{}, false + } + device.Token = "" + return device, true +} + +func UpdateSettings(input Settings) (Settings, error) { + global.mu.Lock() + defer global.mu.Unlock() + + if global.readOnly { + return Settings{}, errors.New("playback devices are read-only") + } + settings, err := normalizeSettings(input, global.devices) + if err != nil { + return Settings{}, err + } + previous := global.settings + global.settings = settings + if err := global.saveLocked(); err != nil { + global.settings = previous + return Settings{}, err + } + return global.settings, nil +} + +func normalizeSettings(settings Settings, devices map[string]Device) (Settings, error) { + settings.RoutingMode = strings.TrimSpace(settings.RoutingMode) + settings.PrimaryDeviceID = strings.TrimSpace(settings.PrimaryDeviceID) + if settings.RoutingMode == "" { + settings.RoutingMode = RoutingLocal + } + switch settings.RoutingMode { + case RoutingLocal, RoutingPrimary, RoutingPerBrowser: + default: + return Settings{}, errors.New("invalid playback routing mode") + } + if settings.RoutingMode == RoutingPrimary && settings.Enabled { + if settings.PrimaryDeviceID == "" { + return Settings{}, ErrPrimaryDeviceNotSelected + } + if devices != nil { + if _, ok := devices[settings.PrimaryDeviceID]; !ok { + return Settings{}, os.ErrNotExist + } + } + } + return settings, nil +} + +func Upsert(input DeviceInput) (PublicDevice, error) { + global.mu.Lock() + defer global.mu.Unlock() + + if global.readOnly { + return PublicDevice{}, errors.New("playback devices are read-only") + } + + id := strings.TrimSpace(input.ID) + if id == "" { + var err error + id, err = randomID() + if err != nil { + return PublicDevice{}, err + } + } + + existing := global.devices[id] + token := strings.TrimSpace(input.Token) + if token == "" && !input.ClearToken { + token = existing.Token + } + + device, err := normalizeDevice(Device{ + ID: id, + Name: input.Name, + Endpoint: input.Endpoint, + Token: token, + StreamBaseURL: input.StreamBaseURL, + Fullscreen: input.Fullscreen, + }) + if err != nil { + return PublicDevice{}, err + } + + global.devices[id] = device + if err := global.saveLocked(); err != nil { + if existing.ID == "" { + delete(global.devices, id) + } else { + global.devices[id] = existing + } + return PublicDevice{}, err + } + + return PublicDevice{ID: device.ID, Name: device.Name}, nil +} + +func Delete(id string) error { + global.mu.Lock() + defer global.mu.Unlock() + + if global.readOnly { + return errors.New("playback devices are read-only") + } + device, ok := global.devices[id] + if !ok { + return os.ErrNotExist + } + previousSettings := global.settings + delete(global.devices, id) + if global.settings.PrimaryDeviceID == id { + global.settings.PrimaryDeviceID = "" + global.settings.Enabled = false + global.settings.RoutingMode = RoutingLocal + } + if err := global.saveLocked(); err != nil { + global.devices[id] = device + global.settings = previousSettings + return err + } + return nil +} + +// ResolveTarget enforces the server-wide routing mode. In primary mode the +// browser cannot override the configured main playback device. +func ResolveTarget(requestedID string) (string, error) { + global.mu.RLock() + defer global.mu.RUnlock() + + if !global.settings.Enabled || global.settings.RoutingMode == RoutingLocal { + return "", ErrRemotePlaybackDisabled + } + if global.settings.RoutingMode == RoutingPrimary { + if global.settings.PrimaryDeviceID == "" { + return "", ErrPrimaryDeviceNotSelected + } + if _, ok := global.devices[global.settings.PrimaryDeviceID]; !ok { + return "", os.ErrNotExist + } + return global.settings.PrimaryDeviceID, nil + } + requestedID = strings.TrimSpace(requestedID) + if requestedID == "" { + return "", errors.New("playback device is not selected") + } + if _, ok := global.devices[requestedID]; !ok { + return "", os.ErrNotExist + } + return requestedID, nil +} + +func Play(deviceID string, payload AgentPlayRequest) error { + device, err := getDevice(deviceID) + if err != nil { + return err + } + payload.Fullscreen = device.Fullscreen + return sendJSON(device, http.MethodPost, "play", payload) +} + +func Test(deviceID string) error { + device, err := getDevice(deviceID) + if err != nil { + return err + } + return sendJSON(device, http.MethodGet, "health", nil) +} + +func getDevice(id string) (Device, error) { + global.mu.RLock() + defer global.mu.RUnlock() + device, ok := global.devices[id] + if !ok { + return Device{}, os.ErrNotExist + } + return device, nil +} + +func sendJSON(device Device, method, endpointPath string, payload any) error { + base, err := url.Parse(device.Endpoint) + if err != nil { + return err + } + base.Path = strings.TrimRight(base.Path, "/") + "/" + endpointPath + + var body *strings.Reader + if payload == nil { + body = strings.NewReader("") + } else { + buf, err := json.Marshal(payload) + if err != nil { + return err + } + body = strings.NewReader(string(buf)) + } + + req, err := http.NewRequest(method, base.String(), body) + if err != nil { + return err + } + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + if device.Token != "" { + req.Header.Set("Authorization", "Bearer "+device.Token) + } + + resp, err := global.client.Do(req) + if err != nil { + return fmt.Errorf("playback device request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("playback device returned %s", resp.Status) + } + return nil +} + +func normalizeDevice(device Device) (Device, error) { + device.ID = strings.TrimSpace(device.ID) + device.Name = strings.TrimSpace(device.Name) + device.Endpoint = strings.TrimRight(strings.TrimSpace(device.Endpoint), "/") + device.StreamBaseURL = strings.TrimRight(strings.TrimSpace(device.StreamBaseURL), "/") + device.Token = strings.TrimSpace(device.Token) + + if !idPattern.MatchString(device.ID) { + return Device{}, errors.New("invalid device id") + } + if device.Name == "" || len(device.Name) > 80 { + return Device{}, errors.New("device name must contain 1-80 characters") + } + if len(device.Token) > 512 { + return Device{}, errors.New("device token is too long") + } + if err := validateBaseURL(device.Endpoint, true); err != nil { + return Device{}, fmt.Errorf("invalid endpoint: %w", err) + } + if err := validateBaseURL(device.StreamBaseURL, false); err != nil { + return Device{}, fmt.Errorf("invalid stream base URL: %w", err) + } + return device, nil +} + +func validateBaseURL(value string, required bool) error { + if value == "" { + if required { + return errors.New("URL is required") + } + return nil + } + parsed, err := url.Parse(value) + if err != nil { + return err + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return errors.New("only http and https are supported") + } + if parsed.Host == "" { + return errors.New("host is required") + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return errors.New("credentials, query and fragment are not allowed") + } + return nil +} + +func randomID() (string, error) { + buf := make([]byte, 8) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generate device id: %w", err) + } + return "player-" + hex.EncodeToString(buf), nil +} + +func (s *store) saveLocked() error { + devices := make([]Device, 0, len(s.devices)) + for _, device := range s.devices { + devices = append(devices, device) + } + sort.Slice(devices, func(i, j int) bool { return devices[i].ID < devices[j].ID }) + + buf, err := json.MarshalIndent(persistedConfig{ + Version: configVersion, + Settings: s.settings, + Devices: devices, + }, "", " ") + if err != nil { + return err + } + buf = append(buf, '\n') + + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, buf, 0o600); err != nil { + return err + } + if err := os.Chmod(tmp, 0o600); err != nil { + os.Remove(tmp) + return err + } + if err := os.Rename(tmp, s.path); err != nil { + os.Remove(tmp) + return err + } + return os.Chmod(s.path, 0o600) +} diff --git a/server/playback/devices_test.go b/server/playback/devices_test.go new file mode 100644 index 000000000..5f5b89144 --- /dev/null +++ b/server/playback/devices_test.go @@ -0,0 +1,313 @@ +package playback + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" +) + +func TestConservativeDefaults(t *testing.T) { + if err := Init(t.TempDir(), false); err != nil { + t.Fatal(err) + } + config := GetPublicConfig() + if config.Enabled || config.RoutingMode != RoutingLocal || len(config.Devices) != 0 { + t.Fatalf("unexpected defaults: %#v", config) + } + if _, err := ResolveTarget("anything"); !errors.Is(err, ErrRemotePlaybackDisabled) { + t.Fatalf("remote routing should be disabled by default, got %v", err) + } +} + +func TestLegacyArrayMigrationStaysDisabled(t *testing.T) { + dir := t.TempDir() + legacy := []Device{{ID: "tv", Name: "TV", Endpoint: "http://127.0.0.1:9000"}} + buf, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, configFileName), buf, 0o600); err != nil { + t.Fatal(err) + } + if err := Init(dir, false); err != nil { + t.Fatal(err) + } + config := GetPublicConfig() + if config.Enabled || config.RoutingMode != RoutingLocal || len(config.Devices) != 1 { + t.Fatalf("legacy migration enabled new behavior: %#v", config) + } +} + +func TestDevicePersistenceSettingsAndTokenRedaction(t *testing.T) { + dir := t.TempDir() + if err := Init(dir, false); err != nil { + t.Fatal(err) + } + + created, err := Upsert(DeviceInput{ + ID: "living-room", + Name: "Living room", + Endpoint: "http://127.0.0.1:9080", + Token: "secret", + StreamBaseURL: "http://192.168.1.10:8090", + Fullscreen: true, + }) + if err != nil { + t.Fatal(err) + } + if created.ID != "living-room" || created.Name != "Living room" { + t.Fatalf("unexpected public device: %#v", created) + } + + public := GetPublicConfig() + if len(public.Devices) != 1 || public.Devices[0].ID != "living-room" { + t.Fatalf("unexpected public config: %#v", public) + } + managed := GetManagedConfig() + if len(managed.Devices) != 1 || !managed.Devices[0].HasToken || !managed.Devices[0].Fullscreen { + t.Fatalf("unexpected managed config: %#v", managed) + } + + settings, err := UpdateSettings(Settings{ + Enabled: true, + RoutingMode: RoutingPrimary, + PrimaryDeviceID: "living-room", + }) + if err != nil { + t.Fatal(err) + } + if !settings.Enabled || settings.RoutingMode != RoutingPrimary { + t.Fatalf("settings were not saved: %#v", settings) + } + + // Empty token on update preserves the existing secret. + if _, err := Upsert(DeviceInput{ + ID: "living-room", + Name: "TV", + Endpoint: "http://127.0.0.1:9080", + StreamBaseURL: "http://192.168.1.10:8090", + Fullscreen: false, + }); err != nil { + t.Fatal(err) + } + managed = GetManagedConfig() + if !managed.Devices[0].HasToken || managed.Devices[0].Name != "TV" || managed.Devices[0].Fullscreen { + t.Fatalf("device update was not applied correctly: %#v", managed.Devices[0]) + } + + info, err := os.Stat(filepath.Join(dir, configFileName)) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("unexpected config permissions: %o", info.Mode().Perm()) + } + + if err := Init(dir, false); err != nil { + t.Fatal(err) + } + device, ok := getDeviceForTest("living-room") + if !ok || device.Token != "secret" { + t.Fatalf("token was not persisted: %#v", device) + } + reloaded := GetManagedConfig() + if !reloaded.Enabled || reloaded.RoutingMode != RoutingPrimary || reloaded.PrimaryDeviceID != "living-room" { + t.Fatalf("routing settings were not persisted: %#v", reloaded) + } + + // A replacement token takes priority over a stale clear checkbox. + if _, err := Upsert(DeviceInput{ + ID: "living-room", + Name: "TV", + Endpoint: "http://127.0.0.1:9080", + StreamBaseURL: "http://192.168.1.10:8090", + Token: "replacement", + ClearToken: true, + }); err != nil { + t.Fatal(err) + } + device, ok = getDeviceForTest("living-room") + if !ok || device.Token != "replacement" { + t.Fatalf("replacement token was discarded: %#v", device) + } + + if _, err := Upsert(DeviceInput{ + ID: "living-room", + Name: "TV", + Endpoint: "http://127.0.0.1:9080", + StreamBaseURL: "http://192.168.1.10:8090", + ClearToken: true, + }); err != nil { + t.Fatal(err) + } + if GetManagedConfig().Devices[0].HasToken { + t.Fatal("clear_token did not remove the secret") + } +} + +func TestRoutingModesAndPrimaryDeletion(t *testing.T) { + if err := Init(t.TempDir(), false); err != nil { + t.Fatal(err) + } + for _, input := range []DeviceInput{ + {ID: "living-room", Name: "Living room", Endpoint: "http://127.0.0.1:9001"}, + {ID: "bedroom", Name: "Bedroom", Endpoint: "http://127.0.0.1:9002"}, + } { + if _, err := Upsert(input); err != nil { + t.Fatal(err) + } + } + + if _, err := UpdateSettings(Settings{Enabled: true, RoutingMode: RoutingPrimary}); !errors.Is(err, ErrPrimaryDeviceNotSelected) { + t.Fatalf("primary mode without a device should fail, got %v", err) + } + if _, err := UpdateSettings(Settings{ + Enabled: true, + RoutingMode: RoutingPrimary, + PrimaryDeviceID: "missing", + }); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("unknown primary device should fail, got %v", err) + } + + if _, err := UpdateSettings(Settings{ + Enabled: true, + RoutingMode: RoutingPrimary, + PrimaryDeviceID: "living-room", + }); err != nil { + t.Fatal(err) + } + resolved, err := ResolveTarget("bedroom") + if err != nil || resolved != "living-room" { + t.Fatalf("browser overrode primary target: %q, %v", resolved, err) + } + + if _, err := UpdateSettings(Settings{Enabled: true, RoutingMode: RoutingPerBrowser}); err != nil { + t.Fatal(err) + } + resolved, err = ResolveTarget("bedroom") + if err != nil || resolved != "bedroom" { + t.Fatalf("per-browser target was not honored: %q, %v", resolved, err) + } + + if _, err := UpdateSettings(Settings{Enabled: true, RoutingMode: RoutingLocal}); err != nil { + t.Fatal(err) + } + if _, err := ResolveTarget("bedroom"); !errors.Is(err, ErrRemotePlaybackDisabled) { + t.Fatalf("local mode should reject remote routing, got %v", err) + } + + if _, err := UpdateSettings(Settings{ + Enabled: true, + RoutingMode: RoutingPrimary, + PrimaryDeviceID: "living-room", + }); err != nil { + t.Fatal(err) + } + if err := Delete("living-room"); err != nil { + t.Fatal(err) + } + settings := GetPublicConfig().Settings + if settings.Enabled || settings.RoutingMode != RoutingLocal || settings.PrimaryDeviceID != "" { + t.Fatalf("deleting primary device did not restore conservative mode: %#v", settings) + } +} + +func TestValidation(t *testing.T) { + if err := Init(t.TempDir(), false); err != nil { + t.Fatal(err) + } + + cases := []DeviceInput{ + {ID: "bad id", Name: "TV", Endpoint: "http://127.0.0.1:9000"}, + {ID: "tv", Name: "", Endpoint: "http://127.0.0.1:9000"}, + {ID: "tv", Name: "TV", Endpoint: "file:///tmp/socket"}, + {ID: "tv", Name: "TV", Endpoint: "http://user:pass@127.0.0.1:9000"}, + {ID: "tv", Name: "TV", Endpoint: "http://127.0.0.1:9000?x=1"}, + {ID: "tv", Name: "TV", Endpoint: "http://127.0.0.1:9000", StreamBaseURL: "ftp://host"}, + } + for _, input := range cases { + if _, err := Upsert(input); err == nil { + t.Fatalf("expected validation error for %#v", input) + } + } + if _, err := UpdateSettings(Settings{Enabled: true, RoutingMode: "unknown"}); err == nil { + t.Fatal("invalid routing mode was accepted") + } +} + +func TestAgentRequestsIncludeFullscreen(t *testing.T) { + var mu sync.Mutex + requests := make([]string, 0, 2) + + agent := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer agent-secret" { + t.Errorf("unexpected authorization: %q", got) + } + mu.Lock() + requests = append(requests, r.Method+" "+r.URL.Path) + mu.Unlock() + + switch r.URL.Path { + case "/health": + w.WriteHeader(http.StatusOK) + case "/play": + var payload AgentPlayRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Errorf("decode play request: %v", err) + } + if payload.StreamURL != "http://ts.local/stream/Movie.mkv?link=hash&index=1&play" { + t.Errorf("unexpected stream URL: %q", payload.StreamURL) + } + if !payload.Fullscreen { + t.Error("device fullscreen preference was not sent") + } + w.WriteHeader(http.StatusAccepted) + default: + http.NotFound(w, r) + } + })) + defer agent.Close() + + if err := Init(t.TempDir(), false); err != nil { + t.Fatal(err) + } + if _, err := Upsert(DeviceInput{ + ID: "tv", + Name: "TV", + Endpoint: agent.URL, + Token: "agent-secret", + Fullscreen: true, + }); err != nil { + t.Fatal(err) + } + + if err := Test("tv"); err != nil { + t.Fatal(err) + } + if err := Play("tv", AgentPlayRequest{ + Path: "Movie.mkv", + Hash: "hash", + Index: 1, + StreamURL: "http://ts.local/stream/Movie.mkv?link=hash&index=1&play", + }); err != nil { + t.Fatal(err) + } + + mu.Lock() + defer mu.Unlock() + if len(requests) != 2 || requests[0] != "GET /health" || requests[1] != "POST /play" { + t.Fatalf("unexpected requests: %#v", requests) + } +} + +func getDeviceForTest(id string) (Device, bool) { + global.mu.RLock() + defer global.mu.RUnlock() + device, ok := global.devices[id] + return device, ok +} diff --git a/server/server.go b/server/server.go index 8d6350f33..ad2af568a 100644 --- a/server/server.go +++ b/server/server.go @@ -10,6 +10,7 @@ import ( "server/log" "server/netbind" + "server/playback" "server/settings" "server/torr/utils" "server/web" @@ -17,6 +18,9 @@ import ( func Start() { settings.InitSets(settings.Args.RDB, settings.Args.SearchWA) + if err := playback.Init(settings.Path, settings.ReadOnly); err != nil { + log.TLogln("Playback devices init failed:", err) + } // https checks if settings.Args.Ssl { // set settings ssl enabled diff --git a/server/web/api/playback.go b/server/web/api/playback.go new file mode 100644 index 000000000..0ff1bf9da --- /dev/null +++ b/server/web/api/playback.go @@ -0,0 +1,190 @@ +package api + +import ( + "errors" + "net/http" + "net/url" + "os" + pathpkg "path" + "regexp" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + "server/playback" +) + +var playbackHashPattern = regexp.MustCompile(`^[0-9a-fA-F]{40}$`) + +type playbackRequest struct { + DeviceID string `json:"device_id,omitempty"` + Path string `json:"path" binding:"required"` + Hash string `json:"hash" binding:"required"` + Index int `json:"index"` +} + +func listPlaybackDevices(c *gin.Context) { + c.JSON(http.StatusOK, playback.GetPublicConfig()) +} + +func listPlaybackDeviceConfigs(c *gin.Context) { + c.JSON(http.StatusOK, playback.GetManagedConfig()) +} + +func updatePlaybackSettings(c *gin.Context) { + var input playback.Settings + if err := c.ShouldBindJSON(&input); err != nil { + c.AbortWithError(http.StatusBadRequest, err) + return + } + settings, err := playback.UpdateSettings(input) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + c.AbortWithStatus(http.StatusNotFound) + return + } + c.AbortWithError(http.StatusBadRequest, err) + return + } + c.JSON(http.StatusOK, settings) +} + +func upsertPlaybackDevice(c *gin.Context) { + var input playback.DeviceInput + if err := c.ShouldBindJSON(&input); err != nil { + c.AbortWithError(http.StatusBadRequest, err) + return + } + device, err := playback.Upsert(input) + if err != nil { + c.AbortWithError(http.StatusBadRequest, err) + return + } + c.JSON(http.StatusOK, device) +} + +func deletePlaybackDevice(c *gin.Context) { + if err := playback.Delete(c.Param("id")); err != nil { + if errors.Is(err, os.ErrNotExist) { + c.AbortWithStatus(http.StatusNotFound) + return + } + c.AbortWithError(http.StatusBadRequest, err) + return + } + c.Status(http.StatusNoContent) +} + +func testPlaybackDevice(c *gin.Context) { + if err := playback.Test(c.Param("id")); err != nil { + if errors.Is(err, os.ErrNotExist) { + c.AbortWithStatus(http.StatusNotFound) + return + } + c.AbortWithError(http.StatusBadGateway, err) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func playOnDevice(c *gin.Context) { + var req playbackRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.AbortWithError(http.StatusBadRequest, err) + return + } + + fileName, err := validatePlaybackRequest(req) + if err != nil { + c.AbortWithError(http.StatusBadRequest, err) + return + } + + deviceID, err := playback.ResolveTarget(req.DeviceID) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + c.AbortWithStatus(http.StatusNotFound) + return + } + c.AbortWithError(http.StatusBadRequest, err) + return + } + + streamURL, err := playbackStreamURL(c, deviceID, fileName, req.Hash, req.Index) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + c.AbortWithStatus(http.StatusNotFound) + return + } + c.AbortWithError(http.StatusBadRequest, err) + return + } + + if err := playback.Play(deviceID, playback.AgentPlayRequest{ + Path: fileName, + Hash: strings.ToLower(req.Hash), + Index: req.Index, + StreamURL: streamURL, + }); err != nil { + if errors.Is(err, os.ErrNotExist) { + c.AbortWithStatus(http.StatusNotFound) + return + } + c.AbortWithError(http.StatusBadGateway, err) + return + } + + c.JSON(http.StatusAccepted, gin.H{"ok": true, "device_id": deviceID}) +} + +func validatePlaybackRequest(req playbackRequest) (string, error) { + if !playbackHashPattern.MatchString(req.Hash) { + return "", errors.New("invalid torrent hash") + } + if req.Index < 0 || req.Index > 100000 { + return "", errors.New("invalid file index") + } + fileName := pathpkg.Base(strings.ReplaceAll(strings.TrimSpace(req.Path), "\\", "/")) + if fileName == "" || fileName == "." || fileName == ".." || len(fileName) > 1024 { + return "", errors.New("invalid file path") + } + return fileName, nil +} + +func playbackStreamURL(c *gin.Context, deviceID, fileName, hash string, index int) (string, error) { + device, ok := playback.GetPrivate(deviceID) + if !ok { + return "", os.ErrNotExist + } + + baseURL := device.StreamBaseURL + if baseURL == "" { + scheme := forwardedScheme(c) + baseURL = scheme + "://" + c.Request.Host + } + + parsed, err := url.Parse(baseURL) + if err != nil { + return "", err + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/stream/" + fileName + query := parsed.Query() + query.Set("link", strings.ToLower(hash)) + query.Set("index", strconv.Itoa(index)) + query.Set("play", "") + parsed.RawQuery = query.Encode() + return parsed.String(), nil +} + +func forwardedScheme(c *gin.Context) string { + if forwarded := c.GetHeader("X-Forwarded-Proto"); forwarded != "" { + if scheme := strings.TrimSpace(strings.Split(forwarded, ",")[0]); scheme == "http" || scheme == "https" { + return scheme + } + } + if c.Request.TLS != nil { + return "https" + } + return "http" +} diff --git a/server/web/api/playback_test.go b/server/web/api/playback_test.go new file mode 100644 index 000000000..cd0354d16 --- /dev/null +++ b/server/web/api/playback_test.go @@ -0,0 +1,95 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "server/playback" +) + +func TestValidatePlaybackRequest(t *testing.T) { + validHash := strings.Repeat("a", 40) + + fileName, err := validatePlaybackRequest(playbackRequest{ + Path: `folder\Movie.mkv`, + Hash: validHash, + Index: 3, + }) + if err != nil { + t.Fatal(err) + } + if fileName != "Movie.mkv" { + t.Fatalf("unexpected filename: %q", fileName) + } + + invalid := []playbackRequest{ + {Path: "Movie.mkv", Hash: "bad", Index: 1}, + {Path: "Movie.mkv", Hash: validHash, Index: -1}, + {Path: "Movie.mkv", Hash: validHash, Index: 100001}, + {Path: "..", Hash: validHash, Index: 1}, + } + for _, request := range invalid { + if _, err := validatePlaybackRequest(request); err == nil { + t.Fatalf("expected validation error for %#v", request) + } + } +} + +func TestPlaybackStreamURLUsesForwardedRequestAddress(t *testing.T) { + if err := playback.Init(t.TempDir(), false); err != nil { + t.Fatal(err) + } + if _, err := playback.Upsert(playback.DeviceInput{ + ID: "tv", + Name: "TV", + Endpoint: "http://127.0.0.1:9000", + }); err != nil { + t.Fatal(err) + } + + gin.SetMode(gin.TestMode) + request := httptest.NewRequest(http.MethodPost, "http://internal/playback/play", nil) + request.Host = "movies.example.com" + request.Header.Set("X-Forwarded-Proto", "https") + context, _ := gin.CreateTestContext(httptest.NewRecorder()) + context.Request = request + + streamURL, err := playbackStreamURL(context, "tv", "My Movie.mkv", strings.Repeat("b", 40), 7) + if err != nil { + t.Fatal(err) + } + expected := "https://movies.example.com/stream/My%20Movie.mkv?index=7&link=" + strings.Repeat("b", 40) + "&play=" + if streamURL != expected { + t.Fatalf("unexpected stream URL:\nwant %s\n got %s", expected, streamURL) + } +} + +func TestPlaybackStreamURLUsesDeviceOverride(t *testing.T) { + if err := playback.Init(t.TempDir(), false); err != nil { + t.Fatal(err) + } + if _, err := playback.Upsert(playback.DeviceInput{ + ID: "tv", + Name: "TV", + Endpoint: "http://127.0.0.1:9000", + StreamBaseURL: "http://192.168.1.10:8090/torrserver", + }); err != nil { + t.Fatal(err) + } + + context, _ := gin.CreateTestContext(httptest.NewRecorder()) + context.Request = httptest.NewRequest(http.MethodPost, "http://ignored/playback/play", nil) + + streamURL, err := playbackStreamURL(context, "tv", "Movie.mkv", strings.Repeat("c", 40), 1) + if err != nil { + t.Fatal(err) + } + expected := "http://192.168.1.10:8090/torrserver/stream/Movie.mkv?index=1&link=" + strings.Repeat("c", 40) + "&play=" + if streamURL != expected { + t.Fatalf("unexpected stream URL:\nwant %s\n got %s", expected, streamURL) + } +} diff --git a/server/web/api/route.go b/server/web/api/route.go index 6acb071b6..12bd422c5 100644 --- a/server/web/api/route.go +++ b/server/web/api/route.go @@ -22,6 +22,14 @@ func SetupRoute(route gin.IRouter) { authorized.POST("/waf", updateWAF) authorized.POST("/torznab/test", torznabTest) + authorized.GET("/playback/devices", listPlaybackDevices) + authorized.GET("/playback/devices/config", listPlaybackDeviceConfigs) + authorized.POST("/playback/settings", updatePlaybackSettings) + authorized.POST("/playback/devices", upsertPlaybackDevice) + authorized.DELETE("/playback/devices/:id", deletePlaybackDevice) + authorized.POST("/playback/devices/:id/test", testPlaybackDevice) + authorized.POST("/playback/play", playOnDevice) + authorized.POST("/torrents", torrents) authorized.POST("/torrent/upload", torrentUpload) diff --git a/web/src/components/App/index.jsx b/web/src/components/App/index.jsx index d469ed8c5..0ef278708 100644 --- a/web/src/components/App/index.jsx +++ b/web/src/components/App/index.jsx @@ -26,6 +26,7 @@ import GlobalStyle from 'style/GlobalStyle' import { /* lightTheme, */ THEME_MODES, useMaterialUITheme } from 'style/materialUISetup' import getStyledComponentsTheme from 'style/getStyledComponentsTheme' import SearchDialog from 'components/Search/SearchDialog' +import PlaybackTargetSelector from 'components/Playback/PlaybackTargetSelector' import { AppWrapper, AppHeader, HeaderToggle, StyledIconButton, SidebarOverlay } from './style' import Sidebar from './Sidebar' @@ -86,11 +87,13 @@ export default function App() {
+ + {isStandaloneApp && ( setIsSearchDialogOpen(true)}> diff --git a/web/src/components/DialogTorrentDetailsContent/Table/index.jsx b/web/src/components/DialogTorrentDetailsContent/Table/index.jsx index db7fc0eec..8f4c97d3f 100644 --- a/web/src/components/DialogTorrentDetailsContent/Table/index.jsx +++ b/web/src/components/DialogTorrentDetailsContent/Table/index.jsx @@ -2,9 +2,11 @@ import { streamHost } from 'utils/Hosts' import isEqual from 'lodash/isEqual' import { humanizeSize, detectStandaloneApp, isMacOS, isAppleDevice } from 'utils/Utils' import ptt from 'parse-torrent-title' -import { Button } from '@material-ui/core' +import { Button, CircularProgress, Snackbar } from '@material-ui/core' import CopyToClipboard from 'react-copy-to-clipboard' import { useTranslation } from 'react-i18next' +import { usePlayback } from 'playback/PlaybackContext' +import { useBooleanPlayerPreference } from 'utils/PlayerPreferences' import { gstreamerHeartbeatUrl, gstreamerMasterUrl, @@ -32,6 +34,9 @@ const Table = memo( ({ playableFileList, viewedFileList, selectedSeason, seasonAmount, hash }) => { const { t } = useTranslation() const [unsupportedPlayers, setUnsupportedPlayers] = useState({}) + const [vlcLaunchingFileId, setVlcLaunchingFileId] = useState(null) + const [vlcLaunchError, setVlcLaunchError] = useState(false) + const { launchVlc, selectedTarget } = usePlayback() const gstRuntime = useGStreamerRuntime() const preloadBuffer = fileId => fetch(`${streamHost()}?link=${hash}&index=${fileId}&preload`) const getFileLink = (path, id) => @@ -48,6 +53,17 @@ const Table = memo( const markPlayerUnsupported = key => { setUnsupportedPlayers(current => ({ ...current, [key]: true })) } + const openFileInVlc = async (path, id, streamUrl) => { + if (!selectedTarget) return + setVlcLaunchingFileId(id) + try { + await launchVlc({ path, hash, index: id, streamUrl }) + } catch (_) { + setVlcLaunchError(true) + } finally { + setVlcLaunchingFileId(null) + } + } const fileHasEpisodeText = !!playableFileList?.find(({ path }) => ptt.parse(path).episode) const fileHasSeasonText = !!playableFileList?.find(({ path }) => ptt.parse(path).season) const fileHasResolutionText = !!playableFileList?.find(({ path }) => ptt.parse(path).resolution) @@ -55,7 +71,7 @@ const Table = memo( // if files in list is more then 1 and no season text detected by ptt.parse, show full name const shouldDisplayFullFileName = playableFileList?.length > 1 && !fileHasEpisodeText - const isVlcUsed = JSON.parse(localStorage.getItem('isVlcUsed')) ?? false + const isVlcUsed = useBooleanPlayerPreference('isVlcUsed') const isInfuseUsed = JSON.parse(localStorage.getItem('isInfuseUsed')) ?? false const isSenPlayerUsed = JSON.parse(localStorage.getItem('isSenPlayerUsed')) ?? false const isIinaUsed = JSON.parse(localStorage.getItem('isIinaUsed')) ?? false @@ -124,11 +140,16 @@ const Table = memo( )} {isVlcUsed && ( - - - + )} {isMac && isIinaUsed && ( @@ -246,11 +267,16 @@ const Table = memo( )} {isVlcUsed && ( - - - + )} {isMac && isIinaUsed && ( @@ -291,6 +317,12 @@ const Table = memo( ) })} + setVlcLaunchError(false)} + message={t('Playback.LaunchFailed')} + /> ) }, diff --git a/web/src/components/Playback/PlaybackTargetSelector.jsx b/web/src/components/Playback/PlaybackTargetSelector.jsx new file mode 100644 index 000000000..375f954f0 --- /dev/null +++ b/web/src/components/Playback/PlaybackTargetSelector.jsx @@ -0,0 +1,78 @@ +import { FormControl, MenuItem, Select, Tooltip, useMediaQuery } from '@material-ui/core' +import { Tv as TvIcon } from '@material-ui/icons' +import { useTranslation } from 'react-i18next' +import { PLAYBACK_ROUTING_MODES, usePlayback } from 'playback/PlaybackContext' +import { useBooleanPlayerPreference } from 'utils/PlayerPreferences' + +const PlaybackTargetSelector = () => { + const { t } = useTranslation() + const isCompact = useMediaQuery('(max-width:700px)') + const { + isLoadingDevices, + remotePlaybackEnabled, + routingMode, + selectedTarget, + setTargetId, + showTargetSelector, + targetId, + targets, + } = usePlayback() + const isVlcUsed = useBooleanPlayerPreference('isVlcUsed') + + if (!isVlcUsed || !remotePlaybackEnabled || routingMode === PLAYBACK_ROUTING_MODES.LOCAL) return null + + const label = selectedTarget?.name || t('Playback.NoDevice') + const labelContent = ( + + + + {label} + + + ) + + if (!showTargetSelector) { + return ( + + {labelContent} + + ) + } + + return ( + + + + + + ) +} + +export default PlaybackTargetSelector diff --git a/web/src/components/Settings/PlaybackSettings.jsx b/web/src/components/Settings/PlaybackSettings.jsx new file mode 100644 index 000000000..2b0a9b003 --- /dev/null +++ b/web/src/components/Settings/PlaybackSettings.jsx @@ -0,0 +1,391 @@ +import axios from 'axios' +import { + Button, + Checkbox, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormControlLabel, + FormHelperText, + InputLabel, + MenuItem, + Select, + Snackbar, + Switch, + TextField, +} from '@material-ui/core' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { playbackDeviceConfigsHost, playbackDevicesHost, playbackSettingsHost } from 'utils/Hosts' +import { PLAYBACK_CLIENT_MODES, PLAYBACK_ROUTING_MODES, usePlayback } from 'playback/PlaybackContext' + +import { Divider, SecondarySettingsContent, SettingSectionLabel } from './style' + +const defaultServerSettings = { + enabled: false, + routing_mode: PLAYBACK_ROUTING_MODES.LOCAL, + primary_device_id: '', +} + +const emptyForm = { + id: '', + name: '', + endpoint: '', + stream_base_url: '', + token: '', + clear_token: false, + fullscreen: false, + has_token: false, +} + +const PlaybackSettings = () => { + const { t } = useTranslation() + const { mode, refreshPlaybackConfig, setMode } = usePlayback() + const [serverSettings, setServerSettings] = useState(defaultServerSettings) + const [configs, setConfigs] = useState([]) + const [form, setForm] = useState(emptyForm) + const [isLoading, setIsLoading] = useState(true) + const [isSavingSettings, setIsSavingSettings] = useState(false) + const [isSavingDevice, setIsSavingDevice] = useState(false) + const [testingId, setTestingId] = useState('') + const [deleteCandidate, setDeleteCandidate] = useState(null) + const [message, setMessage] = useState('') + const [error, setError] = useState('') + + const loadConfigs = useCallback(async () => { + setIsLoading(true) + try { + const { data } = await axios.get(playbackDeviceConfigsHost()) + if (Array.isArray(data)) { + setServerSettings(defaultServerSettings) + setConfigs(data) + } else { + setServerSettings({ + enabled: Boolean(data?.enabled), + routing_mode: data?.routing_mode || PLAYBACK_ROUTING_MODES.LOCAL, + primary_device_id: data?.primary_device_id || '', + }) + setConfigs(Array.isArray(data?.devices) ? data.devices : []) + } + } catch (_) { + setError(t('Playback.LoadFailed')) + } finally { + setIsLoading(false) + } + }, [t]) + + useEffect(() => { + loadConfigs() + }, [loadConfigs]) + + const updateForm = + key => + ({ target: { value, checked, type } }) => { + setForm(current => ({ ...current, [key]: type === 'checkbox' ? checked : value })) + } + + const updateServerSetting = + key => + ({ target: { value, checked, type } }) => { + setServerSettings(current => ({ ...current, [key]: type === 'checkbox' ? checked : value })) + } + + const editDevice = device => { + setForm({ + id: device.id, + name: device.name, + endpoint: device.endpoint, + stream_base_url: device.stream_base_url || '', + token: '', + clear_token: false, + fullscreen: Boolean(device.fullscreen), + has_token: Boolean(device.has_token), + }) + } + + const resetForm = () => setForm(emptyForm) + + const saveRoutingSettings = async () => { + setIsSavingSettings(true) + try { + await axios.post(playbackSettingsHost(), serverSettings) + setMessage(t('Playback.SettingsSaved')) + await Promise.all([loadConfigs(), refreshPlaybackConfig()]) + } catch (requestError) { + setError(requestError.response?.data?.error || t('Playback.SettingsSaveFailed')) + } finally { + setIsSavingSettings(false) + } + } + + const saveDevice = async () => { + setIsSavingDevice(true) + try { + await axios.post(playbackDevicesHost(), { + id: form.id || undefined, + name: form.name, + endpoint: form.endpoint, + stream_base_url: form.stream_base_url, + token: form.token, + clear_token: form.clear_token, + fullscreen: form.fullscreen, + }) + setMessage(t('Playback.DeviceSaved')) + resetForm() + await Promise.all([loadConfigs(), refreshPlaybackConfig()]) + } catch (requestError) { + setError(requestError.response?.data?.error || t('Playback.SaveFailed')) + } finally { + setIsSavingDevice(false) + } + } + + const deleteDevice = async () => { + if (!deleteCandidate) return + try { + await axios.delete(`${playbackDevicesHost()}/${encodeURIComponent(deleteCandidate.id)}`) + if (form.id === deleteCandidate.id) resetForm() + setMessage(t('Playback.DeviceDeleted')) + setDeleteCandidate(null) + await Promise.all([loadConfigs(), refreshPlaybackConfig()]) + } catch (_) { + setError(t('Playback.DeleteFailed')) + } + } + + const testDevice = async id => { + setTestingId(id) + try { + await axios.post(`${playbackDevicesHost()}/${encodeURIComponent(id)}/test`) + setMessage(t('Playback.TestSuccessful')) + } catch (_) { + setError(t('Playback.TestFailed')) + } finally { + setTestingId('') + } + } + + const primaryMode = serverSettings.routing_mode === PLAYBACK_ROUTING_MODES.PRIMARY + const perBrowserMode = serverSettings.routing_mode === PLAYBACK_ROUTING_MODES.PER_BROWSER + + return ( + + {t('Playback.RemotePlayback')} + + } + label={t('Playback.EnableRemotePlayback')} + labelPlacement='start' + /> + {t('Playback.EnableRemotePlaybackHint')} + + + {t('Playback.RoutingMode')} + + {t('Playback.RoutingModeHint')} + + + {serverSettings.enabled && primaryMode && ( + + {t('Playback.PrimaryDevice')} + + {t('Playback.PrimaryDeviceHint')} + + )} + + {serverSettings.enabled && perBrowserMode && ( + + {t('Playback.BrowserRole')} + + {t('Playback.BrowserRoleHint')} + + )} + +
+ +
+ + + + {t('Playback.RemoteDevices')} + {t('Playback.RemoteDevicesHint')} + + {isLoading ? ( +
+ +
+ ) : ( +
+ {configs.length === 0 && {t('Playback.NoRemoteDevices')}} + {configs.map(device => ( +
+
+
{device.name}
+
{device.endpoint}
+
+ {device.fullscreen ? t('Playback.FullscreenEnabled') : t('Playback.FullscreenDisabled')} +
+
+
+ + + +
+
+ ))} +
+ )} + + + + {form.id ? t('Playback.EditDevice') : t('Playback.AddDevice')} +
+ + + + + } + label={t('Playback.OpenFullscreen')} + /> + {t('Playback.OpenFullscreenHint')} + {form.id && form.has_token && ( + } + label={t('Playback.ClearToken')} + /> + )} +
+ {form.id && ( + + )} + +
+
+ + setDeleteCandidate(null)}> + {t('Playback.DeleteDevice')} + {t('Playback.DeleteConfirm', { name: deleteCandidate?.name || '' })} + + + + + + + setMessage('')} message={message} /> + setError('')} message={error} /> +
+ ) +} + +export default PlaybackSettings diff --git a/web/src/components/Settings/SettingsDialog.jsx b/web/src/components/Settings/SettingsDialog.jsx index 3b3e6a7c5..0e66c0425 100644 --- a/web/src/components/Settings/SettingsDialog.jsx +++ b/web/src/components/Settings/SettingsDialog.jsx @@ -23,6 +23,7 @@ import TorznabSettings from './TorznabSettings' import TMDBSettings from './TMDBSettings' import GStreamerSettings from './GStreamerSettings' import WAFSettings from './WAFSettings' +import PlaybackSettings from './PlaybackSettings' export default function SettingsDialog({ handleClose }) { const { t } = useTranslation() @@ -256,6 +257,7 @@ export default function SettingsDialog({ handleClose }) { isIinaUsed={isIinaUsed} setIsIinaUsed={setIsIinaUsed} /> + diff --git a/web/src/components/TorrentCard/index.jsx b/web/src/components/TorrentCard/index.jsx index dc1b31640..328985048 100644 --- a/web/src/components/TorrentCard/index.jsx +++ b/web/src/components/TorrentCard/index.jsx @@ -21,6 +21,7 @@ import { ListItemText, Menu, MenuItem, + Snackbar, useMediaQuery, useTheme, } from '@material-ui/core' @@ -31,6 +32,7 @@ import AddDialog from 'components/Add/AddDialog' import { StyledDialog } from 'style/CustomMaterialUiStyles' import useOnStandaloneAppOutsideClick from 'utils/useOnStandaloneAppOutsideClick' import { useBooleanPlayerPreference } from 'utils/PlayerPreferences' +import { usePlayback } from 'playback/PlaybackContext' import { GETTING_INFO, IN_DB, CLOSED, PRELOAD, WORKING } from 'torrentStates' import { TORRENT_CATEGORIES } from 'components/categories' import VideoPlayer from 'components/VideoPlayer' @@ -159,8 +161,11 @@ const Torrent = ({ torrent }) => { const [audioMenuAnchor, setAudioMenuAnchor] = useState(null) const [audioMenuPlayer, setAudioMenuPlayer] = useState(null) const [isResolvingAudio, setIsResolvingAudio] = useState(false) + const [isLaunchingVlc, setIsLaunchingVlc] = useState(false) + const [vlcLaunchError, setVlcLaunchError] = useState(false) const isVlcUsed = useBooleanPlayerPreference('isVlcUsed') const showQuickVlcButton = useBooleanPlayerPreference('showQuickVlcButton') + const { launchVlc, selectedTarget } = usePlayback() const isMounted = useRef(true) const episodeButtonRef = useRef(null) const audioButtonRef = useRef(null) @@ -257,10 +262,21 @@ const Torrent = ({ torrent }) => { const singlePlayer = players.length === 1 ? players[0] : null const audioMenuTracks = audioMenuPlayer ? audioTracksByFile[audioMenuPlayer.id] || [] : [] - const openSingleFileInVlc = () => { - if (!singlePlayer) return - const streamUrl = new URL(singlePlayer.downloadSrc, window.location.href) - window.location.href = `vlc://${streamUrl}` + const openSingleFileInVlc = async () => { + if (!singlePlayer || !selectedTarget) return + setIsLaunchingVlc(true) + try { + await launchVlc({ + path: singlePlayer.path, + hash, + index: singlePlayer.id, + streamUrl: singlePlayer.downloadSrc, + }) + } catch (_) { + setVlcLaunchError(true) + } finally { + if (isMounted.current) setIsLaunchingVlc(false) + } } const playerWithAudio = (player, audio) => ({ @@ -386,8 +402,12 @@ const Torrent = ({ torrent }) => { {isVlcUsed && showQuickVlcButton && singlePlayer && ( - - + + {isLaunchingVlc ? : } VLC )} @@ -553,6 +573,13 @@ const Torrent = ({ torrent }) => { + setVlcLaunchError(false)} + message={t('Playback.LaunchFailed')} + /> + - + + + , document.getElementById('root'), diff --git a/web/src/locales/en/translation.json b/web/src/locales/en/translation.json index 17cc89c49..ecc3bf572 100644 --- a/web/src/locales/en/translation.json +++ b/web/src/locales/en/translation.json @@ -380,5 +380,57 @@ "Uncategorized": "Uncategorized", "UploadFile": "Upload File", "UploadSpeed": "Upload speed", - "Viewed": "Viewed" + "Viewed": "Viewed", + "Playback": { + "AddDevice": "Add playback device", + "AgentToken": "Agent token", + "AgentTokenHint": "Optional bearer token shared with the playback agent.", + "AgentURL": "Agent URL", + "AgentURLHint": "Base URL of the playback agent. TorrServer calls /health and /play.", + "BrowserRole": "This browser", + "BrowserRoleHint": "Controller-only browsers send playback to another device. Controller and playback browsers may also open VLC locally.", + "ClearToken": "Remove saved token", + "ControlAndPlayback": "Control and playback", + "ControlOnly": "Control only", + "DeleteConfirm": "Delete “{{name}}”?", + "DeleteDevice": "Delete playback device", + "DeleteFailed": "Failed to delete playback device", + "DeviceDeleted": "Playback device deleted", + "DeviceName": "Device name", + "DeviceSaved": "Playback device saved", + "EditDevice": "Edit playback device", + "LaunchFailed": "Failed to start playback on the selected device", + "LoadFailed": "Failed to load playback devices", + "NoDevice": "No playback device", + "NoRemoteDevices": "No remote playback devices configured.", + "PlayOn": "Play on", + "RemoteDevices": "Remote playback devices", + "RemoteDevicesHint": "Devices are shared by all TorrServer users. Agent addresses and tokens stay on the server.", + "SaveFailed": "Failed to save playback device", + "StreamBaseURL": "TorrServer URL for this device", + "StreamBaseURLHint": "Leave empty when the agent can use the current TorrServer address. Set it when the player reaches TorrServer through another URL.", + "Test": "Test", + "TestFailed": "Playback device is unavailable", + "TestSuccessful": "Playback device is available", + "ThisDevice": "This device", + "TokenConfiguredHint": "A token is already saved. Leave this field empty to keep it.", + "RemotePlayback": "Remote playback", + "EnableRemotePlayback": "Enable playback devices", + "EnableRemotePlaybackHint": "Off by default. When disabled, every browser opens VLC on itself exactly as before.", + "RoutingMode": "Playback routing", + "RoutingLocal": "Each browser plays locally (legacy)", + "RoutingPrimary": "Always use one primary device", + "RoutingPerBrowser": "Choose a device in each browser", + "RoutingModeHint": "Choose the conservative local behavior, one shared TV device, or a separate target for each browser.", + "PrimaryDevice": "Primary playback device", + "PrimaryDeviceHint": "All browsers send VLC playback to this device. The target cannot be overridden from another browser.", + "PrimaryDeviceActive": "Primary playback device", + "SaveRouting": "Save playback mode", + "SettingsSaved": "Playback mode saved", + "SettingsSaveFailed": "Failed to save playback mode", + "OpenFullscreen": "Open VLC in fullscreen", + "OpenFullscreenHint": "Applies only to this remote player. Leave off to open VLC normally.", + "FullscreenEnabled": "Starts fullscreen", + "FullscreenDisabled": "Starts in a normal window" + } } diff --git a/web/src/locales/ru/translation.json b/web/src/locales/ru/translation.json index 7540e11f0..c46a1c731 100644 --- a/web/src/locales/ru/translation.json +++ b/web/src/locales/ru/translation.json @@ -380,5 +380,57 @@ "Saved": "Списки сохранены и перезагружены", "LoadFailed": "Не удалось загрузить списки доступа", "SaveFailed": "Не удалось сохранить списки доступа" + }, + "Playback": { + "AddDevice": "Добавить устройство воспроизведения", + "AgentToken": "Токен агента", + "AgentTokenHint": "Необязательный Bearer-токен, общий для TorrServer и агента.", + "AgentURL": "Адрес агента", + "AgentURLHint": "Базовый адрес агента. TorrServer обращается к /health и /play.", + "BrowserRole": "Этот браузер", + "BrowserRoleHint": "Браузер в режиме управления запускает фильм на другом устройстве. В режиме управления и просмотра VLC можно открыть и здесь.", + "ClearToken": "Удалить сохранённый токен", + "ControlAndPlayback": "Управление и просмотр", + "ControlOnly": "Только управление", + "DeleteConfirm": "Удалить устройство «{{name}}»?", + "DeleteDevice": "Удаление устройства воспроизведения", + "DeleteFailed": "Не удалось удалить устройство воспроизведения", + "DeviceDeleted": "Устройство воспроизведения удалено", + "DeviceName": "Название устройства", + "DeviceSaved": "Устройство воспроизведения сохранено", + "EditDevice": "Изменить устройство воспроизведения", + "LaunchFailed": "Не удалось запустить фильм на выбранном устройстве", + "LoadFailed": "Не удалось загрузить устройства воспроизведения", + "NoDevice": "Нет устройства воспроизведения", + "NoRemoteDevices": "Удалённые устройства пока не добавлены.", + "PlayOn": "Воспроизводить на", + "RemoteDevices": "Удалённые устройства воспроизведения", + "RemoteDevicesHint": "Список общий для всех пользователей TorrServer. Адреса агентов и токены остаются на сервере.", + "SaveFailed": "Не удалось сохранить устройство воспроизведения", + "StreamBaseURL": "Адрес TorrServer для этого устройства", + "StreamBaseURLHint": "Оставьте пустым, если агент может использовать текущий адрес TorrServer. Укажите другой адрес, если проигрыватель подключается к серверу иначе.", + "Test": "Проверить", + "TestFailed": "Устройство воспроизведения недоступно", + "TestSuccessful": "Устройство воспроизведения доступно", + "ThisDevice": "Это устройство", + "TokenConfiguredHint": "Токен уже сохранён. Оставьте поле пустым, чтобы не менять его.", + "RemotePlayback": "Удалённое воспроизведение", + "EnableRemotePlayback": "Включить устройства воспроизведения", + "EnableRemotePlaybackHint": "По умолчанию выключено. Пока функция выключена, каждый браузер открывает VLC у себя — как раньше.", + "RoutingMode": "Куда запускать VLC", + "RoutingLocal": "Каждый браузер открывает у себя (как раньше)", + "RoutingPrimary": "Всегда открывать на одном основном устройстве", + "RoutingPerBrowser": "Выбирать устройство в каждом браузере", + "RoutingModeHint": "Можно сохранить старое поведение, назначить один телевизор или выбирать цель отдельно на каждом телефоне и компьютере.", + "PrimaryDevice": "Основное устройство воспроизведения", + "PrimaryDeviceHint": "Все браузеры будут запускать VLC на этом устройстве. Из другого браузера цель изменить нельзя.", + "PrimaryDeviceActive": "Используется основное устройство воспроизведения", + "SaveRouting": "Сохранить режим воспроизведения", + "SettingsSaved": "Режим воспроизведения сохранён", + "SettingsSaveFailed": "Не удалось сохранить режим воспроизведения", + "OpenFullscreen": "Открывать VLC во весь экран", + "OpenFullscreenHint": "Настройка относится только к этому удалённому проигрывателю. Выключите, чтобы VLC открывался как обычно.", + "FullscreenEnabled": "Запуск во весь экран", + "FullscreenDisabled": "Запуск в обычном окне" } } diff --git a/web/src/playback/PlaybackContext.jsx b/web/src/playback/PlaybackContext.jsx new file mode 100644 index 000000000..25abd1c87 --- /dev/null +++ b/web/src/playback/PlaybackContext.jsx @@ -0,0 +1,165 @@ +import axios from 'axios' +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { playbackDevicesHost, playbackPlayHost } from 'utils/Hosts' + +export const LOCAL_PLAYBACK_DEVICE_ID = 'this-device' +export const PLAYBACK_CLIENT_MODES = { + CONTROL_ONLY: 'control-only', + CONTROL_AND_PLAYBACK: 'control-and-playback', +} +export const PLAYBACK_ROUTING_MODES = { + LOCAL: 'local', + PRIMARY: 'primary', + PER_BROWSER: 'per-browser', +} + +const MODE_STORAGE_KEY = 'playbackClientMode' +const TARGET_STORAGE_KEY = 'playbackTargetId' + +const PlaybackContext = createContext(null) + +const defaultConfig = { + enabled: false, + routing_mode: PLAYBACK_ROUTING_MODES.LOCAL, + primary_device_id: '', + devices: [], +} + +const storedMode = () => { + const value = localStorage.getItem(MODE_STORAGE_KEY) + return Object.values(PLAYBACK_CLIENT_MODES).includes(value) ? value : PLAYBACK_CLIENT_MODES.CONTROL_AND_PLAYBACK +} + +const normalizeConfig = data => { + if (Array.isArray(data)) return { ...defaultConfig, devices: data } + return { + ...defaultConfig, + ...(data || {}), + devices: Array.isArray(data?.devices) ? data.devices : [], + } +} + +export const PlaybackProvider = ({ children }) => { + const { t } = useTranslation() + const [config, setConfig] = useState(defaultConfig) + const [isLoadingDevices, setIsLoadingDevices] = useState(true) + const [mode, setModeState] = useState(storedMode) + const [targetId, setTargetIdState] = useState(localStorage.getItem(TARGET_STORAGE_KEY) || '') + + const refreshPlaybackConfig = useCallback(async () => { + setIsLoadingDevices(true) + try { + const { data } = await axios.get(playbackDevicesHost()) + setConfig(normalizeConfig(data)) + } finally { + setIsLoadingDevices(false) + } + }, []) + + useEffect(() => { + refreshPlaybackConfig().catch(() => setConfig(defaultConfig)) + }, [refreshPlaybackConfig]) + + const localTarget = useMemo( + () => ({ id: LOCAL_PLAYBACK_DEVICE_ID, name: t('Playback.ThisDevice'), isLocal: true }), + [t], + ) + const remoteTargets = useMemo(() => config.devices.map(device => ({ ...device, isLocal: false })), [config.devices]) + + const targets = useMemo(() => { + if (!config.enabled || config.routing_mode === PLAYBACK_ROUTING_MODES.LOCAL) return [localTarget] + if (config.routing_mode === PLAYBACK_ROUTING_MODES.PRIMARY) { + const primary = remoteTargets.find(device => device.id === config.primary_device_id) + return primary ? [primary] : [] + } + if (mode === PLAYBACK_CLIENT_MODES.CONTROL_ONLY) return remoteTargets + return [localTarget, ...remoteTargets] + }, [config.enabled, config.primary_device_id, config.routing_mode, localTarget, mode, remoteTargets]) + + useEffect(() => { + if (config.routing_mode !== PLAYBACK_ROUTING_MODES.PER_BROWSER || !config.enabled) return + if (targets.some(target => target.id === targetId)) return + const fallback = targets[0]?.id || '' + setTargetIdState(fallback) + if (fallback) localStorage.setItem(TARGET_STORAGE_KEY, fallback) + else localStorage.removeItem(TARGET_STORAGE_KEY) + }, [config.enabled, config.routing_mode, targetId, targets]) + + const setMode = useCallback(value => { + if (!Object.values(PLAYBACK_CLIENT_MODES).includes(value)) return + setModeState(value) + localStorage.setItem(MODE_STORAGE_KEY, value) + }, []) + + const setTargetId = useCallback(value => { + setTargetIdState(value) + if (value) localStorage.setItem(TARGET_STORAGE_KEY, value) + else localStorage.removeItem(TARGET_STORAGE_KEY) + }, []) + + const selectedTarget = useMemo(() => { + if (!config.enabled || config.routing_mode === PLAYBACK_ROUTING_MODES.LOCAL) return localTarget + if (config.routing_mode === PLAYBACK_ROUTING_MODES.PRIMARY) return targets[0] || null + return targets.find(target => target.id === targetId) || null + }, [config.enabled, config.routing_mode, localTarget, targetId, targets]) + + const launchVlc = useCallback( + async ({ path, hash, index, streamUrl }) => { + if (!selectedTarget) throw new Error('playback target is not selected') + + if (selectedTarget.isLocal) { + const absoluteStreamUrl = new URL(streamUrl, window.location.href) + window.location.href = `vlc://${absoluteStreamUrl}` + return + } + + await axios.post(playbackPlayHost(), { + device_id: selectedTarget.id, + path, + hash, + index, + }) + }, + [selectedTarget], + ) + + const value = useMemo( + () => ({ + config, + devices: config.devices, + isLoadingDevices, + launchVlc, + mode, + refreshPlaybackConfig, + remotePlaybackEnabled: config.enabled, + routingMode: config.routing_mode, + selectedTarget, + setMode, + setTargetId, + showTargetSelector: config.enabled && config.routing_mode === PLAYBACK_ROUTING_MODES.PER_BROWSER, + targetId, + targets, + }), + [ + config, + isLoadingDevices, + launchVlc, + mode, + refreshPlaybackConfig, + selectedTarget, + setMode, + setTargetId, + targetId, + targets, + ], + ) + + return {children} +} + +export const usePlayback = () => { + const context = useContext(PlaybackContext) + if (!context) throw new Error('usePlayback must be used inside PlaybackProvider') + return context +} diff --git a/web/src/utils/Hosts.js b/web/src/utils/Hosts.js index b545b959a..8abf09175 100644 --- a/web/src/utils/Hosts.js +++ b/web/src/utils/Hosts.js @@ -17,6 +17,10 @@ export const torznabTestHost = () => `${torrserverHost}/torznab/test` export const tmdbSettingsHost = () => `${torrserverHost}/tmdb/settings` export const gstSettingsHost = () => `${torrserverHost}/gst/settings` export const wafHost = () => `${torrserverHost}/waf` +export const playbackDevicesHost = () => `${torrserverHost}/playback/devices` +export const playbackDeviceConfigsHost = () => `${torrserverHost}/playback/devices/config` +export const playbackSettingsHost = () => `${torrserverHost}/playback/settings` +export const playbackPlayHost = () => `${torrserverHost}/playback/play` export const getTorrServerHost = () => torrserverHost export const setTorrServerHost = host => {