Compare commits

..
Author SHA1 Message Date
eminuxandCursor 3ed70b6999 Aggiorna marketing: prezzi lancio, Funzionalità narrativa e link store.
Allinea i piani Light/Full ai prezzi di lancio, ripensa /funzionalita per vendere il prodotto e aggiunge i badge App Store/Play nella home.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 23:00:17 +02:00
eminuxandCursor bc2257efd2 Aggiunge la pagina pubblica /support compatibile con App Store Review.
Fornisce assistenza multilingua senza CTA commerciali e riusa l’email di supporto configurabile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 19:37:20 +02:00
Emiliano FrascaroandCursor 4af4fa68ac Prepara iOS build 32 per App Store: icona opaca e orientamenti iPad.
Rimuove l'alpha dall'AppIcon e dichiara tutte le orientazioni richieste dal multitasking iPad, così l'upload ASC non fallisce più.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 22:58:27 +02:00
Emiliano FrascaroandCursor 45bdda7c95 Semplifica la home partite a un solo CTA e sistema la nav iOS.
Rimuove il pulsante ridondante «Partita programmata», migliora il padding dei bottoni e fa di splash/login/matches root vere così non compare più il chevron indietro spurio.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 18:54:44 +02:00
Emiliano FrascaroandCursor b926531447 Allinea la versione iOS a Android 2.0.10 e aggiorna la doc di gap.
Bump marketing/build a 2.0.10/31, copy EN forgot password e checklist password policy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 18:39:43 +02:00
eminux da6fc1d523 Merge branch 'feature/password-policy'
Policy password, errori API localizzati, fix UI mobile marketing e handoff iOS.
2026-08-08 14:19:35 +02:00
eminuxandCursor 1d1cbf9f3f Localizza errori API, sistema layout mobile e documenta allineamento iOS.
Gli header Accept-Language dalle app e i fix di padding/menu sul web chiudono il giro password-policy; il doc guida il lavoro su Mac.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 14:14:42 +02:00
eminuxandCursor dd9901519a Rifiuta il riuso della password attuale in cambio e reset.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:31:47 +02:00
eminuxandCursor 53449c5d3c Allinea le password di seed alla nuova policy di complessità.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:30:51 +02:00
eminuxandCursor db908e3109 Rafforza i requisiti password con regole di complessità di mercato.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:30:37 +02:00
eminux 5857f60d79 Merge branch 'feature/account-management'
Gestione account MVP: profilo, cambio password e forgot password su web/Android/iOS.
2026-08-08 10:39:08 +02:00
eminuxandCursor 83a804a709 Aggiunge gestione account con cambio password su web e app.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 10:38:57 +02:00
eminuxandCursor b8694a6b7b Corregge i simboli nativi AAB in formato .so.sym per Play Console.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:45:29 +02:00
eminuxandCursor 5daada81b0 Incorpora i simboli nativi nell’AAB per Play Console.
Le .so AndroidX sono già stripped: AGP non generava metadata; ora le iniettiamo nel bundle prima della firma (release 2.0.9 / 30).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:36:39 +02:00
eminuxandCursor d85bab30d1 Ignora i secret Garage locali nel deploy sync.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:27:25 +02:00
eminuxandCursor 7b3256c8a0 Alleggerisce il relay YouTube e rilascia Android 2.0.8.
ffmpeg fa solo remux copy A/V; le app fissano AAC 48 kHz mono; sync deploy non cancella più garage.prod.toml.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:25:40 +02:00
eminuxandCursor 9bc89fceba Allinea il fingerprint Garage negli health check ops.
Così al ripristino dello storage gli incidenti garage_storage:head si chiudono da soli invece di restare aperti.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 16:29:03 +02:00
eminuxandCursor d5ec266437 Pubblica App Links Android e prepara la release 2.0.7.
Serve assetlinks su edge/Rails, documenta overflow Hetzner e bumpa l'AAB a 28 con simboli nativi per Play.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 19:44:17 +02:00
eminuxandCursor a448ad59ee Allinea i doc al flusso live senza OverlayRelay.
Documenta overlay in app, YoutubeRelay copy+AAC in Sidekiq
e HLS sul path camera; aggiorna checklist e troubleshooting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 23:05:50 +02:00
eminuxandCursor 74a156cedc Corregge i contatti email al dominio matchlivetv.it.
I mailto di prezzi/billing e i default privacy/mailer puntavano
ancora a matchlive.it, dominio brand incoerente.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 14:36:42 +02:00
eminuxandCursor 97d787f758 Bump Android a 2.0.6 e corregge la compilazione termica.
Alza versionCode a 27 per il test chiuso Play Console e sistema
l'etichetta Kotlin invalida in ThermalStateManager.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 15:02:35 +02:00
Emiliano FrascaroandCursor ea5da1eb86 Allinea iOS a i18n Android 2.0.5 e aggiunge monitoraggio termico nativo.
Completa L10n su login/hub/wizard/broadcast e introduce ThermalStateManager su iOS/Android con degradazione qualità e indicatore in overlay.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 11:19:37 +02:00
eminuxandCursor ff4de33cc5 Stabilizza i18n Android 2.0.5: lingua live senza perdere sessione né crashare il wizard.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 09:36:56 +02:00
eminuxandCursor 97af398654 Aggiorna il gap iOS per replicare i18n Android 2.0.4 completo.
Checklist a fasi (stringhe, hub/sheet, wizard, broadcast, bump versione) allineata al rilascio sul telefono.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 08:48:55 +02:00
eminuxandCursor 0a9185347f Aggiorna il gap doc iOS con la copertura i18n Android 2.0.4.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 08:41:28 +02:00
eminuxandCursor 5ff22c7586 Completa i18n Android su sheet, wizard, broadcast e login.
Selettore lingua con icona globo al login; dialog e bottom sheet (es. scegli squadra) tradotti; bump a 2.0.4-native.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 08:40:52 +02:00
eminuxandCursor adceb8f13f Documenta il gap i18n Android→iOS per continuare sul Mac.
Checklist operativa con file, chiavi stringa e bump versione per allineare l’app iOS alla release Android 2.0.3.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 01:04:42 +02:00
eminuxandCursor f2498a9761 Allinea le expectation billing agli stringhe i18n.
Le assertion usavano HTML/testi pre-traduzione (strong e label Attiva).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 00:52:21 +02:00
eminuxandCursor b88f44ab1c Completa i18n IT/EN/FR/DE/ES su sito pubblico, area autenticata e admin.
Tutte le view marketing, legali, viewer, dashboard e admin usano t(); mailer utente-facing e flash localizzati; admin con LocaleResolver e selettore lingua.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 00:38:50 +02:00
eminuxandCursor fb7068f86c Fix deploy migrate su immagine nuova e bump Android 2.0.3.
Le migrate partono dall'immagine appena buildata invece che dal container vecchio; helper live tollera slug assente.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 23:31:17 +02:00
eminuxandCursor 1d97271555 Aggiunge i18n IT/EN/FR/DE/ES, pagine pubbliche squadre e UX archivio.
Il selettore lingua funziona sul web e sulle app native; su Android la preferenza è persistita e applicata al riavvio. Incluse anche eliminazione replay a scope e campi pagina pubblica squadra.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 19:30:44 +02:00
eminuxandCursor 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 FrascaroandCursor 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 FrascaroandCursor 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 FrascaroandCursor 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 FrascaroandCursor 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 FrascaroandCursor 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 FrascaroandCursor 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 FrascaroandCursor 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 FrascaroandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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 FrascaroandCursor 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 FrascaroandCursor 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 FrascaroandCursor 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 FrascaroandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
eminuxandCursor 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
578 changed files with 42950 additions and 3397 deletions
+7 -2
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)
+2
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"
+9
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
@@ -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
@@ -10,16 +10,16 @@ module Admin
account = AdminAccount.find_by(username: params[:username]&.strip) account = AdminAccount.find_by(username: params[:username]&.strip)
if account&.authenticate(params[:password]) if account&.authenticate(params[:password])
session[:admin_account_id] = account.id session[:admin_account_id] = account.id
redirect_to session.delete(:admin_return_to) || admin_root_path, notice: "Accesso effettuato" redirect_to session.delete(:admin_return_to) || admin_root_path, notice: t("admin.flash.login_success")
else else
flash.now[:alert] = "Username o password non validi" flash.now[:alert] = t("admin.flash.invalid_credentials")
render :new, status: :unauthorized render :new, status: :unauthorized
end end
end end
def destroy def destroy
reset_session reset_session
redirect_to admin_login_path, notice: "Disconnesso" redirect_to admin_login_path, notice: t("admin.flash.logout_success")
end end
end end
end end
@@ -3,18 +3,40 @@ module Admin
layout "admin" layout "admin"
protect_from_forgery with: :exception protect_from_forgery with: :exception
before_action :set_admin_locale
before_action :require_admin_login before_action :require_admin_login
include ::AdminHelper include ::AdminHelper
helper_method :current_admin_account, :admin_logged_in? helper ApplicationHelper
helper RecordingsArchiveHelper
helper_method :current_admin_account, :admin_logged_in?, :current_locale, :language_options, :current_language_option
private private
def set_admin_locale
I18n.locale = LocaleResolver.resolve(
cookie_jar: request.cookie_jar,
accept_language: request.get_header("HTTP_ACCEPT_LANGUAGE")
)
end
def current_locale
I18n.locale
end
def language_options
LocaleResolver.language_options
end
def current_language_option
LocaleResolver.current_language_option
end
def require_admin_login def require_admin_login
return if admin_logged_in? return if admin_logged_in?
session[:admin_return_to] = request.fullpath unless request.path == admin_login_path session[:admin_return_to] = request.fullpath unless request.path == admin_login_path
redirect_to admin_login_path, alert: "Accedi per continuare" redirect_to admin_login_path, alert: t("admin.flash.login_required")
end end
def current_admin_account def current_admin_account
@@ -11,7 +11,7 @@ module Admin
payment = Billing::Payment.find(params[:payment_id]) payment = Billing::Payment.find(params[:payment_id])
Billing::AttachPaymentInvoice.call(payment: payment, pdf: params[:pdf]) Billing::AttachPaymentInvoice.call(payment: payment, pdf: params[:pdf])
redirect_to admin_billing_path(billing_redirect_params(payment)), redirect_to admin_billing_path(billing_redirect_params(payment)),
notice: "Fattura caricata e inviata a #{payment.club.billing_email}." notice: t("admin.flash.invoice_uploaded", email: payment.club.billing_email)
rescue Billing::AttachPaymentInvoice::Error, Billing::IssueInvoice::Error => e rescue Billing::AttachPaymentInvoice::Error, Billing::IssueInvoice::Error => e
payment ||= Billing::Payment.find_by(id: params[:payment_id]) payment ||= Billing::Payment.find_by(id: params[:payment_id])
redirect_to admin_billing_path(payment ? billing_redirect_params(payment) : {}), redirect_to admin_billing_path(payment ? billing_redirect_params(payment) : {}),
@@ -8,7 +8,7 @@ module Admin
end end
def new def new
redirect_to admin_billing_path(club_id: @club.id), alert: "Carica il PDF dalla lista «Pagamenti da fatturare»." redirect_to admin_billing_path(club_id: @club.id), alert: t("admin.flash.invoice_upload_hint")
end end
def create def create
@@ -18,7 +18,7 @@ module Admin
if @invoice.save if @invoice.save
redirect_to edit_admin_club_billing_invoice_path(@club, @invoice), redirect_to edit_admin_club_billing_invoice_path(@club, @invoice),
notice: "Bozza creata. Carica il PDF dalla pagina Fatturazione o qui sotto." notice: t("admin.flash.invoice_draft_created")
else else
@payment = @invoice.billing_payment @payment = @invoice.billing_payment
flash.now[:alert] = @invoice.errors.full_messages.join(", ") flash.now[:alert] = @invoice.errors.full_messages.join(", ")
@@ -36,9 +36,9 @@ module Admin
if issuing? if issuing?
Billing::IssueInvoice.call(invoice: @invoice, pdf: params.dig(:billing_invoice, :pdf)) Billing::IssueInvoice.call(invoice: @invoice, pdf: params.dig(:billing_invoice, :pdf))
redirect_to admin_billing_path(club_id: @club.id), redirect_to admin_billing_path(club_id: @club.id),
notice: "Fattura #{@invoice.number} emessa e inviata a #{@club.billing_email}." notice: t("admin.flash.invoice_issued", number: @invoice.number, email: @club.billing_email)
elsif @invoice.save elsif @invoice.save
redirect_to admin_billing_path(club_id: @club.id), notice: "Fattura #{@invoice.number} aggiornata." redirect_to admin_billing_path(club_id: @club.id), notice: t("admin.flash.invoice_updated", number: @invoice.number)
else else
@payment = @invoice.billing_payment @payment = @invoice.billing_payment
flash.now[:alert] = @invoice.errors.full_messages.join(", ") flash.now[:alert] = @invoice.errors.full_messages.join(", ")
@@ -61,7 +61,7 @@ module Admin
end end
def issuing? def issuing?
params[:commit].to_s == "Emetti e invia via email" params[:commit_action].to_s == "issue"
end end
def invoice_params def invoice_params
@@ -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
@@ -20,14 +20,14 @@ module Admin
reason: params[:reason], reason: params[:reason],
admin: current_admin_account admin: current_admin_account
) )
redirect_back_or_club notice: "Abbonamento omaggio #{Plan[params[:plan_slug]].name} attivato per #{@club.name}." redirect_back_or_club notice: t("admin.flash.comped_granted", plan: Plan[params[:plan_slug]].name, club: @club.name)
rescue Billing::AdminCompedSubscription::Error, ActiveRecord::RecordInvalid => e rescue Billing::AdminCompedSubscription::Error, ActiveRecord::RecordInvalid => e
redirect_back_or_club alert: e.message redirect_back_or_club alert: e.message
end end
def revoke_comped def revoke_comped
Billing::AdminCompedSubscription.revoke(club: @club, admin: current_admin_account) Billing::AdminCompedSubscription.revoke(club: @club, admin: current_admin_account)
redirect_back_or_club notice: "Abbonamento omaggio revocato per #{@club.name}." redirect_back_or_club notice: t("admin.flash.comped_revoked", club: @club.name)
rescue Billing::AdminCompedSubscription::Error => e rescue Billing::AdminCompedSubscription::Error => e
redirect_back_or_club alert: e.message redirect_back_or_club alert: e.message
end end
@@ -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
@@ -0,0 +1,11 @@
module Admin
class LocalesController < BaseController
skip_before_action :require_admin_login
def update
locale = LocaleResolver.persist!(cookies, params[:locale])
I18n.locale = locale
redirect_back fallback_location: admin_root_path
end
end
end
@@ -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: t("admin.flash.ops_acknowledged")
end
def resolve
incident = Ops::Incident.find(params[:id])
incident.resolve!
redirect_to admin_ops_path, notice: t("admin.flash.ops_resolved")
end
def mute
incident = Ops::Incident.find(params[:id])
incident.mute!(duration: 24.hours)
redirect_to admin_ops_path, notice: t("admin.flash.ops_muted")
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
@@ -5,22 +5,32 @@ module Admin
def update def update
unless current_admin_account.authenticate(params[:current_password]) unless current_admin_account.authenticate(params[:current_password])
flash.now[:alert] = "Password attuale non corretta" flash.now[:alert] = t("admin.flash.password_current_incorrect")
return render :edit, status: :unprocessable_entity
end
if params[:password].blank? || params[:password].length < 8
flash.now[:alert] = "La nuova password deve avere almeno 8 caratteri"
return render :edit, status: :unprocessable_entity return render :edit, status: :unprocessable_entity
end end
if params[:password] != params[:password_confirmation] if params[:password] != params[:password_confirmation]
flash.now[:alert] = "Le password non coincidono" flash.now[:alert] = t("admin.flash.password_mismatch")
return render :edit, status: :unprocessable_entity
end
if (code = PasswordComplexity.violation(params[:password]))
key = case code
when :blank, :too_short then :password_too_short
when :too_long then :password_too_long
else :password_too_weak
end
flash.now[:alert] = t("admin.flash.#{key}")
return render :edit, status: :unprocessable_entity
end
if PasswordComplexity.same_as_current?(current_admin_account, params[:password])
flash.now[:alert] = t("admin.flash.password_same_as_current")
return render :edit, status: :unprocessable_entity return render :edit, status: :unprocessable_entity
end end
if current_admin_account.update(password: params[:password]) if current_admin_account.update(password: params[:password])
redirect_to admin_root_path, notice: "Password aggiornata" redirect_to admin_root_path, notice: t("admin.flash.password_updated")
else else
flash.now[:alert] = current_admin_account.errors.full_messages.to_sentence flash.now[:alert] = current_admin_account.errors.full_messages.to_sentence
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
@@ -12,15 +12,32 @@ module Admin
def stop def stop
@session = StreamSession.find(params[:id]) @session = StreamSession.find(params[:id])
if @session.terminal? if @session.terminal?
redirect_to admin_session_path(@session), alert: "Sessione già terminata (#{@session.status})." redirect_to admin_session_path(@session), alert: t("admin.flash.session_already_terminated", status: @session.status)
return return
end end
Sessions::Stop.new(@session).call Sessions::Stop.new(@session).call
redirect_to admin_session_path(@session), notice: "Sessione terminata." redirect_to admin_session_path(@session), notice: t("admin.flash.session_stopped")
rescue StandardError => e rescue StandardError => e
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: t("admin.flash.session_stop_error", error: e.message)
end
def regia_link
@session = StreamSession.find(params[:id])
if @session.terminal?
redirect_to admin_session_path(@session), alert: t("admin.flash.session_terminated_no_regia")
return
end
access = Sessions::RegiaAccess.new(@session)
token = access.issue_token!
redirect_to admin_session_path(@session),
notice: t("admin.flash.session_regia_generated"),
flash: {
regia_url: access.url(token),
regia_expires_at: @session.regia_token_expires_at&.iso8601
}
end end
end end
end end
@@ -2,7 +2,7 @@ module Admin
class YoutubeController < Admin::BaseController class YoutubeController < Admin::BaseController
def platform def platform
if ENV["YOUTUBE_CLIENT_ID"].blank? if ENV["YOUTUBE_CLIENT_ID"].blank?
redirect_to admin_root_path, alert: "Configura YOUTUBE_CLIENT_ID e YOUTUBE_CLIENT_SECRET in .env" redirect_to admin_root_path, alert: t("admin.flash.youtube_not_configured")
return return
end end
@@ -0,0 +1,60 @@
module Api
module V1
class AccountsController < ApplicationController
def show
render json: user_json(current_user)
end
def update
name = params[:name].to_s.strip
if name.blank?
return render json: { error: I18n.t("flash.accounts.name_required") }, status: :unprocessable_entity
end
if current_user.update(name: name)
render json: user_json(current_user)
else
render json: { error: current_user.errors.full_messages.join(", ") }, status: :unprocessable_entity
end
end
def password
result = Users::ChangePassword.call(
user: current_user,
current_password: params[:current_password],
password: params[:password],
password_confirmation: params[:password_confirmation]
)
unless result.ok?
return render json: { error: password_error_message(result.error) }, status: :unprocessable_entity
end
render json: { message: I18n.t("flash.accounts.password_updated") }
end
private
def user_json(user)
{
id: user.id,
email: user.email,
name: user.name,
role: user.role
}
end
def password_error_message(code)
case code
when :current_incorrect then I18n.t("flash.accounts.password_current_incorrect")
when :too_short then I18n.t("flash.accounts.password_too_short")
when :too_long then I18n.t("flash.accounts.password_too_long")
when :too_weak then I18n.t("flash.accounts.password_too_weak")
when :same_as_current then I18n.t("flash.accounts.password_same_as_current")
when :mismatch then I18n.t("flash.accounts.password_mismatch")
else I18n.t("flash.accounts.password_update_failed")
end
end
end
end
end
@@ -1,7 +1,7 @@
module Api module Api
module V1 module V1
class AuthController < ApplicationController class AuthController < ApplicationController
skip_before_action :authenticate_request!, only: %i[login refresh register] skip_before_action :authenticate_request!, only: %i[login refresh register forgot_password]
def register def register
user = User.new( user = User.new(
@@ -22,18 +22,18 @@ module Api
if user&.authenticate(params[:password]) if user&.authenticate(params[:password])
render json: token_response(user), status: :ok render json: token_response(user), status: :ok
else else
render json: { error: "Invalid credentials" }, status: :unauthorized render json: { error: I18n.t("flash.sessions.invalid_credentials") }, status: :unauthorized
end end
end end
def logout def logout
render json: { message: "Logged out" } render json: { message: I18n.t("flash.sessions.logged_out") }
end end
def refresh def refresh
payload = JsonWebToken.decode(params[:refresh_token] || bearer_token) payload = JsonWebToken.decode(params[:refresh_token] || bearer_token)
user = User.find_by(id: payload&.dig(:user_id)) user = User.find_by(id: payload&.dig(:user_id))
return render json: { error: "Invalid token" }, status: :unauthorized unless user return render json: { error: I18n.t("flash.sessions.invalid_token") }, status: :unauthorized unless user
render json: token_response(user) render json: token_response(user)
end end
@@ -42,6 +42,13 @@ module Api
render json: user_json(current_user) render json: user_json(current_user)
end end
def forgot_password
Users::RequestPasswordReset.call(email: params[:email])
render json: {
message: I18n.t("flash.password_resets.email_sent")
}
end
private private
def token_response(user) def token_response(user)
@@ -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
@@ -5,13 +5,18 @@ 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
def destroy def destroy
authorize_manage! authorize_manage!
Recordings::Delete.new(@recording).call scope = params[:delete_scope].to_s
scope = "both" unless Recordings::Delete::SCOPES.include?(scope)
Recordings::Delete.new(@recording, scope: scope).call
head :no_content head :no_content
end end
@@ -96,7 +101,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
@@ -0,0 +1,9 @@
module Api
module V1
class SportsController < BaseController
def index
render json: ::Sports::Catalog.as_api_list
end
end
end
end
@@ -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
@@ -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
@@ -1,17 +1,25 @@
class ApplicationController < ActionController::API class ApplicationController < ActionController::API
include ActionController::HttpAuthentication::Token::ControllerMethods include ActionController::HttpAuthentication::Token::ControllerMethods
before_action :set_api_locale
before_action :authenticate_request! before_action :authenticate_request!
attr_reader :current_user attr_reader :current_user
private private
def set_api_locale
explicit = LocaleResolver.normalize(request.headers["X-Locale"])
I18n.locale = explicit ||
LocaleResolver.from_accept_language(request.headers["Accept-Language"]) ||
I18n.default_locale
end
def authenticate_request! def authenticate_request!
token = bearer_token token = bearer_token
payload = JsonWebToken.decode(token) payload = JsonWebToken.decode(token)
@current_user = User.find_by(id: payload[:user_id]) if payload @current_user = User.find_by(id: payload[:user_id]) if payload
render json: { error: "Unauthorized" }, status: :unauthorized unless @current_user render json: { error: I18n.t("flash.sessions.unauthorized") }, status: :unauthorized unless @current_user
end end
def bearer_token def bearer_token
@@ -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
@@ -0,0 +1,140 @@
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
scope = delete_scope_param
Recordings::Delete.new(@recording, scope: scope).call
redirect_to archive_index_path, notice: delete_notice_for(scope)
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 delete_scope_param
value = params[:delete_scope].to_s
Recordings::Delete::SCOPES.include?(value) ? value : "both"
end
def delete_notice_for(scope)
case scope
when "site"
"Replay eliminato dal sito (YouTube invariato)"
when "youtube"
"Video rimosso da YouTube (resta disponibile sul sito)"
else
"Replay eliminato dal sito e da YouTube"
end
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
+77 -17
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"
5.times do
response = hls_conn.get(upstream) { |req| req.headers["Cookie"] = cookie } response = hls_conn.get(upstream) { |req| req.headers["Cookie"] = cookie }
if response.status == 302 if response.status.in?([301, 302, 307, 308])
location = response.headers["location"].to_s location = response.headers["location"].to_s
follow_url = if location.start_with?("http") cookie = cookie_from_set_header(response.headers["set-cookie"]).presence || cookie
location upstream = resolve_upstream_url(location)
else next
"#{MatchLiveTv.mediamtx_hls_url.sub(%r{/$}, "")}#{location}"
end
set_cookie = response.headers["set-cookie"].to_s
cookie = set_cookie.presence || cookie
response = hls_conn.get(follow_url) { |req| req.headers["Cookie"] = cookie }
end end
response.headers.each do |key, value| return render_proxied_response(response)
next if %w[transfer-encoding connection].include?(key.downcase)
headers[key] = value
end end
render body: response.body, status: response.status
head :bad_gateway
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|
@@ -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
@@ -0,0 +1,39 @@
module Public
class AccountsController < WebBaseController
before_action :require_login!
def show
end
def update
name = params[:name].to_s.strip
if name.blank?
flash.now[:alert] = t("flash.accounts.name_required")
return render :show, status: :unprocessable_entity
end
if current_user.update(name: name)
redirect_to public_account_path, notice: t("flash.accounts.profile_updated")
else
flash.now[:alert] = current_user.errors.full_messages.to_sentence
render :show, status: :unprocessable_entity
end
end
def update_password
result = Users::ChangePassword.call(
user: current_user,
current_password: params[:current_password],
password: params[:password],
password_confirmation: params[:password_confirmation]
)
unless result.ok?
redirect_to public_account_path, alert: t("flash.accounts.password_#{result.error}")
return
end
redirect_to public_account_path, notice: t("flash.accounts.password_updated")
end
end
end
@@ -15,9 +15,9 @@ module Public
@club, @club,
plan: premium_checkout_return_params[:plan], plan: premium_checkout_return_params[:plan],
interval: premium_checkout_return_params[:interval] interval: premium_checkout_return_params[:interval]
), notice: "Dati salvati. Procedi con il pagamento." ), notice: t("flash.club_billing.profile_saved_proceed_payment")
else else
redirect_to public_club_billing_path(@club), notice: "Dati di fatturazione aggiornati." redirect_to public_club_billing_path(@club), notice: t("flash.club_billing.profile_updated")
end end
else else
flash.now[:alert] = @club.errors.full_messages.join(", ") flash.now[:alert] = @club.errors.full_messages.join(", ")
@@ -27,25 +27,25 @@ module Public
def cancel_subscription def cancel_subscription
unless MatchLiveTv.stripe_enabled? unless MatchLiveTv.stripe_enabled?
redirect_to public_club_billing_path(@club), alert: "Stripe non configurato." redirect_to public_club_billing_path(@club), alert: t("flash.club_billing.stripe_not_configured")
return return
end end
Billing::Stripe::CancelSubscription.call(club: @club) Billing::Stripe::CancelSubscription.call(club: @club)
sub = @club.subscription.reload sub = @club.subscription.reload
date = sub.current_period_end ? helpers.l_local(sub.current_period_end.to_date) : "fine periodo" date = sub.current_period_end ? helpers.l_local(sub.current_period_end.to_date) : t("billing.subscription_status.end_of_period")
redirect_to public_club_billing_path(@club), redirect_to public_club_billing_path(@club),
notice: "Abbonamento disdetto. Resta attivo fino al #{date}; da quel giorno passerai al piano Free." notice: t("flash.club_billing.subscription_canceled", date: date)
rescue ArgumentError, RuntimeError => e rescue ArgumentError, RuntimeError => e
redirect_to public_club_billing_path(@club), alert: e.message redirect_to public_club_billing_path(@club), alert: e.message
rescue ::Stripe::StripeError => e rescue ::Stripe::StripeError => e
redirect_to public_club_billing_path(@club), alert: "Errore Stripe: #{e.message}" redirect_to public_club_billing_path(@club), alert: t("flash.club_billing.stripe_error", message: e.message)
end end
def download_invoice def download_invoice
invoice = @club.billing_invoices.find(params[:invoice_id]) invoice = @club.billing_invoices.find(params[:invoice_id])
unless invoice.pdf.attached? unless invoice.pdf.attached?
redirect_to public_club_billing_path(@club), alert: "PDF fattura non disponibile." redirect_to public_club_billing_path(@club), alert: t("flash.club_billing.invoice_pdf_unavailable")
return return
end end
@@ -14,7 +14,7 @@ module Public
@selected_team = @schedulable_teams.find { |t| t.id.to_s == params[:team_id].to_s } @selected_team = @schedulable_teams.find { |t| t.id.to_s == params[:team_id].to_s }
unless @selected_team unless @selected_team
@match = default_match_for_form @match = default_match_for_form
flash.now[:alert] = "Seleziona una squadra valida." flash.now[:alert] = t("flash.club_matches.invalid_team")
return render :new, status: :unprocessable_entity return render :new, status: :unprocessable_entity
end end
@@ -22,7 +22,7 @@ module Public
match_params.merge(sport: @selected_team.sport, sets_to_win: 3) match_params.merge(sport: @selected_team.sport, sets_to_win: 3)
) )
redirect_to public_live_index_path(club_id: @club.id), redirect_to public_live_index_path(club_id: @club.id),
notice: "Partita programmata: #{@selected_team.name} vs #{@match.opponent_name}." notice: t("flash.club_matches.scheduled", team: @selected_team.name, opponent: @match.opponent_name)
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
@match = @selected_team.matches.build(match_params) @match = @selected_team.matches.build(match_params)
flash.now[:alert] = e.record.errors.full_messages.join(", ") flash.now[:alert] = e.record.errors.full_messages.join(", ")
@@ -44,7 +44,7 @@ module Public
return if @schedulable_teams.any? return if @schedulable_teams.any?
redirect_to public_live_index_path(club_id: @club.id), redirect_to public_live_index_path(club_id: @club.id),
alert: "Non hai permessi per programmare partite in questa società." alert: t("flash.club_matches.no_schedulable_teams")
end end
def resolve_selected_team def resolve_selected_team
@@ -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: t("flash.club_recordings.not_authorized")
end end
end end
end end
@@ -12,7 +12,7 @@ module Public
def create def create
if current_user.owned_clubs.exists? if current_user.owned_clubs.exists?
redirect_to public_club_path(current_user.primary_club), alert: "Hai già registrato una società" redirect_to public_club_path(current_user.primary_club), alert: t("flash.clubs.already_registered")
return return
end end
@@ -22,7 +22,7 @@ module Public
ClubMembership.create!(user: current_user, club: club, role: "owner") ClubMembership.create!(user: current_user, club: club, role: "owner")
first_team_name = params.dig(:first_team, :name).presence || "Prima squadra" first_team_name = params.dig(:first_team, :name).presence || t("club.new.default_first_team_name")
club.teams.create!( club.teams.create!(
name: first_team_name, name: first_team_name,
sport: club.sport sport: club.sport
@@ -34,14 +34,14 @@ module Public
if plan.in?(%w[premium_light premium_full]) && MatchLiveTv.stripe_enabled? if plan.in?(%w[premium_light premium_full]) && MatchLiveTv.stripe_enabled?
unless club.billing_profile_complete? unless club.billing_profile_complete?
redirect_to public_club_billing_profile_path(club, plan: plan, interval: checkout_interval_param), redirect_to public_club_billing_profile_path(club, plan: plan, interval: checkout_interval_param),
alert: "Completa i dati di fatturazione prima di attivare un piano premium." alert: t("flash.clubs.complete_billing_first")
return return
end end
interval = checkout_interval_param interval = checkout_interval_param
redirect_to public_club_checkout_path(club, plan: plan, interval: interval) redirect_to public_club_checkout_path(club, plan: plan, interval: interval)
else else
redirect_to public_club_path(club), notice: "Società e prima squadra create." redirect_to public_club_path(club), notice: t("flash.clubs.created")
end end
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
flash.now[:alert] = e.record.errors.full_messages.join(", ") flash.now[:alert] = e.record.errors.full_messages.join(", ")
@@ -66,7 +66,7 @@ module Public
@club.assign_attributes(club_params) @club.assign_attributes(club_params)
attach_branding_logo(@club) attach_branding_logo(@club)
@club.save! @club.save!
redirect_to public_club_path(@club), notice: "Società aggiornata." redirect_to public_club_path(@club), notice: t("flash.clubs.updated")
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
flash.now[:alert] = e.record.errors.full_messages.join(", ") flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
@@ -88,12 +88,12 @@ module Public
require_club_owner!(@club) require_club_owner!(@club)
if @club.subscription&.admin_comped? if @club.subscription&.admin_comped?
redirect_to public_club_billing_path(@club), redirect_to public_club_billing_path(@club),
alert: "Il piano è un abbonamento omaggio gestito da Match Live TV. Per passare a un abbonamento a pagamento contatta il supporto." alert: t("flash.clubs.comped_change_denied")
return return
end end
unless MatchLiveTv.stripe_enabled? unless MatchLiveTv.stripe_enabled?
redirect_to public_club_billing_path(@club), alert: "Pagamenti non ancora configurati sul server" redirect_to public_club_billing_path(@club), alert: t("flash.clubs.stripe_not_configured")
return return
end end
@@ -105,15 +105,15 @@ module Public
if sub&.stripe_subscription_id.present? && sub.active? && if sub&.stripe_subscription_id.present? && sub.active? &&
sub.plan.slug == plan_slug && sub.billing_interval == interval && !sub.plan_change_pending? sub.plan.slug == plan_slug && sub.billing_interval == interval && !sub.plan_change_pending?
redirect_to public_club_billing_path(@club), redirect_to public_club_billing_path(@club),
notice: "Sei già su #{target_plan.name} (#{Billing::Stripe::PriceCatalog.label(plan_slug: plan_slug, interval: interval)})." notice: t("flash.clubs.already_on_plan", plan: target_plan.name, price: Billing::Stripe::PriceCatalog.label(plan_slug: plan_slug, interval: interval))
return return
end end
if sub&.plan_change_pending? && sub.pending_plan&.slug == plan_slug && if sub&.plan_change_pending? && sub.pending_plan&.slug == plan_slug &&
sub.pending_billing_interval == interval sub.pending_billing_interval == interval
when_label = sub.current_period_end.present? ? I18n.l(sub.current_period_end, format: :long) : "il prossimo rinnovo" when_label = sub.current_period_end.present? ? I18n.l(sub.current_period_end, format: :long) : t("billing.messages.next_renewal")
redirect_to public_club_billing_path(@club), redirect_to public_club_billing_path(@club),
notice: "Il passaggio a #{target_plan.name} è già programmato dal #{when_label}." notice: t("flash.clubs.change_already_scheduled", plan: target_plan.name, date: when_label)
return return
end end
@@ -134,7 +134,7 @@ module Public
redirect_to public_club_billing_path(@club), alert: e.message redirect_to public_club_billing_path(@club), alert: e.message
rescue ::Stripe::StripeError => e rescue ::Stripe::StripeError => e
Rails.logger.warn("[Stripe checkout] #{e.message}") Rails.logger.warn("[Stripe checkout] #{e.message}")
redirect_to public_club_billing_path(@club), alert: "Errore Stripe: #{e.message}" redirect_to public_club_billing_path(@club), alert: t("flash.clubs.stripe_error", message: e.message)
end end
def youtube_connect def youtube_connect
@@ -143,7 +143,7 @@ module Public
entitlements.assert_can_connect_youtube! entitlements.assert_can_connect_youtube!
if ENV["YOUTUBE_CLIENT_ID"].blank? if ENV["YOUTUBE_CLIENT_ID"].blank?
redirect_to public_club_path(@club), alert: "YouTube OAuth non configurato sul server" redirect_to public_club_path(@club), alert: t("flash.clubs.youtube_oauth_not_configured")
return return
end end
@@ -161,13 +161,13 @@ module Public
def youtube_disconnect def youtube_disconnect
require_club_owner!(@club) require_club_owner!(@club)
@club.youtube_credential&.destroy! @club.youtube_credential&.destroy!
redirect_to public_club_path(@club), notice: "Canale YouTube della società scollegato" redirect_to public_club_path(@club), notice: t("flash.clubs.youtube_disconnected")
end end
def portal def portal
require_club_owner!(@club) require_club_owner!(@club)
unless MatchLiveTv.stripe_enabled? unless MatchLiveTv.stripe_enabled?
redirect_to public_club_billing_path(@club), alert: "Portale pagamenti non configurato" redirect_to public_club_billing_path(@club), alert: t("flash.clubs.portal_not_configured")
return return
end end
@@ -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
@@ -203,7 +204,7 @@ module Public
@club, @club,
plan: plan_slug, plan: plan_slug,
interval: params[:interval].presence interval: params[:interval].presence
), alert: "Completa i dati di fatturazione prima di attivare un piano premium." ), alert: t("flash.clubs.complete_billing_first")
end end
def checkout_interval_param def checkout_interval_param
@@ -235,10 +236,10 @@ module Public
def apply_checkout_flash! def apply_checkout_flash!
case params[:checkout] case params[:checkout]
when "success" when "success"
plan_name = @subscription&.plan&.name || "premium" plan_name = @subscription&.plan&.name || "Premium"
flash.now[:notice] = "Pagamento completato! Piano attivo: #{plan_name}." flash.now[:notice] = t("flash.clubs.checkout_success", plan: plan_name)
when "canceled" when "canceled"
flash.now[:alert] = "Pagamento annullato. Nessun addebito è stato effettuato." flash.now[:alert] = t("flash.clubs.checkout_canceled")
end end
end end
end end
@@ -4,24 +4,24 @@ module Public
@token = params[:token] @token = params[:token]
@invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(@token.to_s)) @invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(@token.to_s))
unless @invitation unless @invitation
redirect_to public_pricing_path, alert: "Invito non valido o scaduto" redirect_to public_pricing_path, alert: t("flash.invitations.invalid_or_expired")
end end
end end
def accept def accept
invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(params[:token].to_s)) invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(params[:token].to_s))
return redirect_to public_pricing_path, alert: "Invito non valido" unless invitation return redirect_to public_pricing_path, alert: t("flash.invitations.invalid") unless invitation
if logged_in? if logged_in?
if current_user.email.downcase != invitation.email.downcase if current_user.email.downcase != invitation.email.downcase
redirect_to public_pricing_path, alert: "Questo invito è per #{invitation.email}" redirect_to public_pricing_path, alert: t("flash.invitations.wrong_email", email: invitation.email)
return return
end end
invitation.accept!(current_user) invitation.accept!(current_user)
redirect_to public_team_details_path(invitation.team), notice: "Sei entrato nella squadra!" redirect_to public_team_details_path(invitation.team), notice: t("flash.invitations.joined_team")
else else
session[:pending_invite_token] = params[:token] session[:pending_invite_token] = params[:token]
redirect_to public_signup_path, notice: "Registrati con #{invitation.email} per accettare l'invito" redirect_to public_signup_path, notice: t("flash.invitations.signup_to_accept", email: invitation.email)
end end
end end
end end
@@ -0,0 +1,9 @@
module Public
class LocalesController < SiteBaseController
def update
locale = LocaleResolver.persist!(cookies, params[:locale])
I18n.locale = locale
redirect_back fallback_location: root_path
end
end
end
@@ -25,7 +25,7 @@ module Public
match_params.merge(sport: @team.sport, sets_to_win: match_params[:sets_to_win].presence || 3) match_params.merge(sport: @team.sport, sets_to_win: match_params[:sets_to_win].presence || 3)
) )
redirect_to public_team_matches_path(@team), redirect_to public_team_matches_path(@team),
notice: "Partita programmata: #{@match.opponent_name}. Avvia lo streaming dallapp quando sei in palestra." notice: t("flash.matches.scheduled", opponent: @match.opponent_name)
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
@match = @team.matches.build(match_params) @match = @team.matches.build(match_params)
flash.now[:alert] = e.record.errors.full_messages.join(", ") flash.now[:alert] = e.record.errors.full_messages.join(", ")
@@ -37,7 +37,7 @@ module Public
def update def update
@match.update!(match_params) @match.update!(match_params)
redirect_to public_team_matches_path(@team), notice: "Partita aggiornata." redirect_to public_team_matches_path(@team), notice: t("flash.matches.updated")
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
flash.now[:alert] = e.record.errors.full_messages.join(", ") flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
@@ -47,12 +47,12 @@ module Public
active = active_session_for(@match) active = active_session_for(@match)
if active&.resumable? if active&.resumable?
redirect_to public_team_matches_path(@team), redirect_to public_team_matches_path(@team),
alert: "Chiudi la diretta prima di eliminare questa partita." alert: t("flash.matches.close_live_before_delete")
return return
end end
@match.destroy! @match.destroy!
redirect_to public_team_matches_path(@team), notice: "Partita eliminata." redirect_to public_team_matches_path(@team), notice: t("flash.matches.deleted")
end end
private private
@@ -66,7 +66,7 @@ module Public
return if current_user.can_schedule_for?(@team) return if current_user.can_schedule_for?(@team)
redirect_to public_team_details_path(@team), redirect_to public_team_details_path(@team),
alert: "Solo un responsabile trasmissione o il titolare della società possono programmare le partite." alert: t("flash.matches.schedule_denied")
end end
def set_match def set_match
@@ -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
@@ -11,17 +11,8 @@ module Public
load_club_billing_context_for_pricing load_club_billing_context_for_pricing
end end
private def support
@app_store_review_chrome = true
def load_club_billing_context_for_pricing
return unless logged_in?
@club = current_user.primary_club
return unless @club
@subscription = @club.subscription
@team = @club.teams.order(:name).first
@entitlements = @team&.entitlements
end end
def privacy def privacy
@@ -38,5 +29,18 @@ module Public
def pallavolo def pallavolo
end end
private
def load_club_billing_context_for_pricing
return unless logged_in?
@club = current_user.primary_club
return unless @club
@subscription = @club.subscription
@team = @club.teams.order(:name).first
@entitlements = @team&.entitlements
end
end end
end end
@@ -4,21 +4,17 @@ module Public
end end
def create def create
user = User.find_by(email: params[:email]&.downcase&.strip) Users::RequestPasswordReset.call(email: params[:email])
if user
token = user.generate_password_reset!
UserMailer.password_reset(user, token).deliver_now
end
redirect_to public_login_path, redirect_to public_login_path,
notice: "Se l'email è registrata, riceverai a breve un link per reimpostare la password." notice: t("flash.password_resets.email_sent")
end end
def edit def edit
@user = User.find_by_password_reset_token(params[:token]) @user = User.find_by_password_reset_token(params[:token])
if @user.nil? || @user.password_reset_expired? if @user.nil? || @user.password_reset_expired?
redirect_to new_public_password_reset_path, redirect_to public_password_forgot_path,
alert: "Link non valido o scaduto. Richiedi un nuovo reset password." alert: t("flash.password_resets.invalid_or_expired_link")
return return
end end
@token = params[:token] @token = params[:token]
@@ -27,26 +23,32 @@ module Public
def update def update
@user = User.find_by_password_reset_token(params[:token]) @user = User.find_by_password_reset_token(params[:token])
if @user.nil? || @user.password_reset_expired? if @user.nil? || @user.password_reset_expired?
redirect_to new_public_password_reset_path, redirect_to public_password_forgot_path,
alert: "Link non valido o scaduto. Richiedi un nuovo reset password." alert: t("flash.password_resets.invalid_or_expired_link")
return return
end end
if params[:password].blank? || params[:password].length < 8 if params[:password] != params[:password_confirmation]
flash.now[:alert] = "La password deve avere almeno 8 caratteri" flash.now[:alert] = t("flash.password_resets.password_mismatch")
@token = params[:token] @token = params[:token]
return render :edit, status: :unprocessable_entity return render :edit, status: :unprocessable_entity
end end
if params[:password] != params[:password_confirmation] if (code = PasswordComplexity.violation(params[:password]))
flash.now[:alert] = "Le password non coincidono" flash.now[:alert] = t("flash.password_resets.password_#{code == :blank ? :too_short : code}")
@token = params[:token]
return render :edit, status: :unprocessable_entity
end
if PasswordComplexity.same_as_current?(@user, params[:password])
flash.now[:alert] = t("flash.password_resets.password_same_as_current")
@token = params[:token] @token = params[:token]
return render :edit, status: :unprocessable_entity return render :edit, status: :unprocessable_entity
end end
@user.update!(password: params[:password]) @user.update!(password: params[:password])
@user.clear_password_reset! @user.clear_password_reset!
redirect_to public_login_path, notice: "Password aggiornata. Ora puoi accedere." redirect_to public_login_path, notice: t("flash.password_resets.password_updated")
end end
end end
end end
@@ -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
@@ -8,7 +8,7 @@ module Public
def create def create
unless params[:accept_terms] == "1" unless params[:accept_terms] == "1"
@user = User.new(user_params.merge(role: "coach")) @user = User.new(user_params.merge(role: "coach"))
flash.now[:alert] = "Devi accettare linformativa privacy e i termini di servizio per registrarti." flash.now[:alert] = t("flash.registrations.accept_terms_required")
return render :new, status: :unprocessable_entity return render :new, status: :unprocessable_entity
end end
@@ -20,10 +20,10 @@ module Public
invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(token)) invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(token))
if invitation && invitation.email.downcase == @user.email.downcase if invitation && invitation.email.downcase == @user.email.downcase
invitation.accept!(@user) invitation.accept!(@user)
return redirect_to public_team_details_path(invitation.team), notice: "Benvenuto nella squadra!" return redirect_to public_team_details_path(invitation.team), notice: t("flash.registrations.welcome_to_team")
end end
end end
redirect_to public_new_club_path, notice: "Account creato. Ora registra la tua società." redirect_to public_new_club_path, notice: t("flash.registrations.account_created")
else else
flash.now[:alert] = @user.errors.full_messages.join(", ") flash.now[:alert] = @user.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
@@ -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]
@@ -40,7 +42,7 @@ module Public
def download def download
unless download_allowed? unless download_allowed?
redirect_to public_replay_path(@session), alert: "Download non disponibile" redirect_to public_replay_path(@session), alert: t("flash.replay.download_unavailable")
return return
end end
@@ -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",
@@ -91,13 +105,13 @@ module Public
@recording = Recording.not_deleted.find_by(stream_session: @session) @recording = Recording.not_deleted.find_by(stream_session: @session)
return if @recording return if @recording
redirect_to public_live_path(@session), alert: "Replay non disponibile" redirect_to public_live_path(@session), alert: t("flash.replay.not_available")
end end
def authorize_replay_access! def authorize_replay_access!
return if Recordings::AccessPolicy.new(@recording, viewer: current_user).allowed? return if Recordings::AccessPolicy.new(@recording, viewer: current_user).allowed?
redirect_to public_live_index_path, alert: "Replay non disponibile" redirect_to public_live_index_path, alert: t("flash.replay.not_available")
end end
end end
end end
@@ -17,16 +17,16 @@ module Public
else else
public_new_club_path public_new_club_path
end end
redirect_to dest, notice: "Bentornato!" redirect_to dest, notice: t("flash.sessions.welcome_back")
else else
flash.now[:alert] = "Email o password non validi" flash.now[:alert] = t("flash.sessions.invalid_credentials")
render :new, status: :unauthorized render :new, status: :unauthorized
end end
end end
def destroy def destroy
reset_session reset_session
redirect_to public_pricing_path, notice: "Disconnesso" redirect_to public_pricing_path, notice: t("flash.sessions.logged_out")
end end
end end
end end
@@ -5,14 +5,30 @@ module Public
include ::SeoHelper include ::SeoHelper
include ::LegalHelper include ::LegalHelper
helper ApplicationHelper helper ApplicationHelper
helper_method :current_user, :logged_in? helper_method :current_user, :logged_in?, :current_locale, :language_options, :current_language_option
protect_from_forgery with: :exception
before_action :set_site_locale before_action :set_site_locale
private private
def set_site_locale def set_site_locale
I18n.locale = :it I18n.locale = LocaleResolver.resolve(
cookie_jar: request.cookie_jar,
accept_language: request.get_header("HTTP_ACCEPT_LANGUAGE")
)
end
def current_locale
I18n.locale
end
def language_options
LocaleResolver.language_options
end
def current_language_option
LocaleResolver.current_language_option
end end
def current_user def current_user
@@ -9,11 +9,21 @@ module Public
{ loc: "#{base}/pallavolo-giovanile", changefreq: "monthly", priority: "0.85" }, { loc: "#{base}/pallavolo-giovanile", changefreq: "monthly", priority: "0.85" },
{ loc: "#{base}/faq", changefreq: "monthly", priority: "0.8" }, { loc: "#{base}/faq", changefreq: "monthly", priority: "0.8" },
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" }, { loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" }, { loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
{ loc: "#{base}/support", changefreq: "yearly", priority: "0.3" },
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" }, { loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
{ loc: "#{base}/termini", changefreq: "yearly", priority: "0.3" } { loc: "#{base}/termini", changefreq: "yearly", priority: "0.3" }
] ]
Team.find_each do |team|
@entries << {
loc: "#{base}/squadre/#{team.slug}",
changefreq: "daily",
priority: "0.7"
}
end
respond_to do |format| respond_to do |format|
format.xml { render layout: false } format.xml { render layout: false }
end end
@@ -0,0 +1,64 @@
module Public
class TeamPagesController < SiteBaseController
include Public::LiveHelper
layout "marketing_live"
def index
@query = params[:q].to_s.strip
@sport = params[:sport].to_s.strip.presence
@live_filter = params[:live].present?
@replays_filter = params[:replays].present?
@entries = Teams::PublicDirectory.call(
q: @query,
sport: @sport,
live: @live_filter,
replays: @replays_filter
)
@sport_options = Sports::Catalog.as_api_list
end
def show
@team = Team.includes(:club, roster_members: []).find_by!(slug: params[:slug])
@club = @team.club
load_live_content!
end
private
def load_live_content!
@online_paths = fetch_online_paths
@live_sessions = StreamSession
.broadcasting
.publicly_listed
.where(platform: "matchlivetv")
.includes(:score_state, match: { team: :club })
.joins(:match)
.where(matches: { team_id: @team.id })
.order(Arel.sql("started_at DESC NULLS LAST"), created_at: :desc)
broadcasting_match_ids = StreamSession.broadcasting.select(:match_id)
@upcoming_matches = @team.matches
.scheduled_for_live
.where.not(id: broadcasting_match_ids)
.order(scheduled_at: :asc)
.limit(20)
@recordings = @team.recordings
.ready
.publicly_listed
.includes(stream_session: :match)
.order(recorded_at: :desc, created_at: :desc)
.limit(12)
@roster_by_category = @team.roster_public? ? @team.roster_by_category : nil
end
def fetch_online_paths
Mediamtx::Client.new.online_path_names
rescue Mediamtx::Client::Error, Errno::ECONNREFUSED, SocketError
[]
end
end
end
@@ -9,7 +9,7 @@ module Public
member = @team.roster_members.build(member_params) member = @team.roster_members.build(member_params)
attach_photo(member) attach_photo(member)
member.save! member.save!
redirect_to public_team_details_path(@team), notice: "#{member.full_name} aggiunto all'organico." redirect_to public_team_details_path(@team), notice: t("flash.team_roster_members.added", name: member.full_name)
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
redirect_to public_team_details_path(@team), alert: e.record.errors.full_messages.join(", ") redirect_to public_team_details_path(@team), alert: e.record.errors.full_messages.join(", ")
end end
@@ -22,7 +22,7 @@ module Public
@member.assign_attributes(member_params) @member.assign_attributes(member_params)
attach_photo(@member) attach_photo(@member)
@member.save! @member.save!
redirect_to public_team_details_path(@team), notice: "Scheda aggiornata." redirect_to public_team_details_path(@team), notice: t("flash.team_roster_members.updated")
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
@club = @team.club @club = @team.club
flash.now[:alert] = e.record.errors.full_messages.join(", ") flash.now[:alert] = e.record.errors.full_messages.join(", ")
@@ -32,7 +32,7 @@ module Public
def destroy def destroy
name = @member.full_name name = @member.full_name
@member.destroy! @member.destroy!
redirect_to public_team_details_path(@team), notice: "#{name} rimosso dall'organico." redirect_to public_team_details_path(@team), notice: t("flash.team_roster_members.removed", name: name)
end end
private private
@@ -19,7 +19,7 @@ module Public
require_club_owner!(@club) require_club_owner!(@club)
team = @club.teams.create!(team_params) team = @club.teams.create!(team_params)
attach_branding_logo(team) attach_branding_logo(team)
redirect_to public_club_path(@club), notice: "Squadra «#{team.name}» aggiunta." redirect_to public_club_path(@club), notice: t("flash.teams.added", name: team.name)
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
flash.now[:alert] = e.record.errors.full_messages.join(", ") flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
@@ -49,7 +49,7 @@ module Public
attach_branding_logo(@team) attach_branding_logo(@team)
attach_team_photo(@team) attach_team_photo(@team)
@team.save! @team.save!
redirect_to public_team_details_path(@team), notice: "Squadra aggiornata." redirect_to public_team_details_path(@team), notice: t("flash.teams.updated")
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
@club = @team.club @club = @team.club
flash.now[:alert] = e.record.errors.full_messages.join(", ") flash.now[:alert] = e.record.errors.full_messages.join(", ")
@@ -66,7 +66,7 @@ module Public
membership = current_user.user_teams.find_or_initialize_by(team: @team) membership = current_user.user_teams.find_or_initialize_by(team: @team)
membership.role = "member" if membership.new_record? membership.role = "member" if membership.new_record?
Teams::StaffAssignment.call(team: @team, user: current_user, membership: membership) Teams::StaffAssignment.call(team: @team, user: current_user, membership: membership)
redirect_to public_team_details_path(@team), notice: "Il tuo account è ora responsabile trasmissione." redirect_to public_team_details_path(@team), notice: t("flash.teams.now_staff")
rescue Teams::StaffAssignmentError, Teams::EntitlementError => e rescue Teams::StaffAssignmentError, Teams::EntitlementError => e
redirect_to public_team_details_path(@team), alert: e.message redirect_to public_team_details_path(@team), alert: e.message
end end
@@ -75,7 +75,7 @@ module Public
require_club_owner_for_team!(@team) require_club_owner_for_team!(@team)
membership = current_user.user_teams.find_by(team: @team) membership = current_user.user_teams.find_by(team: @team)
membership&.update!(staff_kind: nil) membership&.update!(staff_kind: nil)
redirect_to public_team_details_path(@team), notice: "Ruolo staff rimosso dal tuo account." redirect_to public_team_details_path(@team), notice: t("flash.teams.staff_role_removed")
end end
def create_invitation def create_invitation
@@ -95,7 +95,7 @@ module Public
expires_at: 7.days.from_now expires_at: 7.days.from_now
) )
@invite_url = join_public_invitation_url(token: token) @invite_url = join_public_invitation_url(token: token)
flash.now[:notice] = "Link invito generato (valido 7 giorni)" flash.now[:notice] = t("flash.teams.invite_link_generated")
render :invite render :invite
rescue Teams::EntitlementError, Teams::StaffAssignmentError => e rescue Teams::EntitlementError, Teams::StaffAssignmentError => e
redirect_to public_team_invite_path(@team), alert: e.message redirect_to public_team_invite_path(@team), alert: e.message
@@ -105,14 +105,14 @@ module Public
require_club_owner_for_team!(@team) require_club_owner_for_team!(@team)
ut = @team.user_teams.find_by!(user_id: params[:user_id], role: "member") ut = @team.user_teams.find_by!(user_id: params[:user_id], role: "member")
ut.destroy! ut.destroy!
redirect_to public_team_details_path(@team), notice: "Accesso revocato" redirect_to public_team_details_path(@team), notice: t("flash.teams.access_revoked")
end end
def destroy_invitation def destroy_invitation
require_club_owner_for_team!(@team) require_club_owner_for_team!(@team)
inv = @team.team_invitations.pending.find(params[:invitation_id]) inv = @team.team_invitations.pending.find(params[:invitation_id])
inv.destroy! inv.destroy!
redirect_to public_team_details_path(@team), notice: "Invito annullato" redirect_to public_team_details_path(@team), notice: t("flash.teams.invitation_canceled")
end end
def youtube_connect def youtube_connect
@@ -125,7 +125,7 @@ module Public
def youtube_disconnect def youtube_disconnect
require_club_owner_for_team!(@team) require_club_owner_for_team!(@team)
@team.club.youtube_credential&.destroy! @team.club.youtube_credential&.destroy!
redirect_to public_club_path(@team.club), notice: "Canale YouTube della società scollegato" redirect_to public_club_path(@team.club), notice: t("flash.teams.youtube_disconnected")
end end
private private
@@ -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, :slug, :roster_public)
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?
@@ -7,15 +7,15 @@ module Public
def require_login! def require_login!
return if logged_in? return if logged_in?
redirect_to public_login_path, alert: "Accedi per continuare" redirect_to public_login_path, alert: t("flash.web_base.login_required")
end end
def require_club_owner!(club) def require_club_owner!(club)
redirect_to public_prezzi_path, alert: "Accesso non consentito" unless club.owned_by?(current_user) redirect_to public_prezzi_path, alert: t("flash.web_base.access_denied") unless club.owned_by?(current_user)
end end
def require_club_owner_for_team!(team) def require_club_owner_for_team!(team)
redirect_to public_prezzi_path, alert: "Accesso non consentito" unless team.club&.owned_by?(current_user) redirect_to public_prezzi_path, alert: t("flash.web_base.access_denied") unless team.club&.owned_by?(current_user)
end end
end end
end end
+11
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
+94
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
+11
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
+25
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
+38
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
+18
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: I18n.t("admin.common.live_page"), 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
+49 -2
View File
@@ -9,11 +9,58 @@ module ApplicationHelper
PLAN_ICONS[plan.slug] || "fa-solid fa-circle" PLAN_ICONS[plan.slug] || "fa-solid fa-circle"
end end
# Data/ora nel fuso dell'app (Europe/Rome) e nella lingua del sito. # Data/ora nel fuso dell'app (Europe/Rome) e nella lingua corrente.
def l_local(date_or_time, format: :long) def l_local(date_or_time, format: :long)
return nil if date_or_time.blank? return nil if date_or_time.blank?
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.l(value, format: format)
end
def og_locale_tag
{
it: "it_IT",
en: "en_US",
fr: "fr_FR",
de: "de_DE",
es: "es_ES"
}.fetch(I18n.locale.to_sym, "it_IT")
end
def html_lang
I18n.locale.to_s
end
def confirm_dialog_i18n_json
{
eyebrow: t("common.confirm_eyebrow"),
title: t("common.confirm_title"),
deleteTitle: t("common.delete_title"),
confirmOk: t("common.confirm_ok"),
deleteOk: t("common.delete_ok"),
cancel: t("common.cancel"),
multiDeleteTitle: t("common.multi_delete_title"),
multiDeleteMessage: t("common.multi_delete_message"),
deleteYoutubeTitle: t("common.delete_youtube_title"),
deleteYoutubeMessage: t("common.delete_youtube_message"),
deleteYoutubeOk: t("common.delete_youtube_ok"),
deleteSiteTitle: t("common.delete_site_title"),
deleteSiteMessageWithYoutube: t("common.delete_site_message_with_youtube"),
deleteSiteMessageOnly: t("common.delete_site_message_only"),
deleteSiteOk: t("common.delete_site_ok"),
choiceSiteLabel: t("common.choice_site_label"),
choiceSiteHintWithYoutube: t("common.choice_site_hint_with_youtube"),
choiceSiteHintOnly: t("common.choice_site_hint_only"),
choiceYoutubeLabel: t("common.choice_youtube_label"),
choiceYoutubeHintWithSite: t("common.choice_youtube_hint_with_site"),
choiceYoutubeHintOnly: t("common.choice_youtube_hint_only"),
choiceBothLabel: t("common.choice_both_label"),
choiceBothHint: t("common.choice_both_hint")
}.to_json
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 end
+11 -7
View File
@@ -2,25 +2,25 @@ module Public
module BillingHelper module BillingHelper
def plan_billing_action(current_slug:, target_plan:, stripe_subscription_active:, current_interval: nil, subscription: nil) def plan_billing_action(current_slug:, target_plan:, stripe_subscription_active:, current_interval: nil, subscription: nil)
if target_plan.slug == "free" if target_plan.slug == "free"
return { kind: :current, label: "Piano attuale" } if current_slug == "free" return { kind: :current, label: I18n.t("billing.actions.current_plan") } if current_slug == "free"
return { kind: :none } if stripe_subscription_active return { kind: :none } if stripe_subscription_active
return { kind: :contact, label: "Per passare al piano Free, contatta il supporto." } return { kind: :contact, label: I18n.t("billing.actions.contact_for_free") }
end end
unless MatchLiveTv.stripe_enabled? unless MatchLiveTv.stripe_enabled?
return { kind: :disabled, label: "Stripe non configurato" } return { kind: :disabled, label: I18n.t("billing.actions.stripe_not_configured") }
end end
intervals = Billing::Stripe::PriceCatalog.available_intervals(plan_slug: target_plan.slug) intervals = Billing::Stripe::PriceCatalog.available_intervals(plan_slug: target_plan.slug)
return { kind: :disabled, label: "Prezzi Stripe non configurati" } if intervals.empty? return { kind: :disabled, label: I18n.t("billing.actions.stripe_prices_not_configured") } if intervals.empty?
active_interval = current_interval.presence || Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL active_interval = current_interval.presence || Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL
if current_slug == target_plan.slug && stripe_subscription_active && !subscription&.plan_change_pending? if current_slug == target_plan.slug && stripe_subscription_active && !subscription&.plan_change_pending?
other_intervals = intervals - [active_interval] other_intervals = intervals - [active_interval]
if other_intervals.empty? if other_intervals.empty?
label = "Piano attuale#{Billing::Stripe::PriceCatalog.label(plan_slug: target_plan.slug, interval: active_interval)}" label = "#{I18n.t('billing.actions.current_plan')}#{Billing::Stripe::PriceCatalog.label(plan_slug: target_plan.slug, interval: active_interval)}"
return { kind: :current, label: label } return { kind: :current, label: label }
end end
@@ -57,6 +57,10 @@ module Public
Billing::Stripe::PlanChangeMessages.button_label(plan: plan, interval: interval, kind: btn_kind) Billing::Stripe::PlanChangeMessages.button_label(plan: plan, interval: interval, kind: btn_kind)
end end
def plan_cta_name(plan)
I18n.t("pages.plans.cta_name.#{plan.slug}", default: plan.name)
end
def billing_plan_change_info_lines(subscription: nil) def billing_plan_change_info_lines(subscription: nil)
Billing::Stripe::PlanChangeMessages.billing_info_lines(subscription: subscription) Billing::Stripe::PlanChangeMessages.billing_info_lines(subscription: subscription)
end end
@@ -87,9 +91,9 @@ module Public
def billing_profile_incomplete_message(club) def billing_profile_incomplete_message(club)
missing = club.billing_profile_errors missing = club.billing_profile_errors
return "Completa i dati di fatturazione prima di attivare un piano premium." if missing.empty? return I18n.t("billing.actions.profile_incomplete_default") if missing.empty?
"Mancano: #{missing.join(", ")}." I18n.t("billing.actions.profile_missing", fields: missing.join(", "))
end end
end end
+47 -30
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"
t("score.timer_label")
when "generic"
t("score.generic_label")
else
t("score.sets_label", set: score_state.current_set, home: score_state.home_sets, away: score_state.away_sets)
end
end
def live_score_period_label(match, score_state)
return t("score.fallback") 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?
@@ -18,35 +35,41 @@ module Public
def live_scheduled_label(datetime) def live_scheduled_label(datetime)
return nil unless datetime return nil unless datetime
datetime.in_time_zone.strftime("%d/%m/%Y alle %H:%M") datetime.in_time_zone.strftime(t("score.scheduled_date_format"))
end end
def live_scheduled_relative(datetime) def live_scheduled_relative(datetime)
return nil unless datetime return nil unless datetime
if datetime.to_date == Time.zone.today if datetime.to_date == Time.zone.today
"Oggi alle #{datetime.strftime('%H:%M')}" t("score.scheduled_today", time: datetime.strftime("%H:%M"))
elsif datetime.to_date == Time.zone.tomorrow elsif datetime.to_date == Time.zone.tomorrow
"Domani alle #{datetime.strftime('%H:%M')}" t("score.scheduled_tomorrow", time: datetime.strftime("%H:%M"))
else else
live_scheduled_label(datetime) live_scheduled_label(datetime)
end end
end end
def live_match_card_heading(match) def live_match_card_heading(match, link_team: true)
team = match.team team = match.team
club_name = team.club&.name.presence || "Società" club_name = team.club&.name.presence || t("score.default_club_name")
team_slug = team.respond_to?(:slug) ? team.slug : nil
team_label = if link_team && team_slug.present?
link_to(team.name, public_team_page_path(team_slug), class: "live-card__team-link")
else
team.name
end
content_tag(:h3, class: "live-card__title") do content_tag(:h3, class: "live-card__title") do
safe_join([ safe_join([
content_tag(:span, club_name, class: "live-card__club"), content_tag(:span, club_name, class: "live-card__club"),
content_tag(:span, "#{team.name} vs #{match.opponent_name}", class: "live-card__matchup") content_tag(:span, safe_join([team_label, " vs ", match.opponent_name]), class: "live-card__matchup")
]) ])
end end
end end
def live_match_page_heading(match) def live_match_page_heading(match)
team = match.team team = match.team
club_name = team.club&.name.presence || "Società" club_name = team.club&.name.presence || t("score.default_club_name")
content_tag(:div, class: "live-page-heading") do content_tag(:div, class: "live-page-heading") do
safe_join([ safe_join([
content_tag(:p, club_name, class: "live-page-heading__club"), content_tag(:p, club_name, class: "live-page-heading__club"),
@@ -56,39 +79,33 @@ module Public
end end
def live_score_points_label(match, score_state) def live_score_points_label(match, score_state)
return "" unless score_state return t("score.fallback") unless score_state
"#{match.team.name} #{score_state.home_points} - #{score_state.away_points} #{match.opponent_name}" board = match.effective_board_type
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 end
# Colore ospite sul tabellone: secondario società se leggibile su bianco, altrimenti blu visitatore. t("score.points_label", team: match.team.name, home: home, away: away, opponent: 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"]
home = data[:home] || data["home"] home = data[:home] || data["home"]
away = data[:away] || data["away"] away = data[:away] || data["away"]
label = set_no.present? ? "Set #{set_no}" : "Set" if set_no.present?
"#{label} #{home}-#{away}" t("score.partial_entry", set: set_no, home: home, away: away)
else
t("score.partial_entry_no_number", home: home, away: away)
end
end end
end end
end end
@@ -0,0 +1,78 @@
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"
t("regia.board.stopwatch")
when "generic"
t("regia.board.generic_score")
else
t("regia.board.set_label", number: score.current_set)
end
end
def regia_js_i18n_json
{
periodUpdated: t("regia.js.period_updated"),
nowInPeriodTemplate: t("regia.js.now_in_period", period: "%{period}"),
setWonTitle: t("regia.modal.set_won_title"),
setWonBodyTemplate: t("regia.js.set_won_body", winner: "%{winner}"),
matchWonTitle: t("regia.js.match_won_title"),
matchWonBodyTemplate: t("regia.js.match_won_body", winner: "%{winner}"),
closeSetTitle: t("regia.board.close_set"),
closeSetConfirm: t("regia.js.close_set_confirm"),
scoreUpdateError: t("regia.js.score_update_error"),
genericError: t("regia.js.generic_error"),
setsLineTemplate: t("regia.js.sets_line", current: "%{current}", home: "%{home}", away: "%{away}"),
setPartialLabelTemplate: t("regia.board.set_label", number: "%{number}"),
freeScore: t("regia.board.free_score"),
stopwatch: t("regia.board.stopwatch"),
quarterLabelTemplate: t("regia.js.quarter_label", n: "%{n}"),
halfLabelTemplate: t("regia.js.half_label", n: "%{n}"),
overtimeBasketTemplate: t("regia.js.overtime_basket", n: "%{n}"),
overtimeOther: t("regia.js.overtime_other"),
unknownPeriod: t("regia.js.unknown_period"),
partialsPrefix: t("regia.board.partials_prefix"),
resumed: t("regia.js.resumed"),
pausedCover: t("regia.js.paused_cover"),
closed: t("regia.js.closed"),
resumeError: t("regia.js.resume_error"),
pauseError: t("regia.js.pause_error"),
closeError: t("regia.js.close_error"),
closeConfirm: t("regia.js.close_confirm"),
linkUnavailable: t("regia.js.link_unavailable"),
linkCopied: t("regia.js.link_copied"),
copyPrompt: t("regia.js.copy_prompt"),
shareRegiaText: t("regia.js.share_regia_text"),
shareLiveText: t("regia.js.share_live_text"),
previewWaiting: t("regia.preview_waiting"),
streamEnded: t("regia.preview_ended"),
resumeLabel: t("regia.resume"),
pauseLabel: t("regia.pause"),
endedBadge: t("regia.status.ended"),
pausedBadge: t("regia.status.paused"),
liveBadge: t("regia.status.live"),
waitingBadge: t("regia.status.waiting"),
subtitlePrefix: t("regia.subtitle_prefix")
}.to_json
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
@@ -0,0 +1,25 @@
module Public
module TeamPagesHelper
def team_page_meta_description(team, club)
t("team_pages.show.meta_description", team: team.name, club: club.name)
end
def filter_params_for_canonical
params.permit(:q, :sport, :live, :replays).to_h.compact_blank
end
def team_page_structured_data(team, club)
{
"@context" => "https://schema.org",
"@type" => "SportsTeam",
"name" => team.name,
"sport" => team.sport_label,
"url" => seo_absolute_url(public_team_page_path(team.slug)),
"memberOf" => {
"@type" => "SportsOrganization",
"name" => club.name
}
}
end
end
end
@@ -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
+4 -3
View File
@@ -19,13 +19,14 @@ module RosterHelper
def roster_category_options def roster_category_options
TeamRosterMember::DISPLAY_ORDER.map do |cat| TeamRosterMember::DISPLAY_ORDER.map do |cat|
[TeamRosterMember::CATEGORY_LABELS[cat], cat] [TeamRosterMember.category_label(cat), cat]
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] }, [[t("roster.role_select_blank"), ""]] + roles.map { |r| [r, r] },
selected selected
) )
end end
@@ -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
@@ -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
@@ -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
@@ -4,15 +4,18 @@ module Billing
@invoice = params[:invoice] @invoice = params[:invoice]
@club = @invoice.club @club = @invoice.club
attachments["fattura-#{@invoice.number.parameterize}.pdf"] = { I18n.with_locale(I18n.locale) do
prefix = t("mailers.invoice.attachment_prefix")
attachments["#{prefix}-#{@invoice.number.parameterize}.pdf"] = {
mime_type: "application/pdf", mime_type: "application/pdf",
content: @invoice.pdf.download content: @invoice.pdf.download
} }
mail( mail(
to: @club.billing_email, to: @club.billing_email,
subject: "Fattura #{@invoice.display_number} — Match Live TV" subject: t("mailers.invoice.subject", number: @invoice.display_number)
) )
end end
end end
end
end end
+11
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
@@ -9,12 +9,15 @@ module Recordings
@recipient = recipient @recipient = recipient
@replay_url = recording.replay_url @replay_url = recording.replay_url
@expires_at = recording.expires_at @expires_at = recording.expires_at
@archive_url = "#{MatchLiveTv.app_public_url.chomp('/')}/clubs/#{@club.id}/replays"
I18n.with_locale(I18n.locale) do
mail( mail(
to: recipient.email, to: recipient.email,
subject: "Replay pronto — #{recording.title_or_default}" subject: t("mailers.replay_ready.subject", title: recording.title_or_default)
) )
end end
end
def replay_expiring_soon(recording:, recipient:) def replay_expiring_soon(recording:, recipient:)
@recording = recording @recording = recording
@@ -24,11 +27,14 @@ module Recordings
@replay_url = recording.replay_url @replay_url = recording.replay_url
@expires_at = recording.expires_at @expires_at = recording.expires_at
@days_left = ((recording.expires_at - Time.current) / 1.day).ceil @days_left = ((recording.expires_at - Time.current) / 1.day).ceil
@archive_url = "#{MatchLiveTv.app_public_url.chomp('/')}/clubs/#{@club.id}/replays"
I18n.with_locale(I18n.locale) do
mail( mail(
to: recipient.email, to: recipient.email,
subject: "Replay in scadenza tra #{@days_left} giorni — #{recording.title_or_default}" subject: t("mailers.replay_expiring.subject", days: @days_left, title: recording.title_or_default)
) )
end end
end end
end
end end
+3 -1
View File
@@ -6,6 +6,8 @@ class UserMailer < ApplicationMailer
@reset_url = public_password_reset_url(token: token) @reset_url = public_password_reset_url(token: token)
@expiry_hours = MatchLiveTv.password_reset_expiry_hours @expiry_hours = MatchLiveTv.password_reset_expiry_hours
mail to: user.email, subject: "Reimposta la password — Match Live TV" I18n.with_locale(I18n.locale) do
mail to: user.email, subject: t("mailers.password_reset.subject")
end
end end
end end
+2
View File
@@ -1,4 +1,6 @@
class AdminAccount < ApplicationRecord class AdminAccount < ApplicationRecord
include PasswordComplexity
has_secure_password has_secure_password
validates :username, presence: true, uniqueness: true validates :username, presence: true, uniqueness: true
@@ -1,14 +1,16 @@
module ClubBillingProfile module ClubBillingProfile
extend ActiveSupport::Concern extend ActiveSupport::Concern
BILLING_ENTITY_TYPES = { BILLING_ENTITY_TYPE_KEYS = %w[company individual nonprofit].freeze
"company" => "Società / ASD con P.IVA",
"individual" => "Persona fisica", class_methods do
"nonprofit" => "Associazione senza scopo di lucro" def billing_entity_types
}.freeze ClubBillingProfile::BILLING_ENTITY_TYPE_KEYS.index_with { |key| I18n.t("billing.entity_types.#{key}") }
end
end
included do included do
validates :billing_entity_type, inclusion: { in: BILLING_ENTITY_TYPES.keys }, allow_nil: true validates :billing_entity_type, inclusion: { in: BILLING_ENTITY_TYPE_KEYS }, allow_nil: true
validates :billing_email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true validates :billing_email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
validates :billing_recipient_code, length: { is: 7 }, allow_blank: true validates :billing_recipient_code, length: { is: 7 }, allow_blank: true
validates :billing_province, length: { is: 2 }, allow_blank: true validates :billing_province, length: { is: 2 }, allow_blank: true
@@ -23,28 +25,28 @@ module ClubBillingProfile
# Campi minimi per intestazione fattura e invio (email PDF + SDI/PEC). # Campi minimi per intestazione fattura e invio (email PDF + SDI/PEC).
def billing_profile_errors def billing_profile_errors
errors = [] errors = []
errors << "Tipo intestatario" if billing_entity_type.blank? errors << I18n.t("billing.profile_errors.entity_type") if billing_entity_type.blank?
errors << "Ragione sociale o nome intestatario" if billing_legal_name.blank? errors << I18n.t("billing.profile_errors.legal_name") if billing_legal_name.blank?
errors << "Email di fatturazione" if billing_email.blank? errors << I18n.t("billing.profile_errors.email") if billing_email.blank?
errors << "Indirizzo" if billing_address_line.blank? errors << I18n.t("billing.profile_errors.address") if billing_address_line.blank?
errors << "Città" if billing_city.blank? errors << I18n.t("billing.profile_errors.city") if billing_city.blank?
errors << "Provincia (sigla 2 lettere)" if billing_province.blank? || billing_province.to_s.length != 2 errors << I18n.t("billing.profile_errors.province") if billing_province.blank? || billing_province.to_s.length != 2
errors << "CAP" if billing_postal_code.blank? errors << I18n.t("billing.profile_errors.postal_code") if billing_postal_code.blank?
errors << "Paese (ISO)" if billing_country.blank? || billing_country.to_s.length != 2 errors << I18n.t("billing.profile_errors.country") if billing_country.blank? || billing_country.to_s.length != 2
errors.concat(billing_tax_id_errors) errors.concat(billing_tax_id_errors)
errors << "Codice destinatario SDI (7 caratteri) o PEC" if billing_recipient_code.blank? && billing_pec.blank? errors << I18n.t("billing.profile_errors.sdi_or_pec") if billing_recipient_code.blank? && billing_pec.blank?
errors << "Codice destinatario SDI (7 caratteri)" if billing_recipient_code.present? && billing_recipient_code.length != 7 errors << I18n.t("billing.profile_errors.sdi_invalid") if billing_recipient_code.present? && billing_recipient_code.length != 7
errors errors
end end
def billing_tax_id_errors def billing_tax_id_errors
case billing_entity_type case billing_entity_type
when "company" when "company"
billing_vat_number.blank? ? ["Partita IVA"] : [] billing_vat_number.blank? ? [I18n.t("billing.profile_errors.vat_number")] : []
when "individual" when "individual"
billing_fiscal_code.blank? ? ["Codice Fiscale"] : [] billing_fiscal_code.blank? ? [I18n.t("billing.profile_errors.fiscal_code")] : []
else else
billing_vat_number.blank? && billing_fiscal_code.blank? ? ["P.IVA o Codice Fiscale"] : [] billing_vat_number.blank? && billing_fiscal_code.blank? ? [I18n.t("billing.profile_errors.vat_or_fiscal")] : []
end end
end end
@@ -56,7 +58,7 @@ module ClubBillingProfile
# Righe per intestazione fattura in admin (etichetta, valore). # Righe per intestazione fattura in admin (etichetta, valore).
def billing_profile_invoice_lines def billing_profile_invoice_lines
lines = [] lines = []
lines << ["Tipo", BILLING_ENTITY_TYPES[billing_entity_type]] if billing_entity_type.present? lines << ["Tipo", self.class.billing_entity_types[billing_entity_type]] if billing_entity_type.present?
lines << ["Intestatario", billing_legal_name] lines << ["Intestatario", billing_legal_name]
lines << ["P.IVA", billing_vat_number] if billing_vat_number.present? lines << ["P.IVA", billing_vat_number] if billing_vat_number.present?
lines << ["Codice fiscale", billing_fiscal_code] if billing_fiscal_code.present? lines << ["Codice fiscale", billing_fiscal_code] if billing_fiscal_code.present?
@@ -0,0 +1,62 @@
# Criteri "di mercato" (stile Cognito/Auth0 bilanciato):
# - minimo 8 caratteri (max 72 per bcrypt)
# - almeno 3 classi su 4: minuscole, maiuscole, numeri, simboli
module PasswordComplexity
extend ActiveSupport::Concern
MIN_LENGTH = 8
MAX_LENGTH = 72
REQUIRED_CLASSES = 3
CLASS_CHECKS = {
lowercase: /[a-z]/,
uppercase: /[A-Z]/,
digit: /\d/,
symbol: /[^A-Za-z0-9]/
}.freeze
class << self
def violation(password)
value = password.to_s
return :blank if value.blank?
return :too_short if value.length < MIN_LENGTH
return :too_long if value.bytesize > MAX_LENGTH
return :too_weak unless strong_enough?(value)
nil
end
def strong_enough?(password)
matched = CLASS_CHECKS.count { |_, pattern| password.match?(pattern) }
matched >= REQUIRED_CLASSES
end
# Confronta con il digest già salvato (prima di assegnare la nuova password).
def same_as_current?(record, password)
return false if password.blank? || !record.respond_to?(:authenticate)
record.authenticate(password).present?
end
def requirement_summary
I18n.t("password_policy.hint")
end
end
included do
validate :password_meets_complexity_policy, if: -> { password.present? }
end
private
def password_meets_complexity_policy
case PasswordComplexity.violation(password)
when :too_short
errors.add(:password, :too_short, count: MIN_LENGTH)
when :too_long
errors.add(:password, :too_long, count: MAX_LENGTH)
when :too_weak
errors.add(:password, :complexity)
end
end
end
+126 -7
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?
@@ -34,11 +47,11 @@ class Match < ApplicationRecord
def public_status_label def public_status_label
active = active_stream_session active = active_stream_session
return "In corso / da riprendere" if active return I18n.t("matches.status.active") if active
return "Programmata" if scheduled_upcoming? return I18n.t("matches.status.scheduled") if scheduled_upcoming?
return "Pronta" if scheduled_at.present? return I18n.t("matches.status.ready") if scheduled_at.present?
"Senza orario" I18n.t("matches.status.no_time")
end end
scope :search_teams_or_opponents, lambda { |query| scope :search_teams_or_opponents, lambda { |query|
@@ -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
+43
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
+29 -7
View File
@@ -141,14 +141,36 @@ class Recording < ApplicationRecord
count == 1 ? "1 visualizzazione" : "#{count} visualizzazioni" count == 1 ? "1 visualizzazione" : "#{count} visualizzazioni"
end end
def status_label def source_platform
return "Eliminato" if deleted? metadata.is_a?(Hash) ? metadata["source_platform"].presence : nil
return "Scaduto" if status == "expired" end
return "Errore" if status == "failed"
return "In elaborazione" if status == "processing"
return "Scade presto" if expires_at.present? && expires_at <= 7.days.from_now
"Disponibile" 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
return I18n.t("recordings.status.deleted") if deleted?
return I18n.t("recordings.status.expired") if status == "expired"
return I18n.t("recordings.status.failed") if status == "failed"
return I18n.t("recordings.status.processing") if status == "processing"
return I18n.t("recordings.status.expiring_soon") if expires_at.present? && expires_at <= 7.days.from_now
I18n.t("recordings.status.available")
end end
private private
+72 -2
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
+6
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
+52
View File
@@ -12,8 +12,17 @@ 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
validates :slug, presence: true, uniqueness: true,
format: { with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/, message: "solo lettere minuscole, numeri e trattini" }
validate :sport_key_known
validate :photo_file_type, if: -> { photo_file.attached? } validate :photo_file_type, if: -> { photo_file.attached? }
before_validation :assign_slug, on: :create
before_validation :normalize_slug, if: -> { slug_changed? && slug.present? }
before_validation :normalize_sport_key
def subscription def subscription
club.subscription club.subscription
end end
@@ -43,8 +52,43 @@ class Team < ApplicationRecord
end end
end end
def roster_public?
roster_public
end
def public_page_path
Rails.application.routes.url_helpers.public_team_page_path(slug)
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])
@@ -55,6 +99,14 @@ class Team < ApplicationRecord
club club
end end
def assign_slug
self.slug = Teams::GenerateSlug.call(self) if slug.blank?
end
def normalize_slug
self.slug = slug.to_s.parameterize
end
def default_primary_color def default_primary_color
club&.primary_color.presence || super club&.primary_color.presence || super
end end
+15 -22
View File
@@ -2,21 +2,12 @@ class TeamRosterMember < ApplicationRecord
CATEGORIES = %w[staff coach manager player].freeze CATEGORIES = %w[staff coach manager player].freeze
# Ordine di visualizzazione in pagina organico # Ordine di visualizzazione in pagina organico
DISPLAY_ORDER = %w[coach manager player staff].freeze DISPLAY_ORDER = %w[coach manager player staff].freeze
CATEGORY_LABELS = {
"staff" => "Staff",
"coach" => "Allenatori",
"manager" => "Dirigenti",
"player" => "Giocatori"
}.freeze
VOLLEYBALL_PLAYER_ROLES = %w[ def self.category_label(category)
Schiacciatore I18n.t("roster.category.#{category}", default: category.to_s.capitalize)
Opposto end
Centrale
Palleggiatore VOLLEYBALL_PLAYER_ROLES = Sports::PlayerRoles::BY_BOARD["volley"].freeze
Libero
Universale
].freeze
belongs_to :team belongs_to :team
@@ -34,7 +25,7 @@ class TeamRosterMember < ApplicationRecord
scope :by_category, ->(cat) { where(category: cat).ordered } scope :by_category, ->(cat) { where(category: cat).ordered }
def category_label def category_label
CATEGORY_LABELS[category] || category self.class.category_label(category)
end end
def initials def initials
@@ -65,23 +56,25 @@ class TeamRosterMember < ApplicationRecord
def default_role_label def default_role_label
case category case category
when "coach" then "Allenatore" when "coach" then I18n.t("roster.role.coach")
when "manager" then "Dirigente" when "manager" then I18n.t("roster.role.manager")
when "staff" then "Staff" when "staff" then I18n.t("roster.role.staff")
when "player" then jersey_number.present? ? "Giocatore ##{jersey_number}" : "Giocatore" when "player"
jersey_number.present? ? I18n.t("roster.role.player_numbered", number: jersey_number) : I18n.t("roster.role.player")
else category else category
end end
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, I18n.t("roster.errors.role_invalid"))
end 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])
errors.add(:photo_file, "deve essere PNG, JPEG o WebP") errors.add(:photo_file, I18n.t("roster.errors.photo_invalid_type"))
end end
end end
+2
View File
@@ -1,4 +1,6 @@
class User < ApplicationRecord class User < ApplicationRecord
include PasswordComplexity
ROLES = %w[admin coach parent volunteer].freeze ROLES = %w[admin coach parent volunteer].freeze
has_secure_password has_secure_password
+40 -5
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
@@ -5,9 +5,13 @@ module Billing
AMOUNT_HINTS = { AMOUNT_HINTS = {
500 => %w[premium_light monthly], 500 => %w[premium_light monthly],
790 => %w[premium_light monthly],
4000 => %w[premium_light yearly], 4000 => %w[premium_light yearly],
5900 => %w[premium_light yearly],
2000 => %w[premium_full monthly], 2000 => %w[premium_full monthly],
20_000 => %w[premium_full yearly] 2490 => %w[premium_full monthly],
20_000 => %w[premium_full yearly],
19_900 => %w[premium_full yearly]
}.freeze }.freeze
class << self class << self
@@ -10,38 +10,38 @@ module Billing
if result.upgrade? if result.upgrade?
amount = format_amount(result.amount_cents, result.currency) amount = format_amount(result.amount_cents, result.currency)
if amount.present? if amount.present?
"Upgrade a #{plan.name} (#{interval_label}) completato. Addebito immediato: #{amount}." I18n.t("billing.messages.upgrade_completed_with_amount", plan: plan.name, interval: interval_label, amount: amount)
else else
"Upgrade a #{plan.name} (#{interval_label}) completato. L'addebito della differenza è in elaborazione sulla carta salvata." I18n.t("billing.messages.upgrade_completed_pending_charge", plan: plan.name, interval: interval_label)
end end
else else
when_label = result.effective_at.present? ? I18n.l(result.effective_at, format: :long) : "il prossimo rinnovo" when_label = result.effective_at.present? ? I18n.l(result.effective_at, format: :long) : I18n.t("billing.messages.next_renewal")
current_name = current_plan&.name || "il piano attuale" current_name = current_plan&.name || I18n.t("billing.messages.current_plan_generic")
"Passaggio a #{plan.name} (#{interval_label}) programmato: resti su #{current_name} fino al #{when_label}. " \ I18n.t("billing.messages.downgrade_scheduled", plan: plan.name, interval: interval_label, current_name: current_name, when: when_label)
"Nessun addebito aggiuntivo ora; dal prossimo ciclo pagherai il nuovo prezzo."
end end
end end
def button_label(plan:, interval:, kind:) def button_label(plan:, interval:, kind:)
price = PriceCatalog.label(plan_slug: plan.slug, interval: interval) price = PriceCatalog.label(plan_slug: plan.slug, interval: interval)
name = I18n.t("pages.plans.cta_name.#{plan.slug}", default: plan.name)
if kind == :checkout if kind == :checkout
"Attiva #{plan.name}#{price}" I18n.t("billing.messages.activate_button", plan: name, price: price)
else else
"Passa a #{plan.name}#{price}" I18n.t("billing.messages.switch_button", plan: name, price: price)
end end
end end
def billing_info_lines(subscription: nil) def billing_info_lines(subscription: nil)
lines = [ lines = [
"Upgrade (piano superiore o passaggio alla fatturazione annuale): la differenza viene addebitata subito sulla carta salvata in Stripe.", I18n.t("billing.messages.upgrade_info_line"),
"Downgrade tra piani a pagamento (es. Full → Light): il nuovo piano è attivo dal prossimo rinnovo; fino ad allora restano le funzioni del piano attuale, senza addebito aggiuntivo ora." I18n.t("billing.messages.downgrade_info_line")
] ]
if subscription&.plan_change_pending? && subscription.pending_plan.present? if subscription&.plan_change_pending? && subscription.pending_plan.present?
when_at = subscription.current_period_end when_at = subscription.current_period_end
when_label = when_at.present? ? I18n.l(when_at, format: :long) : "il prossimo rinnovo" when_label = when_at.present? ? I18n.l(when_at, format: :long) : I18n.t("billing.messages.next_renewal")
lines.unshift( lines.unshift(
"Hai già richiesto #{subscription.pending_plan.name} dal #{when_label}: fino ad allora resta attivo #{subscription.plan.name}." I18n.t("billing.messages.already_requested_line", plan: subscription.pending_plan.name, when: when_label, current_plan: subscription.plan.name)
) )
end end
@@ -4,9 +4,16 @@ module Billing
INTERVALS = %w[monthly yearly].freeze INTERVALS = %w[monthly yearly].freeze
DEFAULT_INTERVAL = "yearly" DEFAULT_INTERVAL = "yearly"
PRICING = { # Importi in centesimi (EUR). Il yearly è il prezzo lancio addebitato.
"premium_light" => { "yearly" => "€40/anno", "monthly" => "€5/mese" }, AMOUNTS = {
"premium_full" => { "yearly" => "€200/anno", "monthly" => "€20/mese" } "premium_light" => {
"yearly" => { charge: 5900, list: 7900, equivalent_monthly: 492 },
"monthly" => { charge: 790 }
},
"premium_full" => {
"yearly" => { charge: 19_900, list: 24_900, equivalent_monthly: 1658 },
"monthly" => { charge: 2490 }
}
}.freeze }.freeze
class << self class << self
@@ -30,7 +37,23 @@ module Billing
end end
def label(plan_slug:, interval:) def label(plan_slug:, interval:)
PRICING.dig(plan_slug.to_s, normalize_interval(interval)) || normalize_interval(interval) interval = normalize_interval(interval)
format_interval_price(charge_cents(plan_slug, interval), interval)
end
def list_label(plan_slug:, interval: "yearly")
interval = normalize_interval(interval)
cents = list_cents(plan_slug, interval)
return nil if cents.blank?
format_interval_price(cents, interval)
end
def equivalent_monthly_label(plan_slug:)
cents = AMOUNTS.dig(plan_slug.to_s, "yearly", :equivalent_monthly)
return nil if cents.blank?
format_interval_price(cents, "monthly")
end end
def available_intervals(plan_slug:) def available_intervals(plan_slug:)
@@ -52,8 +75,33 @@ module Billing
nil nil
end end
def format_eur(cents)
cents = cents.to_i
whole, frac = cents.divmod(100)
if frac.zero?
"#{whole}"
else
sep = I18n.locale.to_s == "en" ? "." : ","
"#{whole}#{sep}#{frac.to_s.rjust(2, '0')}"
end
end
private private
def charge_cents(plan_slug, interval)
AMOUNTS.dig(plan_slug.to_s, interval, :charge)
end
def list_cents(plan_slug, interval)
AMOUNTS.dig(plan_slug.to_s, interval, :list)
end
def format_interval_price(cents, interval)
return nil if cents.blank?
I18n.t("billing.prices.#{interval}", amount: format_eur(cents))
end
def price_id_for(plan_slug, interval) def price_id_for(plan_slug, interval)
case [plan_slug, interval] case [plan_slug, interval]
when %w[premium_light monthly] when %w[premium_light monthly]
+73
View File
@@ -0,0 +1,73 @@
# frozen_string_literal: true
module LocaleResolver
COOKIE_NAME = "mltv_locale"
COOKIE_MAX_AGE = 2.years.to_i
module_function
def available
I18n.available_locales.map(&:to_sym)
end
def normalize(value)
code = value.to_s.strip.downcase.split(/[-_]/).first
return nil if code.blank?
sym = code.to_sym
available.include?(sym) ? sym : nil
end
def from_cookie(cookies)
return nil if cookies.nil?
value = if cookies.respond_to?(:[])
cookies[COOKIE_NAME]
elsif cookies.respond_to?(:fetch)
cookies.fetch(COOKIE_NAME, nil)
end
normalize(value)
end
def from_accept_language(header)
return nil if header.blank?
header.to_s.split(",").each do |part|
tag = part.split(";").first.to_s.strip
locale = normalize(tag)
return locale if locale
end
nil
end
def resolve(cookie_jar: nil, cookies: nil, accept_language: nil)
jar = cookie_jar || cookies
from_cookie(jar) || from_accept_language(accept_language) || I18n.default_locale
end
def persist!(cookie_jar, locale)
normalized = normalize(locale) || I18n.default_locale
cookie_jar[COOKIE_NAME] = {
value: normalized.to_s,
expires: COOKIE_MAX_AGE.seconds.from_now,
path: "/",
same_site: :lax,
httponly: false
}
normalized
end
def language_options
[
{ code: :it, label: "Italiano", native: "Italiano", flag: "🇮🇹" },
{ code: :en, label: "English", native: "English", flag: "🇬🇧" },
{ code: :fr, label: "Français", native: "Français", flag: "🇫🇷" },
{ code: :de, label: "Deutsch", native: "Deutsch", flag: "🇩🇪" },
{ code: :es, label: "Español", native: "Español", flag: "🇪🇸" }
].select { |opt| available.include?(opt[:code]) }
end
def current_language_option
language_options.find { |opt| opt[:code] == I18n.locale.to_sym } || language_options.first
end
end
@@ -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
+10 -1
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:)
@@ -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
+326
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:head", "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
@@ -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
@@ -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
+26
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
+65
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
@@ -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
@@ -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
@@ -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
+53 -3
View File
@@ -1,11 +1,48 @@
module Recordings module Recordings
class Delete class Delete
def initialize(recording, reason: :manual) SCOPES = %w[both site youtube].freeze
def initialize(recording, reason: :manual, scope: :both)
@recording = recording @recording = recording
@reason = reason @reason = reason
@scope = normalize_scope(scope)
end end
def call def call
case @scope
when "youtube"
delete_youtube_only!
when "site"
delete_site_only!
else
delete_both!
end
@recording
end
private
def normalize_scope(scope)
value = scope.to_s.presence || "both"
SCOPES.include?(value) ? value : "both"
end
def delete_both!
delete_youtube_video
delete_storage_object if @recording.storage_key.present?
cleanup_local_artifacts
@recording.update!(
status: "expired",
deleted_at: Time.current,
storage_key: nil,
thumbnail_storage_key: nil,
youtube_video_id: nil,
youtube_published_at: nil
)
end
def delete_site_only!
delete_storage_object if @recording.storage_key.present? delete_storage_object if @recording.storage_key.present?
cleanup_local_artifacts cleanup_local_artifacts
@@ -15,10 +52,23 @@ module Recordings
storage_key: nil, storage_key: nil,
thumbnail_storage_key: nil thumbnail_storage_key: nil
) )
@recording
end end
private def delete_youtube_only!
delete_youtube_video
@recording.update!(
youtube_video_id: nil,
youtube_published_at: nil
)
end
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
@@ -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
@@ -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
@@ -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
@@ -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

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