Avvia i relay YouTube overflow dal Sidekiq home e passa STREAM/HCLOUD in produzione.
Così un CPX nuovo non richiede worker Docker a mano, l'agent è nel cloud-init e gli E2E restano non in elenco. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -125,6 +125,8 @@ STREAM_NODE_ENV=prod
|
||||
STREAM_CLOUD_PROVIDER=local_lab
|
||||
RELAY_MAX_CONCURRENT=4
|
||||
YOUTUBE_RELAY_WORKER=1
|
||||
# Deve coincidere con cloud-init (default mediamtx_webhook_dev_secret) — home chiama l'agent :9100.
|
||||
STREAM_NODE_AGENT_SECRET=mediamtx_webhook_dev_secret
|
||||
|
||||
# Autoscaler (kill-switch: 0 finché WireGuard/smoke Cloud non sono OK — NON abilitare in prod senza lab)
|
||||
STREAM_AUTOSCALE_ENABLED=0
|
||||
|
||||
@@ -53,6 +53,8 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
command: bundle exec rails server -b 0.0.0.0 -p 3000 -e production
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
RAILS_ENV: production
|
||||
RAILS_MAX_THREADS: ${RAILS_MAX_THREADS:-5}
|
||||
@@ -70,6 +72,33 @@ services:
|
||||
YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-}
|
||||
YOUTUBE_REDIRECT_URI: ${YOUTUBE_REDIRECT_URI:-}
|
||||
YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-}
|
||||
STREAM_NODE_AGENT_SECRET: ${STREAM_NODE_AGENT_SECRET:-mediamtx_webhook_dev_secret}
|
||||
STREAM_NODE_HOME_MAX_PUBLISHERS: ${STREAM_NODE_HOME_MAX_PUBLISHERS:-6}
|
||||
STREAM_CLOUD_PROVIDER: ${STREAM_CLOUD_PROVIDER:-local_lab}
|
||||
STREAM_DNS_PROVIDER: ${STREAM_DNS_PROVIDER:-hetzner}
|
||||
STREAM_AUTOSCALE_ENABLED: ${STREAM_AUTOSCALE_ENABLED:-0}
|
||||
STREAM_AUTOSCALE_KIND: ${STREAM_AUTOSCALE_KIND:-lab}
|
||||
STREAM_AUTOSCALE_ALLOW_CLOUD: ${STREAM_AUTOSCALE_ALLOW_CLOUD:-0}
|
||||
STREAM_AUTOSCALE_SOFT_FREE_SLOTS: ${STREAM_AUTOSCALE_SOFT_FREE_SLOTS:-2}
|
||||
STREAM_AUTOSCALE_WARM_SPARE: ${STREAM_AUTOSCALE_WARM_SPARE:-1}
|
||||
STREAM_AUTOSCALE_MAX_NODES: ${STREAM_AUTOSCALE_MAX_NODES:-3}
|
||||
STREAM_AUTOSCALE_IDLE_MINUTES: ${STREAM_AUTOSCALE_IDLE_MINUTES:-30}
|
||||
STREAM_AUTOSCALE_INTERVAL_SECS: ${STREAM_AUTOSCALE_INTERVAL_SECS:-60}
|
||||
STREAM_AUTOSCALE_NODE_EUR_PER_HOUR: ${STREAM_AUTOSCALE_NODE_EUR_PER_HOUR:-0.015}
|
||||
STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR: ${STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR:-40}
|
||||
STREAM_CLOUD_PUBLIC_CONTROL: ${STREAM_CLOUD_PUBLIC_CONTROL:-1}
|
||||
STREAM_NODE_ENV: ${STREAM_NODE_ENV:-prod}
|
||||
STREAM_DNS_ZONE: ${STREAM_DNS_ZONE:-mltv-stream.net}
|
||||
STREAM_DNS_TTL: ${STREAM_DNS_TTL:-60}
|
||||
STREAM_CLOUD_DNS_SUFFIX: ${STREAM_CLOUD_DNS_SUFFIX:-mltv-stream.net}
|
||||
STREAM_CLOUD_MAX_PUBLISHERS: ${STREAM_CLOUD_MAX_PUBLISHERS:-4}
|
||||
HCLOUD_TOKEN: ${HCLOUD_TOKEN:-}
|
||||
HCLOUD_LOCATION: ${HCLOUD_LOCATION:-nbg1}
|
||||
HCLOUD_SERVER_TYPE: ${HCLOUD_SERVER_TYPE:-cpx12}
|
||||
HCLOUD_IMAGE: ${HCLOUD_IMAGE:-debian-12}
|
||||
HCLOUD_SSH_KEY: ${HCLOUD_SSH_KEY:-matchlivetv-stream-hetzner}
|
||||
HCLOUD_NETWORK_ID: ${HCLOUD_NETWORK_ID:-}
|
||||
HCLOUD_USER_DATA_FILE: /opt/matchlivetv/infra/stream-node/cloud-init.yaml
|
||||
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
|
||||
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
||||
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
|
||||
@@ -166,6 +195,8 @@ services:
|
||||
restart: unless-stopped
|
||||
hostname: home
|
||||
command: bundle exec sidekiq -C config/sidekiq.yml -e production
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
RAILS_ENV: production
|
||||
RAILS_MAX_THREADS: ${RAILS_MAX_THREADS:-5}
|
||||
@@ -182,6 +213,33 @@ services:
|
||||
YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-}
|
||||
YOUTUBE_RELAY_WORKER: "1"
|
||||
STREAM_NODE_SLUG: home
|
||||
STREAM_NODE_HOME_MAX_PUBLISHERS: ${STREAM_NODE_HOME_MAX_PUBLISHERS:-6}
|
||||
STREAM_CLOUD_PROVIDER: ${STREAM_CLOUD_PROVIDER:-local_lab}
|
||||
STREAM_DNS_PROVIDER: ${STREAM_DNS_PROVIDER:-hetzner}
|
||||
STREAM_AUTOSCALE_ENABLED: ${STREAM_AUTOSCALE_ENABLED:-0}
|
||||
STREAM_AUTOSCALE_KIND: ${STREAM_AUTOSCALE_KIND:-lab}
|
||||
STREAM_AUTOSCALE_ALLOW_CLOUD: ${STREAM_AUTOSCALE_ALLOW_CLOUD:-0}
|
||||
STREAM_AUTOSCALE_SOFT_FREE_SLOTS: ${STREAM_AUTOSCALE_SOFT_FREE_SLOTS:-2}
|
||||
STREAM_AUTOSCALE_WARM_SPARE: ${STREAM_AUTOSCALE_WARM_SPARE:-1}
|
||||
STREAM_AUTOSCALE_MAX_NODES: ${STREAM_AUTOSCALE_MAX_NODES:-3}
|
||||
STREAM_AUTOSCALE_IDLE_MINUTES: ${STREAM_AUTOSCALE_IDLE_MINUTES:-30}
|
||||
STREAM_AUTOSCALE_INTERVAL_SECS: ${STREAM_AUTOSCALE_INTERVAL_SECS:-60}
|
||||
STREAM_AUTOSCALE_NODE_EUR_PER_HOUR: ${STREAM_AUTOSCALE_NODE_EUR_PER_HOUR:-0.015}
|
||||
STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR: ${STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR:-40}
|
||||
STREAM_CLOUD_PUBLIC_CONTROL: ${STREAM_CLOUD_PUBLIC_CONTROL:-1}
|
||||
STREAM_NODE_ENV: ${STREAM_NODE_ENV:-prod}
|
||||
STREAM_DNS_ZONE: ${STREAM_DNS_ZONE:-mltv-stream.net}
|
||||
STREAM_DNS_TTL: ${STREAM_DNS_TTL:-60}
|
||||
STREAM_CLOUD_DNS_SUFFIX: ${STREAM_CLOUD_DNS_SUFFIX:-mltv-stream.net}
|
||||
STREAM_CLOUD_MAX_PUBLISHERS: ${STREAM_CLOUD_MAX_PUBLISHERS:-4}
|
||||
HCLOUD_TOKEN: ${HCLOUD_TOKEN:-}
|
||||
HCLOUD_LOCATION: ${HCLOUD_LOCATION:-nbg1}
|
||||
HCLOUD_SERVER_TYPE: ${HCLOUD_SERVER_TYPE:-cpx12}
|
||||
HCLOUD_IMAGE: ${HCLOUD_IMAGE:-debian-12}
|
||||
HCLOUD_SSH_KEY: ${HCLOUD_SSH_KEY:-matchlivetv-stream-hetzner}
|
||||
HCLOUD_NETWORK_ID: ${HCLOUD_NETWORK_ID:-}
|
||||
HCLOUD_USER_DATA_FILE: /opt/matchlivetv/infra/stream-node/cloud-init.yaml
|
||||
STREAM_NODE_AGENT_SECRET: ${STREAM_NODE_AGENT_SECRET:-mediamtx_webhook_dev_secret}
|
||||
STREAM_NODE_LOCAL_RTMP_URL: rtmp://mediamtx:1935
|
||||
STREAM_NODE_LOCAL_HLS_URL: http://mediamtx:8888
|
||||
RAILS_INTERNAL_URL: http://rails:3000
|
||||
|
||||
@@ -56,6 +56,210 @@ write_files:
|
||||
STREAM_NODE_AGENT_LISTEN=0.0.0.0:9100
|
||||
STREAM_NODE_LOCAL_HLS_URL=http://127.0.0.1:8888
|
||||
STREAM_NODE_RELAY_LOG_DIR=/var/log
|
||||
- path: /opt/stream-node/relay-agent.py
|
||||
permissions: "0755"
|
||||
content: |
|
||||
#!/usr/bin/env python3
|
||||
"""Avvia/ferma ffmpeg sul nodo stream (loopback MediaMTX → YouTube).
|
||||
Il control plane chiama questo agent all'avvio della diretta; ffmpeg resta sul CPX.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse
|
||||
|
||||
SECRET = os.environ.get("STREAM_NODE_AGENT_SECRET", "")
|
||||
LISTEN = os.environ.get("STREAM_NODE_AGENT_LISTEN", "0.0.0.0:9100")
|
||||
HLS_BASE = os.environ.get("STREAM_NODE_LOCAL_HLS_URL", "http://127.0.0.1:8888").rstrip("/")
|
||||
LOG_DIR = os.environ.get("STREAM_NODE_RELAY_LOG_DIR", "/var/log")
|
||||
|
||||
_lock = threading.Lock()
|
||||
# session_id -> {"pid": int, "path": str, "proc": Popen}
|
||||
_relays: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _authorized(handler: BaseHTTPRequestHandler) -> bool:
|
||||
if not SECRET:
|
||||
return True
|
||||
hdr = handler.headers.get("Authorization", "")
|
||||
return hdr == f"Bearer {SECRET}"
|
||||
|
||||
|
||||
def _ffmpeg_cmd(path: str, rtmps: str) -> list[str]:
|
||||
return [
|
||||
"ffmpeg",
|
||||
"-nostdin",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"warning",
|
||||
"-fflags",
|
||||
"+genpts+discardcorrupt",
|
||||
"-reconnect",
|
||||
"1",
|
||||
"-reconnect_streamed",
|
||||
"1",
|
||||
"-reconnect_on_network_error",
|
||||
"1",
|
||||
"-reconnect_delay_max",
|
||||
"2",
|
||||
"-rw_timeout",
|
||||
"15000000",
|
||||
"-live_start_index",
|
||||
"-1",
|
||||
"-i",
|
||||
f"{HLS_BASE}/{path}/index.m3u8",
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"copy",
|
||||
"-bsf:a",
|
||||
"aac_adtstoasc",
|
||||
"-f",
|
||||
"flv",
|
||||
rtmps,
|
||||
]
|
||||
|
||||
|
||||
def _alive(pid: int) -> bool:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _stop_session(session_id: str) -> None:
|
||||
info = _relays.pop(session_id, None)
|
||||
if not info:
|
||||
return
|
||||
proc = info.get("proc")
|
||||
pid = int(info.get("pid") or 0)
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGTERM)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
except OSError:
|
||||
pass
|
||||
elif pid:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _start_session(session_id: str, path: str, rtmps: str) -> int:
|
||||
existing = _relays.get(session_id)
|
||||
if existing:
|
||||
proc = existing.get("proc")
|
||||
pid = int(existing.get("pid") or 0)
|
||||
if proc is not None and proc.poll() is None and existing.get("path") == path:
|
||||
return pid
|
||||
_stop_session(session_id)
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
safe = path.replace("/", "_")
|
||||
log_path = os.path.join(LOG_DIR, f"youtube-relay-{safe}.log")
|
||||
log = open(log_path, "ab", buffering=0)
|
||||
proc = subprocess.Popen(
|
||||
_ffmpeg_cmd(path, rtmps),
|
||||
stdout=log,
|
||||
stderr=log,
|
||||
start_new_session=True,
|
||||
)
|
||||
_relays[session_id] = {"pid": proc.pid, "path": path, "proc": proc}
|
||||
return proc.pid
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt: str, *args) -> None:
|
||||
print(f"[relay-agent] {self.address_string()} {fmt % args}")
|
||||
|
||||
def _json(self, code: int, payload: dict) -> None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path in ("/health", "/"):
|
||||
self._json(200, {"ok": True, "relays": len(_relays)})
|
||||
return
|
||||
if not _authorized(self):
|
||||
self._json(401, {"error": "unauthorized"})
|
||||
return
|
||||
if parsed.path.startswith("/relays/"):
|
||||
session_id = parsed.path.split("/relays/", 1)[1].strip("/")
|
||||
with _lock:
|
||||
info = _relays.get(session_id)
|
||||
running = bool(info and _alive(int(info["pid"])))
|
||||
if info and not running:
|
||||
_relays.pop(session_id, None)
|
||||
if not running:
|
||||
self._json(404, {"running": False, "session_id": session_id})
|
||||
return
|
||||
self._json(200, {"running": True, "session_id": session_id, "pid": info["pid"], "path": info["path"]})
|
||||
return
|
||||
self._json(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if not _authorized(self):
|
||||
self._json(401, {"error": "unauthorized"})
|
||||
return
|
||||
if urlparse(self.path).path != "/relays":
|
||||
self._json(404, {"error": "not found"})
|
||||
return
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
try:
|
||||
data = json.loads(self.rfile.read(length) or b"{}")
|
||||
except json.JSONDecodeError:
|
||||
self._json(400, {"error": "invalid json"})
|
||||
return
|
||||
session_id = str(data.get("session_id") or "").strip()
|
||||
path = str(data.get("path") or "").strip()
|
||||
rtmps = str(data.get("rtmps") or "").strip()
|
||||
if not session_id or not path or not rtmps.startswith("rtmps://"):
|
||||
self._json(400, {"error": "session_id, path, rtmps required"})
|
||||
return
|
||||
with _lock:
|
||||
pid = _start_session(session_id, path, rtmps)
|
||||
self._json(201, {"pid": pid, "session_id": session_id, "path": path})
|
||||
|
||||
def do_DELETE(self) -> None:
|
||||
if not _authorized(self):
|
||||
self._json(401, {"error": "unauthorized"})
|
||||
return
|
||||
parsed = urlparse(self.path)
|
||||
if not parsed.path.startswith("/relays/"):
|
||||
self._json(404, {"error": "not found"})
|
||||
return
|
||||
session_id = parsed.path.split("/relays/", 1)[1].strip("/")
|
||||
with _lock:
|
||||
_stop_session(session_id)
|
||||
self._json(200, {"stopped": True, "session_id": session_id})
|
||||
|
||||
|
||||
def main() -> None:
|
||||
host, port_s = LISTEN.rsplit(":", 1)
|
||||
httpd = ThreadingHTTPServer((host, int(port_s)), Handler)
|
||||
print(f"[relay-agent] listen {LISTEN} hls={HLS_BASE}")
|
||||
httpd.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
- path: /opt/stream-node/bootstrap.sh
|
||||
permissions: "0755"
|
||||
content: |
|
||||
|
||||
Reference in New Issue
Block a user