Corregge cloud-init montato, PublisherOnline multi-nodo e E2E locale-aware.
Senza volume stream-node i nodi Hetzner nascevano senza MediaMTX; sync live usa ora Client.for_session e rtmpconns (MediaMTX 1.20). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -105,6 +105,16 @@ module Mediamtx
|
||||
[]
|
||||
end
|
||||
|
||||
def list_rtmp_conns
|
||||
response = @conn.get("/v3/rtmpconns/list")
|
||||
return [] unless response.success?
|
||||
|
||||
body = response.body
|
||||
body.is_a?(Hash) ? (body["items"] || []) : []
|
||||
rescue Error, Faraday::Error
|
||||
[]
|
||||
end
|
||||
|
||||
def online_path_names
|
||||
Set.new(list_paths.filter_map { |item| item["name"] if item["online"] })
|
||||
end
|
||||
|
||||
@@ -4,17 +4,37 @@ module Mediamtx
|
||||
module_function
|
||||
|
||||
def active?(session)
|
||||
return true if rtmp_publisher?(session)
|
||||
|
||||
active_path?(path_info(session))
|
||||
end
|
||||
|
||||
def path_info(session)
|
||||
Client.new.list_paths.find { |i| i["name"] == session.mediamtx_path_name }
|
||||
Client.for_session(session).list_paths.find { |i| i["name"] == session.mediamtx_path_name }
|
||||
end
|
||||
|
||||
# MediaMTX <=1.19: online + source.type=rtmpConn.
|
||||
# MediaMTX 1.20+: online/source spesso null anche con publisher; usare rtmpconns.
|
||||
def active_path?(info)
|
||||
return false unless info
|
||||
return true if info["online"] == true && rtmp_source?(info.dig("source", "type"))
|
||||
|
||||
info["online"] == true && info.dig("source", "type") == "rtmpConn"
|
||||
false
|
||||
end
|
||||
|
||||
def rtmp_publisher?(session)
|
||||
path = session.mediamtx_path_name.to_s
|
||||
return false if path.blank?
|
||||
|
||||
Client.for_session(session).list_rtmp_conns.any? do |conn|
|
||||
conn_path = conn["path"].to_s.sub(%r{\A/}, "")
|
||||
next false unless conn_path == path
|
||||
|
||||
state = conn["state"].to_s
|
||||
state.empty? || state == "publish" || state == "idle"
|
||||
end
|
||||
rescue StandardError
|
||||
false
|
||||
end
|
||||
|
||||
def h264_video?(info)
|
||||
@@ -26,12 +46,14 @@ module Mediamtx
|
||||
end
|
||||
|
||||
def video_publishing?(session)
|
||||
info = path_info(session)
|
||||
return false unless active_path?(info)
|
||||
# Slate alwaysAvailable ha H264 ma non è il telefono.
|
||||
return false unless info.dig("source", "type") == "rtmpConn"
|
||||
return false unless active?(session)
|
||||
|
||||
info = path_info(session)
|
||||
h264_video?(info)
|
||||
end
|
||||
|
||||
def rtmp_source?(type)
|
||||
type.to_s.match?(/\Artmps?Conn\z/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,7 +12,7 @@ module Mediamtx
|
||||
return @session if @session.terminal?
|
||||
|
||||
path_info = Mediamtx::PublisherOnline.path_info(@session)
|
||||
publisher_online = Mediamtx::PublisherOnline.active_path?(path_info)
|
||||
publisher_online = Mediamtx::PublisherOnline.active?(@session)
|
||||
|
||||
if publisher_online
|
||||
clear_publisher_misses!(@session.id)
|
||||
@@ -86,7 +86,7 @@ module Mediamtx
|
||||
key = format("youtube:slate_disabled:%s", session.id)
|
||||
return unless redis.set(key, "1", nx: true, ex: 48.hours.to_i)
|
||||
|
||||
Client.new.set_always_available(session, enabled: false)
|
||||
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}")
|
||||
@@ -95,7 +95,7 @@ module Mediamtx
|
||||
def restore_slate_path!(session)
|
||||
return if session.platform == "matchlivetv"
|
||||
|
||||
Client.new.set_always_available(session, enabled: true)
|
||||
Client.for_session(session).set_always_available(session, enabled: true)
|
||||
rescue Client::Error => e
|
||||
Rails.logger.warn("[PublisherSync] enable slate session=#{session.id}: #{e.message}")
|
||||
end
|
||||
@@ -128,7 +128,7 @@ module Mediamtx
|
||||
return
|
||||
end
|
||||
|
||||
Client.new.set_path_recording(session, enabled: enabled)
|
||||
Client.for_session(session).set_path_recording(session, enabled: enabled)
|
||||
redis.set(key, desired, ex: 48.hours.to_i)
|
||||
mark_recording_patch!(session.id)
|
||||
rescue Client::Error => e
|
||||
|
||||
@@ -40,7 +40,7 @@ module Streams
|
||||
network_id = ENV["HCLOUD_NETWORK_ID"].presence
|
||||
body[:networks] = [network_id.to_i] if network_id
|
||||
user_data = cloud_init_user_data
|
||||
body[:user_data] = user_data if user_data.present?
|
||||
body[:user_data] = user_data
|
||||
|
||||
data = post("servers", body)
|
||||
server = data["server"] || {}
|
||||
@@ -114,9 +114,16 @@ module Streams
|
||||
|
||||
def cloud_init_user_data
|
||||
path = ENV["HCLOUD_USER_DATA_FILE"].presence
|
||||
return File.read(path) if path && File.file?(path)
|
||||
if path.present?
|
||||
raise Error, "HCLOUD_USER_DATA_FILE non leggibile nel container: #{path}" unless File.file?(path)
|
||||
|
||||
ENV["HCLOUD_USER_DATA"].presence
|
||||
return File.read(path)
|
||||
end
|
||||
|
||||
inline = ENV["HCLOUD_USER_DATA"].presence
|
||||
raise Error, "Manca cloud-init: imposta HCLOUD_USER_DATA_FILE (montato) o HCLOUD_USER_DATA" if inline.blank?
|
||||
|
||||
inline
|
||||
end
|
||||
|
||||
def conn
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Mediamtx::PublisherSync do
|
||||
let(:user) { User.create!(email: "sync@test.com", name: "Sync", password: "password123", role: "coach") }
|
||||
let(:user) { User.create!(email: "sync@test.com", name: "Sync", password: "Password123", role: "coach") }
|
||||
let(:club) { Club.create!(name: "Sync Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||
let(:team) { club.teams.create!(name: "Under 16", sport: "volleyball") }
|
||||
let!(:match) { team.matches.create!(opponent_name: "Avversario") }
|
||||
@@ -20,8 +20,11 @@ RSpec.describe Mediamtx::PublisherSync do
|
||||
before do
|
||||
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
|
||||
allow(Mediamtx::Client).to receive(:new).and_return(client)
|
||||
allow(Mediamtx::Client).to receive(:for_session).and_return(client)
|
||||
allow(Mediamtx::PublisherOnline).to receive(:path_info).and_return(path_info)
|
||||
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(true)
|
||||
allow(Mediamtx::PublisherOnline).to receive(:rtmp_publisher?).and_return(false)
|
||||
allow(Mediamtx::PublisherOnline).to receive(:active?).and_return(true)
|
||||
allow_any_instance_of(described_class).to receive(:redis).and_return(redis)
|
||||
allow(client).to receive(:set_path_recording)
|
||||
end
|
||||
@@ -36,6 +39,7 @@ RSpec.describe Mediamtx::PublisherSync do
|
||||
|
||||
it "non abilita la registrazione in connecting senza publisher online" do
|
||||
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(false)
|
||||
allow(Mediamtx::PublisherOnline).to receive(:active?).and_return(false)
|
||||
path_info["online"] = false
|
||||
|
||||
described_class.new(session).call
|
||||
@@ -53,6 +57,7 @@ RSpec.describe Mediamtx::PublisherSync do
|
||||
it "non disabilita la registrazione su reconnecting con publisher offline" do
|
||||
session.update!(status: "reconnecting")
|
||||
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(false)
|
||||
allow(Mediamtx::PublisherOnline).to receive(:active?).and_return(false)
|
||||
|
||||
described_class.new(session).call
|
||||
|
||||
|
||||
@@ -16,13 +16,18 @@ services:
|
||||
- "18888:8888" # HLS diretto opzionale; in genere basta /hls via edge+NPM
|
||||
|
||||
# Passa tutto .env (HCLOUD_*, STREAM_*, …) ai processi Rails/Sidekiq
|
||||
# Monta cloud-init: HCLOUD_USER_DATA_FILE punta a path host non presente nell'immagine.
|
||||
rails:
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./stream-node:/opt/matchlivetv/infra/stream-node:ro
|
||||
|
||||
sidekiq:
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./stream-node:/opt/matchlivetv/infra/stream-node:ro
|
||||
|
||||
# ntfy collaudo su porta diversa (evita confusione se si punta per sbaglio l'host)
|
||||
ntfy:
|
||||
|
||||
@@ -135,6 +135,7 @@ services:
|
||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/recordings:/recordings
|
||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/active_storage:/app/storage
|
||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/log:/app/log
|
||||
- ${HCLOUD_USER_DATA_HOST_DIR:-./stream-node}:/opt/matchlivetv/infra/stream-node:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/up"]
|
||||
interval: 15s
|
||||
@@ -230,6 +231,7 @@ services:
|
||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/recordings:/recordings
|
||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/active_storage:/app/storage
|
||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/log:/app/log
|
||||
- ${HCLOUD_USER_DATA_HOST_DIR:-./stream-node}:/opt/matchlivetv/infra/stream-node:ro
|
||||
|
||||
garage:
|
||||
image: dxflrs/garage:v1.0.1
|
||||
|
||||
@@ -49,10 +49,11 @@ write_files:
|
||||
docker pull bluenviron/mediamtx:latest
|
||||
docker rm -f mediamtx 2>/dev/null || true
|
||||
# Slate offline (alwaysAvailable) — richiesta da Mediamtx::Client#create_path
|
||||
# MediaMTX 1.20+: il publisher RTMP deve matchare canali audio della slate (stereo).
|
||||
mkdir -p /slates
|
||||
if [ ! -f /slates/offline.mp4 ]; then
|
||||
ffmpeg -y -f lavfi -i color=c=black:s=1280x720:d=2 -f lavfi -i anullsrc=r=44100:cl=stereo \
|
||||
-c:v libx264 -t 2 -pix_fmt yuv420p -c:a aac -shortest /slates/offline.mp4
|
||||
-c:v libx264 -t 2 -pix_fmt yuv420p -c:a aac -ac 2 -shortest /slates/offline.mp4
|
||||
fi
|
||||
# Debian cloud image: docker.io senza apparmor_parser → serve unconfined o pkg apparmor
|
||||
docker run -d --name mediamtx --restart unless-stopped --network host \
|
||||
|
||||
+70
-24
@@ -17,11 +17,16 @@ import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* E2E sul simulatore: login → nuova partita → wizard 3 step → schermata diretta.
|
||||
* Usa le stringhe dell'app (locale del device), non testo hardcodato IT/EN.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class E2EWizardFlowTest {
|
||||
private lateinit var device: UiDevice
|
||||
private val pkg = "com.matchlivetv.match_live_tv"
|
||||
private val ctx by lazy { InstrumentationRegistry.getInstrumentation().targetContext }
|
||||
|
||||
private fun s(id: Int): String = ctx.getString(id)
|
||||
private fun su(id: Int): String = s(id).uppercase()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
@@ -32,30 +37,42 @@ class E2EWizardFlowTest {
|
||||
|
||||
@Test
|
||||
fun login_newMatch_wizard_reachesBroadcastScreen() {
|
||||
waitForAnyText("Email", "ACCEDI", timeoutMs = 45_000)
|
||||
waitForAnyText(s(R.string.login_email), su(R.string.login_submit), timeoutMs = 45_000)
|
||||
fillLogin()
|
||||
waitForText("NUOVA PARTITA", timeoutMs = 45_000)
|
||||
tapClickableText("NUOVA PARTITA")
|
||||
waitForText("Avvia subito", timeoutMs = 15_000)
|
||||
tapClickableText("Avvia subito")
|
||||
waitForText("01 · Partita", timeoutMs = 45_000)
|
||||
waitForText(su(R.string.matches_new), timeoutMs = 45_000)
|
||||
tapClickableText(su(R.string.matches_new))
|
||||
waitForText(s(R.string.sheet_quick_option), timeoutMs = 15_000)
|
||||
tapClickableText(s(R.string.sheet_quick_option))
|
||||
waitForText(s(R.string.wizard_step_title_match), timeoutMs = 45_000)
|
||||
scrollDown()
|
||||
tapClickableText("AVANTI >")
|
||||
waitForText("02 · Trasmissione", timeoutMs = 45_000)
|
||||
waitForText("Piattaforma", timeoutMs = 30_000)
|
||||
tapClickableText(s(R.string.wizard_action_next))
|
||||
waitForText(s(R.string.wizard_step_title_transmission), timeoutMs = 45_000)
|
||||
waitForText(s(R.string.wizard_transmission_platform_title), timeoutMs = 30_000)
|
||||
scrollDown()
|
||||
tapClickableText("AVANTI >")
|
||||
waitForText("03 · Test rete", timeoutMs = 45_000)
|
||||
waitForText("AVVIA TEST RETE", timeoutMs = 30_000)
|
||||
tapClickableText("AVVIA TEST RETE")
|
||||
waitForText("INIZIA >", timeoutMs = 30_000)
|
||||
waitUntilEnabled("INIZIA >", timeoutMs = 25_000)
|
||||
tapClickableText(s(R.string.wizard_action_next))
|
||||
waitForText(s(R.string.wizard_step_title_network), timeoutMs = 45_000)
|
||||
waitForText(s(R.string.wizard_network_test_start_label), timeoutMs = 30_000)
|
||||
tapClickableText(s(R.string.wizard_network_test_start_label))
|
||||
waitForText(s(R.string.wizard_action_start), timeoutMs = 30_000)
|
||||
waitUntilEnabled(s(R.string.wizard_action_start), timeoutMs = 25_000)
|
||||
scrollDown()
|
||||
tapClickableText("INIZIA >")
|
||||
val diretta = waitForText("Diretta", timeoutMs = 60_000)
|
||||
assertNotNull(diretta)
|
||||
assertNotNull(waitForText("TERMINA DIRETTA", timeoutMs = 30_000))
|
||||
assertNotNull(waitForText("CHIUDI SET", timeoutMs = 20_000))
|
||||
tapClickableText(s(R.string.wizard_action_start))
|
||||
waitForAnyText(
|
||||
s(R.string.broadcast_status_live),
|
||||
s(R.string.broadcast_status_connecting),
|
||||
s(R.string.broadcast_status_reconnecting),
|
||||
timeoutMs = 60_000,
|
||||
)
|
||||
// CLOSE SET è un SideIconButton: in hierarchy compare come content-desc, non sempre come text.
|
||||
assertTrue(
|
||||
"Schermata diretta non pronta (né CLOSE SET né End live)",
|
||||
waitForTextOrDesc(
|
||||
timeoutMs = 30_000,
|
||||
s(R.string.broadcast_close_set_button),
|
||||
s(R.string.score_action_close_set),
|
||||
s(R.string.broadcast_terminate_cd),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun grantRuntimePermissions() {
|
||||
@@ -69,11 +86,10 @@ class E2EWizardFlowTest {
|
||||
}
|
||||
|
||||
private fun launchApp() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val intent = context.packageManager.getLaunchIntentForPackage(pkg)?.apply {
|
||||
val intent = ctx.packageManager.getLaunchIntentForPackage(pkg)?.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
} ?: error("Launch intent mancante per $pkg")
|
||||
context.startActivity(intent)
|
||||
ctx.startActivity(intent)
|
||||
device.wait(Until.hasObject(By.pkg(pkg).depth(0)), 15_000)
|
||||
}
|
||||
|
||||
@@ -81,8 +97,10 @@ class E2EWizardFlowTest {
|
||||
val fields = device.wait(Until.findObjects(By.clazz("android.widget.EditText")), 15_000)
|
||||
if (fields.size < 2) error("Campi login non trovati (${fields.size})")
|
||||
pasteIntoField(fields[0], "coach@matchlivetv.test")
|
||||
pasteIntoField(fields[1], "password123")
|
||||
pasteIntoField(fields[1], "Password123")
|
||||
device.pressKeyCode(KeyEvent.KEYCODE_ENTER)
|
||||
// Preferisci il bottone (ultimo match uppercase), non il titolo.
|
||||
tapLastMatchingText(su(R.string.login_submit))
|
||||
device.waitForIdle()
|
||||
}
|
||||
|
||||
@@ -111,6 +129,34 @@ class E2EWizardFlowTest {
|
||||
error("Nessuno dei testi trovato: ${texts.joinToString()}")
|
||||
}
|
||||
|
||||
private fun waitForTextOrDesc(timeoutMs: Long, vararg labels: String): Boolean {
|
||||
val deadline = SystemClock.elapsedRealtime() + timeoutMs
|
||||
while (SystemClock.elapsedRealtime() < deadline) {
|
||||
for (label in labels) {
|
||||
if (device.hasObject(By.text(label)) || device.hasObject(By.desc(label))) return true
|
||||
}
|
||||
SystemClock.sleep(250)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun tapLastMatchingText(text: String) {
|
||||
val nodes = device.findObjects(By.text(text))
|
||||
val target = nodes.lastOrNull { it.isClickable }
|
||||
?: nodes.lastOrNull()?.let { label ->
|
||||
var node: UiObject2? = label
|
||||
repeat(6) {
|
||||
val current = node ?: return@repeat
|
||||
if (current.isClickable) return@let current
|
||||
node = current.parent
|
||||
}
|
||||
label
|
||||
}
|
||||
?: error("Testo non trovato: $text")
|
||||
target.click()
|
||||
device.waitForIdle()
|
||||
}
|
||||
|
||||
private fun tapClickableText(text: String) {
|
||||
device.findObject(By.text(text).clickable(true))?.let { node ->
|
||||
node.click()
|
||||
|
||||
Reference in New Issue
Block a user