Compare commits

..
Author SHA1 Message Date
eminuxandCursor 4573edfedc Aggiunge i link ai profili social ufficiali nel footer e in Contatti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 16:10:18 +02:00
eminuxandCursor b301868774 Aggiunge regola Cursor per scalare i soft limit ingest col crescere dei clienti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 15:11:25 +02:00
eminuxandCursor b87a0f7bc0 Fix race create sessione su CPX: ready solo dopo MediaMTX.
I nodi cloud restano in provisioning finché :9997 risponde; retry su create_path e 503 retryable se l’ingest è ancora irraggiungibile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 14:06:47 +02:00
30 changed files with 571 additions and 12 deletions
@@ -0,0 +1,22 @@
---
description: Scalare soft limit ingest/autoscaler man mano che crescono i clienti
alwaysApply: true
---
# Soft limit stream / autoscaler
Con la crescita del numero di clienti (e delle dirette concorrenti attese), **alzare i soft limit** prima di restare a corto di capacità.
Config rilevante (prod, tipicamente `infra/.env` + `StreamNode`):
| Parametro | Ruolo oggi (post load test 2026-08) |
|-----------|--------------------------------------|
| `STREAM_NODE_HOME_MAX_PUBLISHERS` | Soft home (es. 6) |
| `STREAM_CLOUD_MAX_PUBLISHERS` | Soft per CPX (es. 4; cpx12 ha tenuto 8 in probe) |
| `STREAM_AUTOSCALE_MAX_OVERFLOW` / max overflow nodes | Quanti CPX in parallelo (es. 3 → tetto cluster ≈ home + N×cloud) |
| `STREAM_AUTOSCALE_SOFT_FREE_SLOTS` | Anticipo scale-out |
| Piano `concurrent_streams_limit` | Tetto **per club** (Premium Full = 10): indipendente dal cluster |
Capienza cluster soft ≈ `home_max + max_overflow × cloud_max` (es. 6+3×4 = **18**).
Quando si parla di capacity planning, deploy autoscale, o “troppe dirette”, ricordare di rivedere questi valori (e il limite piano) in base ai clienti reali — non lasciare i soft limit di collaudo/early-prod a lungo.
@@ -2,6 +2,7 @@ module Api
module V1 module V1
class BaseController < ApplicationController class BaseController < ApplicationController
rescue_from Teams::EntitlementError, with: :render_entitlement_error rescue_from Teams::EntitlementError, with: :render_entitlement_error
rescue_from Streams::IngestUnavailableError, with: :render_ingest_unavailable
rescue_from BrandingAttachments::CoverUploadError, with: :render_cover_upload_error rescue_from BrandingAttachments::CoverUploadError, with: :render_cover_upload_error
rescue_from Youtube::BroadcastService::Error, with: :render_youtube_error rescue_from Youtube::BroadcastService::Error, with: :render_youtube_error
@@ -22,6 +23,13 @@ module Api
}, status: :forbidden }, status: :forbidden
end end
def render_ingest_unavailable(error)
render json: {
error: error.message,
error_code: error.code
}, status: :service_unavailable
end
def render_cover_upload_error(error) def render_cover_upload_error(error)
render json: { error: error.message, error_code: "cover_upload_invalid" }, status: :unprocessable_entity render json: { error: error.message, error_code: "cover_upload_invalid" }, status: :unprocessable_entity
end end
+36 -1
View File
@@ -4,6 +4,9 @@ module Mediamtx
class Client class Client
class Error < StandardError; end class Error < StandardError; end
CREATE_PATH_RETRIES = -> { ENV.fetch("MEDIAMTX_CREATE_RETRIES", "5").to_i }
CREATE_PATH_RETRY_BASE_SECS = -> { ENV.fetch("MEDIAMTX_CREATE_RETRY_BASE_SECS", "0.4").to_f }
def self.for_session(session) def self.for_session(session)
new(base_url: session.mediamtx_api_base_url) new(base_url: session.mediamtx_api_base_url)
end end
@@ -19,6 +22,19 @@ module Mediamtx
attr_reader :base_url attr_reader :base_url
# Health probe for CPX readiness (GET /v3/paths/list).
def reachable?(timeout: 2)
conn = Faraday.new(url: @base_url) do |f|
f.adapter Faraday.default_adapter
f.options.open_timeout = timeout
f.options.timeout = timeout
end
response = conn.get("/v3/paths/list")
response.success?
rescue Faraday::Error
false
end
def create_path(session) def create_path(session)
path = session.mediamtx_path_name path = session.mediamtx_path_name
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe # record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe
@@ -30,7 +46,9 @@ module Mediamtx
body[:alwaysAvailable] = true body[:alwaysAvailable] = true
body[:alwaysAvailableFile] = slate_file_path(session) body[:alwaysAvailableFile] = slate_file_path(session)
# YouTube: telefono → MediaMTX; relay copy verso RTMPS in sidekiq. # YouTube: telefono → MediaMTX; relay copy verso RTMPS in sidekiq.
response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body) response = with_connection_retries("create_path #{path}") do
@conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body)
end
unless response.success? unless response.success?
err = response.body.is_a?(Hash) ? response.body["error"] : response.body err = response.body.is_a?(Hash) ? response.body["error"] : response.body
raise Error, "MediaMTX path create failed: #{response.status} #{err}" raise Error, "MediaMTX path create failed: #{response.status} #{err}"
@@ -161,6 +179,23 @@ module Mediamtx
private private
def with_connection_retries(label)
attempts = [CREATE_PATH_RETRIES.call, 1].max
base = CREATE_PATH_RETRY_BASE_SECS.call
try = 0
begin
try += 1
yield
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
raise if try >= attempts
sleep_secs = base * (2**(try - 1))
Rails.logger.warn("[Mediamtx::Client] #{label} retry #{try}/#{attempts} after #{e.class}: #{e.message} (sleep #{sleep_secs}s)")
sleep(sleep_secs)
retry
end
end
def recording_body(session, enabled:) def recording_body(session, enabled:)
ent = session.match.team.entitlements ent = session.match.team.entitlements
can_record = ent.recording_enabled_for_mediamtx? can_record = ent.recording_enabled_for_mediamtx?
+7
View File
@@ -58,6 +58,13 @@ module Sessions
session session
rescue Streams::NodeRegistry::NoCapacityError => e rescue Streams::NodeRegistry::NoCapacityError => e
raise Teams::EntitlementError.new(e.message, code: "stream_capacity_exhausted") raise Teams::EntitlementError.new(e.message, code: "stream_capacity_exhausted")
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
raise Streams::IngestUnavailableError, "Ingest temporaneamente non disponibile (#{e.class})"
rescue Mediamtx::Client::Error => e
if e.message.to_s.match?(/failed to open|Connection refused|Timeout|timed out/i)
raise Streams::IngestUnavailableError, e.message
end
raise
end end
private private
@@ -136,6 +136,8 @@ module Streams
def reconcile! def reconcile!
actions = [] actions = []
actions.concat(promote_provisioning_nodes!)
actions.concat(reclaim_stuck_provisioning!)
m = self.class.metrics m = self.class.metrics
if need_capacity?(m) && can_provision?(m) if need_capacity?(m) && can_provision?(m)
@@ -170,6 +172,30 @@ module Streams
private private
def promote_provisioning_nodes!
actions = []
StreamNode.where(status: "provisioning").find_each do |node|
next unless Streams::NodeHealth.promote_if_healthy!(node)
actions << :"ready_#{node.slug}"
Rails.logger.info("[Streams::Autoscaler] promoted #{node.slug} to ready")
end
actions
end
def reclaim_stuck_provisioning!
actions = []
stuck_after = ENV.fetch("STREAM_NODE_PROVISIONING_STUCK_MINUTES", "15").to_i.minutes.ago
StreamNode.where(status: "provisioning").where("created_at < ?", stuck_after).find_each do |node|
@provisioner.decommission!(node)
actions << :"reclaim_#{node.slug}"
Rails.logger.warn("[Streams::Autoscaler] decommissioned stuck provisioning #{node.slug}")
rescue NodeProvisioner::BusyError, NodeProvisioner::Error => e
Rails.logger.warn("[Streams::Autoscaler] reclaim #{node.slug}: #{e.message}")
end
actions
end
def need_capacity?(m) def need_capacity?(m)
m[:free_slots] <= self.class.soft_free_slots m[:free_slots] <= self.class.soft_free_slots
end end
@@ -0,0 +1,13 @@
# frozen_string_literal: true
module Streams
# Ingest MediaMTX non raggiungibile (es. CPX ancora in bootstrap). API → 503 retryable.
class IngestUnavailableError < StandardError
attr_reader :code
def initialize(message = "Ingest temporaneamente non disponibile", code: "stream_ingest_unavailable")
super(message)
@code = code
end
end
end
@@ -0,0 +1,27 @@
# frozen_string_literal: true
module Streams
# Probe reachability of MediaMTX API on a stream node (:9997).
class NodeHealth
def self.mediamtx_up?(node, timeout: 2)
new(node, timeout: timeout).mediamtx_up?
end
def self.promote_if_healthy!(node, timeout: 2)
return false unless node.status == "provisioning"
return false unless mediamtx_up?(node, timeout: timeout)
node.update!(status: "ready", last_health_at: Time.current)
true
end
def initialize(node, timeout: 2)
@node = node
@timeout = timeout
end
def mediamtx_up?
Mediamtx::Client.new(base_url: @node.api_base_url).reachable?(timeout: @timeout)
end
end
end
@@ -80,11 +80,15 @@ module Streams
urls = urls_for_node(role: role, home: home, hostname: hostname, simulated: simulated, urls = urls_for_node(role: role, home: home, hostname: hostname, simulated: simulated,
private_ip: private_ip, public_ip: ip, use_node_hostname: use_node_hostname) private_ip: private_ip, public_ip: ip, use_node_hostname: use_node_hostname)
StreamNode.create!( # Simulated/lab che riusa MediaMTX home: subito ready. Cloud reale: provisioning
# finché :9997 risponde (evita allocate → Faraday Connection refused → 500).
initial_status = simulated ? "ready" : "provisioning"
node = StreamNode.create!(
slug: slug, slug: slug,
hostname: hostname, hostname: hostname,
role: role, role: role,
status: "ready", status: initial_status,
provider: provider_name_for(cloud, role: role), provider: provider_name_for(cloud, role: role),
provider_instance_id: instance.id, provider_instance_id: instance.id,
rtmp_base_url: urls.fetch(:rtmp_base_url), rtmp_base_url: urls.fetch(:rtmp_base_url),
@@ -102,6 +106,34 @@ module Streams
"cloud_raw" => instance.raw "cloud_raw" => instance.raw
} }
) )
wait_until_mediamtx_ready!(node) unless simulated
node.reload
end
def wait_until_mediamtx_ready!(node, timeout: nil, interval: nil)
timeout ||= ENV.fetch("STREAM_NODE_READY_TIMEOUT_SECS", "180").to_i
interval ||= ENV.fetch("STREAM_NODE_READY_POLL_SECS", "3").to_f
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
loop do
if Streams::NodeHealth.promote_if_healthy!(node)
Rails.logger.info("[Streams::NodeProvisioner] #{node.slug} MediaMTX ready")
return node
end
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
if remaining <= 0
Rails.logger.warn(
"[Streams::NodeProvisioner] #{node.slug} ancora provisioning dopo #{timeout}s " \
"(api=#{node.api_base_url}); autoscaler continuerà a promuovere"
)
return node
end
sleep([interval, remaining].min)
node.reload
end
end end
def urls_for_node(role:, home:, hostname:, simulated:, private_ip:, public_ip: nil, use_node_hostname:) def urls_for_node(role:, home:, hostname:, simulated:, private_ip:, public_ip: nil, use_node_hostname:)
+1 -1
View File
@@ -9,7 +9,7 @@
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<%= yield :head %> <%= yield :head %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=68"> <link rel="stylesheet" href="/marketing.css?v=76">
</head> </head>
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>> <body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
<%= render "shared/cookie_banner" %> <%= render "shared/cookie_banner" %>
@@ -7,7 +7,7 @@
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=68"> <link rel="stylesheet" href="/marketing.css?v=76">
<link rel="stylesheet" href="/live.css?v=26"> <link rel="stylesheet" href="/live.css?v=26">
<%= yield :head %> <%= yield :head %>
</head> </head>
@@ -63,6 +63,13 @@
</div> </div>
</section> </section>
<section class="section wrap" aria-labelledby="contacts-social-title">
<div class="features-panel contacts-social">
<h2 id="contacts-social-title"><%= t("pages.contacts.social_title") %></h2>
<%= render "shared/social_links", modifier: "panel" %>
</div>
</section>
<section class="section wrap" aria-labelledby="contacts-form-title"> <section class="section wrap" aria-labelledby="contacts-form-title">
<div class="contacts-form-panel"> <div class="contacts-form-panel">
<h2 id="contacts-form-title"><%= t("pages.contacts.form_title") %></h2> <h2 id="contacts-form-title"><%= t("pages.contacts.form_title") %></h2>
@@ -1,10 +1,9 @@
<footer class="site-footer"> <footer class="site-footer">
<div class="wrap"> <div class="wrap">
<div> <div class="site-footer__brand">
<strong style="color:#fff">Match Live TV</strong><%= t("footer.tagline") %> <strong style="color:#fff">Match Live TV</strong><%= t("footer.tagline") %>
<%= render "shared/store_badges", variant: "footer" %>
</div> </div>
<div> <div class="site-footer__nav">
<%= link_to t("common.contacts"), public_contatti_path %> · <%= link_to t("common.contacts"), public_contatti_path %> ·
<%= link_to t("common.support"), public_support_path %> · <%= link_to t("common.support"), public_support_path %> ·
<%= link_to t("common.pricing"), public_prezzi_path %> · <%= link_to t("common.pricing"), public_prezzi_path %> ·
@@ -15,6 +14,10 @@
<%= link_to t("common.terms"), public_termini_path %> <%= link_to t("common.terms"), public_termini_path %>
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button> · <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
</div> </div>
<div class="site-footer__apps">
<%= render "shared/store_badges", variant: "footer" %>
<%= render "shared/social_links", modifier: "footer" %>
</div>
<div class="site-footer__legal"> <div class="site-footer__legal">
<p><%= t("footer.copyright") %></p> <p><%= t("footer.copyright") %></p>
<p><%= t("footer.responsibility") %></p> <p><%= t("footer.responsibility") %></p>
@@ -1,16 +1,19 @@
<%# Footer minimale per App Store Review: solo link legali/supporto, nessun CTA commerciale. %> <%# Footer minimale per App Store Review: solo link legali/supporto, nessun CTA commerciale. %>
<footer class="site-footer"> <footer class="site-footer">
<div class="wrap"> <div class="wrap">
<div> <div class="site-footer__brand">
<strong style="color:#fff">Match Live TV</strong><%= t("footer.tagline") %> <strong style="color:#fff">Match Live TV</strong><%= t("footer.tagline") %>
</div> </div>
<div> <div class="site-footer__nav">
<%= link_to t("common.support"), public_support_path %> · <%= link_to t("common.support"), public_support_path %> ·
<%= link_to t("common.privacy"), public_privacy_path %> · <%= link_to t("common.privacy"), public_privacy_path %> ·
<%= link_to t("common.cookies"), public_cookies_path %> · <%= link_to t("common.cookies"), public_cookies_path %> ·
<%= link_to t("common.terms"), public_termini_path %> <%= link_to t("common.terms"), public_termini_path %>
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button> · <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
</div> </div>
<div class="site-footer__apps">
<%= render "shared/social_links", modifier: "footer" %>
</div>
<div class="site-footer__legal"> <div class="site-footer__legal">
<p><%= t("footer.copyright") %></p> <p><%= t("footer.copyright") %></p>
<p><%= t("footer.responsibility") %></p> <p><%= t("footer.responsibility") %></p>
@@ -0,0 +1,41 @@
<%# Social links: Facebook, Instagram, TikTok, YouTube — glyph brand-color su sfondo trasparente. %>
<% urls = {
facebook: "https://www.facebook.com/matchlivetvapp",
instagram: "https://www.instagram.com/matchlivetv_app/",
tiktok: "https://www.tiktok.com/@matchlivetv_app",
youtube: "https://www.youtube.com/@SportMatchLiveTv"
} %>
<% ig_grad_id = "ig-grad-#{SecureRandom.hex(4)}" %>
<nav class="site-social<%= local_assigns[:modifier].present? ? " site-social--#{modifier}" : "" %>" aria-label="<%= t("footer.social_nav") %>">
<a class="site-social__link site-social__link--facebook" href="<%= urls[:facebook] %>" target="_blank" rel="noopener noreferrer" aria-label="<%= t("footer.social_facebook") %>">
<svg class="site-social__icon" viewBox="0 0 24 24" width="36" height="36" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M14 8.2h2.2V5h-2.2C11.7 5 10 6.8 10 9.2V11H8v3.2h2V19h3.2v-4.8h2.2l.6-3.2h-2.8V9.2c0-.6.4-1 1-1z"/>
</svg>
</a>
<a class="site-social__link site-social__link--instagram" href="<%= urls[:instagram] %>" target="_blank" rel="noopener noreferrer" aria-label="<%= t("footer.social_instagram") %>">
<svg class="site-social__icon" viewBox="0 0 24 24" width="36" height="36" aria-hidden="true" focusable="false">
<defs>
<linearGradient id="<%= ig_grad_id %>" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#f58529"/>
<stop offset="45%" stop-color="#dd2a7b"/>
<stop offset="100%" stop-color="#515bd4"/>
</linearGradient>
</defs>
<path fill="url(#<%= ig_grad_id %>)" d="M12 7.2A4.8 4.8 0 1 0 12 16.8 4.8 4.8 0 0 0 12 7.2zm0 7.7a2.9 2.9 0 1 1 0-5.8 2.9 2.9 0 0 1 0 5.8z"/>
<circle fill="url(#<%= ig_grad_id %>)" cx="17.4" cy="6.7" r="1.1"/>
<path fill="url(#<%= ig_grad_id %>)" d="M12 2.5c-2.6 0-2.9 0-3.9.1-2.6.1-3.9 1.4-4 4-.1 1-.1 1.3-.1 3.9s0 2.9.1 3.9c.1 2.6 1.4 3.9 4 4 1 .1 1.3.1 3.9.1s2.9 0 3.9-.1c2.6-.1 3.9-1.4 4-4 .1-1 .1-1.3.1-3.9s0-2.9-.1-3.9c-.1-2.6-1.4-3.9-4-4-1-.1-1.3-.1-3.9-.1zm0 1.7c2.5 0 2.8 0 3.8.1 1.8.1 2.7.9 2.8 2.8.1 1 .1 1.3.1 3.8s0 2.8-.1 3.8c-.1 1.8-.9 2.7-2.8 2.8-1 .1-1.3.1-3.8.1s-2.8 0-3.8-.1c-1.8-.1-2.7-.9-2.8-2.8-.1-1-.1-1.3-.1-3.8s0-2.8.1-3.8c.1-1.8 1-2.7 2.8-2.8 1-.1 1.3-.1 3.8-.1z"/>
</svg>
</a>
<a class="site-social__link site-social__link--tiktok" href="<%= urls[:tiktok] %>" target="_blank" rel="noopener noreferrer" aria-label="<%= t("footer.social_tiktok") %>">
<svg class="site-social__icon" viewBox="0 0 24 24" width="36" height="36" aria-hidden="true" focusable="false">
<path fill="#69C9D0" transform="translate(1.15 0.75)" d="M19.1 8.3a5.7 5.7 0 0 1-3.4-1.1v6.2a5.4 5.4 0 1 1-5.4-5.4c.3 0 .5 0 .8.1v2.7a2.7 2.7 0 1 0 1.9 2.6V2.5h2.6a5.7 5.7 0 0 0 3.5 3.4v2.4z"/>
<path fill="#EE1D52" transform="translate(-1.15 -0.75)" d="M19.1 8.3a5.7 5.7 0 0 1-3.4-1.1v6.2a5.4 5.4 0 1 1-5.4-5.4c.3 0 .5 0 .8.1v2.7a2.7 2.7 0 1 0 1.9 2.6V2.5h2.6a5.7 5.7 0 0 0 3.5 3.4v2.4z"/>
<path fill="#fff" d="M19.1 8.3a5.7 5.7 0 0 1-3.4-1.1v6.2a5.4 5.4 0 1 1-5.4-5.4c.3 0 .5 0 .8.1v2.7a2.7 2.7 0 1 0 1.9 2.6V2.5h2.6a5.7 5.7 0 0 0 3.5 3.4v2.4z"/>
</svg>
</a>
<a class="site-social__link site-social__link--youtube" href="<%= urls[:youtube] %>" target="_blank" rel="noopener noreferrer" aria-label="<%= t("footer.social_youtube") %>">
<svg class="site-social__icon" viewBox="0 0 24 24" width="36" height="36" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M23.5 7.2a3 3 0 0 0-2.1-2.1C19.5 4.6 12 4.6 12 4.6s-7.5 0-9.4.5A3 3 0 0 0 .5 7.2 31.5 31.5 0 0 0 0 12a31.5 31.5 0 0 0 .5 4.8 3 3 0 0 0 2.1 2.1c1.9.5 9.4.5 9.4.5s7.5 0 9.4-.5a3 3 0 0 0 2.1-2.1A31.5 31.5 0 0 0 24 12a31.5 31.5 0 0 0-.5-4.8zM9.6 15.5v-7l6.3 3.5-6.3 3.5z"/>
</svg>
</a>
</nav>
+1
View File
@@ -192,6 +192,7 @@ de:
privacy_body: DSGVO-Rechte, Einwilligung und personenbezogene Daten. Schreib an privacy_body: DSGVO-Rechte, Einwilligung und personenbezogene Daten. Schreib an
company_title: Sitz company_title: Sitz
company_lead: Anbieter des Dienstes und Verantwortlicher für die Datenverarbeitung. company_lead: Anbieter des Dienstes und Verantwortlicher für die Datenverarbeitung.
social_title: Folgen Sie uns auch in den sozialen Medien
label_address: Adresse label_address: Adresse
label_vat: USt-IdNr. label_vat: USt-IdNr.
form_title: Nachricht senden form_title: Nachricht senden
+1
View File
@@ -192,6 +192,7 @@ en:
privacy_body: GDPR rights, consent and personal data. Write to privacy_body: GDPR rights, consent and personal data. Write to
company_title: Company details company_title: Company details
company_lead: Service provider and data controller. company_lead: Service provider and data controller.
social_title: Follow us on social media
label_address: Address label_address: Address
label_vat: VAT number label_vat: VAT number
form_title: Send a message form_title: Send a message
+1
View File
@@ -192,6 +192,7 @@ es:
privacy_body: Derechos RGPD, consentimiento y datos personales. Escribe a privacy_body: Derechos RGPD, consentimiento y datos personales. Escribe a
company_title: Sede company_title: Sede
company_lead: Prestador del servicio y responsable del tratamiento. company_lead: Prestador del servicio y responsable del tratamiento.
social_title: Síguenos también en redes sociales
label_address: Dirección label_address: Dirección
label_vat: NIF / IVA label_vat: NIF / IVA
form_title: Enviar un mensaje form_title: Enviar un mensaje
+1
View File
@@ -192,6 +192,7 @@ fr:
privacy_body: Droits RGPD, consentement et données personnelles. Écrivez à privacy_body: Droits RGPD, consentement et données personnelles. Écrivez à
company_title: Siège company_title: Siège
company_lead: Prestataire du service et responsable du traitement. company_lead: Prestataire du service et responsable du traitement.
social_title: Suivez-nous aussi sur les réseaux sociaux
label_address: Adresse label_address: Adresse
label_vat: N° de TVA label_vat: N° de TVA
form_title: Envoyer un message form_title: Envoyer un message
+1
View File
@@ -192,6 +192,7 @@ it:
privacy_body: Diritti GDPR, consenso e dati personali. Scrivi a privacy_body: Diritti GDPR, consenso e dati personali. Scrivi a
company_title: Sede company_title: Sede
company_lead: Titolare del servizio e del trattamento dei dati. company_lead: Titolare del servizio e del trattamento dei dati.
social_title: Seguici anche sui social
label_address: Indirizzo label_address: Indirizzo
label_vat: Partita IVA label_vat: Partita IVA
form_title: Invia un messaggio form_title: Invia un messaggio
+5
View File
@@ -24,6 +24,11 @@ de:
manage_cookies: Cookies verwalten manage_cookies: Cookies verwalten
copyright: "© 2026 Emiliano Frascaro USt-IdNr. 14230270960" copyright: "© 2026 Emiliano Frascaro USt-IdNr. 14230270960"
responsibility: Die übertragenen Inhalte liegen in der alleinigen Verantwortung der Sportvereine, die sie veröffentlichen. responsibility: Die übertragenen Inhalte liegen in der alleinigen Verantwortung der Sportvereine, die sie veröffentlichen.
social_nav: MatchLiveTV Social-Profile
social_facebook: Facebook MatchLiveTV
social_instagram: Instagram MatchLiveTV
social_tiktok: TikTok MatchLiveTV
social_youtube: YouTube MatchLiveTV
cookie: cookie:
title: Cookies und Datenschutz title: Cookies und Datenschutz
body_html: Wir verwenden notwendige Cookies für Login und Sicherheit. Mit Ihrer Zustimmung aktivieren wir auch <strong>Google Analytics</strong> für aggregierte Website-Statistiken. %{cookie_link} und %{privacy_link}. body_html: Wir verwenden notwendige Cookies für Login und Sicherheit. Mit Ihrer Zustimmung aktivieren wir auch <strong>Google Analytics</strong> für aggregierte Website-Statistiken. %{cookie_link} und %{privacy_link}.
+5
View File
@@ -24,6 +24,11 @@ en:
manage_cookies: Manage cookies manage_cookies: Manage cookies
copyright: "© 2026 Emiliano Frascaro VAT 14230270960" copyright: "© 2026 Emiliano Frascaro VAT 14230270960"
responsibility: Broadcast content is the sole responsibility of the sports clubs that publish it. responsibility: Broadcast content is the sole responsibility of the sports clubs that publish it.
social_nav: MatchLiveTV social profiles
social_facebook: Facebook MatchLiveTV
social_instagram: Instagram MatchLiveTV
social_tiktok: TikTok MatchLiveTV
social_youtube: YouTube MatchLiveTV
cookie: cookie:
title: Cookies and privacy title: Cookies and privacy
body_html: We use necessary cookies for login and security. With your consent we also enable <strong>Google Analytics</strong> for aggregate site statistics. %{cookie_link} and %{privacy_link}. body_html: We use necessary cookies for login and security. With your consent we also enable <strong>Google Analytics</strong> for aggregate site statistics. %{cookie_link} and %{privacy_link}.
+5
View File
@@ -24,6 +24,11 @@ es:
manage_cookies: Gestionar cookies manage_cookies: Gestionar cookies
copyright: "© 2026 Emiliano Frascaro NIF 14230270960" copyright: "© 2026 Emiliano Frascaro NIF 14230270960"
responsibility: Los contenidos emitidos son responsabilidad exclusiva de los clubes deportivos que los publican. responsibility: Los contenidos emitidos son responsabilidad exclusiva de los clubes deportivos que los publican.
social_nav: Perfiles sociales MatchLiveTV
social_facebook: Facebook MatchLiveTV
social_instagram: Instagram MatchLiveTV
social_tiktok: TikTok MatchLiveTV
social_youtube: YouTube MatchLiveTV
cookie: cookie:
title: Cookies y privacidad title: Cookies y privacidad
body_html: Usamos cookies necesarias para el inicio de sesión y la seguridad. Con tu consentimiento también activamos <strong>Google Analytics</strong> para estadísticas agregadas del sitio. %{cookie_link} y %{privacy_link}. body_html: Usamos cookies necesarias para el inicio de sesión y la seguridad. Con tu consentimiento también activamos <strong>Google Analytics</strong> para estadísticas agregadas del sitio. %{cookie_link} y %{privacy_link}.
+5
View File
@@ -24,6 +24,11 @@ fr:
manage_cookies: Gérer les cookies manage_cookies: Gérer les cookies
copyright: "© 2026 Emiliano Frascaro TVA 14230270960" copyright: "© 2026 Emiliano Frascaro TVA 14230270960"
responsibility: Les contenus diffusés relèvent de la seule responsabilité des clubs sportifs qui les publient. responsibility: Les contenus diffusés relèvent de la seule responsabilité des clubs sportifs qui les publient.
social_nav: Profils sociaux MatchLiveTV
social_facebook: Facebook MatchLiveTV
social_instagram: Instagram MatchLiveTV
social_tiktok: TikTok MatchLiveTV
social_youtube: YouTube MatchLiveTV
cookie: cookie:
title: Cookies et confidentialité title: Cookies et confidentialité
body_html: Nous utilisons des cookies nécessaires pour la connexion et la sécurité. Avec votre consentement, nous activons aussi <strong>Google Analytics</strong> pour des statistiques agrégées. %{cookie_link} et %{privacy_link}. body_html: Nous utilisons des cookies nécessaires pour la connexion et la sécurité. Avec votre consentement, nous activons aussi <strong>Google Analytics</strong> pour des statistiques agrégées. %{cookie_link} et %{privacy_link}.
+5
View File
@@ -24,6 +24,11 @@ it:
manage_cookies: Gestisci cookie manage_cookies: Gestisci cookie
copyright: "© 2026 Emiliano Frascaro P. IVA 14230270960" copyright: "© 2026 Emiliano Frascaro P. IVA 14230270960"
responsibility: I contenuti trasmessi sono di esclusiva responsabilità delle società sportive che li pubblicano. responsibility: I contenuti trasmessi sono di esclusiva responsabilità delle società sportive che li pubblicano.
social_nav: Profili social MatchLiveTV
social_facebook: Facebook MatchLiveTV
social_instagram: Instagram MatchLiveTV
social_tiktok: TikTok MatchLiveTV
social_youtube: YouTube MatchLiveTV
cookie: cookie:
title: Cookie e privacy title: Cookie e privacy
body_html: Usiamo cookie necessari per login e sicurezza. Con il tuo consenso attiviamo anche <strong>Google Analytics</strong> per statistiche aggregate sul sito. %{cookie_link} e %{privacy_link}. body_html: Usiamo cookie necessari per login e sicurezza. Con il tuo consenso attiviamo anche <strong>Google Analytics</strong> per statistiche aggregate sul sito. %{cookie_link} e %{privacy_link}.
+86 -2
View File
@@ -1307,7 +1307,7 @@ body.nav-menu-open { overflow: hidden; }
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }
.store-badges--footer { .store-badges--footer {
margin-top: 12px; margin-top: 0;
} }
.store-badges--footer .store-badge { .store-badges--footer .store-badge {
min-height: 42px; min-height: 42px;
@@ -2459,10 +2459,94 @@ body.nav-menu-open { overflow: hidden; }
} }
.stripe-secure--compact i { font-size: 0.95rem; color: #888; } .stripe-secure--compact i { font-size: 0.95rem; color: #888; }
.site-footer { border-top: 1px solid #252530; padding: 32px 0; margin-top: 40px; color: #888; font-size: 0.88rem; } .site-footer { border-top: 1px solid #252530; padding: 32px 0; margin-top: 40px; color: #888; font-size: 0.88rem; }
.site-footer .wrap { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; } .site-footer .wrap { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; align-items: center; }
.site-footer__brand {
flex: 1 1 auto;
min-width: 0;
}
.site-footer__nav {
flex: 0 1 auto;
text-align: right;
}
.site-footer__apps {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px 20px;
width: 100%;
flex: 1 1 100%;
}
.site-footer__legal { flex: 1 1 100%; margin-top: 4px; } .site-footer__legal { flex: 1 1 100%; margin-top: 4px; }
.site-footer__legal p { margin: 0 0 6px; line-height: 1.45; } .site-footer__legal p { margin: 0 0 6px; line-height: 1.45; }
.site-footer__legal p:last-child { margin-bottom: 0; } .site-footer__legal p:last-child { margin-bottom: 0; }
.site-social {
display: flex;
align-items: center;
gap: 10px;
margin-top: 12px;
}
.site-social__link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 8px;
background: transparent;
border: none;
text-decoration: none;
opacity: 0.95;
transition: opacity 0.18s ease, transform 0.18s ease;
}
.site-social__link:hover,
.site-social__link:focus-visible {
opacity: 1;
transform: translateY(-1px);
outline: none;
}
.site-social__link--facebook {
color: #1877F2;
}
.site-social__link--instagram {
color: #dd2a7b;
}
.site-social__link--youtube {
color: #FF0000;
}
.site-social__icon {
display: block;
width: 36px;
height: 36px;
}
.site-social--footer {
margin-top: 0;
margin-left: auto;
justify-content: flex-end;
}
.site-social--panel {
margin-top: 4px;
justify-content: center;
gap: 16px;
}
.site-social--panel .site-social__link {
width: 64px;
height: 64px;
}
.site-social--panel .site-social__icon {
width: 44px;
height: 44px;
}
.contacts-company,
.contacts-social {
max-width: 640px;
margin-left: auto;
margin-right: auto;
text-align: center;
}
.contacts-social h2 {
margin: 0 0 14px;
}
.compare-table-wrap { .compare-table-wrap {
margin-top: 28px; margin-top: 28px;
} }
@@ -0,0 +1,52 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe "API session create ingest unavailable", type: :request do
let!(:user) do
User.create!(email: "ingest-#{SecureRandom.hex(4)}@example.com", name: "Coach", password: "Password123", role: "coach")
end
let!(:club) { Club.create!(name: "IngestClub", sport: "volleyball") }
let!(:membership) { club.club_memberships.create!(user: user, role: "owner") }
let!(:team) { club.teams.create!(name: "Tigers", sport: "volleyball", slug: "ingest-tigers-#{SecureRandom.hex(3)}") }
let!(:match) { team.matches.create!(opponent_name: "Opp", scheduled_at: 1.hour.from_now) }
def auth_headers
post "/api/v1/auth/login", params: { email: user.email, password: "Password123" }
token = response.parsed_body["access_token"]
{ "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" }
end
before do
plan = Plan.find_or_initialize_by(slug: "premium_full")
plan.name ||= "Premium Full"
plan.features = (plan.features || {}).merge(
"platforms" => %w[matchlivetv youtube],
"youtube_enabled" => true,
"concurrent_streams_limit" => 10,
"recordings_enabled" => true,
"recording_days" => 90
)
plan.save!
club.create_subscription!(plan: plan, status: "active") if club.subscription.blank?
allow_any_instance_of(Teams::Entitlements).to receive(:assert_can_stream_on!)
allow_any_instance_of(Teams::Entitlements).to receive(:assert_concurrent_stream!)
allow(Streams::CoverSlateEnsurer).to receive(:ensure_for!)
allow(Streams::SlateDistributor).to receive(:ensure_for!)
Streams::NodeRegistry.ensure_home_from_env!
end
it "returns 503 when MediaMTX connection fails after retries" do
allow_any_instance_of(Mediamtx::Client).to receive(:create_path)
.and_raise(Faraday::ConnectionFailed.new("Connection refused"))
post "/api/v1/matches/#{match.id}/sessions",
params: { platform: "matchlivetv", privacy_status: "private" }.to_json,
headers: auth_headers
expect(response).to have_http_status(:service_unavailable)
body = response.parsed_body
expect(body["error_code"]).to eq("stream_ingest_unavailable")
end
end
@@ -0,0 +1,50 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Mediamtx::Client do
describe "#create_path" do
let(:session) do
user = User.create!(email: "mtx-#{SecureRandom.hex(4)}@example.com", name: "M", password: "Password123", role: "coach")
club = Club.create!(name: "MtxClub-#{SecureRandom.hex(3)}", sport: "volleyball")
team = club.teams.create!(name: "T", sport: "volleyball", slug: "mtx-#{SecureRandom.hex(4)}")
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "idle")
end
it "retries Faraday connection failures then succeeds" do
ENV["MEDIAMTX_CREATE_RETRIES"] = "3"
ENV["MEDIAMTX_CREATE_RETRY_BASE_SECS"] = "0"
client = described_class.new(base_url: "http://mtx.test:9997")
conn = instance_double(Faraday::Connection)
client.instance_variable_set(:@conn, conn)
fail_once = Faraday::ConnectionFailed.new("Connection refused")
ok = instance_double(Faraday::Response, success?: true, status: 200, body: {})
expect(conn).to receive(:post).once.and_raise(fail_once)
expect(conn).to receive(:post).once.and_return(ok)
expect(client.create_path(session)).to eq(true)
ensure
ENV.delete("MEDIAMTX_CREATE_RETRIES")
ENV.delete("MEDIAMTX_CREATE_RETRY_BASE_SECS")
end
it "raises after exhausting connection retries" do
ENV["MEDIAMTX_CREATE_RETRIES"] = "2"
ENV["MEDIAMTX_CREATE_RETRY_BASE_SECS"] = "0"
client = described_class.new(base_url: "http://mtx.test:9997")
conn = instance_double(Faraday::Connection)
client.instance_variable_set(:@conn, conn)
allow(conn).to receive(:post).and_raise(Faraday::ConnectionFailed.new("Connection refused"))
expect { client.create_path(session) }.to raise_error(Faraday::ConnectionFailed)
ensure
ENV.delete("MEDIAMTX_CREATE_RETRIES")
ENV.delete("MEDIAMTX_CREATE_RETRY_BASE_SECS")
end
end
end
@@ -224,4 +224,41 @@ RSpec.describe Streams::Autoscaler do
expect(described_class.within_budget?(1)).to eq(false) expect(described_class.within_budget?(1)).to eq(false)
end end
end end
it "promotes provisioning nodes when MediaMTX becomes reachable" do
with_env(
"STREAM_AUTOSCALE_ENABLED" => "1",
"STREAM_AUTOSCALE_WARM_SPARE" => "0",
"STREAM_AUTOSCALE_SOFT_FREE_SLOTS" => "0",
"STREAM_AUTOSCALE_KIND" => "lab",
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
"MEDIAMTX_RTMP_URL" => "rtmp://home.example:1935",
"HLS_PUBLIC_URL" => "https://home.example/hls"
) do
Streams::NodeRegistry.ensure_home_from_env!
node = StreamNode.create!(
slug: "ingest-lab-prov",
hostname: "prov.lab",
role: "lab",
status: "provisioning",
provider: "local",
rtmp_base_url: "rtmp://h:1935",
hls_base_url: "https://h/hls",
api_base_url: "http://h:9997",
max_publishers: 2,
max_relays: 2
)
allow(Streams::NodeHealth).to receive(:promote_if_healthy!) do |n|
n.update!(status: "ready", last_health_at: Time.current)
true
end
provisioner = instance_double(Streams::NodeProvisioner)
expect(provisioner).not_to receive(:provision_lab!)
result = described_class.reconcile!(provisioner: provisioner)
expect(result.actions).to include(:"ready_ingest-lab-prov")
expect(node.reload.status).to eq("ready")
end
end
end end
@@ -0,0 +1,39 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Streams::NodeHealth do
let!(:node) do
StreamNode.create!(
slug: "ingest-health-01",
hostname: "ingest-health-01.mltv-stream.net",
role: "cloud",
status: "provisioning",
provider: "hetzner",
rtmp_base_url: "rtmp://h:1935",
hls_base_url: "https://h/hls",
api_base_url: "http://203.0.113.10:9997",
max_publishers: 4,
max_relays: 4
)
end
after { node.destroy }
it "promotes provisioning node when MediaMTX is reachable" do
client = instance_double(Mediamtx::Client, reachable?: true)
allow(Mediamtx::Client).to receive(:new).with(base_url: node.api_base_url).and_return(client)
expect(described_class.promote_if_healthy!(node)).to eq(true)
expect(node.reload.status).to eq("ready")
expect(node.last_health_at).to be_present
end
it "does not promote when MediaMTX is down" do
client = instance_double(Mediamtx::Client, reachable?: false)
allow(Mediamtx::Client).to receive(:new).with(base_url: node.api_base_url).and_return(client)
expect(described_class.promote_if_healthy!(node)).to eq(false)
expect(node.reload.status).to eq("provisioning")
end
end
@@ -24,11 +24,15 @@ RSpec.describe "Streams::NodeProvisioner cloud" do
ENV["STREAM_CLOUD_DNS_SUFFIX"] = "mltv-stream.net" ENV["STREAM_CLOUD_DNS_SUFFIX"] = "mltv-stream.net"
ENV["STREAM_CLOUD_MAX_PUBLISHERS"] = "4" ENV["STREAM_CLOUD_MAX_PUBLISHERS"] = "4"
ENV["STREAM_CLOUD_PUBLIC_CONTROL"] = "0" ENV["STREAM_CLOUD_PUBLIC_CONTROL"] = "0"
ENV["STREAM_NODE_READY_TIMEOUT_SECS"] = "0"
allow(Streams::NodeHealth).to receive(:promote_if_healthy!).and_return(false)
node = Streams::NodeProvisioner.new(cloud: cloud, dns: dns).provision_cloud! node = Streams::NodeProvisioner.new(cloud: cloud, dns: dns).provision_cloud!
expect(node.slug).to eq("ingest-01") expect(node.slug).to eq("ingest-01")
expect(node.role).to eq("cloud") expect(node.role).to eq("cloud")
expect(node.provider).to eq("hetzner") expect(node.provider).to eq("hetzner")
expect(node.status).to eq("provisioning")
expect(node.hostname).to eq("ingest-01.mltv-stream.net") expect(node.hostname).to eq("ingest-01.mltv-stream.net")
expect(node.rtmp_base_url).to eq("rtmp://ingest-01.mltv-stream.net:1935") expect(node.rtmp_base_url).to eq("rtmp://ingest-01.mltv-stream.net:1935")
expect(node.api_base_url).to eq("http://10.0.0.9:9997") expect(node.api_base_url).to eq("http://10.0.0.9:9997")
@@ -39,6 +43,45 @@ RSpec.describe "Streams::NodeProvisioner cloud" do
%w[ %w[
MEDIAMTX_API_URL MEDIAMTX_RTMP_URL HLS_PUBLIC_URL MEDIAMTX_API_URL MEDIAMTX_RTMP_URL HLS_PUBLIC_URL
STREAM_CLOUD_DNS_SUFFIX STREAM_CLOUD_MAX_PUBLISHERS STREAM_CLOUD_PUBLIC_CONTROL STREAM_CLOUD_DNS_SUFFIX STREAM_CLOUD_MAX_PUBLISHERS STREAM_CLOUD_PUBLIC_CONTROL
STREAM_NODE_READY_TIMEOUT_SECS
].each { |k| ENV.delete(k) }
end
it "marks cloud node ready when MediaMTX answers during wait" do
cloud = instance_double(
Streams::CloudProviders::Hetzner,
create_node: Streams::CloudProviders::Instance.new(
id: "100",
name: "mltv-stream-ingest-01",
public_ip: "49.13.9.9",
private_ip: "10.0.0.9",
status: "running",
raw: {}
)
)
dns = instance_double(Streams::DnsProviders::Hetzner)
allow(dns).to receive(:upsert_a)
ENV["MEDIAMTX_API_URL"] = "http://mtx-home:9997"
ENV["MEDIAMTX_RTMP_URL"] = "rtmp://home.example:1935"
ENV["HLS_PUBLIC_URL"] = "https://home.example/hls"
ENV["STREAM_CLOUD_DNS_SUFFIX"] = "mltv-stream.net"
ENV["STREAM_CLOUD_PUBLIC_CONTROL"] = "0"
ENV["STREAM_NODE_READY_TIMEOUT_SECS"] = "5"
ENV["STREAM_NODE_READY_POLL_SECS"] = "0.01"
allow(Streams::NodeHealth).to receive(:promote_if_healthy!) do |node|
node.update!(status: "ready", last_health_at: Time.current)
true
end
node = Streams::NodeProvisioner.new(cloud: cloud, dns: dns).provision_cloud!
expect(node.status).to eq("ready")
ensure
%w[
MEDIAMTX_API_URL MEDIAMTX_RTMP_URL HLS_PUBLIC_URL
STREAM_CLOUD_DNS_SUFFIX STREAM_CLOUD_PUBLIC_CONTROL
STREAM_NODE_READY_TIMEOUT_SECS STREAM_NODE_READY_POLL_SECS
].each { |k| ENV.delete(k) } ].each { |k| ENV.delete(k) }
end end
end end