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:
@@ -4,11 +4,20 @@ module MediamtxPlayback
|
||||
private
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def mediamtx_playback_path_name(session)
|
||||
@@ -28,7 +37,7 @@ module MediamtxPlayback
|
||||
end
|
||||
|
||||
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"]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,7 +20,8 @@ class HlsProxyController < ActionController::Base
|
||||
def proxy_mediamtx_path(upstream_path)
|
||||
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?
|
||||
cookie = request.headers["Cookie"].presence || "cookieCheck=1"
|
||||
|
||||
@@ -30,7 +31,7 @@ class HlsProxyController < ActionController::Base
|
||||
if response.status.in?([301, 302, 307, 308])
|
||||
location = response.headers["location"].to_s
|
||||
cookie = cookie_from_set_header(response.headers["set-cookie"]).presence || cookie
|
||||
upstream = resolve_upstream_url(location)
|
||||
upstream = resolve_upstream_url(location, origin)
|
||||
next
|
||||
end
|
||||
|
||||
@@ -43,10 +44,21 @@ class HlsProxyController < ActionController::Base
|
||||
head :bad_gateway
|
||||
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://")
|
||||
|
||||
base = MatchLiveTv.mediamtx_hls_url.sub(%r{/$}, "")
|
||||
base = origin.to_s.sub(%r{/$}, "")
|
||||
location.start_with?("/") ? "#{base}#{location}" : "#{base}/#{location}"
|
||||
end
|
||||
|
||||
|
||||
@@ -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
|
||||
queue_as Streams::YoutubeRelay::QUEUE
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ class YoutubeRelayStopJob < ApplicationJob
|
||||
return unless result == :wrong_host
|
||||
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
|
||||
|
||||
@@ -4,19 +4,33 @@ class YoutubeCredential < ApplicationRecord
|
||||
attr_encrypted :access_token,
|
||||
key: :encryption_key,
|
||||
attribute: "access_token_encrypted",
|
||||
mode: :single_iv_salt
|
||||
mode: :single_iv_and_salt,
|
||||
algorithm: "aes-256-cbc",
|
||||
iv: :encryption_iv
|
||||
attr_encrypted :refresh_token,
|
||||
key: :encryption_key,
|
||||
attribute: "refresh_token_encrypted",
|
||||
mode: :single_iv_salt
|
||||
mode: :single_iv_and_salt,
|
||||
algorithm: "aes-256-cbc",
|
||||
iv: :encryption_iv
|
||||
|
||||
def expired?
|
||||
expires_at.present? && expires_at < Time.current
|
||||
end
|
||||
|
||||
def usable?
|
||||
refresh_token.present?
|
||||
rescue ArgumentError, OpenSSL::Cipher::CipherError, NoMethodError
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def encryption_key
|
||||
Rails.application.secret_key_base[0, 32]
|
||||
end
|
||||
|
||||
def encryption_iv
|
||||
encryption_key[0, 16]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,8 +22,7 @@ module Mediamtx
|
||||
def create_path(session)
|
||||
path = session.mediamtx_path_name
|
||||
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe
|
||||
# solo la slate (schermo nero) in pausa/attesa.
|
||||
# YouTube: niente slate sul path camera (maschera il video al relay ffmpeg).
|
||||
# solo la slate in pausa/attesa. Slate resta accesa anche su YouTube (copertina se l'app cade).
|
||||
body = recording_body(session, enabled: false).merge(
|
||||
source: "publisher",
|
||||
overridePublisher: true
|
||||
@@ -76,7 +75,7 @@ module Mediamtx
|
||||
end
|
||||
|
||||
# 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:)
|
||||
path = session.mediamtx_path_name
|
||||
return true if always_available_remembered?(path, enabled: enabled)
|
||||
|
||||
@@ -19,7 +19,6 @@ module Mediamtx
|
||||
if @session.paused?
|
||||
# RTMP ancora connesso in pausa: non forzare live/reconnect.
|
||||
else
|
||||
enable_live_path_once!(@session)
|
||||
if @session.may_go_live?
|
||||
@session.go_live!
|
||||
@session.reload
|
||||
@@ -75,23 +74,10 @@ module Mediamtx
|
||||
@session.update!(timeout_job_id: job)
|
||||
end
|
||||
|
||||
# Compatibilità con webhook / controller.
|
||||
def self.schedule_youtube_pipeline!(session, force: false)
|
||||
Youtube::LivePipeline.schedule!(session, force: force)
|
||||
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)
|
||||
return if session.platform == "matchlivetv"
|
||||
|
||||
@@ -183,7 +169,7 @@ module Mediamtx
|
||||
key = format("youtube_relay:sched:%s", session.id)
|
||||
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
|
||||
|
||||
def redis
|
||||
|
||||
@@ -77,9 +77,8 @@ module Streams
|
||||
dns.upsert_a(hostname, ip)
|
||||
|
||||
simulated = instance.raw.is_a?(Hash) && (instance.raw[:simulated] || instance.raw["simulated"])
|
||||
api_base = simulated ? home.api_base_url : "http://#{private_ip}:9997"
|
||||
internal_rtmp = simulated ? home.internal_rtmp_url : "rtmp://#{private_ip}:1935"
|
||||
internal_hls = simulated ? home.internal_hls_url : "http://#{private_ip}:8888"
|
||||
urls = urls_for_node(role: role, home: home, hostname: hostname, simulated: simulated,
|
||||
private_ip: private_ip, public_ip: ip, use_node_hostname: use_node_hostname)
|
||||
|
||||
StreamNode.create!(
|
||||
slug: slug,
|
||||
@@ -88,11 +87,11 @@ module Streams
|
||||
status: "ready",
|
||||
provider: provider_name_for(cloud, role: role),
|
||||
provider_instance_id: instance.id,
|
||||
rtmp_base_url: use_node_hostname ? "rtmp://#{hostname}:1935" : home.rtmp_base_url,
|
||||
hls_base_url: use_node_hostname ? "https://#{hostname}/hls" : home.hls_base_url,
|
||||
api_base_url: api_base,
|
||||
internal_rtmp_url: internal_rtmp,
|
||||
internal_hls_url: internal_hls,
|
||||
rtmp_base_url: urls.fetch(:rtmp_base_url),
|
||||
hls_base_url: urls.fetch(:hls_base_url),
|
||||
api_base_url: urls.fetch(:api_base_url),
|
||||
internal_rtmp_url: urls.fetch(:internal_rtmp_url),
|
||||
internal_hls_url: urls.fetch(:internal_hls_url),
|
||||
max_publishers: max,
|
||||
max_relays: max,
|
||||
last_health_at: Time.current,
|
||||
@@ -105,6 +104,39 @@ module Streams
|
||||
)
|
||||
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)
|
||||
used = StreamNode.where("slug LIKE ?", "#{prefix}-%").pluck(:slug)
|
||||
n = 1
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
require "json"
|
||||
require "net/http"
|
||||
require "uri"
|
||||
|
||||
module Streams
|
||||
# Relay verso YouTube: legge RTMP/HLS da MediaMTX e inoltra su RTMPS (-c copy). Nessun overlay.
|
||||
# ffmpeg gira solo sui worker Sidekiq con YOUTUBE_RELAY_WORKER=1 (coda youtube_relay).
|
||||
# Relay verso YouTube: legge HLS da MediaMTX e inoltra su RTMPS (-c copy).
|
||||
# Sul nodo cloud ffmpeg è avviato dall'agent locale (immagine CPX) all'avvio diretta.
|
||||
class YoutubeRelay
|
||||
class Error < StandardError; end
|
||||
|
||||
@@ -8,6 +12,7 @@ module Streams
|
||||
OWNER_KEY = "youtube_relay:owner:%s"
|
||||
OWNED_SET = "youtube_relay:owned:%s"
|
||||
QUEUE = :youtube_relay
|
||||
QUEUE_PREFIX = "youtube_relay"
|
||||
|
||||
class << self
|
||||
def worker?
|
||||
@@ -18,18 +23,44 @@ module Streams
|
||||
ENV.fetch("RELAY_MAX_CONCURRENT", "4").to_i
|
||||
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)
|
||||
return unless worker?
|
||||
return enqueue_ensure!(session) unless worker? && node_matches?(session)
|
||||
|
||||
start_on_worker!(session)
|
||||
end
|
||||
|
||||
# Non cancella owner/pid qui: solo il worker owner deve killare ffmpeg.
|
||||
def stop(session)
|
||||
if worker? && owner_is_local?(session.id)
|
||||
if worker? && owner_is_local?(session.id) && node_matches?(session)
|
||||
stop_on_worker!(session)
|
||||
else
|
||||
YoutubeRelayStopJob.set(queue: QUEUE).perform_later(session.id)
|
||||
enqueue_stop!(session)
|
||||
end
|
||||
true
|
||||
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?
|
||||
|
||||
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.
|
||||
redis.ttl(format(OWNER_KEY, session_id)) > 30
|
||||
@@ -52,15 +83,22 @@ module Streams
|
||||
return if session.terminal?
|
||||
return if session.stream_key.blank?
|
||||
|
||||
if worker?
|
||||
if worker? && node_matches?(session)
|
||||
ensure_on_worker!(session)
|
||||
else
|
||||
YoutubeRelayEnsureJob.set(queue: QUEUE).perform_later(session.id)
|
||||
enqueue_ensure!(session)
|
||||
end
|
||||
end
|
||||
|
||||
def ensure_on_worker!(session)
|
||||
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 if session.terminal?
|
||||
return if session.stream_key.blank?
|
||||
@@ -68,7 +106,7 @@ module Streams
|
||||
return unless intake_available?(session)
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
@@ -78,8 +116,8 @@ module Streams
|
||||
end
|
||||
|
||||
if at_capacity?
|
||||
YoutubeRelayEnsureJob.set(wait: 5.seconds, queue: QUEUE).perform_later(session.id)
|
||||
Rails.logger.info("[YoutubeRelay] at capacity worker=#{worker_id} session=#{session.id} requeue")
|
||||
enqueue_ensure!(session, wait: 5.seconds)
|
||||
Rails.logger.info("[YoutubeRelay] at capacity worker=#{worker_id} node=#{local_node_slug} session=#{session.id} requeue")
|
||||
return :at_capacity
|
||||
end
|
||||
|
||||
@@ -109,7 +147,7 @@ module Streams
|
||||
return :noop
|
||||
end
|
||||
|
||||
terminate_pid(pid)
|
||||
terminate_pid(pid, session_id: session.id)
|
||||
clear_local_ownership(session.id)
|
||||
Rails.logger.info("[YoutubeRelay] stopped pid=#{pid} session=#{session.id} worker=#{worker_id}")
|
||||
:stopped
|
||||
@@ -140,26 +178,88 @@ module Streams
|
||||
|
||||
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)
|
||||
FileUtils.mkdir_p(File.dirname(log_path))
|
||||
intake_source = mediamtx_intake_source(session)
|
||||
output = "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}"
|
||||
|
||||
pid = Process.spawn(
|
||||
*youtube_ffmpeg_args(intake_source, output),
|
||||
%i[out err] => log_path,
|
||||
pgroup: true
|
||||
)
|
||||
Process.detach(pid)
|
||||
pid = if relay_agent_enabled?
|
||||
start_via_agent!(session)
|
||||
else
|
||||
spawn_ffmpeg!(youtube_ffmpeg_args(intake_source, output), log_path)
|
||||
end
|
||||
store_pid(session.id, pid)
|
||||
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)
|
||||
pid
|
||||
rescue Errno::ENOENT => e
|
||||
raise Error, "ffmpeg non disponibile: #{e.message}"
|
||||
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).
|
||||
# HLS (ADTS) → FLV richiede -bsf:a aac_adtstoasc; RTMP ha già ASC in FLV tags.
|
||||
def youtube_ffmpeg_args(intake_source, output)
|
||||
@@ -179,6 +279,9 @@ module Streams
|
||||
end
|
||||
|
||||
common_head + [
|
||||
"-reconnect", "1",
|
||||
"-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", "2",
|
||||
"-rw_timeout", "15000000",
|
||||
"-live_start_index", "-1",
|
||||
"-i", url,
|
||||
@@ -190,14 +293,29 @@ module Streams
|
||||
end
|
||||
|
||||
def mediamtx_intake_source(session)
|
||||
base = session.mediamtx_internal_rtmp_url
|
||||
rtmp_base, hls_base = intake_bases(session)
|
||||
if Mediamtx::PublisherOnline.active?(session)
|
||||
return [:rtmp, "#{base.chomp('/')}/#{session.mediamtx_path_name}"]
|
||||
return [:rtmp, "#{rtmp_base.chomp('/')}/#{session.mediamtx_path_name}"]
|
||||
end
|
||||
|
||||
path = session.mediamtx_path_name
|
||||
hls = session.mediamtx_internal_hls_url.chomp("/")
|
||||
[:hls, "#{hls}/#{path}/index.m3u8"]
|
||||
[:hls, "#{hls_base.chomp('/')}/#{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
|
||||
|
||||
def intake_available?(session)
|
||||
@@ -257,7 +375,9 @@ module Streams
|
||||
redis.get(format(REDIS_KEY, session_id))
|
||||
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")
|
||||
return false if stat.split[2] == "Z"
|
||||
|
||||
@@ -267,12 +387,26 @@ module Streams
|
||||
false
|
||||
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)
|
||||
sleep 0.5
|
||||
rescue Errno::ESRCH
|
||||
nil
|
||||
else
|
||||
return if relay_agent_enabled?
|
||||
|
||||
begin
|
||||
Process.kill("KILL", -pid.to_i)
|
||||
rescue Errno::ESRCH
|
||||
|
||||
@@ -32,7 +32,8 @@ module Youtube
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def effective_channel
|
||||
|
||||
@@ -63,7 +63,6 @@ module Youtube
|
||||
return
|
||||
end
|
||||
|
||||
Mediamtx::Client.for_session(session).set_always_available(session, enabled: false)
|
||||
session.go_live! if session.may_go_live?
|
||||
session.reconnect! if session.reconnecting? && session.may_reconnect?
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ module Youtube
|
||||
when "matchlivetv_light"
|
||||
PlatformCredential.configured?
|
||||
when "team"
|
||||
@team.club.youtube_credential.present? || PlatformCredential.configured?
|
||||
@team.club.youtube_credential&.usable? || PlatformCredential.configured?
|
||||
else
|
||||
false
|
||||
end
|
||||
@@ -31,7 +31,7 @@ module Youtube
|
||||
if @mode == "matchlivetv_light"
|
||||
PlatformCredential.configured?
|
||||
elsif @mode == "team"
|
||||
@team.club.youtube_credential.blank? && PlatformCredential.configured?
|
||||
!@team.club.youtube_credential&.usable? && PlatformCredential.configured?
|
||||
else
|
||||
false
|
||||
end
|
||||
@@ -46,7 +46,7 @@ module Youtube
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
</div>
|
||||
<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="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>
|
||||
|
||||
<script>
|
||||
@@ -63,25 +64,26 @@
|
||||
const offlineMsg = document.getElementById("offline-msg");
|
||||
const pausedMsg = document.getElementById("paused-msg");
|
||||
const awaitingMsg = document.getElementById("awaiting-msg");
|
||||
const reconnectingMsg = document.getElementById("reconnecting-msg");
|
||||
const playHint = document.getElementById("play-hint");
|
||||
const streamBadge = document.getElementById("live-stream-badge");
|
||||
|
||||
function syncStreamBadge(data) {
|
||||
if (!streamBadge) return;
|
||||
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";
|
||||
return;
|
||||
}
|
||||
const paused = !!(data.paused || data.status === "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";
|
||||
} 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";
|
||||
} 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";
|
||||
}
|
||||
}
|
||||
@@ -282,9 +284,10 @@
|
||||
if (onAir || sessionLive) {
|
||||
offlineMsg.hidden = true;
|
||||
if (!publisherOnline && showingCover && !paused) {
|
||||
awaitingMsg.hidden = false;
|
||||
awaitingMsg.textContent = "<%= j t("live.show.js_reconnecting_awaiting") %>";
|
||||
awaitingMsg.hidden = true;
|
||||
if (reconnectingMsg) reconnectingMsg.hidden = false;
|
||||
} else {
|
||||
if (reconnectingMsg) reconnectingMsg.hidden = true;
|
||||
awaitingMsg.hidden = !awaitingSignal;
|
||||
}
|
||||
pausedMsg.hidden = !paused;
|
||||
@@ -299,6 +302,7 @@
|
||||
} else {
|
||||
offlineMsg.hidden = false;
|
||||
awaitingMsg.hidden = true;
|
||||
if (reconnectingMsg) reconnectingMsg.hidden = true;
|
||||
pausedMsg.hidden = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
:concurrency: 5
|
||||
:queues:
|
||||
- critical
|
||||
- youtube_relay_home
|
||||
- youtube_relay
|
||||
- default
|
||||
|
||||
Generated
+1
-1
@@ -305,8 +305,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
|
||||
t.string "regia_token_digest"
|
||||
t.datetime "regia_token_expires_at"
|
||||
t.uuid "stream_node_id"
|
||||
t.boolean "audio_muted", default: false, 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 ["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
|
||||
|
||||
@@ -29,4 +29,126 @@ namespace :streams do
|
||||
puts Streams::DnsProviders::Lab.new.hosts_file_snippet
|
||||
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
|
||||
|
||||
@@ -4,13 +4,13 @@ require "rails_helper"
|
||||
|
||||
RSpec.describe YoutubeRelayStopJob, type: :job 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(Streams::YoutubeRelay).to receive(:worker?).and_return(true)
|
||||
allow(Streams::YoutubeRelay).to receive(:stop_on_worker!).and_return(:wrong_host)
|
||||
|
||||
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)
|
||||
|
||||
described_class.new.perform(session.id, 0)
|
||||
|
||||
@@ -4,6 +4,7 @@ require "rails_helper"
|
||||
|
||||
RSpec.describe "HLS proxy", type: :request do
|
||||
let(:mediamtx_hls) { "http://mediamtx.test:8888" }
|
||||
let(:session_id) { "cc7e90b2-c672-4401-b5d3-51fdcdc7a214" }
|
||||
let(:playlist) do
|
||||
<<~M3U8
|
||||
#EXTM3U
|
||||
@@ -13,24 +14,89 @@ RSpec.describe "HLS proxy", type: :request do
|
||||
/live/match_#{session_id}/segment0.ts
|
||||
M3U8
|
||||
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
|
||||
allow(MatchLiveTv).to receive(:mediamtx_hls_url).and_return(mediamtx_hls)
|
||||
end
|
||||
|
||||
describe "GET /live/match_:session_id/*" do
|
||||
it "proxies MediaMTX redirect and rewrites playlist paths to /hls/" do
|
||||
stub_request(:get, "#{mediamtx_hls}/live/match_#{session_id}/index.m3u8")
|
||||
.to_return(status: 302, headers: { "Location" => "/live/match_#{session_id}/index.m3u8?cookieCheck=1" })
|
||||
stub_request(:get, "#{mediamtx_hls}/live/match_#{session_id}/index.m3u8?cookieCheck=1")
|
||||
.to_return(status: 200, body: playlist, headers: { "Content-Type" => "application/vnd.apple.mpegurl" })
|
||||
it "rewrites playlist paths to /hls/" do
|
||||
stub_hls_get(
|
||||
"#{mediamtx_hls}/live/match_#{session_id}/index.m3u8",
|
||||
status: 200,
|
||||
body: playlist,
|
||||
headers: { "content-type" => "application/vnd.apple.mpegurl" }
|
||||
)
|
||||
|
||||
get "/live/match_#{session_id}/index.m3u8"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
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
|
||||
|
||||
@@ -225,4 +225,34 @@ RSpec.describe "Public regia", type: :request do
|
||||
expect(tel["thermal_state"]).to eq("nominal")
|
||||
expect(tel).to have_key("publisher_online")
|
||||
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
|
||||
|
||||
@@ -23,6 +23,7 @@ RSpec.describe "Streams::NodeProvisioner cloud" do
|
||||
ENV["HLS_PUBLIC_URL"] = "https://home.example/hls"
|
||||
ENV["STREAM_CLOUD_DNS_SUFFIX"] = "mltv-stream.net"
|
||||
ENV["STREAM_CLOUD_MAX_PUBLISHERS"] = "4"
|
||||
ENV["STREAM_CLOUD_PUBLIC_CONTROL"] = "0"
|
||||
|
||||
node = Streams::NodeProvisioner.new(cloud: cloud, dns: dns).provision_cloud!
|
||||
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.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.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")
|
||||
ensure
|
||||
%w[
|
||||
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) }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -27,7 +27,8 @@ RSpec.describe Streams::NodeProvisioner do
|
||||
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
|
||||
"MEDIAMTX_RTMP_URL" => "rtmp://ingest-home.example:1935",
|
||||
"HLS_PUBLIC_URL" => "https://ingest-home.example/hls",
|
||||
"STREAM_LAB_MAX_PUBLISHERS" => "2"
|
||||
"STREAM_LAB_MAX_PUBLISHERS" => "2",
|
||||
"MEDIAMTX_LAB_API_URL" => nil
|
||||
) do
|
||||
provisioner = described_class.new
|
||||
node = provisioner.provision_lab!
|
||||
@@ -45,13 +46,35 @@ RSpec.describe Streams::NodeProvisioner do
|
||||
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
|
||||
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"
|
||||
"HLS_PUBLIC_URL" => "https://ingest-home.example/hls",
|
||||
"MEDIAMTX_LAB_API_URL" => nil
|
||||
) do
|
||||
node = described_class.new.provision_lab!
|
||||
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
|
||||
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("-reconnect", "1")
|
||||
expect(cmd).not_to include("-b:a")
|
||||
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
|
||||
let(:redis) { Redis.new(url: ENV.fetch("REDIS_URL", "redis://redis:6379/0")) }
|
||||
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_id).and_return("worker-a")
|
||||
allow(described_class).to receive(:max_concurrent).and_return(1)
|
||||
allow(described_class).to receive(:local_node_slug).and_return("home")
|
||||
redis.flushdb
|
||||
end
|
||||
|
||||
@@ -40,7 +88,8 @@ RSpec.describe Streams::YoutubeRelay do
|
||||
platform: "youtube",
|
||||
terminal?: false,
|
||||
stream_key: "yt-key",
|
||||
status: "live"
|
||||
status: "live",
|
||||
stream_node: nil
|
||||
)
|
||||
end
|
||||
|
||||
@@ -52,15 +101,91 @@ RSpec.describe Streams::YoutubeRelay do
|
||||
expect(redis.get(format(Streams::YoutubeRelay::OWNER_KEY, session_id))).to eq("worker-b")
|
||||
end
|
||||
|
||||
it "requeues ensure when at capacity" do
|
||||
it "requeues ensure on the node queue when at capacity" do
|
||||
other_id = SecureRandom.uuid
|
||||
redis.sadd(format(Streams::YoutubeRelay::OWNED_SET, "worker-a"), other_id)
|
||||
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)
|
||||
)
|
||||
|
||||
expect(described_class.ensure_on_worker!(session_double)).to eq(:at_capacity)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user