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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user