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:
2026-08-18 08:57:56 +02:00
co-authored by Cursor
parent dd94bf7e66
commit 841a6a15be
10 changed files with 452 additions and 31 deletions
+16
View File
@@ -35,4 +35,20 @@ class StreamNode < ApplicationRecord
def allocatable?
status == "ready" && free_slots.positive?
end
# Agent ffmpeg sul CPX (cloud-init :9100). Home Sidekiq lo chiama senza worker Docker per nodo.
def relay_agent_url
return unless role == "cloud"
ip = (metadata || {})["public_ip"].presence
if ip.blank?
host = URI.parse(api_base_url.to_s).host
ip = host if host.present? && host != "localhost"
end
return if ip.blank? || ip == "127.0.0.1"
"http://#{ip}:#{ENV.fetch("STREAM_NODE_AGENT_PORT", "9100")}"
rescue URI::InvalidURIError
nil
end
end
+58 -23
View File
@@ -35,7 +35,26 @@ module Streams
assigned_node_slug(session) == local_node_slug
end
# Home può avviare ffmpeg sull'agent del CPX (niente worker Docker per nodo).
def can_process?(session)
return false unless worker?
return true if node_matches?(session)
cloud_home_dispatch?(session)
end
def cloud_home_dispatch?(session)
local_node_slug == NodeRegistry::HOME_SLUG &&
session.stream_node&.role == "cloud" &&
session.stream_node.relay_agent_url.present?
end
def queue_for(session)
node = session.stream_node
if node&.role == "cloud" && node.relay_agent_url.present?
return "#{QUEUE_PREFIX}_cloud"
end
"#{QUEUE_PREFIX}_#{assigned_node_slug(session)}"
end
@@ -50,14 +69,14 @@ module Streams
end
def start(session)
return enqueue_ensure!(session) unless worker? && node_matches?(session)
return enqueue_ensure!(session) unless can_process?(session)
start_on_worker!(session)
end
# Non cancella owner/pid qui: solo il worker owner deve killare ffmpeg.
def stop(session)
if worker? && owner_is_local?(session.id) && node_matches?(session)
if worker? && owner_is_local?(session.id) && can_process?(session)
stop_on_worker!(session)
else
enqueue_stop!(session)
@@ -83,7 +102,7 @@ module Streams
return if session.terminal?
return if session.stream_key.blank?
if worker? && node_matches?(session)
if can_process?(session)
ensure_on_worker!(session)
else
enqueue_ensure!(session)
@@ -92,7 +111,7 @@ module Streams
def ensure_on_worker!(session)
return :not_worker unless worker?
unless node_matches?(session)
unless can_process?(session)
enqueue_ensure!(session, wait: 2.seconds)
Rails.logger.info(
"[YoutubeRelay] wrong_node local=#{local_node_slug} assigned=#{assigned_node_slug(session)} session=#{session.id}"
@@ -115,7 +134,7 @@ module Streams
return :already_running
end
if at_capacity?
if at_capacity_for?(session)
enqueue_ensure!(session, wait: 5.seconds)
Rails.logger.info("[YoutubeRelay] at capacity worker=#{worker_id} node=#{local_node_slug} session=#{session.id} requeue")
return :at_capacity
@@ -129,6 +148,7 @@ module Streams
:started
rescue Error => e
Rails.logger.warn("[YoutubeRelay] ensure_on_worker session=#{session.id}: #{e.message}")
enqueue_ensure!(session, wait: 5.seconds) unless session.terminal?
:error
end
@@ -161,6 +181,13 @@ module Streams
local_owned_count >= max_concurrent
end
# I relay sull'agent CPX non consumano ffmpeg locale: non applicare il cap del worker home.
def at_capacity_for?(session)
return false if cloud_home_dispatch?(session)
at_capacity?
end
def owner_is_local?(session_id)
owner = redis.get(format(OWNER_KEY, session_id))
owner.blank? || owner == worker_id
@@ -188,7 +215,7 @@ module Streams
intake_source = mediamtx_intake_source(session)
output = "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}"
pid = if relay_agent_enabled?
pid = if agent_dispatch?(session)
start_via_agent!(session)
else
spawn_ffmpeg!(youtube_ffmpeg_args(intake_source, output), log_path)
@@ -197,7 +224,7 @@ module Streams
claim_ownership!(session.id)
Rails.logger.info(
"[YoutubeRelay] started pid=#{pid} session=#{session.id} intake=#{intake_source.join(':')} " \
"worker=#{worker_id} agent=#{relay_agent_url || '-'}"
"worker=#{worker_id} agent=#{agent_base_url(session) || '-'}"
)
schedule_youtube_activate(session)
pid
@@ -205,16 +232,18 @@ module Streams
raise Error, "ffmpeg non disponibile: #{e.message}"
end
def relay_agent_url
ENV["STREAM_NODE_RELAY_AGENT_URL"].presence
def agent_base_url(session)
return if session.blank?
ENV["STREAM_NODE_RELAY_AGENT_URL"].presence || session.stream_node&.relay_agent_url
end
def relay_agent_enabled?
relay_agent_url.present?
def agent_dispatch?(session)
agent_base_url(session).present?
end
def relay_agent_secret
ENV["STREAM_NODE_AGENT_SECRET"].presence || ENV["MEDIAMTX_WEBHOOK_SECRET"].presence || ""
ENV["STREAM_NODE_AGENT_SECRET"].presence || "mediamtx_webhook_dev_secret"
end
def spawn_ffmpeg!(args, log_path)
@@ -229,19 +258,23 @@ module Streams
"path" => session.mediamtx_path_name,
"rtmps" => "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}"
}
res = agent_request(Net::HTTP::Post, "/relays", payload)
res = agent_request(Net::HTTP::Post, "/relays", payload, session: session)
pid = res["pid"].to_i
raise Error, "relay agent spawn failed: #{res.inspect}" if pid <= 0
File.write(
log_file(session),
"agent=#{relay_agent_url} pid=#{pid} path=#{session.mediamtx_path_name}\n"
"agent=#{agent_base_url(session)} pid=#{pid} path=#{session.mediamtx_path_name}\n"
)
pid
end
def agent_request(http_class, path, payload = nil)
uri = URI.parse("#{relay_agent_url.chomp('/')}#{path}")
def agent_request(http_class, path, payload = nil, session: nil, session_id: nil)
session ||= StreamSession.find_by(id: session_id) if session_id.present?
base = agent_base_url(session)
raise Error, "relay agent URL mancante" if base.blank?
uri = URI.parse("#{base.chomp('/')}#{path}")
http = Net::HTTP.new(uri.host, uri.port)
http.open_timeout = 5
http.read_timeout = 10
@@ -304,7 +337,7 @@ module Streams
# Sul nodo assegnato ffmpeg legge MediaMTX in loopback (o hostname Docker locale).
def intake_bases(session)
if relay_agent_enabled? && node_matches?(session)
if agent_dispatch?(session) && (node_matches?(session) || cloud_home_dispatch?(session))
return ["rtmp://127.0.0.1:1935", "http://127.0.0.1:8888"]
end
@@ -376,7 +409,8 @@ module Streams
end
def process_alive?(pid, session_id: nil)
return agent_session_running?(session_id) if relay_agent_enabled? && session_id.present?
session = session_id.present? ? StreamSession.find_by(id: session_id) : nil
return agent_session_running?(session) if session && agent_dispatch?(session)
stat = File.read("/proc/#{pid.to_i}/stat")
return false if stat.split[2] == "Z"
@@ -387,16 +421,17 @@ module Streams
false
end
def agent_session_running?(session_id)
res = agent_request(Net::HTTP::Get, "/relays/#{session_id}")
def agent_session_running?(session)
res = agent_request(Net::HTTP::Get, "/relays/#{session.id}", session: session)
res["running"] == true
rescue Error
false
end
def terminate_pid(pid, session_id: nil)
if relay_agent_enabled? && session_id.present?
agent_request(Net::HTTP::Delete, "/relays/#{session_id}")
session = session_id.present? ? StreamSession.find_by(id: session_id) : nil
if session && agent_dispatch?(session)
agent_request(Net::HTTP::Delete, "/relays/#{session_id}", session: session)
return
end
@@ -405,7 +440,7 @@ module Streams
rescue Errno::ESRCH
nil
else
return if relay_agent_enabled?
return if session && agent_dispatch?(session)
begin
Process.kill("KILL", -pid.to_i)
+1
View File
@@ -2,5 +2,6 @@
:queues:
- critical
- youtube_relay_home
- youtube_relay_cloud
- youtube_relay
- default
@@ -33,7 +33,15 @@ RSpec.describe Streams::YoutubeRelay do
end
let(:session_id) { SecureRandom.uuid }
let(:lab_node) { instance_double(StreamNode, slug: "ingest-lab-01") }
let(:lab_node) { instance_double(StreamNode, slug: "ingest-lab-01", role: "lab", relay_agent_url: nil) }
let(:cloud_node) do
instance_double(
StreamNode,
slug: "ingest-01",
role: "cloud",
relay_agent_url: "http://203.0.113.10:9100"
)
end
def session_double(stream_node: nil)
instance_double(
@@ -54,10 +62,14 @@ RSpec.describe Streams::YoutubeRelay do
expect(described_class.queue_for(session_double)).to eq("youtube_relay_home")
end
it "routes overflow sessions to the node slug queue" do
it "routes overflow lab sessions to the node slug queue" do
expect(described_class.queue_for(session_double(stream_node: lab_node))).to eq("youtube_relay_ingest-lab-01")
end
it "routes cloud overflow to youtube_relay_cloud for the home dispatcher" do
expect(described_class.queue_for(session_double(stream_node: cloud_node))).to eq("youtube_relay_cloud")
end
it "requeues ensure to the assigned node when the local worker does not match" do
with_env("STREAM_NODE_SLUG" => "home", "YOUTUBE_RELAY_WORKER" => "1") do
allow(described_class).to receive(:worker?).and_return(true)
@@ -67,6 +79,13 @@ RSpec.describe Streams::YoutubeRelay do
expect(described_class.ensure_on_worker!(session_double(stream_node: lab_node))).to eq(:wrong_host)
end
end
it "lets the home worker process cloud overflow instead of requeueing to a per-node queue" do
with_env("STREAM_NODE_SLUG" => "home", "YOUTUBE_RELAY_WORKER" => "1") do
expect(described_class.can_process?(session_double(stream_node: cloud_node))).to be true
expect(described_class.cloud_home_dispatch?(session_double(stream_node: cloud_node))).to be true
end
end
end
describe "multi-host sticky stop/capacity" do
@@ -125,7 +144,7 @@ RSpec.describe Streams::YoutubeRelay do
let(:session) do
instance_double(
StreamSession,
stream_node: instance_double(StreamNode, slug: "ingest-lab-01"),
stream_node: instance_double(StreamNode, slug: "ingest-lab-01", role: "lab", relay_agent_url: nil),
mediamtx_path_name: "live/match_abc",
mediamtx_internal_rtmp_url: "rtmp://10.0.0.9:1935",
mediamtx_internal_hls_url: "http://10.0.0.9:8888"
@@ -179,7 +198,8 @@ RSpec.describe Streams::YoutubeRelay do
expect(described_class).to receive(:agent_request).with(
Net::HTTP::Post,
"/relays",
hash_including("session_id" => "sess-1", "path" => "live/match_abc")
hash_including("session_id" => "sess-1", "path" => "live/match_abc"),
session: session
).and_return("pid" => 4242)
pid = with_env("STREAM_NODE_RELAY_AGENT_URL" => "http://203.0.113.10:9100") do
@@ -189,3 +209,27 @@ RSpec.describe Streams::YoutubeRelay do
end
end
end
RSpec.describe StreamNode do
describe "#relay_agent_url" do
it "uses metadata public_ip on cloud nodes" do
node = described_class.new(
slug: "ingest-01",
hostname: "ingest-01.mltv-stream.net",
role: "cloud",
status: "ready",
provider: "hetzner",
rtmp_base_url: "rtmp://x:1935",
hls_base_url: "https://x/hls",
api_base_url: "http://10.0.0.9:9997",
metadata: { "public_ip" => "203.0.113.9" }
)
expect(node.relay_agent_url).to eq("http://203.0.113.9:9100")
end
it "is nil for lab nodes" do
node = described_class.new(role: "lab", api_base_url: "http://10.0.0.9:9997", metadata: { "public_ip" => "1.2.3.4" })
expect(node.relay_agent_url).to be_nil
end
end
end
+4 -4
View File
@@ -255,12 +255,12 @@ Loverflow deve spostare **ffmpeg**, non solo lingest RTMP. In lab `local_l
### Fase 2 — dettagli implementati
- Coda Sidekiq dedicata `youtube_relay_<slug>` (home: `youtube_relay_home`; overflow: `youtube_relay_ingest-01`)
- `YoutubeRelayEnsureJob` / `YoutubeRelayStopJob` sulla coda del **nodo assegnato**
- Coda Sidekiq `youtube_relay_home` (ffmpeg locale) e `youtube_relay_cloud` (home chiama lagent `:9100` sul CPX). Lab overflow resta `youtube_relay_<slug>`
- `YoutubeRelayEnsureJob` / `YoutubeRelayStopJob` sulla coda del **nodo assegnato** (cloud → `youtube_relay_cloud`)
- Stop sticky: non cancella owner da Rails; lo stop gira sullowner o requeue (`:wrong_host`)
- Cap per worker: `RELAY_MAX_CONCURRENT` (default 4) + set Redis `youtube_relay:owned:HOSTNAME`
- Cap per worker: `RELAY_MAX_CONCURRENT` (default 4) + set Redis `youtube_relay:owned:HOSTNAME` (non si applica al dispatch agent dal home)
- Ensure a capacità piena → requeue 5s sulla stessa coda nodo
- Worker home ascolta `youtube_relay_home` (+ `youtube_relay` legacy). Worker overflow: solo la propria coda, **niente** `default`/`critical`
- Worker home ascolta `youtube_relay_home`, `youtube_relay_cloud` (+ `youtube_relay` legacy). Un CPX nuovo non richiede `prod_relay_worker.sh`
- Intake locale: `STREAM_NODE_LOCAL_RTMP_URL` (loopback sul CPX, `mediamtx` in Docker lab)
### Fase 3 — dettagli implementati
+2
View File
@@ -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
+58
View File
@@ -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
+204
View File
@@ -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: |
@@ -65,6 +65,7 @@ class E2EWizardFlowTest {
waitForText(s(R.string.wizard_step_title_transmission), timeoutMs = 45_000)
waitForText(s(R.string.wizard_transmission_platform_title), timeoutMs = 30_000)
selectYoutubeIfRequested()
selectUnlistedPrivacy()
val onNetworkStep = {
device.hasObject(By.text(s(R.string.wizard_network_test_start_label))) ||
device.hasObject(By.text("AVVIA TEST RETE")) ||
@@ -189,6 +190,12 @@ class E2EWizardFlowTest {
if (!wantsYoutube()) return
waitForAnyText("YouTube Live", timeoutMs = 20_000)
tapClickableText("YouTube Live")
device.waitForIdle()
SystemClock.sleep(400)
}
// Test su backend reali: mai pubblico (sito e YouTube). L'app espone solo pubblico / non in elenco.
private fun selectUnlistedPrivacy() {
waitForAnyText(
s(R.string.wizard_transmission_unlisted_label),
"NON IN ELENCO",
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Fallback manuale: worker dedicato youtube_relay_<slug> verso l'agent sul CPX.
# In produzione il Sidekiq home ascolta youtube_relay_cloud e chiama l'agent da solo;
# questo script serve solo se quel dispatch è spento o per debug.
# STREAM_NODE_SLUG=ingest-01 STREAM_NODE_RELAY_AGENT_URL=http://1.2.3.4:9100 \
# bash scripts/deploy/prod_relay_worker.sh up
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
INFRA="${MATCHLIVETV_INFRA:-$ROOT/infra}"
if [ -d /opt/matchlivetv/infra ]; then
INFRA=/opt/matchlivetv/infra
fi
SLUG="${STREAM_NODE_SLUG:?Imposta STREAM_NODE_SLUG (es. ingest-01)}"
ACTION="${1:-up}"
NAME="infra-sidekiq-relay-${SLUG}"
AGENT_URL="${STREAM_NODE_RELAY_AGENT_URL:-}"
AGENT_SECRET="${STREAM_NODE_AGENT_SECRET:-mediamtx_webhook_dev_secret}"
cd "$INFRA"
COMPOSE=(docker compose -f docker-compose.prod.yml --env-file .env)
case "$ACTION" in
up)
docker rm -f "$NAME" >/dev/null 2>&1 || true
extra=()
if [[ -n "$AGENT_URL" ]]; then
extra+=(
-e STREAM_NODE_RELAY_AGENT_URL="$AGENT_URL"
-e STREAM_NODE_AGENT_SECRET="$AGENT_SECRET"
-e STREAM_NODE_LOCAL_RTMP_URL=rtmp://127.0.0.1:1935
-e STREAM_NODE_LOCAL_HLS_URL=http://127.0.0.1:8888
)
fi
"${COMPOSE[@]}" run -d --name "$NAME" --no-deps \
-e STREAM_NODE_SLUG="$SLUG" \
-e YOUTUBE_RELAY_WORKER=1 \
-e HOSTNAME="$SLUG" \
-e RELAY_MAX_CONCURRENT="${RELAY_MAX_CONCURRENT:-2}" \
"${extra[@]}" \
sidekiq \
bash -lc "bundle exec sidekiq -c \${RELAY_MAX_CONCURRENT:-2} -q youtube_relay_${SLUG} -e production"
echo "relay worker up name=${NAME} queue=youtube_relay_${SLUG} agent=${AGENT_URL:-local}"
;;
down)
docker rm -f "$NAME" >/dev/null 2>&1 || true
echo "relay worker down name=${NAME}"
;;
logs)
docker logs -f --tail=80 "$NAME"
;;
*)
echo "Uso: STREAM_NODE_SLUG=ingest-01 STREAM_NODE_RELAY_AGENT_URL=http://IP:9100 $0 up|down|logs" >&2
exit 1
;;
esac