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>
This commit is contained in:
14
backend/app/jobs/recordings/cleanup_local_job.rb
Normal file
14
backend/app/jobs/recordings/cleanup_local_job.rb
Normal file
@@ -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
|
||||
103
backend/app/services/recordings/cleanup_local.rb
Normal file
103
backend/app/services/recordings/cleanup_local.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
82
backend/spec/services/recordings/cleanup_local_spec.rb
Normal file
82
backend/spec/services/recordings/cleanup_local_spec.rb
Normal file
@@ -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
|
||||
351
docs/TABELLONI_E_OVERLAY.md
Normal file
351
docs/TABELLONI_E_OVERLAY.md
Normal file
@@ -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`.
|
||||
@@ -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
|
||||
"
|
||||
|
||||
Reference in New Issue
Block a user