Implement Remote Client Shares Phase 2 browse support and unify remote agent HTTP + heartbeat

This commit is contained in:
kodi
2026-03-27 14:17:55 +01:00
parent 4062cbf6c8
commit 2fa4a0b291
4 changed files with 243 additions and 552 deletions
+60 -8
View File
@@ -3,12 +3,15 @@ from __future__ import annotations
import argparse
import json
import sys
import time
import threading
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib import error, request
from urllib.parse import urlparse
import uvicorn
AGENT_VERSION = "1.1.0-phase1"
@@ -106,7 +109,7 @@ def post_json(url: str, token: str, payload: dict[str, Any]) -> dict[str, Any]:
return json.loads(resp.read().decode("utf-8"))
def run(config: AgentConfig) -> None:
def run_heartbeat_loop(config: AgentConfig, stop_event: threading.Event) -> None:
require_non_empty(config.webmanager_base_url, "webmanager_base_url")
require_non_empty(config.registration_token, "registration_token")
require_non_empty(config.agent_access_token, "agent_access_token")
@@ -117,9 +120,10 @@ def run(config: AgentConfig) -> None:
heartbeat_url = f"{config.normalized_base_url}/api/clients/heartbeat"
print(f"Starting remote client agent for {config.display_name} ({config.client_id})", flush=True)
print("agent_access_token is configured for future authenticated agent endpoints", flush=True)
print(f"Using config: {config.config_path}", flush=True)
print("agent_access_token is configured for authenticated agent endpoints", flush=True)
while True:
while not stop_event.is_set():
try:
post_json(register_url, config.registration_token, build_register_payload(config))
print("register ok", flush=True)
@@ -128,9 +132,10 @@ def run(config: AgentConfig) -> None:
print(f"register failed: HTTP {exc.code}", file=sys.stderr, flush=True)
except error.URLError as exc:
print(f"register failed: {exc.reason}", file=sys.stderr, flush=True)
time.sleep(config.heartbeat_interval_seconds)
if stop_event.wait(config.heartbeat_interval_seconds):
return
while True:
while not stop_event.is_set():
try:
post_json(heartbeat_url, config.registration_token, build_heartbeat_payload(config))
print("heartbeat ok", flush=True)
@@ -138,7 +143,52 @@ def run(config: AgentConfig) -> None:
print(f"heartbeat failed: HTTP {exc.code}", file=sys.stderr, flush=True)
except error.URLError as exc:
print(f"heartbeat failed: {exc.reason}", file=sys.stderr, flush=True)
time.sleep(config.heartbeat_interval_seconds)
if stop_event.wait(config.heartbeat_interval_seconds):
return
def resolve_bind_host(config: AgentConfig, requested_host: str | None) -> str:
normalized = (requested_host or "").strip()
if normalized:
return normalized
return "0.0.0.0"
def resolve_bind_port(config: AgentConfig, requested_port: int | None) -> int:
if requested_port and requested_port > 0:
return requested_port
parsed = urlparse(config.endpoint)
if parsed.port:
return parsed.port
if parsed.scheme == "https":
return 443
if parsed.scheme == "http":
return 80
return 8765
def run(config: AgentConfig, requested_host: str | None, requested_port: int | None) -> None:
stop_event = threading.Event()
heartbeat_thread = threading.Thread(
target=run_heartbeat_loop,
args=(config, stop_event),
daemon=True,
name="remote-client-heartbeat",
)
heartbeat_thread.start()
bind_host = resolve_bind_host(config, requested_host)
bind_port = resolve_bind_port(config, requested_port)
print(f"Starting HTTP agent on {bind_host}:{bind_port}", flush=True)
print(f"Advertised endpoint: {config.endpoint}", flush=True)
try:
import os
os.environ["FINDER_COMMANDER_REMOTE_AGENT_CONFIG"] = str(config.config_path)
uvicorn.run("app.main:app", host=bind_host, port=bind_port)
finally:
stop_event.set()
heartbeat_thread.join(timeout=2)
def parse_args() -> argparse.Namespace:
@@ -148,6 +198,8 @@ def parse_args() -> argparse.Namespace:
default=str(Path(__file__).resolve().with_name("remote_client_agent.example.json")),
help="Path to remote client agent config JSON",
)
parser.add_argument("--host", default="", help="Bind host for the HTTP agent, defaults to 0.0.0.0")
parser.add_argument("--port", type=int, default=0, help="Bind port for the HTTP agent, defaults to endpoint port")
return parser.parse_args()
@@ -155,7 +207,7 @@ def main() -> int:
args = parse_args()
try:
config = load_config(Path(args.config).resolve())
run(config)
run(config, requested_host=args.host, requested_port=args.port)
except KeyboardInterrupt:
return 130
except Exception as exc: