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,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