44 Commits

Author SHA1 Message Date
7d5d66840f Riduce reload MediaMTX in live e allinea ingest mobile al preset qualità.
Evita patch recording ripetute che distruggevano i muxer HLS (500 ops) e fa encoderare 1080p/720p con codec e bitrate coerenti al wizard.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 22:17:18 +02:00
Emiliano Frascaro
e20c2ae619 Accetta undo punteggio dalla regia sull'overlay iOS.
applyRemote ignorava aggiornamenti Action Cable con progressKey più basso, quindi il meno in regia non aggiornava il tabellone sullo stream.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 16:28:36 +02:00
Emiliano Frascaro
793cfaaaf1 Fix avanzamento quarto basket e regia senza cronometro.
Normalizza il periodo nel backend quando l'app invia "Q1" via cable, così advance_period aggiorna regia e overlay; rimuove i controlli cronometro dalla regia e rende più tollerante il decode iOS al resume sessione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 16:15:44 +02:00
Emiliano Frascaro
c92099a41b Sincronizza overlay video con punteggio dalla regia remota.
L'iPad accetta gli aggiornamenti ActionCable della regia, evita race con risposte API obsolete e ridisegna subito il tabellone sullo stream RTMP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 12:37:46 +02:00
Emiliano Frascaro
270bf680a4 Fix regia basket e stabilità diretta iOS.
La regia remota non va più in 500 su basket/timed; iOS gestisce chiusura set, riconnessione RTMP e avanzamento quarti in modo affidabile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 12:30:34 +02:00
Emiliano Frascaro
731b43ea88 Stabilizza diretta iOS e aggiunge test simulatore pre-release.
Corregge lifecycle RTMP/preview tra partite, reset tabellone, orientamento
con debounce, condivisione su iPad e script test_ios_simulator per CI locale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 12:02:14 +02:00
Emiliano Frascaro
0f69ebcccb Instrada /hls/ su Rails per evitare redirect HTTP nel browser.
MediaMTX reindirizzava a http:// su pagine HTTPS; il proxy Rails segue i redirect lato server.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 19:15:04 +02:00
Emiliano Frascaro
5c85eb3524 Fix HLS browser: redirect HTTPS e proxy /live/match_* su Rails.
MediaMTX reindirizzava a http:// (bloccato in mixed content) e nginx riscriveva male il path live/.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 19:13:19 +02:00
Emiliano Frascaro
a303a94671 Fix HLS browser in prod e stabilizza broadcast RTMP su iOS.
Il proxy Rails gestisce il redirect MediaMTX su /live/match_* e riscrive le playlist; edge nginx reindirizza /live/ sotto /hls/. Su iOS, SessionBuilder HaishinKit, pipeline mixer corretta e refresh token.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 18:48:29 +02:00
ed0f913138 Restringe pattern ops sidekiq_failure per evitare falsi allarmi.
Non segnala più WARN generici come delete_path failed; solo job ActiveJob/Sidekiq realmente falliti.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 18:32:26 +02:00
d8af931bfb Rende idempotente il delete path MediaMTX (404 = già rimosso).
Evita errori quando il path _air non esiste o è già stato eliminato.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 21:04:36 +02:00
fbc9bb5858 Corregge delete path MediaMTX: usa HTTP DELETE.
Il POST restituiva 404 silenzioso e lasciava path alwaysAvailable attivi dopo fine diretta.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 20:59:13 +02:00
827abf80cb Elimina i path MediaMTX orfani dopo ogni diretta.
Corregge il cleanup dei path live/match_{uuid}, rimuove sempre il path a fine sessione e lo esegue ogni ora via cron.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 20:42:40 +02:00
Emiliano Frascaro
594853ed04 Fix punteggio broadcast iOS: decodifica score e aggiornamento UI.
ScoreStateDto non leggeva home_points con convertFromSnakeCase; ScoreController assegna sempre lo stato, aggiornamento ottimistico e refresh overlay.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 07:37:27 +02:00
Emiliano Frascaro
80813b390b Fix connessione RTMP in dev: riscrivi host Docker mediamtx per iOS.
Il simulatore non risolve «mediamtx»; l'app riscrive l'ingest verso 127.0.0.1 e docker-compose espone MEDIAMTX_RTMP_URL client-safe con messaggi d'errore RTMP più chiari.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 07:30:06 +02:00
Emiliano Frascaro
5de3a1a1cf Aggiunge E2E simulatore iOS e API_BASE_URL configurabile in build.
Permette build Debug verso localhost, script smoke per close_set/resume session e rigenerazione progetto con setting API di default produzione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 21:43:51 +02:00
Emiliano Frascaro
585332e32e Aggiunge app iOS nativa con parità multi-sport e test unitari.
Porting SwiftUI da Android (auth, hub, wizard, broadcast RTMP, overlay, scoring board-aware), reload hub al ritorno da diretta, decodifica score tollerante e documentazione allineata.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 21:37:32 +02:00
b798e0ea06 Aggiunge asset brand per Play Store e comunicazione.
Logo 512×512, immagini copertina/primo piano e screenshot app Android per pubblicazione e materiale marketing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 21:27:09 +02:00
498f6069d0 Documenta l'architettura completa del sistema.
Aggiunge docs/ARCHITECTURE.md con topologia rete, edge nginx, flussi HLS/replay e monitoraggio ops; allinea i documenti esistenti.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 21:26:35 +02:00
a44d9fbadd HLS su edge e monitoraggio ops con latenza p95.
Evita di saturare Puma in diretta, separa i check Rails/pubblico e traccia la latenza /up per alert più affidabili.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 21:19:32 +02:00
13390d8a3c Replay fuori da Puma, fix ops cron e firma release Android.
Dopo auth Rails redirect a URL presigned su /media/ (edge → Garage); aumenta
RAILS_MAX_THREADS, corregge ingest log ops via stdin e keystore Play Console.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 22:59:15 +02:00
e1636139a9 Corregge ordine deploy: edge e Rails vanno avviati insieme.
Evita conflitto sulla porta 3000 quando si sposta il bind da Rails al proxy di cortesia.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 09:34:54 +02:00
949a0a01fb Aggiunge proxy edge con pagina di cortesia e rilascio a downtime minimo.
NPM punta a nginx locale su :3000 che resta attivo durante il restart Rails; nuovo script release_production.sh esegue migrate, build e deploy in ordine.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 09:31:36 +02:00
208664848e Rende persistenti i log di produzione e protegge il deploy.
Monta /app/log sul disco video, sposta i cron log sullo stesso path, esclude log/ dal sync rsync e aggiunge pulizia volumi Docker obsoleti.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 09:23:42 +02:00
b802cf3309 Corregge persistenza loghi Active Storage in produzione.
Monta /app/storage sul disco video dedicato invece che su un volume Docker al path sbagliato, così i logo società/squadra sopravvivono ai rebuild.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 09:16:35 +02:00
dc309fbeb0 Permette agli admin di prolungare la scadenza dei replay.
Aggiunge data personalizzata e pulsanti rapidi nell'archivio admin così il job di purge non elimina i video fino alla nuova scadenza.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 09:11:56 +02:00
89854869c7 Aggiunge gestione archivio replay nel pannello admin.
Gli admin possono vedere, modificare ed eliminare i replay di una società come la squadra proprietaria, riusando la stessa UI e logica dell'archivio pubblico.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 09:01:38 +02:00
da75582dae Sposta storage video su disco dedicato e completa ops/SMTP in produzione.
Recordings e Garage usano bind mount su /media/videos; aggiunge ntfy self-hosted,
notifiche critical+warning, SMTP Aruba SSL/465 e fix cron replay.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 23:13:08 +02:00
52cfffb83f Aggiunge monitoraggio ops: health check, log scanner, dashboard e notifiche.
Introduce incidenti con dedup, job Sidekiq ogni 3 min, scan log cron, /up/deep,
dashboard /admin/ops e push ntfy; include Sentry opzionale e fix test suite.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 08:16:47 +02:00
ce8939fffb Aggiunge pulizia ciclica segmenti locali e doc tabelloni/overlay.
Evita il riempimento disco da segmenti MediaMTX post-sessione con cron orario;
documenta come board, overlay e regolamenti sportivi si relazionano nel sistema.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 22:08:05 +02:00
2b7e8b9e36 Migliora overlay basket Android, regia e catalogo sport senza timer.
Fix crash e race su +3, layout tabellone con squadre ai lati, safe area in diretta e rimozione overlay cronometro; backend con sync punteggio periodo, link diretta in regia e init score_state basket.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 23:31:12 +02:00
34c97f4447 Fix sport squadra su partite basket e ruoli organico per board.
Le partite ereditano lo sport_key della squadra alla creazione invece del default pallavolo; l'organico mostra ruoli per board (basket, volley, timed) e il wizard Android usa lo sport del team per overlay e icona.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 22:32:36 +02:00
01b7a3c96b Fix hub diretta: considera solo l'ultima sessione, non idle orfane.
Dopo «Termina trasmissione» la partita restava visibile se esisteva una vecchia sessione idle precedente a quella ended; l'hub e l'app usano ora lo stato dell'ultima sessione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 21:58:01 +02:00
ad9d67ddb6 Completa multi-sport su web, API score_action e controlli Android board-aware.
Allinea i form società/squadra al catalogo sport, espone score_action per basket/timed e corregge bug namespace Sports e Result nel period engine; l'app nativa gestisce overlay e punteggio per board con wizard regole esteso.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 21:48:29 +02:00
c0ede48091 Fix: rinomina Engine.for in for_match (keyword riservata Ruby).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 20:13:33 +02:00
e727069d43 Introduce architettura multi-sport con catalogo, engine scoring e overlay.
Catalogo sport in YAML, board implicito, engine per volley/basket/timed/racket/timer/generic, regia e app Android allineate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 20:11:21 +02:00
53f9a6a884 Allinea replay alla UI live e corregge miniature e loghi.
Player contenuto come la diretta, fix streaming thumbnail da S3, volume persistente ActiveStorage in produzione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 11:01:04 +02:00
0621efb839 Compatta archivio replay senza scroll orizzontale.
Riduce le colonne, impila dettagli e stato su due righe e usa date brevi per stare nel layout della pagina.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 09:28:03 +02:00
214d276508 Migliora UI archivio replay società con play e azioni separate.
Anteprima cliccabile con pulsante play, badge stato, select visibilità compatti e colonna azioni senza sovrapposizioni.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 09:14:38 +02:00
9177e9e5a2 Fix registrazione replay in produzione via PublisherSync.
MediaMTX distroless non invoca i webhook wget: accendiamo record sul path quando il telefono pubblica RTMP e lo spegniamo in pausa/offline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 09:00:43 +02:00
4ed15bc235 Rimuovi replay da app, fix live web e sync punteggi broadcast.
La pagina live mostra solo l'overlay nel video (niente tabellone HTML); l'app nativa non espone più l'archivio replay, il toggle regole punteggio parte spento e i controlli in diretta seguono il punteggio dalla regia.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 08:42:23 +02:00
74eee24293 Completa modulo Replay: S3, retention, sync YouTube e gestione società.
Streaming chunked da Garage, purge con delete YouTube, privacy sincronizzata,
archivio club migliorato, retention 30/90 separata da stato abbonamento.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 09:33:59 +02:00
ae36d17adb Stabilizza regia, live web e sync punteggi app nativa.
Corregge scadenza token regia oltre le 8h, tabellone HTML sulla pagina live,
pulsanti pausa/ripresa in regia, link admin e broadcast ActionCable diretto.
App Android: overlay branding, sync score da regia via WebSocket con reconnect e poll.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 09:30:43 +02:00
08e800120a Allinea hub partite app nativa e API match al wizard semplificato.
Filtra le partite visibili lato client/server, corregge il parsing date Rails su Android, semplifica lo step Partita del wizard e restringe l'API ai campi effettivamente usati (campionato, set e scoring_rules).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 10:49:30 +02:00
367 changed files with 21455 additions and 1601 deletions

View File

@@ -58,8 +58,13 @@ Vedi [native/android/README.md](native/android/README.md).
## Architettura ## Architettura
- Video (MVP): Phone → MediaMTX (RTMP) → HLS su `matchlivetv.*` (pagina `/live/:session_id`) Documentazione completa: **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** (rete, edge nginx, flussi video, ops).
- Video (premium): stesso ingest + relay ffmpeg verso YouTube
In sintesi:
- Video live: Phone → MediaMTX (RTMP) → HLS via edge `/hls/` (non Puma)
- Video replay: Rails auth → redirect `/media/` → Garage (non Puma)
- Video premium: stesso ingest + relay ffmpeg verso YouTube
- Controllo: Phone ↔ Action Cable ↔ Rails ↔ PostgreSQL - Controllo: Phone ↔ Action Cable ↔ Rails ↔ PostgreSQL
- Disconnessioni: MediaMTX `alwaysAvailable` slate + timeout 5 min (Sidekiq) - Disconnessioni: MediaMTX `alwaysAvailable` slate + timeout 5 min (Sidekiq)

View File

@@ -19,6 +19,8 @@ gem "faraday"
gem "stripe" gem "stripe"
gem "aws-sdk-s3", "~> 1.0", require: false gem "aws-sdk-s3", "~> 1.0", require: false
gem "kamal", require: false gem "kamal", require: false
gem "sentry-ruby"
gem "sentry-rails"
group :development, :test do group :development, :test do
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"

View File

@@ -381,6 +381,13 @@ GEM
rubocop-rails (>= 2.30) rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
securerandom (0.4.1) securerandom (0.4.1)
sentry-rails (6.6.2)
railties (>= 5.2.0)
sentry-ruby (~> 6.6.2)
sentry-ruby (6.6.2)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
logger
sidekiq (8.1.5) sidekiq (8.1.5)
connection_pool (>= 3.0.0) connection_pool (>= 3.0.0)
json (>= 2.16.0) json (>= 2.16.0)
@@ -456,6 +463,8 @@ DEPENDENCIES
redis (>= 4.0.1) redis (>= 4.0.1)
rspec-rails rspec-rails
rubocop-rails-omakase rubocop-rails-omakase
sentry-rails
sentry-ruby
sidekiq sidekiq
stripe stripe
tzinfo-data tzinfo-data

View File

@@ -3,7 +3,7 @@ module CableBroadcastable
class_methods do class_methods do
def broadcast_message(session, data) def broadcast_message(session, data)
broadcast_to(session, method: "receive_message", data: data) broadcast_to(session, data)
end end
end end
end end

View File

@@ -6,6 +6,8 @@ module Admin
before_action :require_admin_login before_action :require_admin_login
include ::AdminHelper include ::AdminHelper
helper ApplicationHelper
helper RecordingsArchiveHelper
helper_method :current_admin_account, :admin_logged_in? helper_method :current_admin_account, :admin_logged_in?
private private

View File

@@ -0,0 +1,15 @@
module Admin
class ClubRecordingsController < BaseController
include Recordings::ClubArchiveActions
private
def archive_namespace
:admin
end
def authorize_recording_management!
# Admin può gestire tutti i replay della società senza vincoli di ruolo utente.
end
end
end

View File

@@ -13,7 +13,22 @@ module Admin
def metrics def metrics
host = HostMetrics.new.sample! host = HostMetrics.new.sample!
stats = DashboardStats.new.call stats = DashboardStats.new.call
render json: { host: host, stats: stats, at: Time.current.iso8601 } render json: {
host: host,
stats: stats,
ops_summary: ops_summary,
at: Time.current.iso8601
}
end
private
def ops_summary
{
open_critical: Ops::Incident.critical_open.count,
open_warning: Ops::Incident.open.where(severity: "warning").count,
open_total: Ops::Incident.open.count
}
end end
end end
end end

View File

@@ -0,0 +1,37 @@
module Admin
class OpsController < Admin::BaseController
def index
@open_incidents = Ops::Incident.open.recent.limit(50)
@resolved_incidents = Ops::Incident.where(status: "resolved").since(30.days.ago).recent.limit(30)
@summary = ops_summary
end
def acknowledge
incident = Ops::Incident.find(params[:id])
incident.acknowledge!
redirect_to admin_ops_path, notice: "Incidente preso in carico"
end
def resolve
incident = Ops::Incident.find(params[:id])
incident.resolve!
redirect_to admin_ops_path, notice: "Incidente risolto"
end
def mute
incident = Ops::Incident.find(params[:id])
incident.mute!(duration: 24.hours)
redirect_to admin_ops_path, notice: "Notifiche sospese per 24 ore"
end
private
def ops_summary
{
open_critical: Ops::Incident.critical_open.count,
open_warning: Ops::Incident.open.where(severity: "warning").count,
open_total: Ops::Incident.open.count
}
end
end
end

View File

@@ -22,5 +22,22 @@ module Admin
Rails.logger.error("[admin] stop session #{@session.id}: #{e.class} #{e.message}") Rails.logger.error("[admin] stop session #{@session.id}: #{e.class} #{e.message}")
redirect_to admin_session_path(@session), alert: "Errore durante la chiusura: #{e.message}" redirect_to admin_session_path(@session), alert: "Errore durante la chiusura: #{e.message}"
end end
def regia_link
@session = StreamSession.find(params[:id])
if @session.terminal?
redirect_to admin_session_path(@session), alert: "Sessione terminata: impossibile generare il link regia."
return
end
access = Sessions::RegiaAccess.new(@session)
token = access.issue_token!
redirect_to admin_session_path(@session),
notice: "Link regia generato.",
flash: {
regia_url: access.url(token),
regia_expires_at: @session.regia_token_expires_at&.iso8601
}
end
end end
end end

View File

