Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35dfa923e3 | ||
|
|
9d8b35c06c | ||
|
|
873e0ea55c | ||
|
|
4573edfedc | ||
|
|
b301868774 | ||
|
|
b87a0f7bc0 |
@@ -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
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +46,7 @@ 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|
|
||||||
|
with_unique_retry do
|
||||||
cell = AnalyticsPageCell.find_or_initialize_by(
|
cell = AnalyticsPageCell.find_or_initialize_by(
|
||||||
day: day, page_path: page_path, device: device, cell_x: cell_x, cell_y: cell_y
|
day: day, page_path: page_path, device: device, cell_x: cell_x, cell_y: cell_y
|
||||||
)
|
)
|
||||||
@@ -54,6 +56,7 @@ module Analytics
|
|||||||
cell.save!
|
cell.save!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def apply_stats(events)
|
def apply_stats(events)
|
||||||
return if events.empty?
|
return if events.empty?
|
||||||
@@ -76,6 +79,7 @@ module Analytics
|
|||||||
|
|
||||||
now = Time.current
|
now = Time.current
|
||||||
grouped.each do |(day, page_path, device), vals|
|
grouped.each do |(day, page_path, device), vals|
|
||||||
|
with_unique_retry do
|
||||||
stat = AnalyticsPageStat.find_or_initialize_by(day: day, page_path: page_path, device: device)
|
stat = AnalyticsPageStat.find_or_initialize_by(day: day, page_path: page_path, device: device)
|
||||||
stat.pageview_count = stat.pageview_count.to_i + vals[:pageviews]
|
stat.pageview_count = stat.pageview_count.to_i + vals[:pageviews]
|
||||||
stat.scroll_samples = stat.scroll_samples.to_i + vals[:scroll_samples]
|
stat.scroll_samples = stat.scroll_samples.to_i + vals[:scroll_samples]
|
||||||
@@ -87,4 +91,17 @@ module Analytics
|
|||||||
end
|
end
|
||||||
end
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}.
|
||||||
|
|||||||
@@ -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}.
|
||||||
|
|||||||
@@ -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}.
|
||||||
|
|||||||
@@ -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}.
|
||||||
|
|||||||
@@ -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
|
||||||
Generated
+8
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -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()
|
||||||
|
|||||||
+26
-2
@@ -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")
|
||||||
|
// Come E2EWizardFlowTest: privacy non-in-elenco e AVANTI riprovato con scroll.
|
||||||
|
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()
|
scrollDown()
|
||||||
tapAny("AVANTI >", "NEXT >")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-9
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+43
@@ -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)
|
||||||
|
|||||||
+1
-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(
|
||||||
|
|||||||
+5
@@ -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()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user