Fix connessione RTMP in dev: riscrivi host Docker mediamtx per iOS.
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
POSTGRES_PASSWORD=matchlivetv_dev
|
POSTGRES_PASSWORD=matchlivetv_dev
|
||||||
SECRET_KEY_BASE=change_me_to_a_long_random_string_in_production
|
SECRET_KEY_BASE=change_me_to_a_long_random_string_in_production
|
||||||
MEDIAMTX_WEBHOOK_SECRET=mediamtx_webhook_dev_secret
|
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
|
JWT_SECRET=matchlivetv_jwt_dev_secret_change_in_prod
|
||||||
YOUTUBE_CLIENT_ID=
|
YOUTUBE_CLIENT_ID=
|
||||||
YOUTUBE_CLIENT_SECRET=
|
YOUTUBE_CLIENT_SECRET=
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ services:
|
|||||||
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-matchlivetv_dev}@postgres:5432/matchlivetv
|
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-matchlivetv_dev}@postgres:5432/matchlivetv
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
MEDIAMTX_API_URL: http://mediamtx:9997
|
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}
|
MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret}
|
||||||
RAILS_WEBHOOK_URL: http://rails:3000
|
RAILS_WEBHOOK_URL: http://rails:3000
|
||||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev_secret_key_base_change_me_32chars_min}
|
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev_secret_key_base_change_me_32chars_min}
|
||||||
|
|||||||
@@ -9,3 +9,39 @@ enum MediaUrl {
|
|||||||
return "\(base)/\(path)"
|
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<String> = ["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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,10 +10,30 @@ enum UserFacingError {
|
|||||||
|
|
||||||
static func message(for error: Error, fallback: String = "Operazione non riuscita") -> String? {
|
static func message(for error: Error, fallback: String = "Operazione non riuscita") -> String? {
|
||||||
if isCancellation(error) { return nil }
|
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 {
|
if let api = error as? LocalizedError, let description = api.errorDescription, !description.isEmpty {
|
||||||
return description
|
return description
|
||||||
}
|
}
|
||||||
let text = error.localizedDescription
|
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
|
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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -426,7 +426,7 @@ struct BroadcastScreen: View {
|
|||||||
|
|
||||||
private func broadcastConfig(for loaded: StreamSession, rtmpUrl: String) -> BroadcastConfig {
|
private func broadcastConfig(for loaded: StreamSession, rtmpUrl: String) -> BroadcastConfig {
|
||||||
BroadcastConfig(
|
BroadcastConfig(
|
||||||
rtmpUrl: rtmpUrl,
|
rtmpUrl: RtmpIngestUrl.resolveForDevice(rtmpUrl),
|
||||||
width: 1280,
|
width: 1280,
|
||||||
height: 720,
|
height: 720,
|
||||||
videoBitrate: loaded.targetBitrate,
|
videoBitrate: loaded.targetBitrate,
|
||||||
|
|||||||
@@ -13,6 +13,20 @@ final class ApiInstantTests: XCTestCase {
|
|||||||
XCTAssertEqual(target?.connectURL, "rtmp://stream.example.com/live")
|
XCTAssertEqual(target?.connectURL, "rtmp://stream.example.com/live")
|
||||||
XCTAssertEqual(target?.streamName, "match_abc-123")
|
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 {
|
enum OverlayKindMapping {
|
||||||
@@ -45,3 +59,29 @@ enum RTMPUrlParserLogic {
|
|||||||
return Target(connectURL: connect, streamName: streamName)
|
return Target(connectURL: connect, streamName: streamName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copia della logica `RtmpIngestUrl` per test senza bundle Info.plist.
|
||||||
|
enum RtmpIngestUrlLogic {
|
||||||
|
private static let dockerInternalHosts: Set<String> = ["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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user