@@ -1,6 +1,9 @@
module Api module Api
module V1 module V1
class MatchesController < BaseController class MatchesController < BaseController
include Api::UrlHelper
include BrandingAttachments
before_action :set_team, only: %i[index create] before_action :set_team, only: %i[index create]
before_action :set_match, only: %i[show update destroy] before_action :set_match, only: %i[show update destroy]
@@ -8,11 +11,16 @@ module Api
matches = @team.matches matches = @team.matches
.includes(:team, :stream_sessions) .includes(:team, :stream_sessions)
.order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :desc) .order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :desc)
.select(&:coach_hub_visible?)
render json: matches.map { |m| match_json(m) } render json: matches.map { |m| match_json(m) }
end end
def create def create
match = @team.matches.create!(match_params) attrs = match_params.to_h
attrs["sport_key"] = @team.sport_key
normalize_scoring_rules!(attrs)
match = @team.matches.create!(attrs)
attach_opponent_logo(match)
render json: match_json(match), status: :created render json: match_json(match), status: :created
end end
@@ -21,7 +29,11 @@ module Api
end end
def update def update
@match.update!(match_params) attrs = match_params.to_h
normalize_scoring_rules!(attrs)
normalize_opponent_color!(attrs)
@match.update!(attrs)
attach_opponent_logo(@match)
render json: match_json(@match) render json: match_json(@match)
end end
@@ -46,41 +58,80 @@ module Api
def set_match def set_match
team_ids = current_user.streamable_teams.map(&:id) team_ids = current_user.streamable_teams.map(&:id)
@match = Match.where(team_id: team_ids).find(params[:id]) @match = Match.includes(:team).where(team_id: team_ids).find(params[:id])
end end
def match_params def match_params
params.require(:match).permit( p = params.require(:match).permit(
:opponent_name, :location, :scheduled_at, :sport, :sets_to_win, :opponent_name, :location, :scheduled_at, :sport, :sport_key, :sets_to_win,
:category, :phase, roster_numbers: [], :category, :opponent_primary_color, :overlay_kind,
scoring_rules: %i[points_per_set points_deciding_set min_point_lead] scoring_rules: {}
) )
if p[:sport].present? && p[:sport_key].blank?
p[:sport_key] = p.delete(:sport)
end
p[:overlay_kind] = nil if p.key?(:overlay_kind) && p[:overlay_kind].blank?
p
end
def normalize_scoring_rules!(attrs)
return unless attrs.key?("scoring_rules")
rules = attrs["scoring_rules"]
attrs["scoring_rules"] = {} if rules.blank?
if attrs["sets_to_win"].present? && attrs["scoring_rules"].is_a?(Hash)
attrs["scoring_rules"]["sets_to_win"] ||= attrs["sets_to_win"]
end
end
def normalize_opponent_color!(attrs)
return unless attrs.key?("opponent_primary_color")
attrs["opponent_primary_color"] = normalize_hex_color(
attrs["opponent_primary_color"],
Match::DEFAULT_OPPONENT_COLOR
)
end
def attach_opponent_logo(match)
file = params[:opponent_logo_file]
match.opponent_logo_file.attach(file) if file.present?
end end
def match_json(match, detail: false) def match_json(match, detail: false)
active = active_session_for(match) active = active_session_for(match)
team = match.team
{ {
id: match.id, id: match.id,
team_id: match.team_id, team_id: match.team_id,
team_name: match.team.name, team_name: team.name,
opponent_name: match.opponent_name, opponent_name: match.opponent_name,
location: match.location, location: match.location,
scheduled_at: match.scheduled_at, scheduled_at: match.scheduled_at,
sport: match.sport, sport: match.sport_key,
sport_key: match.sport_key,
sport_label: match.sport_label,
board_type: match.effective_board_type,
overlay_kind: match.overlay_kind,
effective_overlay_kind: match.effective_overlay_kind,
sets_to_win: match.sets_to_win, sets_to_win: match.sets_to_win,
scoring_rules: match.scoring_rules.presence, scoring_rules: match.scoring_rules.presence,
roster_numbers: match.roster_numbers, effective_scoring_rules: match.effective_scoring_rules,
category: match.category, category: match.category,
phase: match.phase, 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),
active_session_id: active&.id, active_session_id: active&.id,
active_session_status: active&.status active_session_status: active&.status,
stream_completed: match.stream_completed?,
coach_hub_visible: match.coach_hub_visible?
} }
end end
def active_session_for(match) def active_session_for(match)
match.stream_sessions match.active_stream_session
.order(created_at: :desc)
.find { |s| !%w[ended error].include?(s.status) }
end end
end end
end end

View File

@@ -5,7 +5,10 @@ module Api
def update def update
authorize_manage! authorize_manage!
@recording.update!(recording_update_attrs) attrs = recording_update_attrs
privacy_changed = attrs.key?(:privacy_status) && attrs[:privacy_status] != @recording.privacy_status
@recording.update!(attrs)
Recordings::SyncYoutubePrivacyJob.perform_async(@recording.id) if privacy_changed && @recording.youtube_video_id.present?
render json: recording_json(@recording) render json: recording_json(@recording)
end end
@@ -96,7 +99,10 @@ module Api
youtube_video_id: recording.youtube_video_id, youtube_video_id: recording.youtube_video_id,
youtube_watch_url: recording.youtube_watch_url, youtube_watch_url: recording.youtube_watch_url,
youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled?, youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled?,
source_platform: recording.source_platform,
source_platform_label: recording.source_platform_label,
expires_at: recording.expires_at, expires_at: recording.expires_at,
days_until_expiry: recording.days_until_expiry,
metadata: recording.metadata metadata: recording.metadata
} }
end end

View File

@@ -0,0 +1,9 @@
module Api
module V1
class SportsController < BaseController
def index
render json: ::Sports::Catalog.as_api_list
end
end
end
end

View File

@@ -39,6 +39,20 @@ module Api
render json: session_json(@session, detail: true) render json: session_json(@session, detail: true)
end end
def score_action
action = params[:score_action].presence
raise ArgumentError, "Azione mancante" if action.blank?
result = Scoring::ApplyAction.new(session: @session, action: action).call
render json: session_json(@session, detail: true).merge(
set_won: result.set_won,
match_won: result.match_won,
winner: result.winner&.to_s
)
rescue ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def events def events
events = @session.stream_events.recent.limit(100) events = @session.stream_events.recent.limit(100)
render json: events.map { |e| event_json(e) } render json: events.map { |e| event_json(e) }
@@ -100,13 +114,31 @@ module Api
end end
def network_test def network_test
upload_mbps = params[:upload_mbps].to_f
preset = Sessions::SelectQuality.call(upload_mbps: upload_mbps)
@session.update!(
quality_preset: preset[:id],
target_bitrate: preset[:target_bitrate],
target_fps: preset[:target_fps]
)
@session.stream_events.create!( @session.stream_events.create!(
event_type: "network_test", event_type: "network_test",
metadata: params.permit(:download_mbps, :upload_mbps, :latency_ms, :network_type).to_h, metadata: params.permit(:download_mbps, :upload_mbps, :latency_ms, :network_type)
.to_h.merge(
"quality_preset" => preset[:id],
"target_bitrate" => preset[:target_bitrate]
),
occurred_at: Time.current occurred_at: Time.current
) )
ready = params[:upload_mbps].to_f >= (@session.target_bitrate / 1_000_000.0 * 0.8) target_upload = preset[:target_bitrate] / 1_000_000.0
render json: { ready: ready, target_upload_mbps: @session.target_bitrate / 1_000_000.0 } ready = upload_mbps >= (target_upload * Sessions::SelectQuality::UPLOAD_HEADROOM)
render json: {
ready: ready,
target_upload_mbps: target_upload,
quality_preset: preset[:id],
target_bitrate: preset[:target_bitrate],
target_fps: preset[:target_fps]
}
end end
def youtube_stats def youtube_stats
@@ -135,6 +167,7 @@ module Api
def score_sync_params def score_sync_params
params.permit( params.permit(
:home_sets, :away_sets, :home_points, :away_points, :current_set, :home_sets, :away_sets, :home_points, :away_points, :current_set,
:clock_secs, :clock_running, :period, :home_score, :away_score, :overtime,
set_partials: %i[set home away] set_partials: %i[set home away]
) )
end end

View File

@@ -1,6 +1,9 @@
module Api module Api
module V1 module V1
class TeamsController < BaseController class TeamsController < BaseController
include Api::UrlHelper
include BrandingAttachments
def index def index
teams = current_user.streamable_teams teams = current_user.streamable_teams
render json: teams.map { |t| team_json(t) } render json: teams.map { |t| team_json(t) }
@@ -18,10 +21,11 @@ module Api
}, status: :unprocessable_entity }, status: :unprocessable_entity
end end
club = Club.create!(name: team_params[:name], sport: team_params[:sport] || "volleyball", sport_key = ::Sports::Catalog.normalize_key(team_params[:sport_key] || team_params[:sport] || "pallavolo")
club = Club.create!(name: team_params[:name], sport: sport_key,
primary_color: "#e53935", secondary_color: "#ffffff") primary_color: "#e53935", secondary_color: "#ffffff")
ClubMembership.create!(user: current_user, club: club, role: "owner") ClubMembership.create!(user: current_user, club: club, role: "owner")
team = club.teams.create!(name: team_params[:name], sport: club.sport) team = club.teams.create!(name: team_params[:name], sport_key: sport_key)
Billing::AssignPlan.call(club: club, plan_slug: "free") Billing::AssignPlan.call(club: club, plan_slug: "free")
render json: team_json(team), status: :created render json: team_json(team), status: :created
end end
@@ -39,6 +43,7 @@ module Api
recs = team.recordings.not_deleted recs = team.recordings.not_deleted
.where(status: %w[ready processing]) .where(status: %w[ready processing])
.where("expires_at IS NULL OR expires_at > ?", Time.current)
.includes(stream_session: :match) .includes(stream_session: :match)
.order(recorded_at: :desc, created_at: :desc) .order(recorded_at: :desc, created_at: :desc)
.limit(50) .limit(50)
@@ -48,6 +53,7 @@ module Api
def update def update
team = current_user.manageable_teams.find(params[:id]) team = current_user.manageable_teams.find(params[:id])
team.update!(team_params) team.update!(team_params)
attach_team_logo(team)
render json: team_json(team) render json: team_json(team)
end end
@@ -69,7 +75,23 @@ module Api
private private
def team_params def team_params
params.require(:team).permit(:name, :sport, :logo_url) p = params.require(:team).permit(:name, :sport, :sport_key, :logo_url, :primary_color, :secondary_color)
if p[:sport].present? && p[:sport_key].blank?
p[:sport_key] = p.delete(:sport)
end
p
if p[:primary_color].present?
p[:primary_color] = normalize_hex_color(p[:primary_color], p[:primary_color])
end
if p[:secondary_color].present?
p[:secondary_color] = normalize_hex_color(p[:secondary_color], p[:secondary_color])
end
p
end
def attach_team_logo(team)
file = params[:logo_file]
team.logo_file.attach(file) if file.present?
end end
def team_json(team, detail: false) def team_json(team, detail: false)
@@ -79,8 +101,11 @@ module Api
data = { data = {
id: team.id, id: team.id,
name: team.name, name: team.name,
sport: team.sport, sport: team.sport_key,
logo_url: team.effective_logo_url, sport_key: team.sport_key,
sport_label: team.sport_label,
board_type: team.effective_board_type,
logo_url: api_absolute_url(team.effective_logo_url),
primary_color: team.effective_primary_color, primary_color: team.effective_primary_color,
secondary_color: team.effective_secondary_color, secondary_color: team.effective_secondary_color,
club_id: team.club_id, club_id: team.club_id,
@@ -151,7 +176,10 @@ module Api
youtube_video_id: recording.youtube_video_id, youtube_video_id: recording.youtube_video_id,
youtube_watch_url: recording.youtube_watch_url, youtube_watch_url: recording.youtube_watch_url,
youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled?, youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled?,
source_platform: recording.source_platform,
source_platform_label: recording.source_platform_label,
expires_at: recording.expires_at, expires_at: recording.expires_at,
days_until_expiry: recording.days_until_expiry,
metadata: recording.metadata metadata: recording.metadata
} }
end end

View File

