Compare commits

..
Author SHA1 Message Date
eminuxandCursor 35dfa923e3 Usa packageName dinamico negli E2E Android per la variante collaudo.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 10:22:20 +02:00
eminuxandCursor 9d8b35c06c Salva telemetria client (OS, app, device, operatore) sulle sessioni per il debug admin.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 09:43:43 +02:00
eminuxandCursor 873e0ea55c Fix race analytics: retry su unique violation in aggregazione heatmap.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 16:23:02 +02:00
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
56 changed files with 1022 additions and 48 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
@@ -77,6 +77,7 @@ module Api
thermal_state: sanitized_thermal_state, thermal_state: sanitized_thermal_state,
last_seen_at: Time.current last_seen_at: Time.current
) )
Sessions::ApplyClientInfo.call(@session, params[:client]) if params[:client].present?
sync_publisher_when_streaming!(params[:fps].to_f) sync_publisher_when_streaming!(params[:fps].to_f)
SessionChannel.broadcast_message(@session, state.as_cable_payload) SessionChannel.broadcast_message(@session, state.as_cable_payload)
head :no_content head :no_content
@@ -180,7 +181,12 @@ module Api
end end
def session_params def session_params
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel) params.permit(
:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel,
client: %i[os client_os app_version app_build version build build_number
device_manufacturer manufacturer device_model model
os_version system_version carrier network_operator operator]
)
end end
def score_sync_params def score_sync_params
+23
View File
@@ -81,6 +81,29 @@ module AdminHelper
end end
end end
def admin_session_client_os_label(session)
case session.client_os.to_s
when "android" then "Android"
when "ios" then "iOS"
else I18n.t("admin.common.dash")
end
end
def admin_session_client_summary(session)
parts = []
parts << admin_session_client_os_label(session) if session.client_os.present?
if session.app_version.present?
ver = session.app_version
ver = "#{ver} (#{session.app_build})" if session.app_build.present?
parts << "app #{ver}"
end
device = [session.device_manufacturer, session.device_model].compact_blank.join(" ")
parts << device if device.present?
parts << "OS #{session.os_version}" if session.os_version.present?
parts << session.carrier if session.carrier.present?
parts.presence&.join(" · ") || I18n.t("admin.common.dash")
end
def admin_format_event_meta(metadata) def admin_format_event_meta(metadata)
return content_tag(:span, I18n.t("admin.common.dash"), class: "muted") if metadata.blank? return content_tag(:span, I18n.t("admin.common.dash"), class: "muted") if metadata.blank?
+32 -15
View File
@@ -3,6 +3,7 @@
module Analytics module Analytics
class Aggregate class Aggregate
BATCH = 500 BATCH = 500
UPSERT_RETRIES = 3
def call def call
loop do loop do
@@ -45,13 +46,15 @@ module Analytics
now = Time.current now = Time.current
grouped.each do |(day, page_path, device, cell_x, cell_y), count| grouped.each do |(day, page_path, device, cell_x, cell_y), count|
cell = AnalyticsPageCell.find_or_initialize_by( with_unique_retry do
day: day, page_path: page_path, device: device, cell_x: cell_x, cell_y: cell_y cell = AnalyticsPageCell.find_or_initialize_by(
) day: day, page_path: page_path, device: device, cell_x: cell_x, cell_y: cell_y
cell[counter_attr] = cell[counter_attr].to_i + count )
cell.created_at ||= now cell[counter_attr] = cell[counter_attr].to_i + count
cell.updated_at = now cell.created_at ||= now
cell.save! cell.updated_at = now
cell.save!
end
end end
end end
@@ -76,14 +79,28 @@ module Analytics
now = Time.current now = Time.current
grouped.each do |(day, page_path, device), vals| grouped.each do |(day, page_path, device), vals|
stat = AnalyticsPageStat.find_or_initialize_by(day: day, page_path: page_path, device: device) with_unique_retry do
stat.pageview_count = stat.pageview_count.to_i + vals[:pageviews] stat = AnalyticsPageStat.find_or_initialize_by(day: day, page_path: page_path, device: device)
stat.scroll_samples = stat.scroll_samples.to_i + vals[:scroll_samples] stat.pageview_count = stat.pageview_count.to_i + vals[:pageviews]
stat.scroll_sum_pct = stat.scroll_sum_pct.to_i + vals[:scroll_sum] stat.scroll_samples = stat.scroll_samples.to_i + vals[:scroll_samples]
stat.max_scroll_pct = [stat.max_scroll_pct.to_i, vals[:max_scroll]].max stat.scroll_sum_pct = stat.scroll_sum_pct.to_i + vals[:scroll_sum]
stat.created_at ||= now stat.max_scroll_pct = [stat.max_scroll_pct.to_i, vals[:max_scroll]].max
stat.updated_at = now stat.created_at ||= now
stat.save! stat.updated_at = now
stat.save!
end
end
end
def with_unique_retry
attempts = 0
begin
attempts += 1
yield
rescue ActiveRecord::RecordNotUnique
raise if attempts >= UPSERT_RETRIES
retry
end end
end end
end end
+9 -1
View File
@@ -31,7 +31,15 @@ module Analytics
end end
AnalyticsEvent.insert_all(rows) if rows.any? AnalyticsEvent.insert_all(rows) if rows.any?
Analytics::Aggregate.new.call if rows.any? if rows.any?
begin
Analytics::Aggregate.new.call
rescue ActiveRecord::RecordNotUnique => e
# Dopo i retry interni: non far fallire la richiesta analytics.
Rails.logger.warn("[analytics] aggregate race after retries, enqueue job: #{e.message}")
Analytics::AggregateJob.perform_later
end
end
Result.new(accepted: accepted, rejected: rejected + (@events.size - slice.size), rate_limited: false) Result.new(accepted: accepted, rejected: rejected + (@events.size - slice.size), rate_limited: false)
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?
@@ -0,0 +1,72 @@
# frozen_string_literal: true
module Sessions
# Normalizza e applica fingerprint del client (OS, app, device, operatore)
# sulla sessione, a create e/o a ogni telemetry.
class ApplyClientInfo
OS_VALUES = %w[android ios].freeze
MAX_LEN = 80
ATTRS = %i[
client_os app_version app_build device_manufacturer device_model os_version carrier
].freeze
def self.call(session, raw)
new(session, raw).call
end
def initialize(session, raw)
@session = session
@raw = normalize_hash(raw)
end
def call
attrs = extract_attrs
return @session if attrs.empty?
@session.assign_attributes(attrs)
@session.save! if @session.persisted? && @session.changed?
@session
end
private
def normalize_hash(raw)
return {} if raw.blank?
data = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw
data = data.to_h if data.respond_to?(:to_h)
data.with_indifferent_access
rescue StandardError
{}
end
def extract_attrs
attrs = {}
os = @raw[:os].presence || @raw[:client_os].presence
os = os.to_s.downcase.strip
attrs[:client_os] = os if OS_VALUES.include?(os)
{
app_version: %i[app_version version],
app_build: %i[app_build build build_number],
device_manufacturer: %i[device_manufacturer manufacturer],
device_model: %i[device_model model],
os_version: %i[os_version system_version],
carrier: %i[carrier network_operator operator]
}.each do |column, keys|
value = keys.map { |k| @raw[k] }.find(&:present?)
next if value.blank?
attrs[column] = truncate(value.to_s.strip)
end
attrs
end
def truncate(value)
value.bytesize <= MAX_LEN ? value : value.byteslice(0, MAX_LEN)
end
end
end
+13 -2
View File
@@ -26,6 +26,7 @@ module Sessions
target_fps: @params[:target_fps] || 30, target_fps: @params[:target_fps] || 30,
status: "idle" status: "idle"
) )
Sessions::ApplyClientInfo.call(session, @params[:client])
youtube_channel = nil youtube_channel = nil
if session.platform == "youtube" if session.platform == "youtube"
@@ -46,8 +47,11 @@ module Sessions
{ {
created: true, created: true,
platform: session.platform, platform: session.platform,
stream_node: session.stream_node&.slug stream_node: session.stream_node&.slug,
} client_os: session.client_os,
app_version: session.app_version,
device_model: session.device_model
}.compact
) )
end end
@@ -58,6 +62,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:)
@@ -107,6 +107,7 @@
<tr> <tr>
<th><%= t("admin.dashboard.sessions.table.match") %></th> <th><%= t("admin.dashboard.sessions.table.match") %></th>
<th><%= t("admin.dashboard.sessions.table.status") %></th> <th><%= t("admin.dashboard.sessions.table.status") %></th>
<th><%= t("admin.dashboard.sessions.table.client") %></th>
<th><%= t("admin.dashboard.sessions.table.ingest") %></th> <th><%= t("admin.dashboard.sessions.table.ingest") %></th>
<th><%= t("admin.dashboard.sessions.table.start") %></th> <th><%= t("admin.dashboard.sessions.table.start") %></th>
<th><%= t("admin.dashboard.sessions.table.link") %></th> <th><%= t("admin.dashboard.sessions.table.link") %></th>
@@ -118,6 +119,7 @@
<tr> <tr>
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td> <td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
<td><span class="badge badge--<%= s.status == 'live' ? 'live' : (s.status == 'paused' ? 'paused' : 'connecting') %>"><%= s.status %></span></td> <td><span class="badge badge--<%= s.status == 'live' ? 'live' : (s.status == 'paused' ? 'paused' : 'connecting') %>"><%= s.status %></span></td>
<td class="muted"><%= admin_session_client_summary(s) %></td>
<td><%= render "admin/sessions/ingest_cell", session: s %></td> <td><%= render "admin/sessions/ingest_cell", session: s %></td>
<td class="muted"><%= s.started_at&.strftime("%d/%m %H:%M") || t("admin.common.dash") %></td> <td class="muted"><%= s.started_at&.strftime("%d/%m %H:%M") || t("admin.common.dash") %></td>
<td> <td>
@@ -83,6 +83,7 @@
<th><%= t("admin.sessions.index.table.ended") %></th> <th><%= t("admin.sessions.index.table.ended") %></th>
<th><%= t("admin.sessions.index.table.duration") %></th> <th><%= t("admin.sessions.index.table.duration") %></th>
<th><%= t("admin.sessions.index.table.ingest") %></th> <th><%= t("admin.sessions.index.table.ingest") %></th>
<th><%= t("admin.sessions.index.table.client") %></th>
<th><%= t("admin.sessions.index.table.disconnects") %></th> <th><%= t("admin.sessions.index.table.disconnects") %></th>
<th><%= t("admin.sessions.index.table.link") %></th> <th><%= t("admin.sessions.index.table.link") %></th>
<th></th> <th></th>
@@ -110,6 +111,7 @@
<td class="muted"><%= s.ended_at ? admin_datetime(s.ended_at) : t("admin.common.dash") %></td> <td class="muted"><%= s.ended_at ? admin_datetime(s.ended_at) : t("admin.common.dash") %></td>
<td class="muted"><%= admin_session_duration_label(s) %></td> <td class="muted"><%= admin_session_duration_label(s) %></td>
<td><%= render "admin/sessions/ingest_cell", session: s %></td> <td><%= render "admin/sessions/ingest_cell", session: s %></td>
<td class="muted admin-table-sub"><%= admin_session_client_summary(s) %></td>
<td><%= s.disconnection_count %></td> <td><%= s.disconnection_count %></td>
<td> <td>
<div class="admin-link-compact"> <div class="admin-link-compact">
@@ -67,6 +67,38 @@
<dt><%= t("admin.sessions.show.fields.platform") %></dt> <dt><%= t("admin.sessions.show.fields.platform") %></dt>
<dd><%= @session.platform %></dd> <dd><%= @session.platform %></dd>
</div> </div>
<div>
<dt><%= t("admin.sessions.show.fields.client_os") %></dt>
<dd><%= admin_session_client_os_label(@session) %></dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.app_version") %></dt>
<dd>
<% if @session.app_version.present? %>
<%= @session.app_version %>
<% if @session.app_build.present? %>
<span class="muted">(<%= @session.app_build %>)</span>
<% end %>
<% else %>
<%= t("admin.common.dash") %>
<% end %>
</dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.device") %></dt>
<dd>
<% device = [@session.device_manufacturer, @session.device_model].compact_blank.join(" ") %>
<%= device.presence || t("admin.common.dash") %>
</dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.os_version") %></dt>
<dd><%= @session.os_version.presence || t("admin.common.dash") %></dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.carrier") %></dt>
<dd><%= @session.carrier.presence || t("admin.common.dash") %></dd>
</div>
<div> <div>
<dt><%= t("admin.sessions.show.fields.privacy") %></dt> <dt><%= t("admin.sessions.show.fields.privacy") %></dt>
<dd><%= @session.privacy_status %></dd> <dd><%= @session.privacy_status %></dd>
+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>
+7
View File
@@ -111,6 +111,7 @@ de:
table: table:
match: Spiel match: Spiel
status: Status status: Status
client: Client
ingest: Ingest ingest: Ingest
start: Start start: Start
link: Link link: Link
@@ -276,6 +277,7 @@ de:
duration: Dauer duration: Dauer
ingest: Ingest ingest: Ingest
disconnects: Verbindungsabbrüche disconnects: Verbindungsabbrüche
client: Client
link: Link link: Link
detail: Details detail: Details
regia: Regie regia: Regie
@@ -308,6 +310,11 @@ de:
opponent: Gegner opponent: Gegner
operator: Operator operator: Operator
platform: Plattform platform: Plattform
client_os: System
app_version: App-Version
device: Gerät
os_version: OS-Version
carrier: Mobilfunkanbieter
privacy: Privacy privacy: Privacy
quality: Qualität quality: Qualität
min_quality: Min. Qualität min_quality: Min. Qualität
+7
View File
@@ -111,6 +111,7 @@ en:
table: table:
match: Match match: Match
status: Status status: Status
client: Client
ingest: Ingest ingest: Ingest
start: Start start: Start
link: Link link: Link
@@ -276,6 +277,7 @@ en:
duration: Duration duration: Duration
ingest: Ingest ingest: Ingest
disconnects: Disconnects disconnects: Disconnects
client: Client
link: Link link: Link
detail: Details detail: Details
regia: Control regia: Control
@@ -308,6 +310,11 @@ en:
opponent: Opponent opponent: Opponent
operator: Operator operator: Operator
platform: Platform platform: Platform
client_os: OS
app_version: App version
device: Device
os_version: OS version
carrier: Carrier
privacy: Privacy privacy: Privacy
quality: Quality quality: Quality
min_quality: Min quality min_quality: Min quality
+7
View File
@@ -111,6 +111,7 @@ es:
table: table:
match: Partido match: Partido
status: Estado status: Estado
client: Cliente
ingest: Ingest ingest: Ingest
start: Inicio start: Inicio
link: Enlace link: Enlace
@@ -276,6 +277,7 @@ es:
duration: Duración duration: Duración
ingest: Ingest ingest: Ingest
disconnects: Desconexiones disconnects: Desconexiones
client: Cliente
link: Enlace link: Enlace
detail: Detalle detail: Detalle
regia: Regie regia: Regie
@@ -308,6 +310,11 @@ es:
opponent: Rival opponent: Rival
operator: Operador operator: Operador
platform: Plataforma platform: Plataforma
client_os: Sistema
app_version: Versión app
device: Dispositivo
os_version: Versión OS
carrier: Operador móvil
privacy: Privacidad privacy: Privacidad
quality: Calidad quality: Calidad
min_quality: Calidad mínima min_quality: Calidad mínima
+7
View File
@@ -111,6 +111,7 @@ fr:
table: table:
match: Match match: Match
status: Statut status: Statut
client: Client
ingest: Ingest ingest: Ingest
start: Début start: Début
link: Lien link: Lien
@@ -276,6 +277,7 @@ fr:
duration: Durée duration: Durée
ingest: Ingest ingest: Ingest
disconnects: Déconnexions disconnects: Déconnexions
client: Client
link: Lien link: Lien
detail: Détail detail: Détail
regia: Régie regia: Régie
@@ -308,6 +310,11 @@ fr:
opponent: Adversaire opponent: Adversaire
operator: Opérateur operator: Opérateur
platform: Plateforme platform: Plateforme
client_os: Système
app_version: Version app
device: Appareil
os_version: Version OS
carrier: Opérateur mobile
privacy: Confidentialité privacy: Confidentialité
quality: Qualité quality: Qualité
min_quality: Qualité mini min_quality: Qualité mini
+7
View File
@@ -115,6 +115,7 @@ it:
table: table:
match: Partita match: Partita
status: Stato status: Stato
client: Client
ingest: Ingest ingest: Ingest
start: Inizio start: Inizio
link: Link link: Link
@@ -297,6 +298,7 @@ it:
duration: Durata duration: Durata
ingest: Ingest ingest: Ingest
disconnects: Disconnessioni disconnects: Disconnessioni
client: Client
link: Link link: Link
detail: Dettaglio detail: Dettaglio
regia: Regia regia: Regia
@@ -329,6 +331,11 @@ it:
opponent: Avversario opponent: Avversario
operator: Operatore operator: Operatore
platform: Piattaforma platform: Piattaforma
client_os: Sistema
app_version: Versione app
device: Dispositivo
os_version: Versione OS
carrier: Operatore telefonico
privacy: Privacy privacy: Privacy
quality: Qualità quality: Qualità
min_quality: Qualità minima min_quality: Qualità minima
+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}.
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class AddClientTelemetryToStreamSessions < ActiveRecord::Migration[7.2]
def change
change_table :stream_sessions, bulk: true do |t|
t.string :client_os
t.string :app_version
t.string :app_build
t.string :device_manufacturer
t.string :device_model
t.string :os_version
t.string :carrier
end
end
end
+8 -1
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_08_20_220000) do ActiveRecord::Schema[7.2].define(version: 2026_08_26_090000) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -427,6 +427,13 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_20_220000) do
t.uuid "stream_node_id" t.uuid "stream_node_id"
t.string "min_quality_preset", default: "auto", null: false t.string "min_quality_preset", default: "auto", null: false
t.boolean "audio_muted", default: false, null: false t.boolean "audio_muted", default: false, null: false
t.string "client_os"
t.string "app_version"
t.string "app_build"
t.string "device_manufacturer"
t.string "device_model"
t.string "os_version"
t.string "carrier"
t.index ["match_id"], name: "index_stream_sessions_on_match_id" t.index ["match_id"], name: "index_stream_sessions_on_match_id"
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
+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
@@ -0,0 +1,45 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Sessions::ApplyClientInfo do
let!(:user) { User.create!(email: "client-info@test.it", name: "U", password: "Password123", role: "coach") }
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "idle") }
it "applica i campi client sulla sessione" do
described_class.call(session, {
os: "android",
app_version: "1.4.0",
app_build: "42",
device_manufacturer: "Samsung",
device_model: "SM-G991B",
os_version: "14",
carrier: "TIM"
})
session.reload
expect(session.client_os).to eq("android")
expect(session.app_version).to eq("1.4.0")
expect(session.app_build).to eq("42")
expect(session.device_manufacturer).to eq("Samsung")
expect(session.device_model).to eq("SM-G991B")
expect(session.os_version).to eq("14")
expect(session.carrier).to eq("TIM")
end
it "ignora os sconosciuti" do
described_class.call(session, { os: "windows" })
expect(session.reload.client_os).to be_nil
end
it "assegna senza salvare su record non persistito" do
draft = StreamSession.new(match: match, user: user, platform: "matchlivetv", status: "idle")
described_class.call(draft, { os: "ios", app_version: "2.0.0" })
expect(draft).not_to be_persisted
expect(draft.client_os).to eq("ios")
expect(draft.app_version).to eq("2.0.0")
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
@@ -22,8 +22,8 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class E2EWizardFlowTest { class E2EWizardFlowTest {
private lateinit var device: UiDevice private lateinit var device: UiDevice
private val pkg = "com.matchlivetv.match_live_tv"
private val ctx by lazy { InstrumentationRegistry.getInstrumentation().targetContext } private val ctx by lazy { InstrumentationRegistry.getInstrumentation().targetContext }
private val pkg by lazy { ctx.packageName }
private fun s(id: Int): String = ctx.getString(id) private fun s(id: Int): String = ctx.getString(id)
private fun su(id: Int): String = s(id).uppercase() private fun su(id: Int): String = s(id).uppercase()
@@ -20,7 +20,7 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class LocalAdaptiveBitrateUiTest { class LocalAdaptiveBitrateUiTest {
private lateinit var device: UiDevice private lateinit var device: UiDevice
private val pkg = "com.matchlivetv.match_live_tv" private val pkg by lazy { InstrumentationRegistry.getInstrumentation().targetContext.packageName }
@Before @Before
fun setUp() { fun setUp() {
@@ -47,8 +47,21 @@ class LocalAdaptiveBitrateUiTest {
tapAny("AVANTI >", "NEXT >") tapAny("AVANTI >", "NEXT >")
waitForAny(45_000, "02 · Trasmissione", "02 · Broadcast") waitForAny(45_000, "02 · Trasmissione", "02 · Broadcast")
waitForAny(30_000, "Piattaforma", "Platform") waitForAny(30_000, "Piattaforma", "Platform")
scrollDown() // Come E2EWizardFlowTest: privacy non-in-elenco e AVANTI riprovato con scroll.
tapAny("AVANTI >", "NEXT >") waitForAny(10_000, "NON IN ELENCO", "UNLISTED")
runCatching { tapAny("NON IN ELENCO", "UNLISTED") }
val onNetworkStep = {
hasAny("03 · Test rete", "03 · Network test", "AVVIA TEST RETE", "START NETWORK TEST")
}
repeat(4) {
if (onNetworkStep()) return@repeat
scrollDown()
runCatching { tapAny("AVANTI >", "NEXT >") }
SystemClock.sleep(1_200)
if (onNetworkStep()) return@repeat
scrollUp()
SystemClock.sleep(800)
}
waitForAny(45_000, "03 · Test rete", "03 · Network test") waitForAny(45_000, "03 · Test rete", "03 · Network test")
waitForAny(30_000, "AVVIA TEST RETE", "START NETWORK TEST") waitForAny(30_000, "AVVIA TEST RETE", "START NETWORK TEST")
tapAny("AVVIA TEST RETE", "START NETWORK TEST") tapAny("AVVIA TEST RETE", "START NETWORK TEST")
@@ -262,4 +275,15 @@ class LocalAdaptiveBitrateUiTest {
SystemClock.sleep(300) SystemClock.sleep(300)
} }
} }
private fun scrollUp(steps: Int = 1) {
val centerX = device.displayWidth / 2
val startY = (device.displayHeight * 0.35).toInt()
val endY = (device.displayHeight * 0.75).toInt()
repeat(steps) {
device.swipe(centerX, startY, centerX, endY, 24)
device.waitForIdle()
SystemClock.sleep(300)
}
}
} }
@@ -28,7 +28,7 @@ class ReleaseApiSmokeTest {
fun login_parsesResponse() = runBlocking { fun login_parsesResponse() = runBlocking {
val session = container.authRepository.login( val session = container.authRepository.login(
email = "coach@matchlivetv.test", email = "coach@matchlivetv.test",
password = "password123", password = "Password123",
) )
assertEquals("coach@matchlivetv.test", session.user.email) assertEquals("coach@matchlivetv.test", session.user.email)
assertTrue(session.accessToken.isNotBlank()) assertTrue(session.accessToken.isNotBlank())
@@ -38,7 +38,7 @@ class ReleaseApiSmokeTest {
fun fetchMatches_afterLogin() = runBlocking { fun fetchMatches_afterLogin() = runBlocking {
container.authRepository.login( container.authRepository.login(
email = "coach@matchlivetv.test", email = "coach@matchlivetv.test",
password = "password123", password = "Password123",
) )
val matches = container.matchRepository.fetchMatches() val matches = container.matchRepository.fetchMatches()
assertTrue(matches.isNotEmpty()) assertTrue(matches.isNotEmpty())
@@ -48,15 +48,22 @@ class ReleaseApiSmokeTest {
fun scheduledMatch_parsesAndIsVisible() = runBlocking { fun scheduledMatch_parsesAndIsVisible() = runBlocking {
container.authRepository.login( container.authRepository.login(
email = "coach@matchlivetv.test", email = "coach@matchlivetv.test",
password = "password123", password = "Password123",
) )
val teams = container.matchRepository.fetchTeams() val teams = container.matchRepository.fetchTeams()
val tigers = teams.first { it.name == "Tigers Volley" } assertTrue(teams.isNotEmpty())
val raw = container.api.matches(tigers.id) var scheduled: com.matchlivetv.match_live_tv.data.api.MatchDto? = null
val scheduled = raw.first { it.opponentName.contains("Crazy Volley") } for (team in teams) {
assertNotNull(scheduled.scheduledAt) val found = container.api.matches(team.id).firstOrNull { !it.scheduledAt.isNullOrBlank() }
assertNotNull(parseApiInstant(scheduled.scheduledAt)) if (found != null) {
val domain = scheduled.toDomain() scheduled = found
break
}
}
val match = checkNotNull(scheduled) { "Nessuna partita con scheduled_at tra i team del coach" }
assertNotNull(match.scheduledAt)
assertNotNull(parseApiInstant(match.scheduledAt))
val domain = match.toDomain()
assertTrue(domain.isCoachHubVisible()) assertTrue(domain.isCoachHubVisible())
} }
} }
@@ -3,9 +3,13 @@ package com.matchlivetv.match_live_tv.core
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageManager
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.os.BatteryManager import android.os.BatteryManager
import android.os.Build
import android.telephony.TelephonyManager
import com.matchlivetv.match_live_tv.data.api.ClientInfoPayload
data class DeviceHealthSnapshot( data class DeviceHealthSnapshot(
val batteryPercent: Int, val batteryPercent: Int,
@@ -27,6 +31,38 @@ object DeviceTelemetry {
} }
}.getOrDefault("Sconosciuto") }.getOrDefault("Sconosciuto")
fun clientInfo(context: Context): ClientInfoPayload {
val packageInfo = runCatching {
if (Build.VERSION.SDK_INT >= 33) {
context.packageManager.getPackageInfo(
context.packageName,
PackageManager.PackageInfoFlags.of(0),
)
} else {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(context.packageName, 0)
}
}.getOrNull()
val versionName = packageInfo?.versionName
val versionCode = packageInfo?.let {
if (Build.VERSION.SDK_INT >= 28) it.longVersionCode.toString() else {
@Suppress("DEPRECATION")
it.versionCode.toString()
}
}
return ClientInfoPayload(
os = "android",
appVersion = versionName,
appBuild = versionCode,
deviceManufacturer = Build.MANUFACTURER?.takeIf { it.isNotBlank() },
deviceModel = Build.MODEL?.takeIf { it.isNotBlank() },
osVersion = Build.VERSION.RELEASE,
carrier = carrierName(context),
)
}
fun snapshot(context: Context, thermalState: ThermalState? = null): DeviceHealthSnapshot { fun snapshot(context: Context, thermalState: ThermalState? = null): DeviceHealthSnapshot {
val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val batteryPercent = readBatteryPercent(batteryIntent) val batteryPercent = readBatteryPercent(batteryIntent)
@@ -37,6 +73,13 @@ object DeviceTelemetry {
) )
} }
private fun carrierName(context: Context): String? = runCatching {
val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager ?: return null
sequenceOf(tm.networkOperatorName, tm.simOperatorName)
.mapNotNull { it?.trim()?.takeIf { name -> name.isNotEmpty() } }
.firstOrNull()
}.getOrNull()
private fun readBatteryPercent(intent: Intent?): Int { private fun readBatteryPercent(intent: Intent?): Int {
if (intent == null) return 100 if (intent == null) return 100
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
@@ -96,7 +96,7 @@ class AppContainer(context: Context) {
.filter { it.id !in dismissed } .filter { it.id !in dismissed }
} }
val sessionRepository = SessionRepository(api) val sessionRepository = SessionRepository(api, appContext)
val scoreRepository = ScoreRepository(api) val scoreRepository = ScoreRepository(api)
@@ -295,6 +295,17 @@ data class CreateSessionRequest(
@Json(name = "target_bitrate") val targetBitrate: Int = 2_500_000, @Json(name = "target_bitrate") val targetBitrate: Int = 2_500_000,
@Json(name = "target_fps") val targetFps: Int = 30, @Json(name = "target_fps") val targetFps: Int = 30,
@Json(name = "youtube_channel") val youtubeChannel: String? = null, @Json(name = "youtube_channel") val youtubeChannel: String? = null,
val client: ClientInfoPayload? = null,
)
data class ClientInfoPayload(
val os: String,
@Json(name = "app_version") val appVersion: String? = null,
@Json(name = "app_build") val appBuild: String? = null,
@Json(name = "device_manufacturer") val deviceManufacturer: String? = null,
@Json(name = "device_model") val deviceModel: String? = null,
@Json(name = "os_version") val osVersion: String? = null,
val carrier: String? = null,
) )
data class MinQualityRequest( data class MinQualityRequest(
@@ -413,6 +424,7 @@ data class TelemetryRequest(
@Json(name = "target_bitrate") val targetBitrate: Int? = null, @Json(name = "target_bitrate") val targetBitrate: Int? = null,
val fps: Int? = null, val fps: Int? = null,
@Json(name = "thermal_state") val thermalState: String? = null, @Json(name = "thermal_state") val thermalState: String? = null,
val client: ClientInfoPayload? = null,
) )
data class AnnouncementDto( data class AnnouncementDto(
@@ -1,5 +1,7 @@
package com.matchlivetv.match_live_tv.data.repository package com.matchlivetv.match_live_tv.data.repository
import android.content.Context
import com.matchlivetv.match_live_tv.core.DeviceTelemetry
import com.matchlivetv.match_live_tv.data.api.CreateSessionRequest import com.matchlivetv.match_live_tv.data.api.CreateSessionRequest
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
import com.matchlivetv.match_live_tv.data.api.MinQualityRequest import com.matchlivetv.match_live_tv.data.api.MinQualityRequest
@@ -9,6 +11,7 @@ import com.matchlivetv.match_live_tv.domain.StreamSession
class SessionRepository( class SessionRepository(
private val api: MatchLiveApi, private val api: MatchLiveApi,
private val appContext: Context,
) { ) {
suspend fun createSession( suspend fun createSession(
matchId: String, matchId: String,
@@ -21,6 +24,7 @@ class SessionRepository(
platform = platform, platform = platform,
privacyStatus = privacyStatus, privacyStatus = privacyStatus,
youtubeChannel = youtubeChannel, youtubeChannel = youtubeChannel,
client = DeviceTelemetry.clientInfo(appContext),
), ),
).toDomain() ).toDomain()
@@ -81,6 +85,7 @@ class SessionRepository(
targetBitrate = targetBitrate, targetBitrate = targetBitrate,
fps = fps, fps = fps,
thermalState = thermalState, thermalState = thermalState,
client = DeviceTelemetry.clientInfo(appContext),
), ),
) )
} }
@@ -1,6 +1,7 @@
import Foundation import Foundation
import UIKit import UIKit
import Network import Network
import CoreTelephony
struct DeviceHealth: Sendable { struct DeviceHealth: Sendable {
let batteryPercent: Int let batteryPercent: Int
@@ -8,6 +9,16 @@ struct DeviceHealth: Sendable {
let networkType: String let networkType: String
} }
struct ClientInfoPayload: Encodable, Sendable {
let os: String
let appVersion: String?
let appBuild: String?
let deviceManufacturer: String?
let deviceModel: String?
let osVersion: String?
let carrier: String?
}
enum DeviceTelemetry { enum DeviceTelemetry {
static func snapshot(thermalState: ThermalState? = nil) -> DeviceHealth { static func snapshot(thermalState: ThermalState? = nil) -> DeviceHealth {
UIDevice.current.isBatteryMonitoringEnabled = true UIDevice.current.isBatteryMonitoringEnabled = true
@@ -20,6 +31,24 @@ enum DeviceTelemetry {
) )
} }
static func clientInfo() -> ClientInfoPayload {
let bundle = Bundle.main
let version = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
let build = bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String
let model = UIDevice.current.model
// Prefer machine identifier when available (e.g. iPhone15,2)
let machine = utsnameMachine()
return ClientInfoPayload(
os: "ios",
appVersion: version,
appBuild: build,
deviceManufacturer: "Apple",
deviceModel: machine ?? model,
osVersion: UIDevice.current.systemVersion,
carrier: carrierName()
)
}
private static func currentNetworkType() -> String { private static func currentNetworkType() -> String {
let monitor = NWPathMonitor() let monitor = NWPathMonitor()
let semaphore = DispatchSemaphore(value: 0) let semaphore = DispatchSemaphore(value: 0)
@@ -40,4 +69,27 @@ enum DeviceTelemetry {
monitor.cancel() monitor.cancel()
return result return result
} }
private static func carrierName() -> String? {
let info = CTTelephonyNetworkInfo()
if let providers = info.serviceSubscriberCellularProviders {
for carrier in providers.values {
if let name = carrier.carrierName?.trimmingCharacters(in: .whitespacesAndNewlines),
!name.isEmpty {
return name
}
}
}
return nil
}
private static func utsnameMachine() -> String? {
var systemInfo = utsname()
uname(&systemInfo)
return withUnsafePointer(to: &systemInfo.machine) {
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
String(validatingUTF8: $0)
}
}
}
} }
@@ -435,6 +435,7 @@ struct CreateSessionRequest: Encodable {
let targetBitrate: Int let targetBitrate: Int
let targetFps: Int let targetFps: Int
let youtubeChannel: String? let youtubeChannel: String?
let client: ClientInfoPayload?
} }
struct AudioMuteRequest: Encodable { struct AudioMuteRequest: Encodable {
@@ -531,6 +532,7 @@ struct TelemetryRequest: Encodable {
let targetBitrate: Int? let targetBitrate: Int?
let fps: Int? let fps: Int?
let thermalState: String? let thermalState: String?
let client: ClientInfoPayload?
} }
extension ScoringRules { extension ScoringRules {
@@ -25,7 +25,8 @@ final class SessionRepository {
qualityPreset: qualityPreset, qualityPreset: qualityPreset,
targetBitrate: targetBitrate, targetBitrate: targetBitrate,
targetFps: targetFps, targetFps: targetFps,
youtubeChannel: youtubeChannel youtubeChannel: youtubeChannel,
client: DeviceTelemetry.clientInfo()
) )
).toDomain() ).toDomain()
} }
@@ -97,7 +98,8 @@ final class SessionRepository {
currentBitrate: currentBitrate, currentBitrate: currentBitrate,
targetBitrate: targetBitrate, targetBitrate: targetBitrate,
fps: fps, fps: fps,
thermalState: health.thermalState.apiValue thermalState: health.thermalState.apiValue,
client: DeviceTelemetry.clientInfo()
) )
) )
} }