Mantiene la copertina YouTube se il telefono cade e avvia ffmpeg sul nodo, non via SSH.

Il relay HLS→RTMPS resta sul worker/agent del nodo assegnato, così tre dirette contemporanee restano in onda sul sito e sul canale della società senza schermo nero.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-16 20:41:46 +02:00
co-authored by Cursor
parent bfb3115a64
commit baa6283a15
36 changed files with 1444 additions and 141 deletions
@@ -4,11 +4,20 @@ module MediamtxPlayback
private private
def mediamtx_paths_index def mediamtx_paths_index
@mediamtx_paths_index ||= Mediamtx::Client.new.list_paths.index_by { |i| i["name"] } mediamtx_paths_index_for_url(MatchLiveTv.mediamtx_api_url)
end
def mediamtx_paths_index_for(session)
mediamtx_paths_index_for_url(session.mediamtx_api_base_url)
end
def mediamtx_paths_index_for_url(api_url)
@mediamtx_paths_by_origin ||= {}
@mediamtx_paths_by_origin[api_url] ||= Mediamtx::Client.new(base_url: api_url).list_paths.index_by { |i| i["name"] }
end end
def mediamtx_path_info(session, path_name: mediamtx_playback_path_name(session)) def mediamtx_path_info(session, path_name: mediamtx_playback_path_name(session))
mediamtx_paths_index[path_name] mediamtx_paths_index_for(session)[path_name]
end end
def mediamtx_playback_path_name(session) def mediamtx_playback_path_name(session)
@@ -28,7 +37,7 @@ module MediamtxPlayback
end end
def mediamtx_publisher_online?(session) def mediamtx_publisher_online?(session)
info = mediamtx_paths_index[session.mediamtx_path_name] info = mediamtx_paths_index_for(session)[session.mediamtx_path_name]
info && info["online"] info && info["online"]
end end
end end
@@ -20,7 +20,8 @@ class HlsProxyController < ActionController::Base
def proxy_mediamtx_path(upstream_path) def proxy_mediamtx_path(upstream_path)
return head :not_found if upstream_path.blank? return head :not_found if upstream_path.blank?
upstream = "#{MatchLiveTv.mediamtx_hls_url}/#{upstream_path}" origin = hls_origin_for(upstream_path)
upstream = "#{origin}/#{upstream_path}"
upstream = "#{upstream}?#{request.query_string}" if request.query_string.present? upstream = "#{upstream}?#{request.query_string}" if request.query_string.present?
cookie = request.headers["Cookie"].presence || "cookieCheck=1" cookie = request.headers["Cookie"].presence || "cookieCheck=1"
@@ -30,7 +31,7 @@ class HlsProxyController < ActionController::Base
if response.status.in?([301, 302, 307, 308]) if response.status.in?([301, 302, 307, 308])
location = response.headers["location"].to_s location = response.headers["location"].to_s
cookie = cookie_from_set_header(response.headers["set-cookie"]).presence || cookie cookie = cookie_from_set_header(response.headers["set-cookie"]).presence || cookie
upstream = resolve_upstream_url(location) upstream = resolve_upstream_url(location, origin)
next next
end end
@@ -43,10 +44,21 @@ class HlsProxyController < ActionController::Base
head :bad_gateway head :bad_gateway
end end
def resolve_upstream_url(location) def hls_origin_for(upstream_path)
session_id = upstream_path[/\Alive\/match_([0-9a-f-]{36})/i, 1]
if session_id
session = StreamSession.find_by(id: session_id)
node_hls = session&.stream_node&.internal_hls_url.presence
return node_hls.sub(%r{/$}, "") if node_hls.present?
end
MatchLiveTv.mediamtx_hls_url.sub(%r{/$}, "")
end
def resolve_upstream_url(location, origin)
return location if location.start_with?("http://", "https://") return location if location.start_with?("http://", "https://")
base = MatchLiveTv.mediamtx_hls_url.sub(%r{/$}, "") base = origin.to_s.sub(%r{/$}, "")
location.start_with?("/") ? "#{base}#{location}" : "#{base}/#{location}" location.start_with?("/") ? "#{base}#{location}" : "#{base}/#{location}"
end end
+1 -1
View File
@@ -1,4 +1,4 @@
# Avvia/riavvia il relay YouTube solo sui worker con YOUTUBE_RELAY_WORKER=1 (coda youtube_relay). # Avvia/riavvia il relay YouTube sul worker del nodo (coda youtube_relay_<slug>).
class YoutubeRelayEnsureJob < ApplicationJob class YoutubeRelayEnsureJob < ApplicationJob
queue_as Streams::YoutubeRelay::QUEUE queue_as Streams::YoutubeRelay::QUEUE
+2 -1
View File
@@ -17,6 +17,7 @@ class YoutubeRelayStopJob < ApplicationJob
return unless result == :wrong_host return unless result == :wrong_host
return if attempts >= 30 return if attempts >= 30
self.class.set(wait: 2.seconds).perform_later(session_id, attempts + 1) self.class.set(wait: 2.seconds, queue: Streams::YoutubeRelay.queue_for(session))
.perform_later(session_id, attempts + 1)
end end
end end
+16 -2
View File
@@ -4,19 +4,33 @@ class YoutubeCredential < ApplicationRecord
attr_encrypted :access_token, attr_encrypted :access_token,
key: :encryption_key, key: :encryption_key,
attribute: "access_token_encrypted", attribute: "access_token_encrypted",
mode: :single_iv_salt mode: :single_iv_and_salt,
algorithm: "aes-256-cbc",
iv: :encryption_iv
attr_encrypted :refresh_token, attr_encrypted :refresh_token,
key: :encryption_key, key: :encryption_key,
attribute: "refresh_token_encrypted", attribute: "refresh_token_encrypted",
mode: :single_iv_salt mode: :single_iv_and_salt,
algorithm: "aes-256-cbc",
iv: :encryption_iv
def expired? def expired?
expires_at.present? && expires_at < Time.current expires_at.present? && expires_at < Time.current
end end
def usable?
refresh_token.present?
rescue ArgumentError, OpenSSL::Cipher::CipherError, NoMethodError
false
end
private private
def encryption_key def encryption_key
Rails.application.secret_key_base[0, 32] Rails.application.secret_key_base[0, 32]
end end
def encryption_iv
encryption_key[0, 16]
end
end end
+2 -3
View File
@@ -22,8 +22,7 @@ module Mediamtx
def create_path(session) def create_path(session)
path = session.mediamtx_path_name path = session.mediamtx_path_name
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe # record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe
# solo la slate (schermo nero) in pausa/attesa. # solo la slate in pausa/attesa. Slate resta accesa anche su YouTube (copertina se l'app cade).
# YouTube: niente slate sul path camera (maschera il video al relay ffmpeg).
body = recording_body(session, enabled: false).merge( body = recording_body(session, enabled: false).merge(
source: "publisher", source: "publisher",
overridePublisher: true overridePublisher: true
@@ -76,7 +75,7 @@ module Mediamtx
end end
# Slate alwaysAvailable: copertina sullo stesso path quando il telefono è offline. # Slate alwaysAvailable: copertina sullo stesso path quando il telefono è offline.
# Disattivare quando il publisher è in onda; riattivare in pausa/disconnessione. # Resta accesa anche con publisher in onda (MediaMTX usa il publisher se presente).
def set_always_available(session, enabled:) def set_always_available(session, enabled:)
path = session.mediamtx_path_name path = session.mediamtx_path_name
return true if always_available_remembered?(path, enabled: enabled) return true if always_available_remembered?(path, enabled: enabled)
@@ -19,7 +19,6 @@ module Mediamtx
if @session.paused? if @session.paused?
# RTMP ancora connesso in pausa: non forzare live/reconnect. # RTMP ancora connesso in pausa: non forzare live/reconnect.
else else
enable_live_path_once!(@session)
if @session.may_go_live? if @session.may_go_live?
@session.go_live! @session.go_live!
@session.reload @session.reload
@@ -75,23 +74,10 @@ module Mediamtx
@session.update!(timeout_job_id: job) @session.update!(timeout_job_id: job)
end end
# Compatibilità con webhook / controller.
def self.schedule_youtube_pipeline!(session, force: false) def self.schedule_youtube_pipeline!(session, force: false)
Youtube::LivePipeline.schedule!(session, force: force) Youtube::LivePipeline.schedule!(session, force: force)
end end
def enable_live_path_once!(session)
return unless session.platform == "youtube"
key = format("youtube:slate_disabled:%s", session.id)
return unless redis.set(key, "1", nx: true, ex: 48.hours.to_i)
Client.for_session(session).set_always_available(session, enabled: false)
rescue Client::Error => e
redis.del(format("youtube:slate_disabled:%s", session.id))
Rails.logger.warn("[PublisherSync] disable slate session=#{session.id}: #{e.message}")
end
def restore_slate_path!(session) def restore_slate_path!(session)
return if session.platform == "matchlivetv" return if session.platform == "matchlivetv"
@@ -183,7 +169,7 @@ module Mediamtx
key = format("youtube_relay:sched:%s", session.id) key = format("youtube_relay:sched:%s", session.id)
return unless redis.set(key, "1", nx: true, ex: 10) return unless redis.set(key, "1", nx: true, ex: 10)
YoutubeRelayEnsureJob.set(queue: Streams::YoutubeRelay::QUEUE).perform_later(session.id) Streams::YoutubeRelay.enqueue_ensure!(session)
end end
def redis def redis
@@ -77,9 +77,8 @@ module Streams
dns.upsert_a(hostname, ip) dns.upsert_a(hostname, ip)
simulated = instance.raw.is_a?(Hash) && (instance.raw[:simulated] || instance.raw["simulated"]) simulated = instance.raw.is_a?(Hash) && (instance.raw[:simulated] || instance.raw["simulated"])
api_base = simulated ? home.api_base_url : "http://#{private_ip}:9997" urls = urls_for_node(role: role, home: home, hostname: hostname, simulated: simulated,
internal_rtmp = simulated ? home.internal_rtmp_url : "rtmp://#{private_ip}:1935" private_ip: private_ip, public_ip: ip, use_node_hostname: use_node_hostname)
internal_hls = simulated ? home.internal_hls_url : "http://#{private_ip}:8888"
StreamNode.create!( StreamNode.create!(
slug: slug, slug: slug,
@@ -88,11 +87,11 @@ module Streams
status: "ready", status: "ready",
provider: provider_name_for(cloud, role: role), provider: provider_name_for(cloud, role: role),
provider_instance_id: instance.id, provider_instance_id: instance.id,
rtmp_base_url: use_node_hostname ? "rtmp://#{hostname}:1935" : home.rtmp_base_url, rtmp_base_url: urls.fetch(:rtmp_base_url),
hls_base_url: use_node_hostname ? "https://#{hostname}/hls" : home.hls_base_url, hls_base_url: urls.fetch(:hls_base_url),
api_base_url: api_base, api_base_url: urls.fetch(:api_base_url),
internal_rtmp_url: internal_rtmp, internal_rtmp_url: urls.fetch(:internal_rtmp_url),
internal_hls_url: internal_hls, internal_hls_url: urls.fetch(:internal_hls_url),
max_publishers: max, max_publishers: max,
max_relays: max, max_relays: max,
last_health_at: Time.current, last_health_at: Time.current,
@@ -105,6 +104,39 @@ module Streams
) )
end end
def urls_for_node(role:, home:, hostname:, simulated:, private_ip:, public_ip: nil, use_node_hostname:)
if role == "lab" && ENV["MEDIAMTX_LAB_API_URL"].present?
return {
api_base_url: ENV.fetch("MEDIAMTX_LAB_API_URL"),
internal_rtmp_url: ENV.fetch("MEDIAMTX_LAB_INTERNAL_RTMP_URL", "rtmp://mediamtx_lab:1935"),
internal_hls_url: ENV.fetch("MEDIAMTX_LAB_HLS_URL", "http://mediamtx_lab:8888"),
rtmp_base_url: ENV.fetch("MEDIAMTX_LAB_RTMP_URL", "rtmp://127.0.0.1:11935"),
# HLS pubblico resta sul proxy del sito: il controller instrada al MediaMTX del nodo.
hls_base_url: home.hls_base_url
}
end
control_ip = control_ip_for(role: role, private_ip: private_ip, public_ip: public_ip)
{
api_base_url: simulated ? home.api_base_url : "http://#{control_ip}:9997",
internal_rtmp_url: simulated ? home.internal_rtmp_url : "rtmp://#{control_ip}:1935",
internal_hls_url: simulated ? home.internal_hls_url : "http://#{control_ip}:8888",
rtmp_base_url: use_node_hostname ? "rtmp://#{hostname}:1935" : home.rtmp_base_url,
# Player del sito: proxy Rails/edge. HTTPS diretto sul nodo arriva dopo TLS/Caddy.
hls_base_url: home.hls_base_url
}
end
# Senza WireGuard/network privata Rails deve parlare con l'IPv4 pubblico (API+HLS).
def control_ip_for(role:, private_ip:, public_ip:)
public_ip = public_ip.presence
return private_ip if role != "cloud" || public_ip.blank?
return public_ip if ENV["STREAM_CLOUD_PUBLIC_CONTROL"] == "1"
return public_ip if private_ip.blank? || private_ip == public_ip
private_ip
end
def next_slug(prefix) def next_slug(prefix)
used = StreamNode.where("slug LIKE ?", "#{prefix}-%").pluck(:slug) used = StreamNode.where("slug LIKE ?", "#{prefix}-%").pluck(:slug)
n = 1 n = 1
+159 -25
View File
@@ -1,6 +1,10 @@
require "json"
require "net/http"
require "uri"
module Streams module Streams
# Relay verso YouTube: legge RTMP/HLS da MediaMTX e inoltra su RTMPS (-c copy). Nessun overlay. # Relay verso YouTube: legge HLS da MediaMTX e inoltra su RTMPS (-c copy).
# ffmpeg gira solo sui worker Sidekiq con YOUTUBE_RELAY_WORKER=1 (coda youtube_relay). # Sul nodo cloud ffmpeg è avviato dall'agent locale (immagine CPX) all'avvio diretta.
class YoutubeRelay class YoutubeRelay
class Error < StandardError; end class Error < StandardError; end
@@ -8,6 +12,7 @@ module Streams
OWNER_KEY = "youtube_relay:owner:%s" OWNER_KEY = "youtube_relay:owner:%s"
OWNED_SET = "youtube_relay:owned:%s" OWNED_SET = "youtube_relay:owned:%s"
QUEUE = :youtube_relay QUEUE = :youtube_relay
QUEUE_PREFIX = "youtube_relay"
class << self class << self
def worker? def worker?
@@ -18,18 +23,44 @@ module Streams
ENV.fetch("RELAY_MAX_CONCURRENT", "4").to_i ENV.fetch("RELAY_MAX_CONCURRENT", "4").to_i
end end
def local_node_slug
ENV["STREAM_NODE_SLUG"].presence || NodeRegistry::HOME_SLUG
end
def assigned_node_slug(session)
session.stream_node&.slug.presence || NodeRegistry::HOME_SLUG
end
def node_matches?(session)
assigned_node_slug(session) == local_node_slug
end
def queue_for(session)
"#{QUEUE_PREFIX}_#{assigned_node_slug(session)}"
end
def enqueue_ensure!(session, wait: nil)
opts = { queue: queue_for(session) }
opts[:wait] = wait if wait
YoutubeRelayEnsureJob.set(**opts).perform_later(session.id)
end
def enqueue_stop!(session)
YoutubeRelayStopJob.set(queue: queue_for(session)).perform_later(session.id)
end
def start(session) def start(session)
return unless worker? return enqueue_ensure!(session) unless worker? && node_matches?(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) if worker? && owner_is_local?(session.id) && node_matches?(session)
stop_on_worker!(session) stop_on_worker!(session)
else else
YoutubeRelayStopJob.set(queue: QUEUE).perform_later(session.id) enqueue_stop!(session)
end end
true true
end end
@@ -41,7 +72,7 @@ module Streams
return false if pid.blank? && owner.present? && owner != worker_id && redis.ttl(format(OWNER_KEY, session_id)) <= 30 return false if pid.blank? && owner.present? && owner != worker_id && redis.ttl(format(OWNER_KEY, session_id)) <= 30
return false if pid.blank? return false if pid.blank?
return process_alive?(pid) if owner.blank? || owner == worker_id return process_alive?(pid, session_id: session_id) if owner.blank? || owner == worker_id
# Relay su altro host: attivo se lock owner ancora fresco. # Relay su altro host: attivo se lock owner ancora fresco.
redis.ttl(format(OWNER_KEY, session_id)) > 30 redis.ttl(format(OWNER_KEY, session_id)) > 30
@@ -52,15 +83,22 @@ module Streams
return if session.terminal? return if session.terminal?
return if session.stream_key.blank? return if session.stream_key.blank?
if worker? if worker? && node_matches?(session)
ensure_on_worker!(session) ensure_on_worker!(session)
else else
YoutubeRelayEnsureJob.set(queue: QUEUE).perform_later(session.id) enqueue_ensure!(session)
end end
end end
def ensure_on_worker!(session) def ensure_on_worker!(session)
return :not_worker unless worker? return :not_worker unless worker?
unless node_matches?(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}"
)
return :wrong_host
end
return unless session.platform == "youtube" return unless session.platform == "youtube"
return if session.terminal? return if session.terminal?
return if session.stream_key.blank? return if session.stream_key.blank?
@@ -68,7 +106,7 @@ module Streams
return unless intake_available?(session) return unless intake_available?(session)
pid = pid_for(session.id) pid = pid_for(session.id)
if pid.present? && !process_alive?(pid.to_i) if pid.present? && !process_alive?(pid.to_i, session_id: session.id)
clear_local_ownership(session.id) clear_local_ownership(session.id)
end end
@@ -78,8 +116,8 @@ module Streams
end end
if at_capacity? if at_capacity?
YoutubeRelayEnsureJob.set(wait: 5.seconds, queue: QUEUE).perform_later(session.id) enqueue_ensure!(session, wait: 5.seconds)
Rails.logger.info("[YoutubeRelay] at capacity worker=#{worker_id} 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
end end
@@ -109,7 +147,7 @@ module Streams
return :noop return :noop
end end
terminate_pid(pid) terminate_pid(pid, session_id: session.id)
clear_local_ownership(session.id) clear_local_ownership(session.id)
Rails.logger.info("[YoutubeRelay] stopped pid=#{pid} session=#{session.id} worker=#{worker_id}") Rails.logger.info("[YoutubeRelay] stopped pid=#{pid} session=#{session.id} worker=#{worker_id}")
:stopped :stopped
@@ -140,26 +178,88 @@ module Streams
stop_on_worker!(session) if pid_for(session.id).present? && owner_is_local?(session.id) stop_on_worker!(session) if pid_for(session.id).present? && owner_is_local?(session.id)
# Due EnsureJob concorrenti (concurrency Sidekiq > 1) non devono spawnare due ffmpeg.
unless redis.set(format("youtube_relay:startlock:%s", session.id), worker_id, nx: true, ex: 20)
return pid_for(session.id).to_i
end
log_path = log_file(session) log_path = log_file(session)
FileUtils.mkdir_p(File.dirname(log_path)) FileUtils.mkdir_p(File.dirname(log_path))
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 = Process.spawn( pid = if relay_agent_enabled?
*youtube_ffmpeg_args(intake_source, output), start_via_agent!(session)
%i[out err] => log_path, else
pgroup: true spawn_ffmpeg!(youtube_ffmpeg_args(intake_source, output), log_path)
) end
Process.detach(pid)
store_pid(session.id, pid) store_pid(session.id, pid)
claim_ownership!(session.id) claim_ownership!(session.id)
Rails.logger.info("[YoutubeRelay] started pid=#{pid} session=#{session.id} intake=#{intake_source.join(":")} worker=#{worker_id}") Rails.logger.info(
"[YoutubeRelay] started pid=#{pid} session=#{session.id} intake=#{intake_source.join(':')} " \
"worker=#{worker_id} agent=#{relay_agent_url || '-'}"
)
schedule_youtube_activate(session) schedule_youtube_activate(session)
pid pid
rescue Errno::ENOENT => e rescue Errno::ENOENT => e
raise Error, "ffmpeg non disponibile: #{e.message}" raise Error, "ffmpeg non disponibile: #{e.message}"
end end
def relay_agent_url
ENV["STREAM_NODE_RELAY_AGENT_URL"].presence
end
def relay_agent_enabled?
relay_agent_url.present?
end
def relay_agent_secret
ENV["STREAM_NODE_AGENT_SECRET"].presence || ENV["MEDIAMTX_WEBHOOK_SECRET"].presence || ""
end
def spawn_ffmpeg!(args, log_path)
pid = Process.spawn(*args, %i[out err] => log_path, pgroup: true)
Process.detach(pid)
pid
end
def start_via_agent!(session)
payload = {
"session_id" => session.id,
"path" => session.mediamtx_path_name,
"rtmps" => "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}"
}
res = agent_request(Net::HTTP::Post, "/relays", payload)
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"
)
pid
end
def agent_request(http_class, path, payload = nil)
uri = URI.parse("#{relay_agent_url.chomp('/')}#{path}")
http = Net::HTTP.new(uri.host, uri.port)
http.open_timeout = 5
http.read_timeout = 10
req = http_class.new(uri)
req["Authorization"] = "Bearer #{relay_agent_secret}" if relay_agent_secret.present?
req["Content-Type"] = "application/json"
req.body = JSON.generate(payload) if payload
res = http.request(req)
body = res.body.present? ? JSON.parse(res.body) : {}
unless res.is_a?(Net::HTTPSuccess)
raise Error, "relay agent #{path} HTTP #{res.code} #{body.inspect}"
end
body
rescue JSON::ParserError => e
raise Error, "relay agent JSON: #{e.message}"
end
# Remux verso YouTube: video+audio copy (AAC già da app/slate a 48k mono). # Remux verso YouTube: video+audio copy (AAC già da app/slate a 48k mono).
# HLS (ADTS) → FLV richiede -bsf:a aac_adtstoasc; RTMP ha già ASC in FLV tags. # HLS (ADTS) → FLV richiede -bsf:a aac_adtstoasc; RTMP ha già ASC in FLV tags.
def youtube_ffmpeg_args(intake_source, output) def youtube_ffmpeg_args(intake_source, output)
@@ -179,6 +279,9 @@ module Streams
end end
common_head + [ common_head + [
"-reconnect", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "2",
"-rw_timeout", "15000000", "-rw_timeout", "15000000",
"-live_start_index", "-1", "-live_start_index", "-1",
"-i", url, "-i", url,
@@ -190,14 +293,29 @@ module Streams
end end
def mediamtx_intake_source(session) def mediamtx_intake_source(session)
base = session.mediamtx_internal_rtmp_url rtmp_base, hls_base = intake_bases(session)
if Mediamtx::PublisherOnline.active?(session) if Mediamtx::PublisherOnline.active?(session)
return [:rtmp, "#{base.chomp('/')}/#{session.mediamtx_path_name}"] return [:rtmp, "#{rtmp_base.chomp('/')}/#{session.mediamtx_path_name}"]
end end
path = session.mediamtx_path_name path = session.mediamtx_path_name
hls = session.mediamtx_internal_hls_url.chomp("/") [:hls, "#{hls_base.chomp('/')}/#{path}/index.m3u8"]
[:hls, "#{hls}/#{path}/index.m3u8"] end
# Sul nodo assegnato ffmpeg legge MediaMTX in loopback (o hostname Docker locale).
def intake_bases(session)
if relay_agent_enabled? && node_matches?(session)
return ["rtmp://127.0.0.1:1935", "http://127.0.0.1:8888"]
end
if node_matches?(session)
[
ENV["STREAM_NODE_LOCAL_RTMP_URL"].presence || session.mediamtx_internal_rtmp_url,
ENV["STREAM_NODE_LOCAL_HLS_URL"].presence || session.mediamtx_internal_hls_url
]
else
[session.mediamtx_internal_rtmp_url, session.mediamtx_internal_hls_url]
end
end end
def intake_available?(session) def intake_available?(session)
@@ -257,7 +375,9 @@ module Streams
redis.get(format(REDIS_KEY, session_id)) redis.get(format(REDIS_KEY, session_id))
end end
def process_alive?(pid) def process_alive?(pid, session_id: nil)
return agent_session_running?(session_id) if relay_agent_enabled? && session_id.present?
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"
@@ -267,12 +387,26 @@ module Streams
false false
end end
def terminate_pid(pid) def agent_session_running?(session_id)
res = agent_request(Net::HTTP::Get, "/relays/#{session_id}")
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}")
return
end
Process.kill("TERM", -pid.to_i) Process.kill("TERM", -pid.to_i)
sleep 0.5 sleep 0.5
rescue Errno::ESRCH rescue Errno::ESRCH
nil nil
else else
return if relay_agent_enabled?
begin begin
Process.kill("KILL", -pid.to_i) Process.kill("KILL", -pid.to_i)
rescue Errno::ESRCH rescue Errno::ESRCH
@@ -32,7 +32,8 @@ module Youtube
end end
def team_channel_available? def team_channel_available?
@ent.youtube_enabled? && @ent.premium_full? && @mode == "team" && @team.club.youtube_credential.present? @ent.youtube_enabled? && @ent.premium_full? && @mode == "team" &&
@team.club.youtube_credential&.usable?
end end
def effective_channel def effective_channel
@@ -63,7 +63,6 @@ module Youtube
return return
end end
Mediamtx::Client.for_session(session).set_always_available(session, enabled: false)
session.go_live! if session.may_go_live? session.go_live! if session.may_go_live?
session.reconnect! if session.reconnecting? && session.may_reconnect? session.reconnect! if session.reconnecting? && session.may_reconnect?
+3 -3
View File
@@ -15,7 +15,7 @@ module Youtube
when "matchlivetv_light" when "matchlivetv_light"
PlatformCredential.configured? PlatformCredential.configured?
when "team" when "team"
@team.club.youtube_credential.present? || PlatformCredential.configured? @team.club.youtube_credential&.usable? || PlatformCredential.configured?
else else
false false
end end
@@ -31,7 +31,7 @@ module Youtube
if @mode == "matchlivetv_light" if @mode == "matchlivetv_light"
PlatformCredential.configured? PlatformCredential.configured?
elsif @mode == "team" elsif @mode == "team"
@team.club.youtube_credential.blank? && PlatformCredential.configured? !@team.club.youtube_credential&.usable? && PlatformCredential.configured?
else else
false false
end end
@@ -46,7 +46,7 @@ module Youtube
end end
def needs_team_oauth? def needs_team_oauth?
@mode == "team" && @ent.premium_full? && @team.club.youtube_credential.blank? && !PlatformCredential.configured? @mode == "team" && @ent.premium_full? && !@team.club.youtube_credential&.usable? && !PlatformCredential.configured?
end end
end end
end end
+10 -6
View File
@@ -54,6 +54,7 @@
</div> </div>
<p id="offline-msg" class="live-status-msg" hidden><%= t("live.show.offline_msg") %></p> <p id="offline-msg" class="live-status-msg" hidden><%= t("live.show.offline_msg") %></p>
<p id="awaiting-msg" class="live-status-msg" hidden><%= t("live.show.awaiting_msg") %></p> <p id="awaiting-msg" class="live-status-msg" hidden><%= t("live.show.awaiting_msg") %></p>
<p id="reconnecting-msg" class="live-status-msg" hidden><%= t("live.show.js_reconnecting_awaiting") %></p>
<p id="paused-msg" class="live-status-msg" hidden><%= t("live.show.paused_msg") %></p> <p id="paused-msg" class="live-status-msg" hidden><%= t("live.show.paused_msg") %></p>
<script> <script>
@@ -63,25 +64,26 @@
const offlineMsg = document.getElementById("offline-msg"); const offlineMsg = document.getElementById("offline-msg");
const pausedMsg = document.getElementById("paused-msg"); const pausedMsg = document.getElementById("paused-msg");
const awaitingMsg = document.getElementById("awaiting-msg"); const awaitingMsg = document.getElementById("awaiting-msg");
const reconnectingMsg = document.getElementById("reconnecting-msg");
const playHint = document.getElementById("play-hint"); const playHint = document.getElementById("play-hint");
const streamBadge = document.getElementById("live-stream-badge"); const streamBadge = document.getElementById("live-stream-badge");
function syncStreamBadge(data) { function syncStreamBadge(data) {
if (!streamBadge) return; if (!streamBadge) return;
if (data.stream_closed) { if (data.stream_closed) {
streamBadge.textContent = "<%= j t("live.overlays.badge_ended") %>"; streamBadge.textContent = <%= raw t("live.overlays.badge_ended").to_json %>;
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-ended"; streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-ended";
return; return;
} }
const paused = !!(data.paused || data.status === "paused"); const paused = !!(data.paused || data.status === "paused");
if (paused) { if (paused) {
streamBadge.textContent = "<%= j t("live.index.badge_paused") %>"; streamBadge.textContent = <%= raw t("live.index.badge_paused").to_json %>;
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait"; streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait";
} else if (data.on_air) { } else if (data.on_air) {
streamBadge.textContent = "<%= j t("live.index.badge_live") %>"; streamBadge.textContent = <%= raw t("live.index.badge_live").to_json %>;
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-live"; streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-live";
} else { } else {
streamBadge.textContent = "<%= j t("live.overlays.badge_waiting") %>"; streamBadge.textContent = <%= raw t("live.overlays.badge_waiting").to_json %>;
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait"; streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait";
} }
} }
@@ -282,9 +284,10 @@
if (onAir || sessionLive) { if (onAir || sessionLive) {
offlineMsg.hidden = true; offlineMsg.hidden = true;
if (!publisherOnline && showingCover && !paused) { if (!publisherOnline && showingCover && !paused) {
awaitingMsg.hidden = false; awaitingMsg.hidden = true;
awaitingMsg.textContent = "<%= j t("live.show.js_reconnecting_awaiting") %>"; if (reconnectingMsg) reconnectingMsg.hidden = false;
} else { } else {
if (reconnectingMsg) reconnectingMsg.hidden = true;
awaitingMsg.hidden = !awaitingSignal; awaitingMsg.hidden = !awaitingSignal;
} }
pausedMsg.hidden = !paused; pausedMsg.hidden = !paused;
@@ -299,6 +302,7 @@
} else { } else {
offlineMsg.hidden = false; offlineMsg.hidden = false;
awaitingMsg.hidden = true; awaitingMsg.hidden = true;
if (reconnectingMsg) reconnectingMsg.hidden = true;
pausedMsg.hidden = true; pausedMsg.hidden = true;
} }
+1
View File
@@ -1,5 +1,6 @@
:concurrency: 5 :concurrency: 5
:queues: :queues:
- critical - critical
- youtube_relay_home
- youtube_relay - youtube_relay
- default - default
+1 -1
View File
@@ -305,8 +305,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
t.string "regia_token_digest" t.string "regia_token_digest"
t.datetime "regia_token_expires_at" t.datetime "regia_token_expires_at"
t.uuid "stream_node_id" t.uuid "stream_node_id"
t.boolean "audio_muted", default: false, null: false
t.string "min_quality_preset", default: "auto", null: false t.string "min_quality_preset", default: "auto", null: false
t.boolean "audio_muted", default: false, null: false
t.index ["match_id"], name: "index_stream_sessions_on_match_id" t.index ["match_id"], name: "index_stream_sessions_on_match_id"
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
+122
View File
@@ -29,4 +29,126 @@ namespace :streams do
puts Streams::DnsProviders::Lab.new.hosts_file_snippet puts Streams::DnsProviders::Lab.new.hosts_file_snippet
end end
end end
namespace :relay do
desc "Mostra coda e slug nodo per i worker ffmpeg YouTube"
task queues: :environment do
puts "local_slug=#{Streams::YoutubeRelay.local_node_slug} worker=#{Streams::YoutubeRelay.worker?}"
StreamNode.order(:slug).find_each do |node|
puts "node=#{node.slug} queue=youtube_relay_#{node.slug} status=#{node.status} relays=#{node.max_relays}"
end
end
desc "Simula un telefono RTMP e verifica che ffmpeg parta sul worker del nodo lab"
task simulate: :environment do
abort "Esegui questo task nel container Rails (docker compose exec rails ...)" unless File.exist?("/.dockerenv") || ENV["FORCE_RELAY_SIMULATE"] == "1"
home = Streams::NodeRegistry.ensure_home_from_env!
lab = StreamNode.find_by(slug: ENV.fetch("STREAM_NODE_SLUG", "ingest-lab-01"))
lab ||= Streams::NodeProvisioner.new.provision_lab!
user = User.find_by!(email: ENV.fetch("E2E_USER_EMAIL", "coach@matchlivetv.test"))
team = user.teams.first || user.clubs.first&.teams&.first || Team.first
abort "Nessun team per #{user.email}" unless team
match = team.matches.first || team.matches.create!(opponent_name: "Relay sim")
previous_max = home.max_publishers
filler = nil
session = nil
ffmpeg_pid = nil
begin
home.update!(max_publishers: 1, status: "ready")
filler = occupy_home!(home, user, match)
session = build_lab_youtube_session!(lab, user, match)
Mediamtx::Client.for_session(session).create_path(session)
session.begin_connect! if session.may_begin_connect?
session.update!(status: "connecting") unless session.status.in?(%w[connecting live reconnecting])
puts "filler_home=#{filler.id} lab_session=#{session.id} node=#{session.stream_node.slug} queue=#{Streams::YoutubeRelay.queue_for(session)}"
ffmpeg_pid = spawn_test_rtmp!(session)
wait_for!("publisher MediaMTX", 30) { Mediamtx::PublisherOnline.active?(session.reload) }
Streams::YoutubeRelay.enqueue_ensure!(session)
wait_for!("owner Redis sul nodo lab", 25) do
owner_for(session.id) == lab.slug
end
owner = owner_for(session.id)
home_owns = relay_redis.sismember(format(Streams::YoutubeRelay::OWNED_SET, "home"), session.id)
puts "owner=#{owner} home_owns=#{home_owns} queue=#{Streams::YoutubeRelay.queue_for(session)}"
abort "FAIL: owner atteso #{lab.slug}, trovato #{owner.inspect}" unless owner == lab.slug
abort "FAIL: il worker home ha reclamato la sessione lab" if home_owns
puts "OK — ffmpeg assegnato al worker #{lab.slug}, non a home"
ensure
begin
Process.kill("TERM", ffmpeg_pid) if ffmpeg_pid
rescue Errno::ESRCH
nil
end
session&.update!(status: "ended", ended_at: Time.current) if session&.persisted?
filler&.update!(status: "ended", ended_at: Time.current) if filler&.persisted?
home.update!(max_publishers: previous_max)
end
end
end
end
def occupy_home!(home, user, match)
StreamSession.create!(
match: match,
user: user,
platform: "matchlivetv",
status: "live",
privacy_status: "unlisted",
stream_node: home,
publish_token: SecureRandom.hex(8),
started_at: Time.current
)
end
def build_lab_youtube_session!(lab, user, match)
StreamSession.create!(
match: match,
user: user,
platform: "youtube",
status: "idle",
privacy_status: "unlisted",
stream_node: lab,
publish_token: SecureRandom.hex(8),
stream_key: "e2e-dummy-#{SecureRandom.hex(6)}"
)
end
def spawn_test_rtmp!(session)
intake = "#{session.mediamtx_internal_rtmp_url.chomp('/')}/#{session.mediamtx_path_name}"
cmd = [
"ffmpeg", "-hide_banner", "-loglevel", "error",
"-re", "-f", "lavfi", "-i", "testsrc=size=1280x720:rate=30",
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000",
"-t", "40",
"-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p", "-b:v", "1500k",
"-c:a", "aac", "-ac", "1", "-ar", "48000", "-b:a", "96k",
"-f", "flv", intake
]
pid = Process.spawn(*cmd, %i[out err] => "/tmp/relay_sim_ffmpeg.log")
Process.detach(pid)
puts "telefono simulato pid=#{pid} rtmp=#{intake}"
pid
end
def wait_for!(label, timeout_sec)
deadline = Time.now + timeout_sec
loop do
return true if yield
abort "FAIL timeout: #{label}" if Time.now >= deadline
sleep 1
end
end
def owner_for(session_id)
relay_redis.get(format(Streams::YoutubeRelay::OWNER_KEY, session_id))
end
def relay_redis
@relay_redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://redis:6379/0"))
end end
@@ -4,13 +4,13 @@ require "rails_helper"
RSpec.describe YoutubeRelayStopJob, type: :job do RSpec.describe YoutubeRelayStopJob, type: :job do
it "requeues when stop runs on the wrong host" do it "requeues when stop runs on the wrong host" do
session = instance_double(StreamSession, id: SecureRandom.uuid) session = instance_double(StreamSession, id: SecureRandom.uuid, stream_node: nil)
allow(StreamSession).to receive(:find_by).and_return(session) allow(StreamSession).to receive(:find_by).and_return(session)
allow(Streams::YoutubeRelay).to receive(:worker?).and_return(true) allow(Streams::YoutubeRelay).to receive(:worker?).and_return(true)
allow(Streams::YoutubeRelay).to receive(:stop_on_worker!).and_return(:wrong_host) allow(Streams::YoutubeRelay).to receive(:stop_on_worker!).and_return(:wrong_host)
job_proxy = double("ConfiguredJob") job_proxy = double("ConfiguredJob")
expect(described_class).to receive(:set).with(wait: 2.seconds).and_return(job_proxy) expect(described_class).to receive(:set).with(wait: 2.seconds, queue: "youtube_relay_home").and_return(job_proxy)
expect(job_proxy).to receive(:perform_later).with(session.id, 1) expect(job_proxy).to receive(:perform_later).with(session.id, 1)
described_class.new.perform(session.id, 0) described_class.new.perform(session.id, 0)
+73 -7
View File
@@ -4,6 +4,7 @@ require "rails_helper"
RSpec.describe "HLS proxy", type: :request do RSpec.describe "HLS proxy", type: :request do
let(:mediamtx_hls) { "http://mediamtx.test:8888" } let(:mediamtx_hls) { "http://mediamtx.test:8888" }
let(:session_id) { "cc7e90b2-c672-4401-b5d3-51fdcdc7a214" }
let(:playlist) do let(:playlist) do
<<~M3U8 <<~M3U8
#EXTM3U #EXTM3U
@@ -13,24 +14,89 @@ RSpec.describe "HLS proxy", type: :request do
/live/match_#{session_id}/segment0.ts /live/match_#{session_id}/segment0.ts
M3U8 M3U8
end end
let(:session_id) { "cc7e90b2-c672-4401-b5d3-51fdcdc7a214" }
def stub_hls_get(url, status:, body: "", headers: {}, location: nil)
response_headers = headers.dup
response_headers["location"] = location if location
faraday_response = instance_double(
Faraday::Response,
status: status,
body: body,
headers: response_headers
)
conn = instance_double(Faraday::Connection)
allow_any_instance_of(HlsProxyController).to receive(:hls_conn).and_return(conn)
allow(conn).to receive(:get) do |requested, &block|
expect(requested).to start_with(url.split("?").first)
block&.call(instance_double(Faraday::Request, headers: {}))
faraday_response
end
end
before do before do
allow(MatchLiveTv).to receive(:mediamtx_hls_url).and_return(mediamtx_hls) allow(MatchLiveTv).to receive(:mediamtx_hls_url).and_return(mediamtx_hls)
end end
describe "GET /live/match_:session_id/*" do describe "GET /live/match_:session_id/*" do
it "proxies MediaMTX redirect and rewrites playlist paths to /hls/" do it "rewrites playlist paths to /hls/" do
stub_request(:get, "#{mediamtx_hls}/live/match_#{session_id}/index.m3u8") stub_hls_get(
.to_return(status: 302, headers: { "Location" => "/live/match_#{session_id}/index.m3u8?cookieCheck=1" }) "#{mediamtx_hls}/live/match_#{session_id}/index.m3u8",
stub_request(:get, "#{mediamtx_hls}/live/match_#{session_id}/index.m3u8?cookieCheck=1") status: 200,
.to_return(status: 200, body: playlist, headers: { "Content-Type" => "application/vnd.apple.mpegurl" }) body: playlist,
headers: { "content-type" => "application/vnd.apple.mpegurl" }
)
get "/live/match_#{session_id}/index.m3u8" get "/live/match_#{session_id}/index.m3u8"
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.body).to include("/hls/live/match_#{session_id}/segment0.ts") expect(response.body).to include("/hls/live/match_#{session_id}/segment0.ts")
expect(response.body).not_to include("/live/match_#{session_id}/segment0.ts") end
end
describe "GET /hls/live/match_:id on a lab node" do
let!(:lab) do
StreamNode.create!(
slug: "ingest-lab-hls-spec",
hostname: "ingest-lab-hls-spec.lab.mltv-stream.net",
role: "lab",
status: "ready",
provider: "local",
rtmp_base_url: "rtmp://127.0.0.1:11935",
hls_base_url: "http://localhost:3000/hls",
api_base_url: "http://mediamtx_lab:9997",
internal_rtmp_url: "rtmp://mediamtx_lab:1935",
internal_hls_url: "http://mediamtx_lab:8888"
)
end
before do
user = User.create!(email: "hls-lab@test.com", name: "H", password: "Password123", role: "coach")
club = Club.create!(name: "HlsLab", sport: "volleyball")
team = club.teams.create!(name: "T", sport: "volleyball", slug: "hls-lab-t")
match = team.matches.create!(opponent_name: "X")
StreamSession.create!(id: session_id, match: match, user: user, platform: "matchlivetv", stream_node: lab)
end
it "proxies HLS to the session MediaMTX, not home" do
requested = []
faraday_response = instance_double(
Faraday::Response,
status: 200,
body: playlist,
headers: { "content-type" => "application/vnd.apple.mpegurl" }
)
conn = instance_double(Faraday::Connection)
allow_any_instance_of(HlsProxyController).to receive(:hls_conn).and_return(conn)
allow(conn).to receive(:get) do |url, &block|
requested << url
block&.call(instance_double(Faraday::Request, headers: {}))
faraday_response
end
get "/hls/live/match_#{session_id}/index.m3u8"
expect(response).to have_http_status(:ok)
expect(requested.first).to eq("http://mediamtx_lab:8888/live/match_#{session_id}/index.m3u8")
end end
end end
end end
@@ -225,4 +225,34 @@ RSpec.describe "Public regia", type: :request do
expect(tel["thermal_state"]).to eq("nominal") expect(tel["thermal_state"]).to eq("nominal")
expect(tel).to have_key("publisher_online") expect(tel).to have_key("publisher_online")
end end
it "legge publisher_online dal MediaMTX del nodo assegnato" do
node = StreamNode.create!(
slug: "ingest-regia-cloud",
hostname: "ingest-regia-cloud.mltv-stream.net",
role: "cloud",
status: "ready",
provider: "hetzner",
rtmp_base_url: "rtmp://ingest-regia-cloud.mltv-stream.net:1935",
hls_base_url: "http://localhost:3000/hls",
api_base_url: "http://203.0.113.9:9997",
internal_hls_url: "http://203.0.113.9:8888",
max_publishers: 4,
max_relays: 4
)
session.update!(stream_node: node)
home_client = instance_double(Mediamtx::Client, list_paths: [])
cloud_client = instance_double(
Mediamtx::Client,
list_paths: [{ "name" => session.mediamtx_path_name, "online" => true, "ready" => true }]
)
allow(Mediamtx::Client).to receive(:new).and_call_original
allow(Mediamtx::Client).to receive(:new).with(hash_including(base_url: MatchLiveTv.mediamtx_api_url)).and_return(home_client)
allow(Mediamtx::Client).to receive(:new).with(hash_including(base_url: "http://203.0.113.9:9997")).and_return(cloud_client)
token = Sessions::RegiaAccess.new(session).issue_token!
get public_regia_status_path(token)
expect(response).to have_http_status(:ok)
expect(response.parsed_body["publisher_online"]).to eq(true)
end
end end
@@ -23,6 +23,7 @@ RSpec.describe "Streams::NodeProvisioner cloud" do
ENV["HLS_PUBLIC_URL"] = "https://home.example/hls" ENV["HLS_PUBLIC_URL"] = "https://home.example/hls"
ENV["STREAM_CLOUD_DNS_SUFFIX"] = "mltv-stream.net" ENV["STREAM_CLOUD_DNS_SUFFIX"] = "mltv-stream.net"
ENV["STREAM_CLOUD_MAX_PUBLISHERS"] = "4" ENV["STREAM_CLOUD_MAX_PUBLISHERS"] = "4"
ENV["STREAM_CLOUD_PUBLIC_CONTROL"] = "0"
node = Streams::NodeProvisioner.new(cloud: cloud, dns: dns).provision_cloud! node = Streams::NodeProvisioner.new(cloud: cloud, dns: dns).provision_cloud!
expect(node.slug).to eq("ingest-01") expect(node.slug).to eq("ingest-01")
@@ -31,11 +32,13 @@ RSpec.describe "Streams::NodeProvisioner cloud" do
expect(node.hostname).to eq("ingest-01.mltv-stream.net") expect(node.hostname).to eq("ingest-01.mltv-stream.net")
expect(node.rtmp_base_url).to eq("rtmp://ingest-01.mltv-stream.net:1935") expect(node.rtmp_base_url).to eq("rtmp://ingest-01.mltv-stream.net:1935")
expect(node.api_base_url).to eq("http://10.0.0.9:9997") expect(node.api_base_url).to eq("http://10.0.0.9:9997")
expect(node.hls_base_url).to eq("https://home.example/hls")
expect(node.internal_hls_url).to eq("http://10.0.0.9:8888")
expect(dns).to have_received(:upsert_a).with("ingest-01.mltv-stream.net", "49.13.9.9") expect(dns).to have_received(:upsert_a).with("ingest-01.mltv-stream.net", "49.13.9.9")
ensure ensure
%w[ %w[
MEDIAMTX_API_URL MEDIAMTX_RTMP_URL HLS_PUBLIC_URL MEDIAMTX_API_URL MEDIAMTX_RTMP_URL HLS_PUBLIC_URL
STREAM_CLOUD_DNS_SUFFIX STREAM_CLOUD_MAX_PUBLISHERS STREAM_CLOUD_DNS_SUFFIX STREAM_CLOUD_MAX_PUBLISHERS STREAM_CLOUD_PUBLIC_CONTROL
].each { |k| ENV.delete(k) } ].each { |k| ENV.delete(k) }
end end
end end
@@ -27,7 +27,8 @@ RSpec.describe Streams::NodeProvisioner do
"MEDIAMTX_API_URL" => "http://mtx-home:9997", "MEDIAMTX_API_URL" => "http://mtx-home:9997",
"MEDIAMTX_RTMP_URL" => "rtmp://ingest-home.example:1935", "MEDIAMTX_RTMP_URL" => "rtmp://ingest-home.example:1935",
"HLS_PUBLIC_URL" => "https://ingest-home.example/hls", "HLS_PUBLIC_URL" => "https://ingest-home.example/hls",
"STREAM_LAB_MAX_PUBLISHERS" => "2" "STREAM_LAB_MAX_PUBLISHERS" => "2",
"MEDIAMTX_LAB_API_URL" => nil
) do ) do
provisioner = described_class.new provisioner = described_class.new
node = provisioner.provision_lab! node = provisioner.provision_lab!
@@ -45,13 +46,35 @@ RSpec.describe Streams::NodeProvisioner do
end end
end end
it "attaches a dedicated lab MediaMTX when MEDIAMTX_LAB_API_URL is set" do
with_env(
"STREAM_CLOUD_PROVIDER" => "local_lab",
"STREAM_DNS_PROVIDER" => "lab",
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
"MEDIAMTX_RTMP_URL" => "rtmp://ingest-home.example:1935",
"HLS_PUBLIC_URL" => "https://ingest-home.example/hls",
"MEDIAMTX_LAB_API_URL" => "http://mediamtx_lab:9997",
"MEDIAMTX_LAB_HLS_URL" => "http://mediamtx_lab:8888",
"MEDIAMTX_LAB_INTERNAL_RTMP_URL" => "rtmp://mediamtx_lab:1935",
"MEDIAMTX_LAB_RTMP_URL" => "rtmp://127.0.0.1:11935"
) do
node = described_class.new.provision_lab!
expect(node.api_base_url).to eq("http://mediamtx_lab:9997")
expect(node.internal_rtmp_url).to eq("rtmp://mediamtx_lab:1935")
expect(node.internal_hls_url).to eq("http://mediamtx_lab:8888")
expect(node.rtmp_base_url).to eq("rtmp://127.0.0.1:11935")
expect(node.hls_base_url).to eq("https://ingest-home.example/hls")
end
end
it "refuses to decommission a busy node" do it "refuses to decommission a busy node" do
with_env( with_env(
"STREAM_CLOUD_PROVIDER" => "local_lab", "STREAM_CLOUD_PROVIDER" => "local_lab",
"STREAM_DNS_PROVIDER" => "lab", "STREAM_DNS_PROVIDER" => "lab",
"MEDIAMTX_API_URL" => "http://mtx-home:9997", "MEDIAMTX_API_URL" => "http://mtx-home:9997",
"MEDIAMTX_RTMP_URL" => "rtmp://ingest-home.example:1935", "MEDIAMTX_RTMP_URL" => "rtmp://ingest-home.example:1935",
"HLS_PUBLIC_URL" => "https://ingest-home.example/hls" "HLS_PUBLIC_URL" => "https://ingest-home.example/hls",
"MEDIAMTX_LAB_API_URL" => nil
) do ) do
node = described_class.new.provision_lab! node = described_class.new.provision_lab!
user = User.create!(email: "lab@example.com", name: "L", password: "Password123", role: "coach") user = User.create!(email: "lab@example.com", name: "L", password: "Password123", role: "coach")
@@ -18,10 +18,57 @@ RSpec.describe Streams::YoutubeRelay do
it "remuxes HLS with AAC bitstream filter for FLV" do it "remuxes HLS with AAC bitstream filter for FLV" do
cmd = args(:hls, "http://mediamtx:8888/live/match_x/index.m3u8") cmd = args(:hls, "http://mediamtx:8888/live/match_x/index.m3u8")
expect(cmd).to include("-c:v", "copy", "-c:a", "copy", "-bsf:a", "aac_adtstoasc", "-f", "flv") expect(cmd).to include("-c:v", "copy", "-c:a", "copy", "-bsf:a", "aac_adtstoasc", "-f", "flv")
expect(cmd).to include("-reconnect", "1")
expect(cmd).not_to include("-b:a") expect(cmd).not_to include("-b:a")
end end
end end
describe "node queue affinity" do
def with_env(vars)
previous = vars.keys.index_with { |k| ENV[k] }
vars.each { |k, v| ENV[k] = v }
yield
ensure
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
end
let(:session_id) { SecureRandom.uuid }
let(:lab_node) { instance_double(StreamNode, slug: "ingest-lab-01") }
def session_double(stream_node: nil)
instance_double(
StreamSession,
id: session_id,
platform: "youtube",
terminal?: false,
stream_key: "yt-key",
status: "live",
stream_node: stream_node,
mediamtx_path_name: "live/match_#{session_id}",
mediamtx_internal_rtmp_url: "rtmp://10.0.0.9:1935",
mediamtx_internal_hls_url: "http://10.0.0.9:8888"
)
end
it "routes home sessions to youtube_relay_home" do
expect(described_class.queue_for(session_double)).to eq("youtube_relay_home")
end
it "routes overflow 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 "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)
job_proxy = double("ConfiguredJob", perform_later: true)
expect(YoutubeRelayEnsureJob).to receive(:set).with(hash_including(queue: "youtube_relay_ingest-lab-01", wait: 2.seconds)).and_return(job_proxy)
expect(described_class.ensure_on_worker!(session_double(stream_node: lab_node))).to eq(:wrong_host)
end
end
end
describe "multi-host sticky stop/capacity" do describe "multi-host sticky stop/capacity" do
let(:redis) { Redis.new(url: ENV.fetch("REDIS_URL", "redis://redis:6379/0")) } let(:redis) { Redis.new(url: ENV.fetch("REDIS_URL", "redis://redis:6379/0")) }
let(:session_id) { SecureRandom.uuid } let(:session_id) { SecureRandom.uuid }
@@ -30,6 +77,7 @@ RSpec.describe Streams::YoutubeRelay do
allow(described_class).to receive(:worker?).and_return(true) allow(described_class).to receive(:worker?).and_return(true)
allow(described_class).to receive(:worker_id).and_return("worker-a") allow(described_class).to receive(:worker_id).and_return("worker-a")
allow(described_class).to receive(:max_concurrent).and_return(1) allow(described_class).to receive(:max_concurrent).and_return(1)
allow(described_class).to receive(:local_node_slug).and_return("home")
redis.flushdb redis.flushdb
end end
@@ -40,7 +88,8 @@ RSpec.describe Streams::YoutubeRelay do
platform: "youtube", platform: "youtube",
terminal?: false, terminal?: false,
stream_key: "yt-key", stream_key: "yt-key",
status: "live" status: "live",
stream_node: nil
) )
end end
@@ -52,15 +101,91 @@ RSpec.describe Streams::YoutubeRelay do
expect(redis.get(format(Streams::YoutubeRelay::OWNER_KEY, session_id))).to eq("worker-b") expect(redis.get(format(Streams::YoutubeRelay::OWNER_KEY, session_id))).to eq("worker-b")
end end
it "requeues ensure when at capacity" do it "requeues ensure on the node queue when at capacity" do
other_id = SecureRandom.uuid other_id = SecureRandom.uuid
redis.sadd(format(Streams::YoutubeRelay::OWNED_SET, "worker-a"), other_id) redis.sadd(format(Streams::YoutubeRelay::OWNED_SET, "worker-a"), other_id)
allow(described_class).to receive(:intake_available?).and_return(true) allow(described_class).to receive(:intake_available?).and_return(true)
expect(YoutubeRelayEnsureJob).to receive(:set).with(hash_including(wait: 5.seconds, queue: :youtube_relay)).and_return( expect(YoutubeRelayEnsureJob).to receive(:set).with(hash_including(wait: 5.seconds, queue: "youtube_relay_home")).and_return(
double(perform_later: true) double(perform_later: true)
) )
expect(described_class.ensure_on_worker!(session_double)).to eq(:at_capacity) expect(described_class.ensure_on_worker!(session_double)).to eq(:at_capacity)
end end
end end
describe ".mediamtx_intake_source (private)" do
def with_env(vars)
previous = vars.keys.index_with { |k| ENV[k] }
vars.each { |k, v| ENV[k] = v }
yield
ensure
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
end
let(:session) do
instance_double(
StreamSession,
stream_node: instance_double(StreamNode, slug: "ingest-lab-01"),
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"
)
end
it "uses loopback RTMP when the worker is on the assigned node" do
with_env(
"STREAM_NODE_SLUG" => "ingest-lab-01",
"STREAM_NODE_LOCAL_RTMP_URL" => "rtmp://127.0.0.1:1935"
) do
allow(Mediamtx::PublisherOnline).to receive(:active?).with(session).and_return(true)
expect(described_class.send(:mediamtx_intake_source, session)).to eq(
[:rtmp, "rtmp://127.0.0.1:1935/live/match_abc"]
)
end
end
it "uses loopback when the node relay agent is enabled" do
with_env(
"STREAM_NODE_SLUG" => "ingest-lab-01",
"STREAM_NODE_RELAY_AGENT_URL" => "http://203.0.113.10:9100",
"STREAM_NODE_LOCAL_RTMP_URL" => "rtmp://mediamtx_lab:1935"
) do
allow(Mediamtx::PublisherOnline).to receive(:active?).with(session).and_return(true)
expect(described_class.send(:mediamtx_intake_source, session)).to eq(
[:rtmp, "rtmp://127.0.0.1:1935/live/match_abc"]
)
end
end
end
describe "relay agent spawn" do
def with_env(vars)
previous = vars.keys.index_with { |k| ENV[k] }
vars.each { |k, v| ENV[k] = v }
yield
ensure
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
end
it "asks the node agent to start ffmpeg and stores the pid" do
session = instance_double(
StreamSession,
id: "sess-1",
mediamtx_path_name: "live/match_abc",
stream_key: "yt-key"
)
allow(described_class).to receive(:log_file).and_return("/tmp/youtube_relay_test.log")
allow(File).to receive(:write)
expect(described_class).to receive(:agent_request).with(
Net::HTTP::Post,
"/relays",
hash_including("session_id" => "sess-1", "path" => "live/match_abc")
).and_return("pid" => 4242)
pid = with_env("STREAM_NODE_RELAY_AGENT_URL" => "http://203.0.113.10:9100") do
described_class.send(:start_via_agent!, session)
end
expect(pid).to eq(4242)
end
end
end end
@@ -0,0 +1,37 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Youtube::LivePipeline do
let(:user) { User.create!(email: "ytpipe@test.it", name: "U", password: "Password123", role: "coach") }
let(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let(:team) { club.teams.create!(name: "T", sport: "volleyball") }
let(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
let(:session) do
StreamSession.create!(
match: match,
user: user,
platform: "youtube",
status: "live",
youtube_broadcast_id: "bcast",
stream_key: "yt-key"
)
end
let(:client) { instance_double(Mediamtx::Client) }
before do
allow(Mediamtx::Client).to receive(:for_session).and_return(client)
allow(Mediamtx::PublisherOnline).to receive(:active?).and_return(true)
allow(Streams::YoutubeRelay).to receive(:ensure_publishing!)
allow(Streams::YoutubeRelay).to receive(:running?).and_return(true)
allow(described_class).to receive(:schedule!)
allow(described_class).to receive(:schedule_activate!)
allow(client).to receive(:set_always_available)
end
it "non spegne la slate quando il publisher è online" do
described_class.tick_session!(session)
expect(client).not_to have_received(:set_always_available)
end
end
+17 -4
View File
@@ -200,6 +200,17 @@ Prima (o in parallelo) al Cloud “vero”:
Cosa il lab **non** replica al 100%: tempi boot Hetzner, vSwitch, RTMP 4G multi-nodo (smoke WAN dopo). Cosa il lab **non** replica al 100%: tempi boot Hetzner, vSwitch, RTMP 4G multi-nodo (smoke WAN dopo).
### Relay ffmpeg sul nodo (lab locale / collaudo)
Loverflow deve spostare **ffmpeg**, non solo lingest RTMP. In lab `local_lab` il MediaMTX resta quello home; si alza un secondo Sidekiq che ascolta solo `youtube_relay_<slug>` così la CPU del remux è isolata.
1. Provisiona il nodo: admin **Nodi stream** o `rails streams:nodes:provision_lab` (slug tipo `ingest-lab-01`).
2. Worker locale: `STREAM_NODE_SLUG=ingest-lab-01 bash scripts/dev_sidekiq_relay.sh`
3. Collaudo: `STREAM_NODE_SLUG=ingest-lab-01 bash scripts/deploy/collaudo_relay_worker.sh up`
4. Per assegnare sessioni al lab: riempi home (`STREAM_NODE_HOME_MAX_PUBLISHERS=1`) oppure alza `max_publishers` del lab e satura home.
5. Verifica: `docker top` / log `[YoutubeRelay] started ... worker=ingest-lab-01` sul container relay, **non** su `sidekiq` home.
6. Nodo Cloud Hetzner: stesso modello quando WireGuard espone Redis/Postgres; cloud-init dovrà avviare Sidekiq con `STREAM_NODE_SLUG` e `STREAM_NODE_LOCAL_RTMP_URL=rtmp://127.0.0.1:1935`.
--- ---
## 11. Fasi di implementazione ## 11. Fasi di implementazione
@@ -209,7 +220,7 @@ Cosa il lab **non** replica al 100%: tempi boot Hetzner, vSwitch, RTMP 4G multi-
| **L — Lab Proxmox** | `LocalLab` / `ProxmoxLab`, DNS lab, admin nodi | **implementata sul branch** | | **L — Lab Proxmox** | `LocalLab` / `ProxmoxLab`, DNS lab, admin nodi | **implementata sul branch** |
| **0 — Multi-nodo ready** | Registry + URL RTMP/HLS in API (`StreamNode`, `Streams::NodeRegistry`) | **implementata sul branch** | | **0 — Multi-nodo ready** | Registry + URL RTMP/HLS in API (`StreamNode`, `Streams::NodeRegistry`) | **implementata sul branch** |
| **1 — Hetzner Cloud + DNS** | `HetznerCloudProvider` + `HetznerDnsProvider`, `mltv-stream.net`, cloud-init, WireGuard (§16) | **implementata sul branch** (WG ops manuale) | | **1 — Hetzner Cloud + DNS** | `HetznerCloudProvider` + `HetznerDnsProvider`, `mltv-stream.net`, cloud-init, WireGuard (§16) | **implementata sul branch** (WG ops manuale) |
| **2 — Routing relay** | Coda `youtube_relay`, sticky owner, cap `RELAY_MAX_CONCURRENT` | **implementata sul branch** | | **2 — Routing relay** | Coda `youtube_relay_<slug>` per nodo, worker overflow senza code `default`, sticky owner, cap `RELAY_MAX_CONCURRENT` | **implementata sul branch** (lab Docker/collaudo; Cloud dopo WG) |
| **3 — Autoscaler** | Soglie + warm spare (+1), kill-switch `STREAM_AUTOSCALE_ENABLED` | **implementata sul branch** | | **3 — Autoscaler** | Soglie + warm spare (+1), kill-switch `STREAM_AUTOSCALE_ENABLED` | **implementata sul branch** |
| **4 — Hardening** | Drain sicuro, budget, kill-switch Redis/admin, alert overflow, runbook | **implementata sul branch** (solo test lab — **non in prod**) | | **4 — Hardening** | Drain sicuro, budget, kill-switch Redis/admin, alert overflow, runbook | **implementata sul branch** (solo test lab — **non in prod**) |
| **A — Auction (futuro)** | Migrazione control plane + vSwitch al posto di WireGuard | Hardware dedicato Hetzner | | **A — Auction (futuro)** | Migrazione control plane + vSwitch al posto di WireGuard | Hardware dedicato Hetzner |
@@ -244,11 +255,13 @@ Cosa il lab **non** replica al 100%: tempi boot Hetzner, vSwitch, RTMP 4G multi-
### Fase 2 — dettagli implementati ### Fase 2 — dettagli implementati
- Coda Sidekiq dedicata `youtube_relay` (priorità sopra `default`) - Coda Sidekiq dedicata `youtube_relay_<slug>` (home: `youtube_relay_home`; overflow: `youtube_relay_ingest-01`)
- `YoutubeRelayEnsureJob` / `YoutubeRelayStopJob` solo su quella coda - `YoutubeRelayEnsureJob` / `YoutubeRelayStopJob` sulla coda del **nodo assegnato**
- 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`
- Ensure a capacità piena → requeue 5s invece di avviare un secondo ffmpeg locale - 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`
- Intake locale: `STREAM_NODE_LOCAL_RTMP_URL` (loopback sul CPX, `mediamtx` in Docker lab)
### Fase 3 — dettagli implementati ### Fase 3 — dettagli implementati
+1
View File
@@ -96,6 +96,7 @@ STREAM_NODE_ENV=collaudo
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
# Worker home: STREAM_NODE_SLUG=home (default). Overflow: coda youtube_relay_<slug>
STREAM_AUTOSCALE_ENABLED=0 STREAM_AUTOSCALE_ENABLED=0
STREAM_AUTOSCALE_KIND=lab STREAM_AUTOSCALE_KIND=lab
+6
View File
@@ -24,8 +24,14 @@ services:
- ./stream-node:/opt/matchlivetv/infra/stream-node:ro - ./stream-node:/opt/matchlivetv/infra/stream-node:ro
sidekiq: sidekiq:
hostname: home
env_file: env_file:
- .env - .env
environment:
STREAM_NODE_SLUG: home
YOUTUBE_RELAY_WORKER: "1"
STREAM_NODE_LOCAL_RTMP_URL: rtmp://mediamtx:1935
STREAM_NODE_LOCAL_HLS_URL: http://mediamtx:8888
volumes: volumes:
- ./stream-node:/opt/matchlivetv/infra/stream-node:ro - ./stream-node:/opt/matchlivetv/infra/stream-node:ro
+4
View File
@@ -164,6 +164,7 @@ services:
context: ../backend context: ../backend
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped restart: unless-stopped
hostname: home
command: bundle exec sidekiq -C config/sidekiq.yml -e production command: bundle exec sidekiq -C config/sidekiq.yml -e production
environment: environment:
RAILS_ENV: production RAILS_ENV: production
@@ -180,6 +181,9 @@ services:
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:-}
YOUTUBE_RELAY_WORKER: "1" YOUTUBE_RELAY_WORKER: "1"
STREAM_NODE_SLUG: home
STREAM_NODE_LOCAL_RTMP_URL: rtmp://mediamtx:1935
STREAM_NODE_LOCAL_HLS_URL: http://mediamtx:8888
RAILS_INTERNAL_URL: http://rails:3000 RAILS_INTERNAL_URL: http://rails:3000
MEDIAMTX_HLS_URL: http://mediamtx:8888 MEDIAMTX_HLS_URL: http://mediamtx:8888
MEDIAMTX_INTERNAL_RTMP_URL: rtmp://mediamtx:1935 MEDIAMTX_INTERNAL_RTMP_URL: rtmp://mediamtx:1935
+141
View File
@@ -46,6 +46,25 @@ services:
retries: 3 retries: 3
start_period: 10s start_period: 10s
# Secondo ingest = nodo lab/cloud locale (RTMP host :11935).
mediamtx_lab:
image: bluenviron/mediamtx:latest
ports:
- "11935:1935"
- "18888:8888"
- "19997:9997"
volumes:
- ./mediamtx.yml:/mediamtx.yml:ro
- ./slates:/slates:ro
- recordings_lab:/recordings
command: /mediamtx.yml
healthcheck:
test: ["CMD", "/mediamtx", "--help"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
rails: rails:
build: build:
context: ../backend context: ../backend
@@ -60,6 +79,34 @@ services:
MEDIAMTX_API_URL: http://mediamtx:9997 MEDIAMTX_API_URL: http://mediamtx:9997
MEDIAMTX_RTMP_URL: ${MEDIAMTX_RTMP_URL:-rtmp://127.0.0.1:1935} MEDIAMTX_RTMP_URL: ${MEDIAMTX_RTMP_URL:-rtmp://127.0.0.1:1935}
MEDIAMTX_HLS_URL: ${MEDIAMTX_HLS_URL:-http://mediamtx:8888} MEDIAMTX_HLS_URL: ${MEDIAMTX_HLS_URL:-http://mediamtx:8888}
MEDIAMTX_LAB_API_URL: http://mediamtx_lab:9997
MEDIAMTX_LAB_HLS_URL: http://mediamtx_lab:8888
MEDIAMTX_LAB_INTERNAL_RTMP_URL: rtmp://mediamtx_lab:1935
MEDIAMTX_LAB_RTMP_URL: rtmp://127.0.0.1:11935
MEDIAMTX_INTERNAL_RTMP_URL: rtmp://mediamtx:1935
STREAM_NODE_HOME_MAX_PUBLISHERS: ${STREAM_NODE_HOME_MAX_PUBLISHERS:-1}
STREAM_CLOUD_PROVIDER: ${STREAM_CLOUD_PROVIDER:-local_lab}
STREAM_DNS_PROVIDER: ${STREAM_DNS_PROVIDER:-lab}
STREAM_LAB_MAX_PUBLISHERS: ${STREAM_LAB_MAX_PUBLISHERS:-2}
STREAM_AUTOSCALE_ENABLED: ${STREAM_AUTOSCALE_ENABLED:-1}
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:-2}
STREAM_AUTOSCALE_INTERVAL_SECS: ${STREAM_AUTOSCALE_INTERVAL_SECS:-30}
STREAM_CLOUD_PUBLIC_CONTROL: ${STREAM_CLOUD_PUBLIC_CONTROL:-1}
STREAM_NODE_ENV: ${STREAM_NODE_ENV:-lab}
STREAM_DNS_ZONE: ${STREAM_DNS_ZONE:-mltv-stream.net}
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:3000/hls} HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:3000/hls}
MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret} MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret}
RAILS_WEBHOOK_URL: http://rails:3000 RAILS_WEBHOOK_URL: http://rails:3000
@@ -100,12 +147,15 @@ services:
condition: service_healthy condition: service_healthy
mediamtx: mediamtx:
condition: service_started condition: service_started
mediamtx_lab:
condition: service_started
garage: garage:
condition: service_started condition: service_started
volumes: volumes:
- ../backend:/app - ../backend:/app
- recordings:/recordings - recordings:/recordings
- bundle_cache:/usr/local/bundle - bundle_cache:/usr/local/bundle
- ./stream-node:/opt/matchlivetv/infra/stream-node:ro
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/up"] test: ["CMD", "curl", "-f", "http://localhost:3000/up"]
interval: 15s interval: 15s
@@ -118,6 +168,85 @@ services:
context: ../backend context: ../backend
dockerfile: Dockerfile dockerfile: Dockerfile
command: bundle exec sidekiq -C config/sidekiq.yml command: bundle exec sidekiq -C config/sidekiq.yml
hostname: home
environment:
RAILS_ENV: development
BUNDLE_WITHOUT: ""
BUNDLE_DEPLOYMENT: "0"
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-matchlivetv_dev}@postgres:5432/matchlivetv
REDIS_URL: redis://redis:6379/0
MEDIAMTX_API_URL: http://mediamtx:9997
MEDIAMTX_LAB_API_URL: http://mediamtx_lab:9997
MEDIAMTX_LAB_HLS_URL: http://mediamtx_lab:8888
MEDIAMTX_LAB_INTERNAL_RTMP_URL: rtmp://mediamtx_lab:1935
MEDIAMTX_LAB_RTMP_URL: rtmp://127.0.0.1:11935
STREAM_NODE_HOME_MAX_PUBLISHERS: ${STREAM_NODE_HOME_MAX_PUBLISHERS:-1}
STREAM_CLOUD_PROVIDER: ${STREAM_CLOUD_PROVIDER:-local_lab}
STREAM_DNS_PROVIDER: ${STREAM_DNS_PROVIDER:-lab}
STREAM_LAB_MAX_PUBLISHERS: ${STREAM_LAB_MAX_PUBLISHERS:-2}
STREAM_AUTOSCALE_ENABLED: ${STREAM_AUTOSCALE_ENABLED:-1}
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:-2}
STREAM_AUTOSCALE_INTERVAL_SECS: ${STREAM_AUTOSCALE_INTERVAL_SECS:-30}
STREAM_CLOUD_PUBLIC_CONTROL: ${STREAM_CLOUD_PUBLIC_CONTROL:-1}
STREAM_NODE_ENV: ${STREAM_NODE_ENV:-lab}
STREAM_DNS_ZONE: ${STREAM_DNS_ZONE:-mltv-stream.net}
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
MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret}
RAILS_WEBHOOK_URL: http://rails:3000
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev_secret_key_base_change_me_32chars_min}
JWT_SECRET: ${JWT_SECRET:-matchlivetv_jwt_dev_secret}
YOUTUBE_CLIENT_ID: ${YOUTUBE_CLIENT_ID:-}
YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-}
YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-}
YOUTUBE_REDIRECT_URI: ${YOUTUBE_REDIRECT_URI:-http://localhost:3000/api/v1/youtube/callback}
YOUTUBE_RELAY_WORKER: "1"
STREAM_NODE_SLUG: home
STREAM_NODE_LOCAL_RTMP_URL: rtmp://mediamtx:1935
STREAM_NODE_LOCAL_HLS_URL: http://mediamtx:8888
MEDIAMTX_INTERNAL_RTMP_URL: rtmp://mediamtx:1935
MEDIAMTX_HLS_URL: http://mediamtx:8888
RECORDINGS_PATH: /recordings
REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-http://garage:3900}
REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays}
REPLAY_STORAGE_REGION: ${REPLAY_STORAGE_REGION:-garage}
REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-}
REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-}
REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true}
depends_on:
rails:
condition: service_healthy
garage:
condition: service_started
volumes:
- ../backend:/app
- recordings:/recordings
- bundle_cache:/usr/local/bundle
- ./stream-node:/opt/matchlivetv/infra/stream-node:ro
# Worker ffmpeg per il secondo MediaMTX (nodo lab locale, RTMP host :11935).
# STREAM_NODE_SLUG=ingest-lab-01 docker compose --profile stream-node up -d sidekiq_relay
sidekiq_relay:
profiles: ["stream-node"]
build:
context: ../backend
dockerfile: Dockerfile
command:
- bash
- -lc
- bundle exec sidekiq -c $${RELAY_MAX_CONCURRENT:-2} -q youtube_relay_$${STREAM_NODE_SLUG} -e $${RAILS_ENV:-development}
hostname: ${STREAM_NODE_SLUG:-ingest-lab-01}
environment: environment:
RAILS_ENV: development RAILS_ENV: development
BUNDLE_WITHOUT: "" BUNDLE_WITHOUT: ""
@@ -131,6 +260,17 @@ services:
JWT_SECRET: ${JWT_SECRET:-matchlivetv_jwt_dev_secret} JWT_SECRET: ${JWT_SECRET:-matchlivetv_jwt_dev_secret}
YOUTUBE_CLIENT_ID: ${YOUTUBE_CLIENT_ID:-} YOUTUBE_CLIENT_ID: ${YOUTUBE_CLIENT_ID:-}
YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-} YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-}
YOUTUBE_RELAY_WORKER: "1"
STREAM_NODE_SLUG: ${STREAM_NODE_SLUG:-ingest-lab-01}
STREAM_NODE_LOCAL_RTMP_URL: rtmp://mediamtx_lab:1935
STREAM_NODE_LOCAL_HLS_URL: http://mediamtx_lab:8888
STREAM_NODE_REMOTE_SSH: ${STREAM_NODE_REMOTE_SSH:-}
STREAM_NODE_SSH_KEY: ${STREAM_NODE_SSH_KEY:-/root/.ssh/id_ed25519}
STREAM_NODE_RELAY_AGENT_URL: ${STREAM_NODE_RELAY_AGENT_URL:-}
STREAM_NODE_AGENT_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret}
MEDIAMTX_INTERNAL_RTMP_URL: rtmp://mediamtx_lab:1935
MEDIAMTX_HLS_URL: http://mediamtx_lab:8888
RELAY_MAX_CONCURRENT: ${RELAY_MAX_CONCURRENT:-2}
RECORDINGS_PATH: /recordings RECORDINGS_PATH: /recordings
REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-http://garage:3900} REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-http://garage:3900}
REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays} REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays}
@@ -171,6 +311,7 @@ volumes:
postgres_data: postgres_data:
redis_data: redis_data:
recordings: recordings:
recordings_lab:
bundle_cache: bundle_cache:
garage_meta: garage_meta:
garage_data: garage_data:
+40 -13
View File
@@ -1,5 +1,5 @@
#cloud-config #cloud-config
# Nodo stream Hetzner: MediaMTX + ffmpeg. # Nodo stream Hetzner: MediaMTX + ffmpeg + relay-agent (YouTube sul nodo).
# HCLOUD_USER_DATA_FILE=.../infra/stream-node/cloud-init.yaml # HCLOUD_USER_DATA_FILE=.../infra/stream-node/cloud-init.yaml
package_update: true package_update: true
@@ -8,14 +8,14 @@ packages:
- ffmpeg - ffmpeg
- curl - curl
- apparmor - apparmor
- python3
- fonts-dejavu-core
write_files: write_files:
- path: /opt/stream-node/mediamtx.yml - path: /opt/stream-node/mediamtx.yml
permissions: "0644" permissions: "0644"
content: | content: |
logLevel: info logLevel: info
# Allinea a infra/mediamtx.yml: API raggiungibile anche da IP non-localhost
# (in prod restringere via WireGuard / firewall Hetzner).
authInternalUsers: authInternalUsers:
- user: any - user: any
pass: pass:
@@ -34,34 +34,61 @@ write_files:
hlsAddress: :8888 hlsAddress: :8888
paths: paths:
all_others: all_others:
- path: /opt/stream-node/relay-agent.service
permissions: "0644"
content: |
[Unit]
Description=Match Live TV YouTube relay agent
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=-/opt/stream-node/agent.env
ExecStart=/usr/bin/python3 /opt/stream-node/relay-agent.py
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
- path: /opt/stream-node/agent.env
permissions: "0600"
content: |
STREAM_NODE_AGENT_SECRET=mediamtx_webhook_dev_secret
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/bootstrap.sh - path: /opt/stream-node/bootstrap.sh
permissions: "0755" permissions: "0755"
content: | content: |
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
mkdir -p /recordings /slates mkdir -p /recordings /slates /var/log
systemctl enable --now docker systemctl enable --now docker
# aspetta il socket docker
for i in $(seq 1 60); do for i in $(seq 1 60); do
if docker info >/dev/null 2>&1; then break; fi if docker info >/dev/null 2>&1; then break; fi
sleep 2 sleep 2
done done
docker pull bluenviron/mediamtx:latest docker pull bluenviron/mediamtx:latest
docker rm -f mediamtx 2>/dev/null || true docker rm -f mediamtx 2>/dev/null || true
# Slate offline (alwaysAvailable) — richiesta da Mediamtx::Client#create_path. FONT=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf
# MediaMTX 1.20+: AAC del publisher RTMP deve matchare la slate. # Copertina alwaysAvailable (NON nera): resta in HLS/YouTube se l'app cade.
# Allineato ad app Android (BroadcastConfig: 48 kHz mono) e infra/scripts/generate_slate.sh. ffmpeg -y -f lavfi -i color=c=0x0a0a0e:s=1280x720:r=30:d=30 \
mkdir -p /slates -f lavfi -i anullsrc=r=48000:cl=mono \
ffmpeg -y -f lavfi -i color=c=black:s=1280x720:d=2 -f lavfi -i anullsrc=r=48000:cl=mono \ -vf "drawtext=fontfile=${FONT}:text='Match Live TV':fontsize=64:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2-48,drawtext=fontfile=${FONT}:text='Trasmissione in pausa':fontsize=36:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2+36" \
-c:v libx264 -t 2 -pix_fmt yuv420p -c:a aac -ac 1 -ar 48000 -shortest /slates/offline.mp4 -c:v libx264 -pix_fmt yuv420p -profile:v baseline -level 3.1 \
# Debian cloud image: docker.io senza apparmor_parser → serve unconfined o pkg apparmor -x264-params "keyint=30:min-keyint=30:scenecut=0:bframes=0" \
-g 30 -keyint_min 30 -preset fast \
-c:a aac -b:a 128k -ac 1 -ar 48000 -shortest /slates/offline.mp4
docker run -d --name mediamtx --restart unless-stopped --network host \ docker run -d --name mediamtx --restart unless-stopped --network host \
--security-opt apparmor=unconfined \ --security-opt apparmor=unconfined \
-v /opt/stream-node/mediamtx.yml:/mediamtx.yml:ro \ -v /opt/stream-node/mediamtx.yml:/mediamtx.yml:ro \
-v /recordings:/recordings \ -v /recordings:/recordings \
-v /slates:/slates:ro \ -v /slates:/slates:ro \
bluenviron/mediamtx:latest /mediamtx.yml bluenviron/mediamtx:latest /mediamtx.yml
# smoke locale if [[ -f /opt/stream-node/relay-agent.py ]]; then
cp /opt/stream-node/relay-agent.service /etc/systemd/system/mltv-relay-agent.service
systemctl daemon-reload
systemctl enable --now mltv-relay-agent.service
fi
for i in $(seq 1 30); do for i in $(seq 1 30); do
if curl -fsS http://127.0.0.1:9997/v3/paths/list >/dev/null; then if curl -fsS http://127.0.0.1:9997/v3/paths/list >/dev/null; then
echo "mediamtx ready" | tee /opt/stream-node/ready echo "mediamtx ready" | tee /opt/stream-node/ready
+201
View File
@@ -0,0 +1,201 @@
#!/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()
@@ -37,10 +37,26 @@ class E2EWizardFlowTest {
@Test @Test
fun login_newMatch_wizard_reachesBroadcastScreen() { fun login_newMatch_wizard_reachesBroadcastScreen() {
waitForAnyText(s(R.string.login_email), su(R.string.login_submit), timeoutMs = 45_000) waitForAnyText(
s(R.string.login_email),
su(R.string.login_submit),
"Email",
"ACCEDI",
"LOG IN",
su(R.string.matches_new),
"NUOVA PARTITA",
"NEW MATCH",
timeoutMs = 45_000,
)
if (!hasMatchList()) {
fillLogin() fillLogin()
waitForText(su(R.string.matches_new), timeoutMs = 45_000) }
tapClickableText(su(R.string.matches_new)) if (!hasMatchList()) {
tapLoginSubmit()
SystemClock.sleep(4000)
}
waitForAnyText(su(R.string.matches_new), "NUOVA PARTITA", "NEW MATCH", timeoutMs = 60_000)
tapFirstAvailable(su(R.string.matches_new), "NUOVA PARTITA", "NEW MATCH")
waitForText(s(R.string.sheet_quick_option), timeoutMs = 15_000) waitForText(s(R.string.sheet_quick_option), timeoutMs = 15_000)
tapClickableText(s(R.string.sheet_quick_option)) tapClickableText(s(R.string.sheet_quick_option))
waitForText(s(R.string.wizard_step_title_match), timeoutMs = 45_000) waitForText(s(R.string.wizard_step_title_match), timeoutMs = 45_000)
@@ -48,20 +64,46 @@ class E2EWizardFlowTest {
tapClickableText(s(R.string.wizard_action_next)) tapClickableText(s(R.string.wizard_action_next))
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)
scrollDown() selectYoutubeIfRequested()
tapClickableText(s(R.string.wizard_action_next)) val onNetworkStep = {
waitForText(s(R.string.wizard_step_title_network), timeoutMs = 45_000) device.hasObject(By.text(s(R.string.wizard_network_test_start_label))) ||
device.hasObject(By.text("AVVIA TEST RETE")) ||
device.hasObject(By.text("START NETWORK TEST"))
}
repeat(4) {
if (onNetworkStep()) return@repeat
runCatching { tapClickableText(s(R.string.wizard_action_next)) }
if (onNetworkStep()) return@repeat
// AVANTI è in fondo alla colonna: uno swipe verso l'alto lo riporta in vista.
scrollUp()
SystemClock.sleep(1_200)
}
waitForAnyText(
s(R.string.wizard_network_test_start_label),
"AVVIA TEST RETE",
"START NETWORK TEST",
timeoutMs = 45_000,
)
waitForText(s(R.string.wizard_network_test_start_label), timeoutMs = 30_000) waitForText(s(R.string.wizard_network_test_start_label), timeoutMs = 30_000)
tapClickableText(s(R.string.wizard_network_test_start_label)) tapClickableText(s(R.string.wizard_network_test_start_label))
waitForText(s(R.string.wizard_action_start), timeoutMs = 30_000) waitForText(s(R.string.wizard_action_start), timeoutMs = 30_000)
waitUntilEnabled(s(R.string.wizard_action_start), timeoutMs = 25_000) waitUntilEnabled(s(R.string.wizard_action_start), timeoutMs = 25_000)
scrollDown() if (wantsYoutube()) {
tapClickableText(s(R.string.wizard_action_start)) waitForTextContaining("youtube.com", "youtu.be", timeoutMs = 60_000)
}
tapStartLive()
waitForAnyText( waitForAnyText(
s(R.string.broadcast_status_live), s(R.string.broadcast_status_live),
s(R.string.broadcast_status_connecting), s(R.string.broadcast_status_connecting),
s(R.string.broadcast_status_reconnecting), s(R.string.broadcast_status_reconnecting),
timeoutMs = 60_000, s(R.string.broadcast_close_set_button),
s(R.string.score_action_close_set),
s(R.string.broadcast_terminate_cd),
"IN DIRETTA",
"LIVE",
"CONNESSIONE…",
"CONNECTING…",
timeoutMs = 90_000,
) )
// CLOSE SET è un SideIconButton: in hierarchy compare come content-desc, non sempre come text. // CLOSE SET è un SideIconButton: in hierarchy compare come content-desc, non sempre come text.
assertTrue( assertTrue(
@@ -73,6 +115,9 @@ class E2EWizardFlowTest {
s(R.string.broadcast_terminate_cd), s(R.string.broadcast_terminate_cd),
), ),
) )
// Tiene la diretta accesa per visione browser / secondo emulatore.
val holdMs = InstrumentationRegistry.getArguments().getString("holdLiveMs")?.toLongOrNull() ?: 0L
if (holdMs > 0) SystemClock.sleep(holdMs)
} }
private fun grantRuntimePermissions() { private fun grantRuntimePermissions() {
@@ -94,14 +139,108 @@ class E2EWizardFlowTest {
} }
private fun fillLogin() { private fun fillLogin() {
val fields = device.wait(Until.findObjects(By.clazz("android.widget.EditText")), 15_000) repeat(3) {
if (fields.size < 2) error("Campi login non trovati (${fields.size})") val email = waitForRes("login_email")
pasteIntoField(fields[0], "coach@matchlivetv.test") val password = waitForRes("login_password")
pasteIntoField(fields[1], "Password123") pasteIntoField(email, "coach@matchlivetv.test")
pasteIntoField(password, "Password123")
device.pressKeyCode(KeyEvent.KEYCODE_ENTER) device.pressKeyCode(KeyEvent.KEYCODE_ENTER)
// Preferisci il bottone (ultimo match uppercase), non il titolo.
tapLastMatchingText(su(R.string.login_submit))
device.waitForIdle() device.waitForIdle()
SystemClock.sleep(1500)
tapLoginSubmit()
device.waitForIdle()
SystemClock.sleep(4000)
if (hasMatchList()) return
}
}
private fun wantsYoutube(): Boolean =
InstrumentationRegistry.getArguments().getString("platform").equals("youtube", ignoreCase = true)
private fun tapStartLive() {
repeat(6) {
val tagged = device.findObject(By.res("wizard_start_live"))
?: device.findObject(By.res("$pkg:id/wizard_start_live"))
if (tagged != null && tagged.isEnabled) {
val b = tagged.visibleBounds
if (b.height() >= 20 && b.bottom <= device.displayHeight && b.top >= 0) {
tagged.click()
device.waitForIdle()
return
}
}
val label = s(R.string.wizard_action_start)
val nodes = device.findObjects(By.text(label))
val target = nodes.lastOrNull { it.isClickable && it.isEnabled }
if (target != null && target.visibleBounds.height() >= 20) {
target.click()
device.waitForIdle()
return
}
val x = device.displayWidth / 2
device.swipe(x, (device.displayHeight * 0.88).toInt(), x, (device.displayHeight * 0.62).toInt(), 24)
device.waitForIdle()
SystemClock.sleep(300)
}
tapClickableText(s(R.string.wizard_action_start))
}
private fun selectYoutubeIfRequested() {
if (!wantsYoutube()) return
waitForAnyText("YouTube Live", timeoutMs = 20_000)
tapClickableText("YouTube Live")
waitForAnyText(
s(R.string.wizard_transmission_unlisted_label),
"NON IN ELENCO",
"UNLISTED",
timeoutMs = 10_000,
)
tapFirstAvailable(s(R.string.wizard_transmission_unlisted_label), "NON IN ELENCO", "UNLISTED")
device.waitForIdle()
SystemClock.sleep(400)
}
private fun hasMatchList(): Boolean =
device.hasObject(By.text(su(R.string.matches_new))) ||
device.hasObject(By.text("NUOVA PARTITA")) ||
device.hasObject(By.text("NEW MATCH"))
private fun waitForRes(tag: String): UiObject2 {
val obj = device.wait(Until.findObject(By.res(tag)), 10_000)
?: device.wait(Until.findObject(By.res("$pkg:id/$tag")), 2_000)
?: error("Nodo non trovato: $tag")
return obj
}
private fun tapLoginSubmit() {
val btn = device.wait(Until.findObject(By.res("login_submit")), 3_000)
?: device.wait(Until.findObject(By.res("$pkg:id/login_submit")), 1_000)
if (btn != null) {
val bounds = btn.visibleBounds
device.click(bounds.centerX(), bounds.centerY())
device.waitForIdle()
return
}
if (hasMatchList()) return
val nodes = device.findObjects(By.text(su(R.string.login_submit))) +
device.findObjects(By.text("ACCEDI")) +
device.findObjects(By.text("LOG IN"))
val target = nodes.lastOrNull { it.isClickable } ?: nodes.lastOrNull()
if (target != null) {
target.click()
device.waitForIdle()
return
}
}
private fun tapFirstAvailable(vararg texts: String) {
for (text in texts) {
if (device.hasObject(By.text(text))) {
tapClickableText(text)
return
}
}
error("Nessun testo cliccabile: ${texts.joinToString()}")
} }
private fun pasteIntoField(field: UiObject2, value: String) { private fun pasteIntoField(field: UiObject2, value: String) {
@@ -118,6 +257,18 @@ class E2EWizardFlowTest {
return obj!! return obj!!
} }
private fun waitForTextContaining(vararg needles: String, timeoutMs: Long) {
val deadline = SystemClock.elapsedRealtime() + timeoutMs
while (SystemClock.elapsedRealtime() < deadline) {
for (needle in needles) {
if (device.hasObject(By.textContains(needle))) return
}
scrollDown(1)
SystemClock.sleep(250)
}
error("Nessun testo contenente: ${needles.joinToString()}")
}
private fun waitForAnyText(vararg texts: String, timeoutMs: Long) { private fun waitForAnyText(vararg texts: String, timeoutMs: Long) {
val deadline = SystemClock.elapsedRealtime() + timeoutMs val deadline = SystemClock.elapsedRealtime() + timeoutMs
while (SystemClock.elapsedRealtime() < deadline) { while (SystemClock.elapsedRealtime() < deadline) {
@@ -158,13 +309,12 @@ class E2EWizardFlowTest {
} }
private fun tapClickableText(text: String) { private fun tapClickableText(text: String) {
device.findObject(By.text(text).clickable(true))?.let { node -> repeat(6) {
node.click() val clickable = device.findObject(By.text(text).clickable(true))
device.waitForIdle() val label = clickable ?: device.findObject(By.text(text))
return if (label != null) {
} val b = label.visibleBounds
val label = device.wait(Until.findObject(By.text(text)), 15_000) if (b.height() >= 16 && b.bottom <= device.displayHeight && b.top >= 0) {
?: error("Testo non trovato: $text")
var node: UiObject2? = label var node: UiObject2? = label
for (i in 0 until 6) { for (i in 0 until 6) {
val current = node ?: break val current = node ?: break
@@ -177,6 +327,13 @@ class E2EWizardFlowTest {
} }
label.click() label.click()
device.waitForIdle() device.waitForIdle()
return
}
}
scrollDown(1)
device.waitForIdle()
}
error("Testo non cliccabile visibile: $text")
} }
private fun waitUntilEnabled(text: String, timeoutMs: Long) { private fun waitUntilEnabled(text: String, timeoutMs: Long) {
@@ -206,4 +363,15 @@ class E2EWizardFlowTest {
SystemClock.sleep(300) SystemClock.sleep(300)
} }
} }
private fun scrollUp(steps: Int = 1) {
val centerX = device.displayWidth / 2
val startY = (device.displayHeight * 0.35).toInt()
val endY = (device.displayHeight * 0.75).toInt()
repeat(steps) {
device.swipe(centerX, startY, centerX, endY, 24)
device.waitForIdle()
SystemClock.sleep(300)
}
}
} }
@@ -26,6 +26,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -263,7 +264,9 @@ fun StepNetworkTestScreen(
starting = false starting = false
} }
}, },
modifier = Modifier.weight(2f), modifier = Modifier
.weight(2f)
.testTag("wizard_start_live"),
) )
} }
} }
@@ -22,6 +22,15 @@ class RtmpIngestUrlTest {
assertEquals("rtmp://10.0.2.2:1935/live/match_abc", resolved) assertEquals("rtmp://10.0.2.2:1935/live/match_abc", resolved)
} }
@Test
fun preservesLabIngestPort() {
val resolved = RtmpIngestUrl.resolve(
"rtmp://127.0.0.1:11935/live/match_abc",
apiBaseUrl = "http://10.0.2.2:3000",
)
assertEquals("rtmp://10.0.2.2:11935/live/match_abc", resolved)
}
@Test @Test
fun keepsProductionHost() { fun keepsProductionHost() {
val input = "rtmp://stream.matchlivetv.it:1935/live/match_abc" val input = "rtmp://stream.matchlivetv.it:1935/live/match_abc"
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Installa slate + relay-agent su un CPX già provisionato (una tantum, non per ogni diretta).
# STREAM_NODE_HOST=116.203.217.194 bash scripts/deploy/bootstrap_stream_node.sh
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
HOST="${STREAM_NODE_HOST:?Imposta STREAM_NODE_HOST (IPv4 pubblico del CPX)}"
SSH_KEY="${STREAM_NODE_SSH_KEY_HOST:-$HOME/.ssh/matchlivetv_stream_hetzner}"
SECRET="${STREAM_NODE_AGENT_SECRET:-mediamtx_webhook_dev_secret}"
SSH=(ssh -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o IdentitiesOnly=yes "root@$HOST")
SCP=(scp -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o IdentitiesOnly=yes)
if [[ ! -f "$ROOT/infra/slates/offline.mp4" ]]; then
(cd "$ROOT/infra" && bash scripts/generate_slate.sh)
fi
"${SSH[@]}" "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y python3 fonts-dejavu-core ffmpeg >/dev/null"
"${SSH[@]}" "mkdir -p /opt/stream-node /slates /var/log"
"${SCP[@]}" "$ROOT/infra/stream-node/relay-agent.py" "root@$HOST:/opt/stream-node/relay-agent.py"
"${SCP[@]}" "$ROOT/infra/slates/offline.mp4" "root@$HOST:/slates/offline.mp4"
"${SSH[@]}" "chmod 755 /opt/stream-node/relay-agent.py"
"${SSH[@]}" "cat >/opt/stream-node/agent.env <<EOF
STREAM_NODE_AGENT_SECRET=${SECRET}
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
EOF
chmod 600 /opt/stream-node/agent.env"
"${SSH[@]}" "cat >/etc/systemd/system/mltv-relay-agent.service <<'EOF'
[Unit]
Description=Match Live TV YouTube relay agent
After=network-online.target
[Service]
Type=simple
EnvironmentFile=/opt/stream-node/agent.env
ExecStart=/usr/bin/python3 /opt/stream-node/relay-agent.py
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
EOF
pkill -f 'ffmpeg -nostdin' || true
systemctl daemon-reload
systemctl enable --now mltv-relay-agent.service
systemctl restart mltv-relay-agent.service
curl -fsS http://127.0.0.1:9100/health"
echo "bootstrap ok host=${HOST}"
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Avvia/ferma un worker ffmpeg per un nodo lab, riusando l'immagine Sidekiq di collaudo.
# Uso:
# STREAM_NODE_SLUG=ingest-lab-01 bash scripts/deploy/collaudo_relay_worker.sh up
# STREAM_NODE_SLUG=ingest-lab-01 bash scripts/deploy/collaudo_relay_worker.sh down
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
SLUG="${STREAM_NODE_SLUG:?Imposta STREAM_NODE_SLUG (es. ingest-lab-01)}"
ACTION="${1:-up}"
NAME="infra-sidekiq-relay-${SLUG}"
COMPOSE=(bash "$ROOT/scripts/deploy/collaudo_compose.sh")
case "$ACTION" in
up)
docker rm -f "$NAME" >/dev/null 2>&1 || true
"${COMPOSE[@]}" run -d --name "$NAME" --no-deps \
-e STREAM_NODE_SLUG="$SLUG" \
-e YOUTUBE_RELAY_WORKER=1 \
-e HOSTNAME="$SLUG" \
-e STREAM_NODE_LOCAL_RTMP_URL=rtmp://mediamtx:1935 \
-e STREAM_NODE_LOCAL_HLS_URL=http://mediamtx:8888 \
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}"
;;
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-lab-01 $0 up|down|logs" >&2
exit 1
;;
esac
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Worker coda youtube_relay_<slug>: parla con l'agent sul CPX (ffmpeg gira lì).
#
# Lab locale:
# STREAM_NODE_SLUG=ingest-lab-01 bash scripts/dev_sidekiq_relay.sh up
#
# CPX:
# STREAM_NODE_SLUG=ingest-01 STREAM_NODE_RELAY_AGENT_URL=http://1.2.3.4:9100 bash scripts/dev_sidekiq_relay.sh up
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SLUG="${STREAM_NODE_SLUG:-ingest-lab-01}"
ACTION="${1:-up}"
NAME="infra-sidekiq-relay-${SLUG}"
export STREAM_NODE_SLUG="$SLUG"
cd "$ROOT/infra"
case "$ACTION" in
up)
docker rm -f "$NAME" >/dev/null 2>&1 || true
extra=()
if [[ -n "${STREAM_NODE_RELAY_AGENT_URL:-}" ]]; then
extra+=(
-e STREAM_NODE_RELAY_AGENT_URL="$STREAM_NODE_RELAY_AGENT_URL"
-e STREAM_NODE_AGENT_SECRET="${STREAM_NODE_AGENT_SECRET:-mediamtx_webhook_dev_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
docker compose --profile stream-node run -d --no-deps --name "$NAME" \
-e STREAM_NODE_SLUG="$SLUG" \
-e HOSTNAME="$SLUG" \
-e YOUTUBE_RELAY_WORKER=1 \
-e RELAY_MAX_CONCURRENT="${RELAY_MAX_CONCURRENT:-1}" \
"${extra[@]}" \
sidekiq_relay
echo "relay worker up name=${NAME} queue=youtube_relay_${SLUG} agent=${STREAM_NODE_RELAY_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 $0 up|down|logs" >&2
exit 1
;;
esac