@@ -0,0 +1,16 @@
module Api
module UrlHelper
extend ActiveSupport::Concern
private
def api_absolute_url(path_or_url)
return nil if path_or_url.blank?
return path_or_url if path_or_url.match?(/\Ahttps?:\/\//i)
base = request.base_url
path = path_or_url.start_with?("/") ? path_or_url : "/#{path_or_url}"
"#{base}#{path}"
end
end
end

View File

@@ -0,0 +1,123 @@
module Recordings
module ClubArchiveActions
extend ActiveSupport::Concern
include RecordingsArchiveHelper
included do
before_action :set_club
before_action :set_recording, only: %i[update destroy publish_youtube]
end
def index
load_archive
end
def update
if admin_expiry_update_requested?
apply_admin_expiry_update!
return
end
permitted = recording_update_params
attrs = {}
privacy = permitted[:privacy_status]
attrs[:privacy_status] = privacy if privacy.in?(Recording::PRIVACY_STATUSES)
title = permitted[:title]
attrs[:title] = title if title.present?
privacy_changed = attrs.key?(:privacy_status) && attrs[:privacy_status] != @recording.privacy_status
@recording.update!(attrs)
Recordings::SyncYoutubePrivacyJob.perform_async(@recording.id) if privacy_changed && @recording.youtube_video_id.present?
redirect_to archive_index_path, notice: "Replay aggiornato"
rescue ActiveRecord::RecordInvalid => e
redirect_to archive_index_path, alert: e.record.errors.full_messages.join(", ")
rescue Recordings::ExtendExpiry::Error => e
redirect_to archive_index_path, alert: e.message
end
def destroy
Recordings::Delete.new(@recording).call
redirect_to archive_index_path, notice: "Replay eliminato"
end
def publish_youtube
ent = @recording.team.entitlements
unless ent.premium_full? && ent.youtube_enabled?
redirect_to archive_index_path, alert: "Republicazione YouTube con Premium Full"
return
end
Recordings::PublishToYoutubeJob.perform_async(@recording.id)
redirect_to archive_index_path, notice: "Pubblicazione su YouTube avviata"
end
private
def load_archive
archive = Recordings::ClubArchive.new(@club, params: params)
@stats = archive.stats
@recordings = archive.recordings
@teams = archive.teams
@filter_team_id = archive.filter_team_id
@filter_status = archive.filter_status
@archive_paths = recordings_archive_paths(@club, namespace: archive_namespace)
@archive_filter_params = archive.filter_params
end
def archive_index_path
archive_paths.index + archive_query_string
end
def archive_query_string
q = @archive_filter_params.presence
return "" if q.blank?
"?#{q.to_query}"
end
def archive_paths
@archive_paths ||= recordings_archive_paths(@club, namespace: archive_namespace)
end
def set_club
@club = Club.find(params[:club_id])
end
def set_recording
@recording = Recording.for_club(@club).not_deleted.find(params[:id])
authorize_recording_management!
end
def authorize_recording_management!
# overridden in Public::ClubRecordingsController
end
def archive_namespace
raise NotImplementedError
end
def recording_update_params
params.require(:recording).permit(:privacy_status, :title, :expires_at, :extend_days)
end
def admin_expiry_update_requested?
return false unless archive_namespace == :admin
permitted = recording_update_params
permitted[:extend_days].present? || permitted[:expires_at].present?
end
def apply_admin_expiry_update!
permitted = recording_update_params
extender = Recordings::ExtendExpiry.new(@recording)
if permitted[:extend_days].present?
extender.by_days(permitted[:extend_days])
redirect_to archive_index_path, notice: "Scadenza replay prolungata di #{permitted[:extend_days].to_i} giorni"
else
expires_at = Time.zone.parse(permitted[:expires_at].to_s)&.end_of_day
extender.to_time(expires_at)
redirect_to archive_index_path, notice: "Scadenza replay aggiornata"
end
end
end
end

View File

@@ -1,38 +1,98 @@
class HlsProxyController < ActionController::Base class HlsProxyController < ActionController::Base
# Proxy pubblico verso MediaMTX HLS (NPM inoltra tutto a Rails :3000). # Proxy pubblico verso MediaMTX HLS.
# Dev: tutto passa da Rails (/hls/*).
# Prod: edge inoltra /hls/ a MediaMTX; il redirect cookie di MediaMTX punta a
# /live/match_{uuid}/… che deve essere gestito da #live_match (fallback).
def show def show
upstream_path = params[:path].to_s proxy_mediamtx_path(params[:path].to_s)
end
def live_match
session_id = params[:session_id].to_s
return head :not_found unless session_id.match?(/\A[0-9a-f-]{36}\z/i)
segments = params[:segments].presence || "index.m3u8"
proxy_mediamtx_path("live/match_#{session_id}/#{segments}")
end
private
def proxy_mediamtx_path(upstream_path)
return head :not_found if upstream_path.blank? return head :not_found if upstream_path.blank?
upstream = "#{MatchLiveTv.mediamtx_hls_url}/#{upstream_path}" upstream = "#{MatchLiveTv.mediamtx_hls_url}/#{upstream_path}"
upstream = "#{upstream}?#{request.query_string}" if request.query_string.present? upstream = "#{upstream}?#{request.query_string}" if request.query_string.present?
cookie = request.headers["Cookie"].presence || "cookieCheck=1" cookie = request.headers["Cookie"].presence || "cookieCheck=1"
response = hls_conn.get(upstream) { |req| req.headers["Cookie"] = cookie }
if response.status == 302 5.times do
location = response.headers["location"].to_s response = hls_conn.get(upstream) { |req| req.headers["Cookie"] = cookie }
follow_url = if location.start_with?("http")
location if response.status.in?([301, 302, 307, 308])
else location = response.headers["location"].to_s
"#{MatchLiveTv.mediamtx_hls_url.sub(%r{/$}, "")}#{location}" cookie = cookie_from_set_header(response.headers["set-cookie"]).presence || cookie
end upstream = resolve_upstream_url(location)
set_cookie = response.headers["set-cookie"].to_s next
cookie = set_cookie.presence || cookie end
response = hls_conn.get(follow_url) { |req| req.headers["Cookie"] = cookie }
return render_proxied_response(response)
end end
response.headers.each do |key, value| head :bad_gateway
next if %w[transfer-encoding connection].include?(key.downcase)
headers[key] = value
end
render body: response.body, status: response.status
rescue Faraday::Error => e rescue Faraday::Error => e
Rails.logger.warn("[HlsProxy] #{upstream_path}: #{e.message}") Rails.logger.warn("[HlsProxy] #{upstream_path}: #{e.message}")
head :bad_gateway head :bad_gateway
end end
private def resolve_upstream_url(location)
return location if location.start_with?("http://", "https://")
base = MatchLiveTv.mediamtx_hls_url.sub(%r{/$}, "")
location.start_with?("/") ? "#{base}#{location}" : "#{base}/#{location}"
end
def render_proxied_response(response)
body = response.body.to_s
content_type = response.headers["content-type"].to_s
if m3u8_playlist?(content_type, body)
public_hls_base = "#{request.base_url}/hls"
body = body.gsub(%r{(?<=["'(\s])/live/(match_[0-9a-f-]{36}/)}, "/hls/live/\\1")
body = body.gsub(%r{\A/live/(match_[0-9a-f-]{36}/)}, "/hls/live/\\1")
end
response.headers.each do |key, value|
next if %w[transfer-encoding connection content-length content-encoding].include?(key.downcase)
if key.downcase == "location"
headers[key] = rewrite_public_location(value)
else
headers[key] = value
end
end
render body: body, status: response.status, content_type: content_type.presence
end
def rewrite_public_location(location)
loc = location.to_s
if loc.start_with?("/live/match_")
return loc.sub(%r{\A/live/}, "/hls/")
end
if loc.include?("/live/match_")
return loc.gsub(%r{/(live/match_[0-9a-f-]{36}/)}, "/hls/live/\\1")
end
loc
end
def m3u8_playlist?(content_type, body)
return true if content_type.include?("mpegurl") || content_type.include?("m3u8")
body.lstrip.start_with?("#EXTM3U")
end
def cookie_from_set_header(set_cookie_header)
set_cookie_header.to_s[/cookieCheck=[^;]+/]
end
def hls_conn def hls_conn
@hls_conn ||= Faraday.new do |f| @hls_conn ||= Faraday.new do |f|

View File

@@ -0,0 +1,28 @@
module Ops
class HealthController < ActionController::API
before_action :authorize_deep_health!
def show
summary = Ops::HealthChecks.new.summary
status_code = case summary[:status]
when "ok" then :ok
when "degraded" then :service_unavailable
else :service_unavailable
end
render json: summary, status: status_code
end
private
def authorize_deep_health!
token = ENV["OPS_HEALTH_TOKEN"].presence
return if token.blank?
provided = request.headers["X-Ops-Token"].presence || params[:token].presence
return head :unauthorized if provided.blank?
head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(provided, token)
end
end
end

View File

@@ -1,55 +1,28 @@
module Public module Public
class ClubRecordingsController < WebBaseController class ClubRecordingsController < WebBaseController
include Recordings::ClubArchiveActions
helper RecordingsArchiveHelper
before_action :require_login! before_action :require_login!
before_action :set_club before_action :require_club_recording_manager!
before_action -> { require_club_owner!(@club) }
before_action :set_recording, only: %i[update destroy publish_youtube]
def index
@stats = Recordings::ClubStats.new(@club).call
@recordings = Recording.for_club(@club)
.not_deleted
.includes(stream_session: { match: :team })
.order(recorded_at: :desc, created_at: :desc)
@teams = @club.teams.order(:name)
end
def update
privacy = params.require(:recording).permit(:privacy_status, :title)[:privacy_status]
attrs = {}
attrs[:privacy_status] = privacy if privacy.in?(Recording::PRIVACY_STATUSES)
title = params.dig(:recording, :title)
attrs[:title] = title if title.present?
@recording.update!(attrs)
redirect_to public_club_recordings_path(@club), notice: "Replay aggiornato"
rescue ActiveRecord::RecordInvalid => e
redirect_to public_club_recordings_path(@club), alert: e.record.errors.full_messages.join(", ")
end
def destroy
Recordings::Delete.new(@recording).call
redirect_to public_club_recordings_path(@club), notice: "Replay eliminato"
end
def publish_youtube
ent = @recording.team.entitlements
unless ent.premium_full? && ent.youtube_enabled?
redirect_to public_club_recordings_path(@club), alert: "Republicazione YouTube con Premium Full"
return
end
Recordings::PublishToYoutubeJob.perform_async(@recording.id)
redirect_to public_club_recordings_path(@club), notice: "Pubblicazione su YouTube avviata"
end
private private
def set_club def archive_namespace
@club = current_user.owned_clubs.find(params[:club_id]) :public
end end
def set_recording def authorize_recording_management!
@recording = Recording.for_club(@club).not_deleted.find(params[:id]) return if Recordings::AccessPolicy.new(@recording, viewer: current_user).manage_allowed?
raise ActiveRecord::RecordNotFound
end
def require_club_recording_manager!
return if current_user.owned_clubs.exists?(id: @club.id)
return if current_user.manageable_teams.joins(:club).exists?(clubs: { id: @club.id })
redirect_to public_clubs_path, alert: "Non autorizzato"
end end
end end
end end

View File

@@ -190,6 +190,7 @@ module Public
) )
p[:primary_color] = normalize_hex_color(p[:primary_color], "#e53935") p[:primary_color] = normalize_hex_color(p[:primary_color], "#e53935")
p[:secondary_color] = normalize_hex_color(p[:secondary_color], "#ffffff") p[:secondary_color] = normalize_hex_color(p[:secondary_color], "#ffffff")
p[:sport] = Sports::Catalog.normalize_key(p[:sport]) if p[:sport].present?
p p
end end

View File

@@ -94,9 +94,7 @@ module Public
end end
def active_session_for(match) def active_session_for(match)
match.stream_sessions match.active_stream_session
.order(created_at: :desc)
.find { |s| !%w[ended error].include?(s.status) }
end end
end end
end end

View File

@@ -1,26 +1,30 @@
module Public module Public
class RegiaController < SiteBaseController class RegiaController < SiteBaseController
include Public::LiveHelper include Public::LiveHelper
include Public::RegiaHelper
include MediamtxPlayback include MediamtxPlayback
layout "regia" layout "regia"
protect_from_forgery with: :null_session protect_from_forgery with: :null_session
skip_before_action :verify_authenticity_token, only: %i[score pause stop] skip_before_action :verify_authenticity_token, only: %i[score pause resume stop]
before_action :set_session_from_token before_action :set_session_from_token
def show def show
@match = @session.match @match = @session.match
@team = @match.team @team = @match.team
@score = @session.score_state || @session.create_score_state!( @score = @session.score_state || Scoring::Engine.ensure_score_for(@session)
home_sets: 0, away_sets: 0, home_points: 0, away_points: 0, current_set: 1, set_partials: []
)
@rules = Scoring::Rules.from_match(@match) @rules = Scoring::Rules.from_match(@match)
@stream_closed = @session.terminal? @stream_closed = @session.terminal?
@on_air = !@stream_closed && mediamtx_stream_playable?(@session) @on_air = !@stream_closed && (
@share_url = request.original_url mediamtx_stream_playable?(@session) ||
@share_title = "Regia — #{@team.name} vs #{@match.opponent_name}" @session.status.in?(%w[connecting live reconnecting paused])
)
@regia_share_url = request.original_url
@regia_share_title = "Regia — #{@team.name} vs #{@match.opponent_name}"
@live_share_url = @session.share_url
@live_share_title = "#{@team.name} vs #{@match.opponent_name} — Diretta Match Live TV"
end end
def status def status
@@ -30,6 +34,7 @@ module Public
def score def score
cmd = params[:cmd].presence || params[:score_action].presence cmd = params[:cmd].presence || params[:score_action].presence
result = Scoring::ApplyAction.new(session: @session, action: cmd).call result = Scoring::ApplyAction.new(session: @session, action: cmd).call
@session.reload
render json: status_payload.merge( render json: status_payload.merge(
set_won: result.set_won, set_won: result.set_won,
match_won: result.match_won, match_won: result.match_won,
@@ -44,6 +49,13 @@ module Public
render json: status_payload render json: status_payload
end end
def resume
Sessions::Resume.new(@session).call
render json: status_payload
rescue Teams::EntitlementError => e
render json: status_payload.merge(error: e.message), status: :unprocessable_entity
end
def stop def stop
Sessions::Stop.new(@session).call Sessions::Stop.new(@session).call
render json: status_payload render json: status_payload
@@ -55,25 +67,25 @@ module Public
@session = Sessions::RegiaAccess.find_session_by_token(params[:token]) @session = Sessions::RegiaAccess.find_session_by_token(params[:token])
return if @session return if @session
render file: Rails.root.join("public/404.html"), status: :not_found, layout: false head :not_found
throw :abort
end end
def status_payload def status_payload
score = @session.score_state score = @session.score_state
closed = @session.terminal? closed = @session.terminal?
playable = !closed && mediamtx_stream_playable?(@session) playable = !closed && mediamtx_stream_playable?(@session)
publisher_online = !closed && mediamtx_publisher_online?(@session)
{ {
status: @session.status, status: @session.status,
paused: @session.paused?, paused: @session.paused?,
stream_closed: closed, stream_closed: closed,
live: !closed && (@session.live? || @session.paused? || mediamtx_publisher_online?(@session)), live: !closed && (@session.live? || @session.paused? || publisher_online),
on_air: playable, on_air: playable || (!closed && @session.status.in?(%w[connecting live reconnecting paused])),
publisher_online: !closed && mediamtx_publisher_online?(@session), publisher_online: publisher_online,
ended_at: @session.ended_at, ended_at: @session.ended_at,
home_name: @session.match.team.name, home_name: @session.match.team.name,
away_name: @session.match.opponent_name, away_name: @session.match.opponent_name,
points_target: Scoring::Rules.from_match(@session.match).points_target(score&.current_set || 1), points_target: Scoring::Rules.points_target_for_match(@session.match, current_set: score&.current_set || 1),
score: score&.as_cable_payload score: score&.as_cable_payload
} }
end end

View File

@@ -1,5 +1,7 @@
module Public module Public
class ReplayController < SiteBaseController class ReplayController < SiteBaseController
include Public::LiveHelper
layout "marketing_live" layout "marketing_live"
before_action :set_session_and_recording, only: %i[show stream thumbnail download] before_action :set_session_and_recording, only: %i[show stream thumbnail download]
@@ -50,6 +52,15 @@ module Public
private private
def serve_video(disposition:) def serve_video(disposition:)
expires_in = disposition == "attachment" ? MatchLiveTv.replay_download_url_ttl_seconds : MatchLiveTv.replay_presigned_url_ttl_seconds
redirect_url = Recordings::PublicMediaUrl.new(
key: @recording.storage_key,
expires_in: expires_in,
disposition: disposition,
filename: disposition == "attachment" ? download_filename : nil
).call
return redirect_to redirect_url, allow_other_host: true if redirect_url.present?
Recordings::ServeFromStorage.new( Recordings::ServeFromStorage.new(
key: @recording.storage_key, key: @recording.storage_key,
content_type: "video/mp4", content_type: "video/mp4",
@@ -64,6 +75,9 @@ module Public
key = @recording.thumbnail_storage_key key = @recording.thumbnail_storage_key
return head :not_found if key.blank? return head :not_found if key.blank?
redirect_url = Recordings::PublicMediaUrl.new(key: key, expires_in: MatchLiveTv.replay_presigned_url_ttl_seconds).call
return redirect_to redirect_url, allow_other_host: true if redirect_url.present?
Recordings::ServeFromStorage.new( Recordings::ServeFromStorage.new(
key: key, key: key,
content_type: "image/jpeg", content_type: "image/jpeg",

View File

@@ -165,7 +165,12 @@ module Public
end end
def team_params def team_params
p = params.require(:team).permit(:name, :sport, :description, :logo_url, :primary_color, :secondary_color) p = params.require(:team).permit(:name, :sport, :sport_key, :description, :logo_url, :primary_color, :secondary_color)
if p[:sport].present? && p[:sport_key].blank?
p[:sport_key] = p.delete(:sport)
elsif p[:sport_key].present?
p.delete(:sport)
end
p[:primary_color] = p[:primary_color].presence p[:primary_color] = p[:primary_color].presence
p[:secondary_color] = p[:secondary_color].presence p[:secondary_color] = p[:secondary_color].presence
if p[:primary_color].present? if p[:primary_color].present?

View File

@@ -0,0 +1,11 @@
# frozen_string_literal: true
module Sports
class BoardType
ALL = %w[volley basket timed racket timer generic].freeze
def self.valid?(value)
ALL.include?(value.to_s)
end
end
end

View File

@@ -0,0 +1,94 @@
# frozen_string_literal: true
module Sports
class Catalog
LEGACY_SPORT_MAP = {
"volleyball" => "pallavolo"
}.freeze
class << self
def all
@all ||= load_entries
end
def keys
all.keys
end
def find(key)
normalized = normalize_key(key)
entry = all[normalized]
raise KeyError, "Sport sconosciuto: #{key}" unless entry
entry.merge(key: normalized)
end
def find_optional(key)
normalized = normalize_key(key)
entry = all[normalized]
return nil unless entry
entry.merge(key: normalized)
end
def board_for(key)
find(key)[:board]
end
def overlay_for(key)
find(key)[:overlay]
end
def allowed_overlays_for(key)
find(key)[:allowed_overlays]
end
def default_rules_for(key)
deep_stringify_keys(find(key)[:default_rules] || {})
end
def normalize_key(key)
raw = key.to_s.strip
return "pallavolo" if raw.blank?
LEGACY_SPORT_MAP.fetch(raw, raw)
end
def as_api_list
all.map do |key, entry|
{
key: key,
label: entry[:label],
board: entry[:board],
overlay: entry[:overlay],
allowed_overlays: entry[:allowed_overlays],
default_rules: entry[:default_rules] || {}
}
end
end
private
def load_entries
path = Rails.root.join("config/sports.yml")
raw = YAML.safe_load(File.read(path), permitted_classes: [], aliases: true) || {}
raw.transform_keys(&:to_s).transform_values do |entry|
entry.deep_symbolize_keys
end.freeze
end
def deep_stringify_keys(value)
case value
when Hash
value.each_with_object({}) do |(k, v), h|
h[k.to_s] = deep_stringify_keys(v)
end
when Array
value.map { |v| deep_stringify_keys(v) }
else
value
end
end
end
end
end

View File

@@ -0,0 +1,11 @@
# frozen_string_literal: true
module Sports
class OverlayKind
ALL = %w[none volley basket timed racket].freeze
def self.valid?(value)
ALL.include?(value.to_s)
end
end
end

View File

@@ -0,0 +1,25 @@
# frozen_string_literal: true
module Sports
class PlayerRoles
BY_BOARD = {
"volley" => %w[Schiacciatore Opposto Centrale Palleggiatore Libero Universale],
"basket" => ["Playmaker", "Guardia", "Ala", "Ala forte", "Centro"],
"racket" => %w[Giocatore],
"timed" => %w[Portiere Difensore Centrocampista Attaccante],
"generic" => %w[Giocatore],
"timer" => %w[Giocatore]
}.freeze
class << self
def for_team(team)
board = team.effective_board_type
BY_BOARD[board] || BY_BOARD["generic"]
end
def for_board(board)
BY_BOARD[board.to_s] || BY_BOARD["generic"]
end
end
end
end

View File

@@ -0,0 +1,38 @@
# frozen_string_literal: true
module Sports
class RulesSchema
INTEGER_RULES = {
"volley" => %w[sets_to_win points_per_set points_deciding_set min_point_lead],
"racket" => %w[sets_to_win points_per_set points_deciding_set min_point_lead],
"basket" => %w[periods period_duration_secs overtime_duration_secs],
"timed" => %w[periods period_duration_secs overtime_duration_secs],
"timer" => [],
"generic" => []
}.freeze
def self.validate!(board_type, rules)
board = board_type.to_s
return if board == "generic" || board == "timer"
hash = rules.is_a?(Hash) ? rules : {}
INTEGER_RULES.fetch(board, []).each do |key|
val = hash[key] || hash[key.to_sym]
next if val.blank?
unless val.is_a?(Integer) || val.to_s.match?(/\A\d+\z/)
raise ArgumentError, "#{key} non valido"
end
int_val = val.to_i
raise ArgumentError, "#{key} deve essere positivo" if int_val < 1
end
end
def self.validate(board_type, rules)
validate!(board_type, rules)
true
rescue ArgumentError
false
end
end
end

View File

@@ -19,4 +19,22 @@ module AdminHelper
mins = minutes % 60 mins = minutes % 60
hours.positive? ? "#{hours}h #{mins}m" : "#{mins} min" hours.positive? ? "#{hours}h #{mins}m" : "#{mins} min"
end end
def admin_session_watch_links(session)
links = []
if session.matchlivetv_platform?
links << { label: "Pagina live", url: session.watch_page_url }
end
youtube = session.youtube_watch_url
links << { label: "YouTube", url: youtube } if youtube.present?
links
end
def admin_regia_expires_label(iso_time)
return if iso_time.blank?
Time.zone.parse(iso_time).strftime("%d/%m/%Y %H:%M")
rescue ArgumentError, TypeError
nil
end
end end

View File

@@ -16,4 +16,9 @@ module ApplicationHelper
value = date_or_time.respond_to?(:in_time_zone) ? date_or_time.in_time_zone : date_or_time value = date_or_time.respond_to?(:in_time_zone) ? date_or_time.in_time_zone : date_or_time
I18n.with_locale(:it) { I18n.l(value, format: format) } I18n.with_locale(:it) { I18n.l(value, format: format) }
end end
def sport_catalog_options(selected = nil)
options = Sports::Catalog.as_api_list.map { |entry| [entry[:label], entry[:key]] }
options_for_select(options, Sports::Catalog.normalize_key(selected))
end
end end

View File

@@ -1,13 +1,30 @@
module Public module Public
module LiveHelper module LiveHelper
def live_score_sets_label(score_state) def live_score_sets_label(score_state, match = nil)
return nil unless score_state return nil unless score_state
"Set #{score_state.current_set} · Set vinti #{score_state.home_sets}-#{score_state.away_sets}" board = match&.effective_board_type || score_state.effective_board_type
case board
when "basket", "timed"
live_score_period_label(match, score_state)
when "timer"
"Cronometro"
when "generic"
"Punteggio"
else
"Set #{score_state.current_set} · Set vinti #{score_state.home_sets}-#{score_state.away_sets}"
end
end
def live_score_period_label(match, score_state)
return "" unless score_state
score_state.current_period_label
end end
def live_score_partials_label(score_state) def live_score_partials_label(score_state)
return nil unless score_state return nil unless score_state
return nil unless score_state.effective_board_type.in?(%w[volley racket])
partials = Array(score_state.set_partials) partials = Array(score_state.set_partials)
return nil if partials.empty? return nil if partials.empty?
@@ -58,30 +75,21 @@ module Public
def live_score_points_label(match, score_state) def live_score_points_label(match, score_state)
return "" unless score_state return "" unless score_state
"#{match.team.name} #{score_state.home_points} - #{score_state.away_points} #{match.opponent_name}" board = match.effective_board_type
end home = case board
when "basket", "timed" then score_state.basket_home_score
else score_state.home_points
end
away = case board
when "basket", "timed" then score_state.basket_away_score
else score_state.away_points
end
# Colore ospite sul tabellone: secondario società se leggibile su bianco, altrimenti blu visitatore. "#{match.team.name} #{home} - #{away} #{match.opponent_name}"
def live_scorebug_away_color(team)
club = team.club
candidate = club&.effective_secondary_color.presence || "#1565c0"
return candidate unless light_hex_color?(candidate)
"#1565c0"
end end
private private
def light_hex_color?(hex)
h = hex.to_s.delete_prefix("#")
return false unless h.match?(/\A\h{6}\z/i)
r = h[0..1].to_i(16)
g = h[2..3].to_i(16)
b = h[4..5].to_i(16)
(0.299 * r + 0.587 * g + 0.114 * b) > 200
end
def format_set_partial_entry(partial) def format_set_partial_entry(partial)
data = partial.respond_to?(:with_indifferent_access) ? partial.with_indifferent_access : partial data = partial.respond_to?(:with_indifferent_access) ? partial.with_indifferent_access : partial
set_no = data[:set] || data["set"] set_no = data[:set] || data["set"]

View File

@@ -0,0 +1,32 @@
module Public
module RegiaHelper
def regia_board_partial(match)
board = match.effective_board_type
partial = "public/regia/#{board}"
lookup_context.exists?(partial, [], true) ? partial : "public/regia/volley"
end
def regia_header_subtitle(match, score)
board = match.effective_board_type
case board
when "basket", "timed"
live_score_period_label(match, score)
when "timer"
"Cronometro"
when "generic"
"Punteggio"
else
"Set #{score.current_set}"
end
end
def format_clock_secs(secs, count_up: false)
total = secs.to_i
return count_up ? "0:00" : "" if total <= 0 && !count_up
m = total / 60
s = total % 60
format("%d:%02d", m, s)
end
end
end

View File

@@ -0,0 +1,24 @@
module RecordingsArchiveHelper
ArchivePaths = Struct.new(:index, :update, :destroy, :publish_youtube, :back, keyword_init: true)
def recordings_archive_paths(club, namespace:)
case namespace
when :admin
ArchivePaths.new(
index: admin_club_recordings_path(club),
update: ->(rec) { admin_club_recording_path(club, rec) },
destroy: ->(rec) { admin_club_recording_path(club, rec) },
publish_youtube: ->(rec) { publish_youtube_admin_club_recording_path(club, rec) },
back: admin_club_path(club)
)
else
ArchivePaths.new(
index: public_club_recordings_path(club),
update: ->(rec) { public_club_recording_path(club, rec) },
destroy: ->(rec) { public_club_recording_path(club, rec) },
publish_youtube: ->(rec) { public_club_recording_publish_youtube_path(club, rec) },
back: public_club_path(club)
)
end
end
end

View File

@@ -23,9 +23,10 @@ module RosterHelper
end end
end end
def roster_player_role_options(selected = nil) def roster_player_role_options(team, selected = nil)
roles = Sports::PlayerRoles.for_team(team)
options_for_select( options_for_select(
[["— Seleziona ruolo —", ""]] + TeamRosterMember::VOLLEYBALL_PLAYER_ROLES.map { |r| [r, r] }, [["— Seleziona ruolo —", ""]] + roles.map { |r| [r, r] },
selected selected
) )
end end

View File

@@ -0,0 +1,33 @@
module Ops
class HealthMonitorJob
include Sidekiq::Job
sidekiq_options retry: 1, queue: "default"
INTERVAL_SECS = ENV.fetch("OPS_HEALTH_INTERVAL_SECS", "180").to_i
REDIS_CHAIN_KEY = "ops:health_monitor:chain"
HEARTBEAT_KEY = "ops:health_monitor:last_run"
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
self.class.redis&.set(HEARTBEAT_KEY, Time.current.to_i, ex: INTERVAL_SECS * 3)
Ops::HealthChecks.new.call
ensure
self.class.perform_in(INTERVAL_SECS)
end
end
end

View File

@@ -0,0 +1,16 @@
module Recordings
class CleanupLocalJob
include Sidekiq::Job
sidekiq_options retry: 1, queue: "default"
def perform
result = Recordings::CleanupLocal.new.call
orphan = Mediamtx::CleanupOrphanPaths.new.call
Rails.logger.info(
"[Recordings::CleanupLocalJob] removed=#{result.removed} " \
"freed=#{result.freed_bytes} skipped=#{result.skipped} " \
"orphan_paths=#{orphan.removed}"
)
end
end
end

View File

@@ -0,0 +1,17 @@
module Recordings
class SyncYoutubePrivacyJob
include Sidekiq::Job
sidekiq_options retry: 2, queue: "default"
def perform(recording_id)
recording = Recording.find_by(id: recording_id)
return unless recording&.youtube_video_id.present?
Youtube::VideoLifecycleService.new(recording).update_privacy!(
privacy_status: recording.privacy_status
)
rescue Youtube::VideoLifecycleService::Error => e
Rails.logger.warn("[SyncYoutubePrivacyJob] #{recording_id}: #{e.message}")
end
end
end

View File

@@ -0,0 +1,11 @@
module Ops
class AlertMailer < ApplicationMailer
def incident(incident)
@incident = incident
mail(
to: ENV.fetch("OPS_ALERT_EMAIL"),
subject: "[Match Live TV Ops] #{incident.severity.upcase}: #{incident.title}"
)
end
end
end

View File

@@ -2,12 +2,23 @@ class Match < ApplicationRecord
belongs_to :team belongs_to :team
has_many :stream_sessions, dependent: :destroy has_many :stream_sessions, dependent: :destroy
has_one_attached :opponent_logo_file
ACTIVE_SESSION_STATUSES = %w[live connecting reconnecting paused].freeze ACTIVE_SESSION_STATUSES = %w[live connecting reconnecting paused].freeze
DEFAULT_OPPONENT_COLOR = "#1E3A8A"
validates :opponent_name, presence: true validates :opponent_name, presence: true
validates :sets_to_win, numericality: { greater_than: 0, less_than: 6 } validates :sets_to_win, numericality: { greater_than: 0, less_than: 6 }
validates :sport_key, presence: true
validates :opponent_primary_color, format: { with: Brandable::HEX_COLOR }, allow_blank: true
validate :sport_key_known
validate :overlay_kind_allowed
validate :scoring_rules_shape
validate :opponent_logo_file_type, if: -> { opponent_logo_file.attached? }
before_validation :normalize_sport_key
before_validation :inherit_sport_from_team, on: :create
# Partite ancora da giocare in diretta (orario nel futuro).
scope :scheduled_for_live, -> { scope :scheduled_for_live, -> {
where.not(scheduled_at: nil).where("scheduled_at >= ?", Time.zone.now) where.not(scheduled_at: nil).where("scheduled_at >= ?", Time.zone.now)
} }
@@ -23,8 +34,10 @@ class Match < ApplicationRecord
end end
def active_stream_session def active_stream_session
stream_sessions.sort_by { |s| s.created_at }.reverse latest = stream_sessions.max_by(&:created_at)
.find { |s| !%w[ended error].include?(s.status) } return nil if latest.nil? || latest.status.in?(%w[ended error])
latest
end end
def deletable? def deletable?
@@ -55,4 +68,110 @@ class Match < ApplicationRecord
def broadcast_active? def broadcast_active?
stream_sessions.where(status: ACTIVE_SESSION_STATUSES).exists? stream_sessions.where(status: ACTIVE_SESSION_STATUSES).exists?
end end
def stream_completed?
stream_sessions.where(status: %w[ended error]).exists?
end
def opponent_logo_url
return unless opponent_logo_file.attached?
Rails.application.routes.url_helpers.rails_blob_path(opponent_logo_file, only_path: true)
end
def effective_opponent_primary_color
opponent_primary_color.presence || DEFAULT_OPPONENT_COLOR
end
def coach_hub_visible?
active = active_stream_session
return true if active&.resumable?
return true if active&.idle?
return false if stream_completed?
scheduled_upcoming?
end
def effective_board_type
Sports::Catalog.board_for(sport_key)
end
def effective_overlay_kind
kind = overlay_kind.presence || Sports::Catalog.overlay_for(sport_key)
kind = "none" if kind == "timer"
allowed = Sports::Catalog.allowed_overlays_for(sport_key)
allowed.include?(kind) ? kind : Sports::Catalog.overlay_for(sport_key)
end
def effective_scoring_rules
merged = Sports::Catalog.default_rules_for(sport_key).deep_merge(normalized_scoring_rules)
merged["sets_to_win"] = sets_to_win if merged["sets_to_win"].blank? && sets_to_win.present?
merged
end
def sport_label
Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize
end
# Alias legacy API/spec (colonna rinominata in sport_key).
def sport
sport_key
end
def sport=(value)
self.sport_key = Sports::Catalog.normalize_key(value)
end
private
def normalize_sport_key
self.sport_key = Sports::Catalog.normalize_key(sport_key)
end
def inherit_sport_from_team
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 sport_key_known
return if sport_key.blank?
return if Sports::Catalog.find_optional(sport_key)
errors.add(:sport_key, "non valido")
end
def overlay_kind_allowed
return if overlay_kind.blank?
unless Sports::OverlayKind.valid?(overlay_kind)
errors.add(:overlay_kind, "non valido")
return
end
allowed = Sports::Catalog.allowed_overlays_for(sport_key)
errors.add(:overlay_kind, "non consentito per questo sport") unless allowed.include?(overlay_kind)
end
def normalized_scoring_rules
return {} unless scoring_rules.is_a?(Hash)
scoring_rules.each_with_object({}) do |(k, v), h|
h[k.to_s] = v
end
end
def opponent_logo_file_type
return if opponent_logo_file.content_type.in?(%w[image/png image/jpeg image/webp])
errors.add(:opponent_logo_file, "deve essere PNG, JPEG o WebP")
end
def scoring_rules_shape
Sports::RulesSchema.validate!(effective_board_type, normalized_scoring_rules)
rescue ArgumentError => e
errors.add(:scoring_rules, e.message)
end
end end

View File

@@ -0,0 +1,43 @@
module Ops
class Incident < ApplicationRecord
self.table_name = "ops_incidents"
KINDS = %w[
disk_space recordings_size service_down http_public http_rails rails_latency
sidekiq_stale sidekiq_dead log_pattern garage_storage
].freeze
SEVERITIES = %w[critical warning info].freeze
STATUSES = %w[open acknowledged resolved].freeze
validates :kind, :severity, :status, :title, :fingerprint, presence: true
validates :severity, inclusion: { in: SEVERITIES }
validates :status, inclusion: { in: STATUSES }
validates :occurrence_count, numericality: { greater_than: 0 }
scope :open, -> { where(status: %w[open acknowledged]) }
scope :unresolved, -> { where.not(status: "resolved") }
scope :critical_open, -> { open.where(severity: "critical") }
scope :recent, -> { order(last_seen_at: :desc) }
scope :since, ->(time) { where("last_seen_at >= ?", time) }
def open?
status.in?(%w[open acknowledged])
end
def muted?
muted_until.present? && muted_until.future?
end
def acknowledge!
update!(status: "acknowledged", acknowledged_at: Time.current)
end
def resolve!
update!(status: "resolved")
end
def mute!(duration: 24.hours)
update!(muted_until: duration.from_now)
end
end
end

View File

@@ -141,6 +141,28 @@ class Recording < ApplicationRecord
count == 1 ? "1 visualizzazione" : "#{count} visualizzazioni" count == 1 ? "1 visualizzazione" : "#{count} visualizzazioni"
end end
def source_platform
metadata.is_a?(Hash) ? metadata["source_platform"].presence : nil
end
def source_platform_label
case source_platform
when "youtube" then "YouTube"
when "matchlivetv" then "Match Live TV"
else ""
end
end
def playable_on_site?
ready? && storage_key.present?
end
def days_until_expiry
return nil unless expires_at
((expires_at - Time.current) / 1.day).ceil
end
def status_label def status_label
return "Eliminato" if deleted? return "Eliminato" if deleted?
return "Scaduto" if status == "expired" return "Scaduto" if status == "expired"

View File

@@ -1,9 +1,12 @@
class ScoreState < ApplicationRecord class ScoreState < ApplicationRecord
belongs_to :stream_session belongs_to :stream_session
before_validation :sync_board_type_from_match, on: :create
def as_cable_payload def as_cable_payload
{ payload = {
type: "score_update", type: "score_update",
board_type: effective_board_type,
home_sets: home_sets, home_sets: home_sets,
away_sets: away_sets, away_sets: away_sets,
home_points: home_points, home_points: home_points,
@@ -11,7 +14,74 @@ class ScoreState < ApplicationRecord
current_set: current_set, current_set: current_set,
set_partials: set_partials || [], set_partials: set_partials || [],
timeout_home: timeout_home, timeout_home: timeout_home,
timeout_away: timeout_away timeout_away: timeout_away,
period: period,
data: data || {}
} }
case effective_board_type
when "basket", "timed"
payload[:home_points] = basket_home_score
payload[:away_points] = basket_away_score
payload[:period] = current_period_label
payload[:clock_secs] = clock_secs
payload[:clock_running] = clock_running?
when "timer"
payload[:clock_secs] = clock_secs
payload[:clock_running] = clock_running?
end
payload
end
def effective_board_type
board_type.presence || stream_session&.match&.effective_board_type || "volley"
end
def basket_home_score
(data || {})["home_score"].to_i
end
def basket_away_score
(data || {})["away_score"].to_i
end
def clock_secs
(data || {})["clock_secs"].to_i
end
def clock_running?
!!(data || {})["clock_running"]
end
def current_period
Scoring::PeriodData.number_from((data || {})["period"]) || 1
end
def current_period_label
case effective_board_type
when "basket"
ot = (data || {})["overtime"]
return "OT#{current_period - rules_periods}" if ot
"Q#{current_period}"
when "timed"
ot = (data || {})["overtime"]
return "Suppl." if ot
"#{current_period}° tempo"
else
period.presence || "#{current_set}° set"
end
end
private
def sync_board_type_from_match
self.board_type = stream_session.match.effective_board_type if board_type.blank? && stream_session&.match
end
def rules_periods
stream_session&.match&.effective_scoring_rules&.dig("periods").to_i
end end
end end

View File

@@ -131,6 +131,12 @@ class StreamSession < ApplicationRecord
status.in?(%w[ended error]) status.in?(%w[ended error])
end end
def regia_token_active?
regia_token_digest.present? &&
regia_token_expires_at&.future? &&
!terminal?
end
def resumable? def resumable?
status.in?(%w[connecting live reconnecting paused]) status.in?(%w[connecting live reconnecting paused])
end end

View File

@@ -12,8 +12,12 @@ class Team < ApplicationRecord
has_one_attached :photo_file has_one_attached :photo_file
validates :name, presence: true validates :name, presence: true
validates :sport_key, presence: true
validate :sport_key_known
validate :photo_file_type, if: -> { photo_file.attached? } validate :photo_file_type, if: -> { photo_file.attached? }
before_validation :normalize_sport_key
def subscription def subscription
club.subscription club.subscription
end end
@@ -43,8 +47,35 @@ class Team < ApplicationRecord
end end
end end
def sport_label
Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize
end
def effective_board_type
Sports::Catalog.board_for(sport_key)
end
def sport
sport_key
end
def sport=(value)
self.sport_key = Sports::Catalog.normalize_key(value)
end
private 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 photo_file_type def photo_file_type
return if photo_file.content_type.in?(%w[image/png image/jpeg image/webp]) return if photo_file.content_type.in?(%w[image/png image/jpeg image/webp])

View File

@@ -9,14 +9,7 @@ class TeamRosterMember < ApplicationRecord
"player" => "Giocatori" "player" => "Giocatori"
}.freeze }.freeze
VOLLEYBALL_PLAYER_ROLES = %w[ VOLLEYBALL_PLAYER_ROLES = Sports::PlayerRoles::BY_BOARD["volley"].freeze
Schiacciatore
Opposto
Centrale
Palleggiatore
Libero
Universale
].freeze
belongs_to :team belongs_to :team
@@ -74,9 +67,10 @@ class TeamRosterMember < ApplicationRecord
end end
def player_role_label_valid def player_role_label_valid
return if role_label.in?(VOLLEYBALL_PLAYER_ROLES) allowed = Sports::PlayerRoles.for_team(team)
return if role_label.in?(allowed)
errors.add(:role_label, "non è un ruolo pallavolo valido") errors.add(:role_label, "non è un ruolo valido per questo sport")
end end
def photo_file_type def photo_file_type

View File

@@ -84,26 +84,57 @@ module Admin
def disk_usage_for(path) def disk_usage_for(path)
return unavailable_disk unless path.present? && (Dir.exist?(path) || File.exist?(path)) return unavailable_disk unless path.present? && (Dir.exist?(path) || File.exist?(path))
usage = disk_usage_via_statvfs(path) || disk_usage_via_df(path)
return unavailable_disk unless usage
usage.merge(dir_size_bytes: recordings_dir_size(path))
rescue StandardError
unavailable_disk
end
def disk_usage_via_statvfs(path)
return nil unless File.respond_to?(:statvfs)
stat = File.statvfs(path) stat = File.statvfs(path)
total = stat.blocks * stat.frsize total = stat.blocks * stat.frsize
free = stat.bavail * stat.frsize free = stat.bavail * stat.frsize
used = total - free used = total - free
build_disk_usage(path, total: total, used: used, free: free)
rescue StandardError
nil
end
def disk_usage_via_df(path)
output = shell_out("df -B1 --output=size,used,avail,pcent,target #{Shellwords.escape(path)} 2>/dev/null").strip
line = output.lines.map(&:strip).reject(&:blank?).last
return nil if line.blank? || line.start_with?("Filesystem")
parts = line.split
return nil if parts.size < 5
total = parts[0].to_i
used = parts[1].to_i
free = parts[2].to_i
pct = parts[3].delete("%").to_f
build_disk_usage(path, total: total, used: used, free: free, used_percent: pct)
rescue StandardError
nil
end
def build_disk_usage(path, total:, used:, free:, used_percent: nil)
{ {
path: path, path: path,
total_bytes: total, total_bytes: total,
used_bytes: used, used_bytes: used,
free_bytes: free, free_bytes: free,
used_percent: total.positive? ? ((used.to_f / total) * 100).round(1) : 0.0, used_percent: used_percent || (total.positive? ? ((used.to_f / total) * 100).round(1) : 0.0)
dir_size_bytes: recordings_dir_size(path)
} }
rescue StandardError
unavailable_disk
end end
def recordings_dir_size(path) def recordings_dir_size(path)
return nil unless path == RECORDINGS_PATH && Dir.exist?(path) return nil unless path == RECORDINGS_PATH && Dir.exist?(path)
`du -sb #{Shellwords.escape(path)} 2>/dev/null`.split.first.to_i shell_out("du -sb #{Shellwords.escape(path)} 2>/dev/null").split.first.to_i
rescue StandardError rescue StandardError
nil nil
end end
@@ -112,6 +143,10 @@ module Admin
{ path: nil, total_bytes: nil, used_bytes: nil, free_bytes: nil, used_percent: nil, dir_size_bytes: nil } { path: nil, total_bytes: nil, used_bytes: nil, free_bytes: nil, used_percent: nil, dir_size_bytes: nil }
end end
def shell_out(cmd)
`#{cmd}`
end
def read_network def read_network
rx, tx = parse_net_bytes rx, tx = parse_net_bytes
prev = MetricsStore.net_totals prev = MetricsStore.net_totals

View File

@@ -0,0 +1,94 @@
# frozen_string_literal: true
module Mediamtx
# Rimuove path MediaMTX live/match_{uuid} lasciati attivi dopo fine diretta.
class CleanupOrphanPaths
Result = Struct.new(:removed, :skipped, keyword_init: true)
SESSION_PATH = %r{\Alive/match_([0-9a-f-]{36})(?:_air)?\z}i
ACTIVE_SESSION_STATUSES = %w[live paused connecting reconnecting].freeze
PROCESSING_GRACE = -> { ENV.fetch("RECORDINGS_LOCAL_PROCESSING_GRACE_HOURS", "6").to_i.hours }
def initialize(client: Client.new, logger: Rails.logger)
@client = client
@logger = logger
@removed = 0
@skipped = 0
@deleted_session_ids = Set.new
end
def call
online = @client.online_path_names
@client.list_paths.each do |item|
name = item["name"].to_s
session_id = session_id_from_path(name)
unless session_id
@skipped += 1
next
end
if online.include?(name) || item["online"]
@skipped += 1
next
end
next unless removable?(session_id)
remove_session_paths(session_id, name)
end
Result.new(removed: @removed, skipped: @skipped)
end
def removable?(session_id)
session = StreamSession.find_by(id: session_id)
return true if session.nil?
return false if ACTIVE_SESSION_STATUSES.include?(session.status)
recording = Recording.find_by(stream_session: session)
return true if recording.nil?
return false if recording.status == "processing" && processing_within_grace?(session)
true
end
private
def session_id_from_path(path_name)
match = path_name.match(SESSION_PATH)
match&.[](1)
end
def processing_within_grace?(session)
ended_at = session.ended_at || session.updated_at
ended_at.nil? || ended_at >= PROCESSING_GRACE.call.ago
end
def remove_session_paths(session_id, path_name)
return if @deleted_session_ids.include?(session_id)
session = StreamSession.find_by(id: session_id)
if session
@client.delete_path(session)
else
delete_orphan_path_names(session_id)
end
@deleted_session_ids.add(session_id)
@removed += 1
@logger.info("[Mediamtx::CleanupOrphanPaths] removed paths for session #{session_id} (from #{path_name})")
rescue Client::Error, Faraday::Error => e
@skipped += 1
@logger.warn("[Mediamtx::CleanupOrphanPaths] skip #{path_name}: #{e.message}")
end
def delete_orphan_path_names(session_id)
@client.delete_path_name("live/match_#{session_id}")
@client.delete_path_name("live/match_#{session_id}_air")
end
end
end

View File

@@ -40,8 +40,17 @@ module Mediamtx
end end
def delete_path_name(path_name) def delete_path_name(path_name)
@conn.post("/v3/config/paths/delete/#{CGI.escape(path_name)}") return true if path_name.blank?
response = @conn.delete("/v3/config/paths/delete/#{CGI.escape(path_name)}")
return true if response.status == 404
unless response.success?
err = response.body.is_a?(Hash) ? response.body["error"] : response.body
raise Error, "MediaMTX path delete failed: #{response.status} #{err}"
end
forget_always_available(path_name) forget_always_available(path_name)
true
end end
def set_path_recording(session, enabled:) def set_path_recording(session, enabled:)

View File

@@ -2,6 +2,8 @@ module Mediamtx
# MediaMTX in produzione è distroless: runOnReady con wget non funziona. # MediaMTX in produzione è distroless: runOnReady con wget non funziona.
# Sincronizziamo lo stato Rails leggendo l'API paths (poll da /live/:id/status.json). # Sincronizziamo lo stato Rails leggendo l'API paths (poll da /live/:id/status.json).
class PublisherSync class PublisherSync
RECORDING_PATCH_COOLDOWN = -> { ENV.fetch("MEDIAMTX_RECORDING_PATCH_COOLDOWN_SECS", "45").to_i.seconds }
def initialize(session) def initialize(session)
@session = session @session = session
end end
@@ -9,15 +11,15 @@ module Mediamtx
def call def call
return @session if @session.terminal? return @session if @session.terminal?
info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == @session.mediamtx_path_name } path_info = Mediamtx::PublisherOnline.path_info(@session)
publisher_online = Mediamtx::PublisherOnline.active_path?(info) publisher_online = Mediamtx::PublisherOnline.active_path?(path_info)
if publisher_online if publisher_online
clear_publisher_misses!(@session.id) clear_publisher_misses!(@session.id)
if @session.paused? if @session.paused?
# RTMP ancora connesso in pausa: non forzare live/reconnect. # RTMP ancora connesso in pausa: non forzare live/reconnect.
else else
enable_live_path!(@session) enable_live_path_once!(@session)
if @session.may_go_live? if @session.may_go_live?
@session.go_live! @session.go_live!
@session.reload @session.reload
@@ -25,21 +27,26 @@ module Mediamtx
elsif @session.reconnecting? && @session.may_reconnect? elsif @session.reconnecting? && @session.may_reconnect?
@session.reconnect! @session.reconnect!
cancel_timeout cancel_timeout
kick_youtube!(@session) kick_youtube!(@session) if @session.platform == "youtube"
end end
kick_youtube!(@session) if @session.platform == "youtube" kick_youtube!(@session) if @session.platform == "youtube"
end end
elsif !@session.paused? && @session.live? && @session.may_lose_connection? elsif !@session.paused? && @session.live? && @session.may_lose_connection?
if publisher_offline_sustained?(@session.id) if publisher_offline_sustained?(@session.id)
clear_publisher_misses!(@session.id) clear_publisher_misses!(@session.id)
disable_recording_on_path!(@session, path_info: path_info)
restore_slate_path!(@session) restore_slate_path!(@session)
@session.lose_connection! @session.lose_connection!
schedule_timeout unless @session.paused? schedule_timeout unless @session.paused?
end end
elsif !publisher_online && @session.status.in?(%w[reconnecting connecting]) && !@session.paused? elsif !publisher_online && @session.status.in?(%w[reconnecting connecting]) && !@session.paused?
# Non togliere recording qui: un gap RTMP breve non deve ricaricare la config MediaMTX.
restore_slate_path!(@session) restore_slate_path!(@session)
end end
desired_recording = recording_should_be_active?(publisher_online)
sync_path_recording!(@session, enabled: desired_recording, path_info: path_info)
@session.reload.tap do |session| @session.reload.tap do |session|
schedule_youtube_relay_if_needed(session) schedule_youtube_relay_if_needed(session)
end end
@@ -73,14 +80,15 @@ module Mediamtx
Youtube::LivePipeline.schedule!(session, force: force) Youtube::LivePipeline.schedule!(session, force: force)
end end
# Match Live TV: lascia alwaysAvailable attivo — MediaMTX usa il publisher quando c'è def enable_live_path_once!(session)
# e la copertina nei gap RTMP (telefono instabile). Disattivare la slate lasciava HLS
# senza stream ("no stream is available") e il player web in pausa/spinner.
def enable_live_path!(session)
return unless session.platform == "youtube" return unless session.platform == "youtube"
key = format("youtube:slate_disabled:%s", session.id)
return unless redis.set(key, "1", nx: true, ex: 48.hours.to_i)
Client.new.set_always_available(session, enabled: false) Client.new.set_always_available(session, enabled: false)
rescue Client::Error => e rescue Client::Error => e
redis.del(format("youtube:slate_disabled:%s", session.id))
Rails.logger.warn("[PublisherSync] disable slate session=#{session.id}: #{e.message}") Rails.logger.warn("[PublisherSync] disable slate session=#{session.id}: #{e.message}")
end end
@@ -92,6 +100,70 @@ module Mediamtx
Rails.logger.warn("[PublisherSync] enable slate session=#{session.id}: #{e.message}") Rails.logger.warn("[PublisherSync] enable slate session=#{session.id}: #{e.message}")
end end
def recording_should_be_active?(publisher_online)
return false unless @session.match.team.entitlements.recording_enabled_for_mediamtx?
return false if @session.paused? || @session.terminal?
return false unless publisher_online
return false unless @session.status.in?(%w[live reconnecting])
true
end
def disable_recording_on_path!(session, path_info:)
sync_path_recording!(session, enabled: false, path_info: path_info, force: true)
end
# In produzione i webhook MediaMTX (wget) non girano: la registrazione va accesa al primo go-live.
# Evitiamo patch ripetute: ogni reload config distrugge i muxer HLS (500 sul proxy /hls/).
def sync_path_recording!(session, enabled:, path_info: nil, force: false)
return unless session.match.team.entitlements.recording_enabled_for_mediamtx?
key = recording_state_key(session.id)
desired = enabled ? "1" : "0"
return if redis.get(key) == desired
return if path_info && path_recording_active?(path_info) == enabled
if !enabled && !force && recording_patch_on_cooldown?(session.id)
Rails.logger.debug("[PublisherSync] skip recording patch session=#{session.id} (cooldown)")
return
end
Client.new.set_path_recording(session, enabled: enabled)
redis.set(key, desired, ex: 48.hours.to_i)
mark_recording_patch!(session.id)
rescue Client::Error => e
Rails.logger.warn("[PublisherSync] recording enabled=#{enabled} session=#{session.id}: #{e.message}")
end
def path_recording_active?(path_info)
path_info["record"] == true
end
def recording_patch_on_cooldown?(session_id)
last = redis.get(recording_patch_at_key(session_id)).to_i
last.positive? && Time.at(last) > RECORDING_PATCH_COOLDOWN.call.ago
end
def mark_recording_patch!(session_id)
redis.set(recording_patch_at_key(session_id), Time.current.to_i, ex: 48.hours.to_i)
end
def recording_patch_at_key(session_id)
format("mediamtx:recording:patched_at:%s", session_id)
end
def self.forget_recording_state!(session_id)
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
redis.del("mediamtx:recording:#{session_id}")
redis.del(format("mediamtx:recording:patched_at:%s", session_id))
rescue Redis::BaseError
nil
end
def recording_state_key(session_id)
"mediamtx:recording:#{session_id}"
end
# Evita reconnect su un singolo poll offline (RTMP instabile dal telefono). # Evita reconnect su un singolo poll offline (RTMP instabile dal telefono).
def publisher_offline_sustained?(session_id) def publisher_offline_sustained?(session_id)
misses = redis.incr(format("publisher_offline_miss:%s", session_id)).to_i misses = redis.incr(format("publisher_offline_miss:%s", session_id)).to_i

View File

@@ -0,0 +1,326 @@
module Ops
class HealthChecks
Finding = Struct.new(:kind, :severity, :healthy, :title, :message, :metadata, :fingerprint, keyword_init: true)
DISK_WARN_PERCENT = -> { ENV.fetch("OPS_DISK_WARN_PERCENT", "80").to_f }
DISK_CRIT_PERCENT = -> { ENV.fetch("OPS_DISK_CRIT_PERCENT", "90").to_f }
RECORDINGS_WARN_BYTES = -> { ENV.fetch("OPS_RECORDINGS_WARN_GB", "20").to_i.gigabytes }
RECORDINGS_CRIT_BYTES = -> { ENV.fetch("OPS_RECORDINGS_CRIT_GB", "50").to_i.gigabytes }
SIDEKIQ_STALE_SECS = -> { ENV.fetch("OPS_SIDEKIQ_STALE_SECS", "300").to_i }
def call
build_findings.each { |finding| process_finding(finding) }
build_findings
end
def summary
findings = build_findings
status = if findings.any? { |f| !f.healthy && f.severity == "critical" }
"down"
elsif findings.any? { |f| !f.healthy }
"degraded"
else
"ok"
end
{
status: status,
checked_at: Time.current.iso8601,
checks: findings.map { |f| finding_to_hash(f) }
}
end
private
def build_findings
findings = [
check_disk_root,
check_recordings_size,
check_postgres,
check_redis,
check_mediamtx,
check_garage,
check_sidekiq_heartbeat,
check_sidekiq_dead,
check_http_rails,
check_rails_latency
]
findings << check_http_public if public_check_due?
findings
end
def process_finding(finding)
if finding.healthy
Ops::IncidentRecorder.resolve(fingerprint: finding.fingerprint)
else
Ops::IncidentRecorder.record(
kind: finding.kind,
severity: finding.severity,
title: finding.title,
message: finding.message,
metadata: finding.metadata,
fingerprint: finding.fingerprint
)
end
end
def public_check_due?
return false if Rails.env.test?
redis = health_redis
return true unless redis
last = redis.get(public_check_redis_key).to_i
due = last.zero? || (Time.current.to_i - last) >= MatchLiveTv.ops_http_public_interval_seconds
redis.set(public_check_redis_key, Time.current.to_i) if due
due
end
def public_check_redis_key
"ops:health:last_public_check_at"
end
def health_redis
@health_redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
rescue Redis::CannotConnectError
nil
end
def check_disk_root
disk = Admin::HostMetrics.new.send(:read_disk)[:root]
pct = disk[:used_percent].to_f
healthy = pct < DISK_WARN_PERCENT.call
severity = pct >= DISK_CRIT_PERCENT.call ? "critical" : "warning"
Finding.new(
kind: "disk_space",
severity: severity,
healthy: healthy,
title: healthy ? "Disco sistema OK" : "Disco sistema quasi pieno",
message: "Uso disco #{pct}% (#{format_bytes(disk[:used_bytes])} / #{format_bytes(disk[:total_bytes])})",
metadata: disk.transform_keys(&:to_s),
fingerprint: "disk_space:root"
)
end
def check_recordings_size
path = MatchLiveTv.recordings_local_path
disk = Admin::HostMetrics.new.send(:disk_usage_for, path)
size = disk[:dir_size_bytes].to_i
healthy = size < RECORDINGS_WARN_BYTES.call
severity = size >= RECORDINGS_CRIT_BYTES.call ? "critical" : "warning"
Finding.new(
kind: "recordings_size",
severity: severity,
healthy: healthy,
title: healthy ? "Archivio registrazioni OK" : "Archivio registrazioni elevato",
message: "Contenuto #{path}: #{format_bytes(size)}",
metadata: { "path" => path, "dir_size_bytes" => size },
fingerprint: "recordings_size:local"
)
end
def check_postgres
ActiveRecord::Base.connection.execute("SELECT 1")
ok_finding("service_down:postgres", "PostgreSQL OK")
rescue StandardError => e
fail_finding("service_down", "critical", "service_down:postgres", "PostgreSQL non raggiungibile", e.message)
end
def check_redis
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
redis.ping
ok_finding("service_down:redis", "Redis OK")
rescue StandardError => e
fail_finding("service_down", "critical", "service_down:redis", "Redis non raggiungibile", e.message)
end
def check_mediamtx
Mediamtx::Client.new.list_paths
ok_finding("service_down:mediamtx", "MediaMTX OK")
rescue StandardError => e
fail_finding("service_down", "critical", "service_down:mediamtx", "MediaMTX API non raggiungibile", e.message)
end
def check_garage
return ok_finding("garage_storage:skip", "Garage non configurato") if MatchLiveTv.replay_storage_local?
require "aws-sdk-s3"
client = Aws::S3::Client.new(
endpoint: MatchLiveTv.replay_storage_endpoint,
region: MatchLiveTv.replay_storage_region,
access_key_id: MatchLiveTv.replay_storage_access_key_id,
secret_access_key: MatchLiveTv.replay_storage_secret_access_key,
force_path_style: MatchLiveTv.replay_storage_force_path_style?
)
client.head_bucket(bucket: MatchLiveTv.replay_storage_bucket)
ok_finding("garage_storage:ok", "Garage storage OK")
rescue StandardError => e
fail_finding("garage_storage", "warning", "garage_storage:head", "Garage storage non raggiungibile", e.message)
end
def check_sidekiq_heartbeat
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
last = redis.get(Ops::HealthMonitorJob::HEARTBEAT_KEY).to_i
age = last.positive? ? Time.current.to_i - last : nil
healthy = age.present? && age < SIDEKIQ_STALE_SECS.call
Finding.new(
kind: "sidekiq_stale",
severity: "critical",
healthy: healthy,
title: healthy ? "Sidekiq attivo" : "Sidekiq non risponde",
message: healthy ? "Ultimo health check #{age}s fa" : "Heartbeat assente o scaduto (#{age || 'mai'}s)",
metadata: { "last_heartbeat_age_secs" => age },
fingerprint: "sidekiq_stale:heartbeat"
)
end
def check_sidekiq_dead
dead_count = Sidekiq::DeadSet.new.size
healthy = dead_count.zero?
severity = dead_count > 5 ? "critical" : "warning"
Finding.new(
kind: "sidekiq_dead",
severity: severity,
healthy: healthy,
title: healthy ? "Nessun job Sidekiq morto" : "Job Sidekiq in dead queue",
message: "#{dead_count} job nella dead queue",
metadata: { "dead_count" => dead_count },
fingerprint: "sidekiq_dead:count"
)
end
def check_http_rails
return ok_finding("http_rails:skip", "HTTP Rails check disabilitato in test") if Rails.env.test?
url = MatchLiveTv.ops_http_rails_url
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
response = Faraday.get(url) { |req| req.options.timeout = 5 }
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
Ops::UpLatencyTracker.record(elapsed_ms)
healthy = response.status == 200
Finding.new(
kind: "http_rails",
severity: "critical",
healthy: healthy,
title: healthy ? "Rails raggiungibile via edge" : "Rails non risponde via edge",
message: healthy ? "GET #{url} → 200 (#{elapsed_ms}ms)" : "GET #{url}#{response.status}",
metadata: { "url" => url, "status" => response.status, "latency_ms" => elapsed_ms },
fingerprint: "http_rails:up"
)
rescue Faraday::Error => e
fail_finding("http_rails", "critical", "http_rails:up", "Rails non raggiungibile via edge", e.message)
end
def check_rails_latency
return ok_finding("rails_latency:skip", "Latenza Rails non valutata in test") if Rails.env.test?
min_samples = ENV.fetch("OPS_UP_LATENCY_MIN_SAMPLES", "5").to_i
p95 = Ops::UpLatencyTracker.p95
count = Ops::UpLatencyTracker.sample_count
if p95.nil? || count < min_samples
return ok_finding(
"rails_latency:pending",
"Latenza /up: campioni insufficienti (#{count}/#{min_samples})"
)
end
warn_ms = MatchLiveTv.ops_up_latency_warn_ms
crit_ms = MatchLiveTv.ops_up_latency_crit_ms
healthy = p95 < warn_ms
severity = if p95 >= crit_ms
"critical"
elsif p95 >= warn_ms
"warning"
else
"info"
end
Finding.new(
kind: "rails_latency",
severity: severity,
healthy: healthy,
title: healthy ? "Latenza Rails OK" : "Latenza Rails elevata",
message: "p95 GET /up via edge: #{p95}ms (soglia warning #{warn_ms}ms, critical #{crit_ms}ms, n=#{count})",
metadata: { "p95_ms" => p95, "samples" => count, "warn_ms" => warn_ms, "crit_ms" => crit_ms },
fingerprint: "rails_latency:p95"
)
end
def check_http_public
return ok_finding("http_public:skip", "HTTP public check disabilitato in test") if Rails.env.test?
url = "#{MatchLiveTv.app_public_url.chomp('/')}/up"
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
response = Faraday.get(url) { |req| req.options.timeout = 10 }
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
healthy = response.status == 200
Finding.new(
kind: "http_public",
severity: "warning",
healthy: healthy,
title: healthy ? "Percorso pubblico raggiungibile" : "Percorso pubblico non risponde",
message: healthy ? "GET #{url} → 200 (#{elapsed_ms}ms)" : "GET #{url}#{response.status}",
metadata: { "url" => url, "status" => response.status, "latency_ms" => elapsed_ms },
fingerprint: "http_public:up"
)
rescue Faraday::Error => e
fail_finding("http_public", "warning", "http_public:up", "Percorso pubblico non raggiungibile", e.message)
end
def ok_finding(fingerprint, title)
Finding.new(
kind: fingerprint.split(":").first,
severity: "info",
healthy: true,
title: title,
message: title,
metadata: {},
fingerprint: fingerprint
)
end
def fail_finding(kind, severity, fingerprint, title, message)
Finding.new(
kind: kind,
severity: severity,
healthy: false,
title: title,
message: message,
metadata: {},
fingerprint: fingerprint
)
end
def finding_to_hash(finding)
{
kind: finding.kind,
severity: finding.severity,
healthy: finding.healthy,
title: finding.title,
message: finding.message,
metadata: finding.metadata,
fingerprint: finding.fingerprint
}
end
def format_bytes(bytes)
return "" if bytes.nil?
units = %w[B KB MB GB TB]
size = bytes.to_f
unit = units.shift
while size >= 1024 && units.any?
size /= 1024
unit = units.shift
end
"#{size.round(size >= 10 ? 0 : 1)} #{unit}"
end
end
end

View File

@@ -0,0 +1,67 @@
module Ops
class IncidentRecorder
COOLDOWN = -> { ENV.fetch("OPS_NOTIFY_COOLDOWN_MINUTES", "30").to_i.minutes }
class << self
def record(finding)
new.record(finding)
end
def resolve(fingerprint:)
new.resolve(fingerprint: fingerprint)
end
end
def record(finding)
fingerprint = finding[:fingerprint] || fingerprint_for(finding)
incident = Ops::Incident.open.find_by(fingerprint: fingerprint)
if incident
incident.occurrence_count += 1
incident.last_seen_at = Time.current
incident.title = finding[:title] if finding[:title].present?
incident.message = finding[:message] if finding[:message].present?
incident.metadata = (incident.metadata || {}).merge(finding[:metadata] || {})
incident.severity = finding[:severity] if finding[:severity].present?
incident.status = "open" if incident.status == "acknowledged"
else
now = Time.current
incident = Ops::Incident.new(
kind: finding[:kind],
severity: finding[:severity],
status: "open",
title: finding[:title],
message: finding[:message],
metadata: finding[:metadata] || {},
fingerprint: fingerprint,
occurrence_count: 1,
first_seen_at: now,
last_seen_at: now
)
end
incident.save!
maybe_notify!(incident)
incident
end
def resolve(fingerprint:)
Ops::Incident.open.where(fingerprint: fingerprint).find_each(&:resolve!)
end
private
def fingerprint_for(finding)
Digest::SHA256.hexdigest([finding[:kind], finding[:title]].join("|"))
end
def maybe_notify!(incident)
return if incident.muted?
return unless Ops::Notifier.notify_severity?(incident.severity)
return if incident.notified_at.present? && incident.notified_at > COOLDOWN.call.ago
Ops::Notifier.new.notify(incident)
incident.update_column(:notified_at, Time.current)
end
end
end

View File

@@ -0,0 +1,37 @@
module Ops
class LogPatternMatcher
Match = Struct.new(:name, :severity, :line, keyword_init: true)
def initialize(patterns: nil)
@patterns = patterns || load_patterns
end
def scan(text)
return [] if text.blank?
matches = []
text.each_line do |line|
@patterns.each do |pattern|
next unless line.match?(pattern[:regex])
matches << Match.new(name: pattern[:name], severity: pattern[:severity], line: line.strip)
end
end
matches
end
private
def load_patterns
path = Rails.root.join("config/ops_log_patterns.yml")
raw = YAML.safe_load(File.read(path), permitted_classes: [], aliases: true) || {}
Array(raw["patterns"]).map do |entry|
{
name: entry["name"].to_s,
severity: entry["severity"].to_s,
regex: Regexp.new(entry["regex"], Regexp::IGNORECASE)
}
end
end
end
end

View File

@@ -0,0 +1,26 @@
module Ops
class LogScanner
def initialize(matcher: LogPatternMatcher.new)
@matcher = matcher
end
def ingest(text)
matches = @matcher.scan(text)
matches.each { |match| record_match(match) }
matches.size
end
private
def record_match(match)
Ops::IncidentRecorder.record(
kind: "log_pattern",
severity: match.severity,
title: "Log: #{match.name}",
message: match.line.truncate(500),
metadata: { "pattern" => match.name, "sample" => match.line },
fingerprint: "log_pattern:#{match.name}"
)
end
end
end

View File

@@ -0,0 +1,65 @@
module Ops
class Notifier
class Error < StandardError; end
def self.notify_severity?(severity)
levels = ENV.fetch("OPS_NOTIFY_SEVERITIES", "critical").split(",").map(&:strip)
levels.include?(severity.to_s)
end
def notify(incident)
notify_ntfy(incident) if ntfy_url.present?
notify_email(incident) if alert_email.present?
end
private
def notify_ntfy(incident)
priority = incident.severity == "critical" ? "5" : "3"
tags = incident.severity == "critical" ? "rotating_light" : "warning"
conn = Faraday.new do |f|
f.adapter Faraday.default_adapter
f.options.timeout = 10
f.options.open_timeout = 5
end
headers = {
"Title" => "[Match Live TV] #{incident.title}",
"Priority" => priority,
"Tags" => tags
}
headers["Authorization"] = "Bearer #{ntfy_token}" if ntfy_token.present?
body = incident.message.to_s
body += "\n\n(#{incident.occurrence_count} occorrenze)" if incident.occurrence_count > 1
response = conn.post(ntfy_url, body, headers)
return if response.success?
Rails.logger.warn("[Ops::Notifier] ntfy failed: #{response.status} #{response.body}")
rescue Faraday::Error => e
Rails.logger.warn("[Ops::Notifier] ntfy error: #{e.message}")
end
def notify_email(incident)
return unless defined?(ActionMailer)
Ops::AlertMailer.incident(incident).deliver_later
rescue StandardError => e
Rails.logger.warn("[Ops::Notifier] email error: #{e.message}")
end
def ntfy_url
ENV["OPS_NTFY_URL"].presence
end
def ntfy_token
ENV["OPS_NTFY_TOKEN"].presence
end
def alert_email
ENV["OPS_ALERT_EMAIL"].presence
end
end
end

View File

@@ -0,0 +1,44 @@
# frozen_string_literal: true
module Ops
# Campioni latenza GET /up (edge → rails) per calcolo p95.
class UpLatencyTracker
REDIS_KEY = "ops:up_latency_ms"
MAX_SAMPLES = -> { ENV.fetch("OPS_UP_LATENCY_SAMPLES", "60").to_i }
class << self
def record(latency_ms)
return unless redis
redis.pipelined do |pipe|
pipe.lpush(REDIS_KEY, latency_ms.to_i)
pipe.ltrim(REDIS_KEY, 0, MAX_SAMPLES.call - 1)
end
end
def samples
return [] unless redis
redis.lrange(REDIS_KEY, 0, -1).map(&:to_i)
end
def sample_count
samples.size
end
def p95
sorted = samples.sort
return nil if sorted.empty?
index = [(sorted.size * 0.95).ceil - 1, 0].max
sorted[index]
end
def redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
rescue Redis::CannotConnectError
nil
end
end
end
end

View File

@@ -0,0 +1,103 @@
module Recordings
class CleanupLocal
ACTIVE_SESSION_STATUSES = %w[live connecting reconnecting paused].freeze
REMOVABLE_RECORDING_STATUSES = %w[ready failed expired].freeze
Result = Struct.new(:removed, :freed_bytes, :skipped, keyword_init: true)
def initialize(online_paths: nil, logger: Rails.logger)
@online_paths = online_paths
@logger = logger
@removed = 0
@freed_bytes = 0
@skipped = 0
end
def call
online = @online_paths || Mediamtx::Client.new.online_path_names
each_live_match_dir do |dir_path, session_id, path_name|
if online.include?(path_name)
@skipped += 1
next
end
session = StreamSession.find_by(id: session_id)
unless removable?(session)
@skipped += 1
next
end
remove_dir(dir_path)
cleanup_mediamtx_path(session) if session
end
Result.new(removed: @removed, freed_bytes: @freed_bytes, skipped: @skipped)
end
private
def each_live_match_dir
base = File.join(MatchLiveTv.recordings_local_path, "live")
return unless Dir.exist?(base)
Dir.children(base).sort.each do |entry|
next unless entry.start_with?("match_")
session_id = entry.delete_prefix("match_")
path_name = "live/#{entry}"
dir_path = File.join(base, entry)
next unless File.directory?(dir_path)
yield dir_path, session_id, path_name
end
end
def removable?(session)
return true if session.nil?
return false if ACTIVE_SESSION_STATUSES.include?(session.status)
recording = Recording.find_by(stream_session: session)
return true if recording&.status.in?(REMOVABLE_RECORDING_STATUSES)
return false unless session.status.in?(%w[ended error])
ended_at = session.ended_at || session.updated_at
grace = recording&.status == "processing" ? processing_grace : ended_grace
ended_at < grace.ago
end
def processing_grace
ENV.fetch("RECORDINGS_LOCAL_PROCESSING_GRACE_HOURS", "6").to_i.hours
end
def ended_grace
ENV.fetch("RECORDINGS_LOCAL_ENDED_GRACE_HOURS", "2").to_i.hours
end
def remove_dir(path)
size = dir_size(path)
FileUtils.rm_rf(path)
@removed += 1
@freed_bytes += size
@logger.info("[Recordings::CleanupLocal] removed #{path} (#{size} bytes)")
rescue StandardError => e
@logger.warn("[Recordings::CleanupLocal] failed #{path}: #{e.message}")
end
def dir_size(path)
`du -sb #{Shellwords.escape(path)} 2>/dev/null`.split.first.to_i
rescue StandardError
0
end
def cleanup_mediamtx_path(session)
return unless session.status.in?(%w[ended error])
Mediamtx::Client.new.delete_path(session)
rescue Mediamtx::Client::Error => e
@logger.warn("[Recordings::CleanupLocal] delete_path #{session.id}: #{e.message}")
end
end
end

View File

@@ -0,0 +1,55 @@
module Recordings
class ClubArchive
def initialize(club, params: {})
@club = club
@params = params
end
def stats
Recordings::ClubStats.new(@club).call
end
def teams
@club.teams.order(:name)
end
def filter_team_id
@params[:team_id]
end
def filter_status
@params[:status]
end
def recordings
scope = Recording.for_club(@club)
.not_deleted
.includes(stream_session: { match: :team })
.order(recorded_at: :desc, created_at: :desc)
scope = scope.where(team_id: filter_team_id) if filter_team_id.present?
apply_status_filter(scope)
end
def filter_params
{ team_id: filter_team_id, status: filter_status }.compact
end
private
def apply_status_filter(scope)
case filter_status
when "ready"
scope.ready
when "processing"
scope.where(status: "processing")
when "expired"
scope.where(
"recordings.status IN (?) OR (recordings.expires_at IS NOT NULL AND recordings.expires_at <= ?)",
%w[expired failed], Time.current
)
else
scope
end
end
end
end

View File

@@ -6,6 +6,7 @@ module Recordings
end end
def call def call
delete_youtube_video
delete_storage_object if @recording.storage_key.present? delete_storage_object if @recording.storage_key.present?
cleanup_local_artifacts cleanup_local_artifacts
@@ -13,13 +14,23 @@ module Recordings
status: "expired", status: "expired",
deleted_at: Time.current, deleted_at: Time.current,
storage_key: nil, storage_key: nil,
thumbnail_storage_key: nil thumbnail_storage_key: nil,
youtube_video_id: nil,
youtube_published_at: nil
) )
@recording @recording
end end
private private
def delete_youtube_video
return if @recording.youtube_video_id.blank?
Youtube::VideoLifecycleService.new(@recording).delete!
rescue Youtube::VideoLifecycleService::Error => e
Rails.logger.warn("[Recordings::Delete] YouTube delete #{@recording.id}: #{e.message}")
end
def delete_storage_object def delete_storage_object
storage = Recordings::Storage.new storage = Recordings::Storage.new
storage.delete(key: @recording.storage_key) if @recording.storage_key.present? storage.delete(key: @recording.storage_key) if @recording.storage_key.present?

View File

@@ -0,0 +1,39 @@
module Recordings
class ExtendExpiry
class Error < StandardError; end
def initialize(recording)
@recording = recording
end
def to_time(expires_at)
raise Error, "Data scadenza obbligatoria" if expires_at.blank?
parsed = expires_at.is_a?(Time) || expires_at.is_a?(ActiveSupport::TimeWithZone) ? expires_at : Time.zone.parse(expires_at.to_s)
raise Error, "Data non valida" unless parsed
apply(parsed)
end
def by_days(days)
count = days.to_i
raise Error, "Giorni non validi" if count <= 0
base = [@recording.expires_at, Time.current].compact.max
apply(base + count.days)
end
private
def apply(new_expires_at)
raise Error, "Replay già eliminato" if @recording.deleted?
raise Error, "La scadenza deve essere nel futuro" unless new_expires_at > Time.current
@recording.update!(
expires_at: new_expires_at,
expiry_warning_sent_at: nil
)
@recording
end
end
end

View File

@@ -6,7 +6,7 @@ module Recordings
def call def call
team = @session.match.team team = @session.match.team
return unless team.entitlements.can_access_recordings? return unless team.entitlements.can_create_recordings?
retention_days = team.entitlements.recording_retention_days retention_days = team.entitlements.recording_retention_days
expires_at = retention_days.positive? ? retention_days.days.from_now : nil expires_at = retention_days.positive? ? retention_days.days.from_now : nil

View File

@@ -13,7 +13,7 @@ module Recordings
return @recording unless @recording.expires_at <= WINDOW_DAYS.days.from_now return @recording unless @recording.expires_at <= WINDOW_DAYS.days.from_now
recipients.each do |user| recipients.each do |user|
Recordings::ReplayMailer.replay_expiring_soon(recording: @recording, recipient: user).deliver_now deliver_expiring_soon(user)
end end
@recording.update!(expiry_warning_sent_at: Time.current) @recording.update!(expiry_warning_sent_at: Time.current)
@@ -22,6 +22,12 @@ module Recordings
private private
def deliver_expiring_soon(user)
Recordings::ReplayMailer.replay_expiring_soon(recording: @recording, recipient: user).deliver_now
rescue EOFError => e
Rails.logger.warn("[Recordings::NotifyExpiring] SMTP EOF on close for #{user.email}: #{e.message}")
end
def recipients def recipients
club = @recording.team.club club = @recording.team.club
owner = club.owner owner = club.owner

View File

@@ -9,7 +9,7 @@ module Recordings
return @recording unless @recording.ready? return @recording unless @recording.ready?
recipients.each do |user| recipients.each do |user|
Recordings::ReplayMailer.replay_ready(recording: @recording, recipient: user).deliver_now deliver_replay_ready(user)
end end
@recording.update!(ready_notified_at: Time.current) @recording.update!(ready_notified_at: Time.current)
@@ -18,6 +18,13 @@ module Recordings
private private
def deliver_replay_ready(user)
Recordings::ReplayMailer.replay_ready(recording: @recording, recipient: user).deliver_now
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}")
end
def recipients def recipients
club = @recording.team.club club = @recording.team.club
owner = club.owner owner = club.owner

View File

@@ -0,0 +1,44 @@
# frozen_string_literal: true
module Recordings
# URL presigned S3 riscritto sul path pubblico /media/ (edge → Garage).
# Rails fa solo auth; il browser scarica/streama senza occupare Puma.
class PublicMediaUrl
def initialize(key:, expires_in: MatchLiveTv.replay_presigned_url_ttl_seconds, disposition: "inline", filename: nil)
@key = key
@expires_in = expires_in
@disposition = disposition
@filename = filename
end
def call
return nil if @key.blank?
return nil unless MatchLiveTv.replay_media_redirect_enabled?
raw = Recordings::Storage.new.presigned_get_url(
key: @key,
expires_in: @expires_in,
response_content_disposition: content_disposition_param
)
return nil if raw.blank?
rewrite_to_public(raw)
end
private
def rewrite_to_public(raw)
uri = URI.parse(raw)
base = MatchLiveTv.replay_media_public_base_url.chomp("/")
query = uri.query.presence
path = uri.path.start_with?("/") ? uri.path : "/#{uri.path}"
query ? "#{base}#{path}?#{query}" : "#{base}#{path}"
end
def content_disposition_param
return nil unless @disposition == "attachment" && @filename.present?
%(attachment; filename="#{@filename.gsub('"', '\\"')}")
end
end
end

View File

@@ -5,6 +5,10 @@ require "aws-sdk-s3"
module Recordings module Recordings
# Serve file replay/thumbnail al browser senza esporre URL S3 interni (es. http://garage:3900). # Serve file replay/thumbnail al browser senza esporre URL S3 interni (es. http://garage:3900).
class ServeFromStorage class ServeFromStorage
CHUNK_SIZE = 256 * 1024
# Thumbnail e file piccoli: buffer completo (l'Enumerator su Puma può non completare la risposta).
SMALL_FILE_MAX = 2 * 1024 * 1024
def initialize(key:, content_type:, disposition: "inline", filename: nil) def initialize(key:, content_type:, disposition: "inline", filename: nil)
@key = key @key = key
@content_type = content_type @content_type = content_type
@@ -14,7 +18,15 @@ module Recordings
def call(controller) def call(controller)
path = local_path path = local_path
return controller.send_file(path, type: @content_type, disposition: @disposition, filename: @filename) if path if path
return controller.send_file(
path,
type: @content_type,
disposition: @disposition,
filename: @filename,
stream: true
)
end
proxy_s3(controller) proxy_s3(controller)
end end
@@ -41,22 +53,33 @@ module Recordings
controller.response.headers["Accept-Ranges"] = "bytes" controller.response.headers["Accept-Ranges"] = "bytes"
controller.response.headers["Content-Length"] = resp.content_length.to_s if resp.content_length controller.response.headers["Content-Length"] = resp.content_length.to_s if resp.content_length
controller.response.headers["Content-Range"] = resp.content_range if resp.content_range.present? controller.response.headers["Content-Range"] = resp.content_range if resp.content_range.present?
controller.response.headers["Content-Disposition"] = content_disposition_header
data = resp.body.read controller.status = :partial_content if range.present? && resp.content_range.present?
max = 1.gigabyte
raise ArgumentError, "File troppo grande per il proxy" if data.bytesize > max
if @disposition == "attachment" && @filename.present? if range.blank? && resp.content_length.to_i.positive? && resp.content_length.to_i <= SMALL_FILE_MAX
controller.send_data( return controller.send_data(
data, resp.body.read,
type: @content_type, type: @content_type,
disposition: "attachment", disposition: @disposition,
filename: @filename filename: @filename
) )
else
controller.send_data(data, type: @content_type, disposition: "inline")
end end
controller.status = :partial_content if range.present? && resp.content_range.present?
body = resp.body
controller.response_body = Enumerator.new do |yielder|
while (chunk = body.read(CHUNK_SIZE))
yielder << chunk
end
end
end
def content_disposition_header
if @disposition == "attachment" && @filename.present?
%(attachment; filename="#{@filename.gsub('"', '\\"')}")
else
"inline"
end
end end
def s3_client def s3_client
@@ -66,7 +89,6 @@ module Recordings
endpoint: MatchLiveTv.replay_storage_endpoint, endpoint: MatchLiveTv.replay_storage_endpoint,
region: MatchLiveTv.replay_storage_region, region: MatchLiveTv.replay_storage_region,
force_path_style: MatchLiveTv.replay_storage_force_path_style?, force_path_style: MatchLiveTv.replay_storage_force_path_style?,
# Garage non sempre allinea i checksum AWS su Range/read parziali
response_checksum_validation: "WHEN_REQUIRED" response_checksum_validation: "WHEN_REQUIRED"
) )
end end

View File

@@ -31,8 +31,12 @@ module Recordings
dest dest
end end
def presigned_get_url(key:, expires_in: MatchLiveTv.replay_presigned_url_ttl_seconds) def presigned_get_url(key:, expires_in: MatchLiveTv.replay_presigned_url_ttl_seconds, response_content_disposition: nil)
@backend.presigned_get_url(key: key, expires_in: expires_in) @backend.presigned_get_url(
key: key,
expires_in: expires_in,
response_content_disposition: response_content_disposition
)
end end
def local_path_for_key(key) def local_path_for_key(key)
@@ -61,7 +65,7 @@ module Recordings
true true
end end
def presigned_get_url(key:, expires_in:) def presigned_get_url(key:, expires_in:, response_content_disposition: nil)
nil nil
end end
@@ -92,14 +96,15 @@ module Recordings
raise Error, e.message raise Error, e.message
end end
def presigned_get_url(key:, expires_in:) def presigned_get_url(key:, expires_in:, response_content_disposition: nil)
signer = Aws::S3::Presigner.new(client: client) signer = Aws::S3::Presigner.new(client: client)
signer.presigned_url( params = {
:get_object,
bucket: MatchLiveTv.replay_storage_bucket, bucket: MatchLiveTv.replay_storage_bucket,
key: key, key: key,
expires_in: expires_in expires_in: expires_in
) }
params[:response_content_disposition] = response_content_disposition if response_content_disposition.present?
signer.presigned_url(:get_object, **params)
end end
def local_path_for_key(_key) def local_path_for_key(_key)

View File

@@ -94,6 +94,7 @@ module Recordings
end end
def cleanup_mediamtx_path def cleanup_mediamtx_path
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
Mediamtx::Client.new.delete_path(@session) Mediamtx::Client.new.delete_path(@session)
rescue Mediamtx::Client::Error => e rescue Mediamtx::Client::Error => e
Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}") Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}")

