Fix HLS browser in prod e stabilizza broadcast RTMP su iOS.

Il proxy Rails gestisce il redirect MediaMTX su /live/match_* e riscrive le playlist; edge nginx reindirizza /live/ sotto /hls/. Su iOS, SessionBuilder HaishinKit, pipeline mixer corretta e refresh token.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Emiliano Frascaro
2026-06-19 18:48:29 +02:00
parent ed0f913138
commit a303a94671
16 changed files with 1092 additions and 319 deletions

View File

@@ -1,38 +1,98 @@
class HlsProxyController < ActionController::Base
# Proxy pubblico verso MediaMTX HLS (NPM inoltra tutto a Rails :3000).
# Proxy pubblico verso MediaMTX HLS.
# Dev: tutto passa da Rails (/hls/*).
# Prod: edge inoltra /hls/ a MediaMTX; il redirect cookie di MediaMTX punta a
# /live/match_{uuid}/… che deve essere gestito da #live_match (fallback).
def show
upstream_path = params[:path].to_s
proxy_mediamtx_path(params[:path].to_s)
end
def live_match
session_id = params[:session_id].to_s
return head :not_found unless session_id.match?(/\A[0-9a-f-]{36}\z/i)
segments = params[:segments].presence || "index.m3u8"
proxy_mediamtx_path("live/match_#{session_id}/#{segments}")
end
private
def proxy_mediamtx_path(upstream_path)
return head :not_found if upstream_path.blank?
upstream = "#{MatchLiveTv.mediamtx_hls_url}/#{upstream_path}"
upstream = "#{upstream}?#{request.query_string}" if request.query_string.present?
cookie = request.headers["Cookie"].presence || "cookieCheck=1"
response = hls_conn.get(upstream) { |req| req.headers["Cookie"] = cookie }
if response.status == 302
location = response.headers["location"].to_s
follow_url = if location.start_with?("http")
location
else
"#{MatchLiveTv.mediamtx_hls_url.sub(%r{/$}, "")}#{location}"
end
set_cookie = response.headers["set-cookie"].to_s
cookie = set_cookie.presence || cookie
response = hls_conn.get(follow_url) { |req| req.headers["Cookie"] = cookie }
5.times do
response = hls_conn.get(upstream) { |req| req.headers["Cookie"] = cookie }
if response.status.in?([301, 302, 307, 308])
location = response.headers["location"].to_s
cookie = cookie_from_set_header(response.headers["set-cookie"]).presence || cookie
upstream = resolve_upstream_url(location)
next
end
return render_proxied_response(response)
end
response.headers.each do |key, value|
next if %w[transfer-encoding connection].include?(key.downcase)
headers[key] = value
end
render body: response.body, status: response.status
head :bad_gateway
rescue Faraday::Error => e
Rails.logger.warn("[HlsProxy] #{upstream_path}: #{e.message}")
head :bad_gateway
end
private
def resolve_upstream_url(location)
return location if location.start_with?("http://", "https://")
base = MatchLiveTv.mediamtx_hls_url.sub(%r{/$}, "")
location.start_with?("/") ? "#{base}#{location}" : "#{base}/#{location}"
end
def render_proxied_response(response)
body = response.body.to_s
content_type = response.headers["content-type"].to_s
if m3u8_playlist?(content_type, body)
public_hls_base = "#{request.base_url}/hls"
body = body.gsub(%r{(?<=["'(\s])/live/(match_[0-9a-f-]{36}/)}, "/hls/live/\\1")
body = body.gsub(%r{\A/live/(match_[0-9a-f-]{36}/)}, "/hls/live/\\1")
end
response.headers.each do |key, value|
next if %w[transfer-encoding connection content-length content-encoding].include?(key.downcase)
if key.downcase == "location"
headers[key] = rewrite_public_location(value)
else
headers[key] = value
end
end
render body: body, status: response.status, content_type: content_type.presence
end
def rewrite_public_location(location)
loc = location.to_s
if loc.start_with?("/live/match_")
return loc.sub(%r{\A/live/}, "/hls/")
end
if loc.include?("/live/match_")
return loc.gsub(%r{/(live/match_[0-9a-f-]{36}/)}, "/hls/live/\\1")
end
loc
end
def m3u8_playlist?(content_type, body)
return true if content_type.include?("mpegurl") || content_type.include?("m3u8")
body.lstrip.start_with?("#EXTM3U")
end
def cookie_from_set_header(set_cookie_header)
set_cookie_header.to_s[/cookieCheck=[^;]+/]
end
def hls_conn
@hls_conn ||= Faraday.new do |f|

View File

@@ -13,7 +13,8 @@ Rails.application.configure do
config.ssl_options = {
redirect: {
exclude: ->(request) {
request.path == "/up" || request.path == "/up/deep" || request.path == "/cable" || request.path.start_with?("/hls/")
request.path == "/up" || request.path == "/up/deep" || request.path == "/cable" ||
request.path.start_with?("/hls/") || request.path.match?(%r{\A/live/match_[0-9a-f-]{36}/}i)
}
}
}
@@ -34,7 +35,8 @@ Rails.application.configure do
config.host_authorization = {
exclude: ->(request) {
request.path == "/up" || request.path == "/up/deep" || request.path.start_with?("/cable") || request.path.start_with?("/hls/")
request.path == "/up" || request.path == "/up/deep" || request.path.start_with?("/cable") ||
request.path.start_with?("/hls/") || request.path.match?(%r{\A/live/match_[0-9a-f-]{36}/}i)
}
}

View File

@@ -109,6 +109,16 @@ Rails.application.routes.draw do
end
get "hls/*path", to: "hls_proxy#show", format: false, constraints: { path: /.+/ }
# MediaMTX HLS cookie redirect → /live/match_{uuid}/… (edge o browser dopo 302).
get "live/match_:session_id",
to: "hls_proxy#live_match",
format: false,
constraints: { session_id: /[0-9a-f-]{36}/i },
defaults: { segments: "index.m3u8" }
get "live/match_:session_id/*segments",
to: "hls_proxy#live_match",
format: false,
constraints: { session_id: /[0-9a-f-]{36}/i, segments: /.+/ }
get "live", to: "public/live#index", as: :public_live_index
get "live/:id", to: "public/live#show", as: :public_live

View File

@@ -0,0 +1,36 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe "HLS proxy", type: :request do
let(:mediamtx_hls) { "http://mediamtx.test:8888" }
let(:playlist) do
<<~M3U8
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:1
#EXTINF:1.0,
/live/match_#{session_id}/segment0.ts
M3U8
end
let(:session_id) { "cc7e90b2-c672-4401-b5d3-51fdcdc7a214" }
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" })
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
end