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
eminuxandCursor b87a0f7bc0 Fix race create sessione su CPX: ready solo dopo MediaMTX.
I nodi cloud restano in provisioning finché :9997 risponde; retry su create_path e 503 retryable se l’ingest è ancora irraggiungibile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 14:06:47 +02:00
206 changed files with 7592 additions and 432 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
@@ -2,6 +2,7 @@ module Api
module V1 module V1
class BaseController < ApplicationController class BaseController < ApplicationController
rescue_from Teams::EntitlementError, with: :render_entitlement_error rescue_from Teams::EntitlementError, with: :render_entitlement_error
rescue_from Streams::IngestUnavailableError, with: :render_ingest_unavailable
rescue_from BrandingAttachments::CoverUploadError, with: :render_cover_upload_error rescue_from BrandingAttachments::CoverUploadError, with: :render_cover_upload_error
rescue_from Youtube::BroadcastService::Error, with: :render_youtube_error rescue_from Youtube::BroadcastService::Error, with: :render_youtube_error
@@ -22,6 +23,13 @@ module Api
}, status: :forbidden }, status: :forbidden
end end
def render_ingest_unavailable(error)
render json: {
error: error.message,
error_code: error.code
}, status: :service_unavailable
end
def render_cover_upload_error(error) def render_cover_upload_error(error)
render json: { error: error.message, error_code: "cover_upload_invalid" }, status: :unprocessable_entity render json: { error: error.message, error_code: "cover_upload_invalid" }, status: :unprocessable_entity
end end
@@ -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
+36 -1
View File
@@ -4,6 +4,9 @@ module Mediamtx
class Client class Client
class Error < StandardError; end class Error < StandardError; end
CREATE_PATH_RETRIES = -> { ENV.fetch("MEDIAMTX_CREATE_RETRIES", "5").to_i }
CREATE_PATH_RETRY_BASE_SECS = -> { ENV.fetch("MEDIAMTX_CREATE_RETRY_BASE_SECS", "0.4").to_f }
def self.for_session(session) def self.for_session(session)
new(base_url: session.mediamtx_api_base_url) new(base_url: session.mediamtx_api_base_url)
end end
@@ -19,6 +22,19 @@ module Mediamtx
attr_reader :base_url attr_reader :base_url
# Health probe for CPX readiness (GET /v3/paths/list).
def reachable?(timeout: 2)
conn = Faraday.new(url: @base_url) do |f|
f.adapter Faraday.default_adapter
f.options.open_timeout = timeout
f.options.timeout = timeout
end
response = conn.get("/v3/paths/list")
response.success?
rescue Faraday::Error
false
end
def create_path(session) def create_path(session)
path = session.mediamtx_path_name path = session.mediamtx_path_name
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe # record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe
@@ -30,7 +46,9 @@ module Mediamtx
body[:alwaysAvailable] = true body[:alwaysAvailable] = true
body[:alwaysAvailableFile] = slate_file_path(session) body[:alwaysAvailableFile] = slate_file_path(session)
# YouTube: telefono → MediaMTX; relay copy verso RTMPS in sidekiq. # YouTube: telefono → MediaMTX; relay copy verso RTMPS in sidekiq.
response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body) response = with_connection_retries("create_path #{path}") do
@conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body)
end
unless response.success? unless response.success?
err = response.body.is_a?(Hash) ? response.body["error"] : response.body err = response.body.is_a?(Hash) ? response.body["error"] : response.body
raise Error, "MediaMTX path create failed: #{response.status} #{err}" raise Error, "MediaMTX path create failed: #{response.status} #{err}"
@@ -161,6 +179,23 @@ module Mediamtx
private private
def with_connection_retries(label)
attempts = [CREATE_PATH_RETRIES.call, 1].max
base = CREATE_PATH_RETRY_BASE_SECS.call
try = 0
begin
try += 1
yield
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
raise if try >= attempts
sleep_secs = base * (2**(try - 1))
Rails.logger.warn("[Mediamtx::Client] #{label} retry #{try}/#{attempts} after #{e.class}: #{e.message} (sleep #{sleep_secs}s)")
sleep(sleep_secs)
retry
end
end
def recording_body(session, enabled:) def recording_body(session, enabled:)
ent = session.match.team.entitlements ent = session.match.team.entitlements
can_record = ent.recording_enabled_for_mediamtx? can_record = ent.recording_enabled_for_mediamtx?
@@ -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
+38 -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,22 +47,56 @@ 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
raise Streams::IngestUnavailableError, "Ingest temporaneamente non disponibile (#{e.class})"
rescue Mediamtx::Client::Error => e
if e.message.to_s.match?(/failed to open|Connection refused|Timeout|timed out/i)
raise Streams::IngestUnavailableError, e.message
end
raise
end end
private private
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")
@@ -136,6 +136,8 @@ module Streams
def reconcile! def reconcile!
actions = [] actions = []
actions.concat(promote_provisioning_nodes!)
actions.concat(reclaim_stuck_provisioning!)
m = self.class.metrics m = self.class.metrics
if need_capacity?(m) && can_provision?(m) if need_capacity?(m) && can_provision?(m)
@@ -170,6 +172,30 @@ module Streams
private private
def promote_provisioning_nodes!
actions = []
StreamNode.where(status: "provisioning").find_each do |node|
next unless Streams::NodeHealth.promote_if_healthy!(node)
actions << :"ready_#{node.slug}"
Rails.logger.info("[Streams::Autoscaler] promoted #{node.slug} to ready")
end
actions
end
def reclaim_stuck_provisioning!
actions = []
stuck_after = ENV.fetch("STREAM_NODE_PROVISIONING_STUCK_MINUTES", "15").to_i.minutes.ago
StreamNode.where(status: "provisioning").where("created_at < ?", stuck_after).find_each do |node|
@provisioner.decommission!(node)
actions << :"reclaim_#{node.slug}"
Rails.logger.warn("[Streams::Autoscaler] decommissioned stuck provisioning #{node.slug}")
rescue NodeProvisioner::BusyError, NodeProvisioner::Error => e
Rails.logger.warn("[Streams::Autoscaler] reclaim #{node.slug}: #{e.message}")
end
actions
end
def need_capacity?(m) def need_capacity?(m)
m[:free_slots] <= self.class.soft_free_slots m[:free_slots] <= self.class.soft_free_slots
end end
@@ -0,0 +1,13 @@
# frozen_string_literal: true
module Streams
# Ingest MediaMTX non raggiungibile (es. CPX ancora in bootstrap). API → 503 retryable.
class IngestUnavailableError < StandardError
attr_reader :code
def initialize(message = "Ingest temporaneamente non disponibile", code: "stream_ingest_unavailable")
super(message)
@code = code
end
end
end
@@ -0,0 +1,27 @@
# frozen_string_literal: true
module Streams
# Probe reachability of MediaMTX API on a stream node (:9997).
class NodeHealth
def self.mediamtx_up?(node, timeout: 2)
new(node, timeout: timeout).mediamtx_up?
end
def self.promote_if_healthy!(node, timeout: 2)
return false unless node.status == "provisioning"
return false unless mediamtx_up?(node, timeout: timeout)
node.update!(status: "ready", last_health_at: Time.current)
true
end
def initialize(node, timeout: 2)
@node = node
@timeout = timeout
end
def mediamtx_up?
Mediamtx::Client.new(base_url: @node.api_base_url).reachable?(timeout: @timeout)
end
end
end
@@ -80,11 +80,15 @@ module Streams
urls = urls_for_node(role: role, home: home, hostname: hostname, simulated: simulated, urls = urls_for_node(role: role, home: home, hostname: hostname, simulated: simulated,
private_ip: private_ip, public_ip: ip, use_node_hostname: use_node_hostname) private_ip: private_ip, public_ip: ip, use_node_hostname: use_node_hostname)
StreamNode.create!( # Simulated/lab che riusa MediaMTX home: subito ready. Cloud reale: provisioning
# finché :9997 risponde (evita allocate → Faraday Connection refused → 500).
initial_status = simulated ? "ready" : "provisioning"
node = StreamNode.create!(
slug: slug, slug: slug,
hostname: hostname, hostname: hostname,
role: role, role: role,
status: "ready", status: initial_status,
provider: provider_name_for(cloud, role: role), provider: provider_name_for(cloud, role: role),
provider_instance_id: instance.id, provider_instance_id: instance.id,
rtmp_base_url: urls.fetch(:rtmp_base_url), rtmp_base_url: urls.fetch(:rtmp_base_url),
@@ -102,6 +106,34 @@ module Streams
"cloud_raw" => instance.raw "cloud_raw" => instance.raw
} }
) )
wait_until_mediamtx_ready!(node) unless simulated
node.reload
end
def wait_until_mediamtx_ready!(node, timeout: nil, interval: nil)
timeout ||= ENV.fetch("STREAM_NODE_READY_TIMEOUT_SECS", "180").to_i
interval ||= ENV.fetch("STREAM_NODE_READY_POLL_SECS", "3").to_f
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
loop do
if Streams::NodeHealth.promote_if_healthy!(node)
Rails.logger.info("[Streams::NodeProvisioner] #{node.slug} MediaMTX ready")
return node
end
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
if remaining <= 0
Rails.logger.warn(
"[Streams::NodeProvisioner] #{node.slug} ancora provisioning dopo #{timeout}s " \
"(api=#{node.api_base_url}); autoscaler continuerà a promuovere"
)
return node
end
sleep([interval, remaining].min)
node.reload
end
end end
def urls_for_node(role:, home:, hostname:, simulated:, private_ip:, public_ip: nil, use_node_hostname:) def urls_for_node(role:, home:, hostname:, simulated:, private_ip:, public_ip: nil, use_node_hostname:)
@@ -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"

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