View File

@@ -6,105 +6,10 @@ module Scoring
@session = session @session = session
@match = session.match @match = session.match
@action = action.to_s @action = action.to_s
@rules = Rules.from_match(@match)
end end
def call def call
score = @session.score_state || @session.create_score_state!( Engine.for_match(@match).apply(session: @session, action: @action)
home_sets: 0, away_sets: 0, home_points: 0, away_points: 0, current_set: 1, set_partials: []
)
case @action
when "home_point"
apply_point(score, :home)
when "away_point"
apply_point(score, :away)
when "home_undo"
apply_undo(score, :home)
when "away_undo"
apply_undo(score, :away)
when "close_set"
apply_close_set(score)
else
raise ArgumentError, "Azione non valida"
end
end
private
def apply_point(score, side)
if side == :home
score.home_points += 1
else
score.away_points += 1
end
score.save!
broadcast(score)
winner = @rules.set_winner_from_points(
home_points: score.home_points,
away_points: score.away_points,
current_set: score.current_set
)
Result.new(score: score, set_won: winner.present?, match_won: false, winner: winner)
end
def apply_undo(score, side)
if side == :home
score.home_points = [score.home_points - 1, 0].max
else
score.away_points = [score.away_points - 1, 0].max
end
score.save!
broadcast(score)
Result.new(score: score, set_won: false, match_won: false, winner: nil)
end
def apply_close_set(score)
winner = @rules.set_winner_from_points(
home_points: score.home_points,
away_points: score.away_points,
current_set: score.current_set
)
winner ||= score.home_points > score.away_points ? :home : :away if score.home_points != score.away_points
return Result.new(score: score, set_won: false, match_won: false, winner: nil) unless winner
close_set_for(score, winner)
end
def close_set_for(score, winner)
partials = Array(score.set_partials)
partials << {
"set" => score.current_set,
"home" => score.home_points,
"away" => score.away_points
}
if winner == :home
score.home_sets += 1
else
score.away_sets += 1
end
match_winner = @rules.match_winner(home_sets: score.home_sets, away_sets: score.away_sets)
score.assign_attributes(
set_partials: partials,
home_points: 0,
away_points: 0,
current_set: score.current_set + 1
)
score.save!
broadcast(score)
Result.new(
score: score,
set_won: true,
match_won: match_winner.present?,
winner: match_winner || winner
)
end
def broadcast(score)
SessionChannel.broadcast_message(@session, score.as_cable_payload)
end end
end end
end end

