sistema A2S
This commit is contained in:
@@ -1 +1,59 @@
|
||||
"""Base package for the future HLL Vietnam Python backend."""
|
||||
"""Minimal bootstrap package for the HLL Vietnam Python backend."""
|
||||
|
||||
from .config import get_allowed_origins, get_bind_address
|
||||
from .main import create_server, run
|
||||
from .normalizers import normalize_a2s_server_info, normalize_server_record
|
||||
from .payloads import build_health_payload
|
||||
from .routes import resolve_get_payload
|
||||
from .snapshots import build_server_snapshot, build_snapshot_batch, utc_now
|
||||
from .storage import initialize_storage, persist_snapshot_batch
|
||||
|
||||
|
||||
def collect_server_snapshots(*args: object, **kwargs: object) -> dict[str, object]:
|
||||
"""Proxy collector access without importing the module during package init."""
|
||||
from .collector import collect_server_snapshots as _collect_server_snapshots
|
||||
|
||||
return _collect_server_snapshots(*args, **kwargs)
|
||||
|
||||
|
||||
def fetch_a2s_probe(*args: object, **kwargs: object) -> dict[str, object]:
|
||||
"""Proxy A2S probe access without importing the collector during package init."""
|
||||
from .collector import fetch_a2s_probe as _fetch_a2s_probe
|
||||
|
||||
return _fetch_a2s_probe(*args, **kwargs)
|
||||
|
||||
|
||||
def query_server_info(*args: object, **kwargs: object) -> object:
|
||||
"""Proxy A2S info queries without importing the module during package init."""
|
||||
from .a2s_client import query_server_info as _query_server_info
|
||||
|
||||
return _query_server_info(*args, **kwargs)
|
||||
|
||||
|
||||
def fetch_controlled_server_source() -> tuple[dict[str, object], ...]:
|
||||
"""Proxy the controlled source without importing the module during package init."""
|
||||
from .collector import (
|
||||
fetch_controlled_server_source as _fetch_controlled_server_source,
|
||||
)
|
||||
|
||||
return tuple(_fetch_controlled_server_source())
|
||||
|
||||
__all__ = [
|
||||
"build_health_payload",
|
||||
"build_server_snapshot",
|
||||
"build_snapshot_batch",
|
||||
"collect_server_snapshots",
|
||||
"create_server",
|
||||
"fetch_a2s_probe",
|
||||
"fetch_controlled_server_source",
|
||||
"get_allowed_origins",
|
||||
"get_bind_address",
|
||||
"initialize_storage",
|
||||
"normalize_a2s_server_info",
|
||||
"normalize_server_record",
|
||||
"persist_snapshot_batch",
|
||||
"query_server_info",
|
||||
"resolve_get_payload",
|
||||
"run",
|
||||
"utc_now",
|
||||
]
|
||||
|
||||
176
backend/app/a2s_client.py
Normal file
176
backend/app/a2s_client.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Minimal Steam A2S info client for development-time HLL server probes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import socket
|
||||
import struct
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
|
||||
DEFAULT_A2S_TIMEOUT = 6.0
|
||||
_A2S_PREFIX = b"\xFF\xFF\xFF\xFF"
|
||||
_A2S_INFO_REQUEST = _A2S_PREFIX + b"\x54Source Engine Query\x00"
|
||||
_A2S_CHALLENGE_RESPONSE = 0x41
|
||||
_A2S_INFO_RESPONSE = 0x49
|
||||
|
||||
|
||||
class A2SError(RuntimeError):
|
||||
"""Base error for A2S query failures."""
|
||||
|
||||
|
||||
class A2STimeoutError(A2SError):
|
||||
"""Raised when an A2S query does not complete before the timeout."""
|
||||
|
||||
|
||||
class A2SProtocolError(A2SError):
|
||||
"""Raised when an A2S server returns an unexpected payload."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class A2SServerInfo:
|
||||
"""Minimal metadata returned by an A2S info query."""
|
||||
|
||||
host: str
|
||||
query_port: int
|
||||
server_name: str
|
||||
map_name: str | None
|
||||
players: int
|
||||
max_players: int
|
||||
protocol: int
|
||||
folder: str | None = None
|
||||
game: str | None = None
|
||||
version: str | None = None
|
||||
|
||||
|
||||
def query_server_info(
|
||||
host: str,
|
||||
query_port: int,
|
||||
*,
|
||||
timeout: float = DEFAULT_A2S_TIMEOUT,
|
||||
) -> A2SServerInfo:
|
||||
"""Query one server using A2S_INFO and return minimal reusable metadata."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as udp_socket:
|
||||
udp_socket.settimeout(timeout)
|
||||
address = (host, query_port)
|
||||
|
||||
try:
|
||||
udp_socket.sendto(_A2S_INFO_REQUEST, address)
|
||||
payload = _receive_packet(udp_socket)
|
||||
if _is_challenge_packet(payload):
|
||||
challenge = payload[5:9]
|
||||
udp_socket.sendto(_A2S_INFO_REQUEST + challenge, address)
|
||||
payload = _receive_packet(udp_socket)
|
||||
except socket.timeout as error:
|
||||
raise A2STimeoutError(
|
||||
f"A2S query to {host}:{query_port} timed out after {timeout:.1f}s."
|
||||
) from error
|
||||
except OSError as error:
|
||||
raise A2SError(
|
||||
f"A2S query to {host}:{query_port} failed: {error}."
|
||||
) from error
|
||||
|
||||
return _parse_info_payload(payload, host=host, query_port=query_port)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Allow a direct development-time probe of one A2S target."""
|
||||
parser = argparse.ArgumentParser(description="Probe one server with A2S_INFO.")
|
||||
parser.add_argument("host", help="Server hostname or IPv4 address.")
|
||||
parser.add_argument("query_port", type=int, help="Server Steam query port.")
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=float,
|
||||
default=DEFAULT_A2S_TIMEOUT,
|
||||
help="Socket timeout in seconds.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
payload = asdict(
|
||||
query_server_info(args.host, args.query_port, timeout=args.timeout)
|
||||
)
|
||||
print(json.dumps(payload, indent=2))
|
||||
|
||||
|
||||
def _receive_packet(udp_socket: socket.socket) -> bytes:
|
||||
payload, _ = udp_socket.recvfrom(4096)
|
||||
return payload
|
||||
|
||||
|
||||
def _is_challenge_packet(payload: bytes) -> bool:
|
||||
return (
|
||||
len(payload) >= 9
|
||||
and payload.startswith(_A2S_PREFIX)
|
||||
and payload[4] == _A2S_CHALLENGE_RESPONSE
|
||||
)
|
||||
|
||||
|
||||
def _parse_info_payload(
|
||||
payload: bytes,
|
||||
*,
|
||||
host: str,
|
||||
query_port: int,
|
||||
) -> A2SServerInfo:
|
||||
if len(payload) < 6 or not payload.startswith(_A2S_PREFIX):
|
||||
raise A2SProtocolError("A2S response did not include the expected packet header.")
|
||||
if payload[4] != _A2S_INFO_RESPONSE:
|
||||
raise A2SProtocolError(
|
||||
f"A2S response type {payload[4]!r} is not an info response."
|
||||
)
|
||||
|
||||
protocol = payload[5]
|
||||
offset = 6
|
||||
server_name, offset = _read_c_string(payload, offset)
|
||||
map_name, offset = _read_c_string(payload, offset)
|
||||
folder, offset = _read_c_string(payload, offset)
|
||||
game, offset = _read_c_string(payload, offset)
|
||||
offset += 2 # app id
|
||||
players = _read_byte(payload, offset)
|
||||
max_players = _read_byte(payload, offset + 1)
|
||||
offset += 6 # players, max, bots, server type, environment, visibility
|
||||
offset += 1 # vac
|
||||
version, offset = _read_c_string(payload, offset)
|
||||
|
||||
if offset < len(payload):
|
||||
extra_data_flag = payload[offset]
|
||||
offset += 1
|
||||
if extra_data_flag & 0x80:
|
||||
offset += 2
|
||||
if extra_data_flag & 0x10:
|
||||
_, offset = _read_c_string(payload, offset)
|
||||
if extra_data_flag & 0x40:
|
||||
offset += 2
|
||||
offset += 8
|
||||
if extra_data_flag & 0x20:
|
||||
offset += 8
|
||||
|
||||
return A2SServerInfo(
|
||||
host=host,
|
||||
query_port=query_port,
|
||||
server_name=server_name or "Unknown server",
|
||||
map_name=map_name or None,
|
||||
players=players,
|
||||
max_players=max_players,
|
||||
protocol=protocol,
|
||||
folder=folder or None,
|
||||
game=game or None,
|
||||
version=version or None,
|
||||
)
|
||||
|
||||
|
||||
def _read_c_string(payload: bytes, offset: int) -> tuple[str, int]:
|
||||
end = payload.find(b"\x00", offset)
|
||||
if end == -1:
|
||||
raise A2SProtocolError("A2S response ended before a null-terminated string.")
|
||||
return payload[offset:end].decode("utf-8", errors="replace"), end + 1
|
||||
|
||||
|
||||
def _read_byte(payload: bytes, offset: int) -> int:
|
||||
if offset >= len(payload):
|
||||
raise A2SProtocolError("A2S response ended before expected integer fields.")
|
||||
return struct.unpack_from("<B", payload, offset)[0]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
269
backend/app/collector.py
Normal file
269
backend/app/collector.py
Normal file
@@ -0,0 +1,269 @@
|
||||
"""Minimal collector bootstrap for provisional server snapshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Callable, Mapping, Sequence
|
||||
|
||||
from .a2s_client import DEFAULT_A2S_TIMEOUT, query_server_info
|
||||
from .normalizers import normalize_a2s_server_info, normalize_server_record
|
||||
from .server_targets import A2SServerTarget, load_a2s_targets
|
||||
from .snapshots import build_snapshot_batch, utc_now
|
||||
from .storage import persist_snapshot_batch
|
||||
|
||||
|
||||
RawSourceFetcher = Callable[[], Sequence[Mapping[str, object]]]
|
||||
TargetProbe = Callable[[A2SServerTarget, float], Mapping[str, object]]
|
||||
|
||||
|
||||
CONTROLLED_RAW_SERVER_SOURCE: tuple[dict[str, object], ...] = (
|
||||
{
|
||||
"external_server_id": "hll-esp-tactical-rotation",
|
||||
"server_name": "HLL ESP Tactical Rotation",
|
||||
"status": "online",
|
||||
"players": 74,
|
||||
"max_players": 100,
|
||||
"current_map": "Sainte-Marie-du-Mont",
|
||||
"region": "EU",
|
||||
},
|
||||
{
|
||||
"external_server_id": "hll-latam-night-offensive",
|
||||
"server_name": "HLL LATAM Night Offensive",
|
||||
"status": "online",
|
||||
"players": 51,
|
||||
"max_players": 100,
|
||||
"current_map": "Carentan",
|
||||
"region": "LATAM",
|
||||
},
|
||||
{
|
||||
"external_server_id": "hll-community-reserve",
|
||||
"server_name": "HLL Community Reserve",
|
||||
"status": "offline",
|
||||
"players": 0,
|
||||
"max_players": 100,
|
||||
"current_map": None,
|
||||
"region": "EU",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def fetch_controlled_server_source() -> Sequence[Mapping[str, object]]:
|
||||
"""Return the controlled development source used by the collector bootstrap."""
|
||||
return CONTROLLED_RAW_SERVER_SOURCE
|
||||
|
||||
|
||||
def fetch_a2s_probe(
|
||||
host: str,
|
||||
query_port: int,
|
||||
*,
|
||||
timeout: float = DEFAULT_A2S_TIMEOUT,
|
||||
source_name: str = "a2s-info",
|
||||
external_server_id: str | None = None,
|
||||
region: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Probe one A2S target and normalize its metadata for the collector model."""
|
||||
server_info = query_server_info(host, query_port, timeout=timeout)
|
||||
return normalize_a2s_server_info(
|
||||
server_info,
|
||||
source_name=source_name,
|
||||
external_server_id=external_server_id,
|
||||
region=region,
|
||||
)
|
||||
|
||||
|
||||
def fetch_configured_a2s_probes(
|
||||
*,
|
||||
timeout: float = DEFAULT_A2S_TIMEOUT,
|
||||
probe_target: TargetProbe | None = None,
|
||||
) -> tuple[dict[str, object], ...]:
|
||||
"""Probe the configured A2S targets without hardcoding them in collector logic."""
|
||||
probe = probe_target or _probe_configured_target
|
||||
return tuple(
|
||||
dict(probe(target, timeout))
|
||||
for target in load_a2s_targets()
|
||||
)
|
||||
|
||||
|
||||
def collect_server_snapshots(
|
||||
*,
|
||||
fetch_raw_source: RawSourceFetcher = fetch_controlled_server_source,
|
||||
source_name: str = "controlled-placeholder",
|
||||
source_mode: str = "controlled",
|
||||
timeout: float = DEFAULT_A2S_TIMEOUT,
|
||||
allow_controlled_fallback: bool = True,
|
||||
probe_target: TargetProbe | None = None,
|
||||
persist: bool = False,
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Collect snapshot batches from controlled data, A2S, or auto mode."""
|
||||
normalized_records, collection_details = _collect_normalized_records(
|
||||
fetch_raw_source=fetch_raw_source,
|
||||
source_name=source_name,
|
||||
source_mode=source_mode,
|
||||
timeout=timeout,
|
||||
allow_controlled_fallback=allow_controlled_fallback,
|
||||
probe_target=probe_target,
|
||||
)
|
||||
captured_at = utc_now()
|
||||
|
||||
payload = {
|
||||
"source_name": collection_details["source_name"],
|
||||
"collection_mode": collection_details["collection_mode"],
|
||||
"fallback_used": collection_details["fallback_used"],
|
||||
"target_count": collection_details["target_count"],
|
||||
"success_count": collection_details["success_count"],
|
||||
"errors": collection_details["errors"],
|
||||
"captured_at": captured_at.isoformat().replace("+00:00", "Z"),
|
||||
"snapshots": build_snapshot_batch(
|
||||
normalized_records,
|
||||
captured_at=captured_at,
|
||||
),
|
||||
}
|
||||
if persist:
|
||||
payload["storage"] = persist_snapshot_batch(
|
||||
payload["snapshots"],
|
||||
source_name=payload["source_name"],
|
||||
captured_at=payload["captured_at"],
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Allow manual collector execution during development."""
|
||||
parser = argparse.ArgumentParser(description="Collect development server snapshots.")
|
||||
parser.add_argument(
|
||||
"--source",
|
||||
choices=("controlled", "a2s", "auto"),
|
||||
default="auto",
|
||||
help="Choose controlled data, configured A2S targets, or auto with fallback.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=float,
|
||||
default=DEFAULT_A2S_TIMEOUT,
|
||||
help="Socket timeout in seconds for A2S probes.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-fallback",
|
||||
action="store_true",
|
||||
help="Disable fallback to controlled data when A2S fails.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
payload = collect_server_snapshots(
|
||||
source_mode=args.source,
|
||||
timeout=args.timeout,
|
||||
allow_controlled_fallback=not args.no_fallback,
|
||||
persist=True,
|
||||
)
|
||||
print(json.dumps(payload, indent=2))
|
||||
|
||||
|
||||
def _collect_normalized_records(
|
||||
*,
|
||||
fetch_raw_source: RawSourceFetcher,
|
||||
source_name: str,
|
||||
source_mode: str,
|
||||
timeout: float,
|
||||
allow_controlled_fallback: bool,
|
||||
probe_target: TargetProbe | None,
|
||||
) -> tuple[list[dict[str, object]], dict[str, object]]:
|
||||
if source_mode == "controlled":
|
||||
raw_records = fetch_raw_source()
|
||||
return (
|
||||
[
|
||||
normalize_server_record(record, source_name=source_name)
|
||||
for record in raw_records
|
||||
],
|
||||
{
|
||||
"source_name": source_name,
|
||||
"collection_mode": "controlled",
|
||||
"fallback_used": False,
|
||||
"target_count": 0,
|
||||
"success_count": 0,
|
||||
"errors": [],
|
||||
},
|
||||
)
|
||||
|
||||
configured_targets = load_a2s_targets()
|
||||
records: list[dict[str, object]] = []
|
||||
errors: list[dict[str, object]] = []
|
||||
probe = probe_target or _probe_configured_target
|
||||
|
||||
for target in configured_targets:
|
||||
try:
|
||||
records.append(dict(probe(target, timeout)))
|
||||
except Exception as error: # noqa: BLE001 - keep collector failures controlled
|
||||
errors.append(
|
||||
{
|
||||
"target": target.name,
|
||||
"host": target.host,
|
||||
"query_port": target.query_port,
|
||||
"message": str(error),
|
||||
}
|
||||
)
|
||||
|
||||
if records:
|
||||
return (
|
||||
records,
|
||||
{
|
||||
"source_name": "a2s-info",
|
||||
"collection_mode": "a2s",
|
||||
"fallback_used": False,
|
||||
"target_count": len(configured_targets),
|
||||
"success_count": len(records),
|
||||
"errors": errors,
|
||||
},
|
||||
)
|
||||
|
||||
if source_mode == "a2s" or not allow_controlled_fallback:
|
||||
return (
|
||||
[],
|
||||
{
|
||||
"source_name": "a2s-info",
|
||||
"collection_mode": "a2s",
|
||||
"fallback_used": False,
|
||||
"target_count": len(configured_targets),
|
||||
"success_count": 0,
|
||||
"errors": errors,
|
||||
},
|
||||
)
|
||||
|
||||
raw_records = fetch_raw_source()
|
||||
normalized_records = [
|
||||
normalize_server_record(record, source_name=source_name)
|
||||
for record in raw_records
|
||||
]
|
||||
return (
|
||||
normalized_records,
|
||||
{
|
||||
"source_name": source_name,
|
||||
"collection_mode": "controlled-fallback",
|
||||
"fallback_used": True,
|
||||
"target_count": len(configured_targets),
|
||||
"success_count": 0,
|
||||
"errors": errors,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _probe_configured_target(
|
||||
target: A2SServerTarget,
|
||||
timeout: float,
|
||||
) -> dict[str, object]:
|
||||
return fetch_a2s_probe(
|
||||
target.host,
|
||||
target.query_port,
|
||||
timeout=timeout,
|
||||
source_name=target.source_name,
|
||||
external_server_id=target.external_server_id,
|
||||
region=target.region,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
77
backend/app/config.py
Normal file
77
backend/app/config.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Local development configuration for the HLL Vietnam backend bootstrap."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 8000
|
||||
DEFAULT_STORAGE_FILENAME = "hll_vietnam_dev.sqlite3"
|
||||
DEFAULT_REFRESH_INTERVAL_SECONDS = 300
|
||||
DEFAULT_ALLOWED_ORIGINS = (
|
||||
"null",
|
||||
"http://127.0.0.1:5500",
|
||||
"http://127.0.0.1:8080",
|
||||
"http://localhost:5500",
|
||||
"http://localhost:8080",
|
||||
)
|
||||
DEFAULT_A2S_TARGETS_ENV_VAR = "HLL_BACKEND_A2S_TARGETS"
|
||||
DEFAULT_A2S_SOURCE_NAME = "community-hispana-a2s"
|
||||
|
||||
|
||||
def get_bind_address() -> tuple[str, int]:
|
||||
"""Return the host and port used by the local backend bootstrap."""
|
||||
host = os.getenv("HLL_BACKEND_HOST", DEFAULT_HOST)
|
||||
port = int(os.getenv("HLL_BACKEND_PORT", str(DEFAULT_PORT)))
|
||||
return host, port
|
||||
|
||||
|
||||
def get_allowed_origins() -> tuple[str, ...]:
|
||||
"""Return the small allowlist used for local frontend development."""
|
||||
raw_origins = os.getenv(
|
||||
"HLL_BACKEND_ALLOWED_ORIGINS",
|
||||
",".join(DEFAULT_ALLOWED_ORIGINS),
|
||||
)
|
||||
origins = []
|
||||
for origin in raw_origins.split(","):
|
||||
normalized_origin = _normalize_origin(origin)
|
||||
if normalized_origin:
|
||||
origins.append(normalized_origin)
|
||||
return tuple(origins) or DEFAULT_ALLOWED_ORIGINS
|
||||
|
||||
|
||||
def _normalize_origin(origin: str) -> str:
|
||||
"""Normalize configured origins so env overrides match browser Origin values."""
|
||||
return origin.strip().rstrip("/")
|
||||
|
||||
|
||||
def get_storage_path() -> Path:
|
||||
"""Return the local SQLite path used for development snapshot persistence."""
|
||||
default_path = Path(__file__).resolve().parent.parent / "data" / DEFAULT_STORAGE_FILENAME
|
||||
configured_path = os.getenv("HLL_BACKEND_STORAGE_PATH")
|
||||
return Path(configured_path) if configured_path else default_path
|
||||
|
||||
|
||||
def get_refresh_interval_seconds() -> int:
|
||||
"""Return the default interval used by the local refresh loop."""
|
||||
configured_value = os.getenv(
|
||||
"HLL_BACKEND_REFRESH_INTERVAL_SECONDS",
|
||||
str(DEFAULT_REFRESH_INTERVAL_SECONDS),
|
||||
)
|
||||
interval_seconds = int(configured_value)
|
||||
if interval_seconds <= 0:
|
||||
raise ValueError("HLL_BACKEND_REFRESH_INTERVAL_SECONDS must be positive.")
|
||||
|
||||
return interval_seconds
|
||||
|
||||
|
||||
def get_a2s_targets_payload() -> str | None:
|
||||
"""Return the optional JSON payload that overrides local A2S targets."""
|
||||
raw_payload = os.getenv(DEFAULT_A2S_TARGETS_ENV_VAR)
|
||||
if raw_payload is None:
|
||||
return None
|
||||
|
||||
normalized = raw_payload.strip()
|
||||
return normalized or None
|
||||
73
backend/app/main.py
Normal file
73
backend/app/main.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Minimal HTTP entrypoint for the HLL Vietnam backend bootstrap."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
from .config import get_allowed_origins, get_bind_address
|
||||
from .routes import resolve_get_payload
|
||||
|
||||
|
||||
class HealthHandler(BaseHTTPRequestHandler):
|
||||
"""Serve the minimal routes required for the backend bootstrap."""
|
||||
|
||||
server_version = "HLLVietnamBackend/0.1"
|
||||
|
||||
def do_OPTIONS(self) -> None: # noqa: N802 - BaseHTTPRequestHandler interface
|
||||
self.send_response(HTTPStatus.NO_CONTENT)
|
||||
self._send_default_headers()
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler interface
|
||||
status, payload = resolve_get_payload(self.path)
|
||||
if status is None:
|
||||
self._write_json(
|
||||
HTTPStatus.NOT_FOUND,
|
||||
{"status": "error", "message": "Route not found"},
|
||||
)
|
||||
return
|
||||
|
||||
self._write_json(status, payload)
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
# Keep local startup output clean unless future tasks need request logging.
|
||||
return
|
||||
|
||||
def _write_json(self, status: HTTPStatus, payload: dict[str, object]) -> None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self._send_default_headers(content_length=len(body))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _send_default_headers(self, content_length: int | None = None) -> None:
|
||||
origin = self.headers.get("Origin")
|
||||
if origin in get_allowed_origins():
|
||||
self.send_header("Access-Control-Allow-Origin", origin)
|
||||
self.send_header("Vary", "Origin")
|
||||
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
if content_length is not None:
|
||||
self.send_header("Content-Length", str(content_length))
|
||||
|
||||
|
||||
def create_server() -> ThreadingHTTPServer:
|
||||
"""Build the HTTP server using the package-supported handler and bind settings."""
|
||||
host, port = get_bind_address()
|
||||
return ThreadingHTTPServer((host, port), HealthHandler)
|
||||
|
||||
|
||||
def run() -> None:
|
||||
"""Start the local bootstrap server."""
|
||||
host, port = get_bind_address()
|
||||
server = create_server()
|
||||
print(f"HLL Vietnam backend bootstrap listening on http://{host}:{port}")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
89
backend/app/normalizers.py
Normal file
89
backend/app/normalizers.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Normalization helpers for provisional server collection flows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Mapping
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .a2s_client import A2SServerInfo
|
||||
|
||||
|
||||
def normalize_server_record(
|
||||
raw_record: Mapping[str, object],
|
||||
*,
|
||||
source_name: str,
|
||||
) -> dict[str, object]:
|
||||
"""Normalize a raw server record into the collector's internal shape."""
|
||||
external_server_id = _string_or_none(raw_record.get("external_server_id"))
|
||||
return {
|
||||
"external_server_id": external_server_id,
|
||||
"server_name": _string_or_default(raw_record.get("server_name"), "Unknown server"),
|
||||
"status": _normalize_status(raw_record.get("status")),
|
||||
"players": _coerce_int(raw_record.get("players")),
|
||||
"max_players": _coerce_int(raw_record.get("max_players")),
|
||||
"current_map": _string_or_none(raw_record.get("current_map")),
|
||||
"region": _string_or_none(raw_record.get("region")),
|
||||
"source_name": source_name,
|
||||
"snapshot_origin": "controlled-fallback",
|
||||
"source_ref": external_server_id or source_name,
|
||||
}
|
||||
|
||||
|
||||
def normalize_a2s_server_info(
|
||||
server_info: "A2SServerInfo",
|
||||
*,
|
||||
source_name: str,
|
||||
external_server_id: str | None = None,
|
||||
region: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Normalize a probed A2S payload into the collector's internal shape."""
|
||||
resolved_external_id = external_server_id or (
|
||||
f"a2s:{server_info.host}:{server_info.query_port}"
|
||||
)
|
||||
return {
|
||||
"external_server_id": resolved_external_id,
|
||||
"server_name": server_info.server_name or "Unknown server",
|
||||
"status": "online",
|
||||
"players": server_info.players,
|
||||
"max_players": server_info.max_players,
|
||||
"current_map": server_info.map_name,
|
||||
"region": region,
|
||||
"source_name": source_name,
|
||||
"snapshot_origin": "real-a2s",
|
||||
"source_ref": f"a2s://{server_info.host}:{server_info.query_port}",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_status(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
return "unknown"
|
||||
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"online", "offline", "unknown"}:
|
||||
return normalized
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _coerce_int(value: object) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _string_or_none(value: object) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
|
||||
stripped = value.strip()
|
||||
return stripped or None
|
||||
|
||||
|
||||
def _string_or_default(value: object, default: str) -> str:
|
||||
normalized = _string_or_none(value)
|
||||
return normalized or default
|
||||
180
backend/app/payloads.py
Normal file
180
backend/app/payloads.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""Placeholder payload builders for the HLL Vietnam backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .server_targets import load_a2s_targets
|
||||
from .storage import list_latest_snapshots, list_server_history, list_snapshot_history
|
||||
|
||||
|
||||
def build_health_payload() -> dict[str, str]:
|
||||
"""Return a small status payload without committing to business contracts."""
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": "hll-vietnam-backend",
|
||||
"phase": "bootstrap",
|
||||
}
|
||||
|
||||
|
||||
def build_community_payload() -> dict[str, object]:
|
||||
"""Return placeholder community content aligned with the documented contract."""
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"title": "Comunidad Hispana HLL Vietnam",
|
||||
"summary": "Punto de encuentro para jugadores, escuadras y comunidad.",
|
||||
"discord_invite_url": "https://discord.com/invite/PedEqZ2Xsa",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_trailer_payload() -> dict[str, object]:
|
||||
"""Return placeholder trailer metadata for future frontend consumption."""
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"video_url": "https://www.youtube.com/embed/JzYzYNVWZ_A",
|
||||
"title": "Trailer HLL Vietnam",
|
||||
"provider": "youtube",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_discord_payload() -> dict[str, object]:
|
||||
"""Return public Discord placeholder data without real integration."""
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"invite_url": "https://discord.com/invite/PedEqZ2Xsa",
|
||||
"label": "Unirse al Discord",
|
||||
"availability": "manual",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_servers_payload() -> dict[str, object]:
|
||||
"""Return a controlled placeholder for current Hell Let Loose servers."""
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"title": "Servidores actuales de Hell Let Loose",
|
||||
"context": "current-hll-reference",
|
||||
"source": "controlled-placeholder",
|
||||
"items": [
|
||||
{
|
||||
"server_name": "HLL ESP Tactical Rotation",
|
||||
"status": "online",
|
||||
"players": 74,
|
||||
"max_players": 100,
|
||||
"current_map": "Sainte-Marie-du-Mont",
|
||||
"region": "EU",
|
||||
},
|
||||
{
|
||||
"server_name": "HLL LATAM Night Offensive",
|
||||
"status": "online",
|
||||
"players": 51,
|
||||
"max_players": 100,
|
||||
"current_map": "Carentan",
|
||||
"region": "LATAM",
|
||||
},
|
||||
{
|
||||
"server_name": "HLL Community Reserve",
|
||||
"status": "offline",
|
||||
"players": 0,
|
||||
"max_players": 100,
|
||||
"current_map": None,
|
||||
"region": "EU",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_server_latest_payload() -> dict[str, object]:
|
||||
"""Return the latest persisted snapshot for each known server."""
|
||||
items = _enrich_server_items(list_latest_snapshots())
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"title": "Ultimo estado conocido de servidores",
|
||||
"context": "current-hll-history",
|
||||
"source": "local-snapshot-storage",
|
||||
"summary_window_size": 6,
|
||||
"items": items,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_server_history_payload(*, limit: int = 20) -> dict[str, object]:
|
||||
"""Return recent persisted snapshots across all known servers."""
|
||||
items = _enrich_server_items(list_snapshot_history(limit=limit))
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"title": "Historial reciente de servidores",
|
||||
"context": "current-hll-history",
|
||||
"source": "local-snapshot-storage",
|
||||
"limit": limit,
|
||||
"items": items,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_server_detail_history_payload(
|
||||
server_id: str,
|
||||
*,
|
||||
limit: int = 20,
|
||||
) -> dict[str, object]:
|
||||
"""Return recent persisted snapshots for one server."""
|
||||
items = _enrich_server_items(list_server_history(server_id, limit=limit))
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"title": "Historial por servidor",
|
||||
"context": "current-hll-history",
|
||||
"source": "local-snapshot-storage",
|
||||
"server_id": server_id,
|
||||
"limit": limit,
|
||||
"items": items,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_error_payload(message: str) -> dict[str, str]:
|
||||
"""Return the shared error payload shape used by the backend bootstrap."""
|
||||
return {
|
||||
"status": "error",
|
||||
"message": message,
|
||||
}
|
||||
|
||||
|
||||
def _enrich_server_items(items: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
target_index = {
|
||||
target.external_server_id: target
|
||||
for target in load_a2s_targets()
|
||||
if target.external_server_id
|
||||
}
|
||||
enriched_items: list[dict[str, object]] = []
|
||||
for item in items:
|
||||
enriched_items.append(_enrich_server_item(item, target_index))
|
||||
return enriched_items
|
||||
|
||||
|
||||
def _enrich_server_item(
|
||||
item: dict[str, object],
|
||||
target_index: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
enriched = dict(item)
|
||||
external_server_id = enriched.get("external_server_id")
|
||||
snapshot_origin = enriched.get("snapshot_origin")
|
||||
target = target_index.get(external_server_id)
|
||||
|
||||
if not target or snapshot_origin != "real-a2s":
|
||||
enriched["host"] = None
|
||||
enriched["query_port"] = None
|
||||
enriched["game_port"] = None
|
||||
return enriched
|
||||
|
||||
enriched["host"] = target.host
|
||||
enriched["query_port"] = target.query_port
|
||||
enriched["game_port"] = target.game_port
|
||||
return enriched
|
||||
70
backend/app/routes.py
Normal file
70
backend/app/routes.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Route resolution helpers for the HLL Vietnam backend bootstrap."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from .payloads import (
|
||||
build_community_payload,
|
||||
build_discord_payload,
|
||||
build_error_payload,
|
||||
build_health_payload,
|
||||
build_server_detail_history_payload,
|
||||
build_server_history_payload,
|
||||
build_server_latest_payload,
|
||||
build_servers_payload,
|
||||
build_trailer_payload,
|
||||
)
|
||||
|
||||
|
||||
GET_ROUTES = {
|
||||
"/health": build_health_payload,
|
||||
"/api/community": build_community_payload,
|
||||
"/api/trailer": build_trailer_payload,
|
||||
"/api/discord": build_discord_payload,
|
||||
"/api/servers": build_servers_payload,
|
||||
}
|
||||
|
||||
|
||||
def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]]:
|
||||
"""Resolve the JSON payload for a supported GET route."""
|
||||
parsed = urlparse(path)
|
||||
if parsed.path == "/api/servers/latest":
|
||||
return HTTPStatus.OK, build_server_latest_payload()
|
||||
|
||||
if parsed.path == "/api/servers/history":
|
||||
limit = _parse_limit(parsed.query)
|
||||
if limit is None:
|
||||
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
|
||||
return HTTPStatus.OK, build_server_history_payload(limit=limit)
|
||||
|
||||
builder = GET_ROUTES.get(parsed.path)
|
||||
if builder is None:
|
||||
if parsed.path.startswith("/api/servers/") and parsed.path.endswith("/history"):
|
||||
server_id = parsed.path.removeprefix("/api/servers/").removesuffix("/history")
|
||||
server_id = server_id.strip("/")
|
||||
if not server_id:
|
||||
return HTTPStatus.BAD_REQUEST, build_error_payload("Server id is required")
|
||||
|
||||
limit = _parse_limit(parsed.query)
|
||||
if limit is None:
|
||||
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
|
||||
|
||||
return HTTPStatus.OK, build_server_detail_history_payload(server_id, limit=limit)
|
||||
return None, {}
|
||||
|
||||
return HTTPStatus.OK, builder()
|
||||
|
||||
|
||||
def _parse_limit(query: str) -> int | None:
|
||||
raw_limit = parse_qs(query).get("limit", ["20"])[0]
|
||||
try:
|
||||
limit = int(raw_limit)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if limit < 1 or limit > 100:
|
||||
return None
|
||||
|
||||
return limit
|
||||
100
backend/app/scheduler.py
Normal file
100
backend/app/scheduler.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Local development loop for periodic snapshot refreshes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
|
||||
from .a2s_client import DEFAULT_A2S_TIMEOUT
|
||||
from .collector import collect_server_snapshots
|
||||
from .config import get_refresh_interval_seconds
|
||||
|
||||
|
||||
def run_local_refresh_loop(
|
||||
*,
|
||||
interval_seconds: int,
|
||||
source_mode: str,
|
||||
timeout: float,
|
||||
allow_controlled_fallback: bool,
|
||||
max_runs: int | None = None,
|
||||
) -> None:
|
||||
"""Run the collector periodically until interrupted or the run limit is reached."""
|
||||
completed_runs = 0
|
||||
print(
|
||||
"Starting local snapshot refresh loop "
|
||||
f"(interval={interval_seconds}s, source={source_mode}, persist=true)."
|
||||
)
|
||||
print("Press Ctrl+C to stop.")
|
||||
|
||||
try:
|
||||
while max_runs is None or completed_runs < max_runs:
|
||||
completed_runs += 1
|
||||
payload = collect_server_snapshots(
|
||||
source_mode=source_mode,
|
||||
timeout=timeout,
|
||||
allow_controlled_fallback=allow_controlled_fallback,
|
||||
persist=True,
|
||||
)
|
||||
print(json.dumps({"run": completed_runs, **payload}, indent=2))
|
||||
|
||||
if max_runs is not None and completed_runs >= max_runs:
|
||||
break
|
||||
|
||||
time.sleep(interval_seconds)
|
||||
except KeyboardInterrupt:
|
||||
print("\nLocal snapshot refresh loop stopped by user.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Allow local scheduled refresh execution without adding external infrastructure."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run periodic local snapshot refreshes for development.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--interval",
|
||||
type=int,
|
||||
default=get_refresh_interval_seconds(),
|
||||
help="Seconds to wait between persisted refresh runs.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--source",
|
||||
choices=("controlled", "a2s", "auto"),
|
||||
default="auto",
|
||||
help="Choose controlled data, configured A2S targets, or auto with fallback.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=float,
|
||||
default=DEFAULT_A2S_TIMEOUT,
|
||||
help="Socket timeout in seconds for A2S probes.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-fallback",
|
||||
action="store_true",
|
||||
help="Disable fallback to controlled data when A2S fails.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-runs",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Optional safety limit for the number of refresh cycles to execute.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.interval <= 0:
|
||||
raise ValueError("--interval must be a positive integer.")
|
||||
if args.max_runs is not None and args.max_runs <= 0:
|
||||
raise ValueError("--max-runs must be positive when provided.")
|
||||
|
||||
run_local_refresh_loop(
|
||||
interval_seconds=args.interval,
|
||||
source_mode=args.source,
|
||||
timeout=args.timeout,
|
||||
allow_controlled_fallback=not args.no_fallback,
|
||||
max_runs=args.max_runs,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
106
backend/app/server_targets.py
Normal file
106
backend/app/server_targets.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Registry helpers for development-time A2S probe targets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .config import DEFAULT_A2S_SOURCE_NAME, get_a2s_targets_payload
|
||||
|
||||
|
||||
DEFAULT_A2S_TARGETS = (
|
||||
{
|
||||
"name": "Comunidad Hispana #01",
|
||||
"host": "152.114.195.174",
|
||||
"query_port": 7778,
|
||||
"game_port": 7777,
|
||||
"source_name": DEFAULT_A2S_SOURCE_NAME,
|
||||
"external_server_id": "comunidad-hispana-01",
|
||||
"region": "ES",
|
||||
},
|
||||
{
|
||||
"name": "Comunidad Hispana #02",
|
||||
"host": "152.114.195.150",
|
||||
"query_port": 7878,
|
||||
"game_port": 7877,
|
||||
"source_name": DEFAULT_A2S_SOURCE_NAME,
|
||||
"external_server_id": "comunidad-hispana-02",
|
||||
"region": "ES",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class A2SServerTarget:
|
||||
"""Minimal configuration needed to query one A2S target."""
|
||||
|
||||
name: str
|
||||
host: str
|
||||
query_port: int
|
||||
game_port: int | None
|
||||
source_name: str
|
||||
external_server_id: str | None = None
|
||||
region: str | None = None
|
||||
|
||||
|
||||
def load_a2s_targets() -> tuple[A2SServerTarget, ...]:
|
||||
"""Load configured A2S targets from env JSON or the local default registry."""
|
||||
raw_payload = get_a2s_targets_payload()
|
||||
raw_targets = DEFAULT_A2S_TARGETS if raw_payload is None else _parse_targets(raw_payload)
|
||||
return tuple(_coerce_target(item) for item in raw_targets)
|
||||
|
||||
|
||||
def _parse_targets(raw_payload: str) -> list[dict[str, object]]:
|
||||
try:
|
||||
parsed = json.loads(raw_payload)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError("HLL_BACKEND_A2S_TARGETS must be valid JSON.") from error
|
||||
|
||||
if not isinstance(parsed, list):
|
||||
raise ValueError("HLL_BACKEND_A2S_TARGETS must be a JSON array.")
|
||||
|
||||
return [item for item in parsed if isinstance(item, dict)]
|
||||
|
||||
|
||||
def _coerce_target(raw_target: dict[str, object]) -> A2SServerTarget:
|
||||
name = str(raw_target.get("name") or "Unnamed target").strip()
|
||||
host = str(raw_target.get("host") or "").strip()
|
||||
source_name = str(raw_target.get("source_name") or DEFAULT_A2S_SOURCE_NAME).strip()
|
||||
query_port = int(raw_target.get("query_port") or 0)
|
||||
game_port = _coerce_optional_positive_int(raw_target.get("game_port"))
|
||||
external_server_id = _string_or_none(raw_target.get("external_server_id"))
|
||||
region = _string_or_none(raw_target.get("region"))
|
||||
|
||||
if not host:
|
||||
raise ValueError("Each A2S target must define a non-empty host.")
|
||||
if query_port <= 0:
|
||||
raise ValueError("Each A2S target must define a valid query_port.")
|
||||
|
||||
return A2SServerTarget(
|
||||
name=name,
|
||||
host=host,
|
||||
query_port=query_port,
|
||||
game_port=game_port,
|
||||
source_name=source_name or DEFAULT_A2S_SOURCE_NAME,
|
||||
external_server_id=external_server_id,
|
||||
region=region,
|
||||
)
|
||||
|
||||
|
||||
def _string_or_none(value: object) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
|
||||
normalized = value.strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _coerce_optional_positive_int(value: object) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
coerced = int(value)
|
||||
if coerced <= 0:
|
||||
raise ValueError("Each A2S target game_port must be positive when defined.")
|
||||
|
||||
return coerced
|
||||
54
backend/app/snapshots.py
Normal file
54
backend/app/snapshots.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Snapshot builders for normalized provisional server data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterable, Mapping
|
||||
|
||||
|
||||
def build_server_snapshot(
|
||||
normalized_record: Mapping[str, object],
|
||||
*,
|
||||
captured_at: datetime,
|
||||
) -> dict[str, object]:
|
||||
"""Build a consistent snapshot payload for one normalized server."""
|
||||
timestamp = _as_utc_timestamp(captured_at)
|
||||
return {
|
||||
"external_server_id": normalized_record.get("external_server_id"),
|
||||
"server_name": normalized_record.get("server_name"),
|
||||
"status": normalized_record.get("status"),
|
||||
"players": normalized_record.get("players"),
|
||||
"max_players": normalized_record.get("max_players"),
|
||||
"current_map": normalized_record.get("current_map"),
|
||||
"region": normalized_record.get("region"),
|
||||
"source_name": normalized_record.get("source_name"),
|
||||
"snapshot_origin": normalized_record.get("snapshot_origin"),
|
||||
"source_ref": normalized_record.get("source_ref"),
|
||||
"captured_at": timestamp,
|
||||
}
|
||||
|
||||
|
||||
def build_snapshot_batch(
|
||||
normalized_records: Iterable[Mapping[str, object]],
|
||||
*,
|
||||
captured_at: datetime,
|
||||
) -> list[dict[str, object]]:
|
||||
"""Build snapshots for a batch captured at the same timestamp."""
|
||||
return [
|
||||
build_server_snapshot(record, captured_at=captured_at)
|
||||
for record in normalized_records
|
||||
]
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
"""Return the current UTC timestamp for snapshot capture."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _as_utc_timestamp(value: datetime) -> str:
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
value = value.astimezone(timezone.utc)
|
||||
|
||||
return value.isoformat().replace("+00:00", "Z")
|
||||
514
backend/app/storage.py
Normal file
514
backend/app/storage.py
Normal file
@@ -0,0 +1,514 @@
|
||||
"""Local SQLite persistence for provisional server snapshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Mapping
|
||||
|
||||
from .config import get_storage_path
|
||||
|
||||
|
||||
DEFAULT_GAME_SOURCE = {
|
||||
"slug": "current-hll",
|
||||
"display_name": "Current Hell Let Loose",
|
||||
"provider_kind": "development",
|
||||
}
|
||||
SUMMARY_SNAPSHOT_LIMIT = 6
|
||||
|
||||
|
||||
def initialize_storage(*, db_path: Path | None = None) -> Path:
|
||||
"""Create the local database file and minimal schema when missing."""
|
||||
resolved_path = db_path or get_storage_path()
|
||||
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with _connect(resolved_path) as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS game_sources (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
provider_kind TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game_source_id INTEGER NOT NULL,
|
||||
external_server_id TEXT,
|
||||
server_name TEXT NOT NULL,
|
||||
region TEXT,
|
||||
first_seen_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (game_source_id, external_server_id),
|
||||
FOREIGN KEY (game_source_id) REFERENCES game_sources(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
captured_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
players INTEGER,
|
||||
max_players INTEGER,
|
||||
current_map TEXT,
|
||||
source_name TEXT NOT NULL,
|
||||
snapshot_origin TEXT,
|
||||
source_ref TEXT,
|
||||
raw_payload_ref TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_server_snapshots_server_time
|
||||
ON server_snapshots(server_id, captured_at);
|
||||
"""
|
||||
)
|
||||
_ensure_server_snapshot_columns(connection)
|
||||
|
||||
return resolved_path
|
||||
|
||||
|
||||
def persist_snapshot_batch(
|
||||
snapshots: Iterable[Mapping[str, object]],
|
||||
*,
|
||||
source_name: str,
|
||||
captured_at: str,
|
||||
game_source: Mapping[str, str] | None = None,
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Persist a batch of normalized snapshots into local SQLite storage."""
|
||||
resolved_path = initialize_storage(db_path=db_path)
|
||||
source_definition = dict(DEFAULT_GAME_SOURCE)
|
||||
if game_source is not None:
|
||||
source_definition.update(game_source)
|
||||
|
||||
persisted = 0
|
||||
with _connect(resolved_path) as connection:
|
||||
game_source_id = _upsert_game_source(connection, source_definition)
|
||||
for snapshot in snapshots:
|
||||
server_id = _upsert_server(
|
||||
connection,
|
||||
game_source_id=game_source_id,
|
||||
snapshot=snapshot,
|
||||
captured_at=captured_at,
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO server_snapshots (
|
||||
server_id,
|
||||
captured_at,
|
||||
status,
|
||||
players,
|
||||
max_players,
|
||||
current_map,
|
||||
source_name,
|
||||
snapshot_origin,
|
||||
source_ref,
|
||||
raw_payload_ref
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
server_id,
|
||||
captured_at,
|
||||
snapshot.get("status"),
|
||||
snapshot.get("players"),
|
||||
snapshot.get("max_players"),
|
||||
snapshot.get("current_map"),
|
||||
snapshot.get("source_name") or source_name,
|
||||
snapshot.get("snapshot_origin"),
|
||||
snapshot.get("source_ref"),
|
||||
None,
|
||||
),
|
||||
)
|
||||
persisted += 1
|
||||
|
||||
return {
|
||||
"db_path": str(resolved_path),
|
||||
"captured_at": captured_at,
|
||||
"persisted_snapshots": persisted,
|
||||
"game_source_slug": source_definition["slug"],
|
||||
}
|
||||
|
||||
|
||||
def list_latest_snapshots(*, db_path: Path | None = None) -> list[dict[str, object]]:
|
||||
"""Return the latest persisted snapshot for each known server."""
|
||||
resolved_path = initialize_storage(db_path=db_path)
|
||||
with _connect(resolved_path) as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT
|
||||
servers.id AS server_id,
|
||||
servers.external_server_id,
|
||||
servers.server_name,
|
||||
servers.region,
|
||||
game_sources.slug AS context,
|
||||
server_snapshots.source_name,
|
||||
server_snapshots.snapshot_origin,
|
||||
server_snapshots.source_ref,
|
||||
server_snapshots.captured_at,
|
||||
server_snapshots.status,
|
||||
server_snapshots.players,
|
||||
server_snapshots.max_players,
|
||||
server_snapshots.current_map
|
||||
FROM servers
|
||||
INNER JOIN game_sources
|
||||
ON game_sources.id = servers.game_source_id
|
||||
INNER JOIN server_snapshots
|
||||
ON server_snapshots.server_id = servers.id
|
||||
INNER JOIN (
|
||||
SELECT server_id, MAX(captured_at) AS latest_captured_at
|
||||
FROM server_snapshots
|
||||
GROUP BY server_id
|
||||
) AS latest
|
||||
ON latest.server_id = server_snapshots.server_id
|
||||
AND latest.latest_captured_at = server_snapshots.captured_at
|
||||
ORDER BY servers.server_name ASC
|
||||
"""
|
||||
).fetchall()
|
||||
items = [_serialize_snapshot_row(row) for row in rows]
|
||||
return _attach_history_summaries(connection, items)
|
||||
|
||||
|
||||
def list_snapshot_history(
|
||||
*,
|
||||
db_path: Path | None = None,
|
||||
limit: int = 20,
|
||||
) -> list[dict[str, object]]:
|
||||
"""Return recent persisted snapshots across all servers."""
|
||||
resolved_path = initialize_storage(db_path=db_path)
|
||||
with _connect(resolved_path) as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT
|
||||
servers.id AS server_id,
|
||||
servers.external_server_id,
|
||||
servers.server_name,
|
||||
servers.region,
|
||||
game_sources.slug AS context,
|
||||
server_snapshots.source_name,
|
||||
server_snapshots.snapshot_origin,
|
||||
server_snapshots.source_ref,
|
||||
server_snapshots.captured_at,
|
||||
server_snapshots.status,
|
||||
server_snapshots.players,
|
||||
server_snapshots.max_players,
|
||||
server_snapshots.current_map
|
||||
FROM server_snapshots
|
||||
INNER JOIN servers
|
||||
ON servers.id = server_snapshots.server_id
|
||||
INNER JOIN game_sources
|
||||
ON game_sources.id = servers.game_source_id
|
||||
ORDER BY server_snapshots.captured_at DESC, servers.server_name ASC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [_serialize_snapshot_row(row) for row in rows]
|
||||
|
||||
|
||||
def list_server_history(
|
||||
server_id: str,
|
||||
*,
|
||||
db_path: Path | None = None,
|
||||
limit: int = 20,
|
||||
) -> list[dict[str, object]]:
|
||||
"""Return recent history for one server by numeric id or external id."""
|
||||
resolved_path = initialize_storage(db_path=db_path)
|
||||
server_filter, server_value = _build_server_filter(server_id)
|
||||
with _connect(resolved_path) as connection:
|
||||
rows = connection.execute(
|
||||
f"""
|
||||
SELECT
|
||||
servers.id AS server_id,
|
||||
servers.external_server_id,
|
||||
servers.server_name,
|
||||
servers.region,
|
||||
game_sources.slug AS context,
|
||||
server_snapshots.source_name,
|
||||
server_snapshots.snapshot_origin,
|
||||
server_snapshots.source_ref,
|
||||
server_snapshots.captured_at,
|
||||
server_snapshots.status,
|
||||
server_snapshots.players,
|
||||
server_snapshots.max_players,
|
||||
server_snapshots.current_map
|
||||
FROM server_snapshots
|
||||
INNER JOIN servers
|
||||
ON servers.id = server_snapshots.server_id
|
||||
INNER JOIN game_sources
|
||||
ON game_sources.id = servers.game_source_id
|
||||
WHERE {server_filter} = ?
|
||||
ORDER BY server_snapshots.captured_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(server_value, limit),
|
||||
).fetchall()
|
||||
return [_serialize_snapshot_row(row) for row in rows]
|
||||
|
||||
|
||||
def _connect(db_path: Path) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(db_path)
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
|
||||
|
||||
def _upsert_game_source(
|
||||
connection: sqlite3.Connection,
|
||||
game_source: Mapping[str, str],
|
||||
) -> int:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO game_sources (slug, display_name, provider_kind, is_active)
|
||||
VALUES (?, ?, ?, 1)
|
||||
ON CONFLICT(slug) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
provider_kind = excluded.provider_kind,
|
||||
is_active = 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(
|
||||
game_source["slug"],
|
||||
game_source["display_name"],
|
||||
game_source["provider_kind"],
|
||||
),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT id FROM game_sources WHERE slug = ?",
|
||||
(game_source["slug"],),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise RuntimeError("Failed to resolve game source during snapshot persistence.")
|
||||
|
||||
return int(row["id"])
|
||||
|
||||
|
||||
def _upsert_server(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
game_source_id: int,
|
||||
snapshot: Mapping[str, object],
|
||||
captured_at: str,
|
||||
) -> int:
|
||||
external_server_id = snapshot.get("external_server_id")
|
||||
if not isinstance(external_server_id, str) or not external_server_id.strip():
|
||||
external_server_id = _build_fallback_external_id(snapshot)
|
||||
|
||||
server_name = str(snapshot.get("server_name") or "Unknown server")
|
||||
region = snapshot.get("region")
|
||||
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO servers (
|
||||
game_source_id,
|
||||
external_server_id,
|
||||
server_name,
|
||||
region,
|
||||
first_seen_at,
|
||||
last_seen_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(game_source_id, external_server_id) DO UPDATE SET
|
||||
server_name = excluded.server_name,
|
||||
region = excluded.region,
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(
|
||||
game_source_id,
|
||||
external_server_id,
|
||||
server_name,
|
||||
region,
|
||||
captured_at,
|
||||
captured_at,
|
||||
),
|
||||
)
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM servers
|
||||
WHERE game_source_id = ? AND external_server_id = ?
|
||||
""",
|
||||
(game_source_id, external_server_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise RuntimeError("Failed to resolve server during snapshot persistence.")
|
||||
|
||||
return int(row["id"])
|
||||
|
||||
|
||||
def _build_fallback_external_id(snapshot: Mapping[str, object]) -> str:
|
||||
server_name = str(snapshot.get("server_name") or "unknown-server")
|
||||
normalized = "".join(
|
||||
character.lower() if character.isalnum() else "-"
|
||||
for character in server_name
|
||||
)
|
||||
compact = "-".join(part for part in normalized.split("-") if part)
|
||||
return compact or "unknown-server"
|
||||
|
||||
|
||||
def _ensure_server_snapshot_columns(connection: sqlite3.Connection) -> None:
|
||||
columns = {
|
||||
str(row["name"])
|
||||
for row in connection.execute("PRAGMA table_info(server_snapshots)").fetchall()
|
||||
}
|
||||
|
||||
if "snapshot_origin" not in columns:
|
||||
connection.execute("ALTER TABLE server_snapshots ADD COLUMN snapshot_origin TEXT")
|
||||
if "source_ref" not in columns:
|
||||
connection.execute("ALTER TABLE server_snapshots ADD COLUMN source_ref TEXT")
|
||||
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE server_snapshots
|
||||
SET snapshot_origin = CASE
|
||||
WHEN source_name = 'controlled-placeholder' THEN 'controlled-fallback'
|
||||
WHEN source_name LIKE '%a2s%' THEN 'real-a2s'
|
||||
ELSE 'unknown'
|
||||
END
|
||||
WHERE snapshot_origin IS NULL OR snapshot_origin = ''
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE server_snapshots
|
||||
SET source_ref = source_name
|
||||
WHERE source_ref IS NULL OR source_ref = ''
|
||||
"""
|
||||
)
|
||||
_backfill_registered_a2s_source_refs(connection)
|
||||
|
||||
|
||||
def _backfill_registered_a2s_source_refs(connection: sqlite3.Connection) -> None:
|
||||
from .server_targets import load_a2s_targets
|
||||
|
||||
for target in load_a2s_targets():
|
||||
if not target.external_server_id:
|
||||
continue
|
||||
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE server_snapshots
|
||||
SET source_ref = ?
|
||||
WHERE snapshot_origin = 'real-a2s'
|
||||
AND source_ref = source_name
|
||||
AND server_id IN (
|
||||
SELECT id
|
||||
FROM servers
|
||||
WHERE external_server_id = ?
|
||||
)
|
||||
""",
|
||||
(
|
||||
f"a2s://{target.host}:{target.query_port}",
|
||||
target.external_server_id,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _serialize_snapshot_row(row: sqlite3.Row) -> dict[str, object]:
|
||||
return {
|
||||
"server_id": row["server_id"],
|
||||
"external_server_id": row["external_server_id"],
|
||||
"server_name": row["server_name"],
|
||||
"region": row["region"],
|
||||
"context": row["context"],
|
||||
"source_name": row["source_name"],
|
||||
"snapshot_origin": row["snapshot_origin"],
|
||||
"source_ref": row["source_ref"],
|
||||
"captured_at": row["captured_at"],
|
||||
"status": row["status"],
|
||||
"players": row["players"],
|
||||
"max_players": row["max_players"],
|
||||
"current_map": row["current_map"],
|
||||
}
|
||||
|
||||
|
||||
def _attach_history_summaries(
|
||||
connection: sqlite3.Connection,
|
||||
items: list[dict[str, object]],
|
||||
) -> list[dict[str, object]]:
|
||||
enriched_items: list[dict[str, object]] = []
|
||||
for item in items:
|
||||
enriched = dict(item)
|
||||
enriched["history_summary"] = _build_history_summary(
|
||||
connection,
|
||||
int(item["server_id"]),
|
||||
)
|
||||
enriched_items.append(enriched)
|
||||
|
||||
return enriched_items
|
||||
|
||||
|
||||
def _build_history_summary(
|
||||
connection: sqlite3.Connection,
|
||||
server_id: int,
|
||||
) -> dict[str, object]:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT
|
||||
captured_at,
|
||||
status,
|
||||
players
|
||||
FROM server_snapshots
|
||||
WHERE server_id = ?
|
||||
ORDER BY captured_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(server_id, SUMMARY_SNAPSHOT_LIMIT),
|
||||
).fetchall()
|
||||
return _summarize_history_rows(rows)
|
||||
|
||||
|
||||
def _summarize_history_rows(rows: list[sqlite3.Row]) -> dict[str, object]:
|
||||
capture_count = len(rows)
|
||||
player_values = [
|
||||
int(row["players"])
|
||||
for row in rows
|
||||
if row["players"] is not None
|
||||
]
|
||||
online_rows = [row for row in rows if row["status"] == "online"]
|
||||
latest_captured_at = str(rows[0]["captured_at"]) if rows else None
|
||||
last_seen_online_at = str(online_rows[0]["captured_at"]) if online_rows else None
|
||||
|
||||
return {
|
||||
"window_size": SUMMARY_SNAPSHOT_LIMIT,
|
||||
"recent_capture_count": capture_count,
|
||||
"recent_online_count": len(online_rows),
|
||||
"recent_average_players": _round_average(player_values),
|
||||
"recent_peak_players": max(player_values, default=None),
|
||||
"last_seen_online_at": last_seen_online_at,
|
||||
"minutes_since_last_capture": _minutes_since_timestamp(latest_captured_at),
|
||||
}
|
||||
|
||||
|
||||
def _round_average(values: list[int]) -> float | None:
|
||||
if not values:
|
||||
return None
|
||||
|
||||
return round(sum(values) / len(values), 1)
|
||||
|
||||
|
||||
def _minutes_since_timestamp(timestamp: str | None) -> int | None:
|
||||
if not timestamp:
|
||||
return None
|
||||
|
||||
normalized = timestamp.replace("Z", "+00:00")
|
||||
captured_at = datetime.fromisoformat(normalized)
|
||||
if captured_at.tzinfo is None:
|
||||
captured_at = captured_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
delta = datetime.now(timezone.utc) - captured_at.astimezone(timezone.utc)
|
||||
return max(0, int(delta.total_seconds() // 60))
|
||||
|
||||
|
||||
def _build_server_filter(server_id: str) -> tuple[str, object]:
|
||||
normalized = server_id.strip()
|
||||
if normalized.isdigit():
|
||||
return "servers.id", int(normalized)
|
||||
|
||||
return "servers.external_server_id", normalized
|
||||
Reference in New Issue
Block a user