From 80813b390b85d0c3efd072e844c07d9be4f22e81 Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Mon, 15 Jun 2026 07:30:06 +0200 Subject: [PATCH] Fix connessione RTMP in dev: riscrivi host Docker mediamtx per iOS. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il simulatore non risolve «mediamtx»; l'app riscrive l'ingest verso 127.0.0.1 e docker-compose espone MEDIAMTX_RTMP_URL client-safe con messaggi d'errore RTMP più chiari. Co-authored-by: Cursor --- infra/.env.example | 3 ++ infra/docker-compose.yml | 2 +- native/ios/MatchLiveTv/Core/MediaUrl.swift | 36 +++++++++++++++++ .../MatchLiveTv/Core/UserFacingError.swift | 20 ++++++++++ .../UI/Broadcast/BroadcastScreen.swift | 2 +- .../MatchLiveTvTests/ApiInstantTests.swift | 40 +++++++++++++++++++ 6 files changed, 101 insertions(+), 2 deletions(-) diff --git a/infra/.env.example b/infra/.env.example index c0d53e5..c6bdbcd 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -1,6 +1,9 @@ POSTGRES_PASSWORD=matchlivetv_dev SECRET_KEY_BASE=change_me_to_a_long_random_string_in_production MEDIAMTX_WEBHOOK_SECRET=mediamtx_webhook_dev_secret +# URL RTMP per telefoni/simulatori (NON usare host Docker «mediamtx»). +# Dev su Mac: rtmp://127.0.0.1:1935 — device fisico sulla LAN: rtmp://IP_MAC:1935 +MEDIAMTX_RTMP_URL=rtmp://127.0.0.1:1935 JWT_SECRET=matchlivetv_jwt_dev_secret_change_in_prod YOUTUBE_CLIENT_ID= YOUTUBE_CLIENT_SECRET= diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index a2159d1..c85db72 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -58,7 +58,7 @@ services: DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-matchlivetv_dev}@postgres:5432/matchlivetv REDIS_URL: redis://redis:6379/0 MEDIAMTX_API_URL: http://mediamtx:9997 - MEDIAMTX_RTMP_URL: rtmp://mediamtx:1935 + MEDIAMTX_RTMP_URL: ${MEDIAMTX_RTMP_URL:-rtmp://127.0.0.1:1935} MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret} RAILS_WEBHOOK_URL: http://rails:3000 SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev_secret_key_base_change_me_32chars_min} diff --git a/native/ios/MatchLiveTv/Core/MediaUrl.swift b/native/ios/MatchLiveTv/Core/MediaUrl.swift index d1afdeb..9f9081e 100644 --- a/native/ios/MatchLiveTv/Core/MediaUrl.swift +++ b/native/ios/MatchLiveTv/Core/MediaUrl.swift @@ -9,3 +9,39 @@ enum MediaUrl { return "\(base)/\(path)" } } + +/// Normalizza l'URL RTMP restituito dall'API per il dispositivo corrente. +enum RtmpIngestUrl { + /// Host interni Docker non raggiungibili da simulatore / telefono → localhost o LAN. + private static let dockerInternalHosts: Set = ["mediamtx", "host.docker.internal"] + + static func resolveForDevice(_ urlString: String) -> String { + guard var components = URLComponents(string: urlString), + let host = components.host?.lowercased() else { + return urlString + } + guard dockerInternalHosts.contains(host) || shouldRewriteForLocalApi else { + return urlString + } + components.host = localIngestHost + if components.port == nil { + components.port = 1935 + } + return components.url?.absoluteString ?? urlString + } + + private static var shouldRewriteForLocalApi: Bool { + guard let host = URL(string: AppConfig.apiBaseUrl)?.host?.lowercased() else { return false } + return host == "localhost" || host == "127.0.0.1" || host.hasSuffix(".local") + } + + /// Host RTMP per dev locale: simulatore iOS → 127.0.0.1; device fisico → stesso host dell'API. + private static var localIngestHost: String { + guard let apiHost = URL(string: AppConfig.apiBaseUrl)?.host else { return "127.0.0.1" } + let lower = apiHost.lowercased() + if lower == "localhost" || lower == "127.0.0.1" { + return "127.0.0.1" + } + return apiHost + } +} diff --git a/native/ios/MatchLiveTv/Core/UserFacingError.swift b/native/ios/MatchLiveTv/Core/UserFacingError.swift index f7ebe77..39bd98d 100644 --- a/native/ios/MatchLiveTv/Core/UserFacingError.swift +++ b/native/ios/MatchLiveTv/Core/UserFacingError.swift @@ -10,10 +10,30 @@ enum UserFacingError { static func message(for error: Error, fallback: String = "Operazione non riuscita") -> String? { if isCancellation(error) { return nil } + if let rtmp = rtmpMessage(for: error) { return rtmp } if let api = error as? LocalizedError, let description = api.errorDescription, !description.isEmpty { return description } let text = error.localizedDescription + if text.contains("RTMPConnection.Error") || text.contains("RTMPHaishinKit") { + return "Connessione RTMP fallita. Verifica che MediaMTX sia raggiungibile (in dev: porta 1935 su localhost)." + } return text.isEmpty ? fallback : text } + + private static func rtmpMessage(for error: Error) -> String? { + let text = String(describing: error) + let localized = error.localizedDescription + guard text.contains("RTMPConnection") || localized.contains("RTMPConnection") else { return nil } + if localized.contains("unsupportedCommand") || text.contains("unsupportedCommand") { + return "URL RTMP non valido. Controlla l'indirizzo di ingest dal server." + } + if localized.contains("connectionTimedOut") || text.contains("connectionTimedOut") { + return "Timeout connessione RTMP. Verifica rete e server MediaMTX (porta 1935)." + } + if localized.contains("socketError") || text.contains("socketError") { + return "Server RTMP non raggiungibile. In dev locale usa localhost:1935 (non l'host Docker «mediamtx»)." + } + return "Connessione RTMP fallita. Verifica rete e server streaming." + } } diff --git a/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift index fb2a71f..703356f 100644 --- a/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift +++ b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift @@ -426,7 +426,7 @@ struct BroadcastScreen: View { private func broadcastConfig(for loaded: StreamSession, rtmpUrl: String) -> BroadcastConfig { BroadcastConfig( - rtmpUrl: rtmpUrl, + rtmpUrl: RtmpIngestUrl.resolveForDevice(rtmpUrl), width: 1280, height: 720, videoBitrate: loaded.targetBitrate, diff --git a/native/ios/MatchLiveTvTests/ApiInstantTests.swift b/native/ios/MatchLiveTvTests/ApiInstantTests.swift index 19fddc7..f6d7c3b 100644 --- a/native/ios/MatchLiveTvTests/ApiInstantTests.swift +++ b/native/ios/MatchLiveTvTests/ApiInstantTests.swift @@ -13,6 +13,20 @@ final class ApiInstantTests: XCTestCase { XCTAssertEqual(target?.connectURL, "rtmp://stream.example.com/live") XCTAssertEqual(target?.streamName, "match_abc-123") } + + func testRtmpIngestUrlRewritesDockerHostForLocalApi() { + let resolved = RtmpIngestUrlLogic.resolve( + "rtmp://mediamtx:1935/live/match_abc", + apiBaseUrl: "http://localhost:3000" + ) + XCTAssertEqual(resolved, "rtmp://127.0.0.1:1935/live/match_abc") + } + + func testRtmpIngestUrlKeepsProductionHost() { + let url = "rtmp://www.matchlivetv.it:1935/live/match_abc" + let resolved = RtmpIngestUrlLogic.resolve(url, apiBaseUrl: "https://www.matchlivetv.it") + XCTAssertEqual(resolved, url) + } } enum OverlayKindMapping { @@ -45,3 +59,29 @@ enum RTMPUrlParserLogic { return Target(connectURL: connect, streamName: streamName) } } + +/// Copia della logica `RtmpIngestUrl` per test senza bundle Info.plist. +enum RtmpIngestUrlLogic { + private static let dockerInternalHosts: Set = ["mediamtx", "host.docker.internal"] + + static func resolve(_ urlString: String, apiBaseUrl: String) -> String { + guard var components = URLComponents(string: urlString), + let host = components.host?.lowercased() else { + return urlString + } + let apiHost = URL(string: apiBaseUrl)?.host?.lowercased() + let isLocalApi = apiHost.map { $0 == "localhost" || $0 == "127.0.0.1" || $0.hasSuffix(".local") } ?? false + guard dockerInternalHosts.contains(host) || isLocalApi else { + return urlString + } + let ingestHost: String + if let apiHost, apiHost != "localhost", apiHost != "127.0.0.1" { + ingestHost = apiHost + } else { + ingestHost = "127.0.0.1" + } + components.host = ingestHost + if components.port == nil { components.port = 1935 } + return components.url?.absoluteString ?? urlString + } +}