View File

@@ -0,0 +1,26 @@
module Scoring
class Engine
ENGINES = {
"volley" => Engines::VolleyEngine,
"racket" => Engines::RacketEngine,
"generic" => Engines::GenericEngine,
"basket" => Engines::BasketEngine,
"timed" => Engines::TimedEngine,
"timer" => Engines::TimerEngine
}.freeze
def self.for_match(match)
board = match.effective_board_type
klass = ENGINES.fetch(board) { Engines::GenericEngine }
klass.new(match: match)
end
def self.for_session(session)
for_match(session.match)
end
def self.ensure_score_for(session)
for_match(session.match).ensure_score(session)
end
end
end

View File

@@ -0,0 +1,45 @@
module Scoring
module Engines
class BaseEngine
Result = Scoring::ApplyAction::Result
def initialize(match:)
@match = match
end
def apply(session:, action:)
raise NotImplementedError
end
def sync(session:, payload:)
raise NotImplementedError
end
def ensure_score(session)
board = @match.effective_board_type
score = session.score_state
return score if score
attrs = {
board_type: board,
home_sets: 0,
away_sets: 0,
home_points: 0,
away_points: 0,
current_set: 1,
set_partials: [],
data: default_data
}
session.create_score_state!(attrs)
end
def default_data
{}
end
def broadcast(session, score)
SessionChannel.broadcast_message(session, score.as_cable_payload)
end
end
end
end

