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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -255,12 +255,12 @@ L’overflow deve spostare **ffmpeg**, non solo l’ingest 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 l’agent `: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 sull’owner o requeue (`:wrong_host`)
|
- Stop sticky: non cancella owner da Rails; lo stop gira sull’owner 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: |
|
||||||
|
|||||||
+7
@@ -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",
|
||||||
|
|||||||
Executable
+54
@@ -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
|
||||||
Reference in New Issue
Block a user