From 5a0ca8ef1c15090c4f49f774c3df07dad39bcd65 Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Tue, 11 Aug 2026 08:48:28 +0200 Subject: [PATCH] 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 --- backend/app/services/mediamtx/client.rb | 10 ++ .../app/services/mediamtx/publisher_online.rb | 34 +++++-- .../app/services/mediamtx/publisher_sync.rb | 8 +- .../streams/cloud_providers/hetzner.rb | 13 ++- .../services/mediamtx/publisher_sync_spec.rb | 7 +- infra/docker-compose.collaudo.yml | 5 + infra/docker-compose.prod.yml | 2 + infra/stream-node/cloud-init.yaml | 3 +- .../match_live_tv/E2EWizardFlowTest.kt | 94 ++++++++++++++----- 9 files changed, 137 insertions(+), 39 deletions(-) diff --git a/backend/app/services/mediamtx/client.rb b/backend/app/services/mediamtx/client.rb index cc0a5d6..92a5ab0 100644 --- a/backend/app/services/mediamtx/client.rb +++ b/backend/app/services/mediamtx/client.rb @@ -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 diff --git a/backend/app/services/mediamtx/publisher_online.rb b/backend/app/services/mediamtx/publisher_online.rb index 018d04a..c87ce53 100644 --- a/backend/app/services/mediamtx/publisher_online.rb +++ b/backend/app/services/mediamtx/publisher_online.rb @@ -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 diff --git a/backend/app/services/mediamtx/publisher_sync.rb b/backend/app/services/mediamtx/publisher_sync.rb index 2f15ef9..39340db 100644 --- a/backend/app/services/mediamtx/publisher_sync.rb +++ b/backend/app/services/mediamtx/publisher_sync.rb @@ -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 diff --git a/backend/app/services/streams/cloud_providers/hetzner.rb b/backend/app/services/streams/cloud_providers/hetzner.rb index 6963201..aa26ca1 100644 --- a/backend/app/services/streams/cloud_providers/hetzner.rb +++ b/backend/app/services/streams/cloud_providers/hetzner.rb @@ -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 diff --git a/backend/spec/services/mediamtx/publisher_sync_spec.rb b/backend/spec/services/mediamtx/publisher_sync_spec.rb index 6a0ebe3..2a5286b 100644 --- a/backend/spec/services/mediamtx/publisher_sync_spec.rb +++ b/backend/spec/services/mediamtx/publisher_sync_spec.rb @@ -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 diff --git a/infra/docker-compose.collaudo.yml b/infra/docker-compose.collaudo.yml index 158b921..e8d9f00 100644 --- a/infra/docker-compose.collaudo.yml +++ b/infra/docker-compose.collaudo.yml @@ -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: diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml index 99f3360..62d143e 100644 --- a/infra/docker-compose.prod.yml +++ b/infra/docker-compose.prod.yml @@ -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 diff --git a/infra/stream-node/cloud-init.yaml b/infra/stream-node/cloud-init.yaml index 80f4c56..d3f5eee 100644 --- a/infra/stream-node/cloud-init.yaml +++ b/infra/stream-node/cloud-init.yaml @@ -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 \ diff --git a/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/E2EWizardFlowTest.kt b/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/E2EWizardFlowTest.kt index fafb977..bebdff6 100644 --- a/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/E2EWizardFlowTest.kt +++ b/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/E2EWizardFlowTest.kt @@ -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()