View File

@@ -0,0 +1,32 @@
module Scoring
module Engines
class BasketEngine < BaseEngine
include PeriodEngineMixin
def apply(session:, action:)
score = ensure_score(session)
case action.to_s
when "home_point" then adjust_score(score, :home, 1)
when "away_point" then adjust_score(score, :away, 1)
when "home_undo" then adjust_score(score, :home, -1)
when "away_undo" then adjust_score(score, :away, -1)
when "home_point_2" then adjust_score(score, :home, 2)
when "away_point_2" then adjust_score(score, :away, 2)
when "home_point_3" then adjust_score(score, :home, 3)
when "away_point_3" then adjust_score(score, :away, 3)
when "advance_period" then advance_period(score)
when "clock_toggle" then toggle_clock(score)
when "clock_reset" then reset_clock(score)
when "clock_tick" then tick_clock(score)
else
raise ArgumentError, "Azione non valida"
end
end
def sync(session:, payload:)
sync_period(session: session, payload: payload.stringify_keys)
end
end
end
end

View File

@@ -0,0 +1,42 @@
module Scoring
module Engines
class GenericEngine < BaseEngine
def apply(session:, action:)
score = ensure_score(session)
case action.to_s
when "home_point" then adjust(score, :home, 1)
when "away_point" then adjust(score, :away, 1)
when "home_undo" then adjust(score, :home, -1)
when "away_undo" then adjust(score, :away, -1)
else
raise ArgumentError, "Azione non valida"
end
end
def sync(session:, payload:)
score = ensure_score(session)
attrs = {}
%w[home_points away_points].each do |key|
attrs[key] = payload[key] if payload.key?(key)
end
score.update!(attrs)
broadcast(session, score)
score
end
private
def adjust(score, side, delta)
if side == :home
score.home_points = [score.home_points + delta, 0].max
else
score.away_points = [score.away_points + delta, 0].max
end
score.save!
broadcast(score.stream_session, score)
Result.new(score: score, set_won: false, match_won: false, winner: nil)
end
end
end
end

