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? def allocatable?
status == "ready" && free_slots.positive? status == "ready" && free_slots.positive?
end 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 end
+58 -23
View File
@@ -35,7 +35,26 @@ module Streams
assigned_node_slug(session) == local_node_slug assigned_node_slug(session) == local_node_slug
end 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) 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)}" "#{QUEUE_PREFIX}_#{assigned_node_slug(session)}"
end end
@@ -50,14 +69,14 @@ module Streams
end end
def start(session) def start(session)
return enqueue_ensure!(session) unless worker? && node_matches?(session) return enqueue_ensure!(session) unless can_process?(session)
start_on_worker!(session) start_on_worker!(session)
end end
# Non cancella owner/pid qui: solo il worker owner deve killare ffmpeg. # Non cancella owner/pid qui: solo il worker owner deve killare ffmpeg.
def stop(session) 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) stop_on_worker!(session)
else else
enqueue_stop!(session) enqueue_stop!(session)
@@ -83,7 +102,7 @@ module Streams
return if session.terminal? return if session.terminal?
return if session.stream_key.blank? return if session.stream_key.blank?
if worker? && node_matches?(session) if can_process?(session)
ensure_on_worker!(session) ensure_on_worker!(session)
else else
enqueue_ensure!(session) enqueue_ensure!(session)
@@ -92,7 +111,7 @@ module Streams
def ensure_on_worker!(session) def ensure_on_worker!(session)
return :not_worker unless worker? return :not_worker unless worker?
unless node_matches?(session) unless can_process?(session)
enqueue_ensure!(session, wait: 2.seconds) enqueue_ensure!(session, wait: 2.seconds)
Rails.logger.info( Rails.logger.info(
"[YoutubeRelay] wrong_node local=#{local_node_slug} assigned=#{assigned_node_slug(session)} session=#{session.id}" "[YoutubeRelay] wrong_node local=#{local_node_slug} assigned=#{assigned_node_slug(session)} session=#{session.id}"
@@ -115,7 +134,7 @@ module Streams
return :already_running return :already_running
end end
if at_capacity? if at_capacity_for?(session)
enqueue_ensure!(session, wait: 5.seconds) enqueue_ensure!(session, wait: 5.seconds)
Rails.logger.info("[YoutubeRelay] at capacity worker=#{worker_id} node=#{local_node_slug} session=#{session.id} requeue") Rails.logger.info("[YoutubeRelay] at capacity worker=#{worker_id} node=#{local_node_slug} session=#{session.id} requeue")
return :at_capacity return :at_capacity
@@ -129,6 +148,7 @@ module Streams
:started :started
rescue Error => e rescue Error => e
Rails.logger.warn("[YoutubeRelay] ensure_on_worker session=#{session.id}: #{e.message}") Rails.logger.warn("[YoutubeRelay] ensure_on_worker session=#{session.id}: #{e.message}")
enqueue_ensure!(session, wait: 5.seconds) unless session.terminal?
:error :error
end end
@@ -161,6 +181,13 @@ module Streams
local_owned_count >= max_concurrent local_owned_count >= max_concurrent
end 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) def owner_is_local?(session_id)
owner = redis.get(format(OWNER_KEY, session_id)) owner = redis.get(format(OWNER_KEY, session_id))
owner.blank? || owner == worker_id owner.blank? || owner == worker_id
@@ -188,7 +215,7 @@ module Streams
intake_source = mediamtx_intake_source(session) intake_source = mediamtx_intake_source(session)
output = "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}" output = "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}"
pid = if relay_agent_enabled? pid = if agent_dispatch?(session)
start_via_agent!(session) start_via_agent!(session)
else else
spawn_ffmpeg!(youtube_ffmpeg_args(intake_source, output), log_path) spawn_ffmpeg!(youtube_ffmpeg_args(intake_source, output), log_path)
@@ -197,7 +224,7 @@ module Streams
claim_ownership!(session.id) claim_ownership!(session.id)
Rails.logger.info( Rails.logger.info(
"[YoutubeRelay] started pid=#{pid} session=#{session.id} intake=#{intake_source.join(':')} " \ "[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) schedule_youtube_activate(session)
pid pid
@@ -205,16 +232,18 @@ module Streams
raise Error, "ffmpeg non disponibile: #{e.message}" raise Error, "ffmpeg non disponibile: #{e.message}"
end end
def relay_agent_url def agent_base_url(session)
ENV["STREAM_NODE_RELAY_AGENT_URL"].presence return if session.blank?
ENV["STREAM_NODE_RELAY_AGENT_URL"].presence || session.stream_node&.relay_agent_url
end end
def relay_agent_enabled? def agent_dispatch?(session)
relay_agent_url.present? agent_base_url(session).present?
end end
def relay_agent_secret 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 end
def spawn_ffmpeg!(args, log_path) def spawn_ffmpeg!(args, log_path)
@@ -229,19 +258,23 @@ module Streams
"path" => session.mediamtx_path_name, "path" => session.mediamtx_path_name,
"rtmps" => "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}" "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 pid = res["pid"].to_i
raise Error, "relay agent spawn failed: #{res.inspect}" if pid <= 0 raise Error, "relay agent spawn failed: #{res.inspect}" if pid <= 0
File.write( File.write(
log_file(session), 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 pid
end end
def agent_request(http_class, path, payload = nil) def agent_request(http_class, path, payload = nil, session: nil, session_id: nil)
uri = URI.parse("#{relay_agent_url.chomp('/')}#{path}") 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 = Net::HTTP.new(uri.host, uri.port)
http.open_timeout = 5 http.open_timeout = 5
http.read_timeout = 10 http.read_timeout = 10
@@ -304,7 +337,7 @@ module Streams
# Sul nodo assegnato ffmpeg legge MediaMTX in loopback (o hostname Docker locale). # Sul nodo assegnato ffmpeg legge MediaMTX in loopback (o hostname Docker locale).
def intake_bases(session) 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"] return ["rtmp://127.0.0.1:1935", "http://127.0.0.1:8888"]
end end
@@ -376,7 +409,8 @@ module Streams
end end
def process_alive?(pid, session_id: nil) 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") stat = File.read("/proc/#{pid.to_i}/stat")
return false if stat.split[2] == "Z" return false if stat.split[2] == "Z"
@@ -387,16 +421,17 @@ module Streams
false false
end end
def agent_session_running?(session_id) def agent_session_running?(session)
res = agent_request(Net::HTTP::Get, "/relays/#{session_id}") res = agent_request(Net::HTTP::Get, "/relays/#{session.id}", session: session)
res["running"] == true res["running"] == true
rescue Error rescue Error
false false
end end
def terminate_pid(pid, session_id: nil) def terminate_pid(pid, session_id: nil)
if relay_agent_enabled? && session_id.present? session = session_id.present? ? StreamSession.find_by(id: session_id) : nil
agent_request(Net::HTTP::Delete, "/relays/#{session_id}") if session && agent_dispatch?(session)
agent_request(Net::HTTP::Delete, "/relays/#{session_id}", session: session)
return return
end end
@@ -405,7 +440,7 @@ module Streams
rescue Errno::ESRCH rescue Errno::ESRCH
nil nil
else else
return if relay_agent_enabled? return if session && agent_dispatch?(session)
begin begin
Process.kill("KILL", -pid.to_i) Process.kill("KILL", -pid.to_i)
+1
View File
@@ -2,5 +2,6 @@
:queues: :queues:
- critical - critical
- youtube_relay_home - youtube_relay_home
- youtube_relay_cloud
- youtube_relay - youtube_relay
- default - default
@@ -33,7 +33,15 @@ RSpec.describe Streams::YoutubeRelay do
end end
let(:session_id) { SecureRandom.uuid } 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) def session_double(stream_node: nil)
instance_double( instance_double(
@@ -54,10 +62,14 @@ RSpec.describe Streams::YoutubeRelay do
expect(described_class.queue_for(session_double)).to eq("youtube_relay_home") expect(described_class.queue_for(session_double)).to eq("youtube_relay_home")
end 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") expect(described_class.queue_for(session_double(stream_node: lab_node))).to eq("youtube_relay_ingest-lab-01")
end 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 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 with_env("STREAM_NODE_SLUG" => "home", "YOUTUBE_RELAY_WORKER" => "1") do
allow(described_class).to receive(:worker?).and_return(true) 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) expect(described_class.ensure_on_worker!(session_double(stream_node: lab_node))).to eq(:wrong_host)
end end
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 end
describe "multi-host sticky stop/capacity" do describe "multi-host sticky stop/capacity" do
@@ -125,7 +144,7 @@ RSpec.describe Streams::YoutubeRelay do
let(:session) do let(:session) do
instance_double( instance_double(
StreamSession, 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_path_name: "live/match_abc",
mediamtx_internal_rtmp_url: "rtmp://10.0.0.9:1935", mediamtx_internal_rtmp_url: "rtmp://10.0.0.9:1935",
mediamtx_internal_hls_url: "http://10.0.0.9:8888" 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( expect(described_class).to receive(:agent_request).with(
Net::HTTP::Post, Net::HTTP::Post,
"/relays", "/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) ).and_return("pid" => 4242)
pid = with_env("STREAM_NODE_RELAY_AGENT_URL" => "http://203.0.113.10:9100") do 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 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 ### Fase 2 — dettagli implementati
- Coda Sidekiq dedicata `youtube_relay_<slug>` (home: `youtube_relay_home`; overflow: `youtube_relay_ingest-01`) - 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** - `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`) - 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 - 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) - Intake locale: `STREAM_NODE_LOCAL_RTMP_URL` (loopback sul CPX, `mediamtx` in Docker lab)
### Fase 3 — dettagli implementati ### Fase 3 — dettagli implementati
+2
View File
@@ -125,6 +125,8 @@ STREAM_NODE_ENV=prod
STREAM_CLOUD_PROVIDER=local_lab STREAM_CLOUD_PROVIDER=local_lab
RELAY_MAX_CONCURRENT=4 RELAY_MAX_CONCURRENT=4
YOUTUBE_RELAY_WORKER=1 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) # Autoscaler (kill-switch: 0 finché WireGuard/smoke Cloud non sono OK — NON abilitare in prod senza lab)
STREAM_AUTOSCALE_ENABLED=0 STREAM_AUTOSCALE_ENABLED=0
+58
View File
@@ -53,6 +53,8 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped restart: unless-stopped
command: bundle exec rails server -b 0.0.0.0 -p 3000 -e production command: bundle exec rails server -b 0.0.0.0 -p 3000 -e production
env_file:
- .env
environment: environment:
RAILS_ENV: production RAILS_ENV: production
RAILS_MAX_THREADS: ${RAILS_MAX_THREADS:-5} RAILS_MAX_THREADS: ${RAILS_MAX_THREADS:-5}
@@ -70,6 +72,33 @@ services:
YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-} YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-}
YOUTUBE_REDIRECT_URI: ${YOUTUBE_REDIRECT_URI:-} YOUTUBE_REDIRECT_URI: ${YOUTUBE_REDIRECT_URI:-}
YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-} 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} HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000} APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it} PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
@@ -166,6 +195,8 @@ services:
restart: unless-stopped restart: unless-stopped
hostname: home hostname: home
command: bundle exec sidekiq -C config/sidekiq.yml -e production command: bundle exec sidekiq -C config/sidekiq.yml -e production
env_file:
- .env
environment: environment:
RAILS_ENV: production RAILS_ENV: production
RAILS_MAX_THREADS: ${RAILS_MAX_THREADS:-5} RAILS_MAX_THREADS: ${RAILS_MAX_THREADS:-5}
@@ -182,6 +213,33 @@ services:
YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-} YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-}
YOUTUBE_RELAY_WORKER: "1" YOUTUBE_RELAY_WORKER: "1"
STREAM_NODE_SLUG: home 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_RTMP_URL: rtmp://mediamtx:1935
STREAM_NODE_LOCAL_HLS_URL: http://mediamtx:8888 STREAM_NODE_LOCAL_HLS_URL: http://mediamtx:8888
RAILS_INTERNAL_URL: http://rails:3000 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_AGENT_LISTEN=0.0.0.0:9100
STREAM_NODE_LOCAL_HLS_URL=http://127.0.0.1:8888 STREAM_NODE_LOCAL_HLS_URL=http://127.0.0.1:8888
STREAM_NODE_RELAY_LOG_DIR=/var/log 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 - path: /opt/stream-node/bootstrap.sh
permissions: "0755" permissions: "0755"
content: | content: |
@@ -65,6 +65,7 @@ class E2EWizardFlowTest {
waitForText(s(R.string.wizard_step_title_transmission), timeoutMs = 45_000) waitForText(s(R.string.wizard_step_title_transmission), timeoutMs = 45_000)
waitForText(s(R.string.wizard_transmission_platform_title), timeoutMs = 30_000) waitForText(s(R.string.wizard_transmission_platform_title), timeoutMs = 30_000)
selectYoutubeIfRequested() selectYoutubeIfRequested()
selectUnlistedPrivacy()
val onNetworkStep = { val onNetworkStep = {
device.hasObject(By.text(s(R.string.wizard_network_test_start_label))) || device.hasObject(By.text(s(R.string.wizard_network_test_start_label))) ||
device.hasObject(By.text("AVVIA TEST RETE")) || device.hasObject(By.text("AVVIA TEST RETE")) ||
@@ -189,6 +190,12 @@ class E2EWizardFlowTest {
if (!wantsYoutube()) return if (!wantsYoutube()) return
waitForAnyText("YouTube Live", timeoutMs = 20_000) waitForAnyText("YouTube Live", timeoutMs = 20_000)
tapClickableText("YouTube Live") 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( waitForAnyText(
s(R.string.wizard_transmission_unlisted_label), s(R.string.wizard_transmission_unlisted_label),
"NON IN ELENCO", "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