Compare commits

...
Author SHA1 Message Date
eminuxandCursor 2db6541c91 Fix 500 in cancellazione partita con recording collegato.
Distruggendo la sessione ora si elimina anche il recording, evitando la violazione FK su recordings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 08:09:34 +02:00
eminuxandCursor 5d4bd8544d Aggiunge le cover slate custom generate in ambiente locale.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 18:21:48 +02:00
eminuxandCursor cc95fcacbf Permette all’admin di forzare anagrafica, fiscali, owner, staff e inviti.
Ops può correggere dati clienti errati (es. email titolare) dalla scheda società senza interventi manuali sul DB.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 18:18:18 +02:00
eminuxandCursor d7dd744c32 Mostra in admin anagrafica e dati fiscali delle società.
Così Ops può emettere fattura dalla scheda club senza cercare il profilo solo tra i pagamenti pending.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 16:17:20 +02:00
eminuxandCursor adfea01cdb Permette di filtrare il grafico analytics su una singola pagina.
Aggiunge il filtro Pagina (grafico) e il link Grafico in tabella, mantenendo Da/A/Device.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 22:14:35 +02:00
eminuxandCursor 81e7e3c9af Semplifica il grafico analytics: solo pageview e toggle nascondi.
Chiarisce che il totale è su tutte le pagine (filtri Da/A/Device), distinto dal dettaglio per path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 22:07:34 +02:00
Emiliano FrascaroandCursor 53ad26fdbe Alza iOS a build 38 per il rilascio da produzione.
La 37 era stata caricata da collaudo: App Store Connect richiede un numero di build nuovo dal branch di produzione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 21:58:09 +02:00
Emiliano FrascaroandCursor db0e842ca7 Prepara iOS 2.0.13 build 37 per lo Store con icona chiara e test del calendario.
Apple ha chiuso il treno 2.0.12: servono versione nuova, monogramma visibile sul nero e copertura dei casi dopo l'orario previsto.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 21:57:56 +02:00
eminuxandCursor 2e67e590d6 Aggiunge grafico andamento visite in admin Analytics.
Serie giornaliera di pageview/click/movimenti nel periodo filtrato, sopra la tabella pagine.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 21:52:29 +02:00
eminuxandCursor bb342ead79 Bump Android a 2.0.13-native (versionCode 34) per Play Store.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 08:48:08 +02:00
eminuxandCursor ce81bb5789 Nasconde dall'hub le partite con risultato già inserito.
Restano avviabili solo le gare in ritardo senza punteggio, non quelle già terminate dal portale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 10:13:10 +02:00
eminuxandCursor 24c6ce4da0 Lascia trasmissibili le partite anche dopo l'orario previsto.
Se l'operatore è in ritardo deve ancora trovare la gara nell'app: restano in hub per tutta la giornata e, nei tornei aperti, fino alla fine dell'evento.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 10:07:23 +02:00
eminuxandCursor c3ef8e91a6 Rende usabili calendario, overlay ospite e stato diretta del cartellone.
Il calendario non deve più scorrere in orizzontale, il logo a destra non copre il nome e una diretta YouTube o in collegamento compare come In diretta.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 09:55:08 +02:00
eminuxandCursor b09e83db09 Allinea stato e titolo replay del tabellone al risultato reale.
Una gara con punteggio non resta più «In programma» solo perché l'orario è futuro, e i replay usano le squadre del match invece della squadra di trasmissione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 09:15:10 +02:00
eminux 097e55df79 Merge branch 'feature/tornei' into collaudo 2026-09-05 15:06:24 +02:00
eminuxandCursor a1f4c24a43 Aggiunge i tornei con hub, pagina pubblica e tabellone per semifinali e finali.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 15:05:57 +02:00
eminuxandCursor 7c7b2bf14c Alza soft capacity fase A e aggiunge quiet hours CPX notturne.
Home a 4 e cloud a 6 con MAX_NODES=12 (~76 soft); di notte (02–07 Europe/Rome) niente scale-out/warm-spare e sweeper che chiude i CPX idle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 09:48:50 +02:00
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
353 changed files with 16539 additions and 564 deletions
@@ -0,0 +1,26 @@
---
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 (qualità-first 2026-09) |
|-----------|-------------------------------|
| `STREAM_NODE_HOME_MAX_PUBLISHERS` | Soft home (**4** — scale-out anticipato) |
| `STREAM_CLOUD_MAX_PUBLISHERS` | Soft per CPX (**6**; 8 solo dopo misure QoS) |
| `STREAM_AUTOSCALE_MAX_NODES` | Quanti CPX in parallelo (fase A **12** → B 22 → C 33) |
| `STREAM_AUTOSCALE_SOFT_FREE_SLOTS` | Anticipo scale-out |
| `STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR` | Gate 24/7 (fase A **150**) |
| `STREAM_AUTOSCALE_QUIET_HOURS` | `02:00-07:00` Europe/Rome: no scale-out/warm-spare; sweeper chiude CPX idle |
| Piano `concurrent_streams_limit` | Tetto **per club** (Premium Full = 10): indipendente dal cluster |
Capienza cluster soft ≈ `home_max + max_overflow × cloud_max` (fase A: 4+12×6 = **76**; fase C: 4+33×6 = **202**).
Quiet hours: `Streams::NightCloudSweeper` + blocco provision in `Streams::Autoscaler`. Nodi cloud con sessioni attive di notte → alert Ops, non spegnere.
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. Densità 8 solo dopo misure.
@@ -6,8 +6,10 @@ module Admin
@filters = {
from: parse_date(params[:from]) || 7.days.ago.to_date,
to: parse_date(params[:to]) || Time.zone.today,
device: params[:device].presence
device: params[:device].presence,
chart_path: params[:chart_path].presence
}
@analytics_preview_active = analytics_preview_active?
scope = AnalyticsPageStat.where(day: @filters[:from]..@filters[:to])
scope = scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
@@ -35,21 +37,47 @@ module Admin
max_scroll: max_scroll_by_path[path].to_i
}
end.sort_by { |r| [-r[:pageviews], -r[:moves], -r[:clicks], r[:page_path]] }
@chart_path_options = @pages.map { |row| row[:page_path] }
if @filters[:chart_path].present? && @chart_path_options.exclude?(@filters[:chart_path])
@filters[:chart_path] = nil
end
trend_scope = scope
trend_scope = trend_scope.where(page_path: @filters[:chart_path]) if @filters[:chart_path].present?
pageviews_by_day = trend_scope.group(:day).sum(:pageview_count)
@trend = (@filters[:from]..@filters[:to]).map do |day|
{
day: day.iso8601,
pageviews: pageviews_by_day[day].to_i
}
end
@trend_totals = {
pageviews: @trend.sum { |r| r[:pageviews] }
}
end
def show
@page_path = params[:page_path].to_s
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 = {
from: parse_date(params[:from]) || 7.days.ago.to_date,
to: parse_date(params[:to]) || Time.zone.today,
device: params[:device].presence,
from: from,
to: to,
device: device,
layer: params[:layer].to_s
}
cells = AnalyticsPageCell.where(page_path: @page_path, day: @filters[:from]..@filters[:to])
cells = cells.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
cells = AnalyticsPageCell.where(
page_path: @page_path,
day: @filters[:from]..@filters[:to],
device: @filters[:device]
)
@click_total = cells.sum(:click_count)
@move_total = cells.sum(:move_count)
@@ -67,8 +95,11 @@ module Admin
@max_weight = @cells.values.max.to_i
@total_points = @cells.values.sum
stats = AnalyticsPageStat.where(page_path: @page_path, day: @filters[:from]..@filters[:to])
stats = stats.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
stats = AnalyticsPageStat.where(
page_path: @page_path,
day: @filters[:from]..@filters[:to],
device: @filters[:device]
)
@pageviews = stats.sum(:pageview_count)
@scroll_samples = stats.sum(:scroll_samples)
@scroll_sum = stats.sum(:scroll_sum_pct)
@@ -78,8 +109,30 @@ module Admin
@snapshot = find_snapshot(@page_path, @filters[:device])
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
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)
return nil if value.blank?
@@ -88,14 +141,43 @@ module Admin
nil
end
def find_snapshot(page_path, device)
scope = AnalyticsPageSnapshot.where(page_path: page_path)
if device.present? && AnalyticsEvent::DEVICES.include?(device)
snap = scope.find_by(device: device)
return snap if snap&.image&.attached?
def device_tab_stats(page_path, from, to)
cell_totals = AnalyticsPageCell.where(page_path: page_path, day: from..to)
.group(:device)
.pluck(
: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
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
@@ -1,17 +1,34 @@
module Admin
class ClubsController < BaseController
before_action :set_club, only: %i[show grant_comped revoke_comped set_quote revoke_quote]
before_action :set_club, only: %i[show edit update grant_comped revoke_comped set_quote revoke_quote]
def index
@clubs = Club.includes(:teams, :billing_quote, subscription: %i[plan admin_comped_by])
@clubs = Club.includes(:teams, :billing_quote, { club_memberships: :user }, subscription: %i[plan admin_comped_by])
.order(:name)
end
def show
@subscription = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active")
@plans = Plan.ordered.reject { |p| p.slug == "free" }
@teams = @club.teams.order(:name)
@quote = @club.active_billing_quote
load_show_context
end
def edit
load_edit_context
end
def update
Admin::UpdateClubData.call(
club: @club,
club_attrs: club_update_params,
owner_attrs: owner_params,
staff_attrs: staff_params,
invitation_attrs: invitation_params,
team_attrs: team_params
)
redirect_to admin_club_path(@club), notice: t("admin.flash.club_updated", club: @club.name)
rescue Admin::UpdateClubData::Error, ActiveRecord::RecordInvalid => e
flash.now[:alert] = e.message
load_edit_context
render :edit, status: :unprocessable_entity
end
def grant_comped
@@ -62,7 +79,76 @@ module Admin
private
def set_club
@club = Club.find(params[:id])
@club = Club.includes(club_memberships: :user).find(params[:id])
end
def load_show_context
@subscription = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active")
@plans = Plan.ordered.reject { |p| p.slug == "free" }
@teams = @club.teams.order(:name)
@quote = @club.active_billing_quote
@concurrency_violations = StreamConcurrencyViolation.for_club(@club.id).recent.limit(20)
end
def load_edit_context
@teams = @club.teams.includes(:user_teams, :team_invitations).order(:name)
@owner = @club.owner
@staff_users = User.joins(:user_teams)
.where(user_teams: { team_id: @club.teams.select(:id) })
.distinct
.order(:email)
.to_a
@pending_invitations = TeamInvitation.pending
.where(team_id: @club.teams.select(:id))
.includes(:team)
.order(:email)
@sport_options = Sports::Catalog.as_api_list.map { |entry| [entry[:label], entry[:key]] }
end
def club_update_params
params.require(:club).permit(
:name, :sport, :logo_url, :primary_color, :secondary_color,
:billing_entity_type, :billing_legal_name, :billing_vat_number, :billing_fiscal_code,
:billing_email, :billing_phone, :billing_address_line, :billing_city, :billing_province,
:billing_postal_code, :billing_country, :billing_recipient_code, :billing_pec
)
end
def owner_params
params.fetch(:owner, {}).permit(:name, :email).to_h.symbolize_keys
end
def staff_params
raw = params[:staff_users]
return {} if raw.blank?
raw.permit!.to_h.each_with_object({}) do |(user_id, attrs), acc|
next unless attrs.is_a?(Hash)
acc[user_id] = attrs.slice("name", "email").symbolize_keys
end
end
def invitation_params
raw = params[:invitations]
return {} if raw.blank?
raw.permit!.to_h.each_with_object({}) do |(invitation_id, attrs), acc|
next unless attrs.is_a?(Hash)
acc[invitation_id] = attrs.slice("email").symbolize_keys
end
end
def team_params
raw = params[:teams]
return {} if raw.blank?
raw.permit!.to_h.each_with_object({}) do |(team_id, attrs), acc|
next unless attrs.is_a?(Hash)
acc[team_id] = attrs.slice("name", "sport").symbolize_keys
end
end
def redirect_back_or_club(notice: nil, alert: nil)
@@ -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)
.order(started_at: :desc)
@teams = Team.includes(:matches).order(:name).limit(8)
@recent_concurrency_violations = StreamConcurrencyViolation.recent.limit(8)
@concurrency_violation_lookback = StreamConcurrencyViolation.lookback.count
end
def metrics
@@ -18,6 +18,7 @@ module Admin
.find(params[:id])
@events = @session.stream_events.recent.limit(100)
@club = @session.match.team.club
@concurrency_violations = StreamConcurrencyViolation.for_session(@session.id).recent.limit(20)
end
def stop
@@ -79,14 +80,21 @@ module Admin
if @filters[:status].present? && StreamSession::STATUSES.include?(@filters[:status])
scope = scope.where(stream_sessions: { status: @filters[:status] })
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] })
end
if @filters[:club_id].present?
scope = scope.where(teams: { club_id: @filters[:club_id] })
end
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
if (from_time = parse_filter_date(@filters[:from], end_of_day: false))
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
class EventsController < ActionController::API
include ActionController::Cookies
def create
if analytics_suppressed?
return render json: { accepted: 0, rejected: 0, suppressed: true }, status: :accepted
end
payload = parse_payload
result = Analytics::Ingest.new(events: payload, remote_ip: request.remote_ip).call
@@ -15,6 +21,10 @@ module Analytics
private
def analytics_suppressed?
Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME])
end
def parse_payload
body = request.request_parameters
return body["events"] if body.is_a?(Hash) && body["events"].is_a?(Array)
@@ -2,7 +2,13 @@
module Analytics
class SnapshotsController < ActionController::API
include ActionController::Cookies
def create
if analytics_suppressed?
return render json: { ok: true, skipped: true, suppressed: true }, status: :accepted
end
result = Analytics::SnapshotIngest.new(
path: params[:path] || params[:page_path],
device: params[:device],
@@ -17,5 +23,11 @@ module Analytics
render json: { ok: true, skipped: result.skipped }, status: :accepted
end
private
def analytics_suppressed?
Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME])
end
end
end
@@ -2,6 +2,7 @@ module Api
module V1
class BaseController < ApplicationController
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 Youtube::BroadcastService::Error, with: :render_youtube_error
@@ -22,6 +23,13 @@ module Api
}, status: :forbidden
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)
render json: { error: error.message, error_code: "cover_upload_invalid" }, status: :unprocessable_entity
end
@@ -9,34 +9,23 @@ module Api
return render json: { valid: false, error: "Invito non valido o scaduto" }, status: :not_found
end
render json: {
valid: true,
email: invitation.email,
team_id: invitation.team_id,
team_name: invitation.team.name,
club_name: invitation.team.club.name,
staff_kind: invitation.staff_kind,
expires_at: invitation.expires_at
}
render json: invitation_json(invitation).merge(valid: true)
end
def accept
invitation = find_pending_invitation
return render json: { error: "Invito non valido o scaduto" }, status: :not_found unless invitation
if current_user.email.downcase != invitation.email.downcase
email = invitation.email
if current_user.email.downcase != email.downcase
return render json: {
error: "Questo invito è per #{invitation.email}. Accedi con quell'indirizzo email.",
invited_email: invitation.email
error: "Questo invito è per #{email}. Accedi con quell'indirizzo email.",
invited_email: email
}, status: :unprocessable_entity
end
invitation.accept!(current_user)
render json: {
team_id: invitation.team_id,
team_name: invitation.team.name,
message: "Sei entrato in #{invitation.team.name} come responsabile trasmissione."
}
render json: invitation_json(invitation).merge(message: accept_message(invitation))
end
private
@@ -45,7 +34,42 @@ module Api
token = params[:token].to_s
return nil if token.blank?
TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(token))
digest = Digest::SHA256.hexdigest(token)
TeamInvitation.pending.find_by(token_digest: digest) ||
TournamentBroadcastInvitation.pending.find_by(token_digest: digest)
end
def invitation_json(invitation)
if invitation.is_a?(TournamentBroadcastInvitation)
tournament = invitation.tournament
{
email: invitation.email,
tournament_id: tournament.id,
tournament_name: tournament.name,
club_name: tournament.club.name,
team_id: tournament.broadcast_team_id,
team_name: tournament.name,
staff_kind: "transmission",
expires_at: invitation.expires_at
}
else
{
email: invitation.email,
team_id: invitation.team_id,
team_name: invitation.team.name,
club_name: invitation.team.club.name,
staff_kind: invitation.staff_kind,
expires_at: invitation.expires_at
}
end
end
def accept_message(invitation)
if invitation.is_a?(TournamentBroadcastInvitation)
"Sei incaricato delle dirette per #{invitation.tournament.name}."
else
"Sei entrato in #{invitation.team.name} come responsabile trasmissione."
end
end
end
end
@@ -10,13 +10,22 @@ module Api
def index
matches = @team.matches
.includes(:team, :stream_sessions)
.includes(:team, :stream_sessions, :home_participant, :away_participant, :tournament)
.order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :desc)
.select(&:coach_hub_visible?)
render json: matches.map { |m| match_json(m) }
unless current_user.club_admin?(@team.club)
if @team.tournament_broadcast?
assigned_ids = current_user.tournament_broadcast_assignments.select(:match_id)
matches = matches.where(id: assigned_ids)
end
end
render json: matches.select(&:coach_hub_visible?).map { |m| match_json(m) }
end
def create
if @team.tournament_broadcast?
return render json: { error: "Le partite del torneo si programmano dal sito web." }, status: :unprocessable_entity
end
attrs = match_params.to_h
attrs["sport_key"] = @team.sport_key
normalize_scoring_rules!(attrs)
@@ -112,9 +121,12 @@ module Api
{
id: match.id,
team_id: match.team_id,
team_name: team.name,
opponent_name: match.opponent_name,
location: match.location,
team_name: match.home_display_name,
opponent_name: match.away_display_name,
location: match.court_or_location,
court: match.court,
tournament_id: match.tournament_id,
tournament_name: match.tournament&.name,
scheduled_at: match.scheduled_at,
sport: match.sport_key,
sport_key: match.sport_key,
@@ -126,11 +138,11 @@ module Api
scoring_rules: match.scoring_rules.presence,
effective_scoring_rules: match.effective_scoring_rules,
category: match.category,
home_primary_color: team.effective_primary_color,
home_secondary_color: team.effective_secondary_color,
home_logo_url: api_absolute_url(team.effective_logo_url),
opponent_primary_color: match.effective_opponent_primary_color,
opponent_logo_url: api_absolute_url(match.opponent_logo_url),
home_primary_color: match.home_participant&.effective_primary_color || team.effective_primary_color,
home_secondary_color: match.home_participant&.effective_secondary_color || team.effective_secondary_color,
home_logo_url: api_absolute_url(match.home_participant&.effective_logo_url || team.effective_logo_url),
opponent_primary_color: match.away_participant&.effective_primary_color || match.effective_opponent_primary_color,
opponent_logo_url: api_absolute_url(match.away_participant&.effective_logo_url || match.opponent_logo_url),
**match_cover_json(match),
active_session_id: active&.id,
active_session_status: active&.status,
@@ -97,10 +97,11 @@ module Api
replay_url: recording.replay_url,
playback_url: recording.playback_stream_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_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_label: recording.source_platform_label,
expires_at: recording.expires_at,
@@ -6,6 +6,10 @@ module Api
def create
team_ids = current_user.streamable_teams.map(&:id)
match = Match.where(team_id: team_ids).find(params[:match_id])
unless current_user.can_broadcast_match?(match)
return render json: { error: "Non sei incaricato di trasmettere questa partita" }, status: :forbidden
end
session = Sessions::Create.new(user: current_user, match: match, params: session_params).call
render json: session_json(session), status: :created
end
@@ -77,6 +81,7 @@ module Api
thermal_state: sanitized_thermal_state,
last_seen_at: Time.current
)
Sessions::ApplyClientInfo.call(@session, params[:client]) if params[:client].present?
sync_publisher_when_streaming!(params[:fps].to_f)
SessionChannel.broadcast_message(@session, state.as_cable_payload)
head :no_content
@@ -180,7 +185,12 @@ module Api
end
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
def score_sync_params
@@ -110,6 +110,9 @@ module Api
secondary_color: team.effective_secondary_color,
club_id: team.club_id,
club_name: team.club.name,
tournament_broadcast: team.tournament_broadcast?,
tournament_id: team.broadcast_tournament&.id,
tournament_name: team.broadcast_tournament&.name,
youtube_connected: yt.connected?,
youtube_selectable: yt.selectable?,
youtube_channel_title: yt.channel_title,
@@ -171,12 +174,13 @@ module Api
replay_url: recording.replay_url,
playback_url: recording.playback_stream_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,
views_label: recording.views_label,
youtube_video_id: recording.youtube_video_id,
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_label: recording.source_platform_label,
expires_at: recording.expires_at,
@@ -56,9 +56,9 @@ module Public
def show
require_club_owner!(@club)
apply_checkout_flash!
@entitlements_team = @club.teams.first
@entitlements_team = @club.teams.visible.first
@entitlements = @entitlements_team&.entitlements
@teams = @club.teams.order(:name)
@teams = @club.teams.visible.order(:name)
@replay_stats = Recordings::ClubStats.new(@club).call if @entitlements&.can_access_recordings?
end
@@ -2,26 +2,40 @@ module Public
class InvitationsController < WebBaseController
def show
@token = params[:token]
@invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(@token.to_s))
unless @invitation
digest = Digest::SHA256.hexdigest(@token.to_s)
@invitation = TeamInvitation.pending.find_by(token_digest: digest)
@tournament_invitation = TournamentBroadcastInvitation.pending.find_by(token_digest: digest) unless @invitation
unless @invitation || @tournament_invitation
redirect_to public_pricing_path, alert: t("flash.invitations.invalid_or_expired")
end
end
def accept
invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(params[:token].to_s))
return redirect_to public_pricing_path, alert: t("flash.invitations.invalid") unless invitation
token = params[:token].to_s
digest = Digest::SHA256.hexdigest(token)
invitation = TeamInvitation.pending.find_by(token_digest: digest)
tournament_invitation = TournamentBroadcastInvitation.pending.find_by(token_digest: digest) unless invitation
if logged_in?
if current_user.email.downcase != invitation.email.downcase
redirect_to public_pricing_path, alert: t("flash.invitations.wrong_email", email: invitation.email)
return
end
unless invitation || tournament_invitation
return redirect_to public_pricing_path, alert: t("flash.invitations.invalid")
end
target_email = (invitation || tournament_invitation).email
unless logged_in?
session[:pending_invite_token] = token
return redirect_to public_signup_path, notice: t("flash.invitations.signup_to_accept", email: target_email)
end
if current_user.email.downcase != target_email.downcase
return redirect_to public_pricing_path, alert: t("flash.invitations.wrong_email", email: target_email)
end
if invitation
invitation.accept!(current_user)
redirect_to public_team_details_path(invitation.team), notice: t("flash.invitations.joined_team")
else
session[:pending_invite_token] = params[:token]
redirect_to public_signup_path, notice: t("flash.invitations.signup_to_accept", email: invitation.email)
tournament_invitation.accept!(current_user)
redirect_to public_account_path, notice: t("flash.invitations.joined_tournament")
end
end
end
@@ -33,7 +33,11 @@ module Public
.order(scheduled_at: :asc)
.limit(50)
@online_paths = Mediamtx::Client.new.online_path_names
tournaments = Tournament.listed_on_live.with_attached_logo_file.includes(:club).search_public(@query)
tournaments = tournaments.where(club_id: @club.id) if @club
@public_tournaments = tournaments.order(starts_on: :desc, name: :asc).limit(12)
@online_paths = fetch_online_paths
return unless logged_in? && @club
@@ -83,5 +87,13 @@ module Public
score: session.score_state&.as_cable_payload
}
end
private
def fetch_online_paths
Mediamtx::Client.new.online_path_names
rescue Mediamtx::Client::Error, Errno::ECONNREFUSED, SocketError
[]
end
end
end
@@ -17,11 +17,18 @@ module Public
session[:user_id] = @user.id
if session[:pending_invite_token].present?
token = session.delete(:pending_invite_token)
invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(token))
digest = Digest::SHA256.hexdigest(token)
invitation = TeamInvitation.pending.find_by(token_digest: digest)
if invitation && invitation.email.downcase == @user.email.downcase
invitation.accept!(@user)
return redirect_to public_team_details_path(invitation.team), notice: t("flash.registrations.welcome_to_team")
end
tournament_invitation = TournamentBroadcastInvitation.pending.find_by(token_digest: digest)
if tournament_invitation && tournament_invitation.email.downcase == @user.email.downcase
tournament_invitation.accept!(@user)
return redirect_to public_account_path, notice: t("flash.invitations.joined_tournament")
end
end
redirect_to public_new_club_path, notice: t("flash.registrations.account_created")
else
@@ -45,6 +45,8 @@ module Public
PRIVATE_WEB_CONTROLLERS = %w[
accounts clubs teams club_recordings club_billing
club_matches matches team_roster_members
tournaments tournament_participants tournament_groups
tournament_matches tournament_invitations tournament_results
].freeze
def load_site_announcements
@@ -11,6 +11,7 @@ module Public
{ loc: "#{base}/contatti", changefreq: "monthly", priority: "0.6" },
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
{ loc: "#{base}/tornei", changefreq: "daily", priority: "0.8" },
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
{ loc: "#{base}/support", changefreq: "yearly", priority: "0.3" },
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
@@ -25,6 +26,14 @@ module Public
}
end
Tournament.visible_to_public.find_each do |tournament|
@entries << {
loc: "#{base}/tornei/#{tournament.slug}",
changefreq: "daily",
priority: "0.75"
}
end
respond_to do |format|
format.xml { render layout: false }
end
@@ -27,6 +27,11 @@ module Public
end
def details
if @team.tournament_broadcast? && @team.broadcast_tournament
redirect_to public_tournament_path(@team.broadcast_tournament)
return
end
load_team_details!
render :details
end
@@ -0,0 +1,29 @@
module Public
class TournamentAssignmentsController < WebBaseController
before_action :require_login!
before_action :set_tournament
def destroy
Tournaments::Entitlements.new(@club).assert_writable!
unless @tournament.writable?
redirect_to public_tournament_path(@tournament, tab: "dirette"),
alert: t("flash.tournaments.archived_locked")
return
end
assignment = @tournament.broadcast_assignments.find(params[:assignment_id])
Tournaments::RevokeAssignment.call(assignment: assignment)
redirect_to public_tournament_path(@tournament, tab: "dirette"),
notice: t("flash.tournaments.assignment_revoked")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
private
def set_tournament
@tournament = Tournament.find(params[:id])
@club = @tournament.club
require_club_owner!(@club)
end
end
end
@@ -0,0 +1,32 @@
module Public
class TournamentGroupsController < WebBaseController
before_action :require_login!
before_action :set_tournament
def create
Tournaments::Entitlements.new(@club).assert_writable!
position = @tournament.groups.maximum(:position).to_i + 1
name = params.dig(:tournament_group, :name).presence || "Girone #{('A'.ord + position).chr}"
@tournament.groups.create!(name: name, position: position)
redirect_to public_tournament_path(@tournament, tab: "struttura"), notice: t("flash.tournaments.group_added")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
def destroy
Tournaments::Entitlements.new(@club).assert_writable!
@tournament.groups.find(params[:group_id]).destroy!
redirect_to public_tournament_path(@tournament, tab: "struttura"), notice: t("flash.tournaments.group_removed")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
private
def set_tournament
@tournament = Tournament.find(params[:id])
@club = @tournament.club
require_club_owner!(@club)
end
end
end
@@ -0,0 +1,136 @@
module Public
class TournamentInvitationsController < WebBaseController
before_action :require_login!
before_action :set_tournament
def create
if params[:intent] == "save"
save_draft
return
end
match_ids = Array(params[:match_ids]).reject(&:blank?)
scope_kind = if match_ids.any?
"matches"
elsif params[:whole_court].present? || params[:scope_kind].to_s == "court_day"
"court_day"
else
raise ArgumentError, t("tournaments.hub.stream_plan_need_selection")
end
persist_draft! unless calendar_invite?
invitation, token = Tournaments::Invite.call(
tournament: @tournament,
email: params[:email],
scope_kind: scope_kind,
match_ids: match_ids,
court: params[:court],
on_date: params[:on_date].presence,
invited_by: current_user,
note: params[:note]
)
invite_url = public_invitation_url(token: token)
flash[:invite_url] = invite_url unless calendar_invite?
body_html = Tournaments::ComposeInviteEmail.call(
html: draft_html,
invite_url: invite_url,
expires_on: I18n.l(invitation.expires_at.to_date, format: :long)
)
invitation.update!(email_html: body_html)
begin
Tournaments::InvitationMailer.transmission_invite(
tournament: @tournament,
invitation: invitation,
invite_url: invite_url,
invited_by: current_user,
body_html: body_html
).deliver_now
flash[:notice] = t("flash.tournaments.invite_email_sent", email: invitation.email)
rescue StandardError => e
Rails.logger.error("[tournament_invite] #{e.class}: #{e.message}")
flash[:alert] = t("flash.tournaments.invite_email_failed", email: invitation.email)
end
redirect_to public_tournament_path(@tournament, tab: invite_return_tab, anchor: calendar_invite? ? nil : "invito-generato")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid, ArgumentError => e
redirect_to public_tournament_path(@tournament, tab: invite_return_tab), alert: e.message
end
def save_draft
Tournaments::Entitlements.new(@club).assert_writable!
persist_draft!
redirect_to public_tournament_path(@tournament, tab: "dirette"),
notice: t("flash.tournaments.invite_draft_saved")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "dirette"), alert: e.message
end
def upload_image
Tournaments::Entitlements.new(@club).assert_writable!
file = params[:file]
unless file.respond_to?(:content_type) && file.content_type.to_s.in?(%w[image/png image/jpeg image/webp image/gif])
return render json: { error: t("tournaments.hub.invite_image_invalid") }, status: :unprocessable_entity
end
if file.size > 2.megabytes
return render json: { error: t("tournaments.hub.invite_image_too_big") }, status: :unprocessable_entity
end
@tournament.invite_email_images.attach(file)
blob = @tournament.invite_email_images.blobs.last
render json: { url: url_for(blob) }
rescue Tournaments::EntitlementError => e
render json: { error: e.message }, status: :forbidden
end
def destroy
Tournaments::Entitlements.new(@club).assert_writable!
invitation = @tournament.broadcast_invitations.find(params[:invitation_id])
invitation.destroy!
redirect_to public_tournament_path(@tournament, tab: invite_return_tab), notice: t("flash.tournaments.invite_canceled")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
private
def set_tournament
@tournament = Tournament.find(params[:id])
@club = @tournament.club
require_club_owner!(@club)
end
def persist_draft!
@tournament.update!(
invite_draft_html: Tournaments::ComposeInviteEmail.sanitize_html(draft_html),
invite_draft_note: params[:note].to_s.strip.presence,
invite_draft_email: params[:email].to_s.strip.presence,
invite_draft_saved_at: Time.current
)
end
def draft_html
html = params[:email_html].to_s
stripped = ActionController::Base.helpers.strip_tags(html).to_s.gsub(/\s+/, "")
return html if stripped.present?
stored = @tournament.invite_draft_html.to_s
if ActionController::Base.helpers.strip_tags(stored).to_s.gsub(/\s+/, "").present?
return stored
end
Tournaments::ComposeInviteEmail.default_html(tournament: @tournament, invited_by: current_user)
end
def calendar_invite?
params[:from].to_s == "calendar" || invite_return_tab == "calendario"
end
def invite_return_tab
tab = params[:tab].to_s
tab = "dirette" if tab == "delega"
tab.presence_in(%w[calendario dirette]) || "dirette"
end
end
end
@@ -0,0 +1,113 @@
module Public
class TournamentMatchesController < WebBaseController
before_action :require_login!
before_action :set_tournament
def create
Tournaments::ScheduleMatch.call(tournament: @tournament, attrs: match_params)
redirect_to public_tournament_path(@tournament, tab: "calendario"), notice: t("flash.tournaments.match_scheduled")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "calendario"), alert: e.record.errors.full_messages.join(", ")
end
def update
Tournaments::Entitlements.new(@club).assert_writable!
match = @tournament.matches.find(params[:match_id])
attrs = match_params
if match.result_recorded?
attrs = attrs.except(:home_participant_id, :away_participant_id)
end
match.update!(attrs)
schedule_changed = match.previous_changes.keys.intersect?(%w[scheduled_at court location])
recorded = record_result_if_present!(match)
notice = if recorded && !schedule_changed
t("flash.tournaments.result_saved")
else
t("flash.tournaments.match_updated")
end
redirect_to public_tournament_path(@tournament, tab: hub_tab), notice: notice
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "calendario"), alert: e.record.errors.full_messages.join(", ")
end
def swap
match = @tournament.matches.find(params[:match_id])
Tournaments::SwapSides.call(match: match)
respond_to do |format|
format.json { render json: { ok: true, matchup_label: match.reload.matchup_label } }
format.html { redirect_to public_tournament_path(@tournament, tab: "calendario"), notice: t("flash.tournaments.sides_swapped") }
end
rescue Tournaments::EntitlementError => e
respond_to do |format|
format.json { render json: { ok: false, error: e.message }, status: :unprocessable_entity }
format.html { redirect_to public_club_billing_path(@club), alert: e.message }
end
rescue Tournaments::SwapSides::LiveBroadcastError => e
respond_to do |format|
format.json { render json: { ok: false, error: e.message }, status: :unprocessable_entity }
format.html { redirect_to public_tournament_path(@tournament, tab: "calendario"), alert: e.message }
end
end
def destroy
Tournaments::Entitlements.new(@club).assert_writable!
match = @tournament.matches.find(params[:match_id])
unless match.deletable?
redirect_to public_tournament_path(@tournament, tab: "calendario"),
alert: t("flash.matches.close_live_before_delete")
return
end
match.destroy!
redirect_to public_tournament_path(@tournament, tab: "calendario"), notice: t("flash.tournaments.match_deleted")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
private
def set_tournament
@tournament = Tournament.find(params[:id])
@club = @tournament.club
require_club_owner!(@club)
end
def record_result_if_present!(match)
home = params[:home_score]
away = params[:away_score]
return false if home.blank? || away.blank?
Tournaments::RecordResult.call(match: match, home_score: home, away_score: away, source: "manual")
true
end
def match_params
p = params.require(:match).permit(
:home_participant_id, :away_participant_id, :tournament_group_id,
:tournament_round_id, :court, :location, :scheduled_at
)
%i[home_participant_id away_participant_id].each do |key|
p[key] = p[key].presence if p.key?(key)
end
p[:scheduled_at] = parse_scheduled_at(p[:scheduled_at]) if p[:scheduled_at].present?
p
end
def parse_scheduled_at(value)
raw = value.to_s.strip
return nil if raw.blank?
Time.zone.strptime(raw, "%Y-%m-%dT%H:%M")
rescue ArgumentError
Time.zone.parse(raw)
end
def hub_tab
params[:tab].to_s.presence_in(%w[squadre struttura calendario dirette tabellone]) || "calendario"
end
end
end
@@ -0,0 +1,81 @@
module Public
class TournamentPagesController < SiteBaseController
include Public::LiveHelper
layout "marketing_live"
def index
@tournaments = Tournament
.visible_to_public
.with_attached_logo_file
.includes(:club)
.order(starts_on: :desc, name: :asc)
end
def show
@tournament = Tournament.find_by!(slug: params[:slug])
unless @tournament.published? || owner_access?
raise ActiveRecord::RecordNotFound
end
@club = @tournament.club
@owner_preview = !@tournament.published? && owner_access?
@owner_manage = owner_access?
@tab = params[:tab].to_s.presence_in(%w[risultati tabellone]) || "risultati"
load_public_content!
end
private
def owner_access?
logged_in? && @tournament.club.owned_by?(current_user)
end
def load_public_content!
@online_paths = fetch_online_paths
@matches = @tournament.matches
.includes(
:tournament_group,
:tournament_round,
:stream_sessions,
home_participant: { logo_file_attachment: :blob },
away_participant: { logo_file_attachment: :blob }
)
.order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :asc)
broadcasting = StreamSession
.broadcasting
.includes(:score_state, match: [:home_participant, :away_participant, { team: :club }])
.where(match_id: @tournament.matches.select(:id))
.order(Arel.sql("started_at DESC NULLS LAST"), created_at: :desc)
.to_a
@live_by_match_id = {}
broadcasting.each { |session| @live_by_match_id[session.match_id] ||= session }
@live_sessions = broadcasting.select(&:public_watchable?)
@recordings = Recording.ready.publicly_listed
.joins(stream_session: :match)
.where(matches: { tournament_id: @tournament.id })
.includes(stream_session: { match: [:home_participant, :away_participant] })
.order(recorded_at: :desc)
@recording_by_match_id = {}
@recordings.each do |rec|
match_id = rec.stream_session.match_id
@recording_by_match_id[match_id] ||= rec
end
@groups = @tournament.groups.order(:position)
@standings_by_group = @groups.index_with { |group| Tournaments::Standings.call(group) }
@rounds = @tournament.rounds.order(:position)
@matches_by_round = @matches.select { |match| match.tournament_round_id.present? }
.group_by(&:tournament_round_id)
end
def fetch_online_paths
Mediamtx::Client.new.online_path_names
rescue Mediamtx::Client::Error, Errno::ECONNREFUSED, SocketError
[]
end
end
end
@@ -0,0 +1,51 @@
module Public
class TournamentParticipantsController < WebBaseController
before_action :require_login!
before_action :set_tournament
def create
Tournaments::Entitlements.new(@club).assert_writable!
@tournament.participants.create!(participant_params)
redirect_to public_tournament_path(@tournament, tab: "squadre"), notice: t("flash.tournaments.participant_added")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "squadre"), alert: e.record.errors.full_messages.join(", ")
end
def update
Tournaments::Entitlements.new(@club).assert_writable!
participant = @tournament.participants.find(params[:participant_id])
participant.update!(participant_params)
file = params.dig(:tournament_participant, :logo_file)
participant.logo_file.attach(file) if file.present?
redirect_to public_tournament_path(@tournament, tab: "squadre"), notice: t("flash.tournaments.participant_updated")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "squadre"), alert: e.record.errors.full_messages.join(", ")
end
def destroy
Tournaments::Entitlements.new(@club).assert_writable!
@tournament.participants.find(params[:participant_id]).destroy!
redirect_to public_tournament_path(@tournament, tab: "squadre"), notice: t("flash.tournaments.participant_removed")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
private
def set_tournament
@tournament = Tournament.find(params[:id])
@club = @tournament.club
require_club_owner!(@club)
end
def participant_params
permitted = params.require(:tournament_participant).permit(:name, :group_id, :primary_color, :logo_url, :source_team_id)
permitted[:group_id] = permitted[:group_id].presence if permitted.key?(:group_id)
permitted
end
end
end
@@ -0,0 +1,32 @@
module Public
class TournamentResultsController < WebBaseController
before_action :require_login!
before_action :set_tournament
def create
Tournaments::Entitlements.new(@club).assert_writable!
match = @tournament.matches.find(params[:match_id])
Tournaments::RecordResult.call(
match: match,
home_score: params[:home_score],
away_score: params[:away_score],
source: "manual",
walkover: params[:walkover].presence
)
redirect_to public_tournament_path(@tournament, tab: params[:tab].presence || "calendario"),
notice: t("flash.tournaments.result_saved")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "calendario"), alert: e.record.errors.full_messages.join(", ")
end
private
def set_tournament
@tournament = Tournament.find(params[:id])
@club = @tournament.club
require_club_owner!(@club)
end
end
end
@@ -0,0 +1,190 @@
module Public
class TournamentsController < WebBaseController
before_action :require_login!
before_action :set_club, only: %i[index new create]
before_action :set_tournament, except: %i[index new create]
before_action :require_owner!
before_action :require_writable!, only: %i[update generate_group_matches propose_knockout]
def index
@tournaments = @club.tournaments.order(starts_on: :desc)
@can_create = Tournaments::Entitlements.new(@club).premium_full?
end
def new
assert_full!
return if performed?
@tournament = @club.tournaments.build(
sport_key: Sports::Catalog.normalize_key(@club.sport),
starts_on: Date.current,
ends_on: Date.current + 1,
format_kind: "mixed",
courts: ["Campo 1", "Campo 2"],
knockout_size: 4
)
end
def create
@tournament = Tournaments::Create.call(club: @club, attrs: tournament_params)
redirect_to public_tournament_path(@tournament), notice: t("flash.tournaments.created")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid => e
@tournament = e.record
flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity
end
def show
if params[:tab] == "delega"
redirect_to public_tournament_path(@tournament, tab: "dirette")
return
end
load_hub!
end
def update
attrs = tournament_params
file = attrs.delete(:logo_file)
@tournament.assign_attributes(attrs)
@tournament.logo_file.attach(file) if file.present?
@tournament.save!
redirect_to public_tournament_path(@tournament, tab: hub_tab || params[:tab]), notice: t("flash.tournaments.updated")
rescue ActiveRecord::RecordInvalid => e
load_hub!
flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :show, status: :unprocessable_entity
end
def publish
assert_full!
return if performed?
@tournament.update!(status: "published")
redirect_to public_tournament_path(@tournament), notice: t("flash.tournaments.published")
end
def unpublish
assert_full!
return if performed?
@tournament.update!(status: "draft")
redirect_to public_tournament_path(@tournament), notice: t("flash.tournaments.unpublished")
end
def archive
assert_full!
return if performed?
@tournament.update!(status: "archived")
redirect_to public_tournament_path(@tournament), notice: t("flash.tournaments.archived")
end
def destroy
Tournaments::Destroy.call(tournament: @tournament)
redirect_to public_club_tournaments_path(@club), notice: t("flash.tournaments.deleted")
rescue Tournaments::Destroy::LiveBroadcastError => e
redirect_to public_tournament_path(@tournament), alert: e.message
end
def generate_group_matches
created = Tournaments::GenerateGroupMatches.call(tournament: @tournament)
redirect_to public_tournament_path(@tournament, tab: "calendario"),
notice: t("flash.tournaments.group_matches_created", count: created.size)
rescue Tournaments::EntitlementError, ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "calendario"), alert: e.message
end
def propose_knockout
updated = Tournaments::ProposeKnockout.call(@tournament)
redirect_to public_tournament_path(@tournament, tab: "tabellone"),
notice: t("flash.tournaments.knockout_proposed", count: updated.size)
rescue Tournaments::EntitlementError, ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "tabellone"), alert: e.message
end
private
def set_club
@club = Club.find(params[:club_id] || params[:id])
end
def set_tournament
@tournament = Tournament.find(params[:id])
@club = @tournament.club
end
def require_owner!
require_club_owner!(@club)
end
def assert_full!
Tournaments::Entitlements.new(@club).assert_writable!
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
def require_writable!
assert_full!
return if performed?
return if @tournament.writable?
redirect_to public_tournament_path(@tournament), alert: t("flash.tournaments.archived_locked")
end
def load_hub!
@tab = hub_tab || "squadre"
@participants = @tournament.participants.with_attached_logo_file.includes(:group, :source_team).order(:position, :name)
@groups = @tournament.groups.order(:position)
@rounds = @tournament.rounds.order(:position)
@matches = @tournament.matches.includes(
{ home_participant: { logo_file_attachment: :blob } },
{ away_participant: { logo_file_attachment: :blob } },
:tournament_group, :tournament_round, :stream_sessions,
{ broadcast_assignments: :user }
).order(:scheduled_at)
@invitations = @tournament.broadcast_invitations.order(created_at: :desc)
@pending_invites_by_match_id = pending_invites_by_match_id
@standings_by_group = @groups.index_with { |group| Tournaments::Standings.call(group) }
@overlap_warnings = overlap_warnings
@writable = @tournament.writable? && Tournaments::Entitlements.new(@club).premium_full?
end
def hub_tab
raw = params[:tab].to_s
raw = "dirette" if raw == "delega"
raw.presence_in(%w[squadre struttura calendario dirette tabellone])
end
def pending_invites_by_match_id
pending = @tournament.broadcast_invitations.pending.to_a
map = Hash.new { |h, k| h[k] = [] }
@matches.each do |match|
pending.each do |invitation|
map[match.id] << invitation if invitation.covers_match?(match)
end
end
map
end
def overlap_warnings
limit = @tournament.concurrent_limit
return [] unless limit
@matches.group_by { |m| m.scheduled_at&.strftime("%Y-%m-%d %H:%M") }.filter_map do |slot, list|
next if slot.blank? || list.size <= limit
t("tournaments.hub.overlap_warning", slot: slot, count: list.size, limit: limit)
end
end
def tournament_params
params.require(:tournament).permit(
:name, :sport_key, :venue, :starts_on, :ends_on, :format_kind,
:description, :knockout_size, :courts, :logo_file
)
end
end
end
+50 -4
View File
@@ -1,4 +1,21 @@
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)
return "" if bytes.nil?
@@ -30,8 +47,9 @@ module AdminHelper
links
end
def admin_session_ingest_badge_class(node)
case node.role
def admin_session_ingest_badge_class(role_or_node)
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 "lab" then "badge--ingest-lab"
when "cloud" then "badge--ingest-cloud"
@@ -39,8 +57,9 @@ module AdminHelper
end
end
def admin_session_ingest_role_label(node)
I18n.t("admin.sessions.ingest.role.#{node.role}", default: node.role.to_s.humanize)
def admin_session_ingest_role_label(role_or_node)
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
def admin_session_status_badge_class(status)
@@ -81,6 +100,29 @@ module AdminHelper
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)
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.presence&.join(" · ") || I18n.t("admin.common.dash")
end
def admin_concurrency_violation_lookback_count
@admin_concurrency_violation_lookback_count ||= StreamConcurrencyViolation.lookback.count
end
end
+4
View File
@@ -3,6 +3,10 @@ module LegalHelper
"20 agosto 2026"
end
def terms_last_updated
"31 agosto 2026"
end
def cookie_policy_last_updated
"3 giugno 2026"
end
+24 -1
View File
@@ -53,6 +53,18 @@ module Public
def live_match_card_heading(match, link_team: true)
team = match.team
club_name = team.club&.name.presence || t("score.default_club_name")
if match.tournament_match?
tournament = match.tournament
heading_name = tournament&.name.presence || club_name
matchup = match.matchup_label
return content_tag(:h3, class: "live-card__title") do
safe_join([
content_tag(:span, heading_name, class: "live-card__club"),
content_tag(:span, matchup, class: "live-card__matchup")
])
end
end
team_slug = team.respond_to?(:slug) ? team.slug : nil
team_label = if link_team && team_slug.present?
link_to(team.name, public_team_page_path(team_slug), class: "live-card__team-link")
@@ -68,6 +80,17 @@ module Public
end
def live_match_page_heading(match)
if match.tournament_match?
tournament = match.tournament
club_name = tournament&.name.presence || match.team.club&.name.presence || t("score.default_club_name")
return content_tag(:div, class: "live-page-heading") do
safe_join([
content_tag(:p, club_name, class: "live-page-heading__club"),
content_tag(:h1, match.matchup_label, class: "live-page-heading__matchup")
])
end
end
team = match.team
club_name = team.club&.name.presence || t("score.default_club_name")
content_tag(:div, class: "live-page-heading") do
@@ -91,7 +114,7 @@ module Public
else score_state.away_points
end
t("score.points_label", team: match.team.name, home: home, away: away, opponent: match.opponent_name)
t("score.points_label", team: match.home_display_name, home: home, away: away, opponent: match.away_display_name)
end
private
@@ -0,0 +1,175 @@
module Public
module TournamentsHelper
def tournament_team_chip(participant, name: nil)
label = name.presence || participant&.name.presence || t("tournaments.tbd")
logo = participant&.effective_logo_url
content_tag(:span, class: "tournament-team-chip") do
parts = []
if logo.present?
parts << image_tag(logo, alt: "", width: 28, height: 28)
end
parts << content_tag(:span, label)
safe_join(parts)
end
end
def tournament_datetime_local(time)
time&.in_time_zone&.strftime("%Y-%m-%dT%H:%M")
end
def tournament_invite_default_html(tournament, invited_by)
Tournaments::ComposeInviteEmail.default_html(tournament: tournament, invited_by: invited_by)
end
def tournament_invite_editor_html(tournament, invited_by)
stored = tournament.invite_draft_html.to_s
return tournament_invite_default_html(tournament, invited_by) if stored.blank?
Tournaments::ComposeInviteEmail.sanitize_html(stored)
end
def tournament_streaming_operator_label(user)
user&.name.presence || user&.email.presence || ""
end
def tournament_pending_invites_for(match)
Array(@pending_invites_by_match_id&.[](match.id))
end
def tournament_invite_cancel_confirm(invitation)
count = @matches.to_a.count { |match| invitation.covers_match?(match) }
if count > 1
t("tournaments.hub.streaming_cancel_multi", email: invitation.email, count: count)
else
t("tournaments.hub.streaming_cancel", email: invitation.email)
end
end
def tournament_matches_grouped_by_date(matches)
matches.group_by { |match| match.scheduled_at&.in_time_zone&.to_date }
end
def tournament_streaming_coverage(matches)
assigned = pending = open = 0
matches.each do |match|
if match.broadcast_assignments.any?
assigned += 1
elsif tournament_pending_invites_for(match).any?
pending += 1
else
open += 1
end
end
{ assigned: assigned, pending: pending, open: open, total: matches.size }
end
def tournament_broadcast_live_state(match)
sessions = match.stream_sessions.to_a
latest = sessions.max_by(&:created_at)
if latest && !latest.status.in?(%w[ended error])
return :live if latest.status.in?(%w[live reconnecting paused connecting])
return :waiting_operator
end
return :ended if sessions.any? { |session| session.status.in?(%w[ended error]) } || match.played?
if match.broadcast_assignments.any?
return :waiting_time if match.scheduled_upcoming?
return :waiting_operator
end
return :waiting_operator if tournament_pending_invites_for(match).any?
:uncovered
end
def tournament_streaming_live_counts(matches)
counts = Hash.new(0)
matches.each { |match| counts[tournament_broadcast_live_state(match)] += 1 }
counts
end
def tournament_public_live_session(match)
@live_by_match_id&.[](match.id)
end
def tournament_public_recording(match)
@recording_by_match_id&.[](match.id)
end
def tournament_public_board_state(match)
return :live if tournament_public_live_session(match)
return :replay if tournament_public_recording(match)
return :ended if match.result_recorded?
return :scheduled if match.scheduled_upcoming?
:waiting
end
def tournament_public_watch_target(session)
return unless session
if session.matchlivetv_platform? && session.publicly_listed?
return [public_live_path(session), t("tournaments.page.watch_live"), {}]
end
if session.youtube_watch_url.present?
return [session.youtube_watch_url, t("live.show.youtube_watch_link"), { target: "_blank", rel: "noopener" }]
end
nil
end
def tournament_public_score_label(match)
return "#{match.home_score}#{match.away_score}" if match.home_score.present? && match.away_score.present?
session = tournament_public_live_session(match)
score = session&.score_state
return unless score
"#{score.home_points}#{score.away_points}"
end
def tournament_match_sides_locked?(match)
match.result_recorded?
end
def tournament_phase_label(match)
match.tournament_round&.name.presence || match.tournament_group&.name.presence || ""
end
def tournament_side_blank_label(match, side)
source = tournament_side_source_label(match, side)
source.presence || t("tournaments.tbd")
end
def tournament_side_source_label(match, side)
kind = match.public_send("#{side}_source_kind").to_s
case kind
when "winner_match"
source = tournament_source_match(match, side)
return t("tournaments.hub.winner_tbd") if source.blank?
t("tournaments.hub.winner_of", match: source.matchup_label)
when "group_rank"
group = tournament_source_group(match, side)
rank = match.public_send("#{side}_source_rank").to_i
return t("tournaments.hub.group_rank_tbd") if group.blank? || rank <= 0
t("tournaments.hub.group_rank", group: group.name, rank: rank)
end
end
def tournament_source_match(match, side)
id = match.public_send("#{side}_source_match_id")
return if id.blank?
@matches&.find { |item| item.id == id } || Match.find_by(id: id)
end
def tournament_source_group(match, side)
id = match.public_send("#{side}_source_group_id")
return if id.blank?
@groups&.find { |item| item.id == id } || TournamentGroup.find_by(id: id)
end
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
class PostProcessJob
include Sidekiq::Job
@@ -7,10 +9,30 @@ module Recordings
recording = Recording.find_by(id: recording_id)
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 if recording.youtube_video_id.present?
return if recording.source_platform == "youtube"
Recordings::PublishToYoutubeJob.perform_async(recording.id)
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
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
return unless redis
@@ -17,6 +19,16 @@ module Streams
perform_in(INTERVAL_SECS)
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
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
rescue Redis::CannotConnectError
@@ -0,0 +1,33 @@
# frozen_string_literal: true
module Streams
class NightCloudSweeperJob
include Sidekiq::Job
sidekiq_options retry: 1, queue: "default"
INTERVAL_SECS = ENV.fetch("STREAM_NIGHT_SWEEP_INTERVAL_SECS", "900").to_i
REDIS_CHAIN_KEY = "streams:night_sweep:chain"
def self.ensure_chain
return unless redis
return if redis.get(REDIS_CHAIN_KEY)
redis.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL_SECS * 2)
perform_in(INTERVAL_SECS)
end
def self.redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
rescue Redis::CannotConnectError
nil
end
def perform
Streams::NightCloudSweeper.sweep!
ensure
self.class.redis&.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL_SECS * 2)
self.class.perform_in(INTERVAL_SECS)
end
end
end
@@ -0,0 +1,27 @@
module Tournaments
class InvitationMailer < ApplicationMailer
default from: -> { MatchLiveTv.mail_from }
def transmission_invite(tournament:, invitation:, invite_url:, invited_by:, body_html: nil)
@tournament = tournament
@club = tournament.club
@invitation = invitation
@invite_url = invite_url
@invited_by = invited_by
@expires_on = I18n.l(invitation.expires_at.to_date, format: :long)
@body_html = body_html.presence || invitation.email_html.presence
@body_text = ActionController::Base.helpers.strip_tags(@body_html.to_s).squish
I18n.with_locale(I18n.locale) do
mail(
to: invitation.email,
subject: t(
"mailers.tournament_invite.subject",
tournament: @tournament.name,
club: @club.name
)
)
end
end
end
end
+9 -1
View File
@@ -6,6 +6,7 @@ class Club < ApplicationRecord
has_many :club_memberships, dependent: :destroy
has_many :users, through: :club_memberships
has_many :teams, dependent: :destroy
has_many :tournaments, dependent: :restrict_with_error
has_one :youtube_credential, dependent: :destroy
has_one :subscription, dependent: :destroy
has_one :billing_quote, -> { where(active: true) }, class_name: "Billing::ClubQuote", inverse_of: :club
@@ -20,7 +21,14 @@ class Club < ApplicationRecord
validates :secondary_color, presence: true
def owner
club_memberships.find_by(role: "owner")&.user
memberships = club_memberships
membership =
if memberships.loaded?
memberships.detect { |m| m.role == "owner" }
else
memberships.find_by(role: "owner")
end
membership&.user
end
def owned_by?(user)
@@ -59,18 +59,43 @@ module ClubBillingProfile
def billing_profile_invoice_lines
lines = []
lines << ["Tipo", self.class.billing_entity_types[billing_entity_type]] if billing_entity_type.present?
lines << ["Intestatario", billing_legal_name]
lines << ["Intestatario", billing_legal_name] if billing_legal_name.present?
lines << ["P.IVA", billing_vat_number] if billing_vat_number.present?
lines << ["Codice fiscale", billing_fiscal_code] if billing_fiscal_code.present?
lines << ["Email fatturazione", billing_email]
lines << ["Email fatturazione", billing_email] if billing_email.present?
lines << ["Telefono", billing_phone] if billing_phone.present?
addr = [billing_address_line, billing_postal_code, billing_city, billing_province, billing_country].compact.join(", ")
lines << ["Indirizzo", addr] if addr.present?
core_address = [billing_address_line, billing_postal_code, billing_city, billing_province].compact_blank
if core_address.any?
core_address << billing_country if billing_country.present?
lines << ["Indirizzo", core_address.join(", ")]
end
lines << ["SDI", billing_recipient_code] if billing_recipient_code.present?
lines << ["PEC", billing_pec] if billing_pec.present?
lines
end
# Stato profilo fiscale per badge admin: :complete | :incomplete | :absent
def billing_profile_admin_status
return :complete if billing_profile_complete?
return :absent unless billing_profile_started?
:incomplete
end
def billing_profile_started?
billing_legal_name.present? ||
billing_vat_number.present? ||
billing_fiscal_code.present? ||
billing_email.present? ||
billing_phone.present? ||
billing_address_line.present? ||
billing_city.present? ||
billing_postal_code.present? ||
billing_province.present? ||
billing_recipient_code.present? ||
billing_pec.present?
end
private
def billing_profile_for_invoicing
+70 -2
View File
@@ -2,7 +2,14 @@ class Match < ApplicationRecord
include Coverable
belongs_to :team
belongs_to :tournament, optional: true
belongs_to :home_participant, class_name: "TournamentParticipant", optional: true
belongs_to :away_participant, class_name: "TournamentParticipant", optional: true
belongs_to :tournament_group, optional: true
belongs_to :tournament_round, optional: true
belongs_to :winner_participant, class_name: "TournamentParticipant", optional: true
has_many :stream_sessions, dependent: :destroy
has_many :broadcast_assignments, class_name: "TournamentBroadcastAssignment", dependent: :destroy
has_one_attached :opponent_logo_file
@@ -20,6 +27,8 @@ class Match < ApplicationRecord
before_validation :normalize_sport_key
before_validation :inherit_sport_from_team, on: :create
before_validation :sync_tournament_display_names
after_commit :sync_tournament_broadcast_assignments, on: %i[create update]
scope :scheduled_for_live, -> {
where.not(scheduled_at: nil).where("scheduled_at >= ?", Time.zone.now)
@@ -85,14 +94,27 @@ class Match < ApplicationRecord
opponent_primary_color.presence || DEFAULT_OPPONENT_COLOR
end
def result_recorded?
played? || result_status.to_s.start_with?("walkover") || (home_score.present? && away_score.present?)
end
def coach_hub_visible?
active = active_stream_session
return true if active&.resumable?
return true if active&.idle?
return false if result_recorded?
return false if stream_completed?
return true if scheduled_on_calendar?
return true if tournament_open_for_late_stream?
scheduled_upcoming?
false
end
def tournament_open_for_late_stream?
return false unless tournament_match?
event = tournament
event.present? && !event.archived? && event.ends_on >= Date.current
end
def effective_board_type
@@ -116,6 +138,30 @@ class Match < ApplicationRecord
Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize
end
def tournament_match?
has_attribute?(:tournament_id) && tournament_id.present?
end
def home_display_name
home_participant&.name.presence || (tournament_match? ? I18n.t("tournaments.tbd") : team.name)
end
def away_display_name
away_participant&.name.presence || (tournament_match? ? I18n.t("tournaments.tbd") : opponent_name.presence)
end
def matchup_label
"#{home_display_name} vs #{away_display_name}"
end
def played?
result_status == "played"
end
def court_or_location
court.presence || location
end
# Alias legacy API/spec (colonna rinominata in sport_key).
def sport
sport_key
@@ -132,12 +178,34 @@ class Match < ApplicationRecord
end
def inherit_sport_from_team
if has_attribute?(:tournament_id) && tournament.present?
self.sport_key = tournament.sport_key if new_record? || sport_key.blank?
return
end
return unless team.present?
# Il default DB (pallavolo) non deve prevalere sullo sport della squadra alla creazione.
self.sport_key = team.sport_key if new_record? || sport_key.blank?
end
def sync_tournament_display_names
return unless has_attribute?(:tournament_id) && tournament_id.present?
self.opponent_name = away_display_name if away_participant.present? || opponent_name.blank?
venue_court = [tournament&.venue, court].compact_blank
self.location = venue_court.join("") if venue_court.any?
self.category = tournament.name if category.blank? && tournament.present?
end
def sync_tournament_broadcast_assignments
return unless has_attribute?(:tournament_id) && tournament_id.present?
return unless saved_change_to_court? || saved_change_to_scheduled_at? || previously_new_record?
Tournaments::SyncAssignments.sync_match!(self)
end
def sport_key_known
return if sport_key.blank?
return if Sports::Catalog.find_optional(sport_key)
+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
+73 -6
View File
@@ -2,6 +2,8 @@ class Recording < ApplicationRecord
STATUSES = %w[processing ready expired failed].freeze
PRIVACY_STATUSES = %w[public unlisted].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 :team
@@ -9,6 +11,7 @@ class Recording < ApplicationRecord
validates :status, inclusion: { in: STATUSES }
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
validates :storage_backend, inclusion: { in: STORAGE_BACKENDS }
validates :storage_policy, inclusion: { in: STORAGE_POLICIES }
scope :not_deleted, -> { where(deleted_at: nil) }
scope :ready, lambda {
@@ -23,7 +26,16 @@ class Recording < ApplicationRecord
ready.where(expires_at: ..days.days.from_now)
}
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|
q = query.to_s.strip
@@ -46,19 +58,30 @@ class Recording < ApplicationRecord
end
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"
end
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?
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}/thumbnail"
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
return nil unless ready?
return nil unless ready? && storage_key.present?
"/api/v1/recordings/#{id}/download"
end
@@ -70,6 +93,30 @@ class Recording < ApplicationRecord
"https://www.youtube.com/watch?v=#{youtube_video_id}"
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?
status == "ready" && !deleted? && (expires_at.nil? || expires_at.future?)
end
@@ -99,14 +146,22 @@ class Recording < ApplicationRecord
end
def title_or_default
title.presence || default_title
match = stream_session&.match
return title.presence || "Replay" unless match
generated = match.matchup_label
stored = title.to_s.strip
return generated if stored.blank?
return generated if stale_auto_title?(match, stored)
stored
end
def default_title
match = stream_session&.match
return "Replay" unless match
"#{match.team.name} vs #{match.opponent_name}"
match.matchup_label
end
def recorded_at_or_fallback
@@ -154,7 +209,7 @@ class Recording < ApplicationRecord
end
def playable_on_site?
ready? && storage_key.present?
available_in_archive?
end
def days_until_expiry
@@ -175,6 +230,18 @@ class Recording < ApplicationRecord
private
def stale_auto_title?(match, stored)
return false unless match.tournament_match?
team_name = match.team&.name.to_s
return false if team_name.blank?
[
"#{team_name} vs #{match.opponent_name}",
"#{team_name} vs #{match.away_display_name}"
].include?(stored)
end
def normalize_privacy_status
self.privacy_status = "public" if privacy_status == "private"
self.privacy_status = "unlisted" if privacy_status.blank?
@@ -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
+56 -2
View File
@@ -1,7 +1,8 @@
class StreamSession < ApplicationRecord
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
PRIVACY_STATUSES = %w[public unlisted private].freeze
@@ -9,8 +10,12 @@ class StreamSession < ApplicationRecord
belongs_to :user
belongs_to :stream_node, optional: true
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 :recording
has_one :recording, dependent: :destroy
has_many :device_states, dependent: :destroy
validates :platform, inclusion: { in: PLATFORMS }
@@ -20,6 +25,7 @@ class StreamSession < ApplicationRecord
before_validation :normalize_privacy_status
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 :publicly_listed, -> { where(privacy_status: "public") }
@@ -74,6 +80,43 @@ class StreamSession < ApplicationRecord
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
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
# MediaMTX path = live/match_{uuid} (no ?token= nel path).
@@ -143,6 +186,12 @@ class StreamSession < ApplicationRecord
platform == "youtube" && youtube_watch_url.present?
end
def public_watchable?
return true if matchlivetv_platform? && publicly_listed?
youtube_ready?
end
def link_only?
privacy_status.in?(%w[unlisted private])
end
@@ -208,6 +257,11 @@ class StreamSession < ApplicationRecord
self.privacy_status = "unlisted" if privacy_status == "private"
end
def snapshot_ingest_from_node
self.ingest_slug = stream_node.slug
self.ingest_role = stream_node.role
end
def record_ended_timestamps!
now = Time.current
update!(ended_at: now) if ended_at.nil?
+10
View File
@@ -2,6 +2,8 @@ class Team < ApplicationRecord
include Brandable
include Coverable
INTERNAL_KINDS = %w[tournament_broadcast].freeze
belongs_to :club
has_many :user_teams, dependent: :destroy
has_many :users, through: :user_teams
@@ -9,6 +11,10 @@ class Team < ApplicationRecord
has_many :recordings, dependent: :destroy
has_many :team_invitations, dependent: :destroy
has_many :roster_members, class_name: "TeamRosterMember", dependent: :destroy
has_one :broadcast_tournament, class_name: "Tournament", foreign_key: :broadcast_team_id, inverse_of: :broadcast_team
scope :visible, -> { where("internal_kind IS NULL OR internal_kind = ''") }
scope :tournament_broadcast, -> { where(internal_kind: Tournament::INTERNAL_TEAM_KIND) }
has_one_attached :photo_file
@@ -61,6 +67,10 @@ class Team < ApplicationRecord
Rails.application.routes.url_helpers.public_team_page_path(slug)
end
def tournament_broadcast?
internal_kind == Tournament::INTERNAL_TEAM_KIND
end
def sport_label
Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize
end
+145
View File
@@ -0,0 +1,145 @@
class Tournament < ApplicationRecord
include Coverable
FORMAT_KINDS = %w[groups knockout mixed free].freeze
STATUSES = %w[draft published live archived].freeze
INTERNAL_TEAM_KIND = "tournament_broadcast"
belongs_to :club
belongs_to :broadcast_team, class_name: "Team", optional: true
has_many :broadcast_assignments, class_name: "TournamentBroadcastAssignment", dependent: :destroy
has_many :matches, dependent: :destroy
has_many :broadcast_invitations, class_name: "TournamentBroadcastInvitation", dependent: :destroy
has_many :participants, class_name: "TournamentParticipant", dependent: :destroy
has_many :rounds, class_name: "TournamentRound", dependent: :destroy
has_many :groups, class_name: "TournamentGroup", dependent: :destroy
has_many_attached :invite_email_images
has_one_attached :logo_file
validates :name, presence: true
validates :slug, presence: true, uniqueness: true,
format: { with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/, message: "solo lettere minuscole, numeri e trattini" }
validates :sport_key, presence: true
validates :starts_on, :ends_on, presence: true
validates :format_kind, inclusion: { in: FORMAT_KINDS }
validates :status, inclusion: { in: STATUSES }
validates :invite_draft_note, length: { maximum: 2000 }, allow_blank: true
validate :sport_key_known
validate :ends_on_not_before_starts_on
validate :logo_file_type, if: -> { logo_file.attached? }
before_validation :normalize_sport_key
before_validation :assign_slug, on: :create
before_validation :normalize_slug, if: -> { slug_changed? && slug.present? }
before_validation :normalize_courts
scope :visible_to_public, -> { where(status: %w[published live archived]) }
scope :listed_on_live, -> { where(status: %w[published live]) }
scope :search_public, lambda { |query|
q = query.to_s.strip
return all if q.blank?
term = "%#{sanitize_sql_like(q)}%"
left_joins(:club).where(
"tournaments.name ILIKE :term OR clubs.name ILIKE :term OR tournaments.venue ILIKE :term",
term: term
)
}
def published?
status.in?(%w[published live archived])
end
def archived?
status == "archived"
end
def writable?
!archived?
end
def uses_groups?
format_kind.in?(%w[groups mixed])
end
def uses_knockout?
format_kind.in?(%w[knockout mixed])
end
def court_list
Array(courts).map { |c| c.to_s.strip }.reject(&:blank?)
end
def days
(starts_on..ends_on).to_a
end
def sport_label
Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize
end
def public_page_path
Rails.application.routes.url_helpers.public_tournament_page_path(slug)
end
def effective_primary_color
club.effective_primary_color
end
def effective_secondary_color
club.effective_secondary_color
end
def effective_logo_url
return unless logo_file.attached?
Rails.application.routes.url_helpers.rails_blob_path(logo_file, only_path: true)
end
def concurrent_limit
club.subscription&.plan&.concurrent_streams_limit
end
private
def normalize_sport_key
self.sport_key = Sports::Catalog.normalize_key(sport_key) if sport_key.present?
end
def sport_key_known
return if sport_key.blank?
return if Sports::Catalog.find_optional(sport_key)
errors.add(:sport_key, "non valido")
end
def ends_on_not_before_starts_on
return if starts_on.blank? || ends_on.blank?
return if ends_on >= starts_on
errors.add(:ends_on, "non può precedere la data di inizio")
end
def assign_slug
self.slug = Tournaments::GenerateSlug.call(self) if slug.blank?
end
def normalize_slug
self.slug = slug.to_s.parameterize
end
def normalize_courts
self.courts = court_list
self.courts = ["Campo 1"] if courts.blank?
end
def cover_parent
club
end
def logo_file_type
return if logo_file.content_type.in?(Coverable::COVER_IMAGE_TYPES)
errors.add(:logo_file, I18n.t("coverable.errors.invalid_type"))
end
end
@@ -0,0 +1,8 @@
class TournamentBroadcastAssignment < ApplicationRecord
belongs_to :tournament
belongs_to :match
belongs_to :user
belongs_to :invitation, class_name: "TournamentBroadcastInvitation", optional: true
validates :user_id, uniqueness: { scope: :match_id }
end
@@ -0,0 +1,55 @@
class TournamentBroadcastInvitation < ApplicationRecord
SCOPE_KINDS = %w[matches court_day].freeze
belongs_to :tournament
belongs_to :accepted_by, class_name: "User", optional: true
has_many :assignments, class_name: "TournamentBroadcastAssignment",
foreign_key: :invitation_id, dependent: :destroy
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :token_digest, presence: true, uniqueness: true
validates :scope_kind, inclusion: { in: SCOPE_KINDS }
validates :court, presence: true, if: -> { scope_kind == "court_day" }
validates :on_date, presence: true, if: -> { scope_kind == "court_day" }
validates :note, length: { maximum: 2000 }, allow_blank: true
scope :pending, -> { where(accepted_at: nil).where("expires_at > ?", Time.current) }
scope :accepted, -> { where.not(accepted_at: nil) }
def self.generate_token
SecureRandom.urlsafe_base64(32)
end
def expired?
expires_at.past?
end
def covers_match?(match)
return false if match.blank? || match.tournament_id != tournament_id
case scope_kind
when "court_day"
return false if court.blank? || on_date.blank? || match.scheduled_at.blank?
court.to_s == match.court.to_s && on_date == match.scheduled_at.in_time_zone.to_date
else
Array(match_ids).map(&:to_s).include?(match.id.to_s)
end
end
def accept!(user)
transaction do
update!(accepted_at: Time.current, accepted_by: user)
Tournaments::GrantBroadcastAccess.call(invitation: self, user: user)
Tournaments::SyncAssignments.call(invitation: self)
end
end
def scope_label
if scope_kind == "court_day"
"#{court} · #{I18n.l(on_date)}"
else
"#{Array(match_ids).size} partite"
end
end
end
+8
View File
@@ -0,0 +1,8 @@
class TournamentGroup < ApplicationRecord
belongs_to :tournament
has_many :participants, class_name: "TournamentParticipant", foreign_key: :group_id, dependent: :nullify
has_many :matches, dependent: :nullify
validates :name, presence: true
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
end
@@ -0,0 +1,35 @@
class TournamentParticipant < ApplicationRecord
include Brandable
belongs_to :tournament
belongs_to :group, class_name: "TournamentGroup", optional: true
belongs_to :source_team, class_name: "Team", optional: true
has_many :home_matches, class_name: "Match", foreign_key: :home_participant_id, dependent: :nullify
has_many :away_matches, class_name: "Match", foreign_key: :away_participant_id, dependent: :nullify
validates :name, presence: true
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
after_update :sync_related_match_names, if: :saved_change_to_name?
def branding_parent
source_team || tournament
end
def effective_logo_url
if logo_file.attached?
Rails.application.routes.url_helpers.rails_blob_path(logo_file, only_path: true)
elsif logo_url.present?
logo_url
else
source_team&.effective_logo_url
end
end
private
def sync_related_match_names
Match.where(home_participant_id: id).or(Match.where(away_participant_id: id)).find_each(&:save!)
end
end
+10
View File
@@ -0,0 +1,10 @@
class TournamentRound < ApplicationRecord
KINDS = %w[round_of_16 quarterfinal semifinal final third_place].freeze
belongs_to :tournament
has_many :matches, dependent: :nullify
validates :kind, inclusion: { in: KINDS }
validates :name, presence: true
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
end
+16 -3
View File
@@ -11,16 +11,20 @@ class User < ApplicationRecord
has_many :clubs, through: :club_memberships
has_many :owned_clubs, -> { where(club_memberships: { role: "owner" }) }, through: :club_memberships, source: :club
has_many :stream_sessions, dependent: :nullify
has_many :stream_concurrency_violations, dependent: :nullify
has_many :tournament_broadcast_assignments, dependent: :destroy
def manageable_teams
staff_ids = teams.select(:id)
owner_ids = Team.where(club_id: owned_clubs.select(:id)).select(:id)
owner_ids = Team.visible.where(club_id: owned_clubs.select(:id)).select(:id)
Team.where(id: staff_ids).or(Team.where(id: owner_ids))
end
# Squadre da cui l'utente può programmare partite e avviare lo streaming (app).
def streamable_teams
manageable_teams.includes(:club).select { |team| can_stream_for?(team) }
listed = manageable_teams.includes(:club).select { |team| can_stream_for?(team) }
owned_broadcast = Team.tournament_broadcast.where(club_id: owned_clubs.select(:id)).includes(:club)
(listed + owned_broadcast.to_a).uniq
end
def club_admin?(club)
@@ -28,6 +32,7 @@ class User < ApplicationRecord
end
def can_schedule_for?(team)
return false if team.tournament_broadcast?
return true if team.club&.owned_by?(self)
membership = user_teams.find_by(team: team)
@@ -37,7 +42,7 @@ class User < ApplicationRecord
end
def schedulable_teams_for(club)
teams = club.teams.order(:name).to_a
teams = club.teams.visible.order(:name).to_a
return teams if club.owned_by?(self)
teams.select { |team| can_schedule_for?(team) }
@@ -54,6 +59,14 @@ class User < ApplicationRecord
Teams::StaffCoverage.new(team).covers_both_roles?(membership)
end
def can_broadcast_match?(match)
return true if match.team.club&.owned_by?(self)
return false unless can_stream_for?(match.team)
return true unless match.tournament_match?
tournament_broadcast_assignments.exists?(match_id: match.id)
end
def staff_role_for(team)
return "owner" if team.club&.owned_by?(self)
@@ -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
@@ -0,0 +1,156 @@
# frozen_string_literal: true
module Admin
class UpdateClubData
class Error < StandardError; end
def self.call(**kwargs)
new(**kwargs).call
end
def initialize(club:, club_attrs:, owner_attrs: {}, staff_attrs: {}, invitation_attrs: {}, team_attrs: {})
@club = club
@club_attrs = club_attrs.to_h
@owner_attrs = owner_attrs.to_h
@staff_attrs = staff_attrs.to_h
@invitation_attrs = invitation_attrs.to_h
@team_attrs = team_attrs.to_h
end
def call
ActiveRecord::Base.transaction do
update_club!
update_owner!
update_staff!
update_invitations!
update_teams!
end
@club.reload
end
private
def update_club!
attrs = @club_attrs.dup
if attrs[:sport].present?
attrs[:sport] = Sports::Catalog.normalize_key(attrs[:sport])
end
%i[primary_color secondary_color].each do |key|
next unless attrs.key?(key)
attrs[key] = normalize_hex(attrs[key], key == :primary_color ? "#e53935" : "#ffffff")
end
%i[
billing_legal_name billing_vat_number billing_fiscal_code billing_email billing_phone
billing_address_line billing_city billing_province billing_postal_code billing_country
billing_recipient_code billing_pec logo_url
].each do |key|
next unless attrs.key?(key)
attrs[key] = attrs[key].to_s.strip.presence
end
if attrs[:billing_province].present?
attrs[:billing_province] = attrs[:billing_province].to_s.upcase
end
if attrs[:billing_country].present?
attrs[:billing_country] = attrs[:billing_country].to_s.upcase
end
if attrs[:billing_recipient_code].present?
attrs[:billing_recipient_code] = attrs[:billing_recipient_code].to_s.upcase
end
@club.assign_attributes(attrs)
@club.save!
rescue ActiveRecord::RecordInvalid => e
raise Error, e.record.errors.full_messages.join(", ")
end
def update_owner!
return if @owner_attrs.blank?
owner = @club.owner
raise Error, I18n.t("admin.clubs.edit.errors.owner_missing") if owner.blank?
update_user!(owner, @owner_attrs)
end
def update_staff!
return if @staff_attrs.blank?
allowed_ids = staff_users.index_by(&:id)
@staff_attrs.each do |user_id, attrs|
user = allowed_ids[user_id.to_s] || allowed_ids[user_id]
next unless user
update_user!(user, attrs)
end
end
def update_invitations!
return if @invitation_attrs.blank?
pending = TeamInvitation.pending.where(team_id: @club.teams.select(:id)).index_by { |inv| inv.id.to_s }
@invitation_attrs.each do |invitation_id, attrs|
invitation = pending[invitation_id.to_s]
next unless invitation
email = attrs[:email].to_s.strip.presence
next if email.blank?
invitation.update!(email: email)
rescue ActiveRecord::RecordInvalid => e
raise Error, e.record.errors.full_messages.join(", ")
end
end
def update_teams!
return if @team_attrs.blank?
teams = @club.teams.index_by { |t| t.id.to_s }
@team_attrs.each do |team_id, attrs|
team = teams[team_id.to_s]
next unless team
updates = {}
updates[:name] = attrs[:name].to_s.strip if attrs.key?(:name) && attrs[:name].present?
if attrs[:sport].present?
updates[:sport] = Sports::Catalog.normalize_key(attrs[:sport])
end
next if updates.empty?
team.update!(updates)
rescue ActiveRecord::RecordInvalid => e
raise Error, e.record.errors.full_messages.join(", ")
end
end
def update_user!(user, attrs)
updates = {}
updates[:name] = attrs[:name].to_s.strip if attrs.key?(:name) && attrs[:name].present?
if attrs.key?(:email)
email = attrs[:email].to_s.strip.presence
updates[:email] = email if email.present?
end
return if updates.empty?
user.update!(updates)
rescue ActiveRecord::RecordInvalid => e
raise Error, e.record.errors.full_messages.join(", ")
rescue ActiveRecord::RecordNotUnique
raise Error, I18n.t("admin.clubs.edit.errors.email_taken", email: updates[:email])
end
def staff_users
User.joins(:user_teams).where(user_teams: { team_id: @club.teams.select(:id) }).distinct.to_a
end
def normalize_hex(value, fallback)
raw = value.to_s.strip
return fallback if raw.blank?
return raw.downcase if raw.match?(/\A#[0-9a-fA-F]{6}\z/)
return "##{raw.downcase}" if raw.match?(/\A[0-9a-fA-F]{6}\z/)
fallback
end
end
end
+32 -15
View File
@@ -3,6 +3,7 @@
module Analytics
class Aggregate
BATCH = 500
UPSERT_RETRIES = 3
def call
loop do
@@ -45,13 +46,15 @@ module Analytics
now = Time.current
grouped.each do |(day, page_path, device, cell_x, cell_y), count|
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.updated_at = now
cell.save!
with_unique_retry do
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.updated_at = now
cell.save!
end
end
end
@@ -76,14 +79,28 @@ module Analytics
now = Time.current
grouped.each do |(day, page_path, device), vals|
stat = AnalyticsPageStat.find_or_initialize_by(day: day, page_path: page_path, device: device)
stat.pageview_count = stat.pageview_count.to_i + vals[:pageviews]
stat.scroll_samples = stat.scroll_samples.to_i + vals[:scroll_samples]
stat.scroll_sum_pct = stat.scroll_sum_pct.to_i + vals[:scroll_sum]
stat.max_scroll_pct = [stat.max_scroll_pct.to_i, vals[:max_scroll]].max
stat.created_at ||= now
stat.updated_at = now
stat.save!
with_unique_retry do
stat = AnalyticsPageStat.find_or_initialize_by(day: day, page_path: page_path, device: device)
stat.pageview_count = stat.pageview_count.to_i + vals[:pageviews]
stat.scroll_samples = stat.scroll_samples.to_i + vals[:scroll_samples]
stat.scroll_sum_pct = stat.scroll_sum_pct.to_i + vals[:scroll_sum]
stat.max_scroll_pct = [stat.max_scroll_pct.to_i, vals[:max_scroll]].max
stat.created_at ||= now
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
+9 -1
View File
@@ -31,7 +31,15 @@ module Analytics
end
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)
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 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)
new(base_url: session.mediamtx_api_base_url)
end
@@ -19,6 +22,19 @@ module Mediamtx
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)
path = session.mediamtx_path_name
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe
@@ -30,7 +46,9 @@ module Mediamtx
body[:alwaysAvailable] = true
body[:alwaysAvailableFile] = slate_file_path(session)
# 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?
err = response.body.is_a?(Hash) ? response.body["error"] : response.body
raise Error, "MediaMTX path create failed: #{response.status} #{err}"
@@ -161,6 +179,23 @@ module Mediamtx
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:)
ent = session.match.team.entitlements
can_record = ent.recording_enabled_for_mediamtx?
@@ -54,6 +54,8 @@ module Ops
def process_finding(finding)
if finding.healthy
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
Ops::IncidentRecorder.record(
kind: finding.kind,
@@ -10,6 +10,10 @@ module Ops
def resolve(fingerprint:)
new.resolve(fingerprint: fingerprint)
end
def resolve_kind(kind)
new.resolve_kind(kind)
end
end
def record(finding)
@@ -49,6 +53,10 @@ module Ops
Ops::Incident.open.where(fingerprint: fingerprint).find_each(&:resolve!)
end
def resolve_kind(kind)
Ops::Incident.open.where(kind: kind).find_each(&:resolve!)
end
private
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
class FinalizeSession
def initialize(session)
@@ -5,45 +7,86 @@ module Recordings
end
def call
team = @session.match.team
return unless team.entitlements.can_create_recordings?
policy = Recordings::StoragePolicy.call(@session)
return if policy == Recordings::StoragePolicy::NONE
retention_days = team.entitlements.recording_retention_days
expires_at = retention_days.positive? ? retention_days.days.from_now : nil
team = @session.match.team
attrs = attributes_for(policy, team)
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,
status: "processing",
title: default_title,
privacy_status: privacy_from_session,
storage_path: @session.mediamtx_path_name,
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,
metadata: initial_metadata(team)
)
recording.save!
recording
metadata: initial_metadata(policy, team)
}
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
match = @session.match
"#{match.team.name} vs #{match.opponent_name}"
match.matchup_label.presence || "Replay"
end
def privacy_from_session
@session.privacy_status == "public" ? "public" : "unlisted"
end
def initial_metadata(team)
ent = team.entitlements
def initial_metadata(policy, team)
{
"source_platform" => @session.platform,
"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" => {}
}
end
@@ -23,7 +23,7 @@ module Recordings
private
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
Rails.logger.warn("[Recordings::NotifyExpiring] SMTP EOF on close for #{user.email}: #{e.message}")
end
@@ -19,7 +19,7 @@ module Recordings
private
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
# 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}")
@@ -0,0 +1,100 @@
# 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) && body_present?(res.body)
FileUtils.remove_entry(dest)
raise Error, "agent GET recordings HTTP #{res.code} #{safe_body_snippet(res.body)}"
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 body_present?(body)
body && !body.empty?
end
def safe_body_snippet(body)
body.to_s.dup.force_encoding("UTF-8").scrub[0, 200]
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
recording = Recording.find_by(stream_session: @session)
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?
fail_recording!(recording, "Nessun file di registrazione trovato")
cleanup_mediamtx_path
@@ -35,23 +36,52 @@ module Recordings
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_cloud_pull!
cleanup_remote_recordings!
cleanup_mediamtx_path
Recordings::PostProcessJob.perform_async(recording.id)
recording
rescue Error, Recordings::Storage::Error => e
rescue Error, Recordings::Storage::Error, Recordings::PullFromCloudNode::Error => e
recording = Recording.find_by(stream_session: @session)
fail_recording!(recording, e.message) if recording
raise
ensure
FileUtils.rm_f(@merged_temp_path) if @merged_temp_path && File.exist?(@merged_temp_path)
cleanup_cloud_pull! unless recording_ready?
end
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
base = File.join(MatchLiveTv.recordings_local_path, @session.mediamtx_path_name)
return [] unless Dir.exist?(base)
scan_recording_files(File.join(MatchLiveTv.recordings_local_path, @session.mediamtx_path_name))
end
def scan_recording_files(base)
return [] if base.blank? || !Dir.exist?(base)
Dir.glob(File.join(base, "**", "*"))
.select { |path| File.file?(path) && path.match?(/\.(mp4|fmp4|m4s|ts)$/i) }
@@ -83,7 +113,11 @@ module Recordings
end
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
def cleanup_local_sources(source_files, merged_path)
@@ -100,6 +134,24 @@ module Recordings
Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}")
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)
recording.update!(status: "failed", error_message: message)
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,
status: "idle"
)
Sessions::ApplyClientInfo.call(session, @params[:client])
youtube_channel = nil
if session.platform == "youtube"
@@ -46,22 +47,56 @@ module Sessions
{
created: true,
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
kick_autoscaler_if_soft_limit!
if session.platform == "youtube"
YoutubeBroadcastSetupJob.perform_later(session.id, youtube_channel)
end
session
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
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)
resolver = Youtube::CredentialResolver.new(@match.team, channel: youtube_channel)
if resolver.resolve.blank?
+4 -2
View File
@@ -10,8 +10,10 @@ module Sessions
end
cancel_timeout_job
# connecting finché RTMP non è online (evita lose_connection da PublisherSync)
@session.begin_connect! if @session.may_begin_connect?
Sessions::AssertUserConcurrent.with_lock(@session, action: "resume") do
# 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).
log_event("resumed")
SessionChannel.broadcast_message(@session, { type: "command", action: "resume_stream" })
+5 -3
View File
@@ -5,9 +5,11 @@ module Sessions
end
def call
@session.match.team.entitlements.assert_concurrent_stream!(excluding_session: @session)
@session.begin_connect! if @session.may_begin_connect?
@session.update!(status: "connecting") unless @session.connecting?
Sessions::AssertUserConcurrent.with_lock(@session, action: "start") do
@session.match.team.entitlements.assert_concurrent_stream!(excluding_session: @session)
@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"
broadcast_status("connecting")
@session
+6 -1
View File
@@ -12,9 +12,14 @@ module Sessions
complete_youtube_broadcast! if @session.youtube_broadcast_id.present?
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!
begin
Tournaments::CaptureStreamResult.call(@session)
rescue StandardError => e
Rails.logger.warn("[Sessions::Stop] tournament result: #{e.class}: #{e.message}")
end
log_event("ended")
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "ended" })
@session
+38 -1
View File
@@ -71,6 +71,10 @@ module Streams
estimated_monthly_eur(overflow_count) <= monthly_budget_eur
end
def quiet_hours?
QuietHours.active?
end
def reconcile!(provisioner: nil)
return Result.new(skipped: true, actions: [], metrics: metrics) unless enabled?
@@ -106,7 +110,8 @@ module Streams
allow_cloud: allow_cloud?,
estimated_monthly_eur: estimated_monthly_eur(overflow.size),
monthly_budget_eur: monthly_budget_eur,
within_budget: within_budget?(overflow.size)
within_budget: within_budget?(overflow.size),
quiet_hours: quiet_hours?
}
end
@@ -136,6 +141,8 @@ module Streams
def reconcile!
actions = []
actions.concat(promote_provisioning_nodes!)
actions.concat(reclaim_stuck_provisioning!)
m = self.class.metrics
if need_capacity?(m) && can_provision?(m)
@@ -170,11 +177,36 @@ module Streams
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)
m[:free_slots] <= self.class.soft_free_slots
end
def warm_spare_desired?(m)
return false if self.class.quiet_hours?
return false if self.class.warm_spare_min <= 0
need_capacity?(m) || overflow_in_use?
@@ -185,6 +217,7 @@ module Streams
end
def can_provision?(m)
return false if self.class.quiet_hours?
return false if m[:overflow_nodes] >= self.class.max_overflow_nodes
return false unless self.class.within_budget?(m[:overflow_nodes] + 1)
return false if self.class.kind == "cloud" && !self.class.allow_cloud?
@@ -223,6 +256,9 @@ module Streams
end
def idle_long_enough?(node)
# Di notte chiudi subito i nodi idle (niente attesa IDLE_MINUTES).
return true if self.class.quiet_hours?
idle_since(node) <= self.class.idle_minutes.minutes.ago
end
@@ -232,6 +268,7 @@ module Streams
end
def keep_as_warm_spare?(node)
return false if self.class.quiet_hours?
return false unless warm_spare_desired?(self.class.metrics)
spares = StreamNode.ready.where.not(slug: NodeRegistry::HOME_SLUG).select { |n| n.active_publishers.zero? }
@@ -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,83 @@
# frozen_string_literal: true
module Streams
# Durante quiet hours chiude CPX cloud idle; se hanno sessioni attive solo alert Ops.
class NightCloudSweeper
Result = Struct.new(:skipped, :actions, :error, keyword_init: true)
class << self
def enabled?
ENV.fetch("STREAM_NIGHT_SWEEP_ENABLED", "1") == "1"
end
def sweep!(provisioner: nil)
new(provisioner: provisioner).sweep!
end
end
def initialize(provisioner: nil)
@provisioner = provisioner || NodeProvisioner.new
end
def sweep!
unless self.class.enabled?
return Result.new(skipped: true, actions: [], error: "disabled")
end
unless QuietHours.active?
return Result.new(skipped: true, actions: [], error: "outside_quiet_hours")
end
actions = []
cloud_nodes.find_each do |node|
if node.occupying_sessions.exists?
alert_active_night_node!(node)
actions << :"alert_active_#{node.slug}"
next
end
close_idle_node!(node)
actions << :"decommission_#{node.slug}"
rescue NodeProvisioner::BusyError, NodeProvisioner::Error => e
Rails.logger.warn("[Streams::NightCloudSweeper] #{node.slug}: #{e.message}")
actions << :"error_#{node.slug}"
end
Rails.logger.info("[Streams::NightCloudSweeper] actions=#{actions.inspect}")
Result.new(skipped: false, actions: actions)
end
private
def cloud_nodes
StreamNode.where(role: "cloud").where(status: %w[ready draining provisioning])
end
def alert_active_night_node!(node)
Ops::IncidentRecorder.record(
{
kind: "stream_overflow",
severity: "warning",
title: "CPX attivo di notte — verificare evento",
message: "Nodo #{node.slug} ha #{node.active_publishers} sessione/i in quiet hours (#{QuietHours.range_config} #{QuietHours.time_zone_name})",
metadata: {
"slug" => node.slug,
"session_ids" => node.occupying_sessions.pluck(:id),
"quiet_hours" => QuietHours.range_config
},
fingerprint: "stream_overflow:night_active:#{node.slug}"
}
)
end
def close_idle_node!(node)
@provisioner.drain!(node) unless node.status == "draining"
node.reload
raise NodeProvisioner::BusyError, "sessioni ancora attive" if node.occupying_sessions.exists?
slug = node.slug
@provisioner.decommission!(node)
Rails.logger.warn("[Streams::NightCloudSweeper] decommissioned idle cloud node #{slug} during quiet hours")
Ops::IncidentRecorder.resolve(fingerprint: "stream_overflow:night_active:#{slug}")
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,
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,
hostname: hostname,
role: role,
status: "ready",
status: initial_status,
provider: provider_name_for(cloud, role: role),
provider_instance_id: instance.id,
rtmp_base_url: urls.fetch(:rtmp_base_url),
@@ -102,6 +106,34 @@ module Streams
"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
def urls_for_node(role:, home:, hostname:, simulated:, private_ip:, public_ip: nil, use_node_hostname:)
@@ -0,0 +1,59 @@
# frozen_string_literal: true
module Streams
# Finestra notturna (default 02:0007:00 Europe/Rome) in cui non devono restare CPX idle.
module QuietHours
DEFAULT_RANGE = "02:00-07:00"
DEFAULT_TZ = "Europe/Rome"
module_function
def range_config
ENV.fetch("STREAM_AUTOSCALE_QUIET_HOURS", DEFAULT_RANGE).to_s.strip
end
def time_zone_name
ENV.fetch("STREAM_AUTOSCALE_QUIET_TZ", DEFAULT_TZ)
end
def configured?
range_config.present? && range_config != "off" && range_config != "0"
end
def active?(now: Time.current)
return false unless configured?
zone = ActiveSupport::TimeZone[time_zone_name] || Time.find_zone!(time_zone_name)
local = now.in_time_zone(zone)
start_min, end_min = parse_range(range_config)
return false if start_min.nil? || end_min.nil?
current = local.hour * 60 + local.min
if start_min <= end_min
current >= start_min && current < end_min
else
# es. 22:00-06:00
current >= start_min || current < end_min
end
rescue ArgumentError => e
Rails.logger.warn("[Streams::QuietHours] invalid config: #{e.message}")
false
end
def parse_range(raw)
start_s, end_s = raw.split("-", 2).map { |p| p.to_s.strip }
return [nil, nil] if start_s.blank? || end_s.blank?
[parse_hhmm(start_s), parse_hhmm(end_s)]
end
def parse_hhmm(value)
h, m = value.split(":", 2)
hours = Integer(h)
mins = Integer(m || 0)
raise ArgumentError, "ora fuori range: #{value}" unless hours.between?(0, 23) && mins.between?(0, 59)
hours * 60 + mins
end
end
end
@@ -45,7 +45,7 @@ module Teams
end
def base_teams
scope = Team.includes(:club).where(id: active_team_ids)
scope = Team.visible.includes(:club).where(id: active_team_ids)
scope = scope.where(sport_key: @sport) if @sport
scope = apply_search(scope) if @q.present?
scope.to_a
@@ -0,0 +1,35 @@
module Tournaments
class CaptureStreamResult
def self.call(session)
new(session).call
end
def initialize(session)
@session = session
end
def call
match = @session.match
return unless match&.tournament_match?
score = @session.score_state
return unless score
board = match.effective_board_type
home, away, extra = case board
when "basket", "timed"
[score.basket_home_score, score.basket_away_score, { "board" => board }]
else
[score.home_sets, score.away_sets, { "board" => board, "partials" => score.set_partials }]
end
Tournaments::RecordResult.call(
match: match,
home_score: home,
away_score: away,
source: "stream",
result_data: extra
)
end
end
end
@@ -0,0 +1,118 @@
# frozen_string_literal: true
module Tournaments
class ComposeInviteEmail
LINK_TOKEN = "{{link_invito}}"
EXPIRY_TOKEN = "{{scadenza}}"
MAX_HTML_BYTES = 100_000
ALLOWED_TAGS = %w[p br strong b em i u ul ol li a img h2 h3 h4 span div blockquote].freeze
ALLOWED_ATTR = %w[href src alt width height style target rel data-invite-note].freeze
ALLOWED_STYLE = %w[
width height max-width min-width margin margin-top margin-bottom margin-left margin-right
padding padding-top padding-bottom padding-left padding-right
float display text-align color background background-color
font-weight font-size line-height text-decoration border-radius border
].freeze
def self.call(html:, invite_url:, expires_on: nil)
new(html: html, invite_url: invite_url, expires_on: expires_on).call
end
def self.sanitize_html(html)
new(html: html.to_s, invite_url: "").sanitize(html.to_s)
end
def self.default_html(tournament:, invited_by:)
intro = I18n.t(
"mailers.tournament_invite.body_html",
inviter: CGI.escapeHTML(invited_by.name.to_s),
tournament: CGI.escapeHTML(tournament.name.to_s),
club: CGI.escapeHTML(tournament.club.name.to_s),
assignment: I18n.t("tournaments.hub.invite_email_scope_generic")
)
cta = CGI.escapeHTML(I18n.t("mailers.tournament_invite.cta"))
<<~HTML
<p>#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.hello"))}</p>
<p>#{intro}</p>
<p><a href="#{LINK_TOKEN}" style="display:inline-block;background:#e53935;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:8px;font-weight:600;">#{cta}</a></p>
<p>#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.link_fallback"))}<br>#{LINK_TOKEN}</p>
<p>#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.steps"))}</p>
<p>#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.expiry", date: EXPIRY_TOKEN))}</p>
<p>#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.ignore"))}</p>
HTML
end
def initialize(html:, invite_url:, expires_on: nil)
@html = html.to_s
@invite_url = invite_url.to_s
@expires_on = expires_on.to_s
end
def call
html = @html.dup
html.gsub!(LINK_TOKEN, @invite_url)
html.gsub!(EXPIRY_TOKEN, @expires_on) if @expires_on.present?
sanitized = sanitize(html)
sanitized += fallback_cta if @invite_url.present? && !sanitized.include?(@invite_url)
sanitized
end
def sanitize(html)
html = html.bytesize > MAX_HTML_BYTES ? html.byteslice(0, MAX_HTML_BYTES) : html
fragment = Loofah.fragment(html.to_s)
fragment.css("[data-invite-note]").each do |el|
el.remove if el.text.to_s.strip.blank?
end
scrubber = Rails::HTML::PermitScrubber.new
scrubber.tags = ALLOWED_TAGS
scrubber.attributes = ALLOWED_ATTR
fragment.scrub!(scrubber)
fragment.css("a").each { |node| scrub_url!(node, "href") }
fragment.css("img").each do |node|
scrub_url!(node, "src")
end
fragment.css("[style]").each { |node| scrub_style!(node) }
fragment.css("a[target='_blank']").each do |node|
node["rel"] = "noopener noreferrer"
end
fragment.to_s
end
private
def scrub_url!(node, attr)
url = node[attr].to_s.strip
return if url == LINK_TOKEN || url.include?(LINK_TOKEN)
return if url.match?(/\Ahttps?:\/\//i) || url.start_with?("/rails/active_storage")
if attr == "src"
node.remove
else
node.remove_attribute(attr)
end
end
def scrub_style!(node)
decls = node["style"].to_s.split(";").map(&:strip).reject(&:blank?)
kept = decls.select do |decl|
prop, value = decl.split(":", 2).map { |part| part.to_s.strip }
next false if prop.blank? || value.blank?
next false unless ALLOWED_STYLE.include?(prop.downcase)
next false if value.match?(/expression|javascript|url\s*\(/i)
true
end
if kept.any?
node["style"] = kept.join("; ")
else
node.remove_attribute("style")
end
end
def fallback_cta
label = CGI.escapeHTML(I18n.t("mailers.tournament_invite.cta"))
url = CGI.escapeHTML(@invite_url)
%(<p><a href="#{url}" style="display:inline-block;background:#e53935;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:8px;font-weight:600;">#{label}</a></p><p>#{url}</p>)
end
end
end
@@ -0,0 +1,87 @@
module Tournaments
class Create
KNOCKOUT_ROUNDS = {
16 => %w[round_of_16 quarterfinal semifinal final],
8 => %w[quarterfinal semifinal final],
4 => %w[semifinal final],
2 => %w[final]
}.freeze
ROUND_LABELS = {
"round_of_16" => "Ottavi",
"quarterfinal" => "Quarti",
"semifinal" => "Semifinali",
"final" => "Finale",
"third_place" => "Finale 3° posto"
}.freeze
def self.call(club:, attrs:)
new(club: club, attrs: attrs).call
end
def initialize(club:, attrs:)
@club = club
@attrs = attrs
end
def call
Tournaments::Entitlements.new(@club).assert_creatable!
tournament = @club.tournaments.build(filtered_attrs)
tournament.sport_key = Sports::Catalog.normalize_key(tournament.sport_key.presence || @club.sport)
Tournament.transaction do
tournament.save!
attach_logo!(tournament)
Tournaments::EnsureBroadcastTeam.call(tournament)
create_default_groups!(tournament)
create_knockout_rounds!(tournament)
end
tournament
end
private
def filtered_attrs
@attrs.to_h.symbolize_keys.slice(
:name, :sport_key, :venue, :starts_on, :ends_on, :format_kind,
:description, :knockout_size, :courts
).tap do |h|
h[:courts] = parse_courts(h[:courts]) if h.key?(:courts)
h[:knockout_size] = h[:knockout_size].to_i if h[:knockout_size].present?
h[:knockout_size] = nil if h[:knockout_size].to_i <= 0
end
end
def parse_courts(value)
case value
when Array then value
else value.to_s.split(/[\n,]/)
end
end
def attach_logo!(tournament)
file = @attrs.to_h.symbolize_keys[:logo_file]
tournament.logo_file.attach(file) if file.present?
end
def create_default_groups!(tournament)
return unless tournament.uses_groups?
return if tournament.groups.exists?
["Girone A", "Girone B"].each_with_index do |name, idx|
tournament.groups.create!(name: name, position: idx)
end
end
def create_knockout_rounds!(tournament)
return unless tournament.uses_knockout?
return if tournament.rounds.exists?
size = tournament.knockout_size.presence || 4
kinds = KNOCKOUT_ROUNDS[size] || KNOCKOUT_ROUNDS[4]
kinds.each_with_index do |kind, idx|
tournament.rounds.create!(kind: kind, name: ROUND_LABELS[kind], position: idx)
end
end
end
end
@@ -0,0 +1,46 @@
module Tournaments
class Destroy
class LiveBroadcastError < StandardError; end
def self.call(tournament:)
new(tournament).call
end
def initialize(tournament)
@tournament = tournament
end
def call
raise LiveBroadcastError, I18n.t("flash.tournaments.delete_blocked_live") if live_broadcast?
team = @tournament.broadcast_team
Tournament.transaction do
purge_recordings!
@tournament.update_column(:broadcast_team_id, nil) if team
@tournament.destroy!
destroy_orphan_broadcast_team!(team)
end
end
private
def live_broadcast?
StreamSession.where(
match_id: @tournament.matches.select(:id),
status: %w[connecting live reconnecting paused]
).exists?
end
def purge_recordings!
session_ids = StreamSession.where(match_id: @tournament.matches.select(:id)).select(:id)
Recording.where(stream_session_id: session_ids).find_each(&:destroy!)
end
def destroy_orphan_broadcast_team!(team)
return unless team&.tournament_broadcast?
return if Tournament.exists?(broadcast_team_id: team.id)
team.destroy!
end
end
end
@@ -0,0 +1,23 @@
module Tournaments
class EnsureBroadcastTeam
def self.call(tournament)
new(tournament).call
end
def initialize(tournament)
@tournament = tournament
end
def call
return @tournament.broadcast_team if @tournament.broadcast_team.present?
team = @tournament.club.teams.create!(
name: @tournament.name,
sport_key: @tournament.sport_key,
internal_kind: Tournament::INTERNAL_TEAM_KIND
)
@tournament.update!(broadcast_team: team)
team
end
end
end
@@ -0,0 +1,43 @@
module Tournaments
class EntitlementError < StandardError
attr_reader :code, :billing_url
def initialize(message, code:, billing_url: nil)
super(message)
@code = code
@billing_url = billing_url
end
end
class Entitlements
def initialize(club)
@club = club
end
def subscription
@subscription ||= @club.subscription
end
def premium_full?
subscription&.premium_full? == true
end
def billing_url
"#{MatchLiveTv.app_public_url.chomp('/')}/clubs/#{@club.id}/billing"
end
def assert_creatable!
assert_writable!
end
def assert_writable!
unless premium_full?
raise EntitlementError.new(
"I tornei sono disponibili con il piano Premium Full.",
code: "premium_full_required",
billing_url: billing_url
)
end
end
end
end
@@ -0,0 +1,40 @@
module Tournaments
class FillKnockoutSources
def self.call(tournament)
new(tournament).call
end
def initialize(tournament)
@tournament = tournament
end
def call
@tournament.matches.find_each do |match|
next if match.home_source_kind.blank? && match.away_source_kind.blank?
home = resolve(match, :home)
away = resolve(match, :away)
attrs = {}
attrs[:home_participant] = home if home && match.home_participant_id.blank?
attrs[:away_participant] = away if away && match.away_participant_id.blank?
match.update!(attrs) if attrs.any?
end
end
private
def resolve(match, side)
kind = match.public_send("#{side}_source_kind")
case kind
when "winner_match"
source = Match.find_by(id: match.public_send("#{side}_source_match_id"))
source&.winner_participant
when "group_rank"
group = TournamentGroup.find_by(id: match.public_send("#{side}_source_group_id"))
rank = match.public_send("#{side}_source_rank").to_i
return nil unless group && rank.positive?
Tournaments::Standings.call(group)[rank - 1]&.participant
end
end
end
end
@@ -0,0 +1,72 @@
module Tournaments
class GenerateGroupMatches
def self.call(tournament:, start_at: nil)
new(tournament, start_at: start_at).call
end
def initialize(tournament, start_at: nil)
@tournament = tournament
@start_at = start_at
end
def call
Tournaments::Entitlements.new(@tournament.club).assert_writable!
created = []
cursor = @start_at || Time.zone.local(@tournament.starts_on.year, @tournament.starts_on.month, @tournament.starts_on.day, 9, 0, 0)
courts = @tournament.court_list
court_idx = 0
@tournament.groups.includes(:participants).order(:position).each do |group|
pairs = round_robin(group.participants.to_a)
pairs.each do |home, away|
next if home.blank? || away.blank?
match = Tournaments::ScheduleMatch.call(
tournament: @tournament,
attrs: {
home_participant_id: home.id,
away_participant_id: away.id,
tournament_group_id: group.id,
court: courts[court_idx % courts.size],
scheduled_at: cursor
}
)
created << match
court_idx += 1
if (court_idx % courts.size).zero?
cursor += 1.hour
cursor = next_day_morning(cursor) if cursor.to_date > @tournament.ends_on
end
end
end
created
end
private
def round_robin(participants)
list = participants.dup
return [] if list.size < 2
list << nil if list.size.odd?
n = list.size
rounds = n - 1
pairs = []
rounds.times do
(n / 2).times do |i|
a = list[i]
b = list[n - 1 - i]
pairs << [a, b] if a && b
end
list = [list[0]] + [list[-1]] + list[1..-2]
end
pairs
end
def next_day_morning(time)
nxt = time.to_date + 1.day
nxt = @tournament.starts_on if nxt > @tournament.ends_on
Time.zone.local(nxt.year, nxt.month, nxt.day, 9, 0, 0)
end
end
end
@@ -0,0 +1,30 @@
module Tournaments
class GenerateSlug
def self.call(tournament)
new(tournament).call
end
def initialize(tournament)
@tournament = tournament
end
def call
base = @tournament.name.to_s.parameterize.presence || "torneo"
slug = base
n = 2
while conflict?(slug)
slug = "#{base}-#{n}"
n += 1
end
slug
end
private
def conflict?(slug)
scope = Tournament.where(slug: slug)
scope = scope.where.not(id: @tournament.id) if @tournament.persisted?
scope.exists?
end
end
end
@@ -0,0 +1,21 @@
module Tournaments
class GrantBroadcastAccess
def self.call(invitation:, user:)
new(invitation: invitation, user: user).call
end
def initialize(invitation:, user:)
@invitation = invitation
@user = user
end
def call
team = Tournaments::EnsureBroadcastTeam.call(@invitation.tournament)
membership = UserTeam.find_or_initialize_by(user: @user, team: team)
membership.role = "member" if membership.new_record?
membership.staff_kind = "transmission"
membership.save!
membership
end
end
end
@@ -0,0 +1,45 @@
module Tournaments
class Invite
def self.call(tournament:, email:, scope_kind:, match_ids: [], court: nil, on_date: nil, invited_by:, note: nil)
new(
tournament: tournament,
email: email,
scope_kind: scope_kind,
match_ids: match_ids,
court: court,
on_date: on_date,
invited_by: invited_by,
note: note
).call
end
def initialize(tournament:, email:, scope_kind:, match_ids:, court:, on_date:, invited_by:, note: nil)
@tournament = tournament
@email = email.to_s.downcase.strip
@scope_kind = scope_kind.to_s
@match_ids = Array(match_ids).reject(&:blank?)
@court = court
@on_date = on_date
@invited_by = invited_by
@note = note.to_s.strip.presence
end
def call
Tournaments::Entitlements.new(@tournament.club).assert_writable!
raise ArgumentError, "Email non valida" if @email.blank?
token = TournamentBroadcastInvitation.generate_token
invitation = @tournament.broadcast_invitations.create!(
email: @email,
token_digest: Digest::SHA256.hexdigest(token),
scope_kind: @scope_kind,
match_ids: @match_ids,
court: @court,
on_date: @on_date,
note: @note,
expires_at: 7.days.from_now
)
[invitation, token]
end
end
end
@@ -0,0 +1,95 @@
module Tournaments
class ProposeKnockout
def self.call(tournament)
new(tournament).call
end
def initialize(tournament)
@tournament = tournament
end
def call
Tournaments::Entitlements.new(@tournament.club).assert_writable!
first_round = @tournament.rounds.order(:position).first
return [] unless first_round
pairs = pair_qualified
slots = first_round.matches.order(:scheduled_at, :created_at).to_a
slots = create_slots!(first_round, [pairs.size, 1].max) if slots.empty?
updated = []
slots.each_with_index do |match, idx|
home, away = pairs[idx]
next if home.blank? && away.blank?
match.update!(home_participant: home, away_participant: away)
updated << match
end
updated.concat(seed_later_rounds!(slots))
updated
end
private
def pair_qualified
groups = @tournament.groups.order(:position).to_a
ranked = groups.map { |group| Tournaments::Standings.call(group).map(&:participant) }
return ranked.flatten.each_slice(2).to_a if groups.size < 2
pairs = []
first = ranked[0] || []
second = ranked[1] || []
pairs << [first[0], second[1]] if first[0] || second[1]
pairs << [second[0], first[1]] if second[0] || first[1]
leftover = (first.drop(2) + second.drop(2) + ranked.drop(2).flatten)
pairs.concat(leftover.each_slice(2).to_a)
pairs
end
def seed_later_rounds!(first_round_matches)
prev = first_round_matches
created = []
@tournament.rounds.order(:position).offset(1).each do |round|
needed = [prev.size / 2, 1].max
slots = round.matches.order(:scheduled_at, :created_at).to_a
last_at = prev.map(&:scheduled_at).compact.max
start = last_at&.+(1.hour)
slots = create_slots!(round, needed, start: start) if slots.empty?
slots.each_with_index do |match, idx|
home_src = prev[idx * 2]
away_src = prev[idx * 2 + 1]
attrs = {}
if home_src && match.home_source_match_id.blank?
attrs[:home_source_kind] = "winner_match"
attrs[:home_source_match_id] = home_src.id
end
if away_src && match.away_source_match_id.blank?
attrs[:away_source_kind] = "winner_match"
attrs[:away_source_match_id] = away_src.id
end
match.update!(attrs) if attrs.any?
created << match
end
prev = slots
end
created
end
def create_slots!(round, n, start: nil)
count = [n, 1].max
day = @tournament.ends_on
start ||= Time.zone.local(day.year, day.month, day.day, 18, 0, 0)
Array.new(count) do |i|
Tournaments::ScheduleMatch.call(
tournament: @tournament,
attrs: {
tournament_round_id: round.id,
court: @tournament.court_list.first,
scheduled_at: start + i.hours
}
)
end
end
end
end
@@ -0,0 +1,58 @@
module Tournaments
class RecordResult
def self.call(match:, home_score:, away_score:, source: "manual", result_data: {}, walkover: nil)
new(
match: match,
home_score: home_score,
away_score: away_score,
source: source,
result_data: result_data,
walkover: walkover
).call
end
def initialize(match:, home_score:, away_score:, source:, result_data:, walkover:)
@match = match
@home_score = home_score
@away_score = away_score
@source = source
@result_data = result_data || {}
@walkover = walkover
end
def call
return @match unless @match.tournament_match?
status = "played"
winner_id = nil
if @walkover == "home"
status = "walkover_home"
winner_id = @match.home_participant_id
@home_score = @home_score.presence || 1
@away_score = 0
elsif @walkover == "away"
status = "walkover_away"
winner_id = @match.away_participant_id
@away_score = @away_score.presence || 1
@home_score = 0
else
winner_id = if @home_score.to_i > @away_score.to_i
@match.home_participant_id
elsif @away_score.to_i > @home_score.to_i
@match.away_participant_id
end
end
@match.update!(
home_score: @home_score,
away_score: @away_score,
result_status: status,
result_source: @source,
result_data: @result_data,
winner_participant_id: winner_id
)
Tournaments::FillKnockoutSources.call(@match.tournament)
@match
end
end
end
@@ -0,0 +1,20 @@
module Tournaments
class RevokeAssignment
def self.call(assignment:)
new(assignment).call
end
def initialize(assignment)
@assignment = assignment
end
def call
invitation = @assignment.invitation
@assignment.destroy!
return unless invitation
leftover = invitation.assignments.reload.pluck(:match_id).map(&:to_s)
invitation.update!(scope_kind: "matches", match_ids: leftover, court: nil, on_date: nil)
end
end
end

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