View File

@@ -0,0 +1,129 @@
module Scoring
module Engines
module PeriodEngineMixin
Result = Scoring::ApplyAction::Result
private
def period_rules
@period_rules ||= Rules.from_match(@match)
end
def period_data(score)
score.data.is_a?(Hash) ? score.data.deep_dup : {}
end
def default_data
rules = period_rules
{
"period" => 1,
"clock_secs" => rules.period_duration_secs,
"clock_running" => false,
"home_score" => 0,
"away_score" => 0,
"overtime" => false
}
end
def ensure_period_data(score)
data = period_data(score)
default_data.each { |k, v| data[k] = v unless data.key?(k) }
PeriodData.normalize_hash!(data)
data
end
def save_period_score!(score, data)
score.assign_attributes(
data: data,
home_points: data["home_score"].to_i,
away_points: data["away_score"].to_i,
period: period_label(data)
)
score.save!
broadcast(score.stream_session, score)
end
def period_label(data)
period = PeriodData.number_from(data["period"]) || 1
return "OT" if data["overtime"]
board = @match.effective_board_type
board == "basket" ? "Q#{period}" : "#{period}° tempo"
end
def adjust_score(score, side, delta)
data = ensure_period_data(score)
key = side == :home ? "home_score" : "away_score"
data[key] = [data[key].to_i + delta, 0].max
save_period_score!(score, data)
Result.new(score: score, set_won: false, match_won: false, winner: nil)
end
def advance_period(score)
data = ensure_period_data(score)
rules = period_rules
max_periods = rules.periods
current = PeriodData.number_from(data["period"]) || 1
if data["overtime"]
data["period"] = current + 1
data["clock_secs"] = rules.overtime_duration_secs
elsif current >= max_periods
data["overtime"] = true
data["period"] = max_periods + 1
data["clock_secs"] = rules.overtime_duration_secs
else
data["period"] = current + 1
data["clock_secs"] = rules.period_duration_secs
end
data["clock_running"] = false
save_period_score!(score, data)
Result.new(score: score, set_won: false, match_won: false, winner: nil)
end
def toggle_clock(score)
data = ensure_period_data(score)
data["clock_running"] = !data["clock_running"]
save_period_score!(score, data)
Result.new(score: score, set_won: false, match_won: false, winner: nil)
end
def reset_clock(score)
data = ensure_period_data(score)
rules = period_rules
duration = data["overtime"] ? rules.overtime_duration_secs : rules.period_duration_secs
data["clock_secs"] = duration
data["clock_running"] = false
save_period_score!(score, data)
Result.new(score: score, set_won: false, match_won: false, winner: nil)
end
def tick_clock(score, secs: 1)
data = ensure_period_data(score)
return Result.new(score: score, set_won: false, match_won: false, winner: nil) unless data["clock_running"]
data["clock_secs"] = [data["clock_secs"].to_i - secs, 0].max
save_period_score!(score, data)
Result.new(score: score, set_won: false, match_won: false, winner: nil)
end
def sync_period(session:, payload:)
score = ensure_score(session)
data = ensure_period_data(score)
%w[period clock_secs clock_running home_score away_score overtime].each do |key|
next unless payload.key?(key)
if key == "period"
period = PeriodData.number_from(payload[key])
data["period"] = period if period&.positive?
else
data[key] = payload[key]
end
end
save_period_score!(score, data)
score
end
end
end
end

View File

@@ -0,0 +1,7 @@
module Scoring
module Engines
# Fase 1: stessa logica del volley (set + punti).
class RacketEngine < VolleyEngine
end
end
end

View File

@@ -0,0 +1,28 @@
module Scoring
module Engines
class TimedEngine < BaseEngine
include PeriodEngineMixin
def apply(session:, action:)
score = ensure_score(session)
case action.to_s
when "home_point" then adjust_score(score, :home, 1)
when "away_point" then adjust_score(score, :away, 1)
when "home_undo" then adjust_score(score, :home, -1)
when "away_undo" then adjust_score(score, :away, -1)
when "advance_period" then advance_period(score)
when "clock_toggle" then toggle_clock(score)
when "clock_reset" then reset_clock(score)
when "clock_tick" then tick_clock(score)
else
raise ArgumentError, "Azione non valida"
end
end
def sync(session:, payload:)
sync_period(session: session, payload: payload.stringify_keys)
end
end
end
end

View File

@@ -0,0 +1,49 @@
module Scoring
module Engines
class TimerEngine < BaseEngine
def apply(session:, action:)
score = ensure_score(session)
data = timer_data(score)
case action.to_s
when "clock_toggle"
data["clock_running"] = !data["clock_running"]
when "clock_reset"
data["clock_secs"] = 0
data["clock_running"] = false
when "clock_tick"
data["clock_secs"] = data["clock_secs"].to_i + 1 if data["clock_running"]
else
raise ArgumentError, "Azione non valida"
end
score.update!(data: data)
broadcast(session, score)
Result.new(score: score, set_won: false, match_won: false, winner: nil)
end
def sync(session:, payload:)
score = ensure_score(session)
data = timer_data(score)
%w[clock_secs clock_running].each do |key|
data[key] = payload[key] if payload.key?(key)
end
score.update!(data: data)
broadcast(session, score)
score
end
private
def default_data
{ "clock_secs" => 0, "clock_running" => false }
end
def timer_data(score)
base = default_data
stored = score.data.is_a?(Hash) ? score.data : {}
base.merge(stored)
end
end
end
end

View File

@@ -0,0 +1,105 @@
module Scoring
module Engines
class VolleyEngine < BaseEngine
def apply(session:, action:)
@rules = Rules.from_match(@match)
score = ensure_score(session)
case action.to_s
when "home_point" then apply_point(score, :home)
when "away_point" then apply_point(score, :away)
when "home_undo" then apply_undo(score, :home)
when "away_undo" then apply_undo(score, :away)
when "close_set" then apply_close_set(score)
else
raise ArgumentError, "Azione non valida"
end
end
def sync(session:, payload:)
score = ensure_score(session)
attrs = {}
%w[home_sets away_sets home_points away_points current_set].each do |key|
attrs[key] = payload[key] if payload.key?(key)
end
attrs[:set_partials] = payload["set_partials"] if payload.key?("set_partials")
score.update!(attrs)
broadcast(session, score)
score
end
private
def apply_point(score, side)
if side == :home
score.home_points += 1
else
score.away_points += 1
end
score.save!
broadcast(score.stream_session, score)
winner = @rules.set_winner_from_points(
home_points: score.home_points,
away_points: score.away_points,
current_set: score.current_set
)
Result.new(score: score, set_won: winner.present?, match_won: false, winner: winner)
end
def apply_undo(score, side)
if side == :home
score.home_points = [score.home_points - 1, 0].max
else
score.away_points = [score.away_points - 1, 0].max
end
score.save!
broadcast(score.stream_session, score)
Result.new(score: score, set_won: false, match_won: false, winner: nil)
end
def apply_close_set(score)
winner = @rules.set_winner_from_points(
home_points: score.home_points,
away_points: score.away_points,
current_set: score.current_set
)
winner ||= score.home_points > score.away_points ? :home : :away if score.home_points != score.away_points
return Result.new(score: score, set_won: false, match_won: false, winner: nil) unless winner
close_set_for(score, winner)
end
def close_set_for(score, winner)
partials = Array(score.set_partials)
partials << {
"set" => score.current_set,
"home" => score.home_points,
"away" => score.away_points
}
if winner == :home
score.home_sets += 1
else
score.away_sets += 1
end
match_winner = @rules.match_winner(home_sets: score.home_sets, away_sets: score.away_sets)
score.assign_attributes(
set_partials: partials,
home_points: 0,
away_points: 0,
current_set: score.current_set + 1
)
score.save!
broadcast(score.stream_session, score)
Result.new(
score: score,
set_won: true,
match_won: match_winner.present?,
winner: match_winner || winner
)
end
end
end
end

View File

@@ -0,0 +1,32 @@
module Scoring
# Normalizza `score_states.data["period"]` (intero o etichetta "Q2" / "2° tempo").
module PeriodData
module_function
def number_from(raw)
case raw
when Integer, Float
value = raw.to_i
value.positive? ? value : nil
when String
text = raw.strip
return nil if text.empty?
if (match = text.match(/\AQ(\d+)\z/i))
match[1].to_i
elsif (match = text.match(/\A(\d+)\s*°/))
match[1].to_i
elsif (value = text.to_i).positive?
value
end
end
end
def normalize_hash!(data)
number = number_from(data["period"])
data["period"] = number if number&.positive?
data["period"] = 1 unless data["period"].to_i.positive?
data
end
end
end

View File

@@ -3,13 +3,31 @@ module Scoring
Side = Struct.new(:home, :away, keyword_init: true) Side = Struct.new(:home, :away, keyword_init: true)
def self.from_match(match) def self.from_match(match)
overrides = match.scoring_rules.is_a?(Hash) ? match.scoring_rules : {} rules = match.effective_scoring_rules
new( board = match.effective_board_type
sets_to_win: match.sets_to_win,
points_per_set: overrides["points_per_set"] || overrides[:points_per_set] || 25, case board
points_deciding_set: overrides["points_deciding_set"] || overrides[:points_deciding_set] || 15, when "volley", "racket"
min_point_lead: overrides["min_point_lead"] || overrides[:min_point_lead] || 2 new(
) sets_to_win: rules["sets_to_win"].to_i,
points_per_set: rules["points_per_set"].to_i,
points_deciding_set: rules["points_deciding_set"].to_i,
min_point_lead: rules["min_point_lead"].to_i
)
when "basket", "timed"
PeriodRules.new(
periods: rules["periods"].to_i,
period_duration_secs: rules["period_duration_secs"].to_i,
overtime_duration_secs: rules["overtime_duration_secs"].to_i
)
else
new(
sets_to_win: rules["sets_to_win"].to_i.positive? ? rules["sets_to_win"].to_i : 3,
points_per_set: rules["points_per_set"].to_i.positive? ? rules["points_per_set"].to_i : 25,
points_deciding_set: rules["points_deciding_set"].to_i.positive? ? rules["points_deciding_set"].to_i : 15,
min_point_lead: rules["min_point_lead"].to_i.positive? ? rules["min_point_lead"].to_i : 2
)
end
end end
def initialize(sets_to_win:, points_per_set: 25, points_deciding_set: 15, min_point_lead: 2) def initialize(sets_to_win:, points_per_set: 25, points_deciding_set: 15, min_point_lead: 2)
@@ -27,6 +45,11 @@ module Scoring
current_set >= max_sets ? @points_deciding_set : @points_per_set current_set >= max_sets ? @points_deciding_set : @points_per_set
end end
def self.points_target_for_match(match, current_set: 1)
rules = from_match(match)
rules.is_a?(Rules) ? rules.points_target(current_set) : 0
end
def set_winner_from_points(home_points:, away_points:, current_set:) def set_winner_from_points(home_points:, away_points:, current_set:)
target = points_target(current_set) target = points_target(current_set)
if home_points >= target && (home_points - away_points) >= @min_point_lead if home_points >= target && (home_points - away_points) >= @min_point_lead
@@ -42,5 +65,15 @@ module Scoring
nil nil
end end
class PeriodRules
attr_reader :periods, :period_duration_secs, :overtime_duration_secs
def initialize(periods:, period_duration_secs:, overtime_duration_secs: 300)
@periods = periods
@period_duration_secs = period_duration_secs
@overtime_duration_secs = overtime_duration_secs
end
end
end end
end end

View File

@@ -1,27 +1,13 @@
module Scoring module Scoring
# Sincronizza lo stato completo del tabellone (app mobile / Action Cable). # Sincronizza lo stato completo del tabellone (app mobile / Action Cable).
class SyncState class SyncState
SCORE_KEYS = %w[home_sets away_sets home_points away_points current_set].freeze
def initialize(session:, payload:) def initialize(session:, payload:)
@session = session @session = session
@payload = payload.stringify_keys @payload = payload.stringify_keys
end end
def call def call
score = @session.score_state || @session.create_score_state!( Engine.for_match(@session.match).sync(session: @session, payload: @payload)
home_sets: 0, away_sets: 0, home_points: 0, away_points: 0, current_set: 1, set_partials: []
)
attrs = {}
SCORE_KEYS.each do |key|
attrs[key] = @payload[key] if @payload.key?(key)
end
attrs[:set_partials] = @payload["set_partials"] if @payload.key?("set_partials")
score.update!(attrs)
SessionChannel.broadcast_message(@session, score.as_cable_payload)
score
end end
end end
end end

View File

@@ -35,7 +35,7 @@ module Sessions
StreamSession.transaction do StreamSession.transaction do
session.save! session.save!
session.create_score_state! Scoring::Engine.ensure_score_for(session)
mtx = Mediamtx::Client.new mtx = Mediamtx::Client.new
mtx.create_path(session) mtx.create_path(session)
log_event(session, "pairing", { created: true, platform: session.platform }) log_event(session, "pairing", { created: true, platform: session.platform })

View File

@@ -7,6 +7,12 @@ module Sessions
def call def call
cancel_timeout_job cancel_timeout_job
@session.pause! if @session.may_pause? @session.pause! if @session.may_pause?
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
begin
Mediamtx::Client.new.set_path_recording(@session, enabled: false)
rescue Mediamtx::Client::Error => e
Rails.logger.warn("[Sessions::Pause] disable recording: #{e.message}")
end
Mediamtx::Client.new.set_always_available(@session, enabled: true) Mediamtx::Client.new.set_always_available(@session, enabled: true)
# Slate su path per HLS in pausa; RTMP telefono si ferma via comando app. # Slate su path per HLS in pausa; RTMP telefono si ferma via comando app.
log_event("paused") log_event("paused")

View File

@@ -13,7 +13,9 @@ module Sessions
def rotate! def rotate!
token = SecureRandom.urlsafe_base64(24) token = SecureRandom.urlsafe_base64(24)
expires = [DEFAULT_TTL.from_now, session_end_cap].compact.min expires = DEFAULT_TTL.from_now
cap = session_end_cap
expires = [expires, cap].min if cap&.future?
@session.update!( @session.update!(
regia_token_digest: digest(token), regia_token_digest: digest(token),
regia_token_expires_at: expires regia_token_expires_at: expires

View File

@@ -0,0 +1,31 @@
module Sessions
# Sceglie il preset più alto supportato dall'upload misurato (test rete).
class SelectQuality
UPLOAD_HEADROOM = 0.8
UPLOAD_HEADROOM_1080P = 1.0
PRESETS = [
{ id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 },
{ id: "720p_30_4mbps", target_bitrate: 4_000_000, target_fps: 30 },
{ id: "720p_30_2.5mbps", target_bitrate: 2_500_000, target_fps: 30 },
{ id: "720p_30_1.5mbps", target_bitrate: 1_500_000, target_fps: 30 }
].freeze
def self.call(upload_mbps:)
new(upload_mbps: upload_mbps).call
end
def initialize(upload_mbps:)
@upload_mbps = upload_mbps.to_f
end
def call
PRESETS.find { |preset| upload_sufficient?(preset) } || PRESETS.last
end
def upload_sufficient?(preset)
headroom = preset[:id].start_with?("1080p") ? UPLOAD_HEADROOM_1080P : UPLOAD_HEADROOM
@upload_mbps >= (preset[:target_bitrate] / 1_000_000.0 * headroom)
end
end
end

View File

@@ -12,11 +12,8 @@ module Sessions
complete_youtube_broadcast! if @session.youtube_broadcast_id.present? complete_youtube_broadcast! if @session.youtube_broadcast_id.present?
recording = Recordings::FinalizeSession.new(@session).call recording = Recordings::FinalizeSession.new(@session).call
if recording Recordings::UploadJob.perform_async(@session.id) if recording
Recordings::UploadJob.perform_async(@session.id) remove_mediamtx_paths!
else
Mediamtx::Client.new.delete_path(@session)
end
log_event("ended") log_event("ended")
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "ended" }) SessionChannel.broadcast_message(@session, { type: "stream_event", event: "ended" })
@@ -35,6 +32,12 @@ module Sessions
@session.stream_events.create!(event_type: type, occurred_at: Time.current, metadata: {}) @session.stream_events.create!(event_type: type, occurred_at: Time.current, metadata: {})
end end
def remove_mediamtx_paths!
Mediamtx::Client.new.delete_path(@session)
rescue Mediamtx::Client::Error => e
Rails.logger.warn("[Sessions::Stop] delete_path #{@session.id}: #{e.message}")
end
def complete_youtube_broadcast! def complete_youtube_broadcast!
Youtube::BroadcastService.new(@session.match.team).complete_broadcast!(@session.youtube_broadcast_id) Youtube::BroadcastService.new(@session.match.team).complete_broadcast!(@session.youtube_broadcast_id)
rescue Youtube::BroadcastService::Error => e rescue Youtube::BroadcastService::Error => e

