Compare commits

..
Author SHA1 Message Date
eminuxandCursor d52434fb2e Limita una diretta attiva per account e valorizza la copertina Premium Full.
Blocca la seconda diretta sullo stesso utente, registra gli abusi in admin e presenta la copertina come grafica caricata dalla società, con demo Team MLTV.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 12:52:26 +02:00
eminuxandCursor 356aa28fad Aggiunge la garanzia Soddisfatti o rimborsati di 30 giorni su prezzi, homepage e Termini.
Riduce il rischio percepito all'acquisto (Light e Full) e chiama l'archivio replay in modo orientato all'utente, senza cambiare prezzi o logica dei piani.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 19:27:28 +02:00
Emiliano FrascaroandCursor 1185d0ce61 Prepara iOS 2.0.12 build 35 per TestFlight e rafforza i test in produzione.
Forza dirette unlisted sul sito (non YouTube), copertina di test e verifica che in pausa gli spettatori vedano la slate. Alza il numero di build oltre le 33–34 già su App Store Connect.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 17:54:33 +02:00
Emiliano FrascaroandCursor 90d481a2d1 Rende più robusti i UI test iOS (logout, copertina, YouTube non selezionato in prod).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 17:54:33 +02:00
Emiliano FrascaroandCursor 50b8a8c8e2 Allinea iOS ad Android sul dialog Configura/Più tardi e porta l'app a 2.0.12.
Aggiunge UI test sul simulatore (hub, lingue, wizard, diretta) e identificatori di accessibilità per poterli ripetere su collaudo e produzione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 17:54:33 +02:00
eminuxandCursor 79850dfc2c Non far crashare il reset password se SMTP rifiuta il destinatario.
Un account di test su dominio .test faceva 500; ora l'invio fallito viene loggato e l'API resta ok.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 12:00:22 +02:00
eminuxandCursor 645807b853 Evita 500 senza SMTP e chiude gli incidenti overflow stale.
Reset password e mail replay usano deliver_mail; il health check overflow risolve tutte le fingerprint del kind, non solo quella sana.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 08:55:36 +02:00
eminuxandCursor b08049cf69 Fix home 500: usa request.cookie_jar nella partial analytics suppress.
L'action cookies di PagesController mascherava il helper cookies nelle viste marketing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 19:31:05 +02:00
eminuxandCursor 29c8afb94d Admin analytics: heatmap per device, anteprima staff e analisi costi.
Separa heatmap mobile/desktop, opt-out analytics per operatori, dashboard costi con KPI e trend mensili.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 19:25:38 +02:00
eminuxandCursor e0e07e5316 Non forza enableEmbed in creazione live: YouTube lo rifiuta sul canale piattaforma.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 15:15:35 +02:00
eminuxandCursor 42dadb993d Abilita l'embed YouTube sulle dirette e tiene la copia locale se il VOD non è incorporabile.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 15:05:12 +02:00
eminuxandCursor 05ef56c56d Aggiunge replay YouTube temporaneo, pull registrazioni dai CPX e snapshot ingest in admin.
Così overflow Hetzner e VOD YouTube restano in archivio dopo lo spegnimento del nodo, e la colonna ingest non si svuota.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 13:01:41 +02:00
eminuxandCursor 35dfa923e3 Usa packageName dinamico negli E2E Android per la variante collaudo.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 10:22:20 +02:00
eminuxandCursor 9d8b35c06c Salva telemetria client (OS, app, device, operatore) sulle sessioni per il debug admin.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 09:43:43 +02:00
eminuxandCursor 873e0ea55c Fix race analytics: retry su unique violation in aggregazione heatmap.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 16:23:02 +02:00
eminuxandCursor 4573edfedc Aggiunge i link ai profili social ufficiali nel footer e in Contatti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 16:10:18 +02:00
eminuxandCursor b301868774 Aggiunge regola Cursor per scalare i soft limit ingest col crescere dei clienti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 15:11:25 +02:00
195 changed files with 7220 additions and 429 deletions
@@ -0,0 +1,22 @@
---
description: Scalare soft limit ingest/autoscaler man mano che crescono i clienti
alwaysApply: true
---
# Soft limit stream / autoscaler
Con la crescita del numero di clienti (e delle dirette concorrenti attese), **alzare i soft limit** prima di restare a corto di capacità.
Config rilevante (prod, tipicamente `infra/.env` + `StreamNode`):
| Parametro | Ruolo oggi (post load test 2026-08) |
|-----------|--------------------------------------|
| `STREAM_NODE_HOME_MAX_PUBLISHERS` | Soft home (es. 6) |
| `STREAM_CLOUD_MAX_PUBLISHERS` | Soft per CPX (es. 4; cpx12 ha tenuto 8 in probe) |
| `STREAM_AUTOSCALE_MAX_OVERFLOW` / max overflow nodes | Quanti CPX in parallelo (es. 3 → tetto cluster ≈ home + N×cloud) |
| `STREAM_AUTOSCALE_SOFT_FREE_SLOTS` | Anticipo scale-out |
| Piano `concurrent_streams_limit` | Tetto **per club** (Premium Full = 10): indipendente dal cluster |
Capienza cluster soft ≈ `home_max + max_overflow × cloud_max` (es. 6+3×4 = **18**).
Quando si parla di capacity planning, deploy autoscale, o “troppe dirette”, ricordare di rivedere questi valori (e il limite piano) in base ai clienti reali — non lasciare i soft limit di collaudo/early-prod a lungo.
@@ -8,6 +8,7 @@ module Admin
to: parse_date(params[:to]) || Time.zone.today, to: parse_date(params[:to]) || Time.zone.today,
device: params[:device].presence device: params[:device].presence
} }
@analytics_preview_active = analytics_preview_active?
scope = AnalyticsPageStat.where(day: @filters[:from]..@filters[:to]) scope = AnalyticsPageStat.where(day: @filters[:from]..@filters[:to])
scope = scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) scope = scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
@@ -41,15 +42,23 @@ module Admin
@page_path = params[:page_path].to_s @page_path = params[:page_path].to_s
redirect_to admin_analytics_path, alert: t("admin.analytics.missing_path") and return if @page_path.blank? redirect_to admin_analytics_path, alert: t("admin.analytics.missing_path") and return if @page_path.blank?
from = parse_date(params[:from]) || 7.days.ago.to_date
to = parse_date(params[:to]) || Time.zone.today
@device_tab_stats = device_tab_stats(@page_path, from, to)
device = resolve_heatmap_device(@device_tab_stats, params[:device].presence)
@filters = { @filters = {
from: parse_date(params[:from]) || 7.days.ago.to_date, from: from,
to: parse_date(params[:to]) || Time.zone.today, to: to,
device: params[:device].presence, device: device,
layer: params[:layer].to_s layer: params[:layer].to_s
} }
cells = AnalyticsPageCell.where(page_path: @page_path, day: @filters[:from]..@filters[:to]) cells = AnalyticsPageCell.where(
cells = cells.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) page_path: @page_path,
day: @filters[:from]..@filters[:to],
device: @filters[:device]
)
@click_total = cells.sum(:click_count) @click_total = cells.sum(:click_count)
@move_total = cells.sum(:move_count) @move_total = cells.sum(:move_count)
@@ -67,8 +76,11 @@ module Admin
@max_weight = @cells.values.max.to_i @max_weight = @cells.values.max.to_i
@total_points = @cells.values.sum @total_points = @cells.values.sum
stats = AnalyticsPageStat.where(page_path: @page_path, day: @filters[:from]..@filters[:to]) stats = AnalyticsPageStat.where(
stats = stats.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) page_path: @page_path,
day: @filters[:from]..@filters[:to],
device: @filters[:device]
)
@pageviews = stats.sum(:pageview_count) @pageviews = stats.sum(:pageview_count)
@scroll_samples = stats.sum(:scroll_samples) @scroll_samples = stats.sum(:scroll_samples)
@scroll_sum = stats.sum(:scroll_sum_pct) @scroll_sum = stats.sum(:scroll_sum_pct)
@@ -78,8 +90,30 @@ module Admin
@snapshot = find_snapshot(@page_path, @filters[:device]) @snapshot = find_snapshot(@page_path, @filters[:device])
end end
def preview_enable
Analytics::Suppress.enable!(cookies)
redirect_to preview_return_to(params[:return_to]), notice: t("admin.analytics.preview.enabled")
end
def preview_disable
Analytics::Suppress.disable!(cookies)
redirect_to admin_analytics_path, notice: t("admin.analytics.preview.disabled")
end
private private
def analytics_preview_active?
Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME])
end
def preview_return_to(value)
path = value.to_s.strip
return root_path if path.blank?
return path if path.start_with?("/") && !path.start_with?("//")
admin_analytics_path
end
def parse_date(value) def parse_date(value)
return nil if value.blank? return nil if value.blank?
@@ -88,14 +122,43 @@ module Admin
nil nil
end end
def find_snapshot(page_path, device) def device_tab_stats(page_path, from, to)
scope = AnalyticsPageSnapshot.where(page_path: page_path) cell_totals = AnalyticsPageCell.where(page_path: page_path, day: from..to)
if device.present? && AnalyticsEvent::DEVICES.include?(device) .group(:device)
snap = scope.find_by(device: device) .pluck(
return snap if snap&.image&.attached? :device,
Arel.sql("SUM(click_count)"),
Arel.sql("SUM(move_count)")
)
cell_by_device = cell_totals.to_h { |device, clicks, moves| [device, { clicks: clicks.to_i, moves: moves.to_i }] }
pageview_totals = AnalyticsPageStat.where(page_path: page_path, day: from..to)
.group(:device)
.sum(:pageview_count)
AnalyticsEvent::DEVICES.index_with do |device|
cells = cell_by_device[device] || { clicks: 0, moves: 0 }
cells.merge(pageviews: pageview_totals[device].to_i)
end
end
def resolve_heatmap_device(tab_stats, requested)
if requested.present? && AnalyticsEvent::DEVICES.include?(requested)
return requested
end end
scope.order(captured_at: :desc).detect { |s| s.image.attached? } AnalyticsEvent::DEVICES.max_by do |device|
stats = tab_stats[device]
stats[:clicks] + stats[:moves] + stats[:pageviews]
end
end
def find_snapshot(page_path, device)
return nil unless AnalyticsEvent::DEVICES.include?(device)
AnalyticsPageSnapshot.where(page_path: page_path, device: device)
.order(captured_at: :desc)
.detect { |snapshot| snapshot.image.attached? }
end end
end end
end end
@@ -12,6 +12,7 @@ module Admin
@plans = Plan.ordered.reject { |p| p.slug == "free" } @plans = Plan.ordered.reject { |p| p.slug == "free" }
@teams = @club.teams.order(:name) @teams = @club.teams.order(:name)
@quote = @club.active_billing_quote @quote = @club.active_billing_quote
@concurrency_violations = StreamConcurrencyViolation.for_club(@club.id).recent.limit(20)
end end
def grant_comped def grant_comped
@@ -0,0 +1,68 @@
# frozen_string_literal: true
module Admin
class CostEntriesController < Admin::BaseController
before_action :set_entry, only: %i[edit update destroy]
def new
@entry = PlatformCostEntry.new(
month: parse_month(params[:month]) || Time.zone.today.beginning_of_month,
label: PlatformCostEntry::DEFAULT_LABEL
)
end
def create
@entry = PlatformCostEntry.new(entry_attributes)
if @entry.save
redirect_to admin_costs_path(month: month_param(@entry.month)), notice: t("admin.flash.cost_entry_created")
else
flash.now[:alert] = @entry.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity
end
end
def edit; end
def update
if @entry.update(entry_attributes)
redirect_to admin_costs_path(month: month_param(@entry.month)), notice: t("admin.flash.cost_entry_updated")
else
flash.now[:alert] = @entry.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity
end
end
def destroy
month = @entry.month
@entry.destroy!
redirect_to admin_costs_path(month: month_param(month)), notice: t("admin.flash.cost_entry_destroyed")
end
private
def set_entry
@entry = PlatformCostEntry.find(params[:id])
end
def entry_attributes
attrs = params.require(:platform_cost_entry).permit(:month, :label, :amount_euros, :notes)
if attrs[:amount_euros].present?
attrs[:amount_cents] = Billing::EuroAmount.to_cents(attrs.delete(:amount_euros))
end
attrs[:notes] = nil if attrs[:notes].blank?
attrs
end
def parse_month(value)
return nil if value.blank?
Date.strptime(value.to_s, "%Y-%m").beginning_of_month
rescue ArgumentError, TypeError
nil
end
def month_param(date)
date.strftime("%Y-%m")
end
end
end
@@ -0,0 +1,30 @@
# frozen_string_literal: true
module Admin
class CostsController < Admin::BaseController
def index
@month = parse_month(params[:month]) || Time.zone.today.beginning_of_month
analytics = Admin::CostAnalytics.new(month: @month)
@summary = analytics.summary
@trend = analytics.trend
@clubs = analytics.club_breakdown
@entries = PlatformCostEntry.for_month(@month).ordered
@month_options = month_options(@month)
end
private
def parse_month(value)
return nil if value.blank?
Date.strptime(value.to_s, "%Y-%m").beginning_of_month
rescue ArgumentError, TypeError
nil
end
def month_options(selected)
start = selected - 23.months
(0..23).map { |i| start + i.months }.reverse
end
end
end
@@ -8,6 +8,8 @@ module Admin
.includes(:stream_node, match: :team) .includes(:stream_node, match: :team)
.order(started_at: :desc) .order(started_at: :desc)
@teams = Team.includes(:matches).order(:name).limit(8) @teams = Team.includes(:matches).order(:name).limit(8)
@recent_concurrency_violations = StreamConcurrencyViolation.recent.limit(8)
@concurrency_violation_lookback = StreamConcurrencyViolation.lookback.count
end end
def metrics def metrics
@@ -18,6 +18,7 @@ module Admin
.find(params[:id]) .find(params[:id])
@events = @session.stream_events.recent.limit(100) @events = @session.stream_events.recent.limit(100)
@club = @session.match.team.club @club = @session.match.team.club
@concurrency_violations = StreamConcurrencyViolation.for_session(@session.id).recent.limit(20)
end end
def stop def stop
@@ -79,14 +80,21 @@ module Admin
if @filters[:status].present? && StreamSession::STATUSES.include?(@filters[:status]) if @filters[:status].present? && StreamSession::STATUSES.include?(@filters[:status])
scope = scope.where(stream_sessions: { status: @filters[:status] }) scope = scope.where(stream_sessions: { status: @filters[:status] })
end end
if @filters[:platform].present? && StreamSession::PLATFORMS.include?(@filters[:platform]) if @filters[:platform].present? && StreamSession::LIVE_PLATFORMS.include?(@filters[:platform])
scope = scope.where(stream_sessions: { platform: @filters[:platform] }) scope = scope.where(stream_sessions: { platform: @filters[:platform] })
end end
if @filters[:club_id].present? if @filters[:club_id].present?
scope = scope.where(teams: { club_id: @filters[:club_id] }) scope = scope.where(teams: { club_id: @filters[:club_id] })
end end
if @filters[:stream_node_id].present? if @filters[:stream_node_id].present?
scope = scope.where(stream_sessions: { stream_node_id: @filters[:stream_node_id] }) node = StreamNode.find_by(id: @filters[:stream_node_id])
if node
scope = scope.where(
"stream_sessions.stream_node_id = :id OR stream_sessions.ingest_slug = :slug",
id: node.id,
slug: node.slug
)
end
end end
if (from_time = parse_filter_date(@filters[:from], end_of_day: false)) if (from_time = parse_filter_date(@filters[:from], end_of_day: false))
scope = scope.where( scope = scope.where(
@@ -0,0 +1,25 @@
# frozen_string_literal: true
module Admin
class StreamConcurrencyViolationsController < Admin::BaseController
def index
@filters = {
q: params[:q].to_s.strip.presence,
devices_differ: params[:devices_differ].to_s == "1"
}
scope = StreamConcurrencyViolation.recent
if @filters[:q]
term = "%#{ActiveRecord::Base.sanitize_sql_like(@filters[:q])}%"
scope = scope.where(
"user_email ILIKE :term OR user_name ILIKE :term OR occupying_club_name ILIKE :term OR attempted_club_name ILIKE :term OR occupying_match_label ILIKE :term OR attempted_match_label ILIKE :term",
term: term
)
end
scope = scope.two_devices if @filters[:devices_differ]
@total_count = scope.count
@violations = scope.limit(200)
@lookback_count = StreamConcurrencyViolation.lookback.count
@two_devices_count = StreamConcurrencyViolation.lookback.two_devices.count
end
end
end
@@ -2,7 +2,13 @@
module Analytics module Analytics
class EventsController < ActionController::API class EventsController < ActionController::API
include ActionController::Cookies
def create def create
if analytics_suppressed?
return render json: { accepted: 0, rejected: 0, suppressed: true }, status: :accepted
end
payload = parse_payload payload = parse_payload
result = Analytics::Ingest.new(events: payload, remote_ip: request.remote_ip).call result = Analytics::Ingest.new(events: payload, remote_ip: request.remote_ip).call
@@ -15,6 +21,10 @@ module Analytics
private private
def analytics_suppressed?
Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME])
end
def parse_payload def parse_payload
body = request.request_parameters body = request.request_parameters
return body["events"] if body.is_a?(Hash) && body["events"].is_a?(Array) return body["events"] if body.is_a?(Hash) && body["events"].is_a?(Array)
@@ -2,7 +2,13 @@
module Analytics module Analytics
class SnapshotsController < ActionController::API class SnapshotsController < ActionController::API
include ActionController::Cookies
def create def create
if analytics_suppressed?
return render json: { ok: true, skipped: true, suppressed: true }, status: :accepted
end
result = Analytics::SnapshotIngest.new( result = Analytics::SnapshotIngest.new(
path: params[:path] || params[:page_path], path: params[:path] || params[:page_path],
device: params[:device], device: params[:device],
@@ -17,5 +23,11 @@ module Analytics
render json: { ok: true, skipped: result.skipped }, status: :accepted render json: { ok: true, skipped: result.skipped }, status: :accepted
end end
private
def analytics_suppressed?
Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME])
end
end end
end end
@@ -97,10 +97,11 @@ module Api
replay_url: recording.replay_url, replay_url: recording.replay_url,
playback_url: recording.playback_stream_url, playback_url: recording.playback_stream_url,
thumbnail_url: recording.thumbnail_url, thumbnail_url: recording.thumbnail_url,
download_enabled: ent.phone_download_enabled?, download_enabled: ent.phone_download_enabled? && recording.storage_key.present?,
youtube_video_id: recording.youtube_video_id, youtube_video_id: recording.youtube_video_id,
youtube_watch_url: recording.youtube_watch_url, youtube_watch_url: recording.youtube_watch_url,
youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled?, youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled? && recording.storage_key.present? && recording.youtube_video_id.blank?,
replay_source: recording.replay_source,
source_platform: recording.source_platform, source_platform: recording.source_platform,
source_platform_label: recording.source_platform_label, source_platform_label: recording.source_platform_label,
expires_at: recording.expires_at, expires_at: recording.expires_at,
@@ -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
@@ -171,12 +171,13 @@ module Api
replay_url: recording.replay_url, replay_url: recording.replay_url,
playback_url: recording.playback_stream_url, playback_url: recording.playback_stream_url,
thumbnail_url: recording.thumbnail_url, thumbnail_url: recording.thumbnail_url,
download_enabled: ent.phone_download_enabled?, download_enabled: ent.phone_download_enabled? && recording.storage_key.present?,
view_count: recording.view_count, view_count: recording.view_count,
views_label: recording.views_label, views_label: recording.views_label,
youtube_video_id: recording.youtube_video_id, youtube_video_id: recording.youtube_video_id,
youtube_watch_url: recording.youtube_watch_url, youtube_watch_url: recording.youtube_watch_url,
youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled?, youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled? && recording.storage_key.present? && recording.youtube_video_id.blank?,
replay_source: recording.replay_source,
source_platform: recording.source_platform, source_platform: recording.source_platform,
source_platform_label: recording.source_platform_label, source_platform_label: recording.source_platform_label,
expires_at: recording.expires_at, expires_at: recording.expires_at,
+50 -4
View File
@@ -1,4 +1,21 @@
module AdminHelper module AdminHelper
def format_euros(cents, precision: 2)
return I18n.t("admin.common.dash") if cents.nil?
format("%.*f €", precision, cents.to_f / 100.0)
end
def format_hours(hours)
return I18n.t("admin.common.dash") if hours.nil? || hours.to_f <= 0
total_minutes = (hours.to_f * 60).round
format_duration_minutes(total_minutes)
end
def admin_month_label(month)
I18n.l(month, format: "%B %Y")
end
def format_bytes(bytes) def format_bytes(bytes)
return "" if bytes.nil? return "" if bytes.nil?
@@ -30,8 +47,9 @@ module AdminHelper
links links
end end
def admin_session_ingest_badge_class(node) def admin_session_ingest_badge_class(role_or_node)
case node.role role = role_or_node.respond_to?(:role) ? role_or_node.role : role_or_node
case role.to_s
when "home" then "badge--ingest-home" when "home" then "badge--ingest-home"
when "lab" then "badge--ingest-lab" when "lab" then "badge--ingest-lab"
when "cloud" then "badge--ingest-cloud" when "cloud" then "badge--ingest-cloud"
@@ -39,8 +57,9 @@ module AdminHelper
end end
end end
def admin_session_ingest_role_label(node) def admin_session_ingest_role_label(role_or_node)
I18n.t("admin.sessions.ingest.role.#{node.role}", default: node.role.to_s.humanize) role = role_or_node.respond_to?(:role) ? role_or_node.role : role_or_node
I18n.t("admin.sessions.ingest.role.#{role}", default: role.to_s.humanize)
end end
def admin_session_status_badge_class(status) def admin_session_status_badge_class(status)
@@ -81,6 +100,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?
@@ -146,4 +188,8 @@ module AdminHelper
labels = item.selected_channels.map { |key| I18n.t("admin.announcements.channels.#{key}") } labels = item.selected_channels.map { |key| I18n.t("admin.announcements.channels.#{key}") }
labels.presence&.join(" · ") || I18n.t("admin.common.dash") labels.presence&.join(" · ") || I18n.t("admin.common.dash")
end end
def admin_concurrency_violation_lookback_count
@admin_concurrency_violation_lookback_count ||= StreamConcurrencyViolation.lookback.count
end
end end
+4
View File
@@ -3,6 +3,10 @@ module LegalHelper
"20 agosto 2026" "20 agosto 2026"
end end
def terms_last_updated
"31 agosto 2026"
end
def cookie_policy_last_updated def cookie_policy_last_updated
"3 giugno 2026" "3 giugno 2026"
end end
@@ -0,0 +1,19 @@
# frozen_string_literal: true
module Recordings
class ClearTemporaryMediaJob
include Sidekiq::Job
sidekiq_options retry: 3, queue: "default"
def perform(recording_id, reason = "verified")
recording = Recording.find_by(id: recording_id)
return unless recording
Recordings::ClearTemporaryMedia.new(
recording,
reason: reason.to_sym,
force: reason.to_s == "verified"
).call
end
end
end
@@ -1,3 +1,5 @@
# frozen_string_literal: true
module Recordings module Recordings
class PostProcessJob class PostProcessJob
include Sidekiq::Job include Sidekiq::Job
@@ -7,10 +9,30 @@ module Recordings
recording = Recording.find_by(id: recording_id) recording = Recording.find_by(id: recording_id)
return unless recording&.ready? return unless recording&.ready?
Recordings::NotifyReady.new(recording).call begin
Recordings::NotifyReady.new(recording).call
rescue StandardError => e
# SMTP/ntfy giù non deve bloccare VerifyYoutubeReplay né il resto del post-process.
Rails.logger.warn(
"[Recordings::PostProcessJob] notify_failed recording=#{recording.id} " \
"#{e.class}: #{e.message}"
)
end
if recording.temporary_storage?
grace = MatchLiveTv.youtube_replay_verify_grace_secs
Rails.logger.info(
"[Recordings::PostProcessJob] schedule VerifyYoutubeReplay " \
"recording=#{recording.id} grace=#{grace}s"
)
Recordings::VerifyYoutubeReplayJob.perform_in(grace.seconds, recording.id, 0)
return
end
# Solo MatchLiveTV-only: eventuale re-upload manuale/flag legacy (non per live YouTube).
return unless recording.auto_publish_youtube? return unless recording.auto_publish_youtube?
return if recording.youtube_video_id.present? return if recording.youtube_video_id.present?
return if recording.source_platform == "youtube"
Recordings::PublishToYoutubeJob.perform_async(recording.id) Recordings::PublishToYoutubeJob.perform_async(recording.id)
end end
@@ -0,0 +1,35 @@
# frozen_string_literal: true
module Recordings
# Safety net: elimina copie temp scadute (anche se verify non è mai riuscito).
class PurgeTemporaryMediaJob
include Sidekiq::Job
sidekiq_options retry: 1, queue: "default"
def perform
scope = Recording.temporary_media_pending_purge
count = 0
scope.find_each do |recording|
unless recording.youtube_verified_at.present?
Rails.logger.warn(
"[Recordings::PurgeTemporaryMediaJob] anomaly_unverified_expiry " \
"recording=#{recording.id} session=#{recording.stream_session_id} " \
"youtube_video_id=#{recording.youtube_video_id.inspect} " \
"temp_expires_at=#{recording.temp_expires_at.inspect}"
)
end
Recordings::ClearTemporaryMedia.new(
recording,
reason: :max_retention,
force: true
).call
count += 1
end
Rails.logger.info("[Recordings::PurgeTemporaryMediaJob] processed=#{count}")
count
end
end
end
@@ -0,0 +1,56 @@
# frozen_string_literal: true
module Recordings
class VerifyYoutubeReplayJob
include Sidekiq::Job
sidekiq_options retry: 0, queue: "default"
def perform(recording_id, attempt = 0)
recording = Recording.find_by(id: recording_id)
return unless recording&.temporary_storage?
return if recording.deleted?
attempt = attempt.to_i
max = MatchLiveTv.youtube_replay_verify_max_attempts
result = Recordings::VerifyYoutubeReplay.new(recording).call
if result.ok?
recording.reload
embeddable = recording.metadata.is_a?(Hash) && recording.metadata.dig("youtube", "embeddable") != false
if embeddable
Recordings::ClearTemporaryMediaJob.perform_async(recording.id, "verified")
else
Rails.logger.warn(
"[Recordings::VerifyYoutubeReplayJob] keep_temp recording=#{recording.id} " \
"youtube_not_embeddable"
)
end
return
end
if result.retriable? && attempt + 1 < max
delay = backoff_secs(attempt)
Rails.logger.info(
"[Recordings::VerifyYoutubeReplayJob] retry recording=#{recording.id} " \
"attempt=#{attempt + 1}/#{max} in=#{delay}s msg=#{result.message}"
)
self.class.perform_in(delay.seconds, recording.id, attempt + 1)
return
end
Rails.logger.warn(
"[Recordings::VerifyYoutubeReplayJob] give_up recording=#{recording.id} " \
"attempts=#{attempt + 1} msg=#{result.message} " \
"temp_expires_at=#{recording.temp_expires_at.inspect}"
)
end
private
def backoff_secs(attempt)
base = MatchLiveTv.youtube_replay_verify_base_interval_secs
# 300, 600, 1200, ... capped at 1h
[base * (2**attempt), 3600].min
end
end
end
@@ -8,6 +8,8 @@ module Streams
INTERVAL_SECS = ENV.fetch("STREAM_AUTOSCALE_INTERVAL_SECS", "60").to_i INTERVAL_SECS = ENV.fetch("STREAM_AUTOSCALE_INTERVAL_SECS", "60").to_i
REDIS_CHAIN_KEY = "streams:autoscaler:chain" REDIS_CHAIN_KEY = "streams:autoscaler:chain"
KICK_DEBOUNCE_KEY = "streams:autoscaler:kick"
KICK_DEBOUNCE_SECS = ENV.fetch("STREAM_AUTOSCALE_KICK_DEBOUNCE_SECS", "5").to_i
def self.ensure_chain def self.ensure_chain
return unless redis return unless redis
@@ -17,6 +19,16 @@ module Streams
perform_in(INTERVAL_SECS) perform_in(INTERVAL_SECS)
end end
# Kick immediato (es. NoCapacity / soft-free bassi). Debounce anti-flood Sidekiq.
def self.kick!
return false unless Streams::Autoscaler.enabled?
return false unless redis
return false unless redis.set(KICK_DEBOUNCE_KEY, "1", nx: true, ex: KICK_DEBOUNCE_SECS)
perform_async
true
end
def self.redis def self.redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")) @redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
rescue Redis::CannotConnectError rescue Redis::CannotConnectError
+51
View File
@@ -0,0 +1,51 @@
# frozen_string_literal: true
class PlatformCostEntry < ApplicationRecord
DEFAULT_LABEL = "Piattaforma produzione"
validates :month, presence: true
validates :label, presence: true, length: { maximum: 120 }
validates :amount_cents, numericality: { only_integer: true, greater_than: 0 }
before_validation :normalize_month
scope :for_month, ->(date) { where(month: date.to_date.beginning_of_month) }
scope :ordered, -> { order(month: :desc, created_at: :desc) }
def month=(value)
if value.is_a?(String) && value.match?(/\A\d{4}-\d{2}\z/)
super(Date.strptime(value, "%Y-%m"))
else
super(value)
end
end
def amount_euros
amount_cents.to_f / 100.0
end
private
def normalize_month
return if month.blank?
parsed =
case month
when Date
month
when Time, ActiveSupport::TimeWithZone
month.to_date
when String
if month.match?(/\A\d{4}-\d{2}\z/)
Date.strptime(month, "%Y-%m")
else
Date.parse(month)
end
else
Date.parse(month.to_s)
end
self.month = parsed.beginning_of_month
rescue ArgumentError, TypeError
errors.add(:month, :invalid)
end
end
+51 -4
View File
@@ -2,6 +2,8 @@ class Recording < ApplicationRecord
STATUSES = %w[processing ready expired failed].freeze STATUSES = %w[processing ready expired failed].freeze
PRIVACY_STATUSES = %w[public unlisted].freeze PRIVACY_STATUSES = %w[public unlisted].freeze
STORAGE_BACKENDS = %w[local s3].freeze STORAGE_BACKENDS = %w[local s3].freeze
STORAGE_POLICIES = %w[temporary retained none].freeze
REPLAY_SOURCES = %w[youtube matchlivetv none].freeze
belongs_to :stream_session belongs_to :stream_session
belongs_to :team belongs_to :team
@@ -9,6 +11,7 @@ class Recording < ApplicationRecord
validates :status, inclusion: { in: STATUSES } validates :status, inclusion: { in: STATUSES }
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES } validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
validates :storage_backend, inclusion: { in: STORAGE_BACKENDS } validates :storage_backend, inclusion: { in: STORAGE_BACKENDS }
validates :storage_policy, inclusion: { in: STORAGE_POLICIES }
scope :not_deleted, -> { where(deleted_at: nil) } scope :not_deleted, -> { where(deleted_at: nil) }
scope :ready, lambda { scope :ready, lambda {
@@ -23,7 +26,16 @@ class Recording < ApplicationRecord
ready.where(expires_at: ..days.days.from_now) ready.where(expires_at: ..days.days.from_now)
} }
scope :expired_pending_purge, lambda { scope :expired_pending_purge, lambda {
not_deleted.where(status: %w[ready failed]).where("expires_at IS NOT NULL AND expires_at <= ?", Time.current) not_deleted
.where(storage_policy: "retained")
.where(status: %w[ready failed])
.where("expires_at IS NOT NULL AND expires_at <= ?", Time.current)
}
scope :temporary_media_pending_purge, lambda {
not_deleted
.where(storage_policy: "temporary")
.where(local_media_purged_at: nil)
.where("temp_expires_at IS NOT NULL AND temp_expires_at <= ?", Time.current)
} }
scope :search_replays, lambda { |query| scope :search_replays, lambda { |query|
q = query.to_s.strip q = query.to_s.strip
@@ -46,19 +58,30 @@ class Recording < ApplicationRecord
end end
def playback_stream_url def playback_stream_url
return nil unless ready? && stream_session_id.present? return nil unless ready? && storage_key.present? && stream_session_id.present?
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}/stream" "#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}/stream"
end end
def thumbnail_url def thumbnail_url
return youtube_thumbnail_url if thumbnail_storage_key.blank? && youtube_video_id.present?
return nil unless thumbnail_storage_key.present? && stream_session_id.present? return nil unless thumbnail_storage_key.present? && stream_session_id.present?
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}/thumbnail" "#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}/thumbnail"
end end
def youtube_thumbnail_url
return nil if youtube_video_id.blank?
return nil if youtube_video_id.to_s.start_with?("mock_")
meta_url = metadata.is_a?(Hash) ? metadata.dig("youtube", "thumbnail_url") : nil
return meta_url if meta_url.present?
"https://i.ytimg.com/vi/#{youtube_video_id}/hqdefault.jpg"
end
def download_api_path def download_api_path
return nil unless ready? return nil unless ready? && storage_key.present?
"/api/v1/recordings/#{id}/download" "/api/v1/recordings/#{id}/download"
end end
@@ -70,6 +93,30 @@ class Recording < ApplicationRecord
"https://www.youtube.com/watch?v=#{youtube_video_id}" "https://www.youtube.com/watch?v=#{youtube_video_id}"
end end
def temporary_storage?
storage_policy == "temporary"
end
def retained_storage?
storage_policy == "retained"
end
def local_media_purged?
local_media_purged_at.present?
end
# Sorgente fisica del player in archivio (non lo storage policy).
def replay_source
return "youtube" if youtube_watch_url.present? || (ready? && youtube_video_id.present?)
return "matchlivetv" if ready? && storage_key.present?
"none"
end
def available_in_archive?
ready? && (youtube_watch_url.present? || youtube_video_id.present? || storage_key.present?)
end
def ready? def ready?
status == "ready" && !deleted? && (expires_at.nil? || expires_at.future?) status == "ready" && !deleted? && (expires_at.nil? || expires_at.future?)
end end
@@ -154,7 +201,7 @@ class Recording < ApplicationRecord
end end
def playable_on_site? def playable_on_site?
ready? && storage_key.present? available_in_archive?
end end
def days_until_expiry def days_until_expiry
@@ -0,0 +1,71 @@
# frozen_string_literal: true
class StreamConcurrencyViolation < ApplicationRecord
LOOKBACK = 30.days
belongs_to :user, optional: true
belongs_to :occupying_session, class_name: "StreamSession", optional: true
belongs_to :attempted_session, class_name: "StreamSession", optional: true
belongs_to :occupying_club, class_name: "Club", optional: true
belongs_to :attempted_club, class_name: "Club", optional: true
validates :user_email, :occupying_match_label, :attempted_match_label, presence: true
validates :attempt_action, inclusion: { in: %w[start resume] }
scope :recent, -> { order(created_at: :desc) }
scope :since, ->(time) { where("created_at >= ?", time) }
scope :lookback, -> { since(LOOKBACK.ago) }
scope :two_devices, -> { where(devices_differ: true) }
scope :for_club, lambda { |club_id|
where("occupying_club_id = :id OR attempted_club_id = :id", id: club_id)
}
scope :for_session, lambda { |session_id|
where("occupying_session_id = :id OR attempted_session_id = :id", id: session_id)
}
def self.record!(attempted:, occupying:, action: "start")
user = attempted.user || occupying.user
occupying_club = occupying.match&.team&.club
attempted_club = attempted.match&.team&.club
occupying_device = occupying.client_device_label
attempted_device = attempted.client_device_label
create!(
user: user,
occupying_session: occupying,
attempted_session: attempted,
occupying_club: occupying_club,
attempted_club: attempted_club,
attempt_action: action.to_s,
user_email: user&.email.presence || "unknown",
user_name: user&.name,
occupying_club_name: occupying_club&.name,
attempted_club_name: attempted_club&.name,
occupying_match_label: occupying.match_label,
attempted_match_label: attempted.match_label,
occupying_status: occupying.status,
occupying_device: occupying_device,
attempted_device: attempted_device,
devices_differ: StreamSession.devices_differ?(occupying, attempted),
metadata: {
occupying_session_id: occupying.id,
attempted_session_id: attempted.id,
occupying_client_os: occupying.client_os,
attempted_client_os: attempted.client_os,
occupying_app_version: occupying.app_version,
attempted_app_version: attempted.app_version
}.compact
)
rescue StandardError => e
Rails.logger.error("[StreamConcurrencyViolation] record failed: #{e.class} #{e.message}")
nil
end
def club_name
attempted_club_name.presence || occupying_club_name
end
def operator_label
[user_name.presence, user_email].compact.join(" · ")
end
end
+49 -1
View File
@@ -1,7 +1,8 @@
class StreamSession < ApplicationRecord class StreamSession < ApplicationRecord
include AASM include AASM
PLATFORMS = %w[matchlivetv youtube facebook twitch].freeze LIVE_PLATFORMS = %w[matchlivetv youtube].freeze
PLATFORMS = (LIVE_PLATFORMS + %w[facebook twitch]).freeze
STATUSES = %w[idle connecting live reconnecting paused ended error].freeze STATUSES = %w[idle connecting live reconnecting paused ended error].freeze
PRIVACY_STATUSES = %w[public unlisted private].freeze PRIVACY_STATUSES = %w[public unlisted private].freeze
@@ -9,6 +10,10 @@ class StreamSession < ApplicationRecord
belongs_to :user belongs_to :user
belongs_to :stream_node, optional: true belongs_to :stream_node, optional: true
has_many :stream_events, dependent: :destroy has_many :stream_events, dependent: :destroy
has_many :occupying_concurrency_violations, class_name: "StreamConcurrencyViolation",
foreign_key: :occupying_session_id, dependent: :nullify, inverse_of: :occupying_session
has_many :attempted_concurrency_violations, class_name: "StreamConcurrencyViolation",
foreign_key: :attempted_session_id, dependent: :nullify, inverse_of: :attempted_session
has_one :score_state, dependent: :destroy has_one :score_state, dependent: :destroy
has_one :recording has_one :recording
has_many :device_states, dependent: :destroy has_many :device_states, dependent: :destroy
@@ -20,6 +25,7 @@ class StreamSession < ApplicationRecord
before_validation :normalize_privacy_status before_validation :normalize_privacy_status
before_validation :ensure_publish_token, on: :create before_validation :ensure_publish_token, on: :create
before_validation :snapshot_ingest_from_node, if: -> { stream_node.present? }
scope :broadcasting, -> { where(status: %w[live connecting reconnecting paused]) } scope :broadcasting, -> { where(status: %w[live connecting reconnecting paused]) }
scope :publicly_listed, -> { where(privacy_status: "public") } scope :publicly_listed, -> { where(privacy_status: "public") }
@@ -74,6 +80,43 @@ class StreamSession < ApplicationRecord
end end
end end
def ingest_slug_display
stream_node&.slug.presence || ingest_slug
end
def ingest_role_display
stream_node&.role.presence || ingest_role
end
def match_label
team_name = match&.team&.name
opponent = match&.opponent_name
return id.to_s if team_name.blank?
opponent.present? ? "#{team_name} vs #{opponent}" : team_name
end
def client_device_label
parts = []
parts << client_os if client_os.present?
device = [device_manufacturer, device_model].compact_blank.join(" ")
parts << device if device.present?
parts << "OS #{os_version}" if os_version.present?
parts.join(" · ").presence
end
def client_device_key
[client_os, device_manufacturer, device_model].map { |v| v.to_s.strip.downcase }.join("|")
end
def self.devices_differ?(left, right)
ka = left.client_device_key
kb = right.client_device_key
return false if ka.delete("|").blank? || kb.delete("|").blank?
ka != kb
end
def rtmp_ingest_url def rtmp_ingest_url
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti). # RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
# MediaMTX path = live/match_{uuid} (no ?token= nel path). # MediaMTX path = live/match_{uuid} (no ?token= nel path).
@@ -208,6 +251,11 @@ class StreamSession < ApplicationRecord
self.privacy_status = "unlisted" if privacy_status == "private" self.privacy_status = "unlisted" if privacy_status == "private"
end end
def snapshot_ingest_from_node
self.ingest_slug = stream_node.slug
self.ingest_role = stream_node.role
end
def record_ended_timestamps! def record_ended_timestamps!
now = Time.current now = Time.current
update!(ended_at: now) if ended_at.nil? update!(ended_at: now) if ended_at.nil?
+1
View File
@@ -11,6 +11,7 @@ class User < ApplicationRecord
has_many :clubs, through: :club_memberships has_many :clubs, through: :club_memberships
has_many :owned_clubs, -> { where(club_memberships: { role: "owner" }) }, through: :club_memberships, source: :club has_many :owned_clubs, -> { where(club_memberships: { role: "owner" }) }, through: :club_memberships, source: :club
has_many :stream_sessions, dependent: :nullify has_many :stream_sessions, dependent: :nullify
has_many :stream_concurrency_violations, dependent: :nullify
def manageable_teams def manageable_teams
staff_ids = teams.select(:id) staff_ids = teams.select(:id)
@@ -0,0 +1,150 @@
# frozen_string_literal: true
module Admin
class CostAnalytics
TREND_MONTHS = 12
def initialize(month:)
@month = month.to_date.beginning_of_month
end
def summary
build_period(@month)
end
def trend(months: TREND_MONTHS)
start_month = @month - (months - 1).months
months_list = (0...months).map { |i| start_month + i.months }
months_list.map { |m| build_period(m) }
end
def club_breakdown
range = month_range(@month)
rows = session_rows(range)
total_secs = rows.sum { |row| row.total_secs.to_i }
total_cost_cents = PlatformCostEntry.for_month(@month).sum(:amount_cents)
revenue_by_club = revenue_by_club(range)
storage_by_club = storage_by_club_index
club_ids = rows.map(&:club_id)
clubs = Club.where(id: club_ids).includes(subscription: :plan).index_by(&:id)
rows.map do |row|
club = clubs[row.club_id]
secs = row.total_secs.to_i
hours = secs / 3600.0
sessions_count = row.sessions_count.to_i
share = total_secs.positive? ? secs.to_f / total_secs : 0.0
allocated_cents = (total_cost_cents * share).round
revenue_cents = revenue_by_club[row.club_id].to_i
storage_bytes = storage_by_club[row.club_id].to_i
{
club_id: row.club_id,
club_name: row.club_name,
plan_slug: club&.subscription&.plan&.slug,
sessions: sessions_count,
hours: hours.round(2),
hours_share_pct: (share * 100).round(1),
allocated_cost_cents: allocated_cents,
revenue_cents: revenue_cents,
margin_cents: revenue_cents - allocated_cents,
cost_per_hour_cents: hours.positive? ? (allocated_cents / hours).round : nil,
cost_per_session_cents: sessions_count.positive? ? (allocated_cents / sessions_count) : nil,
storage_bytes: storage_bytes
}
end.sort_by { |row| [-row[:hours], row[:club_name]] }
end
private
def build_period(month)
range = month_range(month)
cost_cents = PlatformCostEntry.for_month(month).sum(:amount_cents)
sessions_scope = ended_sessions.where(ended_at: range)
total_secs = sessions_scope.sum(:total_duration_secs).to_i
hours = total_secs / 3600.0
sessions = sessions_scope.count
clubs_active = distinct_active_clubs(range)
revenue_cents = Billing::Payment.where(status: "paid", paid_at: range).sum(:amount_cents)
storage_bytes = Recording.ready.sum(:byte_size).to_i
storage_gb = storage_bytes.positive? ? storage_bytes / (1024.0**3) : 0.0
kpis = compute_kpis(
cost_cents: cost_cents,
hours: hours,
sessions: sessions,
clubs_active: clubs_active,
revenue_cents: revenue_cents,
storage_gb: storage_gb
)
{
month: month,
cost_cents: cost_cents,
sessions: sessions,
hours: hours.round(2),
clubs_active: clubs_active,
revenue_cents: revenue_cents,
storage_bytes: storage_bytes,
**kpis
}
end
def compute_kpis(cost_cents:, hours:, sessions:, clubs_active:, revenue_cents:, storage_gb:)
margin_cents = revenue_cents.to_i - cost_cents.to_i
margin_pct = revenue_cents.to_i.positive? ? ((margin_cents.to_f / revenue_cents.to_i) * 100).round(1) : nil
hours_f = hours.to_f
sessions_i = sessions.to_i
clubs_i = clubs_active.to_i
storage_f = storage_gb.to_f
cost_i = cost_cents.to_i
{
cost_per_hour_cents: hours_f.positive? ? (cost_i / hours_f).round : nil,
cost_per_session_cents: sessions_i.positive? ? (cost_i / sessions_i) : nil,
cost_per_club_cents: clubs_i.positive? ? (cost_i / clubs_i) : nil,
revenue_per_hour_cents: hours_f.positive? ? (revenue_cents.to_i / hours_f).round : nil,
cost_per_gb_cents: storage_f.positive? ? (cost_i / storage_f).round : nil,
margin_cents: margin_cents,
margin_pct: margin_pct
}
end
def month_range(month)
month.beginning_of_month.beginning_of_day..month.end_of_month.end_of_day
end
def ended_sessions
StreamSession.where(status: "ended")
end
def distinct_active_clubs(range)
ended_sessions
.where(ended_at: range)
.joins(match: { team: :club })
.distinct
.count("clubs.id")
end
def session_rows(range)
ended_sessions
.where(ended_at: range)
.joins(match: { team: :club })
.group("clubs.id", "clubs.name")
.select(
"clubs.id AS club_id",
"clubs.name AS club_name",
"COUNT(stream_sessions.id) AS sessions_count",
"SUM(stream_sessions.total_duration_secs) AS total_secs"
)
end
def revenue_by_club(range)
Billing::Payment.where(status: "paid", paid_at: range).group(:club_id).sum(:amount_cents)
end
def storage_by_club_index
Recording.ready.joins(:team).group("teams.club_id").sum(:byte_size)
end
end
end
+32 -15
View File
@@ -3,6 +3,7 @@
module Analytics module Analytics
class Aggregate class Aggregate
BATCH = 500 BATCH = 500
UPSERT_RETRIES = 3
def call def call
loop do loop do
@@ -45,13 +46,15 @@ module Analytics
now = Time.current now = Time.current
grouped.each do |(day, page_path, device, cell_x, cell_y), count| grouped.each do |(day, page_path, device, cell_x, cell_y), count|
cell = AnalyticsPageCell.find_or_initialize_by( with_unique_retry do
day: day, page_path: page_path, device: device, cell_x: cell_x, cell_y: cell_y cell = AnalyticsPageCell.find_or_initialize_by(
) day: day, page_path: page_path, device: device, cell_x: cell_x, cell_y: cell_y
cell[counter_attr] = cell[counter_attr].to_i + count )
cell.created_at ||= now cell[counter_attr] = cell[counter_attr].to_i + count
cell.updated_at = now cell.created_at ||= now
cell.save! cell.updated_at = now
cell.save!
end
end end
end end
@@ -76,14 +79,28 @@ module Analytics
now = Time.current now = Time.current
grouped.each do |(day, page_path, device), vals| grouped.each do |(day, page_path, device), vals|
stat = AnalyticsPageStat.find_or_initialize_by(day: day, page_path: page_path, device: device) with_unique_retry do
stat.pageview_count = stat.pageview_count.to_i + vals[:pageviews] stat = AnalyticsPageStat.find_or_initialize_by(day: day, page_path: page_path, device: device)
stat.scroll_samples = stat.scroll_samples.to_i + vals[:scroll_samples] stat.pageview_count = stat.pageview_count.to_i + vals[:pageviews]
stat.scroll_sum_pct = stat.scroll_sum_pct.to_i + vals[:scroll_sum] stat.scroll_samples = stat.scroll_samples.to_i + vals[:scroll_samples]
stat.max_scroll_pct = [stat.max_scroll_pct.to_i, vals[:max_scroll]].max stat.scroll_sum_pct = stat.scroll_sum_pct.to_i + vals[:scroll_sum]
stat.created_at ||= now stat.max_scroll_pct = [stat.max_scroll_pct.to_i, vals[:max_scroll]].max
stat.updated_at = now stat.created_at ||= now
stat.save! stat.updated_at = now
stat.save!
end
end
end
def with_unique_retry
attempts = 0
begin
attempts += 1
yield
rescue ActiveRecord::RecordNotUnique
raise if attempts >= UPSERT_RETRIES
retry
end end
end end
end end
+9 -1
View File
@@ -31,7 +31,15 @@ module Analytics
end end
AnalyticsEvent.insert_all(rows) if rows.any? AnalyticsEvent.insert_all(rows) if rows.any?
Analytics::Aggregate.new.call if rows.any? if rows.any?
begin
Analytics::Aggregate.new.call
rescue ActiveRecord::RecordNotUnique => e
# Dopo i retry interni: non far fallire la richiesta analytics.
Rails.logger.warn("[analytics] aggregate race after retries, enqueue job: #{e.message}")
Analytics::AggregateJob.perform_later
end
end
Result.new(accepted: accepted, rejected: rejected + (@events.size - slice.size), rate_limited: false) Result.new(accepted: accepted, rejected: rejected + (@events.size - slice.size), rate_limited: false)
end end
@@ -0,0 +1,42 @@
# frozen_string_literal: true
module Analytics
module Suppress
COOKIE_NAME = "mltv_analytics_suppress"
COOKIE_MAX_AGE = 7 * 24 * 60 * 60
module_function
def active?(cookie_value)
cookie_value.to_s == "1"
end
def enable!(cookie_jar)
cookie_jar[COOKIE_NAME] = cookie_options(value: "1", expires: COOKIE_MAX_AGE.seconds.from_now)
end
def disable!(cookie_jar)
cookie_jar.delete(
COOKIE_NAME,
path: "/",
same_site: :lax,
secure: cookie_secure?
)
end
def cookie_options(value:, expires:)
{
value: value,
expires: expires,
path: "/",
httponly: true,
same_site: :lax,
secure: cookie_secure?
}
end
def cookie_secure?
Rails.application.config.force_ssl || Rails.env.production?
end
end
end
@@ -54,6 +54,8 @@ module Ops
def process_finding(finding) def process_finding(finding)
if finding.healthy if finding.healthy
Ops::IncidentRecorder.resolve(fingerprint: finding.fingerprint) Ops::IncidentRecorder.resolve(fingerprint: finding.fingerprint)
# overflow usa fingerprint diverse (at_max / orphan_idle / budget) rispetto al check sano
Ops::IncidentRecorder.resolve_kind(finding.kind) if finding.kind == "stream_overflow"
else else
Ops::IncidentRecorder.record( Ops::IncidentRecorder.record(
kind: finding.kind, kind: finding.kind,
@@ -10,6 +10,10 @@ module Ops
def resolve(fingerprint:) def resolve(fingerprint:)
new.resolve(fingerprint: fingerprint) new.resolve(fingerprint: fingerprint)
end end
def resolve_kind(kind)
new.resolve_kind(kind)
end
end end
def record(finding) def record(finding)
@@ -49,6 +53,10 @@ module Ops
Ops::Incident.open.where(fingerprint: fingerprint).find_each(&:resolve!) Ops::Incident.open.where(fingerprint: fingerprint).find_each(&:resolve!)
end end
def resolve_kind(kind)
Ops::Incident.open.where(kind: kind).find_each(&:resolve!)
end
private private
def fingerprint_for(finding) def fingerprint_for(finding)
@@ -0,0 +1,72 @@
# frozen_string_literal: true
module Recordings
# Rimuove solo l'MP4 temporaneo su object storage. Non soft-delete, non tocca YouTube.
class ClearTemporaryMedia
def initialize(recording, reason: :verified, force: false)
@recording = recording
@reason = reason
@force = force
end
def call
unless @recording.temporary_storage?
Rails.logger.info(
"[Recordings::ClearTemporaryMedia] skip recording=#{@recording.id} not_temporary"
)
return @recording
end
if @recording.local_media_purged?
Rails.logger.info(
"[Recordings::ClearTemporaryMedia] already_purged recording=#{@recording.id}"
)
return @recording
end
unless allowed?
Rails.logger.info(
"[Recordings::ClearTemporaryMedia] skip recording=#{@recording.id} " \
"reason=not_verified_and_not_expired force=#{@force}"
)
return @recording
end
delete_video_object!
@recording.update!(
storage_key: nil,
byte_size: nil,
local_media_purged_at: Time.current
)
Rails.logger.info(
"[Recordings::ClearTemporaryMedia] purged recording=#{@recording.id} " \
"reason=#{@reason} youtube_verified=#{@recording.youtube_verified_at.present?} " \
"youtube_video_id=#{@recording.youtube_video_id.inspect}"
)
@recording
end
private
def allowed?
return true if @force
return true if @recording.youtube_verified_at.present?
return true if @recording.temp_expires_at.present? && @recording.temp_expires_at <= Time.current
false
end
def delete_video_object!
key = @recording.storage_key
return if key.blank?
Recordings::Storage.new.delete(key: key)
rescue Recordings::Storage::Error => e
# File già assente → ok (idempotente)
Rails.logger.info(
"[Recordings::ClearTemporaryMedia] storage delete recording=#{@recording.id}: #{e.message}"
)
end
end
end
@@ -1,3 +1,5 @@
# frozen_string_literal: true
module Recordings module Recordings
class FinalizeSession class FinalizeSession
def initialize(session) def initialize(session)
@@ -5,29 +7,69 @@ module Recordings
end end
def call def call
team = @session.match.team policy = Recordings::StoragePolicy.call(@session)
return unless team.entitlements.can_create_recordings? return if policy == Recordings::StoragePolicy::NONE
retention_days = team.entitlements.recording_retention_days team = @session.match.team
expires_at = retention_days.positive? ? retention_days.days.from_now : nil attrs = attributes_for(policy, team)
recording = Recording.find_or_initialize_by(stream_session: @session) recording = Recording.find_or_initialize_by(stream_session: @session)
recording.assign_attributes( if skip_reinitialize?(recording)
Rails.logger.info(
"[Recordings::FinalizeSession] skip already-finalized " \
"recording=#{recording.id} status=#{recording.status}"
)
return recording
end
recording.assign_attributes(attrs)
recording.save!
Rails.logger.info(
"[Recordings::FinalizeSession] session=#{@session.id} recording=#{recording.id} " \
"storage_policy=#{policy} expires_at=#{recording.expires_at.inspect} " \
"temp_expires_at=#{recording.temp_expires_at.inspect}"
)
recording
end
private
def skip_reinitialize?(recording)
return false unless recording.persisted?
recording.ready? ||
recording.status == "processing" ||
recording.storage_key.present?
end
def attributes_for(policy, team)
{
team: team, team: team,
status: "processing", status: "processing",
title: default_title, title: default_title,
privacy_status: privacy_from_session, privacy_status: privacy_from_session,
storage_path: @session.mediamtx_path_name, storage_path: @session.mediamtx_path_name,
recorded_at: @session.ended_at || Time.current, recorded_at: @session.ended_at || Time.current,
expires_at: expires_at, storage_policy: policy,
expires_at: archive_expires_at(policy, team),
temp_expires_at: temp_expires_at_for(policy),
error_message: nil, error_message: nil,
metadata: initial_metadata(team) metadata: initial_metadata(policy, team)
) }
recording.save!
recording
end end
private def archive_expires_at(policy, team)
return nil if policy == Recordings::StoragePolicy::TEMPORARY
retention_days = team.entitlements.recording_retention_days
retention_days.positive? ? retention_days.days.from_now : nil
end
def temp_expires_at_for(policy)
return nil unless policy == Recordings::StoragePolicy::TEMPORARY
MatchLiveTv.youtube_temp_replay_retention_hours.hours.from_now
end
def default_title def default_title
match = @session.match match = @session.match
@@ -38,12 +80,13 @@ module Recordings
@session.privacy_status == "public" ? "public" : "unlisted" @session.privacy_status == "public" ? "public" : "unlisted"
end end
def initial_metadata(team) def initial_metadata(policy, team)
ent = team.entitlements
{ {
"source_platform" => @session.platform, "source_platform" => @session.platform,
"session_privacy" => @session.privacy_status, "session_privacy" => @session.privacy_status,
"auto_publish_youtube" => ent.premium_full? && ent.youtube_enabled? && @session.platform == "youtube", # Re-upload automatico disabilitato: le live YouTube usano VerifyYoutubeReplay.
"auto_publish_youtube" => false,
"storage_policy" => policy,
"ai" => {} "ai" => {}
} }
end end
@@ -23,7 +23,7 @@ module Recordings
private private
def deliver_expiring_soon(user) def deliver_expiring_soon(user)
Recordings::ReplayMailer.replay_expiring_soon(recording: @recording, recipient: user).deliver_now MatchLiveTv.deliver_mail(Recordings::ReplayMailer.replay_expiring_soon(recording: @recording, recipient: user))
rescue EOFError => e rescue EOFError => e
Rails.logger.warn("[Recordings::NotifyExpiring] SMTP EOF on close for #{user.email}: #{e.message}") Rails.logger.warn("[Recordings::NotifyExpiring] SMTP EOF on close for #{user.email}: #{e.message}")
end end
@@ -19,7 +19,7 @@ module Recordings
private private
def deliver_replay_ready(user) def deliver_replay_ready(user)
Recordings::ReplayMailer.replay_ready(recording: @recording, recipient: user).deliver_now MatchLiveTv.deliver_mail(Recordings::ReplayMailer.replay_ready(recording: @recording, recipient: user))
rescue EOFError => e rescue EOFError => e
# Aruba SMTP (465/SSL) chiude la socket prima del QUIT: la mail è già partita. # Aruba SMTP (465/SSL) chiude la socket prima del QUIT: la mail è già partita.
Rails.logger.warn("[Recordings::NotifyReady] SMTP EOF on close for #{user.email}: #{e.message}") Rails.logger.warn("[Recordings::NotifyReady] SMTP EOF on close for #{user.email}: #{e.message}")
@@ -0,0 +1,92 @@
# frozen_string_literal: true
require "cgi"
require "fileutils"
require "net/http"
require "uri"
module Recordings
# Recupera i segmenti MediaMTX dal disco del CPX (agent :9100) verso un tmpdir locale.
class PullFromCloudNode
class Error < StandardError; end
def initialize(session)
@session = session
end
def applicable?
node = @session.stream_node
node.present? && node.role == "cloud" && agent_url.present?
end
# @return [String, nil] directory con i file, o nil se il nodo non è cloud / 404
def fetch
return unless applicable?
dest = Dir.mktmpdir("mltv-cpx-rec-")
uri = recordings_uri
http = Net::HTTP.new(uri.host, uri.port)
http.open_timeout = 5
http.read_timeout = ENV.fetch("STREAM_NODE_RECORDINGS_PULL_TIMEOUT", "180").to_i
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{agent_secret}" if agent_secret.present?
res = http.request(req)
if res.is_a?(Net::HTTPNotFound)
FileUtils.remove_entry(dest)
return nil
end
unless res.is_a?(Net::HTTPSuccess) && res.body.present?
FileUtils.remove_entry(dest)
raise Error, "agent GET recordings HTTP #{res.code} #{res.body.to_s.truncate(200)}"
end
tar_path = File.join(dest, "recordings.tar.gz")
File.binwrite(tar_path, res.body)
unpack!(tar_path, dest)
FileUtils.rm_f(tar_path)
dest
rescue StandardError
FileUtils.remove_entry(dest) if dest && Dir.exist?(dest)
raise
end
def cleanup_remote!
return unless applicable?
uri = recordings_uri
http = Net::HTTP.new(uri.host, uri.port)
http.open_timeout = 5
http.read_timeout = 15
req = Net::HTTP::Delete.new(uri)
req["Authorization"] = "Bearer #{agent_secret}" if agent_secret.present?
res = http.request(req)
return if res.is_a?(Net::HTTPSuccess) || res.is_a?(Net::HTTPNotFound)
Rails.logger.warn(
"[Recordings::PullFromCloudNode] delete HTTP #{res.code} session=#{@session.id}"
)
rescue StandardError => e
Rails.logger.warn("[Recordings::PullFromCloudNode] delete #{e.class}: #{e.message}")
end
private
def recordings_uri
path = @session.mediamtx_path_name.to_s
URI.parse("#{agent_url.chomp('/')}/recordings/#{CGI.escape(path)}")
end
def agent_url
ENV["STREAM_NODE_RELAY_AGENT_URL"].presence || @session.stream_node&.relay_agent_url
end
def agent_secret
ENV["STREAM_NODE_AGENT_SECRET"].presence || "mediamtx_webhook_dev_secret"
end
def unpack!(tar_path, dest)
ok = system("tar", "-xzf", tar_path, "-C", dest, out: File::NULL, err: File::NULL)
raise Error, "tar extract failed" unless ok
end
end
end
@@ -0,0 +1,36 @@
# frozen_string_literal: true
module Recordings
# Decisione centralizzata: temporary (YouTube) | retained (HLS) | none.
class StoragePolicy
TEMPORARY = "temporary"
RETAINED = "retained"
NONE = "none"
POLICIES = [TEMPORARY, RETAINED, NONE].freeze
def self.call(session)
new(session).call
end
def initialize(session)
@session = session
end
def call
team = @session.match.team
ent = team.entitlements
return NONE unless ent.can_create_recordings?
if youtube_destination?
TEMPORARY
else
RETAINED
end
end
def youtube_destination?
@session.platform.to_s == "youtube"
end
end
end
@@ -9,8 +9,9 @@ module Recordings
def call def call
recording = Recording.find_by(stream_session: @session) recording = Recording.find_by(stream_session: @session)
return unless recording&.status == "processing" return unless recording&.status == "processing"
return if recording.storage_key.present?
source_files = local_source_files source_files = collect_source_files
if source_files.empty? if source_files.empty?
fail_recording!(recording, "Nessun file di registrazione trovato") fail_recording!(recording, "Nessun file di registrazione trovato")
cleanup_mediamtx_path cleanup_mediamtx_path
@@ -35,23 +36,52 @@ module Recordings
error_message: nil error_message: nil
) )
Rails.logger.info(
"[Recordings::UploadFromSession] ready recording=#{recording.id} " \
"storage_policy=#{recording.storage_policy} key=#{storage_key} bytes=#{byte_size}"
)
cleanup_local_sources(source_files, merged_path) cleanup_local_sources(source_files, merged_path)
cleanup_cloud_pull!
cleanup_remote_recordings!
cleanup_mediamtx_path cleanup_mediamtx_path
Recordings::PostProcessJob.perform_async(recording.id) Recordings::PostProcessJob.perform_async(recording.id)
recording recording
rescue Error, Recordings::Storage::Error => e rescue Error, Recordings::Storage::Error, Recordings::PullFromCloudNode::Error => e
recording = Recording.find_by(stream_session: @session) recording = Recording.find_by(stream_session: @session)
fail_recording!(recording, e.message) if recording fail_recording!(recording, e.message) if recording
raise raise
ensure ensure
FileUtils.rm_f(@merged_temp_path) if @merged_temp_path && File.exist?(@merged_temp_path) FileUtils.rm_f(@merged_temp_path) if @merged_temp_path && File.exist?(@merged_temp_path)
cleanup_cloud_pull! unless recording_ready?
end end
private private
def collect_source_files
files = local_source_files
return files if files.any?
puller = Recordings::PullFromCloudNode.new(@session)
return [] unless puller.applicable?
3.times do |i|
cleanup_cloud_pull!
@cloud_pull_dir = puller.fetch
files = scan_recording_files(@cloud_pull_dir)
return files if files.any?
sleep 2 if i < 2
end
[]
end
def local_source_files def local_source_files
base = File.join(MatchLiveTv.recordings_local_path, @session.mediamtx_path_name) scan_recording_files(File.join(MatchLiveTv.recordings_local_path, @session.mediamtx_path_name))
return [] unless Dir.exist?(base) end
def scan_recording_files(base)
return [] if base.blank? || !Dir.exist?(base)
Dir.glob(File.join(base, "**", "*")) Dir.glob(File.join(base, "**", "*"))
.select { |path| File.file?(path) && path.match?(/\.(mp4|fmp4|m4s|ts)$/i) } .select { |path| File.file?(path) && path.match?(/\.(mp4|fmp4|m4s|ts)$/i) }
@@ -83,7 +113,11 @@ module Recordings
end end
def object_key(recording) def object_key(recording)
"teams/#{recording.team_id}/sessions/#{@session.id}/replay.mp4" if recording.temporary_storage?
"temporary_replays/teams/#{recording.team_id}/sessions/#{@session.id}/replay.mp4"
else
"teams/#{recording.team_id}/sessions/#{@session.id}/replay.mp4"
end
end end
def cleanup_local_sources(source_files, merged_path) def cleanup_local_sources(source_files, merged_path)
@@ -100,6 +134,24 @@ module Recordings
Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}") Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}")
end end
def cleanup_cloud_pull!
return if @cloud_pull_dir.blank? || !Dir.exist?(@cloud_pull_dir)
FileUtils.remove_entry(@cloud_pull_dir)
@cloud_pull_dir = nil
rescue StandardError
nil
end
def cleanup_remote_recordings!
Recordings::PullFromCloudNode.new(@session).cleanup_remote!
end
def recording_ready?
rec = Recording.find_by(stream_session: @session)
rec&.status == "ready"
end
def fail_recording!(recording, message) def fail_recording!(recording, message)
recording.update!(status: "failed", error_message: message) recording.update!(status: "failed", error_message: message)
cleanup_mediamtx_path cleanup_mediamtx_path
@@ -0,0 +1,106 @@
# frozen_string_literal: true
module Recordings
# Collega la live YouTube (broadcast_id) al VOD e aggiorna metadata recording.
class VerifyYoutubeReplay
class Error < StandardError; end
Result = Struct.new(:status, :message, keyword_init: true) do
def ok?
status == :ok
end
def retriable?
status == :pending
end
end
def initialize(recording)
@recording = recording
end
def call
unless @recording.temporary_storage?
return Result.new(status: :skipped, message: "not_temporary")
end
if @recording.youtube_verified_at.present? && @recording.youtube_video_id.present?
return Result.new(status: :ok, message: "already_verified")
end
session = @recording.stream_session
broadcast_id = session&.youtube_broadcast_id
if broadcast_id.blank?
Rails.logger.warn(
"[Recordings::VerifyYoutubeReplay] missing broadcast_id recording=#{@recording.id}"
)
return Result.new(status: :failed, message: "missing_broadcast_id")
end
info = Youtube::VodStatus.new(@recording.team, channel: "team").fetch(broadcast_id)
unless info.ready
Rails.logger.info(
"[Recordings::VerifyYoutubeReplay] not_ready recording=#{@recording.id} " \
"video=#{broadcast_id} upload_status=#{info.upload_status.inspect}"
)
return Result.new(status: :pending, message: "vod_not_ready")
end
info = enable_embed_if_needed!(info)
apply_verified!(info)
Rails.logger.info(
"[Recordings::VerifyYoutubeReplay] ok recording=#{@recording.id} " \
"youtube_video_id=#{info.video_id} embeddable=#{info.embeddable}"
)
Result.new(status: :ok, message: "verified")
rescue Youtube::VodStatus::Error => e
Rails.logger.warn(
"[Recordings::VerifyYoutubeReplay] api_error recording=#{@recording.id}: #{e.message}"
)
Result.new(status: :pending, message: e.message)
end
private
def apply_verified!(info)
meta = @recording.metadata.is_a?(Hash) ? @recording.metadata.deep_dup : {}
yt = meta.fetch("youtube", {}).merge(
"broadcast_id" => @recording.stream_session.youtube_broadcast_id,
"thumbnail_url" => info.thumbnail_url,
"privacy_status" => info.privacy_status,
"upload_status" => info.upload_status,
"embeddable" => info.embeddable,
"verified_via" => "live_broadcast"
).compact
attrs = {
youtube_video_id: info.video_id,
youtube_verified_at: Time.current,
youtube_published_at: @recording.youtube_published_at || Time.current,
metadata: meta.merge("youtube" => yt)
}
attrs[:duration_secs] = info.duration_secs if info.duration_secs.to_i.positive?
attrs[:title] = info.title if info.title.present? && @recording.title.blank?
@recording.update!(attrs)
end
def enable_embed_if_needed!(info)
return info if info.embeddable != false
return info if info.video_id.to_s.start_with?("mock_")
session = @recording.stream_session
Youtube::BroadcastService.new(session.match.team).enable_video_embed!(
info.video_id,
privacy_status: info.privacy_status
)
refreshed = Youtube::VodStatus.new(@recording.team, channel: "team").fetch(info.video_id)
refreshed.ready ? refreshed : info
rescue Youtube::BroadcastService::Error, Youtube::VodStatus::Error => e
Rails.logger.warn(
"[Recordings::VerifyYoutubeReplay] enable_embed recording=#{@recording.id}: #{e.message}"
)
info
end
end
end
@@ -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
@@ -0,0 +1,51 @@
# frozen_string_literal: true
module Sessions
class AssertUserConcurrent
ERROR_CODE = "user_concurrent_stream"
def self.with_lock(session, action: "start")
new(session, action: action).with_lock { yield }
end
def initialize(session, action: "start")
@session = session
@action = action.to_s
end
def with_lock
occupying = nil
User.transaction do
User.lock.find(@session.user_id) if @session.user_id.present?
occupying = occupying_session
yield if occupying.nil?
end
return if occupying.nil?
StreamConcurrencyViolation.record!(
attempted: @session,
occupying: occupying,
action: @action
)
raise Teams::EntitlementError.new(
I18n.t("api.errors.user_concurrent_stream"),
code: ERROR_CODE
)
end
private
def occupying_session
return if @session.user_id.blank?
StreamSession.broadcasting
.where(user_id: @session.user_id)
.where.not(id: @session.id)
.includes(:user, match: { team: :club })
.order(Arel.sql("COALESCE(started_at, updated_at) DESC"))
.first
end
end
end
+31 -3
View File
@@ -26,6 +26,7 @@ module Sessions
target_fps: @params[:target_fps] || 30, target_fps: @params[:target_fps] || 30,
status: "idle" status: "idle"
) )
Sessions::ApplyClientInfo.call(session, @params[:client])
youtube_channel = nil youtube_channel = nil
if session.platform == "youtube" if session.platform == "youtube"
@@ -46,18 +47,23 @@ 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
kick_autoscaler_if_soft_limit!
if session.platform == "youtube" if session.platform == "youtube"
YoutubeBroadcastSetupJob.perform_later(session.id, youtube_channel) YoutubeBroadcastSetupJob.perform_later(session.id, youtube_channel)
end end
session session
rescue Streams::NodeRegistry::NoCapacityError => e rescue Streams::NodeRegistry::NoCapacityError => e
raise Teams::EntitlementError.new(e.message, code: "stream_capacity_exhausted") raise_no_capacity!(e)
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
raise Streams::IngestUnavailableError, "Ingest temporaneamente non disponibile (#{e.class})" raise Streams::IngestUnavailableError, "Ingest temporaneamente non disponibile (#{e.class})"
rescue Mediamtx::Client::Error => e rescue Mediamtx::Client::Error => e
@@ -69,6 +75,28 @@ module Sessions
private private
def raise_no_capacity!(error)
if Streams::Autoscaler.enabled?
Streams::AutoscalerJob.kick!
raise Teams::EntitlementError.new(
"Capacità streaming in espansione. Riprova tra poco.",
code: "stream_capacity_scaling"
)
end
raise Teams::EntitlementError.new(error.message, code: "stream_capacity_exhausted")
end
def kick_autoscaler_if_soft_limit!
return unless Streams::Autoscaler.enabled?
free = Streams::Autoscaler.metrics[:free_slots].to_i
return if free > Streams::Autoscaler.soft_free_slots
Streams::AutoscalerJob.kick!
end
def assert_youtube_channel!(youtube_channel) def assert_youtube_channel!(youtube_channel)
resolver = Youtube::CredentialResolver.new(@match.team, channel: youtube_channel) resolver = Youtube::CredentialResolver.new(@match.team, channel: youtube_channel)
if resolver.resolve.blank? if resolver.resolve.blank?
+4 -2
View File
@@ -10,8 +10,10 @@ module Sessions
end end
cancel_timeout_job cancel_timeout_job
# connecting finché RTMP non è online (evita lose_connection da PublisherSync) Sessions::AssertUserConcurrent.with_lock(@session, action: "resume") do
@session.begin_connect! if @session.may_begin_connect? # connecting finché RTMP non è online (evita lose_connection da PublisherSync)
@session.begin_connect! if @session.may_begin_connect?
end
# Recording riabilitato in PublisherSync quando RTMP è online (evita patch path prima del publisher). # Recording riabilitato in PublisherSync quando RTMP è online (evita patch path prima del publisher).
log_event("resumed") log_event("resumed")
SessionChannel.broadcast_message(@session, { type: "command", action: "resume_stream" }) SessionChannel.broadcast_message(@session, { type: "command", action: "resume_stream" })
+5 -3
View File
@@ -5,9 +5,11 @@ module Sessions
end end
def call def call
@session.match.team.entitlements.assert_concurrent_stream!(excluding_session: @session) Sessions::AssertUserConcurrent.with_lock(@session, action: "start") do
@session.begin_connect! if @session.may_begin_connect? @session.match.team.entitlements.assert_concurrent_stream!(excluding_session: @session)
@session.update!(status: "connecting") unless @session.connecting? @session.begin_connect! if @session.may_begin_connect?
@session.update!(status: "connecting") unless @session.connecting?
end
Youtube::LivePipeline.schedule!(@session, force: true) if @session.platform == "youtube" Youtube::LivePipeline.schedule!(@session, force: true) if @session.platform == "youtube"
broadcast_status("connecting") broadcast_status("connecting")
@session @session
+1 -1
View File
@@ -12,7 +12,7 @@ module Sessions
complete_youtube_broadcast! if @session.youtube_broadcast_id.present? complete_youtube_broadcast! if @session.youtube_broadcast_id.present?
recording = Recordings::FinalizeSession.new(@session).call recording = Recordings::FinalizeSession.new(@session).call
Recordings::UploadJob.perform_async(@session.id) if recording Recordings::UploadJob.perform_async(@session.id) if recording&.status == "processing"
remove_mediamtx_paths! remove_mediamtx_paths!
log_event("ended") log_event("ended")
@@ -13,7 +13,7 @@ module Users
return if user.nil? return if user.nil?
token = user.generate_password_reset! token = user.generate_password_reset!
UserMailer.password_reset(user, token).deliver_now MatchLiveTv.deliver_mail(UserMailer.password_reset(user, token))
end end
end end
end end
@@ -97,6 +97,24 @@ module Youtube
:skipped :skipped
end end
def enable_video_embed!(video_id, privacy_status: "unlisted")
return if video_id.blank? || video_id.to_s.start_with?("mock_")
return if @credential.blank? || missing_oauth_config?
client = authorized_client
video = Google::Apis::YoutubeV3::Video.new(
id: video_id,
status: Google::Apis::YoutubeV3::VideoStatus.new(
embeddable: true,
privacy_status: privacy_status.presence || "unlisted",
self_declared_made_for_kids: false
)
)
client.update_video("status", video)
rescue Google::Apis::Error => e
raise Error, e.message
end
def complete_broadcast!(broadcast_id) def complete_broadcast!(broadcast_id)
return if broadcast_id.blank? return if broadcast_id.blank?
return if @credential.blank? || missing_oauth_config? return if @credential.blank? || missing_oauth_config?
@@ -0,0 +1,88 @@
# frozen_string_literal: true
module Youtube
# Stato VOD dopo una live (broadcast_id ≈ video_id su YouTube).
class VodStatus
class Error < StandardError; end
Result = Struct.new(
:ready,
:video_id,
:title,
:duration_secs,
:thumbnail_url,
:privacy_status,
:upload_status,
:embeddable,
keyword_init: true
)
def initialize(team, channel: "team")
@team = team
@channel = channel
end
def fetch(video_id)
raise Error, "video_id mancante" if video_id.blank?
if mock_or_unconfigured?(video_id)
return Result.new(
ready: true,
video_id: video_id,
title: nil,
duration_secs: nil,
thumbnail_url: nil,
privacy_status: "unlisted",
upload_status: "processed",
embeddable: true
)
end
client = authorized_client
item = client.list_videos("snippet,contentDetails,status", id: video_id).items&.first
return Result.new(ready: false, video_id: video_id) if item.blank?
upload_status = item.status&.upload_status.to_s
ready = upload_status.in?(%w[processed uploaded]) ||
(item.snippet.present? && upload_status != "deleted" && upload_status != "rejected" && upload_status != "failed")
Result.new(
ready: ready,
video_id: item.id,
title: item.snippet&.title,
duration_secs: parse_duration(item.content_details&.duration),
thumbnail_url: item.snippet&.thumbnails&.high&.url || item.snippet&.thumbnails&.default&.url,
privacy_status: item.status&.privacy_status,
upload_status: upload_status,
embeddable: item.status&.embeddable != false
)
rescue Google::Apis::Error => e
raise Error, e.message
end
private
def mock_or_unconfigured?(video_id)
video_id.to_s.start_with?("mock_") ||
ENV["YOUTUBE_CLIENT_ID"].blank? ||
CredentialResolver.new(@team, channel: @channel).resolve.blank?
end
def authorized_client
credential = CredentialResolver.new(@team, channel: @channel).resolve
raise Error, "Credenziali YouTube non disponibili" if credential.blank?
OauthRefresh.new(credential).apply!(Google::Apis::YoutubeV3::YouTubeService.new)
end
def parse_duration(iso)
return nil if iso.blank?
# PT1H2M3S
match = iso.match(/\APT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?\z/)
return nil unless match
match[1].to_i * 3600 + match[2].to_i * 60 + match[3].to_i
end
end
end
@@ -5,6 +5,32 @@
<p class="muted admin-page-sub"><%= t("admin.analytics.index.lead") %></p> <p class="muted admin-page-sub"><%= t("admin.analytics.index.lead") %></p>
</div> </div>
<section class="panel admin-analytics-preview">
<h3><%= t("admin.analytics.preview.title") %></h3>
<p class="muted admin-table-sub"><%= t("admin.analytics.preview.lead") %></p>
<% if @analytics_preview_active %>
<p class="admin-analytics-preview__status"><%= t("admin.analytics.preview.active") %></p>
<div class="admin-filter-actions">
<%= button_to t("admin.analytics.preview.disable"),
admin_analytics_preview_path,
method: :delete,
class: "admin-btn admin-btn--outline admin-btn--sm" %>
<%= link_to t("admin.analytics.preview.open_site"),
root_path,
class: "admin-btn admin-btn--primary admin-btn--sm",
target: "_blank",
rel: "noopener" %>
</div>
<% else %>
<div class="admin-filter-actions">
<%= button_to t("admin.analytics.preview.enable"),
admin_analytics_preview_path(return_to: root_path),
method: :post,
class: "admin-btn admin-btn--primary admin-btn--sm" %>
</div>
<% end %>
</section>
<div class="panel admin-sessions-filters"> <div class="panel admin-sessions-filters">
<%= form_with url: admin_analytics_path, method: :get, local: true, class: "admin-filter-form" do %> <%= form_with url: admin_analytics_path, method: :get, local: true, class: "admin-filter-form" do %>
<div class="admin-filter-grid"> <div class="admin-filter-grid">
@@ -58,8 +84,9 @@
<td class="muted"><%= avg %>%</td> <td class="muted"><%= avg %>%</td>
<td class="muted"><%= row[:max_scroll] %>%</td> <td class="muted"><%= row[:max_scroll] %>%</td>
<td> <td>
<%= link_to t("admin.analytics.index.heatmap"), <% heatmap_params = { page_path: row[:page_path], from: @filters[:from], to: @filters[:to] } %>
admin_analytics_page_path(page_path: row[:page_path], from: @filters[:from], to: @filters[:to], device: @filters[:device]) %> <% heatmap_params[:device] = @filters[:device] if @filters[:device].present? %>
<%= link_to t("admin.analytics.index.heatmap"), admin_analytics_page_path(heatmap_params) %>
</td> </td>
</tr> </tr>
<% end %> <% end %>
+42 -14
View File
@@ -3,7 +3,7 @@
<div class="admin-page-head admin-session-head"> <div class="admin-page-head admin-session-head">
<div> <div>
<p class="muted admin-page-sub"> <p class="muted admin-page-sub">
<%= link_to t("admin.analytics.show.back"), admin_analytics_path(from: @filters[:from], to: @filters[:to], device: @filters[:device]) %> <%= link_to t("admin.analytics.show.back"), admin_analytics_path(from: @filters[:from], to: @filters[:to]) %>
</p> </p>
<h2 class="admin-page-title"><%= t("admin.analytics.show.title") %></h2> <h2 class="admin-page-title"><%= t("admin.analytics.show.title") %></h2>
<p><code class="admin-mono"><%= @page_path %></code></p> <p><code class="admin-mono"><%= @page_path %></code></p>
@@ -11,8 +11,32 @@
</div> </div>
<div class="panel admin-sessions-filters"> <div class="panel admin-sessions-filters">
<p class="admin-filter-field" style="margin-bottom: 0.75rem;">
<span><%= t("admin.analytics.show.device_tabs_label") %></span>
</p>
<div class="admin-locale-tabs" role="tablist">
<% AnalyticsEvent::DEVICES.each do |device| %>
<% stats = @device_tab_stats[device] %>
<% active = @filters[:device] == device %>
<%= link_to admin_analytics_page_path(
page_path: @page_path,
from: @filters[:from],
to: @filters[:to],
device: device,
layer: @filters[:layer]
),
class: "admin-locale-tab #{'is-active' if active}",
role: "tab",
"aria-selected": active do %>
<%= t("admin.analytics.devices.#{device}") %>
<span class="admin-device-tab-count"><%= stats[:pageviews] %></span>
<% end %>
<% end %>
</div>
<%= form_with url: admin_analytics_page_path, method: :get, local: true, class: "admin-filter-form" do %> <%= form_with url: admin_analytics_page_path, method: :get, local: true, class: "admin-filter-form" do %>
<%= hidden_field_tag :page_path, @page_path %> <%= hidden_field_tag :page_path, @page_path %>
<%= hidden_field_tag :device, @filters[:device] %>
<div class="admin-filter-grid"> <div class="admin-filter-grid">
<label class="admin-filter-field"> <label class="admin-filter-field">
<span><%= t("admin.analytics.filters.from") %></span> <span><%= t("admin.analytics.filters.from") %></span>
@@ -22,14 +46,6 @@
<span><%= t("admin.analytics.filters.to") %></span> <span><%= t("admin.analytics.filters.to") %></span>
<%= date_field_tag :to, @filters[:to] %> <%= date_field_tag :to, @filters[:to] %>
</label> </label>
<label class="admin-filter-field">
<span><%= t("admin.analytics.filters.device") %></span>
<%= select_tag :device,
options_for_select(
[[t("admin.analytics.filters.any"), ""]] + AnalyticsEvent::DEVICES.map { |d| [d, d] },
@filters[:device]
) %>
</label>
<label class="admin-filter-field"> <label class="admin-filter-field">
<span><%= t("admin.analytics.filters.layer") %></span> <span><%= t("admin.analytics.filters.layer") %></span>
<%= select_tag :layer, <%= select_tag :layer,
@@ -91,7 +107,12 @@
<h3> <h3>
<%= @filters[:layer] == "click" ? t("admin.analytics.show.heatmap_clicks") : t("admin.analytics.show.heatmap_moves") %> <%= @filters[:layer] == "click" ? t("admin.analytics.show.heatmap_clicks") : t("admin.analytics.show.heatmap_moves") %>
</h3> </h3>
<p class="muted admin-table-sub"><%= t("admin.analytics.show.heatmap_hint") %></p> <p class="muted admin-table-sub">
<%= t(
"admin.analytics.show.heatmap_hint",
device: t("admin.analytics.devices.#{@filters[:device]}")
) %>
</p>
<% if @cells.any? %> <% if @cells.any? %>
<div class="admin-heatmap-stage" id="admin-heatmap-stage"> <div class="admin-heatmap-stage" id="admin-heatmap-stage">
@@ -115,13 +136,20 @@
></canvas> ></canvas>
</div> </div>
<p class="muted admin-table-sub" style="margin: 0.75rem 1rem 1rem;"> <p class="muted admin-table-sub" style="margin: 0.75rem 1rem 1rem;">
<%= t("admin.analytics.show.snapshot_meta", <%= t(
device: @snapshot.device, "admin.analytics.show.snapshot_meta",
at: l(@snapshot.captured_at, format: :short)) %> device: t("admin.analytics.devices.#{@snapshot.device}"),
at: l(@snapshot.captured_at, format: :short)
) %>
</p> </p>
<% else %> <% else %>
<div class="admin-heatmap-fallback" aria-hidden="true"></div> <div class="admin-heatmap-fallback" aria-hidden="true"></div>
<p class="muted admin-heatmap-fallback-note"><%= t("admin.analytics.show.snapshot_missing") %></p> <p class="muted admin-heatmap-fallback-note">
<%= t(
"admin.analytics.show.snapshot_missing",
device: t("admin.analytics.devices.#{@filters[:device]}")
) %>
</p>
<canvas <canvas
id="admin-heatmap" id="admin-heatmap"
class="admin-heatmap-overlay" class="admin-heatmap-overlay"
@@ -46,3 +46,14 @@
</tbody> </tbody>
</table> </table>
<% end %> <% end %>
<h3 style="font-size:1rem;margin-top:28px"><%= t("admin.clubs.show.concurrency_title") %></h3>
<p class="muted"><%= t("admin.clubs.show.concurrency_lead") %></p>
<%= render "admin/stream_concurrency_violations/table",
violations: @concurrency_violations,
empty_key: "admin.clubs.show.concurrency_none" %>
<% if @concurrency_violations.any? %>
<p class="kpi-sub" style="margin-top:0.75rem">
<%= link_to t("admin.clubs.show.concurrency_all"), admin_stream_concurrency_violations_path(q: @club.name) %>
</p>
<% end %>
@@ -0,0 +1,34 @@
<%= form_with model: @entry, url: url, method: method, local: true, class: "admin-filter-form" do |f| %>
<div class="admin-filter-grid">
<label class="admin-filter-field">
<span><%= t("admin.costs.entries.month") %></span>
<%= text_field_tag "platform_cost_entry[month]",
@entry.month&.strftime("%Y-%m"),
type: "month",
required: true %>
</label>
<label class="admin-filter-field">
<span><%= t("admin.costs.entries.label") %></span>
<%= f.text_field :label, required: true, maxlength: 120 %>
<span class="muted admin-table-sub"><%= t("admin.costs.entries.label_hint") %></span>
</label>
<label class="admin-filter-field">
<span><%= t("admin.costs.entries.amount") %></span>
<%= number_field_tag "platform_cost_entry[amount_euros]",
(@entry.amount_cents.to_f / 100.0 if @entry.amount_cents.present?),
step: 0.01,
min: 0.01,
required: true %>
</label>
<label class="admin-filter-field" style="grid-column: span 2;">
<span><%= t("admin.costs.entries.notes") %></span>
<%= f.text_area :notes, rows: 3 %>
</label>
</div>
<div class="admin-filter-actions">
<%= f.submit t("admin.costs.entries.save"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
<%= link_to t("admin.costs.entries.cancel"),
admin_costs_path(month: @entry.month&.strftime("%Y-%m")),
class: "admin-btn admin-btn--outline admin-btn--sm" %>
</div>
<% end %>
@@ -0,0 +1,10 @@
<% content_for :body_class, "admin-body" %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.costs.entries.edit_title") %></h2>
<p class="muted admin-page-sub"><%= @entry.label %> · <%= admin_month_label(@entry.month) %></p>
</div>
<section class="panel">
<%= render "form", url: admin_cost_entry_path(@entry), method: :patch %>
</section>
@@ -0,0 +1,9 @@
<% content_for :body_class, "admin-body" %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.costs.entries.new_title") %></h2>
</div>
<section class="panel">
<%= render "form", url: admin_cost_entries_path, method: :post %>
</section>
@@ -0,0 +1,197 @@
<% content_for :body_class, "admin-body" %>
<% content_for :head do %>
<script>
window.adminCostTrend = <%= raw @trend.to_json %>;
window.adminCostI18n = {
cost: <%= raw t("admin.costs.charts.cost_revenue").to_json %>,
revenue: <%= raw t("admin.costs.kpi.revenue").to_json %>,
margin: <%= raw t("admin.costs.kpi.margin").to_json %>,
costPerHour: <%= raw t("admin.costs.charts.cost_per_hour").to_json %>,
hours: <%= raw t("admin.costs.charts.hours").to_json %>
};
</script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" crossorigin="anonymous"></script>
<script src="/admin-costs.js?v=1" defer></script>
<% end %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.costs.index.title") %></h2>
<p class="muted admin-page-sub"><%= t("admin.costs.index.lead") %></p>
</div>
<div class="panel admin-sessions-filters">
<%= form_with url: admin_costs_path, method: :get, local: true, class: "admin-filter-form" do %>
<div class="admin-filter-grid">
<label class="admin-filter-field">
<span><%= t("admin.costs.index.month") %></span>
<%= select_tag :month,
options_for_select(
@month_options.map { |m| [admin_month_label(m), m.strftime("%Y-%m")] },
@month.strftime("%Y-%m")
) %>
</label>
</div>
<div class="admin-filter-actions">
<%= submit_tag t("admin.costs.index.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
<%= link_to t("admin.costs.index.add_entry"),
new_admin_cost_entry_path(month: @month.strftime("%Y-%m")),
class: "admin-btn admin-btn--outline admin-btn--sm" %>
</div>
<% end %>
</div>
<section class="kpi-grid">
<div class="kpi-card kpi-card--accent">
<div class="kpi-label"><%= t("admin.costs.kpi.platform_cost") %></div>
<div class="kpi-value"><%= format_euros(@summary[:cost_cents]) %></div>
<div class="kpi-sub"><%= admin_month_label(@month) %></div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.costs.kpi.revenue") %></div>
<div class="kpi-value"><%= format_euros(@summary[:revenue_cents]) %></div>
<div class="kpi-sub">
<%= t("admin.costs.kpi.margin") %>: <%= format_euros(@summary[:margin_cents]) %>
<% if @summary[:margin_pct] %>
· <%= @summary[:margin_pct] %>%
<% end %>
</div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.costs.kpi.cost_per_hour") %></div>
<div class="kpi-value"><%= format_euros(@summary[:cost_per_hour_cents]) %></div>
<div class="kpi-sub">
<%= t("admin.costs.kpi.revenue_per_hour") %>: <%= format_euros(@summary[:revenue_per_hour_cents]) %>
</div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.costs.kpi.hours") %></div>
<div class="kpi-value"><%= format_hours(@summary[:hours]) %></div>
<div class="kpi-sub">
<%= t("admin.costs.kpi.sessions") %>: <%= @summary[:sessions] %>
· <%= t("admin.costs.kpi.clubs_active") %>: <%= @summary[:clubs_active] %>
</div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.costs.kpi.cost_per_session") %></div>
<div class="kpi-value"><%= format_euros(@summary[:cost_per_session_cents]) %></div>
<div class="kpi-sub"><%= t("admin.costs.kpi.cost_per_club") %>: <%= format_euros(@summary[:cost_per_club_cents]) %></div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.costs.kpi.storage") %></div>
<div class="kpi-value" style="font-size:1.2rem"><%= format_bytes(@summary[:storage_bytes]) %></div>
<div class="kpi-sub">
<%= t("admin.costs.kpi.cost_per_gb") %>: <%= format_euros(@summary[:cost_per_gb_cents]) %>
· <%= t("admin.costs.kpi.storage_note") %>
</div>
</div>
</section>
<section class="panel" style="margin-bottom:1.25rem">
<h3><%= t("admin.costs.index.trends_title") %></h3>
<div class="charts-grid">
<div class="chart-card">
<h3><%= t("admin.costs.charts.cost_revenue") %></h3>
<div class="chart-wrap"><canvas id="chart-cost-revenue"></canvas></div>
</div>
<div class="chart-card">
<h3><%= t("admin.costs.charts.cost_per_hour") %></h3>
<div class="chart-wrap"><canvas id="chart-cost-hour"></canvas></div>
</div>
<div class="chart-card">
<h3><%= t("admin.costs.charts.hours") %></h3>
<div class="chart-wrap"><canvas id="chart-hours"></canvas></div>
</div>
</div>
</section>
<section class="panel" style="margin-bottom:1.25rem">
<h3><%= t("admin.costs.index.entries_title") %></h3>
<p class="muted admin-table-sub"><%= t("admin.costs.index.entries_lead") %></p>
<% if @entries.any? %>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th><%= t("admin.costs.table.label") %></th>
<th><%= t("admin.costs.table.amount") %></th>
<th><%= t("admin.costs.table.notes") %></th>
<th></th>
</tr>
</thead>
<tbody>
<% @entries.each do |entry| %>
<tr>
<td><strong><%= entry.label %></strong></td>
<td><%= format_euros(entry.amount_cents) %></td>
<td class="muted"><%= entry.notes.presence || t("admin.common.dash") %></td>
<td class="admin-actions">
<%= link_to t("admin.announcements.actions.edit"),
edit_admin_cost_entry_path(entry),
class: "admin-btn admin-btn--sm" %>
<%= button_to t("admin.costs.entries.delete"),
admin_cost_entry_path(entry),
method: :delete,
class: "admin-btn admin-btn--sm admin-btn--danger",
form: { data: { confirm: t("admin.costs.entries.delete_confirm") } } %>
</td>
</tr>
<% end %>
</tbody>
<tfoot>
<tr>
<th><%= t("admin.costs.kpi.platform_cost") %></th>
<th><%= format_euros(@summary[:cost_cents]) %></th>
<th colspan="2"></th>
</tr>
</tfoot>
</table>
</div>
<% else %>
<p class="empty"><%= t("admin.costs.index.no_entries") %></p>
<% end %>
</section>
<section class="panel">
<h3><%= t("admin.costs.index.clubs_title") %></h3>
<p class="muted admin-table-sub"><%= t("admin.costs.index.clubs_lead") %></p>
<% if @clubs.any? %>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th><%= t("admin.costs.table.club") %></th>
<th><%= t("admin.costs.table.plan") %></th>
<th><%= t("admin.costs.table.hours") %></th>
<th><%= t("admin.costs.table.hours_share") %></th>
<th><%= t("admin.costs.table.allocated_cost") %></th>
<th><%= t("admin.costs.table.revenue") %></th>
<th><%= t("admin.costs.table.margin") %></th>
<th><%= t("admin.costs.table.cost_per_hour") %></th>
<th><%= t("admin.costs.table.cost_per_session") %></th>
<th><%= t("admin.costs.table.storage") %></th>
</tr>
</thead>
<tbody>
<% @clubs.each do |row| %>
<tr>
<td>
<%= link_to row[:club_name], admin_club_path(row[:club_id]), class: "admin-table-strong" %>
</td>
<td class="muted"><%= row[:plan_slug] || t("admin.common.dash") %></td>
<td><%= format_hours(row[:hours]) %></td>
<td class="muted"><%= row[:hours_share_pct] %>%</td>
<td><%= format_euros(row[:allocated_cost_cents]) %></td>
<td><%= format_euros(row[:revenue_cents]) %></td>
<td><%= format_euros(row[:margin_cents]) %></td>
<td><%= format_euros(row[:cost_per_hour_cents]) %></td>
<td><%= format_euros(row[:cost_per_session_cents]) %></td>
<td class="muted"><%= format_bytes(row[:storage_bytes]) %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="empty"><%= t("admin.costs.index.no_clubs") %></p>
<% end %>
</section>
@@ -67,6 +67,24 @@
<% end %> <% end %>
</section> </section>
<section class="panel" style="margin-bottom:1.25rem">
<h2><%= t("admin.dashboard.concurrency_panel.title") %></h2>
<% if @concurrency_violation_lookback.positive? %>
<p class="kpi-sub" style="margin-bottom:0.75rem">
<%= t("admin.dashboard.concurrency_panel.count", count: @concurrency_violation_lookback) %>
<%= link_to t("admin.dashboard.concurrency_panel.view_all"), admin_stream_concurrency_violations_path %>
</p>
<%= render "admin/stream_concurrency_violations/table",
violations: @recent_concurrency_violations,
empty_key: "admin.dashboard.concurrency_panel.none" %>
<% else %>
<p class="empty" style="margin:0">
<%= t("admin.dashboard.concurrency_panel.none_html",
link: link_to(t("admin.dashboard.concurrency_panel.view_all"), admin_stream_concurrency_violations_path)) %>
</p>
<% end %>
</section>
<section class="charts-grid" style="grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));"> <section class="charts-grid" style="grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));">
<div class="chart-card"> <div class="chart-card">
<h3><%= t("admin.dashboard.disk.system_title") %></h3> <h3><%= t("admin.dashboard.disk.system_title") %></h3>
@@ -107,6 +125,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 +137,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>
@@ -1,8 +1,11 @@
<% node = session.stream_node %> <% slug = session.ingest_slug_display %>
<% if node %> <% role = session.ingest_role_display %>
<% if slug.present? %>
<div class="admin-ingest"> <div class="admin-ingest">
<code class="admin-ingest__slug"><%= node.slug %></code> <code class="admin-ingest__slug"><%= slug %></code>
<span class="badge <%= admin_session_ingest_badge_class(node) %>"><%= admin_session_ingest_role_label(node) %></span> <% if role.present? %>
<span class="badge <%= admin_session_ingest_badge_class(role) %>"><%= admin_session_ingest_role_label(role) %></span>
<% end %>
</div> </div>
<% else %> <% else %>
<span class="muted"><%= t("admin.sessions.ingest.none") %></span> <span class="muted"><%= t("admin.sessions.ingest.none") %></span>
@@ -26,7 +26,7 @@
<span><%= t("admin.sessions.index.filters.platform") %></span> <span><%= t("admin.sessions.index.filters.platform") %></span>
<%= select_tag :platform, <%= select_tag :platform,
options_for_select( options_for_select(
[[t("admin.sessions.index.filters.any"), ""]] + StreamSession::PLATFORMS.map { |p| [p, p] }, [[t("admin.sessions.index.filters.any"), ""]] + StreamSession::LIVE_PLATFORMS.map { |p| [p, p] },
@filters[:platform] @filters[:platform]
) %> ) %>
</label> </label>
@@ -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">
+56 -6
View File
@@ -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>
@@ -130,12 +162,20 @@
<div> <div>
<dt><%= t("admin.sessions.show.fields.node") %></dt> <dt><%= t("admin.sessions.show.fields.node") %></dt>
<dd> <dd>
<% if @session.stream_node %> <% slug = @session.ingest_slug_display %>
<code><%= @session.stream_node.slug %></code> <% role = @session.ingest_role_display %>
<span class="badge <%= admin_session_ingest_badge_class(@session.stream_node) %>"> <% if slug.present? %>
<%= admin_session_ingest_role_label(@session.stream_node) %> <code><%= slug %></code>
</span> <% if role.present? %>
<span class="muted">(<%= @session.stream_node.provider %>)</span> <span class="badge <%= admin_session_ingest_badge_class(role) %>">
<%= admin_session_ingest_role_label(role) %>
</span>
<% end %>
<% if @session.stream_node %>
<span class="muted">(<%= @session.stream_node.provider %>)</span>
<% else %>
<span class="muted"><%= t("admin.sessions.ingest.decommissioned") %></span>
<% end %>
<% else %> <% else %>
<span class="muted"><%= t("admin.sessions.ingest.none") %></span> <span class="muted"><%= t("admin.sessions.ingest.none") %></span>
<% end %> <% end %>
@@ -218,6 +258,16 @@
</section> </section>
<% end %> <% end %>
<% if @concurrency_violations.any? %>
<section class="panel">
<h3><%= t("admin.sessions.show.concurrency_title") %></h3>
<p class="muted"><%= t("admin.sessions.show.concurrency_lead") %></p>
<%= render "admin/stream_concurrency_violations/table",
violations: @concurrency_violations,
empty_key: "admin.sessions.show.concurrency_none" %>
</section>
<% end %>
<section class="panel"> <section class="panel">
<h3><%= t("admin.sessions.show.events_title") %></h3> <h3><%= t("admin.sessions.show.events_title") %></h3>
<% if @events.any? %> <% if @events.any? %>
@@ -0,0 +1,67 @@
<% if violations.any? %>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th><%= t("admin.stream_concurrency.table.when") %></th>
<th><%= t("admin.stream_concurrency.table.account") %></th>
<th><%= t("admin.stream_concurrency.table.club") %></th>
<th><%= t("admin.stream_concurrency.table.occupying") %></th>
<th><%= t("admin.stream_concurrency.table.attempted") %></th>
<th></th>
</tr>
</thead>
<tbody>
<% violations.each do |row| %>
<tr class="<%= 'admin-row--two-devices' if row.devices_differ? %>">
<td class="muted"><%= admin_datetime(row.created_at, with_seconds: true) %></td>
<td>
<strong><%= row.user_name.presence || t("admin.common.dash") %></strong>
<div class="muted" style="font-size:0.85rem"><%= row.user_email %></div>
</td>
<td>
<% if row.attempted_club %>
<%= link_to row.club_name, admin_club_path(row.attempted_club) %>
<% elsif row.occupying_club %>
<%= link_to row.club_name, admin_club_path(row.occupying_club) %>
<% else %>
<%= row.club_name.presence || t("admin.common.dash") %>
<% end %>
</td>
<td>
<% if row.occupying_session %>
<%= link_to row.occupying_match_label, admin_session_path(row.occupying_session) %>
<% else %>
<%= row.occupying_match_label %>
<% end %>
<div class="muted" style="font-size:0.85rem;margin-top:0.2rem">
<span class="badge <%= admin_session_status_badge_class(row.occupying_status) %>"><%= row.occupying_status %></span>
<%= row.occupying_device.presence || t("admin.common.dash") %>
</div>
</td>
<td>
<% if row.attempted_session %>
<%= link_to row.attempted_match_label, admin_session_path(row.attempted_session) %>
<% else %>
<%= row.attempted_match_label %>
<% end %>
<div class="muted" style="font-size:0.85rem;margin-top:0.2rem">
<%= t("admin.stream_concurrency.action.#{row.attempt_action}") %>
· <%= row.attempted_device.presence || t("admin.common.dash") %>
</div>
</td>
<td>
<% if row.devices_differ? %>
<span class="badge badge--abuse"><%= t("admin.stream_concurrency.badge.two_devices") %></span>
<% else %>
<span class="muted"><%= t("admin.stream_concurrency.badge.same_or_unknown") %></span>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="empty"><%= t(empty_key) %></p>
<% end %>
@@ -0,0 +1,48 @@
<% content_for :body_class, "admin-body" %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.stream_concurrency.index.title") %></h2>
<p class="muted admin-page-sub"><%= t("admin.stream_concurrency.index.lead") %></p>
</div>
<section class="kpi-grid">
<div class="kpi-card <%= @lookback_count.positive? ? 'kpi-card--accent' : '' %>">
<div class="kpi-label"><%= t("admin.stream_concurrency.kpi.lookback") %></div>
<div class="kpi-value"><%= @lookback_count %></div>
<div class="kpi-sub"><%= t("admin.stream_concurrency.kpi.lookback_sub") %></div>
</div>
<div class="kpi-card <%= @two_devices_count.positive? ? 'kpi-card--danger' : '' %>">
<div class="kpi-label"><%= t("admin.stream_concurrency.kpi.two_devices") %></div>
<div class="kpi-value"><%= @two_devices_count %></div>
<div class="kpi-sub"><%= t("admin.stream_concurrency.kpi.two_devices_sub") %></div>
</div>
</section>
<div class="panel admin-sessions-filters">
<%= form_with url: admin_stream_concurrency_violations_path, method: :get, local: true, class: "admin-filter-form" do %>
<div class="admin-filter-grid">
<label class="admin-filter-field">
<span><%= t("admin.stream_concurrency.filters.q") %></span>
<%= text_field_tag :q, @filters[:q], placeholder: t("admin.stream_concurrency.filters.q_placeholder") %>
</label>
<label class="admin-filter-field admin-filter-field--check">
<span><%= t("admin.stream_concurrency.filters.two_devices") %></span>
<label class="admin-checkbox">
<%= check_box_tag :devices_differ, "1", @filters[:devices_differ] %>
<%= t("admin.stream_concurrency.filters.two_devices_hint") %>
</label>
</label>
</div>
<div class="admin-filter-actions">
<%= submit_tag t("admin.stream_concurrency.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
<%= link_to t("admin.stream_concurrency.filters.reset"), admin_stream_concurrency_violations_path, class: "admin-btn admin-btn--outline admin-btn--sm" %>
<span class="muted admin-filter-count">
<%= t("admin.stream_concurrency.index.results", shown: @violations.size, total: @total_count) %>
</span>
</div>
<% end %>
</div>
<div class="panel">
<%= render "admin/stream_concurrency_violations/table", violations: @violations, empty_key: "admin.stream_concurrency.index.none" %>
</div>
+7 -1
View File
@@ -5,7 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<link rel="stylesheet" href="/admin.css?v=15"> <link rel="stylesheet" href="/admin.css?v=16">
<%= yield :head %>
<% if content_for?(:replay_archive_styles) %> <% if content_for?(:replay_archive_styles) %>
<link rel="stylesheet" href="/marketing.css?v=42"> <link rel="stylesheet" href="/marketing.css?v=42">
<% end %> <% end %>
@@ -31,7 +32,12 @@
<%= link_to t("admin.layout.nav.billing"), admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %> <%= link_to t("admin.layout.nav.billing"), admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %>
<%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %> <%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
<%= link_to t("admin.layout.nav.sessions"), admin_sessions_path, class: ("active" if controller_name == "sessions") %> <%= link_to t("admin.layout.nav.sessions"), admin_sessions_path, class: ("active" if controller_name == "sessions") %>
<% abuse_count = admin_concurrency_violation_lookback_count %>
<%= link_to admin_stream_concurrency_violations_path, class: ("active" if controller_name == "stream_concurrency_violations") do %>
<%= t("admin.layout.nav.stream_concurrency") %><% if abuse_count.positive? %> <span class="admin-nav-badge"><%= abuse_count %></span><% end %>
<% end %>
<%= link_to t("admin.layout.nav.analytics"), admin_analytics_path, class: ("active" if controller_name == "analytics") %> <%= link_to t("admin.layout.nav.analytics"), admin_analytics_path, class: ("active" if controller_name == "analytics") %>
<%= link_to t("admin.layout.nav.costs"), admin_costs_path, class: ("active" if controller_name.in?(%w[costs cost_entries])) %>
<%= link_to t("admin.layout.nav.stream_nodes"), admin_stream_nodes_path, class: ("active" if controller_name == "stream_nodes") %> <%= link_to t("admin.layout.nav.stream_nodes"), admin_stream_nodes_path, class: ("active" if controller_name == "stream_nodes") %>
<%= link_to t("admin.layout.nav.password"), edit_admin_password_path %> <%= link_to t("admin.layout.nav.password"), edit_admin_password_path %>
<%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %> <%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %>
+4 -3
View File
@@ -7,9 +7,10 @@
<meta name="application-name" content="Match Live TV"> <meta name="application-name" content="Match Live TV">
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<%= render "shared/analytics_suppress" %>
<%= 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=81">
</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" %>
@@ -26,7 +27,7 @@
<script src="/password-toggle.js?v=2" defer></script> <script src="/password-toggle.js?v=2" defer></script>
<link rel="stylesheet" href="/confirm-forms.css?v=4"> <link rel="stylesheet" href="/confirm-forms.css?v=4">
<script src="/confirm-forms.js?v=7" defer></script> <script src="/confirm-forms.js?v=7" defer></script>
<script src="/site-analytics.js?v=4" defer></script> <script src="/site-analytics.js?v=6" defer></script>
<script src="/cookie-consent.js?v=2" defer></script> <script src="/cookie-consent.js?v=3" defer></script>
</body> </body>
</html> </html>
@@ -6,8 +6,9 @@
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title> <title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<%= render "shared/analytics_suppress" %>
<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=81">
<link rel="stylesheet" href="/live.css?v=26"> <link rel="stylesheet" href="/live.css?v=26">
<%= yield :head %> <%= yield :head %>
</head> </head>
@@ -21,7 +22,7 @@
<%= render "shared/marketing_footer" %> <%= render "shared/marketing_footer" %>
<link rel="stylesheet" href="/confirm-forms.css?v=4"> <link rel="stylesheet" href="/confirm-forms.css?v=4">
<script src="/confirm-forms.js?v=7" defer></script> <script src="/confirm-forms.js?v=7" defer></script>
<script src="/site-analytics.js?v=4" defer></script> <script src="/site-analytics.js?v=6" defer></script>
<script src="/cookie-consent.js?v=2" defer></script> <script src="/cookie-consent.js?v=3" defer></script>
</body> </body>
</html> </html>
@@ -21,6 +21,9 @@
<%= hidden_field_tag :plan, plan_return if plan_return %> <%= hidden_field_tag :plan, plan_return if plan_return %>
<%= hidden_field_tag :interval, params[:interval] if params[:interval].present? %> <%= hidden_field_tag :interval, params[:interval] if params[:interval].present? %>
<%= render "shared/billing_profile_fields", record: @club %> <%= render "shared/billing_profile_fields", record: @club %>
<% if plan_return %>
<%= render "shared/refund_guarantee", variant: "checkout" %>
<% end %>
<%= submit_tag t("billing.profile.submit"), class: "btn btn-primary" %> <%= submit_tag t("billing.profile.submit"), class: "btn btn-primary" %>
<% end %> <% end %>
</div> </div>
@@ -21,6 +21,9 @@
<%= render "shared/quoted_price_banner", quote: @quote, subscription: @subscription %> <%= render "shared/quoted_price_banner", quote: @quote, subscription: @subscription %>
<% elsif MatchLiveTv.stripe_enabled? %> <% elsif MatchLiveTv.stripe_enabled? %>
<%= render "shared/stripe_secure_payment" %> <%= render "shared/stripe_secure_payment" %>
<% if @subscription.blank? || @subscription.plan&.slug == "free" || !@subscription.active? %>
<%= render "shared/refund_guarantee", variant: "checkout" %>
<% end %>
<% end %> <% end %>
<%= render "shared/pending_bank_transfer", order: @pending_transfer %> <%= render "shared/pending_bank_transfer", order: @pending_transfer %>
<% if MatchLiveTv.stripe_enabled? && @quote.blank? %> <% if MatchLiveTv.stripe_enabled? && @quote.blank? %>
@@ -26,6 +26,9 @@
[t("club.new.plan_full"), "premium_full"] [t("club.new.plan_full"), "premium_full"]
], params[:plan] || "free") %> ], params[:plan] || "free") %>
<p class="muted" style="margin:8px 0 16px"><%= t("club.new.plan_hint") %></p> <p class="muted" style="margin:8px 0 16px"><%= t("club.new.plan_hint") %></p>
<% if params[:plan].presence_in(%w[premium_light premium_full]) %>
<%= render "shared/refund_guarantee", variant: "checkout" %>
<% end %>
<%= submit_tag t("club.new.submit"), class: "btn btn-primary" %> <%= submit_tag t("club.new.submit"), class: "btn btn-primary" %>
<% end %> <% end %>
</div> </div>
@@ -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>
+2 -2
View File
@@ -164,8 +164,8 @@
<aside class="demo-live-card" aria-label="<%= t("live.index.demo_aria_label") %>"> <aside class="demo-live-card" aria-label="<%= t("live.index.demo_aria_label") %>">
<p class="demo-label"><%= t("live.index.demo_label") %></p> <p class="demo-label"><%= t("live.index.demo_label") %></p>
<article class="live-card live-card--demo"> <article class="live-card live-card--demo">
<h3>Tigers Volley vs ASD Eagles</h3> <h3><%= MatchLiveTv::Demo.match_title %></h3>
<p class="meta"><%= t("live.index.demo_meta") %></p> <p class="meta"><%= MatchLiveTv::Demo.live_meta %></p>
<p class="card-score"> <p class="card-score">
<span class="card-sets"><%= t("live.index.demo_sets") %></span> <span class="card-sets"><%= t("live.index.demo_sets") %></span>
<span class="card-points">18 - 16</span> <span class="card-points">18 - 16</span>
@@ -70,7 +70,31 @@
<%= t("pages.faq.q8_answer") %> <%= t("pages.faq.q8_answer") %>
</p> </p>
</details> </details>
<details class="faq-item" id="faq-garanzia" data-mltv-event="guarantee_faq_open">
<summary><%= t("pages.faq.q9_question") %></summary>
<p>
<%= raw t(
"pages.faq.q9_answer_html",
terms_link: link_to(t("pages.faq.q9_terms_link"), public_termini_path(anchor: "garanzia-rimborso"))
) %>
</p>
</details>
<details class="faq-item">
<summary><%= t("pages.faq.q10_question") %></summary>
<p>
<%= t("pages.faq.q10_answer") %>
</p>
</details>
</div> </div>
<script>
(function () {
if (location.hash !== "#faq-garanzia") return;
var item = document.getElementById("faq-garanzia");
if (item) item.open = true;
})();
</script>
<p style="text-align:center;margin:40px 0"> <p style="text-align:center;margin:40px 0">
<%= link_to t("pages.faq.cta_signup"), public_signup_path, class: "btn btn-primary" %> <%= link_to t("pages.faq.cta_signup"), public_signup_path, class: "btn btn-primary" %>
@@ -187,7 +187,7 @@
<div class="features-yt-mock__meta"> <div class="features-yt-mock__meta">
<span class="features-yt-mock__avatar"><i class="fa-solid fa-shield-halved"></i></span> <span class="features-yt-mock__avatar"><i class="fa-solid fa-shield-halved"></i></span>
<div class="features-yt-mock__text"> <div class="features-yt-mock__text">
<strong><%= t("pages.features.youtube_mock_channel") %></strong> <strong><%= MatchLiveTv::Demo.home_team %></strong>
<span><%= t("pages.features.youtube_mock_subs") %></span> <span><%= t("pages.features.youtube_mock_subs") %></span>
</div> </div>
</div> </div>
@@ -196,6 +196,8 @@
</div> </div>
</section> </section>
<%= render "shared/sponsor_cover_promo" %>
<section class="section wrap features-replay" aria-labelledby="features-replay-title"> <section class="section wrap features-replay" aria-labelledby="features-replay-title">
<div class="features-split features-split--reverse"> <div class="features-split features-split--reverse">
<div class="features-split__copy"> <div class="features-split__copy">
@@ -218,7 +220,7 @@
<li> <li>
<span class="features-archive-mock__thumb features-archive-mock__thumb--a"></span> <span class="features-archive-mock__thumb features-archive-mock__thumb--a"></span>
<span class="features-archive-mock__info"> <span class="features-archive-mock__info">
<strong><%= t("pages.features.replay_mock_1_title") %></strong> <strong><%= MatchLiveTv::Demo.match_title %></strong>
<em><%= t("pages.features.replay_mock_1_meta") %></em> <em><%= t("pages.features.replay_mock_1_meta") %></em>
</span> </span>
<i class="fa-solid fa-download" aria-hidden="true"></i> <i class="fa-solid fa-download" aria-hidden="true"></i>
@@ -226,7 +228,7 @@
<li> <li>
<span class="features-archive-mock__thumb features-archive-mock__thumb--b"></span> <span class="features-archive-mock__thumb features-archive-mock__thumb--b"></span>
<span class="features-archive-mock__info"> <span class="features-archive-mock__info">
<strong><%= t("pages.features.replay_mock_2_title") %></strong> <strong><%= MatchLiveTv::Demo.match_title %></strong>
<em><%= t("pages.features.replay_mock_2_meta") %></em> <em><%= t("pages.features.replay_mock_2_meta") %></em>
</span> </span>
<i class="fa-solid fa-download" aria-hidden="true"></i> <i class="fa-solid fa-download" aria-hidden="true"></i>
@@ -234,7 +236,7 @@
<li> <li>
<span class="features-archive-mock__thumb features-archive-mock__thumb--c"></span> <span class="features-archive-mock__thumb features-archive-mock__thumb--c"></span>
<span class="features-archive-mock__info"> <span class="features-archive-mock__info">
<strong><%= t("pages.features.replay_mock_3_title") %></strong> <strong><%= MatchLiveTv::Demo.match_title %></strong>
<em><%= t("pages.features.replay_mock_3_meta") %></em> <em><%= t("pages.features.replay_mock_3_meta") %></em>
</span> </span>
<i class="fa-solid fa-download" aria-hidden="true"></i> <i class="fa-solid fa-download" aria-hidden="true"></i>
@@ -94,6 +94,8 @@
</div> </div>
</section> </section>
<%= render "shared/sponsor_cover_promo", variant: :compact %>
<section class="section wrap plans-teaser"> <section class="section wrap plans-teaser">
<h2><%= t("home.plans_title") %></h2> <h2><%= t("home.plans_title") %></h2>
<p class="plans-teaser-lead"><%= t("home.plans_lead") %></p> <p class="plans-teaser-lead"><%= t("home.plans_lead") %></p>
@@ -101,6 +103,7 @@
<%= image_tag "/home-piani-ecosistema.png?v=1", alt: t("home.plans_alt"), class: "plans-teaser-img", loading: "lazy" %> <%= image_tag "/home-piani-ecosistema.png?v=1", alt: t("home.plans_alt"), class: "plans-teaser-img", loading: "lazy" %>
</div> </div>
<%= link_to t("home.plans_cta"), public_prezzi_path, class: "btn btn-primary" %> <%= link_to t("home.plans_cta"), public_prezzi_path, class: "btn btn-primary" %>
<%= render "shared/refund_guarantee", variant: "cta" %>
</section> </section>
<section class="section wrap seo-prose"> <section class="section wrap seo-prose">
@@ -22,6 +22,8 @@
<%= render "shared/plan_cards" %> <%= render "shared/plan_cards" %>
<%= render "shared/sponsor_cover_promo", variant: :compact, show_cta: false, nested: true %>
<div class="table-scroll compare-table-wrap"> <div class="table-scroll compare-table-wrap">
<table class="compare-table"> <table class="compare-table">
<colgroup> <colgroup>
@@ -50,6 +52,12 @@
<tr><td><%= t("pages.pricing.table_youtube") %></td><td><%= t("pages.pricing.table_no") %></td><td>Match Live TV</td><td><%= t("pages.pricing.table_youtube_club") %></td></tr> <tr><td><%= t("pages.pricing.table_youtube") %></td><td><%= t("pages.pricing.table_no") %></td><td>Match Live TV</td><td><%= t("pages.pricing.table_youtube_club") %></td></tr>
<tr><td><%= t("pages.pricing.table_replay") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.plans.replay_days", count: 30) %></td><td><%= t("pages.plans.replay_days", count: 90) %></td></tr> <tr><td><%= t("pages.pricing.table_replay") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.plans.replay_days", count: 30) %></td><td><%= t("pages.plans.replay_days", count: 90) %></td></tr>
<tr><td><%= t("pages.pricing.table_download") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td></tr> <tr><td><%= t("pages.pricing.table_download") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td></tr>
<tr>
<td><%= t("pages.pricing.table_cover_sponsor") %></td>
<td><%= t("pages.pricing.table_no") %></td>
<td><%= t("pages.pricing.table_no") %></td>
<td><%= t("pages.pricing.table_yes") %></td>
</tr>
<tr> <tr>
<td><%= t("pages.pricing.table_price") %></td> <td><%= t("pages.pricing.table_price") %></td>
<td><%= t("pages.pricing.table_price_free") %></td> <td><%= t("pages.pricing.table_price_free") %></td>
+16 -1
View File
@@ -4,7 +4,7 @@
<div class="wrap legal-doc"> <div class="wrap legal-doc">
<h1><%= t("legal.terms.h1") %></h1> <h1><%= t("legal.terms.h1") %></h1>
<p class="legal-meta"><%= t("legal.terms.meta", date: legal_last_updated) %></p> <p class="legal-meta"><%= t("legal.terms.meta", date: terms_last_updated) %></p>
<section> <section>
<h2><%= t("legal.terms.s1_title") %></h2> <h2><%= t("legal.terms.s1_title") %></h2>
@@ -43,6 +43,21 @@
<p><%= t("legal.terms.s3_p3") %></p> <p><%= t("legal.terms.s3_p3") %></p>
</section> </section>
<section id="garanzia-rimborso">
<h2><%= t("legal.terms.s3b_title") %></h2>
<p><%= t("legal.terms.s3b_p1") %></p>
<p><%= t("legal.terms.s3b_p2") %></p>
<p>
<%= raw t(
"legal.terms.s3b_p3_html",
email_link: link_to(MatchLiveTv.support_email, "mailto:#{MatchLiveTv.support_email}")
) %>
</p>
<p><%= t("legal.terms.s3b_p4") %></p>
<p><%= t("legal.terms.s3b_p5") %></p>
<p><%= t("legal.terms.s3b_p6") %></p>
</section>
<section> <section>
<h2><%= t("legal.terms.s4_title") %></h2> <h2><%= t("legal.terms.s4_title") %></h2>
<p><strong><%= t("legal.terms.s4_lead1") %></strong></p> <p><strong><%= t("legal.terms.s4_lead1") %></strong></p>
+11 -3
View File
@@ -43,9 +43,9 @@
</p> </p>
<% ent = @recording.team.entitlements %> <% ent = @recording.team.entitlements %>
<% if (ent.phone_download_enabled? && (logged_in? || @recording.unlisted?)) || @recording.youtube_watch_url %> <% if (ent.phone_download_enabled? && @recording.storage_key.present? && (logged_in? || @recording.unlisted?)) || @recording.youtube_watch_url %>
<div class="replay-show__actions"> <div class="replay-show__actions">
<% if ent.phone_download_enabled? && (logged_in? || @recording.unlisted?) %> <% if ent.phone_download_enabled? && @recording.storage_key.present? && (logged_in? || @recording.unlisted?) %>
<%= link_to t("replay.show.download_link"), public_replay_download_path(@session), class: "btn btn-primary" %> <%= link_to t("replay.show.download_link"), public_replay_download_path(@session), class: "btn btn-primary" %>
<% end %> <% end %>
<% if @recording.youtube_watch_url %> <% if @recording.youtube_watch_url %>
@@ -53,7 +53,7 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<% elsif @recording.ready? && @recording.youtube_watch_url.present? %> <% elsif @recording.ready? && @recording.replay_source == "youtube" && @recording.youtube_video_id.present? && !@recording.youtube_video_id.to_s.start_with?("mock_") %>
<div class="live-player-wrap live-player-wrap--embed"> <div class="live-player-wrap live-player-wrap--embed">
<iframe src="https://www.youtube.com/embed/<%= @recording.youtube_video_id %>" <iframe src="https://www.youtube.com/embed/<%= @recording.youtube_video_id %>"
title="<%= @recording.title_or_default %>" title="<%= @recording.title_or_default %>"
@@ -68,6 +68,14 @@
badge_label: t("replay.show.badge_label"), badge_label: t("replay.show.badge_label"),
badge_class: "badge-ended" %> badge_class: "badge-ended" %>
</div> </div>
<p class="replay-show__meta-line">
<%= l_local(@recording.recorded_at_or_fallback) %>
· <%= t("replay.show.meta_line_duration", value: @recording.duration_label) %>
· <%= @recording.views_label %>
<% if @recording.source_platform_label != "—" %>
· <%= @recording.source_platform_label %>
<% end %>
</p>
<p class="replay-show__meta-line"> <p class="replay-show__meta-line">
<%= t("replay.show.youtube_only_body") %> <%= t("replay.show.youtube_only_body") %>
<%= link_to t("replay.show.youtube_link"), @recording.youtube_watch_url, target: "_blank", rel: "noopener" %> <%= link_to t("replay.show.youtube_link"), @recording.youtube_watch_url, target: "_blank", rel: "noopener" %>
@@ -158,16 +158,16 @@
<% end %> <% end %>
<span class="visually-hidden"><%= privacy_label %></span> <span class="visually-hidden"><%= privacy_label %></span>
<% end %> <% end %>
<% if ent.phone_download_enabled? && rec.ready? %> <% if ent.phone_download_enabled? && rec.ready? && rec.storage_key.present? %>
<%= link_to "MP4", public_replay_download_path(rec.stream_session_id), class: "replay-archive__action replay-archive__action--secondary", title: t("recordings.archive.download_mp4_title") %> <%= link_to "MP4", public_replay_download_path(rec.stream_session_id), class: "replay-archive__action replay-archive__action--secondary", title: t("recordings.archive.download_mp4_title") %>
<% end %> <% end %>
<% if ent.premium_full? && ent.youtube_enabled? && rec.ready? && rec.youtube_video_id.blank? %> <% if ent.premium_full? && ent.youtube_enabled? && rec.ready? && rec.storage_key.present? && rec.youtube_video_id.blank? %>
<%= button_to "YT", paths.publish_youtube.call(rec), method: :post, class: "replay-archive__action replay-archive__action--secondary", title: t("recordings.archive.publish_youtube_title"), form: { class: "replay-archive__action-form" } %> <%= button_to "YT", paths.publish_youtube.call(rec), method: :post, class: "replay-archive__action replay-archive__action--secondary", title: t("recordings.archive.publish_youtube_title"), form: { class: "replay-archive__action-form" } %>
<% elsif rec.youtube_watch_url %> <% elsif rec.youtube_watch_url %>
<%= link_to "YT", rec.youtube_watch_url, class: "replay-archive__action replay-archive__action--secondary", target: "_blank", rel: "noopener", title: t("recordings.archive.open_youtube_title") %> <%= link_to "YT", rec.youtube_watch_url, class: "replay-archive__action replay-archive__action--secondary", target: "_blank", rel: "noopener", title: t("recordings.archive.open_youtube_title") %>
<% end %> <% end %>
<% has_youtube = rec.youtube_video_id.present? && !rec.youtube_video_id.to_s.start_with?("mock_") %> <% has_youtube = rec.youtube_video_id.present? && !rec.youtube_video_id.to_s.start_with?("mock_") %>
<% has_site = rec.storage_key.present? || %w[ready processing failed].include?(rec.status) %> <% has_site = rec.available_in_archive? || %w[processing failed].include?(rec.status) %>
<% delete_confirm = t("recordings.archive.delete_confirm") %> <% delete_confirm = t("recordings.archive.delete_confirm") %>
<%= button_to paths.destroy.call(rec), method: :delete, <%= button_to paths.destroy.call(rec), method: :delete,
params: filter_params, params: filter_params,
@@ -0,0 +1,7 @@
<% if Analytics::Suppress.active?(request.cookie_jar[Analytics::Suppress::COOKIE_NAME]) %>
<meta name="mltv-analytics-suppress" content="1">
<div class="mltv-preview-badge" role="status">
<span><%= t("ui.analytics_preview.badge") %></span>
<%= link_to t("ui.analytics_preview.manage"), admin_analytics_path, class: "mltv-preview-badge__link" %>
</div>
<% end %>
@@ -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>
+15 -1
View File
@@ -1,4 +1,4 @@
<%# locals: (club: nil, entitlements: nil, subscription: nil, current_plan_slug: nil, show_stripe_portal: false) %> <%# locals: (club: nil, entitlements: nil, subscription: nil, current_plan_slug: nil, show_stripe_portal: false, show_guarantee_banner: nil) %>
<% club ||= @club %> <% club ||= @club %>
<% entitlements ||= @entitlements %> <% entitlements ||= @entitlements %>
<% subscription ||= @subscription %> <% subscription ||= @subscription %>
@@ -65,10 +65,16 @@
t("pages.plans.youtube_none") t("pages.plans.youtube_none")
end end
) %></li> ) %></li>
<% if plan.slug == "premium_full" %>
<li><%= raw t("pages.plans.cover_sponsor_html") %></li>
<% end %>
</ul> </ul>
<% if plan.slug == "premium_full" %> <% if plan.slug == "premium_full" %>
<p class="plan-staff-note"><%= t("pages.plans.staff_note_full") %></p> <p class="plan-staff-note"><%= t("pages.plans.staff_note_full") %></p>
<% end %> <% end %>
<% first_purchase = !billing_mode || action&.fetch(:kind, nil).in?(%i[checkout_options bank_only quoted]) %>
<% show_cta_guarantee = plan.slug.in?(%w[premium_light premium_full]) && first_purchase %>
<div class="plan-card__cta">
<% if billing_mode %> <% if billing_mode %>
<% action_kind = action[:kind] %> <% action_kind = action[:kind] %>
<% quote = club&.active_billing_quote %> <% quote = club&.active_billing_quote %>
@@ -131,6 +137,14 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<% if show_cta_guarantee %>
<%= render "shared/refund_guarantee", variant: "compact" %>
<% end %>
</div>
</div> </div>
<% end %> <% end %>
</div> </div>
<% if local_assigns.fetch(:show_guarantee_banner) { !billing_mode || current_slug.to_s == "free" } %>
<%= render "shared/refund_guarantee", variant: "pricing" %>
<% end %>
@@ -0,0 +1,35 @@
<%# locals: (variant: "pricing") %>
<% variant = local_assigns.fetch(:variant, "pricing").to_s %>
<% if variant == "pricing" %>
<aside class="refund-guarantee refund-guarantee--pricing" aria-label="<%= t("guarantee.aria_pricing") %>">
<span class="refund-guarantee__icon" aria-hidden="true">
<i class="fa-solid fa-shield-halved"></i>
</span>
<div class="refund-guarantee__copy">
<p class="refund-guarantee__title">
<%= t("guarantee.title") %><span class="refund-guarantee__kicker"> · <%= t("guarantee.kicker_days") %></span>
</p>
<p class="refund-guarantee__subtitle"><%= t("guarantee.subtitle") %></p>
<p class="refund-guarantee__body"><%= t("guarantee.body") %></p>
<p class="refund-guarantee__more">
<%= link_to t("guarantee.learn_more"), public_faq_path(anchor: "faq-garanzia"), data: { mltv_event_click: "guarantee_learn_more" } %>
</p>
</div>
</aside>
<% elsif variant == "compact" %>
<p class="refund-guarantee refund-guarantee--compact">
<i class="fa-solid fa-shield-halved" aria-hidden="true"></i>
<span><%= t("guarantee.compact") %></span>
</p>
<% elsif variant == "cta" %>
<p class="refund-guarantee refund-guarantee--cta">
<i class="fa-solid fa-shield-halved" aria-hidden="true"></i>
<span><%= t("guarantee.home_note") %></span>
</p>
<% elsif variant == "checkout" %>
<div class="refund-guarantee refund-guarantee--checkout">
<i class="fa-solid fa-shield-halved" aria-hidden="true"></i>
<p><%= t("guarantee.checkout") %></p>
</div>
<% end %>
@@ -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>
@@ -0,0 +1,26 @@
<figure class="cover-mock" aria-hidden="true" inert>
<div class="cover-mock__chrome">
<span class="cover-mock__brand">MATCH <em>LIVE</em> TV</span>
<span class="cover-mock__state"><%= t("pages.sponsor_cover.mock.state") %></span>
</div>
<div class="cover-mock__stage">
<p class="cover-mock__cat"><%= MatchLiveTv::Demo.category %></p>
<p class="cover-mock__home"><%= MatchLiveTv::Demo.home_team %></p>
<p class="cover-mock__vs">vs</p>
<p class="cover-mock__away"><%= MatchLiveTv::Demo.away_team %></p>
<p class="cover-mock__when"><%= MatchLiveTv::Demo.when_label %></p>
<p class="cover-mock__art-label"><%= t("pages.sponsor_cover.mock.art_label") %></p>
<div class="cover-mock__art">
<div class="cover-mock__crest">
<span class="cover-mock__crest-mark">M</span>
<span class="cover-mock__crest-name"><%= MatchLiveTv::Demo.home_team %></span>
</div>
<div class="cover-mock__marks">
<span class="cover-mock__mark cover-mock__mark--a"></span>
<span class="cover-mock__mark cover-mock__mark--b"></span>
<span class="cover-mock__mark cover-mock__mark--c"></span>
</div>
</div>
<p class="cover-mock__soon"><%= t("pages.sponsor_cover.mock.soon") %></p>
</div>
</figure>
@@ -0,0 +1,30 @@
<%# locals: (variant: :full, show_cta: nil, nested: false) %>
<% variant = (local_assigns[:variant] || :full).to_sym %>
<% compact = variant == :compact %>
<% nested = local_assigns[:nested] %>
<% show_cta = local_assigns.fetch(:show_cta, true) %>
<% title_id = compact ? "sponsor-cover-title-compact" : "sponsor-cover-title" %>
<section class="section<%= " wrap" unless nested %> sponsor-cover<%= " sponsor-cover--compact" if compact %>" aria-labelledby="<%= title_id %>">
<div class="sponsor-cover__panel">
<div class="sponsor-cover__copy">
<p class="feature-card__badge feature-card__badge--gold"><%= t("pages.sponsor_cover.eyebrow") %></p>
<h2 id="<%= title_id %>"><%= t("pages.sponsor_cover.title") %></h2>
<p class="sponsor-cover__lead"><%= t("pages.sponsor_cover.body") %></p>
<% unless compact %>
<ul class="features-checklist">
<li><%= t("pages.sponsor_cover.item_cover") %></li>
<li><%= t("pages.sponsor_cover.item_before") %></li>
<li><%= t("pages.sponsor_cover.item_club") %></li>
</ul>
<p class="sponsor-cover__claim"><%= t("pages.sponsor_cover.claim") %></p>
<% end %>
<% if show_cta %>
<%= link_to t("pages.sponsor_cover.cta"), public_prezzi_path, class: "btn btn-outline" %>
<% end %>
</div>
<div class="sponsor-cover__visual">
<%= render "shared/sponsor_cover_mock" %>
</div>
</div>
</section>
@@ -1,3 +1,6 @@
require "net/smtp"
require "openssl"
module MatchLiveTv module MatchLiveTv
class << self class << self
def jwt_secret def jwt_secret
@@ -102,6 +105,7 @@ module MatchLiveTv
end end
# In produzione senza SMTP non blocca il flusso (es. collaudo): la mail si può inviare a mano. # In produzione senza SMTP non blocca il flusso (es. collaudo): la mail si può inviare a mano.
# Errori SMTP (dominio invalido, EOF Aruba, porta chiusa) non devono far crashare la request.
def deliver_mail(mail) def deliver_mail(mail)
if Rails.env.production? && !smtp_configured? if Rails.env.production? && !smtp_configured?
Rails.logger.info("[mail] skip (SMTP assente): #{mail.subject}") Rails.logger.info("[mail] skip (SMTP assente): #{mail.subject}")
@@ -110,6 +114,9 @@ module MatchLiveTv
mail.deliver_now mail.deliver_now
true true
rescue EOFError, Net::SMTPError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError, OpenSSL::SSL::SSLError => e
Rails.logger.warn("[mail] delivery failed: #{mail.subject} #{e.class}: #{e.message}")
false
end end
def privacy_controller_name def privacy_controller_name
@@ -196,6 +203,24 @@ module MatchLiveTv
ENV.fetch("REPLAY_MEDIA_REDIRECT", "true") == "true" ENV.fetch("REPLAY_MEDIA_REDIRECT", "true") == "true"
end end
# Retention copia temporanea post-live YouTube (ore). Clamp 2472, default 48.
def youtube_temp_replay_retention_hours
raw = ENV.fetch("YOUTUBE_TEMP_REPLAY_RETENTION_HOURS", "48").to_i
raw.clamp(24, 72)
end
def youtube_replay_verify_max_attempts
ENV.fetch("YOUTUBE_REPLAY_VERIFY_MAX_ATTEMPTS", "12").to_i
end
def youtube_replay_verify_base_interval_secs
ENV.fetch("YOUTUBE_REPLAY_VERIFY_BASE_INTERVAL_SECS", "300").to_i
end
def youtube_replay_verify_grace_secs
ENV.fetch("YOUTUBE_REPLAY_VERIFY_GRACE_SECS", "120").to_i
end
def ops_http_rails_url def ops_http_rails_url
ENV.fetch("OPS_HTTP_RAILS_URL", "http://edge/up") ENV.fetch("OPS_HTTP_RAILS_URL", "http://edge/up")
end end
+71 -2
View File
@@ -10,7 +10,9 @@ de:
billing: Abrechnung billing: Abrechnung
youtube: YouTube youtube: YouTube
sessions: Sitzungen sessions: Sitzungen
stream_concurrency: Konto-Missbrauch
analytics: Analytics analytics: Analytics
costs: Kosten
stream_nodes: Stream-Knoten stream_nodes: Stream-Knoten
password: Passwort password: Passwort
logout: Abmelden logout: Abmelden
@@ -50,6 +52,9 @@ de:
announcement_created: Hinweis gespeichert announcement_created: Hinweis gespeichert
announcement_updated: Hinweis aktualisiert announcement_updated: Hinweis aktualisiert
announcement_destroyed: Hinweis gelöscht announcement_destroyed: Hinweis gelöscht
cost_entry_created: Kostenposition gespeichert.
cost_entry_updated: Kostenposition aktualisiert.
cost_entry_destroyed: Kostenposition gelöscht.
common: common:
free_plan: Free free_plan: Free
yes: "Ja" yes: "Ja"
@@ -99,6 +104,12 @@ de:
warnings: "%{count} Warnung(en)" warnings: "%{count} Warnung(en)"
dashboard_link: Ops-Dashboard dashboard_link: Ops-Dashboard
none_html: "Keine offenen Vorfälle. %{link}" none_html: "Keine offenen Vorfälle. %{link}"
concurrency_panel:
title: Konto-Missbrauch (zweite Direktübertragung)
count: "%{count} Versuch(e) in den letzten 30 Tagen"
view_all: Alle ansehen
none: Keine Versuche erfasst.
none_html: "Keine Versuche einer zweiten Direktübertragung mit demselben Konto. %{link}"
disk: disk:
system_title: Systemfestplatte system_title: Systemfestplatte
free_label: "Frei: %{free} (%{percent}% belegt)" free_label: "Frei: %{free} (%{percent}% belegt)"
@@ -111,6 +122,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
@@ -188,6 +200,36 @@ de:
table: table:
resolved_at: Gelöst resolved_at: Gelöst
none: Kürzlich keine Vorfälle gelöst. none: Kürzlich keine Vorfälle gelöst.
stream_concurrency:
index:
title: Konto-Missbrauch
lead: Versuche, mit demselben Konto eine zweite Direktübertragung zu starten, während bereits eine lief (connecting, live, reconnecting oder pausiert).
results: "%{shown} von %{total} angezeigt"
none: Keine Versuche erfasst.
kpi:
lookback: Letzte 30 Tage
lookback_sub: blockierte Versuche
two_devices: Zwei Geräte
two_devices_sub: unterschiedliche Handy-Modelle
filters:
q: Suche
q_placeholder: E-Mail, Verein, Spiel…
two_devices: Filter
two_devices_hint: Nur Versuche von unterschiedlichen Geräten
apply: Filtern
reset: Zurücksetzen
table:
when: Wann
account: Konto
club: Verein
occupying: Bereits laufende Direktübertragung
attempted: Blockierter Versuch
action:
start: Start
resume: Fortsetzen
badge:
two_devices: 2 Telefone
same_or_unknown: gleiches Gerät / n. v.
clubs: clubs:
index: index:
title: Vereine & Teams title: Vereine & Teams
@@ -219,6 +261,10 @@ de:
sport: Sportart sport: Sportart
matches_and_details: Spiele & Details matches_and_details: Spiele & Details
replay: Replays replay: Replays
concurrency_title: Versuche einer zweiten Direktübertragung
concurrency_lead: Dasselbe Konto hat versucht, eine weitere Direktübertragung zu starten, während bereits eine lief.
concurrency_none: Keine Versuche für diesen Verein erfasst.
concurrency_all: Alle Konto-Missbräuche ansehen
comped: comped:
title: Kostenloses Abonnement title: Kostenloses Abonnement
description: "Sponsor oder Aktion: Vergib Premium Light/Full ohne Stripe-Zahlung. Jederzeit widerrufbar." description: "Sponsor oder Aktion: Vergib Premium Light/Full ohne Stripe-Zahlung. Jederzeit widerrufbar."
@@ -276,6 +322,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
@@ -290,6 +337,8 @@ de:
devices_title: Geräte devices_title: Geräte
events_title: Ereignisse events_title: Ereignisse
events_none: Keine Ereignisse erfasst. events_none: Keine Ereignisse erfasst.
concurrency_title: Versuche einer zweiten Direktübertragung
concurrency_lead: Dieses Konto hat versucht, eine weitere Direktübertragung zu starten, während diese bereits lief (oder wurde von einer anderen Sitzung desselben Kontos blockiert).
status_label: "Status:" status_label: "Status:"
stop_button: Sitzung beenden stop_button: Sitzung beenden
stop_confirm: "Übertragung beenden? Der RTMP-Pfad wird entfernt und der Status wechselt zu beendet." stop_confirm: "Übertragung beenden? Der RTMP-Pfad wird entfernt und der Status wechselt zu beendet."
@@ -308,6 +357,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
@@ -346,6 +400,7 @@ de:
generate_button: Regie-Link erzeugen generate_button: Regie-Link erzeugen
ingest: ingest:
none: "—" none: "—"
decommissioned: "(Knoten entfernt)"
role: role:
home: Home-lab home: Home-lab
lab: Lab lab: Lab
@@ -363,6 +418,10 @@ de:
layer: Ebene layer: Ebene
layer_move: Mausbewegungen layer_move: Mausbewegungen
layer_click: Klicks layer_click: Klicks
devices:
mobile: Mobil
tablet: Tablet
desktop: Desktop
index: index:
title: Website-Analytics title: Website-Analytics
lead: Aggregierte First-Party-Heatmaps (Bewegung/Klick) und Scrolltiefe, nur mit Statistik-Einwilligung. Keine personenbezogenen Daten. lead: Aggregierte First-Party-Heatmaps (Bewegung/Klick) und Scrolltiefe, nur mit Statistik-Einwilligung. Keine personenbezogenen Daten.
@@ -383,14 +442,24 @@ de:
scroll_hint: "Durchschnitt %{avg}% · Maximum %{max}%" scroll_hint: "Durchschnitt %{avg}% · Maximum %{max}%"
heatmap_moves: Mausbewegungs-Karte heatmap_moves: Mausbewegungs-Karte
heatmap_clicks: Klickkarte heatmap_clicks: Klickkarte
heatmap_hint: Farbiges Overlay auf dem Seiten-Screenshot. Grün = wenig, Rot = viel. heatmap_hint: Farbiges Overlay auf dem %{device}-Screenshot. Grün = wenig, Rot = viel. Daten werden nicht zwischen Geräten gemischt.
device_tabs_label: Gerät
preview_title: Seiten-Screenshot preview_title: Seiten-Screenshot
preview_unavailable: Vorschau für Pfade mit Platzhalter nicht verfügbar (z. B. /clubs/:id). preview_unavailable: Vorschau für Pfade mit Platzhalter nicht verfügbar (z. B. /clubs/:id).
snapshot_missing: Noch kein Screenshot. Seite mit Analytics-Einwilligung (Desktop) besuchen und erneut versuchen. snapshot_missing: Noch kein Screenshot für %{device}. Seite auf diesem Gerät mit Analytics-Einwilligung besuchen und erneut versuchen.
snapshot_meta: "Screenshot %{device} · %{at}" snapshot_meta: "Screenshot %{device} · %{at}"
no_points: Keine aggregierten Punkte für diese Ebene im Zeitraum. no_points: Keine aggregierten Punkte für diese Ebene im Zeitraum.
heatmap_title: Klickkarte heatmap_title: Klickkarte
no_clicks: Keine aggregierten Klicks für diese Seite im Zeitraum. no_clicks: Keine aggregierten Klicks für diese Seite im Zeitraum.
preview:
title: Vorschau ohne Tracking
lead: Öffentliche Website durchsuchen ohne Pageviews, Klicks, Bewegungen oder Heatmap-Screenshots zu speichern. Für Produktionstests.
active: Staff-Vorschau in diesem Browser aktiv (7 Tage oder bis Deaktivierung).
enable: Staff-Vorschau aktivieren
disable: Vorschau deaktivieren
open_site: Website öffnen
enabled: Staff-Vorschau aktiviert. Dieser Browser speichert keine Analytics.
disabled: Staff-Vorschau deaktiviert.
billing: billing:
index: index:
+131 -2
View File
@@ -10,7 +10,9 @@ en:
billing: Billing billing: Billing
youtube: YouTube youtube: YouTube
sessions: Sessions sessions: Sessions
stream_concurrency: Account abuse
analytics: Analytics analytics: Analytics
costs: Costs
stream_nodes: Stream nodes stream_nodes: Stream nodes
password: Password password: Password
logout: Log out logout: Log out
@@ -50,6 +52,9 @@ en:
announcement_created: Notice saved announcement_created: Notice saved
announcement_updated: Notice updated announcement_updated: Notice updated
announcement_destroyed: Notice deleted announcement_destroyed: Notice deleted
cost_entry_created: Cost entry saved.
cost_entry_updated: Cost entry updated.
cost_entry_destroyed: Cost entry deleted.
common: common:
free_plan: Free free_plan: Free
yes: "Yes" yes: "Yes"
@@ -99,6 +104,12 @@ en:
warnings: "%{count} warning(s)" warnings: "%{count} warning(s)"
dashboard_link: Ops dashboard dashboard_link: Ops dashboard
none_html: "No open incidents. %{link}" none_html: "No open incidents. %{link}"
concurrency_panel:
title: Account abuse (second live)
count: "%{count} attempt(s) in the last 30 days"
view_all: View all
none: No attempts recorded.
none_html: "No second-live attempts from the same account. %{link}"
disk: disk:
system_title: System disk system_title: System disk
free_label: "Free: %{free} (%{percent}% used)" free_label: "Free: %{free} (%{percent}% used)"
@@ -111,6 +122,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
@@ -188,6 +200,36 @@ en:
table: table:
resolved_at: Resolved resolved_at: Resolved
none: No incidents resolved recently. none: No incidents resolved recently.
stream_concurrency:
index:
title: Account abuse
lead: Attempts to start a second live with the same account while another was already running (connecting, live, reconnecting or paused).
results: "Showing %{shown} of %{total}"
none: No attempts recorded.
kpi:
lookback: Last 30 days
lookback_sub: blocked attempts
two_devices: Two devices
two_devices_sub: different phone models
filters:
q: Search
q_placeholder: Email, club, match…
two_devices: Filter
two_devices_hint: Only attempts from different devices
apply: Filter
reset: Reset
table:
when: When
account: Account
club: Club
occupying: Live already running
attempted: Blocked attempt
action:
start: start
resume: resume
badge:
two_devices: 2 phones
same_or_unknown: same device / n.a.
clubs: clubs:
index: index:
title: Clubs & teams title: Clubs & teams
@@ -219,6 +261,10 @@ en:
sport: Sport sport: Sport
matches_and_details: Matches & details matches_and_details: Matches & details
replay: Replays replay: Replays
concurrency_title: Second-live attempts
concurrency_lead: Same account tried to start another live while one was already running.
concurrency_none: No attempts recorded for this club.
concurrency_all: View all account abuse
comped: comped:
title: Complimentary subscription title: Complimentary subscription
description: "Sponsor or promotion: grant Premium Light/Full without a Stripe payment. Revocable at any time." description: "Sponsor or promotion: grant Premium Light/Full without a Stripe payment. Revocable at any time."
@@ -276,6 +322,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
@@ -290,6 +337,8 @@ en:
devices_title: Devices devices_title: Devices
events_title: Events events_title: Events
events_none: No events recorded. events_none: No events recorded.
concurrency_title: Second-live attempts
concurrency_lead: This account tried to start another live while this one was already running (or was blocked by another session of the same account).
status_label: "Status:" status_label: "Status:"
stop_button: End session stop_button: End session
stop_confirm: "End the broadcast? The RTMP path will be removed and the status will move to ended." stop_confirm: "End the broadcast? The RTMP path will be removed and the status will move to ended."
@@ -308,6 +357,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
@@ -346,6 +400,7 @@ en:
generate_button: Generate control link generate_button: Generate control link
ingest: ingest:
none: "—" none: "—"
decommissioned: "(node removed)"
role: role:
home: Home-lab home: Home-lab
lab: Lab lab: Lab
@@ -363,6 +418,10 @@ en:
layer: Layer layer: Layer
layer_move: Mouse moves layer_move: Mouse moves
layer_click: Clicks layer_click: Clicks
devices:
mobile: Mobile
tablet: Tablet
desktop: Desktop
index: index:
title: Site analytics title: Site analytics
lead: Aggregated first-party move/click heatmaps and scroll depth, only with analytics consent. No personal data. lead: Aggregated first-party move/click heatmaps and scroll depth, only with analytics consent. No personal data.
@@ -383,14 +442,84 @@ en:
scroll_hint: "Average %{avg}% · max %{max}%" scroll_hint: "Average %{avg}% · max %{max}%"
heatmap_moves: Mouse-move map heatmap_moves: Mouse-move map
heatmap_clicks: Click map heatmap_clicks: Click map
heatmap_hint: Colored overlay on the page screenshot. Green = low, red = high. heatmap_hint: Colored overlay on the %{device} screenshot. Green = low, red = high. Data is never mixed across devices.
device_tabs_label: Device
preview_title: Page screenshot preview_title: Page screenshot
preview_unavailable: Preview unavailable for paths with placeholders (e.g. /clubs/:id). preview_unavailable: Preview unavailable for paths with placeholders (e.g. /clubs/:id).
snapshot_missing: No screenshot yet. Visit the page on the site with analytics consent (desktop), then retry in a few seconds. snapshot_missing: No screenshot for %{device} yet. Visit the page on that device with analytics consent, then retry in a few seconds.
snapshot_meta: "Screenshot %{device} · captured %{at}" snapshot_meta: "Screenshot %{device} · captured %{at}"
no_points: No aggregated points for this layer in the period. no_points: No aggregated points for this layer in the period.
heatmap_title: Click map heatmap_title: Click map
no_clicks: No aggregated clicks for this page in the period. no_clicks: No aggregated clicks for this page in the period.
preview:
title: Preview without tracking
lead: Browse the public site without recording pageviews, clicks, moves or heatmap screenshots. Useful for production smoke tests.
active: Staff preview is active in this browser (7 days or until disabled).
enable: Enable staff preview
disable: Disable preview
open_site: Open site
enabled: Staff preview enabled. This browser will not record analytics.
disabled: Staff preview disabled.
costs:
index:
title: Cost analysis
lead: Enter monthly platform costs and compare cost, usage and revenue. KPIs recalculate automatically when you edit entries.
month: Month
apply: Apply
entries_title: Cost entries for the month
entries_lead: Add multiple line items (e.g. servers, storage, bandwidth). Month total is the sum of entries.
add_entry: Add entry
no_entries: No cost entries for this month. Add platform cost to see KPIs.
clubs_title: Allocated cost per club
clubs_lead: Infrastructure cost is split proportionally by streamed hours in the month.
no_clubs: No ended streams in this month.
trends_title: Trends (last 12 months)
kpi:
platform_cost: Platform cost
revenue: Revenue collected
margin: Margin
margin_pct: Margin %
hours: Streamed hours
sessions: Streams
clubs_active: Active clubs
storage: Replay storage
cost_per_hour: Cost / stream hour
cost_per_session: Cost / stream
cost_per_club: Cost / active club
revenue_per_hour: Revenue / stream hour
cost_per_gb: Cost / GB storage
storage_note: Current cumulative storage (not monthly).
charts:
cost_revenue: Cost vs revenue
cost_per_hour: Cost per streamed hour
hours: Streamed hours
table:
label: Item
amount: Amount
notes: Notes
club: Club
plan: Plan
hours: Hours
hours_share: Hour share
allocated_cost: Allocated cost
revenue: Revenue
margin: Margin
cost_per_hour: €/hour
cost_per_session: €/stream
storage: Storage
entries:
new_title: New cost entry
edit_title: Edit cost entry
month: Month
label: Description
label_hint: E.g. Hetzner, stream nodes, S3, egress.
amount: Amount (€)
notes: Notes
save: Save
cancel: Cancel
delete: Delete
delete_confirm: Delete this cost entry?
billing: billing:
index: index:
+71 -2
View File
@@ -10,7 +10,9 @@ es:
billing: Facturación billing: Facturación
youtube: YouTube youtube: YouTube
sessions: Sesiones sessions: Sesiones
stream_concurrency: Abuso de cuenta
analytics: Analytics analytics: Analytics
costs: Costes
stream_nodes: Nodos stream stream_nodes: Nodos stream
password: Contraseña password: Contraseña
logout: Salir logout: Salir
@@ -50,6 +52,9 @@ es:
announcement_created: Aviso guardado announcement_created: Aviso guardado
announcement_updated: Aviso actualizado announcement_updated: Aviso actualizado
announcement_destroyed: Aviso eliminado announcement_destroyed: Aviso eliminado
cost_entry_created: Partida de coste guardada.
cost_entry_updated: Partida de coste actualizada.
cost_entry_destroyed: Partida de coste eliminada.
common: common:
free_plan: Free free_plan: Free
yes: "Sí" yes: "Sí"
@@ -99,6 +104,12 @@ es:
warnings: "%{count} aviso(s)" warnings: "%{count} aviso(s)"
dashboard_link: Panel de Ops dashboard_link: Panel de Ops
none_html: "No hay incidencias abiertas. %{link}" none_html: "No hay incidencias abiertas. %{link}"
concurrency_panel:
title: Abuso de cuenta (segundo directo)
count: "%{count} intento(s) en los últimos 30 días"
view_all: Ver todos
none: No hay intentos registrados.
none_html: "Ningún intento de segundo directo con la misma cuenta. %{link}"
disk: disk:
system_title: Disco del sistema system_title: Disco del sistema
free_label: "Libre: %{free} (%{percent}% usado)" free_label: "Libre: %{free} (%{percent}% usado)"
@@ -111,6 +122,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
@@ -188,6 +200,36 @@ es:
table: table:
resolved_at: Resuelta resolved_at: Resuelta
none: No se han resuelto incidencias recientemente. none: No se han resuelto incidencias recientemente.
stream_concurrency:
index:
title: Abuso de cuenta
lead: Intentos de iniciar un segundo directo con la misma cuenta mientras ya había otro en curso (connecting, live, reconnecting o en pausa).
results: "Mostrando %{shown} de %{total}"
none: No hay intentos registrados.
kpi:
lookback: Últimos 30 días
lookback_sub: intentos bloqueados
two_devices: Dos dispositivos
two_devices_sub: modelos de teléfono distintos
filters:
q: Buscar
q_placeholder: Email, club, partido…
two_devices: Filtro
two_devices_hint: Solo intentos desde dispositivos distintos
apply: Filtrar
reset: Restablecer
table:
when: Cuándo
account: Cuenta
club: Club
occupying: Directo ya en curso
attempted: Intento bloqueado
action:
start: inicio
resume: reanudación
badge:
two_devices: 2 teléfonos
same_or_unknown: mismo dispositivo / n. d.
clubs: clubs:
index: index:
title: Clubes y equipos title: Clubes y equipos
@@ -219,6 +261,10 @@ es:
sport: Deporte sport: Deporte
matches_and_details: Partidos y detalles matches_and_details: Partidos y detalles
replay: Repeticiones replay: Repeticiones
concurrency_title: Intentos de segundo directo
concurrency_lead: La misma cuenta intentó iniciar otro directo mientras ya había uno en curso.
concurrency_none: No hay intentos registrados para este club.
concurrency_all: Ver todos los abusos de cuenta
comped: comped:
title: Suscripción de cortesía title: Suscripción de cortesía
description: "Patrocinador o promoción: concede Premium Light/Full sin pago en Stripe. Revocable en cualquier momento." description: "Patrocinador o promoción: concede Premium Light/Full sin pago en Stripe. Revocable en cualquier momento."
@@ -276,6 +322,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
@@ -290,6 +337,8 @@ es:
devices_title: Dispositivos devices_title: Dispositivos
events_title: Eventos events_title: Eventos
events_none: No hay eventos registrados. events_none: No hay eventos registrados.
concurrency_title: Intentos de segundo directo
concurrency_lead: Esta cuenta intentó iniciar otro directo mientras este ya estaba en curso (o fue bloqueada por otra sesión de la misma cuenta).
status_label: "Estado:" status_label: "Estado:"
stop_button: Finalizar sesión stop_button: Finalizar sesión
stop_confirm: "¿Finalizar la emisión? La ruta RTMP se eliminará y el estado pasará a finalizada." stop_confirm: "¿Finalizar la emisión? La ruta RTMP se eliminará y el estado pasará a finalizada."
@@ -308,6 +357,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
@@ -346,6 +400,7 @@ es:
generate_button: Generar enlace de regie generate_button: Generar enlace de regie
ingest: ingest:
none: "—" none: "—"
decommissioned: "(nodo eliminado)"
role: role:
home: Home-lab home: Home-lab
lab: Lab lab: Lab
@@ -363,6 +418,10 @@ es:
layer: Capa layer: Capa
layer_move: Movimientos del ratón layer_move: Movimientos del ratón
layer_click: Clics layer_click: Clics
devices:
mobile: Móvil
tablet: Tablet
desktop: Escritorio
index: index:
title: Analytics del sitio title: Analytics del sitio
lead: Heatmaps de movimientos/clics y scroll agregados (first-party), solo con consentimiento estadístico. Sin datos personales. lead: Heatmaps de movimientos/clics y scroll agregados (first-party), solo con consentimiento estadístico. Sin datos personales.
@@ -383,14 +442,24 @@ es:
scroll_hint: "Media %{avg}% · máximo %{max}%" scroll_hint: "Media %{avg}% · máximo %{max}%"
heatmap_moves: Mapa de movimientos heatmap_moves: Mapa de movimientos
heatmap_clicks: Mapa de clics heatmap_clicks: Mapa de clics
heatmap_hint: Superposición de color sobre la captura de la página. Verde = poco, rojo = mucho. heatmap_hint: Superposición de color sobre la captura %{device}. Verde = poco, rojo = mucho. Los datos no se mezclan entre dispositivos.
device_tabs_label: Dispositivo
preview_title: Captura de página preview_title: Captura de página
preview_unavailable: Vista previa no disponible para rutas con placeholder (p. ej. /clubs/:id). preview_unavailable: Vista previa no disponible para rutas con placeholder (p. ej. /clubs/:id).
snapshot_missing: Aún no hay captura. Visita la página con consentimiento analytics (escritorio) y vuelve a intentarlo. snapshot_missing: Aún no hay captura para %{device}. Visita la página con ese dispositivo y consentimiento analytics, y vuelve a intentarlo.
snapshot_meta: "Captura %{device} · %{at}" snapshot_meta: "Captura %{device} · %{at}"
no_points: No hay puntos agregados para esta capa en el periodo. no_points: No hay puntos agregados para esta capa en el periodo.
heatmap_title: Mapa de clics heatmap_title: Mapa de clics
no_clicks: No hay clics agregados para esta página en el periodo. no_clicks: No hay clics agregados para esta página en el periodo.
preview:
title: Vista previa sin seguimiento
lead: Navega el sitio público sin registrar pageviews, clics, movimientos ni capturas heatmap. Útil para pruebas en producción.
active: Vista previa staff activa en este navegador (7 días o hasta desactivar).
enable: Activar vista previa staff
disable: Desactivar vista previa
open_site: Abrir sitio
enabled: Vista previa staff activada. Este navegador no registrará analytics.
disabled: Vista previa staff desactivada.
billing: billing:
index: index:
+71 -2
View File
@@ -10,7 +10,9 @@ fr:
billing: Facturation billing: Facturation
youtube: YouTube youtube: YouTube
sessions: Sessions sessions: Sessions
stream_concurrency: Abus de compte
analytics: Analytics analytics: Analytics
costs: Coûts
stream_nodes: Nœuds stream stream_nodes: Nœuds stream
password: Mot de passe password: Mot de passe
logout: Déconnexion logout: Déconnexion
@@ -50,6 +52,9 @@ fr:
announcement_created: Alerte enregistrée announcement_created: Alerte enregistrée
announcement_updated: Alerte mise à jour announcement_updated: Alerte mise à jour
announcement_destroyed: Alerte supprimée announcement_destroyed: Alerte supprimée
cost_entry_created: Poste de coût enregistré.
cost_entry_updated: Poste de coût mis à jour.
cost_entry_destroyed: Poste de coût supprimé.
common: common:
free_plan: Free free_plan: Free
yes: "Oui" yes: "Oui"
@@ -99,6 +104,12 @@ fr:
warnings: "%{count} avertissement(s)" warnings: "%{count} avertissement(s)"
dashboard_link: Tableau de bord Ops dashboard_link: Tableau de bord Ops
none_html: "Aucun incident ouvert. %{link}" none_html: "Aucun incident ouvert. %{link}"
concurrency_panel:
title: Abus de compte (deuxième direct)
count: "%{count} tentative(s) sur les 30 derniers jours"
view_all: Voir tout
none: Aucune tentative enregistrée.
none_html: "Aucune tentative de second direct avec le même compte. %{link}"
disk: disk:
system_title: Disque système system_title: Disque système
free_label: "Libre : %{free} (%{percent}% utilisé)" free_label: "Libre : %{free} (%{percent}% utilisé)"
@@ -111,6 +122,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
@@ -188,6 +200,36 @@ fr:
table: table:
resolved_at: Résolu resolved_at: Résolu
none: Aucun incident résolu récemment. none: Aucun incident résolu récemment.
stream_concurrency:
index:
title: Abus de compte
lead: Tentatives de démarrer un second direct avec le même compte alors quun autre était déjà en cours (connecting, live, reconnecting ou en pause).
results: "%{shown} sur %{total} affichés"
none: Aucune tentative enregistrée.
kpi:
lookback: 30 derniers jours
lookback_sub: tentatives bloquées
two_devices: Deux appareils
two_devices_sub: modèles de téléphone différents
filters:
q: Rechercher
q_placeholder: E-mail, club, match…
two_devices: Filtre
two_devices_hint: Uniquement les tentatives depuis des appareils différents
apply: Filtrer
reset: Réinitialiser
table:
when: Quand
account: Compte
club: Club
occupying: Direct déjà en cours
attempted: Tentative bloquée
action:
start: démarrage
resume: reprise
badge:
two_devices: 2 téléphones
same_or_unknown: même appareil / n. d.
clubs: clubs:
index: index:
title: Clubs et équipes title: Clubs et équipes
@@ -219,6 +261,10 @@ fr:
sport: Sport sport: Sport
matches_and_details: Matchs et détails matches_and_details: Matchs et détails
replay: Replays replay: Replays
concurrency_title: Tentatives de second direct
concurrency_lead: Le même compte a tenté de démarrer un autre direct alors quun était déjà en cours.
concurrency_none: Aucune tentative enregistrée pour ce club.
concurrency_all: Voir tous les abus de compte
comped: comped:
title: Abonnement offert title: Abonnement offert
description: "Sponsor ou promotion : accordez Premium Light/Full sans paiement Stripe. Révocable à tout moment." description: "Sponsor ou promotion : accordez Premium Light/Full sans paiement Stripe. Révocable à tout moment."
@@ -276,6 +322,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
@@ -290,6 +337,8 @@ fr:
devices_title: Appareils devices_title: Appareils
events_title: Événements events_title: Événements
events_none: Aucun événement enregistré. events_none: Aucun événement enregistré.
concurrency_title: Tentatives de second direct
concurrency_lead: Ce compte a tenté de démarrer un autre direct alors que celui-ci était déjà en cours (ou a été bloqué par une autre session du même compte).
status_label: "Statut :" status_label: "Statut :"
stop_button: Terminer la session stop_button: Terminer la session
stop_confirm: "Terminer la diffusion ? Le chemin RTMP sera supprimé et le statut passera à terminé." stop_confirm: "Terminer la diffusion ? Le chemin RTMP sera supprimé et le statut passera à terminé."
@@ -308,6 +357,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
@@ -346,6 +400,7 @@ fr:
generate_button: Générer le lien de régie generate_button: Générer le lien de régie
ingest: ingest:
none: "—" none: "—"
decommissioned: "(nœud retiré)"
role: role:
home: Home-lab home: Home-lab
lab: Lab lab: Lab
@@ -363,6 +418,10 @@ fr:
layer: Couche layer: Couche
layer_move: Mouvements souris layer_move: Mouvements souris
layer_click: Clics layer_click: Clics
devices:
mobile: Mobile
tablet: Tablette
desktop: Ordinateur
index: index:
title: Analytics du site title: Analytics du site
lead: Heatmaps mouvements/clics et scroll agrégés (first-party), uniquement avec consentement statistiques. Aucune donnée personnelle. lead: Heatmaps mouvements/clics et scroll agrégés (first-party), uniquement avec consentement statistiques. Aucune donnée personnelle.
@@ -383,14 +442,24 @@ fr:
scroll_hint: "Moyenne %{avg}% · maximum %{max}%" scroll_hint: "Moyenne %{avg}% · maximum %{max}%"
heatmap_moves: Carte des mouvements heatmap_moves: Carte des mouvements
heatmap_clicks: Carte des clics heatmap_clicks: Carte des clics
heatmap_hint: Superposition colorée sur la capture d’écran de la page. Vert = faible, rouge = fort. heatmap_hint: Superposition colorée sur la capture %{device}. Vert = faible, rouge = fort. Les données ne sont pas mélangées entre appareils.
device_tabs_label: Appareil
preview_title: Capture de page preview_title: Capture de page
preview_unavailable: Aperçu indisponible pour les chemins avec placeholder (ex. /clubs/:id). preview_unavailable: Aperçu indisponible pour les chemins avec placeholder (ex. /clubs/:id).
snapshot_missing: Pas encore de capture. Visitez la page avec consentement analytics (desktop), puis réessayez. snapshot_missing: Pas encore de capture pour %{device}. Visitez la page sur cet appareil avec consentement analytics, puis réessayez.
snapshot_meta: "Capture %{device} · %{at}" snapshot_meta: "Capture %{device} · %{at}"
no_points: Aucun point agrégé pour cette couche sur la période. no_points: Aucun point agrégé pour cette couche sur la période.
heatmap_title: Carte des clics heatmap_title: Carte des clics
no_clicks: Aucun clic agrégé pour cette page sur la période. no_clicks: Aucun clic agrégé pour cette page sur la période.
preview:
title: Aperçu sans suivi
lead: Parcourez le site public sans enregistrer pages vues, clics, mouvements ni captures heatmap. Utile pour tester en production.
active: Aperçu staff actif sur ce navigateur (7 jours ou jusqu'à désactivation).
enable: Activer l'aperçu staff
disable: Désactiver l'aperçu
open_site: Ouvrir le site
enabled: Aperçu staff activé. Ce navigateur ne enregistrera pas d'analytics.
disabled: Aperçu staff désactivé.
billing: billing:
index: index:
+131 -2
View File
@@ -10,7 +10,9 @@ it:
billing: Fatturazione billing: Fatturazione
youtube: YouTube youtube: YouTube
sessions: Sessioni sessions: Sessioni
stream_concurrency: Abusi account
analytics: Analytics analytics: Analytics
costs: Costi
stream_nodes: Nodi stream stream_nodes: Nodi stream
password: Password password: Password
logout: Esci logout: Esci
@@ -54,6 +56,9 @@ it:
announcement_created: Avviso salvato announcement_created: Avviso salvato
announcement_updated: Avviso aggiornato announcement_updated: Avviso aggiornato
announcement_destroyed: Avviso eliminato announcement_destroyed: Avviso eliminato
cost_entry_created: Voce di costo registrata.
cost_entry_updated: Voce di costo aggiornata.
cost_entry_destroyed: Voce di costo eliminata.
common: common:
free_plan: Free free_plan: Free
yes: "Sì" yes: "Sì"
@@ -103,6 +108,12 @@ it:
warnings: "%{count} warning" warnings: "%{count} warning"
dashboard_link: Dashboard Ops dashboard_link: Dashboard Ops
none_html: "Nessun incidente aperto. %{link}" none_html: "Nessun incidente aperto. %{link}"
concurrency_panel:
title: Abusi account (seconda diretta)
count: "%{count} tentativo/i negli ultimi 30 giorni"
view_all: Vedi tutti
none: Nessun tentativo registrato.
none_html: "Nessun tentativo di seconda diretta dallo stesso account. %{link}"
disk: disk:
system_title: Disco sistema system_title: Disco sistema
free_label: "Libero: %{free} (%{percent}% usato)" free_label: "Libero: %{free} (%{percent}% usato)"
@@ -115,6 +126,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
@@ -192,6 +204,36 @@ it:
table: table:
resolved_at: Risolto resolved_at: Risolto
none: Nessun incidente risolto di recente. none: Nessun incidente risolto di recente.
stream_concurrency:
index:
title: Abusi account
lead: Tentativi di avviare una seconda diretta con lo stesso account mentre unaltra era già in corso (connecting, live, reconnecting o in pausa).
results: "Mostrate %{shown} di %{total}"
none: Nessun tentativo registrato.
kpi:
lookback: Ultimi 30 giorni
lookback_sub: tentativi bloccati
two_devices: Due dispositivi
two_devices_sub: modelli telefono diversi
filters:
q: Cerca
q_placeholder: Email, società, partita…
two_devices: Filtro
two_devices_hint: Solo tentativi da dispositivi diversi
apply: Filtra
reset: Reset
table:
when: Quando
account: Account
club: Società
occupying: Diretta già in corso
attempted: Tentativo bloccato
action:
start: avvio
resume: ripresa
badge:
two_devices: 2 telefoni
same_or_unknown: stesso device / n.d.
clubs: clubs:
index: index:
title: Società e squadre title: Società e squadre
@@ -224,6 +266,10 @@ it:
sport: Sport sport: Sport
matches_and_details: Partite e dettagli matches_and_details: Partite e dettagli
replay: Replay replay: Replay
concurrency_title: Tentativi di seconda diretta
concurrency_lead: Stesso account che ha provato ad avviare unaltra diretta mentre ne era già in corso una.
concurrency_none: Nessun tentativo registrato per questa società.
concurrency_all: Vedi tutti gli abusi account
comped: comped:
title: Abbonamento omaggio title: Abbonamento omaggio
description: "Sponsor o promozione: assegna Premium Light/Full senza pagamento Stripe. Revocabile in qualsiasi momento." description: "Sponsor o promozione: assegna Premium Light/Full senza pagamento Stripe. Revocabile in qualsiasi momento."
@@ -297,6 +343,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
@@ -311,6 +358,8 @@ it:
devices_title: Dispositivi devices_title: Dispositivi
events_title: Eventi events_title: Eventi
events_none: Nessun evento registrato. events_none: Nessun evento registrato.
concurrency_title: Tentativi di seconda diretta
concurrency_lead: Questo account ha provato ad avviare unaltra diretta mentre questa era già in corso (o è stato bloccato da unaltra sessione dello stesso account).
status_label: "Stato:" status_label: "Stato:"
stop_button: Termina sessione stop_button: Termina sessione
stop_confirm: "Terminare la trasmissione? Il path RTMP verrà rimosso e lo stato passerà a ended." stop_confirm: "Terminare la trasmissione? Il path RTMP verrà rimosso e lo stato passerà a ended."
@@ -329,6 +378,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
@@ -367,6 +421,7 @@ it:
generate_button: Genera link regia generate_button: Genera link regia
ingest: ingest:
none: "—" none: "—"
decommissioned: "(nodo rimosso)"
role: role:
home: Home-lab home: Home-lab
lab: Lab lab: Lab
@@ -384,6 +439,10 @@ it:
layer: Livello layer: Livello
layer_move: Movimenti mouse layer_move: Movimenti mouse
layer_click: Click layer_click: Click
devices:
mobile: Mobile
tablet: Tablet
desktop: Desktop
index: index:
title: Analytics sito title: Analytics sito
lead: Heatmap movimenti/click e scroll aggregati (first-party), solo con consenso statistico. Nessun dato personale. lead: Heatmap movimenti/click e scroll aggregati (first-party), solo con consenso statistico. Nessun dato personale.
@@ -404,14 +463,84 @@ it:
scroll_hint: "Media %{avg}% · massimo %{max}%" scroll_hint: "Media %{avg}% · massimo %{max}%"
heatmap_moves: Mappa movimenti mouse heatmap_moves: Mappa movimenti mouse
heatmap_clicks: Mappa click heatmap_clicks: Mappa click
heatmap_hint: Overlay colorato sullo screenshot della pagina. Verde = poco, rosso = molto. heatmap_hint: Overlay colorato sullo screenshot %{device}. Verde = poco, rosso = molto. I dati non vengono mischiati tra dispositivi.
device_tabs_label: Dispositivo
preview_title: Screenshot pagina preview_title: Screenshot pagina
preview_unavailable: Anteprima non disponibile per path con placeholder (es. /clubs/:id). preview_unavailable: Anteprima non disponibile per path con placeholder (es. /clubs/:id).
snapshot_missing: Nessuno screenshot ancora. Visita la pagina sul sito con consenso analytics (desktop) e riprova tra qualche secondo. snapshot_missing: Nessuno screenshot per %{device}. Visita la pagina sul sito con quel dispositivo e consenso analytics, poi riprova tra qualche secondo.
snapshot_meta: "Screenshot %{device} · catturato %{at}" snapshot_meta: "Screenshot %{device} · catturato %{at}"
no_points: Nessun punto aggregato per questo livello nel periodo. no_points: Nessun punto aggregato per questo livello nel periodo.
heatmap_title: Mappa click heatmap_title: Mappa click
no_clicks: Nessun click aggregato per questa pagina nel periodo. no_clicks: Nessun click aggregato per questa pagina nel periodo.
preview:
title: Anteprima senza tracciamento
lead: Visita il sito pubblico senza registrare pageview, click, movimenti o screenshot heatmap. Utile per test in produzione.
active: Modalità anteprima attiva su questo browser (7 giorni o fino a disattivazione).
enable: Attiva anteprima staff
disable: Disattiva anteprima
open_site: Apri sito
enabled: Anteprima staff attivata. Il sito non registrerà analytics su questo browser.
disabled: Anteprima staff disattivata.
costs:
index:
title: Analisi costi
lead: Inserisci i costi mensili della piattaforma e confronta costo, utilizzo e revenue. I KPI si ricalcolano automaticamente quando modifichi le voci.
month: Mese
apply: Applica
entries_title: Voci di costo del mese
entries_lead: Puoi aggiungere più voci (es. server, storage, banda). Il totale è la somma delle voci del mese.
add_entry: Aggiungi voce
no_entries: Nessuna voce di costo per questo mese. Aggiungi il costo della piattaforma per vedere i KPI.
clubs_title: Costo allocato per società
clubs_lead: Il costo infrastruttura è ripartito proporzionalmente alle ore trasmesse nel mese.
no_clubs: Nessuna diretta terminata in questo mese.
trends_title: Trend (ultimi 12 mesi)
kpi:
platform_cost: Costo piattaforma
revenue: Revenue incassata
margin: Margine
margin_pct: Margine %
hours: Ore trasmesse
sessions: Dirette
clubs_active: Società attive
storage: Storage replay
cost_per_hour: Costo / ora stream
cost_per_session: Costo / diretta
cost_per_club: Costo / società attiva
revenue_per_hour: Revenue / ora stream
cost_per_gb: Costo / GB storage
storage_note: Storage cumulativo attuale (non mensile).
charts:
cost_revenue: Costo vs revenue
cost_per_hour: Costo per ora trasmessa
hours: Ore trasmesse
table:
label: Voce
amount: Importo
notes: Note
club: Società
plan: Piano
hours: Ore
hours_share: Quota ore
allocated_cost: Costo allocato
revenue: Revenue
margin: Margine
cost_per_hour: €/ora
cost_per_session: €/diretta
storage: Storage
entries:
new_title: Nuova voce di costo
edit_title: Modifica voce di costo
month: Mese
label: Descrizione
label_hint: Es. Hetzner, nodi stream CPX, S3, banda egress.
amount: Importo (€)
notes: Note
save: Salva
cancel: Annulla
delete: Elimina
delete_confirm: Eliminare questa voce di costo?
billing: billing:
index: index:
+11 -8
View File
@@ -1,4 +1,7 @@
de: de:
api:
errors:
user_concurrent_stream: "Mit diesem Konto läuft bereits eine Direktübertragung. Beende sie, bevor du eine weitere startest."
password_policy: password_policy:
hint: "Mindestens 8 Zeichen, mit mindestens 3 aus: Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen." hint: "Mindestens 8 Zeichen, mit mindestens 3 aus: Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen."
activerecord: activerecord:
@@ -77,10 +80,10 @@ de:
title: "Verein registrieren — Match Live TV" title: "Verein registrieren — Match Live TV"
meta_description: Erstelle deinen Sportverein und das erste Team auf Match Live TV. meta_description: Erstelle deinen Sportverein und das erste Team auf Match Live TV.
heading: Dein Verein heading: Dein Verein
lead: "Registriere den Verein: du kannst später weitere Teams hinzufügen (U13, U15, Serie C…)." lead: "Registriere den Verein: du kannst später weitere Teams hinzufügen (U13, U15, erste Mannschaft…)."
section_club: Verein section_club: Verein
name_label: Vereinsname / Club name_label: Vereinsname / Club
name_placeholder: "z. B. Crazy Volley Rozzano" name_placeholder: "z. B. Team MLTV"
section_first_team: Erstes Team section_first_team: Erstes Team
default_first_team_name: Erstes Team default_first_team_name: Erstes Team
first_team_name_label: Teamname first_team_name_label: Teamname
@@ -159,7 +162,7 @@ de:
heading: Neues Team heading: Neues Team
club_label: "Verein:" club_label: "Verein:"
name_label: Teamname name_label: Teamname
name_placeholder: "z. B. Under 13, Serie C" name_placeholder: "z. B. U15 männlich"
branding_legend: Branding-Überschreibung (optional) branding_legend: Branding-Überschreibung (optional)
submit: Team hinzufügen submit: Team hinzufügen
invite: invite:
@@ -235,7 +238,7 @@ de:
matches: matches:
back_to_list: "← Spieleliste" back_to_list: "← Spieleliste"
opponent_label: Gegner opponent_label: Gegner
opponent_placeholder: "z. B. Volley Milano" opponent_placeholder: "z. B. Team Guest"
location_label: "Ort (optional)" location_label: "Ort (optional)"
location_placeholder: "Halle, Stadt" location_placeholder: "Halle, Stadt"
datetime_label: Datum und Uhrzeit datetime_label: Datum und Uhrzeit
@@ -361,7 +364,7 @@ de:
legend_hint: "Alle markierten Felder sind erforderlich, um einen Premium-Tarif zu abonnieren und Rechnungen auszustellen. Vereine benötigen eine USt-IdNr., Privatpersonen eine Steuernummer. Du brauchst entweder SDI oder eine zertifizierte E-Mail (PEC). Die Zahlungen werden weiterhin sicher von Stripe abgewickelt." legend_hint: "Alle markierten Felder sind erforderlich, um einen Premium-Tarif zu abonnieren und Rechnungen auszustellen. Vereine benötigen eine USt-IdNr., Privatpersonen eine Steuernummer. Du brauchst entweder SDI oder eine zertifizierte E-Mail (PEC). Die Zahlungen werden weiterhin sicher von Stripe abgewickelt."
entity_type_label: "Art des Rechnungsempfängers *" entity_type_label: "Art des Rechnungsempfängers *"
legal_name_label: "Firmenname / Vor- und Nachname *" legal_name_label: "Firmenname / Vor- und Nachname *"
legal_name_placeholder: "z. B. ASD Tigers Volley" legal_name_placeholder: "z. B. Team MLTV"
vat_number_label: "USt-IdNr. * (Verein)" vat_number_label: "USt-IdNr. * (Verein)"
fiscal_code_label: "Steuernummer * (Privatperson)" fiscal_code_label: "Steuernummer * (Privatperson)"
fiscal_code_placeholder: RSSMRA80A01H501U fiscal_code_placeholder: RSSMRA80A01H501U
@@ -738,7 +741,7 @@ de:
replay_archive_link: "Vergangene Live-Übertragungen — Wiederholungsarchiv" replay_archive_link: "Vergangene Live-Übertragungen — Wiederholungsarchiv"
schedule_match_link: "Spiel planen" schedule_match_link: "Spiel planen"
search_placeholder_club: "Verein, Team, Gegner oder Ort suchen…" search_placeholder_club: "Verein, Team, Gegner oder Ort suchen…"
search_placeholder_default: "Z. B. Crazy Volley, Serie D, Gegner…" search_placeholder_default: "Z. B. Team MLTV, Team Guest…"
search_aria_label: "Team suchen" search_aria_label: "Team suchen"
search_button: "Suchen" search_button: "Suchen"
reset_link: "Zurücksetzen" reset_link: "Zurücksetzen"
@@ -772,7 +775,7 @@ de:
empty_hero_cta_features: "Erfahre, wie es funktioniert" empty_hero_cta_features: "Erfahre, wie es funktioniert"
demo_aria_label: "Beispiel einer aktiven Live-Übertragung" demo_aria_label: "Beispiel einer aktiven Live-Übertragung"
demo_label: "Beispiel — so sieht eine aktive Übertragung aus" demo_label: "Beispiel — so sieht eine aktive Übertragung aus"
demo_meta: "PalaTigers · Match Live TV" demo_meta: "Pala MLTV · Match Live TV"
demo_sets: "Satz 2 · Sätze gewonnen 1-0" demo_sets: "Satz 2 · Sätze gewonnen 1-0"
show: show:
back_to_all: "← Alle Live-Übertragungen" back_to_all: "← Alle Live-Übertragungen"
@@ -805,7 +808,7 @@ de:
back_to_live: "← Live-Übertragungen" back_to_live: "← Live-Übertragungen"
title: "Vergangene Live-Übertragungen" title: "Vergangene Live-Übertragungen"
hint: "Öffentliche Wiederholungen von Sportvereinen — betrifft bereits übertragene Spiele." hint: "Öffentliche Wiederholungen von Sportvereinen — betrifft bereits übertragene Spiele."
search_placeholder: "Z. B. Tigers Volley, Gegner, Verein, Ort…" search_placeholder: "Z. B. Team MLTV, Team Guest…"
search_aria_label: "Wiederholung suchen" search_aria_label: "Wiederholung suchen"
search_button: "Suchen" search_button: "Suchen"
reset_link: "Zurücksetzen" reset_link: "Zurücksetzen"
+11 -8
View File
@@ -1,4 +1,7 @@
en: en:
api:
errors:
user_concurrent_stream: "You already have a live stream running on this account. Stop it before starting another."
password_policy: password_policy:
hint: "At least 8 characters, including 3 of: lowercase, uppercase, numbers and symbols." hint: "At least 8 characters, including 3 of: lowercase, uppercase, numbers and symbols."
activerecord: activerecord:
@@ -82,10 +85,10 @@ en:
title: "Register a club — Match Live TV" title: "Register a club — Match Live TV"
meta_description: Create your sports club and its first team on Match Live TV. meta_description: Create your sports club and its first team on Match Live TV.
heading: Your club heading: Your club
lead: "Register the club: you'll be able to add more teams (Under 13, Under 15, Serie C…)." lead: "Register the club: you'll be able to add more teams (Under 13, Under 15, first team…)."
section_club: Club section_club: Club
name_label: Club name name_label: Club name
name_placeholder: "e.g. Crazy Volley Rozzano" name_placeholder: "e.g. Team MLTV"
section_first_team: First team section_first_team: First team
default_first_team_name: First team default_first_team_name: First team
first_team_name_label: Team name first_team_name_label: Team name
@@ -164,7 +167,7 @@ en:
heading: New team heading: New team
club_label: "Club:" club_label: "Club:"
name_label: Team name name_label: Team name
name_placeholder: "e.g. Under 13, Serie C" name_placeholder: "e.g. U15 boys"
branding_legend: Branding override (optional) branding_legend: Branding override (optional)
submit: Add team submit: Add team
invite: invite:
@@ -240,7 +243,7 @@ en:
matches: matches:
back_to_list: "← Match list" back_to_list: "← Match list"
opponent_label: Opponent opponent_label: Opponent
opponent_placeholder: "e.g. Volley Milano" opponent_placeholder: "e.g. Team Guest"
location_label: "Location (optional)" location_label: "Location (optional)"
location_placeholder: "Gym, city" location_placeholder: "Gym, city"
datetime_label: Date and time datetime_label: Date and time
@@ -366,7 +369,7 @@ en:
legend_hint: "All marked fields are required to subscribe to a premium plan and to issue invoices. Clubs need a VAT number; private individuals need a fiscal code. You need either SDI or certified email (PEC). Payments remain securely handled by Stripe." legend_hint: "All marked fields are required to subscribe to a premium plan and to issue invoices. Clubs need a VAT number; private individuals need a fiscal code. You need either SDI or certified email (PEC). Payments remain securely handled by Stripe."
entity_type_label: "Billing entity type *" entity_type_label: "Billing entity type *"
legal_name_label: "Legal name / full name *" legal_name_label: "Legal name / full name *"
legal_name_placeholder: "e.g. ASD Tigers Volley" legal_name_placeholder: "e.g. Team MLTV"
vat_number_label: "VAT number * (club)" vat_number_label: "VAT number * (club)"
fiscal_code_label: "Fiscal code * (private individual)" fiscal_code_label: "Fiscal code * (private individual)"
fiscal_code_placeholder: RSSMRA80A01H501U fiscal_code_placeholder: RSSMRA80A01H501U
@@ -743,7 +746,7 @@ en:
replay_archive_link: "Past live streams — replay archive" replay_archive_link: "Past live streams — replay archive"
schedule_match_link: "Schedule a match" schedule_match_link: "Schedule a match"
search_placeholder_club: "Search club, team, opponent or venue…" search_placeholder_club: "Search club, team, opponent or venue…"
search_placeholder_default: "E.g. Crazy Volley, Serie D, opponent…" search_placeholder_default: "E.g. Team MLTV, Team Guest…"
search_aria_label: "Search team" search_aria_label: "Search team"
search_button: "Search" search_button: "Search"
reset_link: "Reset" reset_link: "Reset"
@@ -777,7 +780,7 @@ en:
empty_hero_cta_features: "See how it works" empty_hero_cta_features: "See how it works"
demo_aria_label: "Example of an active live stream" demo_aria_label: "Example of an active live stream"
demo_label: "Example — this is how an active stream looks" demo_label: "Example — this is how an active stream looks"
demo_meta: "PalaTigers · Match Live TV" demo_meta: "Pala MLTV · Match Live TV"
demo_sets: "Set 2 · Sets won 1-0" demo_sets: "Set 2 · Sets won 1-0"
show: show:
back_to_all: "← All live streams" back_to_all: "← All live streams"
@@ -810,7 +813,7 @@ en:
back_to_live: "← Live streams" back_to_live: "← Live streams"
title: "Past live streams" title: "Past live streams"
hint: "Public replays from sports clubs — covers matches already broadcast." hint: "Public replays from sports clubs — covers matches already broadcast."
search_placeholder: "E.g. Tigers Volley, opponent, club, venue…" search_placeholder: "E.g. Team MLTV, Team Guest…"
search_aria_label: "Search replays" search_aria_label: "Search replays"
search_button: "Search" search_button: "Search"
reset_link: "Reset" reset_link: "Reset"
+11 -8
View File
@@ -1,4 +1,7 @@
es: es:
api:
errors:
user_concurrent_stream: "Ya tienes un directo en curso con esta cuenta. Ciérralo antes de iniciar otro."
password_policy: password_policy:
hint: "Mínimo 8 caracteres, con al menos 3 entre: minúsculas, mayúsculas, números y símbolos." hint: "Mínimo 8 caracteres, con al menos 3 entre: minúsculas, mayúsculas, números y símbolos."
activerecord: activerecord:
@@ -77,10 +80,10 @@ es:
title: "Registrar un club — Match Live TV" title: "Registrar un club — Match Live TV"
meta_description: Crea tu club deportivo y su primer equipo en Match Live TV. meta_description: Crea tu club deportivo y su primer equipo en Match Live TV.
heading: Tu club heading: Tu club
lead: "Registra el club: podrás añadir más equipos (Sub-13, Sub-15, Serie C…)." lead: "Registra el club: podrás añadir más equipos (Sub-13, Sub-15, primer equipo…)."
section_club: Club section_club: Club
name_label: Nombre del club name_label: Nombre del club
name_placeholder: "ej. Crazy Volley Rozzano" name_placeholder: "ej. Team MLTV"
section_first_team: Primer equipo section_first_team: Primer equipo
default_first_team_name: Primer equipo default_first_team_name: Primer equipo
first_team_name_label: Nombre del equipo first_team_name_label: Nombre del equipo
@@ -159,7 +162,7 @@ es:
heading: Nuevo equipo heading: Nuevo equipo
club_label: "Club:" club_label: "Club:"
name_label: Nombre del equipo name_label: Nombre del equipo
name_placeholder: "ej. Sub-13, Serie C" name_placeholder: "ej. U15 masculino"
branding_legend: Personalización de imagen de marca (opcional) branding_legend: Personalización de imagen de marca (opcional)
submit: Añadir equipo submit: Añadir equipo
invite: invite:
@@ -235,7 +238,7 @@ es:
matches: matches:
back_to_list: "← Lista de partidos" back_to_list: "← Lista de partidos"
opponent_label: Rival opponent_label: Rival
opponent_placeholder: "ej. Volley Milano" opponent_placeholder: "ej. Team Guest"
location_label: "Lugar (opcional)" location_label: "Lugar (opcional)"
location_placeholder: "Pabellón, ciudad" location_placeholder: "Pabellón, ciudad"
datetime_label: Fecha y hora datetime_label: Fecha y hora
@@ -361,7 +364,7 @@ es:
legend_hint: "Todos los campos marcados son obligatorios para suscribirte a un plan premium y para emitir facturas. Los clubes necesitan NIF/CIF; las personas físicas, el código fiscal. Necesitas SDI o correo certificado (PEC). Los pagos siguen gestionados de forma segura por Stripe." legend_hint: "Todos los campos marcados son obligatorios para suscribirte a un plan premium y para emitir facturas. Los clubes necesitan NIF/CIF; las personas físicas, el código fiscal. Necesitas SDI o correo certificado (PEC). Los pagos siguen gestionados de forma segura por Stripe."
entity_type_label: "Tipo de titular *" entity_type_label: "Tipo de titular *"
legal_name_label: "Razón social / nombre y apellidos *" legal_name_label: "Razón social / nombre y apellidos *"
legal_name_placeholder: "ej. ASD Tigers Volley" legal_name_placeholder: "ej. Team MLTV"
vat_number_label: "NIF/CIF * (club)" vat_number_label: "NIF/CIF * (club)"
fiscal_code_label: "Código fiscal * (persona física)" fiscal_code_label: "Código fiscal * (persona física)"
fiscal_code_placeholder: RSSMRA80A01H501U fiscal_code_placeholder: RSSMRA80A01H501U
@@ -738,7 +741,7 @@ es:
replay_archive_link: "Directos pasados — archivo de repeticiones" replay_archive_link: "Directos pasados — archivo de repeticiones"
schedule_match_link: "Programar partido" schedule_match_link: "Programar partido"
search_placeholder_club: "Buscar club, equipo, rival o lugar…" search_placeholder_club: "Buscar club, equipo, rival o lugar…"
search_placeholder_default: "Ej. Crazy Volley, Serie D, rival…" search_placeholder_default: "Ej. Team MLTV, Team Guest…"
search_aria_label: "Buscar equipo" search_aria_label: "Buscar equipo"
search_button: "Buscar" search_button: "Buscar"
reset_link: "Restablecer" reset_link: "Restablecer"
@@ -772,7 +775,7 @@ es:
empty_hero_cta_features: "Descubre cómo funciona" empty_hero_cta_features: "Descubre cómo funciona"
demo_aria_label: "Ejemplo de un directo activo" demo_aria_label: "Ejemplo de un directo activo"
demo_label: "Ejemplo — así se ve un directo activo" demo_label: "Ejemplo — así se ve un directo activo"
demo_meta: "PalaTigers · Match Live TV" demo_meta: "Pala MLTV · Match Live TV"
demo_sets: "Set 2 · Sets ganados 1-0" demo_sets: "Set 2 · Sets ganados 1-0"
show: show:
back_to_all: "← Todos los directos" back_to_all: "← Todos los directos"
@@ -805,7 +808,7 @@ es:
back_to_live: "← Directos en curso" back_to_live: "← Directos en curso"
title: "Directos pasados" title: "Directos pasados"
hint: "Repeticiones públicas de los clubes deportivos — corresponde a partidos ya transmitidos." hint: "Repeticiones públicas de los clubes deportivos — corresponde a partidos ya transmitidos."
search_placeholder: "Ej. Tigers Volley, rival, club, lugar…" search_placeholder: "Ej. Team MLTV, Team Guest…"
search_aria_label: "Buscar repetición" search_aria_label: "Buscar repetición"
search_button: "Buscar" search_button: "Buscar"
reset_link: "Restablecer" reset_link: "Restablecer"
+11 -8
View File
@@ -1,4 +1,7 @@
fr: fr:
api:
errors:
user_concurrent_stream: "Un direct est déjà en cours avec ce compte. Arrêtez-le avant den démarrer un autre."
password_policy: password_policy:
hint: "Au moins 8 caractères, avec au moins 3 parmi : minuscules, majuscules, chiffres et symboles." hint: "Au moins 8 caractères, avec au moins 3 parmi : minuscules, majuscules, chiffres et symboles."
activerecord: activerecord:
@@ -77,10 +80,10 @@ fr:
title: "Inscrire un club — Match Live TV" title: "Inscrire un club — Match Live TV"
meta_description: Crée ton club sportif et sa première équipe sur Match Live TV. meta_description: Crée ton club sportif et sa première équipe sur Match Live TV.
heading: Ton club heading: Ton club
lead: "Inscris le club : tu pourras ajouter d'autres équipes (Under 13, Under 15, Serie C…)." lead: "Inscris le club : tu pourras ajouter d'autres équipes (Under 13, Under 15, première équipe…)."
section_club: Club section_club: Club
name_label: Nom du club name_label: Nom du club
name_placeholder: "ex. Crazy Volley Rozzano" name_placeholder: "ex. Team MLTV"
section_first_team: Première équipe section_first_team: Première équipe
default_first_team_name: Première équipe default_first_team_name: Première équipe
first_team_name_label: Nom de l'équipe first_team_name_label: Nom de l'équipe
@@ -159,7 +162,7 @@ fr:
heading: Nouvelle équipe heading: Nouvelle équipe
club_label: "Club :" club_label: "Club :"
name_label: Nom de l'équipe name_label: Nom de l'équipe
name_placeholder: "ex. Under 13, Serie C" name_placeholder: "ex. U15 masculin"
branding_legend: Personnalisation de l'image de marque (facultatif) branding_legend: Personnalisation de l'image de marque (facultatif)
submit: Ajouter l'équipe submit: Ajouter l'équipe
invite: invite:
@@ -235,7 +238,7 @@ fr:
matches: matches:
back_to_list: "← Liste des matchs" back_to_list: "← Liste des matchs"
opponent_label: Adversaire opponent_label: Adversaire
opponent_placeholder: "ex. Volley Milano" opponent_placeholder: "ex. Team Guest"
location_label: "Lieu (facultatif)" location_label: "Lieu (facultatif)"
location_placeholder: "Gymnase, ville" location_placeholder: "Gymnase, ville"
datetime_label: Date et heure datetime_label: Date et heure
@@ -361,7 +364,7 @@ fr:
legend_hint: "Tous les champs marqués sont obligatoires pour souscrire à un forfait premium et pour émettre les factures. Les clubs ont besoin d'un numéro de TVA ; les personnes physiques d'un code fiscal. Il faut le SDI ou le PEC. Les paiements restent gérés en toute sécurité par Stripe." legend_hint: "Tous les champs marqués sont obligatoires pour souscrire à un forfait premium et pour émettre les factures. Les clubs ont besoin d'un numéro de TVA ; les personnes physiques d'un code fiscal. Il faut le SDI ou le PEC. Les paiements restent gérés en toute sécurité par Stripe."
entity_type_label: "Type de titulaire *" entity_type_label: "Type de titulaire *"
legal_name_label: "Raison sociale / nom et prénom *" legal_name_label: "Raison sociale / nom et prénom *"
legal_name_placeholder: "ex. ASD Tigers Volley" legal_name_placeholder: "ex. Team MLTV"
vat_number_label: "Numéro de TVA * (club)" vat_number_label: "Numéro de TVA * (club)"
fiscal_code_label: "Code fiscal * (personne physique)" fiscal_code_label: "Code fiscal * (personne physique)"
fiscal_code_placeholder: RSSMRA80A01H501U fiscal_code_placeholder: RSSMRA80A01H501U
@@ -738,7 +741,7 @@ fr:
replay_archive_link: "Directs passés — archive des replays" replay_archive_link: "Directs passés — archive des replays"
schedule_match_link: "Programmer un match" schedule_match_link: "Programmer un match"
search_placeholder_club: "Rechercher club, équipe, adversaire ou lieu…" search_placeholder_club: "Rechercher club, équipe, adversaire ou lieu…"
search_placeholder_default: "Ex. Crazy Volley, Série D, adversaire…" search_placeholder_default: "Ex. Team MLTV, Team Guest…"
search_aria_label: "Rechercher une équipe" search_aria_label: "Rechercher une équipe"
search_button: "Rechercher" search_button: "Rechercher"
reset_link: "Réinitialiser" reset_link: "Réinitialiser"
@@ -772,7 +775,7 @@ fr:
empty_hero_cta_features: "Découvrez comment ça marche" empty_hero_cta_features: "Découvrez comment ça marche"
demo_aria_label: "Exemple de direct actif" demo_aria_label: "Exemple de direct actif"
demo_label: "Exemple — voici à quoi ressemble un direct actif" demo_label: "Exemple — voici à quoi ressemble un direct actif"
demo_meta: "PalaTigers · Match Live TV" demo_meta: "Pala MLTV · Match Live TV"
demo_sets: "Set 2 · Sets gagnés 1-0" demo_sets: "Set 2 · Sets gagnés 1-0"
show: show:
back_to_all: "← Tous les directs" back_to_all: "← Tous les directs"
@@ -805,7 +808,7 @@ fr:
back_to_live: "← Directs en cours" back_to_live: "← Directs en cours"
title: "Directs passés" title: "Directs passés"
hint: "Replays publics des clubs sportifs — concerne les matchs déjà diffusés." hint: "Replays publics des clubs sportifs — concerne les matchs déjà diffusés."
search_placeholder: "Ex. Tigers Volley, adversaire, club, lieu…" search_placeholder: "Ex. Team MLTV, Team Guest…"
search_aria_label: "Rechercher un replay" search_aria_label: "Rechercher un replay"
search_button: "Rechercher" search_button: "Rechercher"
reset_link: "Réinitialiser" reset_link: "Réinitialiser"
+11 -8
View File
@@ -1,4 +1,7 @@
it: it:
api:
errors:
user_concurrent_stream: "Hai già una diretta in corso con questo account. Chiudila prima di avviarne unaltra."
password_policy: password_policy:
hint: "Minimo 8 caratteri, con almeno 3 tra: minuscole, maiuscole, numeri e simboli." hint: "Minimo 8 caratteri, con almeno 3 tra: minuscole, maiuscole, numeri e simboli."
activerecord: activerecord:
@@ -82,10 +85,10 @@ it:
title: "Registra società — Match Live TV" title: "Registra società — Match Live TV"
meta_description: Crea la società sportiva e la prima squadra su Match Live TV. meta_description: Crea la società sportiva e la prima squadra su Match Live TV.
heading: La tua società heading: La tua società
lead: "Registra il club: potrai aggiungere più squadre (Under 13, Under 15, Serie C…)." lead: "Registra il club: potrai aggiungere più squadre (Under 13, Under 15, prima squadra…)."
section_club: Società section_club: Società
name_label: Nome società / club name_label: Nome società / club
name_placeholder: "es. Crazy Volley Rozzano" name_placeholder: "es. Team MLTV"
section_first_team: Prima squadra section_first_team: Prima squadra
default_first_team_name: Prima squadra default_first_team_name: Prima squadra
first_team_name_label: Nome squadra first_team_name_label: Nome squadra
@@ -164,7 +167,7 @@ it:
heading: Nuova squadra heading: Nuova squadra
club_label: "Società:" club_label: "Società:"
name_label: Nome squadra name_label: Nome squadra
name_placeholder: "es. Under 13, Serie C" name_placeholder: "es. U15 maschile"
branding_legend: Override branding (opzionale) branding_legend: Override branding (opzionale)
submit: Aggiungi squadra submit: Aggiungi squadra
invite: invite:
@@ -240,7 +243,7 @@ it:
matches: matches:
back_to_list: "← Elenco partite" back_to_list: "← Elenco partite"
opponent_label: Avversario opponent_label: Avversario
opponent_placeholder: "es. Volley Milano" opponent_placeholder: "es. Squadra ospite"
location_label: "Luogo (opzionale)" location_label: "Luogo (opzionale)"
location_placeholder: "Palestra, città" location_placeholder: "Palestra, città"
datetime_label: Data e ora datetime_label: Data e ora
@@ -367,7 +370,7 @@ it:
legend_hint: "Tutti i campi contrassegnati sono obbligatori per abbonarti a un piano premium e per emettere le fatture. Per le società serve la P.IVA; per le persone fisiche il Codice Fiscale. Serve SDI oppure PEC. I pagamenti restano gestiti in modo sicuro da Stripe." legend_hint: "Tutti i campi contrassegnati sono obbligatori per abbonarti a un piano premium e per emettere le fatture. Per le società serve la P.IVA; per le persone fisiche il Codice Fiscale. Serve SDI oppure PEC. I pagamenti restano gestiti in modo sicuro da Stripe."
entity_type_label: "Tipo intestatario *" entity_type_label: "Tipo intestatario *"
legal_name_label: "Ragione sociale / nome e cognome *" legal_name_label: "Ragione sociale / nome e cognome *"
legal_name_placeholder: "es. ASD Tigers Volley" legal_name_placeholder: "es. Team MLTV"
vat_number_label: "Partita IVA * (società)" vat_number_label: "Partita IVA * (società)"
fiscal_code_label: "Codice Fiscale * (persona fisica)" fiscal_code_label: "Codice Fiscale * (persona fisica)"
fiscal_code_placeholder: RSSMRA80A01H501U fiscal_code_placeholder: RSSMRA80A01H501U
@@ -771,7 +774,7 @@ it:
replay_archive_link: "Live passate — archivio replay" replay_archive_link: "Live passate — archivio replay"
schedule_match_link: "Programma partita" schedule_match_link: "Programma partita"
search_placeholder_club: "Cerca società, squadra, avversario o luogo…" search_placeholder_club: "Cerca società, squadra, avversario o luogo…"
search_placeholder_default: "Es. Crazy Volley, Serie D, avversario…" search_placeholder_default: "Es. Team MLTV, Squadra ospite…"
search_aria_label: "Cerca squadra" search_aria_label: "Cerca squadra"
search_button: "Cerca" search_button: "Cerca"
reset_link: "Azzera" reset_link: "Azzera"
@@ -805,7 +808,7 @@ it:
empty_hero_cta_features: "Scopri come funziona" empty_hero_cta_features: "Scopri come funziona"
demo_aria_label: "Esempio di diretta attiva" demo_aria_label: "Esempio di diretta attiva"
demo_label: "Esempio — così appare una diretta attiva" demo_label: "Esempio — così appare una diretta attiva"
demo_meta: "PalaTigers · Match Live TV" demo_meta: "Pala MLTV · Match Live TV"
demo_sets: "Set 2 · Set vinti 1-0" demo_sets: "Set 2 · Set vinti 1-0"
show: show:
back_to_all: "← Tutte le dirette" back_to_all: "← Tutte le dirette"
@@ -838,7 +841,7 @@ it:
back_to_live: "← Dirette live" back_to_live: "← Dirette live"
title: "Live passate" title: "Live passate"
hint: "Replay pubblici delle società sportive — riguarda le partite già trasmesse." hint: "Replay pubblici delle società sportive — riguarda le partite già trasmesse."
search_placeholder: "Es. Tigers Volley, avversario, società, luogo…" search_placeholder: "Es. Team MLTV, Squadra ospite…"
search_aria_label: "Cerca replay" search_aria_label: "Cerca replay"
search_button: "Cerca" search_button: "Cerca"
reset_link: "Azzera" reset_link: "Azzera"
+20
View File
@@ -0,0 +1,20 @@
it:
demo:
away_team: Squadra ospite
when: "Sabato 12 settembre · 10:30"
en:
demo:
away_team: Team Guest
when: "Saturday 12 September · 10:30"
de:
demo:
away_team: Team Guest
when: "Samstag, 12. September · 10:30"
fr:
demo:
away_team: Team Guest
when: "Samedi 12 septembre · 10:30"
es:
demo:
away_team: Team Guest
when: "Sábado 12 de septiembre · 10:30"
+7
View File
@@ -111,6 +111,13 @@ de:
s3_pricing_link_text: Preise s3_pricing_link_text: Preise
s3_p2_html: "Zahlungen für kostenpflichtige Pakete werden über <strong>Stripe</strong> abgewickelt. Mit Abschluss des Checkouts akzeptieren Sie auch die Zahlungsbedingungen von Stripe. Der Anbieter speichert keine vollständigen Kartendaten." s3_p2_html: "Zahlungen für kostenpflichtige Pakete werden über <strong>Stripe</strong> abgewickelt. Mit Abschluss des Checkouts akzeptieren Sie auch die Zahlungsbedingungen von Stripe. Der Anbieter speichert keine vollständigen Kartendaten."
s3_p3: Verlängerungen, Kündigung und Widerrufsrecht richten sich nach den beim Kauf mitgeteilten Angaben und den geltenden Rechtsvorschriften. s3_p3: Verlängerungen, Kündigung und Widerrufsrecht richten sich nach den beim Kauf mitgeteilten Angaben und den geltenden Rechtsvorschriften.
s3b_title: 3-bis. Garantie „Zufrieden oder Geld zurück“
s3b_p1: Die Pakete Premium Light und Premium Full sind durch eine Garantie „Zufrieden oder Geld zurück“ von 30 Tagen ab dem Aktivierungsdatum des ersten von dem Verein (Kunden) erworbenen Match-Live-TV-Abos abgedeckt.
s3b_p2: Wenn Sie in diesem Zeitraum der Auffassung sind, dass der Dienst den Bedürfnissen des Vereins nicht entspricht, können Sie die Erstattung von 100 % des tatsächlich für dieses erste Abo gezahlten Betrags beantragen. Der erstattete Betrag entspricht der vollständig gezahlten Summe; etwaige Zahlungsgebühren trägt der Anbieter.
s3b_p3_html: "Der Antrag ist innerhalb von 30 Tagen nach der Aktivierung zu stellen, per E-Mail an %{email_link} oder über die auf der Website angegebenen Support-Kanäle, unter Angabe des Vereins und der Abo-Daten."
s3b_p4: Die Erstattung erfolgt, soweit technisch möglich, auf dasselbe Zahlungsmittel, das beim Kauf verwendet wurde. Die Gutschriftzeiten hängen auch vom Zahlungsnetz ab; der Anbieter leitet die Bearbeitung in der Regel innerhalb von 14 Tagen nach Bestätigung des Antrags ein.
s3b_p5: Die Garantie gilt nur einmal, für das erste vom Verein erworbene Match-Live-TV-Abo. Sie verlängert sich nicht automatisch auf spätere Zeiträume und darf nicht wiederholt von demselben Verein durch Anlegen neuer Konten in Anspruch genommen werden.
s3b_p6: Die Garantie soll dem Verein ermöglichen, Match Live TV in den ersten 30 Tagen im normalen Gebrauch zu prüfen. Der Anbieter behält sich vor, ihre Anwendung ausschließlich bei Nutzungen auszuschließen, die diesem Zweck eindeutig fremd sind, etwa einer vorübergehenden Aktivierung allein zur intensiven Abdeckung eines einzelnen Events oder Turniers, gefolgt von einem Erstattungsantrag.
s4_title: 4. Livestreams, Inhalte und Schutz Minderjähriger s4_title: 4. Livestreams, Inhalte und Schutz Minderjähriger
s4_lead1: Verantwortung des Sportvereins s4_lead1: Verantwortung des Sportvereins
s4_p1_html: "Der Verein, der einen Livestream startet, ist verantwortlich für die übertragenen Inhalte (Bilder, Audio, Kommentare) sowie für die Einhaltung der Gesetze, des Verbandsreglements und der erforderlichen Einwilligungen, insbesondere wenn <strong>minderjährige Athleten</strong> gefilmt werden." s4_p1_html: "Der Verein, der einen Livestream startet, ist verantwortlich für die übertragenen Inhalte (Bilder, Audio, Kommentare) sowie für die Einhaltung der Gesetze, des Verbandsreglements und der erforderlichen Einwilligungen, insbesondere wenn <strong>minderjährige Athleten</strong> gefilmt werden."
+7
View File
@@ -111,6 +111,13 @@ en:
s3_pricing_link_text: Pricing s3_pricing_link_text: Pricing
s3_p2_html: "Payments for paid plans are handled by <strong>Stripe</strong>. By completing checkout, you also accept Stripe's terms for payment. The Provider does not store full card details." s3_p2_html: "Payments for paid plans are handled by <strong>Stripe</strong>. By completing checkout, you also accept Stripe's terms for payment. The Provider does not store full card details."
s3_p3: Renewals, cancellation and the right of withdrawal follow what was communicated at the time of purchase and applicable law. s3_p3: Renewals, cancellation and the right of withdrawal follow what was communicated at the time of purchase and applicable law.
s3b_title: 3-bis. Satisfied or refunded guarantee
s3b_p1: Premium Light and Premium Full plans are covered by a “Satisfied or refunded” guarantee lasting 30 days from the activation date of the first Match Live TV subscription purchased by the club (customer).
s3b_p2: If within that period you consider the service is not suitable for the clubs needs, you may request a refund of 100% of the amount actually paid for that first subscription. The refunded amount equals the full sum paid; any payment processing fees remain borne by the Provider.
s3b_p3_html: "The request must be sent within 30 days of activation, by writing to %{email_link} or via the support channels indicated on the site, stating the club and the subscription details."
s3b_p4: The refund is made, where technically possible, to the same payment method used for the purchase. Credit times also depend on the payment network; the Provider normally starts processing within 14 days of confirming the request.
s3b_p5: The guarantee applies only once, to the first Match Live TV subscription purchased by the club. It does not automatically renew for later periods and cannot be used repeatedly by the same club through the creation of new accounts.
s3b_p6: The guarantee is intended to allow the club to evaluate Match Live TV in normal use during the first 30 days. The Provider reserves the right to exclude its application solely in cases of use clearly unrelated to that purpose, such as a temporary activation aimed exclusively at intensive coverage of a single event or tournament, followed by a refund request.
s4_title: 4. Live broadcasts, content and protection of minors s4_title: 4. Live broadcasts, content and protection of minors
s4_lead1: Responsibility of the sports club s4_lead1: Responsibility of the sports club
s4_p1_html: "The club that starts a live broadcast is responsible for the content transmitted (images, audio, comments) and for compliance with the law, federation regulations and the necessary consents, in particular when <strong>minor athletes</strong> are filmed." s4_p1_html: "The club that starts a live broadcast is responsible for the content transmitted (images, audio, comments) and for compliance with the law, federation regulations and the necessary consents, in particular when <strong>minor athletes</strong> are filmed."
+7
View File
@@ -111,6 +111,13 @@ es:
s3_pricing_link_text: Precios s3_pricing_link_text: Precios
s3_p2_html: "Los pagos de los planes de pago se gestionan a través de <strong>Stripe</strong>. Al aceptar el checkout, también aceptas los términos de Stripe para el pago. El Proveedor no almacena los datos completos de la tarjeta." s3_p2_html: "Los pagos de los planes de pago se gestionan a través de <strong>Stripe</strong>. Al aceptar el checkout, también aceptas los términos de Stripe para el pago. El Proveedor no almacena los datos completos de la tarjeta."
s3_p3: Las renovaciones, la baja y el derecho de desistimiento siguen lo comunicado en el momento de la compra y la normativa aplicable. s3_p3: Las renovaciones, la baja y el derecho de desistimiento siguen lo comunicado en el momento de la compra y la normativa aplicable.
s3b_title: 3-bis. Garantía Satisfechos o reembolsados
s3b_p1: Los planes Premium Light y Premium Full están cubiertos por una garantía «Satisfechos o reembolsados» de 30 días desde la fecha de activación de la primera suscripción Match Live TV adquirida por el club (cliente).
s3b_p2: Si en ese periodo consideras que el servicio no se adapta a las necesidades del club, puedes solicitar el reembolso del 100% del importe efectivamente pagado por esa primera suscripción. El importe reembolsado equivale a la suma íntegra abonada; las eventuales comisiones de pago corren a cargo del Proveedor.
s3b_p3_html: "La solicitud debe enviarse dentro de los 30 días desde la activación, escribiendo a %{email_link} o a través de los canales de soporte indicados en el sitio, indicando el club y los datos de la suscripción."
s3b_p4: El reembolso se efectúa, cuando sea técnicamente posible, en el mismo método de pago utilizado en la compra. Los plazos de abono también dependen de la red de pago; el Proveedor suele iniciar el trámite en un plazo de 14 días desde la confirmación de la solicitud.
s3b_p5: La garantía se aplica una sola vez, a la primera suscripción Match Live TV adquirida por el club. No se renueva automáticamente en periodos posteriores y no puede utilizarse de forma reiterada por el mismo club mediante la creación de nuevas cuentas.
s3b_p6: La garantía está pensada para que el club evalúe Match Live TV en un uso normal durante los primeros 30 días. El Proveedor se reserva el derecho de excluir su aplicación únicamente en caso de usos manifiestamente ajenos a esa finalidad, como una activación temporal destinada exclusivamente a la cobertura intensiva de un evento o torneo aislado, seguida de una solicitud de reembolso.
s4_title: 4. Directos, contenidos y protección de menores s4_title: 4. Directos, contenidos y protección de menores
s4_lead1: Responsabilidad del club deportivo s4_lead1: Responsabilidad del club deportivo
s4_p1_html: "El club que inicia un directo es responsable de los contenidos transmitidos (imágenes, audio, comentarios) y del cumplimiento de las leyes, el reglamento federativo y los consentimientos necesarios, en particular cuando se filma a <strong>deportistas menores de edad</strong>." s4_p1_html: "El club que inicia un directo es responsable de los contenidos transmitidos (imágenes, audio, comentarios) y del cumplimiento de las leyes, el reglamento federativo y los consentimientos necesarios, en particular cuando se filma a <strong>deportistas menores de edad</strong>."
+7
View File
@@ -111,6 +111,13 @@ fr:
s3_pricing_link_text: Tarifs s3_pricing_link_text: Tarifs
s3_p2_html: "Les paiements des forfaits payants sont gérés par <strong>Stripe</strong>. En validant le paiement, vous acceptez également les conditions de Stripe pour le paiement. Le Fournisseur ne conserve pas les données complètes de la carte." s3_p2_html: "Les paiements des forfaits payants sont gérés par <strong>Stripe</strong>. En validant le paiement, vous acceptez également les conditions de Stripe pour le paiement. Le Fournisseur ne conserve pas les données complètes de la carte."
s3_p3: Les renouvellements, la résiliation et le droit de rétractation suivent ce qui a été communiqué au moment de l'achat et la réglementation applicable. s3_p3: Les renouvellements, la résiliation et le droit de rétractation suivent ce qui a été communiqué au moment de l'achat et la réglementation applicable.
s3b_title: 3-bis. Garantie Satisfait ou remboursé
s3b_p1: Les forfaits Premium Light et Premium Full sont couverts par une garantie « Satisfait ou remboursé » d'une durée de 30 jours à compter de la date d'activation du premier abonnement Match Live TV acheté par le club (client).
s3b_p2: Si dans ce délai vous estimez que le service ne convient pas aux besoins du club, vous pouvez demander le remboursement de 100 % du montant effectivement payé pour ce premier abonnement. Le montant remboursé correspond à la somme intégralement versée ; d'éventuelles commissions de paiement restent à la charge du Fournisseur.
s3b_p3_html: "La demande doit être envoyée dans les 30 jours suivant l'activation, en écrivant à %{email_link} ou via les canaux d'assistance indiqués sur le site, en précisant le club et les références de l'abonnement."
s3b_p4: Le remboursement est effectué, lorsque c'est techniquement possible, sur le même moyen de paiement que celui utilisé pour l'achat. Les délais de crédit dépendent aussi du réseau de paiement ; le Fournisseur engage en principe le traitement dans les 14 jours suivant la confirmation de la demande.
s3b_p5: La garantie s'applique une seule fois, au premier abonnement Match Live TV acheté par le club. Elle ne se renouvelle pas automatiquement sur les périodes suivantes et ne peut pas être utilisée de manière répétée par le même club via la création de nouveaux comptes.
s3b_p6: La garantie vise à permettre au club d'évaluer Match Live TV dans un usage normal pendant les 30 premiers jours. Le Fournisseur se réserve le droit d'en exclure l'application uniquement en cas d'usages manifestement étrangers à cette finalité, tels qu'une activation temporaire destinée exclusivement à la couverture intensive d'un événement ou d'un tournoi isolé, suivie d'une demande de remboursement.
s4_title: 4. Directs, contenus et protection des mineurs s4_title: 4. Directs, contenus et protection des mineurs
s4_lead1: Responsabilité du club sportif s4_lead1: Responsabilité du club sportif
s4_p1_html: "Le club qui lance un direct est responsable des contenus diffusés (images, audio, commentaires) et de la conformité aux lois, au règlement fédéral et aux consentements nécessaires, en particulier lorsque des <strong>athlètes mineurs</strong> sont filmés." s4_p1_html: "Le club qui lance un direct est responsable des contenus diffusés (images, audio, commentaires) et de la conformité aux lois, au règlement fédéral et aux consentements nécessaires, en particulier lorsque des <strong>athlètes mineurs</strong> sont filmés."

Some files were not shown because too many files have changed in this diff Show More