diff --git a/backend/app/jobs/recordings/cleanup_local_job.rb b/backend/app/jobs/recordings/cleanup_local_job.rb new file mode 100644 index 0000000..2fc2543 --- /dev/null +++ b/backend/app/jobs/recordings/cleanup_local_job.rb @@ -0,0 +1,14 @@ +module Recordings + class CleanupLocalJob + include Sidekiq::Job + sidekiq_options retry: 1, queue: "default" + + def perform + result = Recordings::CleanupLocal.new.call + Rails.logger.info( + "[Recordings::CleanupLocalJob] removed=#{result.removed} " \ + "freed=#{result.freed_bytes} skipped=#{result.skipped}" + ) + end + end +end diff --git a/backend/app/services/recordings/cleanup_local.rb b/backend/app/services/recordings/cleanup_local.rb new file mode 100644 index 0000000..993bdb7 --- /dev/null +++ b/backend/app/services/recordings/cleanup_local.rb @@ -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 diff --git a/backend/lib/tasks/recordings.rake b/backend/lib/tasks/recordings.rake index f6f9da2..40fbe80 100644 --- a/backend/lib/tasks/recordings.rake +++ b/backend/lib/tasks/recordings.rake @@ -10,4 +10,11 @@ namespace :recordings do Recordings::ExpiryWarningJob.new.perform puts "Avvisi scadenza replay inviati" end + + desc "Rimuove segmenti MediaMTX locali di sessioni terminate (libera disco)" + task cleanup_local: :environment do + result = Recordings::CleanupLocal.new.call + puts "Pulizia segmenti locali: rimossi=#{result.removed} " \ + "liberati=#{result.freed_bytes} byte saltati=#{result.skipped}" + end end diff --git a/backend/spec/services/recordings/cleanup_local_spec.rb b/backend/spec/services/recordings/cleanup_local_spec.rb new file mode 100644 index 0000000..451d6e3 --- /dev/null +++ b/backend/spec/services/recordings/cleanup_local_spec.rb @@ -0,0 +1,82 @@ +require "rails_helper" + +RSpec.describe Recordings::CleanupLocal do + let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") } + let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "password123", role: "coach") } + let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) } + + let(:recordings_root) { Rails.root.join("tmp/recordings_spec").to_s } + let(:live_root) { File.join(recordings_root, "live") } + + around do |example| + FileUtils.mkdir_p(live_root) + original = ENV["RECORDINGS_PATH"] + ENV["RECORDINGS_PATH"] = recordings_root + example.run + ensure + ENV["RECORDINGS_PATH"] = original + FileUtils.rm_rf(recordings_root) + end + + def write_segment(session_id) + dir = File.join(live_root, "match_#{session_id}") + FileUtils.mkdir_p(dir) + File.write(File.join(dir, "segment.mp4"), "x" * 1024) + dir + end + + it "rimuove segmenti di sessioni terminate con replay pronto" do + session = StreamSession.create!( + match: match, user: user, platform: "matchlivetv", status: "ended", ended_at: 1.hour.ago + ) + write_segment(session.id) + Recording.create!( + stream_session: session, team: team, status: "ready", privacy_status: "public", + storage_key: "teams/#{team.id}/sessions/#{session.id}/replay.mp4" + ) + + result = described_class.new(online_paths: []).call + + expect(result.removed).to eq(1) + expect(Dir.exist?(File.join(live_root, "match_#{session.id}"))).to be(false) + end + + it "non rimuove segmenti di sessioni ancora in onda" do + session = StreamSession.create!( + match: match, user: user, platform: "matchlivetv", status: "live" + ) + write_segment(session.id) + + result = described_class.new(online_paths: [session.mediamtx_path_name]).call + + expect(result.removed).to eq(0) + expect(result.skipped).to eq(1) + expect(Dir.exist?(File.join(live_root, "match_#{session.id}"))).to be(true) + end + + it "non rimuove segmenti di sessioni terminate di recente in elaborazione" do + session = StreamSession.create!( + match: match, user: user, platform: "matchlivetv", status: "ended", ended_at: 30.minutes.ago + ) + write_segment(session.id) + Recording.create!( + stream_session: session, team: team, status: "processing", privacy_status: "public" + ) + + result = described_class.new(online_paths: []).call + + expect(result.removed).to eq(0) + expect(Dir.exist?(File.join(live_root, "match_#{session.id}"))).to be(true) + end + + it "rimuove cartelle orfane senza sessione nel database" do + orphan_id = SecureRandom.uuid + write_segment(orphan_id) + + result = described_class.new(online_paths: []).call + + expect(result.removed).to eq(1) + expect(Dir.exist?(File.join(live_root, "match_#{orphan_id}"))).to be(false) + end +end diff --git a/docs/TABELLONI_E_OVERLAY.md b/docs/TABELLONI_E_OVERLAY.md new file mode 100644 index 0000000..5a8abf0 --- /dev/null +++ b/docs/TABELLONI_E_OVERLAY.md @@ -0,0 +1,351 @@ +# Tabelloni, overlay e regolamenti sportivi + +Questo documento descrive come Match Live TV modella gli sport, applica i regolamenti di punteggio e li traduce in **tabelloni** (logica + controlli) e **overlay** (grafica sul video in diretta). + +--- + +## Panoramica + +Il sistema separa tre livelli che spesso vengono confusi: + +| Livello | Cosa rappresenta | Dove vive | Esempio basket | +|--------|-------------------|-----------|------------------| +| **Sport** (`sport_key`) | La disciplina scelta per squadra/partita | `config/sports.yml`, DB | `basket` | +| **Board** (`board_type`) | Il *tipo di tabellone* — logica di punteggio e controlli | Derivato dal catalogo, `score_states.board_type` | `basket` | +| **Overlay** (`overlay_kind`) | Il *layout grafico* sul video | Per partita, validato dal catalogo | `basket` o `none` | + +In sintesi: + +- Il **regolamento** definisce *come si segna* (set da vincere, punti per set, durata quarti…). +- Il **board** è il motore che applica quel regolamento. +- L’**overlay** è solo la resa visiva sullo stream; può essere disattivato (`none`) anche quando il board resta attivo in regia. + +```mermaid +flowchart LR + subgraph catalogo [Catalogo sport] + SK[sport_key] + BR[board_type] + OV[overlay default] + DR[default_rules] + end + subgraph partita [Partita] + M[Match] + OK[overlay_kind opzionale] + SR[scoring_rules opzionale] + end + subgraph runtime [Sessione live] + SS[ScoreState] + ENG[Scoring Engine] + OL[Overlay video] + end + SK --> BR + SK --> OV + SK --> DR + M --> SK + M --> OK + M --> SR + BR --> ENG + DR --> ENG + SR --> ENG + ENG --> SS + SS --> OL + OK --> OL +``` + +--- + +## Catalogo sport e regolamenti + +Il catalogo è definito in [`backend/config/sports.yml`](../backend/config/sports.yml) e caricato da `Sports::Catalog`. Non esiste una tabella `sports` nel database: lo sport è una **configurazione versionata** esposta via API (`GET /api/v1/sports`). + +Per ogni disciplina il catalogo specifica: + +| Campo | Significato | +|-------|-------------| +| `label` | Nome visualizzato (es. «Pallavolo») | +| `board` | Tipo tabellone implicito | +| `overlay` | Overlay video predefinito | +| `allowed_overlays` | Overlay ammessi sulla partita | +| `default_rules` | Regolamento standard di quello sport | + +### Tipi di regolamento (`default_rules`) + +Gli sport si raggruppano in tre famiglie di regole, validate da `Sports::RulesSchema`: + +#### Sport a set e punti (`board: volley` o `racket`) + +| Chiave | Significato | Esempio pallavolo | +|--------|-------------|-------------------| +| `sets_to_win` | Set necessari per vincere la partita | 3 (best of 5) | +| `points_per_set` | Punti per vincere un set «normale» | 25 | +| `points_deciding_set` | Punti dell’ultimo set (tie-break) | 15 | +| `min_point_lead` | Vantaggio minimo per chiudere il set | 2 | + +**Sport che usano queste regole:** pallavolo, beach volley, sitting volley, tennis, tennis tavolo, badminton, padel, pickleball. + +La logica è in `Scoring::Rules` (`set_winner_from_points`, `match_winner`): un set si vince al raggiungimento del target (`points_per_set` o `points_deciding_set` sull’ultimo set possibile) **con almeno** `min_point_lead` punti di distacco. + +#### Sport a tempi e periodi (`board: basket` o `timed`) + +| Chiave | Significato | Esempio basket | Esempio calcio a 5 | +|--------|-------------|----------------|---------------------| +| `periods` | Numero di periodi regolamentari | 4 quarti | 2 tempi | +| `period_duration_secs` | Durata iniziale del cronometro per periodo | 600 (10 min) | 1200 (20 min) | +| `overtime_duration_secs` | Durata supplementare / OT | 300 (5 min) | 300 | + +**Sport `timed`:** calcio a 5, pallamano, hockey indoor, floorball. + +Il cronometro è gestito dal motore (`clock_toggle`, `clock_reset`, `clock_tick`, `advance_period`) e memorizzato in `score_states.data` (JSONB). + +#### Sport senza punteggio a referto (`board: timer` o `generic`) + +| Board | Uso | Regole | Overlay default | +|-------|-----|--------|-----------------| +| `timer` | Ginnastica, danza, cheerleading, pattinaggio | Nessuna (`{}`) | `none` | +| `generic` | «Altro» | Nessuna | `none` | + +Per questi sport la regia può usare un cronometro (`TimerEngine`) ma **non** viene sovrapposto un tabellone sul video. + +--- + +## Board type: il motore di punteggio + +Il `board_type` non si sceglie manualmente in creazione partita: deriva sempre dallo `sport_key` tramite `Match#effective_board_type` → `Sports::Catalog.board_for`. + +`Scoring::Engine.for_match(match)` seleziona l’engine: + +| Board | Classe engine | Azioni principali | +|-------|---------------|-------------------| +| `volley` | `VolleyEngine` | `home_point`, `away_point`, `home_undo`, `away_undo`, `close_set` | +| `racket` | `RacketEngine` | Come volley (fase 1 — stessa logica a set) | +| `basket` | `BasketEngine` | `+1/+2/+3`, undo, `advance_period`, cronometro | +| `timed` | `TimedEngine` | `+1`, undo, periodi, cronometro | +| `timer` | `TimerEngine` | Solo cronometro (conteggio crescente) | +| `generic` | `GenericEngine` | `+1` / undo senza chiusura set | + +### Come il regolamento entra nel motore + +All’avvio di ogni azione, l’engine costruisce le regole effettive con: + +```ruby +match.effective_scoring_rules +# => default_rules del catalogo deep_merge scoring_rules della partita +``` + +Quindi: + +1. **Default dello sport** — es. pallavolo: 25 punti, set decisivo a 15. +2. **Override partita** — campo `matches.scoring_rules` (JSON) per deroghe locali (categoria giovanile, torneo ridotto…). +3. **Legacy** — per il volley, `matches.sets_to_win` viene fuso in `effective_scoring_rules` se presente. + +Il volley applica automaticamente la vittoria di set quando il punteggio soddisfa `Rules#set_winner_from_points`; `close_set` forza la chiusura manuale (utile se si preferisce confermare a bordo campo). + +--- + +## Overlay kind: la grafica sul video + +L’overlay è **indipendente** dal motore di scoring: descrive *cosa disegnare* sul frame, non *come calcolare* i punti. + +### Valori ammessi + +Definiti in `Sports::OverlayKind`: `none`, `volley`, `basket`, `timed`, `racket`. + +> Il valore legacy `timer` come overlay è mappato a `none` in `Match#effective_overlay_kind`. + +### Scelta per partita + +- Campo opzionale `matches.overlay_kind`. +- Se assente → default del catalogo (`Sports::Catalog.overlay_for`). +- Deve essere incluso in `allowed_overlays` dello sport, altrimenti si ripiega sul default. + +Esempio: il basket ammette `[basket, none]` — si può trasmettere con tabellone o solo watermark. + +### Layout Android (pipeline video) + +Sull’app nativa l’overlay è composito in GPU senza ricodifica aggiuntiva: + +1. `BroadcastScreen` costruisce un `OverlayState` a partire da `effectiveOverlayKind` e `ScoreState`. +2. `OverlayRenderer` → `OverlayCanvasRenderer` disegna gli elementi su bitmap trasparente. +3. Il bitmap viene applicato come filtro OpenGL sul flusso RTMP. + +Elementi grafici (`OverlayCanvasRenderer`): + +| Elemento | Quando è attivo | +|----------|-----------------| +| `WatermarkElement` | Sempre tranne `overlay_kind = none` | +| `ScoreboardElement` | `volley`, `racket` — tabellone a colonne per set | +| `CompactScoreboardElement` | `basket`, `timed` — squadre ai lati, punteggio centrale, etichetta periodo | +| `SponsorElement` | Se configurato | + +**Nota:** il cronometro non è più renderizzato nell’overlay video (basket/timed mostrano solo periodo, es. `Q2` o `2° tempo`). Il cronometro resta disponibile nella **regia** e nei controlli operatore, non sullo stream. + +### Mappatura overlay ← punteggio + +| Overlay | Struttura dati overlay | Fonte dati | +|---------|------------------------|------------| +| `volley` / `racket` | `ScoreboardState` (colonne set) | `set_partials`, `current_set`, punti correnti | +| `basket` / `timed` | `CompactScoreboardState` | `home_score` / `away_score` in `data`, `periodLabel` | +| `none` | Solo watermark / stato LIVE opzionale | — | + +--- + +## Stato tabellone (`ScoreState`) + +Ogni `StreamSession` ha al massimo uno `ScoreState`, creato all’apertura sessione (`Sessions::Create` → `Scoring::Engine.ensure_score_for`). + +### Campi condivisi + +| Campo | Uso | +|-------|-----| +| `board_type` | Snapshot del board al momento della creazione | +| `home_sets` / `away_sets` | Set vinti (volley/racket) | +| `home_points` / `away_points` | Punti set corrente **oppure** mirror del punteggio basket/timed | +| `current_set` | Set in corso | +| `set_partials` | Storico parziali `[{set, home, away}, …]` | +| `data` (JSONB) | Stato esteso basket/timed/timer | + +### Payload WebSocket + +`ScoreState#as_cable_payload` arricchisce il JSON in base al board: + +- **volley / racket:** set, punti, parziali. +- **basket / timed:** `home_points`/`away_points` come punteggio totale, `period`, `clock_secs`, `clock_running`. +- **timer:** solo cronometro. + +L’app mobile e la regia web si sottoscrivono al canale `SessionChannel` e ricevono `score_update` dopo ogni azione. + +--- + +## Regia web (controlli operatore) + +La pagina regia (`public/regia`) usa lo stesso motore di scoring dell’API, con partial diversi per board: + +| Partial | Board | Controlli tipici | +|---------|-------|------------------| +| `_volley.html.erb` | volley | +1, undo, chiudi set, hint punti target | +| `_racket.html.erb` | racket | Come volley | +| `_basket.html.erb` | basket | +1/+2/+3, cronometro, avanza quarto | +| `_timed.html.erb` | timed | +1, cronometro, avanza tempo | +| `_timer.html.erb` | timer | Solo cronometro | +| `_generic.html.erb` | generic | Contatore punti semplice | + +Il partial viene scelto in base a `data-board` sul container `#regia-app`. I pulsanti inviano azioni al controller regia, che chiama `Scoring::ApplyAction`. + +--- + +## Flusso end-to-end + +``` +1. Creazione partita + team.sport_key → match.sport_key + opzionale: match.overlay_kind, match.scoring_rules + +2. Avvio sessione (POST /sessions) + → ScoreState inizializzato con default_data dell’engine + → Per basket/timed: clock_secs = period_duration_secs del regolamento + +3. Azione operatore (app, regia, API) + POST score_action / WebSocket + → Scoring::ApplyAction → Engine.apply + → ScoreState aggiornato + broadcast Action Cable + +4. Rendering overlay (solo app camera / relay grafico) + ScoreState + Match.effectiveOverlayKind + → OverlayState → bitmap → filtro GL sul video + +5. Spettatori + Vedono il video con overlay già composito (non ricevono il tabellone come layer separato) +``` + +--- + +## Tabella sport → board → overlay → regolamento + +| sport_key | Board | Overlay default | Regolamento chiave | +|-----------|-------|-----------------|-------------------| +| pallavolo | volley | volley | 3 set, 25 pt, 5° set a 15, +2 | +| beach_volley | volley | volley | 2 set, 21 pt, 3° set a 15 | +| sitting_volley | volley | volley | Come pallavolo | +| basket | basket | basket | 4×10 min, OT 5 min | +| calcio_a_5 | timed | timed | 2×20 min, suppl. 5 min | +| pallamano | timed | timed | 2×30 min | +| hockey_indoor | timed | timed | 2×20 min | +| floorball | timed | timed | 3×20 min | +| tennis | racket | racket | 2 set, 6 game, tie-break 7 | +| tennis_tavolo | racket | racket | 3 set, 11 pt | +| badminton | racket | racket | 2 set, 21 pt | +| padel | racket | racket | Come tennis | +| pickleball | racket | racket | 2 set, 11 pt | +| ginnastica_* / danza / cheer / pattinaggio | timer | none | Nessun punteggio | +| altro | generic | none | Contatore libero | + +--- + +## Personalizzazione per partita + +| Campo DB | Effetto | +|----------|---------| +| `sport_key` | Determina board, overlay default e regole base | +| `overlay_kind` | Cambia solo la grafica sul video (entro `allowed_overlays`) | +| `scoring_rules` | Sovrascrive i numeri del regolamento (es. `points_per_set: 21` su pallavolo giovanile) | +| `sets_to_win` | Legacy; fuso in `effective_scoring_rules` per il volley | + +Esempio API partita: + +```json +{ + "sport_key": "pallavolo", + "overlay_kind": "none", + "scoring_rules": { + "points_per_set": 21, + "sets_to_win": 2 + } +} +``` + +→ Regia e motore usano regole da 21 punti; lo stream non mostra tabellone (`none`). + +--- + +## Relazione regolamento ↔ UI ↔ overlay + +| Aspetto | Governato da | Visibile in overlay? | +|---------|--------------|----------------------| +| Vittoria set (25+2) | `Scoring::Rules` + `VolleyEngine` | Sì — colonne set / parziali | +| Vittoria partita (3 set) | `match_winner` | Sì — conteggio set | +| Punti basket (+2/+3) | `BasketEngine` | Sì — punteggio centrale | +| Durata quarto | `period_duration_secs` | No — solo etichetta periodo (`Q1`…) | +| Cronometro | `score_states.data` | No sull’overlay (sì in regia) | +| Sport ginnico | `TimerEngine` | No overlay punteggio | + +Questa separazione è intenzionale: il regolamento alimenta sempre il **modello dati** (`ScoreState`); l’**overlay** sceglie quali campi mostrare sul video. + +--- + +## File di riferimento + +| Area | Percorso | +|------|----------| +| Catalogo sport | `backend/config/sports.yml` | +| API catalogo | `backend/app/domain/sports/catalog.rb` | +| Validazione regole | `backend/app/domain/sports/rules_schema.rb` | +| Logica regolamento | `backend/app/services/scoring/rules.rb` | +| Engine | `backend/app/services/scoring/engine.rb`, `engines/*` | +| Modello partita | `backend/app/models/match.rb` | +| Stato punteggio | `backend/app/models/score_state.rb` | +| Overlay Android | `native/android/.../streaming/overlay/*` | +| Schermata broadcast | `native/android/.../ui/broadcast/BroadcastScreen.kt` | +| Regia web | `backend/app/views/public/regia/_*.html.erb` | + +Documento correlato: [`MULTI_SPORT.md`](./MULTI_SPORT.md) (panoramica più sintetica della migrazione multi-sport). + +--- + +## Limitazioni attuali + +1. **`racket` = `volley` a livello motore** — tennis/padel usano logica a set+punti numerici; non c’è ancora scoring «15-30-40» o tie-break automatico stile tennis. +2. **Cronometro non in overlay** — per basket/timed il tempo è gestito in regia ma non bruciato sul video. +3. **Overlay solo lato publisher** — lo spettatore vede il grafico composito nel flusso; non c’è tabellone HTML separato sulla pagina live pubblica. +4. **Regolamenti = parametri numerici** — il sistema non incorpora testi di regolamento FIPAV/FIBA; implementa le regole standard dichiarate in `sports.yml`, personalizzabili per partita. + +Per aggiungere uno sport nuovo: estendere `sports.yml`, verificare che `board` e `allowed_overlays` siano coerenti, e — se serve — aggiungere un partial regia e un ramo in `BroadcastScreen` / `OverlayCanvasRenderer`. diff --git a/infra/scripts/install_production_cron.sh b/infra/scripts/install_production_cron.sh index 1c3c60f..4817611 100755 --- a/infra/scripts/install_production_cron.sh +++ b/infra/scripts/install_production_cron.sh @@ -12,6 +12,7 @@ mkdir -p "$LOG_DIR" MARKER="# matchlivetv-replay-cron" CRON_BLOCK="${MARKER} +30 * * * * ${RUNNER} recordings:cleanup_local >> ${LOG_FILE} 2>&1 0 3 * * * ${RUNNER} recordings:purge_expired >> ${LOG_FILE} 2>&1 0 8 * * * ${RUNNER} recordings:expiry_warnings >> ${LOG_FILE} 2>&1 "