View File

@@ -153,8 +153,8 @@ module Streams
end end
path = session.mediamtx_path_name path = session.mediamtx_path_name
rails = ENV.fetch("RAILS_INTERNAL_URL", "http://rails:3000") hls = ENV.fetch("MEDIAMTX_HLS_URL", "http://mediamtx:8888").chomp("/")
[:hls, "#{rails.chomp('/')}/hls/#{path}/index.m3u8"] [:hls, "#{hls}/#{path}/index.m3u8"]
end end
def intake_available?(session) def intake_available?(session)

View File

@@ -178,12 +178,18 @@ module Teams
assert_can_stream_on!("youtube") assert_can_stream_on!("youtube")
end end
def can_access_recordings? # Nuove registrazioni (MediaMTX + FinalizeSession): richiede abbonamento attivo.
def can_create_recordings?
active? && plan.recordings_enabled? active? && plan.recordings_enabled?
end end
# Archivio esistente: visibile fino a expires_at anche se l'abbonamento è scaduto.
def can_access_recordings?
plan.recordings_enabled? || @team.recordings.not_deleted.exists?
end
def recording_enabled_for_mediamtx? def recording_enabled_for_mediamtx?
can_access_recordings? can_create_recordings?
end end
def recording_retention_days def recording_retention_days
@@ -215,6 +221,7 @@ module Teams
concurrent_streams_used: concurrent_streams_used, concurrent_streams_used: concurrent_streams_used,
concurrent_streams_limit: concurrent_streams_limit, concurrent_streams_limit: concurrent_streams_limit,
recordings_enabled: can_access_recordings?, recordings_enabled: can_access_recordings?,
can_create_recordings: can_create_recordings?,
recording_retention_days: recording_retention_days, recording_retention_days: recording_retention_days,
phone_download_enabled: phone_download_enabled?, phone_download_enabled: phone_download_enabled?,
youtube_enabled: youtube_enabled?, youtube_enabled: youtube_enabled?,

View File

@@ -0,0 +1,66 @@
module Youtube
# Aggiorna privacy o elimina un VOD YouTube collegato a un replay.
class VideoLifecycleService
class Error < StandardError; end
def initialize(recording)
@recording = recording
@team = recording.team
@session = recording.stream_session
end
def update_privacy!(privacy_status:)
video_id = @recording.youtube_video_id
return if video_id.blank? || video_id.to_s.start_with?("mock_")
privacy = map_privacy(privacy_status)
client = authorized_client
video = Google::Apis::YoutubeV3::Video.new(
id: video_id,
status: Google::Apis::YoutubeV3::VideoStatus.new(
privacy_status: privacy,
self_declared_made_for_kids: false
)
)
client.update_video("status", video)
merge_youtube_meta!("privacy_status" => privacy)
true
rescue Google::Apis::Error => e
raise Error, e.message
end
def delete!
video_id = @recording.youtube_video_id
return if video_id.blank? || video_id.to_s.start_with?("mock_")
client = authorized_client
client.delete_video(video_id)
true
rescue Google::Apis::Error => e
raise Error, e.message
end
private
def map_privacy(status)
status.to_s == "public" ? "public" : "unlisted"
end
def merge_youtube_meta!(attrs)
meta = @recording.metadata.is_a?(Hash) ? @recording.metadata.dup : {}
publish = meta.fetch("youtube_publish", {}).merge(attrs)
@recording.update!(metadata: meta.merge("youtube_publish" => publish))
end
def youtube_channel
@session&.platform == "youtube" ? "team" : nil
end
def authorized_client
credential = CredentialResolver.new(@team, channel: youtube_channel).resolve
raise Error, "Credenziali YouTube non disponibili" if credential.blank?
OauthRefresh.new(credential).apply!(Google::Apis::YoutubeV3::YouTubeService.new)
end
end
end

View File

@@ -0,0 +1,4 @@
<% content_for :title, "Replay — #{@club.name}" %>
<% content_for :replay_archive_styles, true %>
<%= render "recordings/club_archive", admin_mode: true %>

View File

@@ -3,6 +3,7 @@
<h2><%= @club.name %></h2> <h2><%= @club.name %></h2>
<p style="color:#888"> <p style="color:#888">
Sport: <%= @club.sport %> Sport: <%= @club.sport %>
· <%= link_to "Archivio replay", admin_club_recordings_path(@club) %>
· <%= link_to "Pagamenti e fatture", admin_billing_path(club_id: @club.id) %> · <%= link_to "Pagamenti e fatture", admin_billing_path(club_id: @club.id) %>
· <%= link_to "Canale YouTube piattaforma", admin_youtube_platform_path %> · <%= link_to "Canale YouTube piattaforma", admin_youtube_platform_path %>
</p> </p>
@@ -38,7 +39,7 @@
<tr> <tr>
<td><strong><%= team.name %></strong></td> <td><strong><%= team.name %></strong></td>
<td><%= team.sport %></td> <td><%= team.sport %></td>
<td><%= link_to "Partite e dettagli", admin_team_path(team) %></td> <td><%= link_to "Partite e dettagli", admin_team_path(team) %> · <%= link_to "Replay", admin_club_recordings_path(@club, team_id: team.id) %></td>
</tr> </tr>
<% end %> <% end %>
</tbody> </tbody>

View File

@@ -54,6 +54,19 @@
</div> </div>
</section> </section>
<section class="panel" style="margin-bottom:1.25rem">
<h2>Stato piattaforma (Ops)</h2>
<% critical = Ops::Incident.critical_open.count %>
<% warnings = Ops::Incident.open.where(severity: "warning").count %>
<% if critical.positive? %>
<p class="admin-flash" style="margin-bottom:0.75rem"><%= critical %> incidente/i critico/i aperto/i — <%= link_to "Vedi dashboard Ops", admin_ops_path %></p>
<% elsif warnings.positive? %>
<p class="kpi-sub" style="margin-bottom:0.75rem"><%= warnings %> warning — <%= link_to "Dashboard Ops", admin_ops_path %></p>
<% else %>
<p class="empty" style="margin:0">Nessun incidente aperto. <%= link_to "Dashboard Ops", admin_ops_path %></p>
<% end %>
</section>
<section class="charts-grid" style="grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));"> <section class="charts-grid" style="grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));">
<div class="chart-card"> <div class="chart-card">
<h3>Disco sistema</h3> <h3>Disco sistema</h3>
@@ -91,7 +104,7 @@
<% if @active_sessions.any? %> <% if @active_sessions.any? %>
<table class="admin-table"> <table class="admin-table">
<thead> <thead>
<tr><th>Partita</th><th>Stato</th><th>Inizio</th><th></th></tr> <tr><th>Partita</th><th>Stato</th><th>Inizio</th><th>Link</th><th></th></tr>
</thead> </thead>
<tbody id="active-sessions-body"> <tbody id="active-sessions-body">
<% @active_sessions.each do |s| %> <% @active_sessions.each do |s| %>
@@ -99,6 +112,12 @@
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td> <td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
<td><span class="badge badge--<%= s.status == 'live' ? 'live' : (s.status == 'paused' ? 'paused' : 'connecting') %>"><%= s.status %></span></td> <td><span class="badge badge--<%= s.status == 'live' ? 'live' : (s.status == 'paused' ? 'paused' : 'connecting') %>"><%= s.status %></span></td>
<td class="muted"><%= s.started_at&.strftime("%d/%m %H:%M") || "—" %></td> <td class="muted"><%= s.started_at&.strftime("%d/%m %H:%M") || "—" %></td>
<td class="admin-link-compact">
<% admin_session_watch_links(s).each do |link| %>
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
<% end %>
<%= link_to "Regia", admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
</td>
<td class="admin-actions"> <td class="admin-actions">
<%= link_to "Dettaglio", admin_session_path(s) %> <%= link_to "Dettaglio", admin_session_path(s) %>
<%= button_to "Termina", stop_admin_session_path(s), method: :post, class: "admin-btn admin-btn--danger admin-btn--sm", form: { data: { turbo_confirm: "Terminare questa sessione?" } } %> <%= button_to "Termina", stop_admin_session_path(s), method: :post, class: "admin-btn admin-btn--danger admin-btn--sm", form: { data: { turbo_confirm: "Terminare questa sessione?" } } %>

View File

@@ -0,0 +1,83 @@
<% content_for :body_class, "admin-body" %>
<section class="kpi-grid">
<div class="kpi-card <%= @summary[:open_critical].positive? ? 'kpi-card--danger' : 'kpi-card--accent' %>">
<div class="kpi-label">Critici aperti</div>
<div class="kpi-value"><%= @summary[:open_critical] %></div>
</div>
<div class="kpi-card">
<div class="kpi-label">Warning aperti</div>
<div class="kpi-value"><%= @summary[:open_warning] %></div>
</div>
<div class="kpi-card">
<div class="kpi-label">Totale aperti</div>
<div class="kpi-value"><%= @summary[:open_total] %></div>
</div>
</section>
<div class="panel" style="margin-bottom:1.5rem">
<h2>Incidenti aperti</h2>
<% if @open_incidents.any? %>
<table class="admin-table">
<thead>
<tr>
<th>Severità</th>
<th>Tipo</th>
<th>Titolo</th>
<th>Occ.</th>
<th>Ultimo</th>
<th></th>
</tr>
</thead>
<tbody>
<% @open_incidents.each do |inc| %>
<tr>
<td><span class="badge badge--<%= inc.severity == 'critical' ? 'live' : 'paused' %>"><%= inc.severity %></span></td>
<td class="muted"><%= inc.kind %></td>
<td>
<strong><%= inc.title %></strong>
<% if inc.message.present? %>
<div class="muted" style="font-size:0.85rem;margin-top:0.25rem"><%= truncate(inc.message, length: 120) %></div>
<% end %>
</td>
<td><%= inc.occurrence_count %></td>
<td class="muted"><%= inc.last_seen_at&.strftime("%d/%m %H:%M") %></td>
<td class="admin-actions">
<% unless inc.status == 'acknowledged' %>
<%= button_to "Preso in carico", acknowledge_admin_op_path(inc), method: :post, class: "admin-btn admin-btn--sm" %>
<% end %>
<%= button_to "Risolvi", resolve_admin_op_path(inc), method: :post, class: "admin-btn admin-btn--sm" %>
<%= button_to "Mute 24h", mute_admin_op_path(inc), method: :post, class: "admin-btn admin-btn--sm admin-btn--outline" %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p class="empty">Nessun incidente aperto. La piattaforma risulta sana.</p>
<% end %>
</div>
<div class="panel">
<h2>Risolti (ultimi 30 giorni)</h2>
<% if @resolved_incidents.any? %>
<table class="admin-table">
<thead>
<tr><th>Severità</th><th>Tipo</th><th>Titolo</th><th>Occ.</th><th>Risolto</th></tr>
</thead>
<tbody>
<% @resolved_incidents.each do |inc| %>
<tr>
<td><span class="badge badge--connecting"><%= inc.severity %></span></td>
<td class="muted"><%= inc.kind %></td>
<td><%= inc.title %></td>
<td><%= inc.occurrence_count %></td>
<td class="muted"><%= inc.updated_at.strftime("%d/%m %H:%M") %></td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p class="empty">Nessun incidente risolto di recente.</p>
<% end %>
</div>

View File

@@ -0,0 +1,41 @@
<% watch_links = admin_session_watch_links(session) %>
<section class="admin-links-panel">
<h3>Link diretta</h3>
<% if watch_links.any? %>
<ul class="admin-link-list">
<% watch_links.each do |link| %>
<li>
<span class="admin-link-label"><%= link[:label] %></span>
<%= link_to link[:url], link[:url], target: "_blank", rel: "noopener", class: "admin-link-url" %>
</li>
<% end %>
</ul>
<% else %>
<p class="muted">Nessun link video disponibile per questa sessione.</p>
<% end %>
<h3>Link regia</h3>
<% if session.terminal? %>
<p class="muted">Sessione terminata — link regia non disponibile.</p>
<% else %>
<% if flash[:regia_url].present? %>
<p class="admin-link-generated">
<span class="admin-link-label">Regia</span>
<%= link_to flash[:regia_url], flash[:regia_url], target: "_blank", rel: "noopener", class: "admin-link-url" %>
</p>
<p><code class="admin-link-code"><%= flash[:regia_url] %></code></p>
<% if (expires = admin_regia_expires_label(flash[:regia_expires_at])) %>
<p class="muted">Valido fino a <%= expires %></p>
<% end %>
<% elsif session.regia_token_active? %>
<p class="muted">Esiste già un link regia attivo (lURL non è recuperabile). Generane uno nuovo se serve condividerlo di nuovo.</p>
<% else %>
<p class="muted">Nessun link regia attivo. Genera un link da condividere con chi gestisce il punteggio.</p>
<% end %>
<%= button_to "Genera link regia",
regia_link_admin_session_path(session),
method: :post,
class: "admin-btn admin-btn--secondary admin-btn--sm",
form: { class: "admin-inline-form" } %>
<% end %>
</section>

View File

@@ -1,12 +1,18 @@
<h2>Stream Sessions</h2> <h2>Stream Sessions</h2>
<table class="admin-table"> <table class="admin-table">
<thead><tr><th>Match</th><th>Status</th><th>Disconnects</th><th></th></tr></thead> <thead><tr><th>Match</th><th>Status</th><th>Disconnects</th><th>Link</th><th></th></tr></thead>
<tbody> <tbody>
<% @sessions.each do |s| %> <% @sessions.each do |s| %>
<tr> <tr>
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td> <td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
<td><span class="badge <%= s.status == 'live' ? 'badge--live' : 'badge--paused' %>"><%= s.status %></span></td> <td><span class="badge <%= s.status == 'live' ? 'badge--live' : 'badge--paused' %>"><%= s.status %></span></td>
<td><%= s.disconnection_count %></td> <td><%= s.disconnection_count %></td>
<td class="admin-link-compact">
<% admin_session_watch_links(s).each do |link| %>
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
<% end %>
<%= link_to "Regia", admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
</td>
<td><%= link_to "Dettaglio", admin_session_path(s) %></td> <td><%= link_to "Dettaglio", admin_session_path(s) %></td>
</tr> </tr>
<% end %> <% end %>

View File

@@ -10,8 +10,11 @@
</p> </p>
<% end %> <% end %>
<p>Match: <%= @session.match.team.name %> vs <%= @session.match.opponent_name %></p> <p>Match: <%= @session.match.team.name %> vs <%= @session.match.opponent_name %></p>
<%= render "admin/sessions/links", session: @session %>
<% if @session.youtube_broadcast_id %> <% if @session.youtube_broadcast_id %>
<p>YouTube: <a href="https://studio.youtube.com/video/<%= @session.youtube_broadcast_id %>/livestreaming" target="_blank">Broadcast</a></p> <p>YouTube Studio: <a href="https://studio.youtube.com/video/<%= @session.youtube_broadcast_id %>/livestreaming" target="_blank" rel="noopener">Broadcast</a></p>
<% end %> <% end %>
<p>RTMP ingest: <code><%= @session.rtmp_ingest_url %></code></p> <p>RTMP ingest: <code><%= @session.rtmp_ingest_url %></code></p>

View File

@@ -5,7 +5,7 @@
<h2><%= @team.name %></h2> <h2><%= @team.name %></h2>
<p>Sport: <%= @team.sport %></p> <p>Sport: <%= @team.sport %></p>
<% if @team.club %> <% if @team.club %>
<p>Società: <%= @team.club.name %> · <%= link_to "Pagamenti e fatture", admin_billing_path(club_id: @team.club.id) %></p> <p>Società: <%= @team.club.name %> · <%= link_to "Replay squadra", admin_club_recordings_path(@team.club, team_id: @team.id) %> · <%= link_to "Pagamenti e fatture", admin_billing_path(club_id: @team.club.id) %></p>
<% end %> <% end %>
<% yt = Youtube::TeamStatus.new(@team) %> <% yt = Youtube::TeamStatus.new(@team) %>
<p> <p>

View File

@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="/admin.css?v=2"> <link rel="stylesheet" href="/admin.css?v=2">
<% if content_for?(:replay_archive_styles) %>
<link rel="stylesheet" href="/marketing.css?v=1">
<% end %>
<% if controller_name == "dashboard" %> <% if controller_name == "dashboard" %>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" crossorigin="anonymous"></script>
<script src="/admin-dashboard.js?v=1" defer></script> <script src="/admin-dashboard.js?v=1" defer></script>
@@ -16,7 +19,11 @@
<nav class="admin-nav"> <nav class="admin-nav">
<% if admin_logged_in? %> <% if admin_logged_in? %>
<%= link_to "Dashboard", admin_root_path, class: ("active" if controller_name == "dashboard") %> <%= link_to "Dashboard", admin_root_path, class: ("active" if controller_name == "dashboard") %>
<%= link_to "Società e squadre", admin_clubs_path, class: ("active" if controller_name.in?(%w[clubs teams])) %> <% ops_critical = Ops::Incident.critical_open.count %>
<%= link_to admin_ops_path, class: ("active" if controller_name == "ops") do %>
Ops<% if ops_critical.positive? %> <span class="admin-nav-badge"><%= ops_critical %></span><% end %>
<% end %>
<%= link_to "Società e squadre", admin_clubs_path, class: ("active" if controller_name.in?(%w[clubs teams club_recordings])) %>
<%= link_to "Fatturazione", admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %> <%= link_to "Fatturazione", admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %>
<%= link_to "YouTube", admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %> <%= link_to "YouTube", admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
<%= link_to "Sessions", admin_sessions_path, class: ("active" if controller_name == "sessions") %> <%= link_to "Sessions", admin_sessions_path, class: ("active" if controller_name == "sessions") %>

View File

@@ -8,7 +8,7 @@
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<%= yield :head %> <%= yield :head %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=36"> <link rel="stylesheet" href="/marketing.css?v=38">
</head> </head>
<body<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>> <body<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
<%= render "shared/cookie_banner" %> <%= render "shared/cookie_banner" %>

View File

@@ -6,8 +6,8 @@
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title> <title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=36"> <link rel="stylesheet" href="/marketing.css?v=38">
<link rel="stylesheet" href="/live.css?v=22"> <link rel="stylesheet" href="/live.css?v=26">
<%= yield :head %> <%= yield :head %>
</head> </head>
<body<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>> <body<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>

View File

@@ -0,0 +1,10 @@
<%= @incident.title %>
Severità: <%= @incident.severity %>
Tipo: <%= @incident.kind %>
Occorrenze: <%= @incident.occurrence_count %>
Ultimo avvistamento: <%= @incident.last_seen_at %>
<%= @incident.message %>
Dashboard: <%= ENV.fetch("APP_PUBLIC_URL", "https://www.matchlivetv.it").chomp("/") %>/admin/ops

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