Compare commits

...
Author SHA1 Message Date
eminuxandCursor 05ef56c56d Aggiunge replay YouTube temporaneo, pull registrazioni dai CPX e snapshot ingest in admin.
Così overflow Hetzner e VOD YouTube restano in archivio dopo lo spegnimento del nodo, e la colonna ingest non si svuota.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 13:01:41 +02:00
eminuxandCursor 35dfa923e3 Usa packageName dinamico negli E2E Android per la variante collaudo.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 10:22:20 +02:00
eminuxandCursor 9d8b35c06c Salva telemetria client (OS, app, device, operatore) sulle sessioni per il debug admin.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 09:43:43 +02:00
eminuxandCursor 873e0ea55c Fix race analytics: retry su unique violation in aggregazione heatmap.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 16:23:02 +02:00
eminuxandCursor 4573edfedc Aggiunge i link ai profili social ufficiali nel footer e in Contatti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 16:10:18 +02:00
eminuxandCursor b301868774 Aggiunge regola Cursor per scalare i soft limit ingest col crescere dei clienti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 15:11:25 +02:00
eminuxandCursor b87a0f7bc0 Fix race create sessione su CPX: ready solo dopo MediaMTX.
I nodi cloud restano in provisioning finché :9997 risponde; retry su create_path e 503 retryable se l’ingest è ancora irraggiungibile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 14:06:47 +02:00
eminuxandCursor 70fccc493e Usa screenshot di pagina come sfondo heatmap al posto dell’iframe.
Cattura periodica client-side (con consenso), storage ActiveStorage e overlay allineato all’immagine.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 23:14:47 +02:00
eminuxandCursor 406a90ae5b Bumpe site-analytics.js a v=3 per bypassare la cache NPM su collaudo.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 23:08:10 +02:00
eminuxandCursor 8e7bbdf8b2 Bumpe cache-bust della heatmap JS su collaudo (evita 404 NPM su ?v=1).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 23:07:07 +02:00
eminuxandCursor b84321346f Upgrade heatmaps stile Hotjar: movimenti mouse e anteprima pagina live.
Traccia i movimenti aggregati, overlay a gradienti sull’iframe della pagina e toggle click/move in admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 23:03:12 +02:00
eminuxandCursor 8a07c5d569 Aggiunge heatmaps first-party (click/scroll) con pagina admin Analytics.
Raccoglie eventi aggregati dietro consenso cookie, senza PII né tracking su admin/regia/live/replay; GA4 resta invariato.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 22:50:15 +02:00
eminuxandCursor cc96c0396a Migliora sessioni admin e rende robusto il logout web.
Aggiunge filtri/colonne (società, orari) e dettaglio leggibile; evita 422 CSRF su logout e non cancella le cover slate custom in sync prod.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 20:41:12 +02:00
eminuxandCursor 79c48b226e Copia AAB/APK di release in native/android/dist con nome versionato.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:53:53 +02:00
eminuxandCursor c9b75065bc Bump Android a 2.0.12-native (versionCode 33) per Play Store.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:50:20 +02:00
eminuxandCursor 4ca9f3af8b Merge branch 'collaudo': avvisi multilanguage e allineamento sessioni admin.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:45:46 +02:00
eminuxandCursor f02782d0f0 Aggiunge traduzioni it/en/fr/de/es agli avvisi e toglie gli override Android/iOS.
Un avviso ha un testo per lingua; per differenziare le piattaforme si creano due avvisi. L'app riceve già il copy risolto da Accept-Language.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:26:30 +02:00
eminuxandCursor fb82b74e92 Allinea verticalmente le righe della tabella sessioni admin.
Il flex era sulla td e spezzava vertical-align; ora sta nel contenuto e l'ingest è su una riga.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:24:18 +02:00
eminuxandCursor 60dfe7a75b Allinea verticalmente le righe della tabella sessioni admin.
Il flex era sulla td e spezzava vertical-align; ora sta nel contenuto e l'ingest è su una riga.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:21:03 +02:00
eminuxandCursor a07f231417 Permette titolo, testo e link diversi per Android e iOS negli avvisi.
Separa i canali app e mostra il nodo ingest nelle sessioni admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:09:04 +02:00
eminuxandCursor 43d5003de8 Completa UI e test del form contatti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 15:39:11 +02:00
eminuxandCursor eadfc29da3 Corregge YAML form contatti: quota form_lead con due punti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 15:37:45 +02:00
eminuxandCursor 50d3e2cf16 Completa traduzioni privacy, mailer e flash del form contatti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 15:31:52 +02:00
eminuxandCursor 55e71ca838 Allinea privacy DE/ES/FR al form contatti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 15:31:07 +02:00
eminuxandCursor 5b723bf844 Aggiunge annunci in-app/sito, contatti e migliora UI copertina sul portale.
Include admin/API/banner nativi, form contatti e reset copertina standard con layout edit più ampio.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 15:30:49 +02:00
eminuxandCursor e502844b88 fix: non bloccare go-live se agent slate Hetzner è down
Se il push della cover custom all'agent fallisce (timeout/connection refused),
usa lo slate di default invece di abortire la creazione sessione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 13:26:52 +02:00
eminuxandCursor 0702cb699c Rendi affidabile pausa con slate e conferma i controlli laterali.
Evita buchi HLS/YouTube in pausa (path slate ricreata, reload player, no patch recording inutili) e richiede conferma su tasti laterali app/regia.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 12:44:23 +02:00
eminuxandCursor e09b3ffcf8 Corregge validazione upload copertina e generazione slate al go-live.
Rifiuta file troppo grandi o non-immagine via Marcel prima di attach, e genera la slate in sync anche quando cover_image esiste ma cover_slate non è ancora pronta.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 09:51:33 +02:00
eminuxandCursor 41d4235258 Aggiunge copertina sponsor custom solo Premium Full, con override in wizard e slate sui nodi di streaming.
Permette upload da portale e app con ereditarietà partita→squadra→società, generazione MP4 via ffmpeg e distribuzione su home lab e nodi CPX per pause e assenza segnale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 23:47:17 +02:00
eminuxandCursor a51d5e8da5 Non inviare in automatico le istruzioni di bonifico.
IBAN e causale restano da comunicare a mano dall'admin; la richiesta registra solo l'ordine in attesa.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 18:40:45 +02:00
eminuxandCursor 5bcb4170c7 Mostra il piano attivo dopo il bonifico e allinea il flusso società.
Dopo la conferma admin la card restava su «Paga con bonifico»; la società parte da Free, l'owner è staff trasmissione e le mail non bloccano se manca SMTP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 18:31:39 +02:00
eminuxandCursor e436f5ece4 Aggiunge pagamento con bonifico e importo concordato per società.
Il piano si attiva solo dopo la conferma admin; Stripe resta a listino se non c'è un prezzo commerciale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 23:20:43 +02:00
eminuxandCursor 1e4e0560f1 Allinea le spec alla policy password e prepara i piani di test.
Senza questo la suite locale fallisce sulla complessità password e sui piani mancanti.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 23:20:31 +02:00
eminuxandCursor 841a6a15be Avvia i relay YouTube overflow dal Sidekiq home e passa STREAM/HCLOUD in produzione.
Così un CPX nuovo non richiede worker Docker a mano, l'agent è nel cloud-init e gli E2E restano non in elenco.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 08:57:56 +02:00
eminuxandCursor dd94bf7e66 Riprova le GET Hetzner su errori SSL transitori e aggiunge i loghi con nome.
Durante il wait del CPX l'API chiudeva la connessione TLS; i tre PNG servono le varianti dark/white del marchio.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 22:56:46 +02:00
eminuxandCursor baa6283a15 Mantiene la copertina YouTube se il telefono cade e avvia ffmpeg sul nodo, non via SSH.
Il relay HLS→RTMPS resta sul worker/agent del nodo assegnato, così tre dirette contemporanee restano in onda sul sito e sul canale della società senza schermo nero.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 20:41:46 +02:00
eminux bfb3115a64 Merge branch 'main' into collaudo
Porta in collaudo l'invito trasmissione con email automatica.
2026-08-15 20:02:40 +02:00
eminuxandCursor db8b14ba4e Snellisci l'invito a trasmettere e invia l'email automatica.
Il form invito è sulla pagina squadra, genera il link e manda la mail; in caso di errore SMTP resta la copia manuale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 20:02:25 +02:00
eminux 46244fe066 Merge branch 'main' into collaudo
Porta su collaudo i prezzi di lancio, la pagina Funzionalità e i badge store.
2026-08-14 23:09:20 +02:00
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 b05b1acd7a Usa l'hostname pubblico per l'ingest RTMP di collaudo.
I telefoni in 4G non raggiungono l'IP LAN; la NAT sulla 11935 espone collaudo.matchlivetv.it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 23:26:34 +02:00
eminuxandCursor c777853c97 Rende autonomo lo spec Hetzner fornendo cloud-init inline.
Così il test di create_node non dipende dal file montato in collaudo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:24:37 +02:00
eminuxandCursor 9225318e1a Aggiunge il flavor Android collaudo installabile accanto all'app di produzione.
Usa un applicationId distinto, icona con fascia gialla e API su collaudo.matchlivetv.it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:19:20 +02:00
eminuxandCursor 061b7fad8b Merge branch 'feature/adaptive-bitrate' into collaudo
Integra bitrate adattivo e telemetria, con il mute audio come icona nella toolbar della regia.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:16:26 +02:00
eminuxandCursor be7957c078 Merge branch 'feature/stream-audio-mute' into collaudo
Integra il mute del microfono in diretta per il collaudo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:10:27 +02:00
eminux 91667e1bfd Merge branch 'feature/streaming-autoscale-hetzner' into collaudo
Integra l'autoscaler streaming Hetzner nell'ambiente di collaudo.
2026-08-13 20:10:11 +02:00
eminuxandCursor f92ab3a813 Aggiunge bitrate adattivo con qualità minima da regia e telemetria del telefono.
L'encoder scende in congestione e risale piano; la regia imposta il pavimento, mostra lo stato del telefono e apre il selettore dal bitrate colorato.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:05:16 +02:00
eminuxandCursor 1fecccaafd Aggiunge il mute del microfono in diretta per evitare claim YouTube sulla musica in palestra.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 22:38:05 +02:00
eminux d100c8030f Merge branch 'main' into feature/streaming-autoscale-hetzner 2026-08-11 19:45:27 +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
eminuxandCursor f1906517a9 Rende leggibile la pagina admin Nodi streaming con KPI e toolbar.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 19:04:12 +02:00
eminuxandCursor 00fde25c50 Evita scale-in del nodo appena creato nello stesso reconcile.
Con IDLE_MINUTES=0 (o race stretta) lo scale-out veniva annullato subito; lo smoke AutoscalerJob Cloud su collaudo ora completa scale-out e scale-in.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 18:42:35 +02:00
eminuxandCursor 4c5af99efa Allinea slate Cloud a AAC 48k mono e preferisce home in allocate.
Senza questo il telefono (mono 48k) veniva rifiutato sui nodi Hetzner e lo warm spare rubava le sessioni a home.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 18:27:13 +02:00
eminuxandCursor 8bf12e7721 Bump versione Android a 2.0.11-native (versionCode 32).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 18:10:08 +02:00
eminuxandCursor 5a0ca8ef1c Corregge cloud-init montato, PublisherOnline multi-nodo e E2E locale-aware.
Senza volume stream-node i nodi Hetzner nascevano senza MediaMTX; sync live usa ora Client.for_session e rtmpconns (MediaMTX 1.20).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 08:48:28 +02:00
eminuxandCursor 714602f3da Aggiunge overlay Compose per collaudo con RTMP su porta 11935.
Isola MediaMTX dal :1935 di produzione sullo stesso IP pubblico e documenta env/helper per il container Proxmox di test.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 20:29:47 +02:00
eminuxandCursor baac3a512b Preserva drain/offline su home e genera slate offline nel cloud-init.
Evita che ensure_home_from_env! annulli il drain a ogni allocate, e prepara /slates/offline.mp4 sul nodo Cloud per create_path MediaMTX.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 12:29:23 +02:00
eminuxandCursor 559284f0b2 Corregge il provisioning Hetzner: Faraday, cloud-init e default cpx12.
Sistemati path API /v1, AppArmor/auth MediaMTX sul nodo e defaults nbg1 così lo smoke Cloud è ripetibile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 12:20:36 +02:00
eminuxandCursor e5fc925bae Completa il hardening autoscaler (Fase 4): budget, kill-switch e runbook.
Drain sicuro in scale-in, alert Ops su overflow e controlli admin senza abilitare il deploy in produzione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 20:00:15 +02:00
eminuxandCursor 318a319608 Aggiunge l'autoscaler streaming con warm spare e kill-switch.
Scala i nodi overflow quando gli slot calano, mantiene una spare a caldo sotto carico e spegne gli idle, disattivabile con STREAM_AUTOSCALE_ENABLED.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 19:56:58 +02:00
eminuxandCursor 0b55d5a8fa Rende sticky e capacizzati i relay YouTube su coda dedicata.
Evita doppi ffmpeg tra host: stop solo sull'owner, ensure con requeue a capacità piena e coda Sidekiq youtube_relay isolata.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 19:54:04 +02:00
eminuxandCursor c3878cdc6d Aggiunge registry nodi stream e provisioning Hetzner/lab per lo scale-out.
Prepara l'architettura multi-nodo (MediaMTX+ffmpeg) con assignment URL per sessione, admin di provision/drain e provider Cloud/DNS astratti verso mltv-stream.net.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 19:52:47 +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
eminuxandCursor f3ff657fc2 Sposta overlay video sull'app Android e rimuove ffmpeg server-side.
Il tabellone stile SportCam e il watermark Match Live TV sono bruciati in GPU via RootEncoder; sul backend spariscono OverlayRelay, job Sidekiq e volume stream_overlays.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 18:12:35 +02:00
eminuxandCursor 68b4390282 Completa app Android nativa e rimuove il client Flutter.
Il monorepo punta a native/android (login, wizard, diretta RTMP, tabellone WebSocket, pausa/telemetria) con encode 16:9 e orientamento via sensore; eliminati mobile/ e gli script Flutter obsoleti.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 17:38:56 +02:00
eminuxandCursor 499aee8988 Aggiunge app Android nativa da zero su branch dedicato.
Nuovo progetto Compose in native/android con API, auth, streaming RTMP e schermate base; nessun codice copiato dalla app Flutter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 15:36:36 +02:00
eminuxandCursor 854738b46d Stabilizza live YouTube/RTMP e fix crash rotazione mobile.
Pipeline YouTube con relay, overlay e publisher sync lato backend; fix GL pauseGlDuringRotation su Android per evitare crash in rotazione durante lo streaming Flutter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 15:30:55 +02:00
eminuxandCursor 1058c644bd PublisherSync: go_live anche con bytesReceived su path camera
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 09:47:57 +02:00
eminuxandCursor 3ef3c59d94 PublisherSync: rileva publisher anche con source RTMP attivo
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 09:45:05 +02:00
eminuxandCursor 61bb22ce44 Fix overlay _air: probesize su intake RTMP e rilevamento zombie
Senza analyzeduration/probesize l'overlay non pubblicava su _air;
YouTube restava in attesa. Esclusi processi ffmpeg zombie da running?.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 09:39:11 +02:00
eminuxandCursor 10fff7ffcb Fix YouTube in attesa: disabilita auto_stop e ricrea broadcast chiusa
YouTube chiudeva la diretta (complete) senza ingest stabile; ora
enable_auto_stop è false e SetupBroadcast.recreate! rigenera link/stream key.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 09:33:07 +02:00
eminuxandCursor d835bce148 Fix relay YouTube: rimuovi opzioni reconnect non supportate da ffmpeg RTMP
ffmpeg usciva subito con "Option reconnect not found", ingest mai attivo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 09:21:58 +02:00
eminuxandCursor 4c7be615f1 Fix freeze YouTube al cambio punteggio: PNG atomica e relay stabile
rsvg scrive su .tmp poi rename; il feeder ignora PNG parziali.
YouTube relay: no doppio avvio, reconnect RTMP; overlay restart solo dopo 3 miss.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 09:07:06 +02:00
eminuxandCursor fde1213ca5 Fix YouTube relay instabile e timer LIVE a zero in app
ffmpeg: probesize/analyzeduration su _air, restart più rapido e avvio relay
al go_live. App: sincronizza started_at dal server e fallback al passaggio live.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 08:56:59 +02:00
eminuxandCursor 841e99c775 Fix Process.spawn: redirect log ffmpeg fuori dallo splat
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 08:46:02 +02:00
eminuxandCursor 02ff1de0f6 Fix relay YouTube bloccato: rimuovi ffprobe su RTMP live
ffprobe su rtmp://mediamtx si bloccava in Sidekiq e impediva
l'avvio del relay verso YouTube (pagina sempre in attesa).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 08:44:57 +02:00
eminuxandCursor 8263475146 Fix YouTube in attesa: audio AAC di riserva, retry attivazione broadcast
Il relay verso YouTube ora gestisce _air senza traccia audio, ritenta
activate_broadcast finché l'ingest è active e riduce la finestra
scheduledStartTime a ~20s.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 08:37:47 +02:00
eminuxandCursor bac655aca8 Fix API YouTube: id e mine non insieme in list_live_streams
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 08:34:50 +02:00
eminuxandCursor d08cd36527 Fix YouTube waiting: transcode verso RTMP e sync stato live.
Il relay non usa più copy (stream inactive su YouTube); PublisherSync
nel refresh overlay; activate solo quando lo stream YouTube è active.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 08:32:30 +02:00
eminuxandCursor 2c5f4f82eb YouTube live immediata: relay sempre attivo e attivazione broadcast.
Il relay parte dal job overlay ogni 2s; scheduledStartTime ~90s invece di 5 min;
transition testing/live quando l'ingest RTMP raggiunge YouTube.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 08:19:20 +02:00
eminuxandCursor 2ccbfea75e Corregge avvio RTMP bloccato dopo errore o cambio sessione.
Il motore Android ignorava startStream in CONNECTING/RECONNECTING; la camera
ora resetta prima di pubblicare. YouTube relay parte solo con overlay attivo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 07:32:15 +02:00
eminuxandCursor 1d67459bbe YouTube come destinazione pubblica, non Match Live TV.
Non propone più il link MLTV per sessioni YouTube; la pagina /live
reindirizza a YouTube; il relay verso YouTube si riavvia al connect.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 07:25:07 +02:00
eminuxandCursor 9fd4d865b3 Corregge keyword youtube_channel in SetupBroadcast.
Il job Sidekiq falliva con ArgumentError unknown keyword :channel,
impedendo la creazione broadcast YouTube dopo la sessione API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 00:32:15 +02:00
eminuxandCursor 70e704827e Risolve timeout creazione sessione YouTube lato app.
La setup broadcast YouTube (~23s) passa in background job; l'API risponde
subito. Aumentato anche il timeout Dio mobile per createSession YouTube.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 00:18:07 +02:00
eminuxandCursor fd7a5c2c8e Fix bind_live_broadcast: stream_id come keyword arg.
La gem google-apis-youtube_v3 richiede stream_id: e non un terzo argomento
posizionale, causando 500 in creazione sessione YouTube.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 00:11:38 +02:00
eminuxandCursor 8139d84d88 Fix orario YouTube scheduledStartTime per dirette immediate.
Normalizza la data partita nel futuro richiesto dall'API: se scheduled_at
è passato o assente usa now+5min, con messaggio d'errore più chiaro.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 00:03:19 +02:00
eminuxandCursor ab9cb02083 Aggiunge overlay server-side burn-in e stabilizza diretta live.
Tabellone, badge e brand sono ricodificati nel flusso _air via OverlayRelay;
sync punteggio HTTP, volume condiviso rails/sidekiq, qualità 720p migliorata
e badge CONNECTING in ripresa da pausa.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 23:55:17 +02:00
eminuxandCursor fd225fbadd Aggiunge Garage al compose di produzione e script di bootstrap.
Rails e Sidekiq usano S3 interno (http://garage:3900); setup_garage_production.sh
configura bucket, chiavi e infra/.env sul server.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 08:05:35 +02:00
eminuxandCursor 565bc2c548 Aggiunge cookie policy, banner consenso e Google Analytics opzionale.
Il banner consente solo necessari o tutti i cookie; GA4 si carica solo dopo
consenso. Aggiornati privacy, footer, sitemap e variabile GOOGLE_ANALYTICS_MEASUREMENT_ID.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 08:00:30 +02:00
eminuxandCursor 1f273f849d Aggiunge modulo Replay, Garage dev e YouTube a livello società.
Pipeline registrazione/upload con storage S3, archivio web e app, proxy replay
per il browser, OAuth YouTube sulla pagina club (Premium Full) e footer legale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 07:53:11 +02:00
eminuxandCursor a87cda156b Implementa pausa diretta con copertina slate e ripresa.
Metti in pausa ferma RTMP lato app mantenendo la camera aperta, MediaMTX
manda offline.mp4 agli spettatori e Chiudi termina definitivamente.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 23:31:48 +02:00
eminuxandCursor 1bd81d84af Aggiunge tabellone punteggio in app senza link regia.
Controlli +1/− e chiudi set in camera e dopo test rete, sync via WebSocket.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 23:19:17 +02:00
eminuxandCursor 8882e7854f Semplifica condivisione link dopo test rete in app.
Mostra link diretta e regia solo a test completato; rimuove pulsante
duplicato e nasconde «Avvia test rete».

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 23:11:40 +02:00
eminuxandCursor 422d8848f6 Corregge chiusura diretta YouTube e stop sessione senza broadcast.
transition_live_broadcast richiede status, id e part; evita 500 in chiusura
diretta Match Live TV quando non c'è broadcast YouTube.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 23:06:46 +02:00
eminuxandCursor 787161807c Ripristina scelta visibilità con due pulsanti segmentati in app.
Pubblico (default) e privato/non in elenco, stessa logica di prima.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 23:03:56 +02:00
eminuxandCursor 1677df7f86 Visibilità pubblico/link-only su tutte le piattaforme di streaming.
Default pubblico; opzione unica privato/non in elenco per Match Live TV,
YouTube piattaforma e canale società. Elenco dirette solo sessioni pubbliche.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 23:00:34 +02:00
eminuxandCursor 1fb5cd5aa2 App: scelta canale YouTube MLTV/società, privacy e condivisione link.
Premium Full può forzare il canale piattaforma; visibilità non in elenco o privato;
share_url unificato per Match Live TV e YouTube.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 22:52:07 +02:00
eminuxandCursor 6ac4db98bb Messaggi chiari per errori YouTube API in creazione diretta.
Include guida su liveStreamingNotEnabled e token non validi.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 22:44:49 +02:00
eminuxandCursor 92c0058085 Corregge OAuth YouTube piattaforma senza access token in memoria.
Il refresh token ENV non veniva scambiato finché expires_at era vuoto,
causando 500 Unauthorized alla creazione sessione YouTube.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 22:43:31 +02:00
eminuxandCursor 9b40deeb61 Unifica admin società/squadre e YouTube Full con fallback MLTV.
Premium Full senza canale società usa il canale piattaforma come Light;
in app restano solo Match Live TV e YouTube Live.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 22:38:28 +02:00
eminuxandCursor 82ff1972a0 Aggiunge abbonamenti omaggio admin per sponsor e promozioni.
Gli admin possono concedere o revocare Premium Light/Full senza Stripe;
il piano omaggio ha priorità sui webhook e la società vede un messaggio dedicato.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 22:26:19 +02:00
eminuxandCursor 566104aff4 Billing upgrade/downgrade, inviti in app e diretta YouTube da mobile.
Upgrade Stripe con addebito immediato; downgrade programmato a fine periodo.
UX billing più sobria con box info; icone piani. API inviti e OAuth YouTube
per staff trasmissione; deep link join; scelta partita programmata o nuova.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 22:15:27 +02:00
eminuxandCursor 2cb267f991 Admin: voce menu YouTube per collegamento canale piattaforma.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 21:12:12 +02:00
eminuxandCursor d051d60415 Fix 500 callback YouTube: pagina token senza layout admin.
Il layout admin richiedeva admin_logged_in? non disponibile nel callback OAuth.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 21:02:51 +02:00
eminuxandCursor cd8e97243f Allinea branding homepage a Match Live TV per verifica OAuth Google.
Nome visibile in hero, meta application-name, JSON-LD e documentazione; corregge mismatch con «Match Live Tv» in Console.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 20:04:34 +02:00
eminuxandCursor 71eaf9cf56 Fix callback OAuth YouTube: pagina HTML con refresh token invece di risposta API vuota.
Il codice OAuth va usato una sola volta; messaggio chiaro se si ricarica il callback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 19:57:08 +02:00
eminuxandCursor 462f050977 Deploy YouTube: non richiede credenziali nel .env locale se già sul server.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 19:41:23 +02:00
eminuxandCursor d0552e1aaa Fix: migration YouTube con timestamp univoco (evita conflitto 20260525120000).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 19:39:27 +02:00
eminuxandCursor 994c1e3c09 Integrazione YouTube Live: OAuth squadra, canale piattaforma Light e UI.
Collegamento da Dettagli squadra e admin per il refresh token Match Live TV; API e app mobile allineate ai piani Light/Full.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 19:36:56 +02:00
eminuxandCursor 1a84d6ae42 Fix checkout Stripe: ricrea customer se ID test su ambiente live.
Evita errore «No such customer» dopo passaggio da sk_test a sk_live.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 19:11:42 +02:00
eminuxandCursor 2b8f842752 Dirette: nasconde partite programmate nel passato.
La pagina /live mostrava tutte le partite da inizio giornata; ora solo con orario futuro.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 19:10:07 +02:00
eminuxandCursor 964e3f472c Admin fatturazione: coda pagamenti senza PDF e upload diretto.
Lista globale dei pagamenti da fatturare con dati intestazione, caricamento
PDF per riga che crea la fattura e invia email; rimuove il flusso bozza manuale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 17:31:49 +02:00
eminuxandCursor b073ded6c1 Admin: voce Fatturazione nel menu e elenco società.
Rende raggiungibili pagamenti e fatture PDF senza passare solo dal dettaglio squadra.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 17:27:09 +02:00
eminuxandCursor ef805984ee Rimuove featured anche da billing squadra.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 17:23:57 +02:00
eminuxandCursor 15e57cddee Card piani: un solo stile di bordo grigio per tutte e tre.
Rimuove featured con contorno rosso che creava 2 card rosse e 1 grigia.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 17:23:51 +02:00
eminuxandCursor cf06acf24d Allinea card piani: stessa altezza e bordo uniforme.
Flex column con azioni in basso; highlight Premium Light con box-shadow
invece di bordo rosso diverso che spezzava l’allineamento.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 17:21:42 +02:00
eminuxandCursor 1901fcce68 Premium Light: canale YouTube indicato come Match Live TV.
Sostituisce la dicitura obsoleta Match TV Light in prezzi, FAQ e funzionalità.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 17:19:43 +02:00
eminuxandCursor 62d86d16ec Fix prezzi pubblici: Premium Light €5/€40, Full €20/€200.
Ripristina le etichette in PriceCatalog usate da prezzi, abbonamento e checkout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 17:17:32 +02:00
eminuxandCursor a5e781729c Billing: fatture manuali, profilo obbligatorio e piani Stripe mensile/annuale.
Rimuove link al portale Stripe in area cliente, aggiunge flusso admin per PDF
fattura e email, blocca checkout senza dati di fatturazione, allinea prezzi
a €4,90/€39,90 e €9,90/€69,90, locale italiano e documentazione deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 17:10:12 +02:00
eminuxandCursor 148402a97c Fix PCI Stripe e anteprima regia; checkout sempre hosted.
Blocca l'invio di numeri carta dall'API server, usa Checkout anche per i cambi piano e nasconde correttamente il placeholder video in regia.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 08:00:28 +02:00
eminuxandCursor f4b7be0f80 Billing Stripe, link regia mobile e staff solo trasmissione.
Aggiunge fatturazione club, pagina regia condivisibile senza account, roster squadre e rimuove la modalità controller dall'app (v1.1.0).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 07:23:13 +02:00
eminuxandCursor 4083bc5dee Club come entità principale, branding e fix infrastruttura streaming.
Introduce clubs con abbonamento, branding Active Storage e flusso registrazione società; corregge healthcheck MediaMTX (immagine distroless) e allinea dominio produzione matchlivetv.it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 09:16:24 +02:00
eminuxandCursor 471291b2c4 Sito marketing, SEO, legal, live programmata e branding Match Live Tv.
Logo e favicon nel header, partite programmate su web e mobile, reset password,
pagine FAQ/sitemap, privacy/termini GDPR e icona Android «Match Live Tv».

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 23:17:15 +02:00
eminuxandCursor 3a5649f482 Admin protetto, dashboard KPI e aggiornamenti sito marketing.
Login admin con cambio password, metriche server (CPU/RAM/disco/banda), grafici e statistiche streaming; sezione piani ridimensionata.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 18:18:47 +02:00
1200 changed files with 87466 additions and 14217 deletions
@@ -0,0 +1,22 @@
---
description: Scalare soft limit ingest/autoscaler man mano che crescono i clienti
alwaysApply: true
---
# Soft limit stream / autoscaler
Con la crescita del numero di clienti (e delle dirette concorrenti attese), **alzare i soft limit** prima di restare a corto di capacità.
Config rilevante (prod, tipicamente `infra/.env` + `StreamNode`):
| Parametro | Ruolo oggi (post load test 2026-08) |
|-----------|--------------------------------------|
| `STREAM_NODE_HOME_MAX_PUBLISHERS` | Soft home (es. 6) |
| `STREAM_CLOUD_MAX_PUBLISHERS` | Soft per CPX (es. 4; cpx12 ha tenuto 8 in probe) |
| `STREAM_AUTOSCALE_MAX_OVERFLOW` / max overflow nodes | Quanti CPX in parallelo (es. 3 → tetto cluster ≈ home + N×cloud) |
| `STREAM_AUTOSCALE_SOFT_FREE_SLOTS` | Anticipo scale-out |
| Piano `concurrent_streams_limit` | Tetto **per club** (Premium Full = 10): indipendente dal cluster |
Capienza cluster soft ≈ `home_max + max_overflow × cloud_max` (es. 6+3×4 = **18**).
Quando si parla di capacity planning, deploy autoscale, o “troppe dirette”, ricordare di rivedere questi valori (e il limite piano) in base ai clienti reali — non lasciare i soft limit di collaudo/early-prod a lungo.
+3
View File
@@ -20,6 +20,9 @@ infra/certs/
infra/recordings/
infra/.env
infra/.env.production.generated
infra/garage/dev-credentials.env
infra/garage/prod-credentials.env
infra/garage/garage.prod.toml
# OS
.DS_Store
+21 -15
View File
@@ -2,15 +2,15 @@
Piattaforma mobile-first per streaming di partite sportive giovanili — MVP su **Match Live TV** (HLS sul nostro sito); YouTube/Facebook/Twitch come integrazioni premium.
**Slogan:** *Lo streaming che non muore*
**Slogan:** *Ogni partita, ogni evento, per i tuoi tifosi.*
## Struttura monorepo
```
├── backend/ # Rails 7.2 API + Action Cable + Sidekiq
├── mobile/ # Flutter app (Camera + Regia)
├── infra/ # Docker Compose, MediaMTX, Nginx, Kamal
└── docs/ # Documentazione
├── backend/ # Rails 7.2 API + Action Cable + Sidekiq
├── native/android/ # App Android nativa (Kotlin + Compose)
├── infra/ # Docker Compose, MediaMTX, Nginx, Kamal
└── docs/ # Documentazione
```
## Deploy produzione (server 192.168.1.146)
@@ -45,26 +45,32 @@ email: coach@matchlivetv.test
password: password123
```
## Mobile
## App Android nativa
```bash
cd mobile
flutter pub get
flutter run
./scripts/build_native_android_apk_prod.sh
adb install -r native/android/app/build/outputs/apk/release/app-release.apk
```
Configura `API_BASE_URL` in `lib/core/config.dart` (default `http://10.0.2.2:3000` per emulatore Android).
API produzione: `https://www.matchlivetv.it` (override con `-PAPI_BASE_URL=...` in Gradle).
Vedi [native/android/README.md](native/android/README.md).
## Architettura
- Video (MVP): Phone → MediaMTX (RTMP) → HLS su `matchlivetv.*` (pagina `/live/:session_id`)
- Video (premium): stesso ingest + relay ffmpeg verso YouTube
Documentazione completa: **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** (rete, edge nginx, flussi video, ops).
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
- Disconnessioni: MediaMTX `alwaysAvailable` slate + timeout 5 min (Sidekiq)
### Mobile produzione
### App Android produzione
```bash
./scripts/run_mobile_android_prod.sh
# API: https://matchlivetv.eminux.it
./scripts/build_native_android_apk_prod.sh
# API: https://www.matchlivetv.it
```
+1 -1
View File
@@ -1,7 +1,7 @@
FROM ruby:3.3-bookworm
RUN apt-get update -qq && \
apt-get install -y --no-install-recommends build-essential libpq-dev curl ffmpeg && \
apt-get install -y --no-install-recommends build-essential libpq-dev curl ffmpeg librsvg2-bin fonts-dejavu-core && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
+3
View File
@@ -17,7 +17,10 @@ gem "attr_encrypted"
gem "google-apis-youtube_v3"
gem "faraday"
gem "stripe"
gem "aws-sdk-s3", "~> 1.0", require: false
gem "kamal", require: false
gem "sentry-ruby"
gem "sentry-rails"
group :development, :test do
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
+30
View File
@@ -81,6 +81,25 @@ GEM
ast (2.4.3)
attr_encrypted (4.2.0)
encryptor (~> 3.0.0)
aws-eventstream (1.4.0)
aws-partitions (1.1256.0)
aws-sdk-core (3.251.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.129.0)
aws-sdk-core (~> 3, >= 3.248.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.225.0)
aws-sdk-core (~> 3, >= 3.248.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.3.0)
bcrypt (3.1.22)
bcrypt_pbkdf (1.1.2)
@@ -158,6 +177,7 @@ GEM
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.19.5)
jwt (3.2.0)
base64
@@ -361,6 +381,13 @@ GEM
rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0)
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)
connection_pool (>= 3.0.0)
json (>= 2.16.0)
@@ -416,6 +443,7 @@ PLATFORMS
DEPENDENCIES
aasm
attr_encrypted
aws-sdk-s3 (~> 1.0)
bcrypt (~> 3.1.7)
bootsnap
brakeman
@@ -435,6 +463,8 @@ DEPENDENCIES
redis (>= 4.0.1)
rspec-rails
rubocop-rails-omakase
sentry-rails
sentry-ruby
sidekiq
stripe
tzinfo-data
@@ -1,8 +1,14 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
identified_by :current_user, :regia_session
def connect
if (regia_token = request.params[:regia_token].presence)
self.regia_session = Sessions::RegiaAccess.find_session_by_token(regia_token)
reject_unauthorized_connection unless regia_session
return
end
payload = JsonWebToken.decode(token_from_header)
self.current_user = User.find_by(id: payload[:user_id]) if payload
reject_unauthorized_connection unless current_user
@@ -3,7 +3,7 @@ module CableBroadcastable
class_methods do
def broadcast_message(session, data)
broadcast_to(session, method: "receive_message", data: data)
broadcast_to(session, data)
end
end
end
+25 -14
View File
@@ -1,19 +1,31 @@
class SessionChannel < ApplicationCable::Channel
include CableBroadcastable
def subscribed
session = StreamSession.joins(match: :team)
.merge(current_user.teams)
.find(params[:session_id])
session = resolve_session
reject unless session && session.id.to_s == params[:session_id].to_s
stream_for session
session.device_states.find_or_create_by!(device_role: params[:device_role] || "controller")
end
def resolve_session
if connection.regia_session
connection.regia_session
else
StreamSession.joins(match: :team)
.merge(connection.current_user.manageable_teams)
.find_by(id: params[:session_id])
end
end
def unsubscribed
stop_all_streams
end
def receive(data)
session = StreamSession.find(params[:session_id])
session = resolve_session
return unless session
handle_message(session, data)
end
@@ -41,20 +53,19 @@ class SessionChannel < ApplicationCable::Channel
Sessions::Stop.new(session).call
when "pause_stream"
Sessions::Pause.new(session).call
when "resume_stream"
Sessions::Resume.new(session).call
when "mute_audio"
Sessions::SetAudioMute.new(session, muted: true).call
when "unmute_audio"
Sessions::SetAudioMute.new(session, muted: false).call
when "set_min_quality"
Sessions::SetMinQuality.new(session, preset: data["min_quality_preset"]).call
end
end
def update_score(session, data)
score = session.score_state || session.create_score_state!
score.update!(
home_sets: data["home_sets"] || score.home_sets,
away_sets: data["away_sets"] || score.away_sets,
home_points: data["home_points"] || score.home_points,
away_points: data["away_points"] || score.away_points,
current_set: data["current_set"] || score.current_set,
set_partials: data.key?("set_partials") ? data["set_partials"] : score.set_partials
)
SessionChannel.broadcast_message(session, score.as_cable_payload)
Scoring::SyncState.new(session: session, payload: data).call
end
def update_timeout(session, data)
@@ -0,0 +1,101 @@
# frozen_string_literal: true
module Admin
class AnalyticsController < Admin::BaseController
def index
@filters = {
from: parse_date(params[:from]) || 7.days.ago.to_date,
to: parse_date(params[:to]) || Time.zone.today,
device: params[:device].presence
}
scope = AnalyticsPageStat.where(day: @filters[:from]..@filters[:to])
scope = scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
cell_scope = AnalyticsPageCell.where(day: @filters[:from]..@filters[:to])
cell_scope = cell_scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
clicks_by_path = cell_scope.group(:page_path).sum(:click_count)
moves_by_path = cell_scope.group(:page_path).sum(:move_count)
pageviews_by_path = scope.group(:page_path).sum(:pageview_count)
scroll_samples_by_path = scope.group(:page_path).sum(:scroll_samples)
scroll_sum_by_path = scope.group(:page_path).sum(:scroll_sum_pct)
max_scroll_by_path = scope.group(:page_path).maximum(:max_scroll_pct)
paths = (pageviews_by_path.keys + clicks_by_path.keys + moves_by_path.keys).uniq
@pages = paths.map do |path|
samples = scroll_samples_by_path[path].to_i
{
page_path: path,
pageviews: pageviews_by_path[path].to_i,
clicks: clicks_by_path[path].to_i,
moves: moves_by_path[path].to_i,
scroll_samples: samples,
scroll_sum: scroll_sum_by_path[path].to_i,
max_scroll: max_scroll_by_path[path].to_i
}
end.sort_by { |r| [-r[:pageviews], -r[:moves], -r[:clicks], r[:page_path]] }
end
def show
@page_path = params[:page_path].to_s
redirect_to admin_analytics_path, alert: t("admin.analytics.missing_path") and return if @page_path.blank?
@filters = {
from: parse_date(params[:from]) || 7.days.ago.to_date,
to: parse_date(params[:to]) || Time.zone.today,
device: params[:device].presence,
layer: params[:layer].to_s
}
cells = AnalyticsPageCell.where(page_path: @page_path, day: @filters[:from]..@filters[:to])
cells = cells.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
@click_total = cells.sum(:click_count)
@move_total = cells.sum(:move_count)
@filters[:layer] =
if %w[move click].include?(@filters[:layer])
@filters[:layer]
elsif @move_total.positive?
"move"
else
"click"
end
counter = @filters[:layer] == "click" ? :click_count : :move_count
@cells = cells.group(:cell_x, :cell_y).sum(counter)
@max_weight = @cells.values.max.to_i
@total_points = @cells.values.sum
stats = AnalyticsPageStat.where(page_path: @page_path, day: @filters[:from]..@filters[:to])
stats = stats.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
@pageviews = stats.sum(:pageview_count)
@scroll_samples = stats.sum(:scroll_samples)
@scroll_sum = stats.sum(:scroll_sum_pct)
@max_scroll = stats.maximum(:max_scroll_pct).to_i
@avg_scroll = @scroll_samples.positive? ? (@scroll_sum.to_f / @scroll_samples).round : 0
@grid = AnalyticsPageCell::GRID_SIZE
@snapshot = find_snapshot(@page_path, @filters[:device])
end
private
def parse_date(value)
return nil if value.blank?
Date.parse(value.to_s)
rescue ArgumentError, TypeError
nil
end
def find_snapshot(page_path, device)
scope = AnalyticsPageSnapshot.where(page_path: page_path)
if device.present? && AnalyticsEvent::DEVICES.include?(device)
snap = scope.find_by(device: device)
return snap if snap&.image&.attached?
end
scope.order(captured_at: :desc).detect { |s| s.image.attached? }
end
end
end
@@ -0,0 +1,76 @@
module Admin
class AnnouncementsController < Admin::BaseController
before_action :set_announcement, only: %i[edit update destroy]
def index
@announcements = AppAnnouncement.newest
end
def new
@announcement = AppAnnouncement.new(
kind: "info",
severity: "info",
status: "draft",
show_on_android: true,
show_on_ios: true,
show_on_web_public: true,
show_on_web_private: true,
dismissible: true
)
end
def create
@announcement = AppAnnouncement.new(announcement_params)
@announcement.created_by_admin = current_admin_account
if @announcement.save
redirect_to admin_announcements_path, notice: t("admin.flash.announcement_created")
else
flash.now[:alert] = @announcement.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity
end
end
def edit; end
def update
if @announcement.update(announcement_params)
redirect_to admin_announcements_path, notice: t("admin.flash.announcement_updated")
else
flash.now[:alert] = @announcement.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity
end
end
def destroy
@announcement.destroy!
redirect_to admin_announcements_path, notice: t("admin.flash.announcement_destroyed")
end
private
def set_announcement
@announcement = AppAnnouncement.find(params[:id])
end
def announcement_params
params.require(:app_announcement).permit(
:kind, :severity, :status, :title, :body,
:action_url, :action_label, :dismissible, :starts_at, :ends_at,
:show_on_android, :show_on_ios, :show_on_web_public, :show_on_web_private,
translations: {
en: AppAnnouncement::COPY_KEYS,
fr: AppAnnouncement::COPY_KEYS,
de: AppAnnouncement::COPY_KEYS,
es: AppAnnouncement::COPY_KEYS
}
).tap do |permitted|
%i[dismissible show_on_android show_on_ios show_on_web_public show_on_web_private].each do |key|
permitted[key] = ActiveModel::Type::Boolean.new.cast(permitted[key])
end
%i[starts_at ends_at action_url action_label].each do |key|
permitted[key] = nil if permitted[key].blank?
end
end
end
end
end
@@ -0,0 +1,27 @@
module Admin
class AuthController < Admin::BaseController
skip_before_action :require_admin_login, only: %i[new create]
# Same as public logout: tolerate stale CSRF after long-lived admin tabs.
skip_before_action :verify_authenticity_token, only: :destroy
def new
redirect_to admin_root_path if admin_logged_in?
end
def create
account = AdminAccount.find_by(username: params[:username]&.strip)
if account&.authenticate(params[:password])
session[:admin_account_id] = account.id
redirect_to session.delete(:admin_return_to) || admin_root_path, notice: t("admin.flash.login_success")
else
flash.now[:alert] = t("admin.flash.invalid_credentials")
render :new, status: :unauthorized
end
end
def destroy
reset_session
redirect_to admin_login_path, notice: t("admin.flash.logout_success")
end
end
end
@@ -1,6 +1,52 @@
module Admin
class BaseController < ActionController::Base
layout "admin"
protect_from_forgery with: :null_session
protect_from_forgery with: :exception
before_action :set_admin_locale
before_action :require_admin_login
include ::AdminHelper
helper ApplicationHelper
helper RecordingsArchiveHelper
helper_method :current_admin_account, :admin_logged_in?, :current_locale, :language_options, :current_language_option
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
return if admin_logged_in?
session[:admin_return_to] = request.fullpath unless request.path == admin_login_path
redirect_to admin_login_path, alert: t("admin.flash.login_required")
end
def current_admin_account
return unless session[:admin_account_id]
@current_admin_account ||= AdminAccount.find_by(id: session[:admin_account_id])
end
def admin_logged_in?
current_admin_account.present?
end
end
end
@@ -0,0 +1,59 @@
module Admin
class BillingController < BaseController
def index
@pending_payments = pending_scope.recent
@pending_transfers = transfer_scope
@completed_payments = Billing::Payment.with_invoice_pdf.includes(:club, :invoice).recent.limit(40)
@filter_club = Club.find_by(id: params[:club_id]) if params[:club_id].present?
@clubs = Club.order(:name)
end
def attach_pdf
payment = Billing::Payment.find(params[:payment_id])
Billing::AttachPaymentInvoice.call(payment: payment, pdf: params[:pdf])
redirect_to admin_billing_path(billing_redirect_params(payment)),
notice: t("admin.flash.invoice_uploaded", email: payment.club.billing_email)
rescue Billing::AttachPaymentInvoice::Error, Billing::IssueInvoice::Error => e
payment ||= Billing::Payment.find_by(id: params[:payment_id])
redirect_to admin_billing_path(payment ? billing_redirect_params(payment) : {}),
alert: e.message
end
def confirm_transfer
order = Billing::TransferOrder.find(params[:id])
Billing::ConfirmBankTransfer.call(
order: order,
admin: current_admin_account,
pdf: params[:pdf]
)
redirect_to admin_billing_path(club_id: order.club_id),
notice: t("admin.flash.transfer_confirmed", plan: order.plan.name, club: order.club.name)
rescue Billing::ConfirmBankTransfer::Error, Billing::AttachPaymentInvoice::Error, Billing::IssueInvoice::Error => e
redirect_to admin_billing_path, alert: e.message
end
def cancel_transfer
order = Billing::TransferOrder.find(params[:id])
order.cancel!
redirect_to admin_billing_path(club_id: order.club_id), notice: t("admin.flash.transfer_cancelled")
end
private
def pending_scope
scope = Billing::Payment.awaiting_invoice_pdf.includes(:club, invoice: { pdf_attachment: :blob })
scope = scope.where(club_id: params[:club_id]) if params[:club_id].present?
scope
end
def transfer_scope
scope = Billing::TransferOrder.awaiting_payment.includes(:club, :requested_by_user)
scope = scope.where(club_id: params[:club_id]) if params[:club_id].present?
scope
end
def billing_redirect_params(payment)
{ club_id: payment.club_id, anchor: "payment-#{payment.id}" }.compact
end
end
end
@@ -0,0 +1,84 @@
module Admin
class BillingInvoicesController < BaseController
before_action :set_club
before_action :set_invoice, only: %i[edit update]
def index
redirect_to admin_billing_path(club_id: @club.id)
end
def new
redirect_to admin_billing_path(club_id: @club.id), alert: t("admin.flash.invoice_upload_hint")
end
def create
@invoice = @club.billing_invoices.build(invoice_params)
@invoice.source = "manual"
@invoice.status = "draft"
if @invoice.save
redirect_to edit_admin_club_billing_invoice_path(@club, @invoice),
notice: t("admin.flash.invoice_draft_created")
else
@payment = @invoice.billing_payment
flash.now[:alert] = @invoice.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity
end
end
def edit
@payment = @invoice.billing_payment
end
def update
@invoice.assign_attributes(invoice_params.except(:pdf))
if issuing?
Billing::IssueInvoice.call(invoice: @invoice, pdf: params.dig(:billing_invoice, :pdf))
redirect_to admin_billing_path(club_id: @club.id),
notice: t("admin.flash.invoice_issued", number: @invoice.number, email: @club.billing_email)
elsif @invoice.save
redirect_to admin_billing_path(club_id: @club.id), notice: t("admin.flash.invoice_updated", number: @invoice.number)
else
@payment = @invoice.billing_payment
flash.now[:alert] = @invoice.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity
end
rescue Billing::IssueInvoice::Error => e
@payment = @invoice.billing_payment
flash.now[:alert] = e.message
render :edit, status: :unprocessable_entity
end
private
def set_club
@club = Club.find(params[:club_id])
end
def set_invoice
@invoice = @club.billing_invoices.find(params[:id])
end
def issuing?
params[:commit_action].to_s == "issue"
end
def invoice_params
params.require(:billing_invoice).permit(
:number,
:issued_on,
:amount_cents,
:amount_euros,
:currency,
:billing_payment_id,
:notes,
:pdf
).tap do |p|
if p[:amount_euros].present?
p[:amount_cents] = (p.delete(:amount_euros).to_f * 100).round
end
end
end
end
end
@@ -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
@@ -0,0 +1,73 @@
module Admin
class ClubsController < BaseController
before_action :set_club, only: %i[show grant_comped revoke_comped set_quote revoke_quote]
def index
@clubs = Club.includes(:teams, :billing_quote, subscription: %i[plan admin_comped_by])
.order(:name)
end
def show
@subscription = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active")
@plans = Plan.ordered.reject { |p| p.slug == "free" }
@teams = @club.teams.order(:name)
@quote = @club.active_billing_quote
end
def grant_comped
Billing::AdminCompedSubscription.grant(
club: @club,
plan_slug: params.require(:plan_slug),
reason: params[:reason],
admin: current_admin_account
)
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
redirect_back_or_club alert: e.message
end
def revoke_comped
Billing::AdminCompedSubscription.revoke(club: @club, admin: current_admin_account)
redirect_back_or_club notice: t("admin.flash.comped_revoked", club: @club.name)
rescue Billing::AdminCompedSubscription::Error => e
redirect_back_or_club alert: e.message
end
def set_quote
quote = Billing::SetClubQuote.upsert(
club: @club,
plan_slug: params.require(:plan_slug),
interval: params[:interval],
amount_euros: params[:amount_euros],
note: params[:note],
admin: current_admin_account
)
redirect_back_or_club notice: t(
"admin.flash.quote_saved",
club: @club.name,
plan: quote.plan.name,
amount: quote.formatted_amount
)
rescue Billing::SetClubQuote::Error, ActionController::ParameterMissing => e
redirect_back_or_club alert: e.message
end
def revoke_quote
Billing::SetClubQuote.revoke(club: @club, admin: current_admin_account)
redirect_back_or_club notice: t("admin.flash.quote_revoked", club: @club.name)
rescue Billing::SetClubQuote::Error => e
redirect_back_or_club alert: e.message
end
private
def set_club
@club = Club.find(params[:id])
end
def redirect_back_or_club(notice: nil, alert: nil)
target = params[:return_to].presence || admin_club_path(@club)
redirect_to target, notice: notice, alert: alert
end
end
end
@@ -1,8 +1,34 @@
module Admin
class DashboardController < Admin::BaseController
def index
@teams = Team.includes(:matches).order(:name)
@active_sessions = StreamSession.where(status: %w[live reconnecting connecting]).includes(:match)
@stats = DashboardStats.new.call
@host = HostMetrics.new.sample!
@active_sessions = StreamSession
.where(status: DashboardStats::ACTIVE_STATUSES)
.includes(:stream_node, match: :team)
.order(started_at: :desc)
@teams = Team.includes(:matches).order(:name).limit(8)
end
def metrics
host = HostMetrics.new.sample!
stats = DashboardStats.new.call
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
@@ -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
@@ -0,0 +1,40 @@
module Admin
class PasswordsController < Admin::BaseController
def edit
end
def update
unless current_admin_account.authenticate(params[:current_password])
flash.now[:alert] = t("admin.flash.password_current_incorrect")
return render :edit, status: :unprocessable_entity
end
if params[:password] != params[:password_confirmation]
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
end
if current_admin_account.update(password: params[:password])
redirect_to admin_root_path, notice: t("admin.flash.password_updated")
else
flash.now[:alert] = current_admin_account.errors.full_messages.to_sentence
render :edit, status: :unprocessable_entity
end
end
end
end
@@ -1,12 +1,122 @@
module Admin
class SessionsController < Admin::BaseController
def index
@sessions = StreamSession.includes(:match, :user).order(created_at: :desc).limit(50)
@filters = session_filters
scope = filtered_sessions
@total_count = scope.count
@sessions = scope
.includes(:stream_node, :user, match: { team: :club })
.order(Arel.sql("COALESCE(stream_sessions.started_at, stream_sessions.created_at) DESC"))
.limit(100)
@clubs = Club.order(:name)
@nodes = StreamNode.order(:slug)
end
def show
@session = StreamSession
.includes(:stream_node, :user, :device_states, :recording, match: { team: :club })
.find(params[:id])
@events = @session.stream_events.recent.limit(100)
@club = @session.match.team.club
end
def stop
@session = StreamSession.find(params[:id])
@events = @session.stream_events.recent.limit(50)
if @session.terminal?
redirect_to admin_session_path(@session), alert: t("admin.flash.session_already_terminated", status: @session.status)
return
end
Sessions::Stop.new(@session).call
redirect_to admin_session_path(@session), notice: t("admin.flash.session_stopped")
rescue StandardError => e
Rails.logger.error("[admin] stop session #{@session.id}: #{e.class} #{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
private
def session_filters
{
q: params[:q].to_s.strip.presence,
status: params[:status].to_s.strip.presence,
platform: params[:platform].to_s.strip.presence,
club_id: params[:club_id].to_s.strip.presence,
stream_node_id: params[:stream_node_id].to_s.strip.presence,
from: params[:from].to_s.strip.presence,
to: params[:to].to_s.strip.presence
}
end
def filtered_sessions
scope = StreamSession.left_outer_joins(match: { team: :club })
if @filters[:q].present?
term = "%#{ActiveRecord::Base.sanitize_sql_like(@filters[:q])}%"
scope = scope.where(
"teams.name ILIKE :term OR matches.opponent_name ILIKE :term OR clubs.name ILIKE :term OR matches.location ILIKE :term",
term: term
)
end
if @filters[:status].present? && StreamSession::STATUSES.include?(@filters[:status])
scope = scope.where(stream_sessions: { status: @filters[:status] })
end
if @filters[:platform].present? && StreamSession::LIVE_PLATFORMS.include?(@filters[:platform])
scope = scope.where(stream_sessions: { platform: @filters[:platform] })
end
if @filters[:club_id].present?
scope = scope.where(teams: { club_id: @filters[:club_id] })
end
if @filters[:stream_node_id].present?
node = StreamNode.find_by(id: @filters[:stream_node_id])
if node
scope = scope.where(
"stream_sessions.stream_node_id = :id OR stream_sessions.ingest_slug = :slug",
id: node.id,
slug: node.slug
)
end
end
if (from_time = parse_filter_date(@filters[:from], end_of_day: false))
scope = scope.where(
"COALESCE(stream_sessions.started_at, stream_sessions.created_at) >= ?",
from_time
)
end
if (to_time = parse_filter_date(@filters[:to], end_of_day: true))
scope = scope.where(
"COALESCE(stream_sessions.started_at, stream_sessions.created_at) <= ?",
to_time
)
end
scope
end
def parse_filter_date(value, end_of_day:)
return nil if value.blank?
date = Date.parse(value)
end_of_day ? date.end_of_day : date.beginning_of_day
rescue ArgumentError, TypeError
nil
end
end
end
@@ -0,0 +1,67 @@
# frozen_string_literal: true
module Admin
class StreamNodesController < Admin::BaseController
def index
Streams::NodeRegistry.ensure_home_from_env!
@nodes = StreamNode.order(:role, :slug)
@dns_provider = ENV.fetch("STREAM_DNS_PROVIDER", "lab")
@cloud_provider = ENV.fetch("STREAM_CLOUD_PROVIDER", "local_lab")
@lab_hosts = lab_dns_snippet
@hetzner_configured = ENV["HCLOUD_TOKEN"].present?
@autoscale_metrics = Streams::Autoscaler.metrics
end
def create
kind = params[:kind].to_s
node =
if kind == "cloud"
Streams::NodeProvisioner.new.provision_cloud!
else
Streams::NodeProvisioner.new.provision_lab!
end
redirect_to admin_stream_nodes_path,
notice: t("admin.flash.stream_node_created", slug: node.slug)
rescue Streams::NodeProvisioner::Error, Streams::CloudProviders::Error, Streams::DnsProviders::Error,
KeyError => e
redirect_to admin_stream_nodes_path, alert: e.message
end
def drain
node = StreamNode.find(params[:id])
Streams::NodeProvisioner.new.drain!(node)
redirect_to admin_stream_nodes_path, notice: t("admin.flash.stream_node_draining", slug: node.slug)
rescue Streams::NodeProvisioner::Error => e
redirect_to admin_stream_nodes_path, alert: e.message
end
def destroy
node = StreamNode.find(params[:id])
Streams::NodeProvisioner.new.decommission!(node)
redirect_to admin_stream_nodes_path, notice: t("admin.flash.stream_node_destroyed", slug: node.slug)
rescue Streams::NodeProvisioner::BusyError, Streams::NodeProvisioner::Error,
Streams::CloudProviders::Error, Streams::DnsProviders::Error => e
redirect_to admin_stream_nodes_path, alert: e.message
end
def kill_switch
Streams::Autoscaler.engage_kill_switch!
redirect_to admin_stream_nodes_path, notice: t("admin.flash.autoscale_kill_on")
end
def clear_kill_switch
Streams::Autoscaler.clear_kill_switch!
redirect_to admin_stream_nodes_path, notice: t("admin.flash.autoscale_kill_off")
end
private
def lab_dns_snippet
return "" unless @dns_provider == "lab"
Streams::DnsProviders::Lab.new.hosts_file_snippet
rescue Redis::BaseError
""
end
end
end
@@ -1,7 +1,7 @@
module Admin
class TeamsController < Admin::BaseController
def index
@teams = Team.all.order(:name)
redirect_to admin_clubs_path, status: :moved_permanently
end
def show
@@ -0,0 +1,13 @@
module Admin
class YoutubeController < Admin::BaseController
def platform
if ENV["YOUTUBE_CLIENT_ID"].blank?
redirect_to admin_root_path, alert: t("admin.flash.youtube_not_configured")
return
end
state = Youtube::OauthState.for_platform(admin_id: current_admin_account.id)
redirect_to Youtube::OauthUrl.build(state: state), allow_other_host: true
end
end
end
@@ -0,0 +1,35 @@
# frozen_string_literal: true
module Analytics
class EventsController < ActionController::API
def create
payload = parse_payload
result = Analytics::Ingest.new(events: payload, remote_ip: request.remote_ip).call
if result.rate_limited
return head :too_many_requests
end
render json: { accepted: result.accepted, rejected: result.rejected }, status: :accepted
end
private
def parse_payload
body = request.request_parameters
return body["events"] if body.is_a?(Hash) && body["events"].is_a?(Array)
return body if body.is_a?(Array)
raw = request.raw_post
return [] if raw.blank?
parsed = JSON.parse(raw)
return parsed["events"] if parsed.is_a?(Hash) && parsed["events"].is_a?(Array)
return parsed if parsed.is_a?(Array)
[]
rescue JSON::ParserError
[]
end
end
end
@@ -0,0 +1,21 @@
# frozen_string_literal: true
module Analytics
class SnapshotsController < ActionController::API
def create
result = Analytics::SnapshotIngest.new(
path: params[:path] || params[:page_path],
device: params[:device],
width: params[:width],
height: params[:height],
image: params[:image],
remote_ip: request.remote_ip
).call
return head :too_many_requests if result.rate_limited
return render json: { ok: false, error: result.error }, status: :unprocessable_content unless result.ok
render json: { ok: true, skipped: result.skipped }, status: :accepted
end
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
@@ -0,0 +1,13 @@
module Api
module V1
class AnnouncementsController < BaseController
skip_before_action :authenticate_request!
def index
platform = params[:platform].to_s.presence
announcements = AppAnnouncement.for_platform(platform).newest
render json: announcements.map { |item| item.as_api_json(platform: platform) }
end
end
end
end
@@ -1,7 +1,7 @@
module Api
module V1
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
user = User.new(
@@ -22,18 +22,18 @@ module Api
if user&.authenticate(params[:password])
render json: token_response(user), status: :ok
else
render json: { error: "Invalid credentials" }, status: :unauthorized
render json: { error: I18n.t("flash.sessions.invalid_credentials") }, status: :unauthorized
end
end
def logout
render json: { message: "Logged out" }
render json: { message: I18n.t("flash.sessions.logged_out") }
end
def refresh
payload = JsonWebToken.decode(params[:refresh_token] || bearer_token)
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)
end
@@ -42,6 +42,13 @@ module Api
render json: user_json(current_user)
end
def forgot_password
Users::RequestPasswordReset.call(email: params[:email])
render json: {
message: I18n.t("flash.password_resets.email_sent")
}
end
private
def token_response(user)
@@ -2,9 +2,19 @@ module Api
module V1
class BaseController < ApplicationController
rescue_from Teams::EntitlementError, with: :render_entitlement_error
rescue_from Streams::IngestUnavailableError, with: :render_ingest_unavailable
rescue_from BrandingAttachments::CoverUploadError, with: :render_cover_upload_error
rescue_from Youtube::BroadcastService::Error, with: :render_youtube_error
private
def render_youtube_error(error)
render json: {
error: "YouTube: #{error.message}",
error_code: "youtube_api_error"
}, status: :unprocessable_entity
end
def render_entitlement_error(error)
render json: {
error: error.message,
@@ -12,6 +22,17 @@ module Api
billing_url: error.billing_url
}, status: :forbidden
end
def render_ingest_unavailable(error)
render json: {
error: error.message,
error_code: error.code
}, status: :service_unavailable
end
def render_cover_upload_error(error)
render json: { error: error.message, error_code: "cover_upload_invalid" }, status: :unprocessable_entity
end
end
end
end
@@ -0,0 +1,52 @@
module Api
module V1
class InvitationsController < BaseController
skip_before_action :authenticate_request!, only: :show
def show
invitation = find_pending_invitation
unless invitation
return render json: { valid: false, error: "Invito non valido o scaduto" }, status: :not_found
end
render json: {
valid: true,
email: invitation.email,
team_id: invitation.team_id,
team_name: invitation.team.name,
club_name: invitation.team.club.name,
staff_kind: invitation.staff_kind,
expires_at: invitation.expires_at
}
end
def accept
invitation = find_pending_invitation
return render json: { error: "Invito non valido o scaduto" }, status: :not_found unless invitation
if current_user.email.downcase != invitation.email.downcase
return render json: {
error: "Questo invito è per #{invitation.email}. Accedi con quell'indirizzo email.",
invited_email: invitation.email
}, status: :unprocessable_entity
end
invitation.accept!(current_user)
render json: {
team_id: invitation.team_id,
team_name: invitation.team.name,
message: "Sei entrato in #{invitation.team.name} come responsabile trasmissione."
}
end
private
def find_pending_invitation
token = params[:token].to_s
return nil if token.blank?
TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(token))
end
end
end
end
@@ -1,18 +1,28 @@
module Api
module V1
class MatchesController < BaseController
include Api::UrlHelper
include Api::CoverJson
include BrandingAttachments
before_action :set_team, only: %i[index create]
before_action :set_match, only: %i[show update destroy]
def index
matches = @team.matches
.includes(:team, :stream_sessions)
.order(scheduled_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) }
end
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)
attach_match_cover(match)
render json: match_json(match), status: :created
end
@@ -21,7 +31,12 @@ module Api
end
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)
attach_match_cover(@match)
render json: match_json(@match)
end
@@ -40,45 +55,92 @@ module Api
private
def set_team
@team = current_user.teams.find(params[:team_id])
@team = current_user.streamable_teams.find { |t| t.id == params[:team_id] }
raise ActiveRecord::RecordNotFound unless @team
end
def set_match
@match = Match.joins(:team).merge(current_user.teams).find(params[:id])
team_ids = current_user.streamable_teams.map(&:id)
@match = Match.includes(:team).where(team_id: team_ids).find(params[:id])
end
def match_params
params.require(:match).permit(
:opponent_name, :location, :scheduled_at, :sport, :sets_to_win,
:category, :phase, roster_numbers: [],
scoring_rules: %i[points_per_set points_deciding_set min_point_lead]
p = params.require(:match).permit(
:opponent_name, :location, :scheduled_at, :sport, :sport_key, :sets_to_win,
:category, :opponent_primary_color, :overlay_kind,
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
def attach_match_cover(match)
attach_cover_image(match, entitlements: match.team.entitlements)
enqueue_cover_slate_generation(match)
end
def match_json(match, detail: false)
active = active_session_for(match)
team = match.team
{
id: match.id,
team_id: match.team_id,
team_name: match.team.name,
team_name: team.name,
opponent_name: match.opponent_name,
location: match.location,
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,
scoring_rules: match.scoring_rules.presence,
roster_numbers: match.roster_numbers,
effective_scoring_rules: match.effective_scoring_rules,
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),
**match_cover_json(match),
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
def active_session_for(match)
match.stream_sessions
.order(created_at: :desc)
.find { |s| !%w[ended error].include?(s.status) }
match.active_stream_session
end
end
end
@@ -0,0 +1,114 @@
module Api
module V1
class RecordingsController < BaseController
before_action :set_recording
def update
authorize_manage!
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)
end
def destroy
authorize_manage!
scope = params[:delete_scope].to_s
scope = "both" unless Recordings::Delete::SCOPES.include?(scope)
Recordings::Delete.new(@recording, scope: scope).call
head :no_content
end
def download
url = Recordings::DownloadUrl.new(@recording, viewer: current_user).call
render json: { download_url: url, expires_in_seconds: MatchLiveTv.replay_download_url_ttl_seconds }
rescue Recordings::DownloadUrl::NotAllowed
render json: { error: "Download non disponibile per il tuo piano", error_code: "download_not_allowed" },
status: :forbidden
rescue ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def publish_youtube
authorize_manage!
ent = @recording.team.entitlements
unless ent.premium_full? && ent.youtube_enabled?
return render json: { error: "Republicazione YouTube con Premium Full" }, status: :forbidden
end
Recordings::PublishToYoutubeJob.perform_async(@recording.id)
render json: { queued: true, message: "Pubblicazione su YouTube in corso" }
end
private
def set_recording
team_ids = current_user.manageable_teams.map(&:id)
@recording = Recording.not_deleted
.includes(stream_session: :match)
.where(team_id: team_ids)
.find(params[:id])
end
def authorize_manage!
team = @recording.team
return if team.club.owned_by?(current_user)
return if current_user.manageable_teams.exists?(id: team.id)
render json: { error: "Non autorizzato" }, status: :forbidden
end
def recording_update_attrs
p = params.require(:recording).permit(:privacy_status, :title)
p = p.to_h
p[:privacy_status] = "unlisted" if p[:privacy_status] == "private"
if params.dig(:recording, :metadata).present?
p[:metadata] = @recording.metadata.merge(
params[:recording][:metadata].to_unsafe_h.stringify_keys
)
end
p
end
def recording_json(recording)
session = recording.stream_session
match = session.match
ent = recording.team.entitlements
{
id: recording.id,
session_id: session.id,
match_id: match.id,
title: recording.title_or_default,
opponent_name: match.opponent_name,
team_name: match.team.name,
team_id: recording.team_id,
status: recording.status,
status_label: recording.status_label,
privacy_status: recording.privacy_status,
ended_at: session.ended_at,
recorded_at: recording.recorded_at_or_fallback,
duration_secs: recording.duration_secs,
duration_label: recording.duration_label,
byte_size: recording.byte_size,
byte_size_label: recording.byte_size_label,
view_count: recording.view_count,
views_label: recording.views_label,
replay_url: recording.replay_url,
playback_url: recording.playback_stream_url,
thumbnail_url: recording.thumbnail_url,
download_enabled: ent.phone_download_enabled? && recording.storage_key.present?,
youtube_video_id: recording.youtube_video_id,
youtube_watch_url: recording.youtube_watch_url,
youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled? && recording.storage_key.present? && recording.youtube_video_id.blank?,
replay_source: recording.replay_source,
source_platform: recording.source_platform,
source_platform_label: recording.source_platform_label,
expires_at: recording.expires_at,
days_until_expiry: recording.days_until_expiry,
metadata: recording.metadata
}
end
end
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
@@ -4,7 +4,8 @@ module Api
before_action :set_session, except: :create
def create
match = Match.joins(:team).merge(current_user.teams).find(params[:match_id])
team_ids = current_user.streamable_teams.map(&:id)
match = Match.where(team_id: team_ids).find(params[:match_id])
session = Sessions::Create.new(user: current_user, match: match, params: session_params).call
render json: session_json(session), status: :created
end
@@ -28,6 +29,36 @@ module Api
render json: session_json(@session)
end
def resume
Sessions::Resume.new(@session).call
render json: session_json(@session)
end
def audio_mute
muted = params.key?(:muted) ? params[:muted] : true
Sessions::SetAudioMute.new(@session, muted: muted).call
render json: session_json(@session)
end
def score
Scoring::SyncState.new(session: @session, payload: score_sync_params).call
render json: session_json(@session, detail: true)
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
events = @session.stream_events.recent.limit(100)
render json: events.map { |e| event_json(e) }
@@ -43,8 +74,11 @@ module Api
current_bitrate: params[:current_bitrate],
target_bitrate: params[:target_bitrate],
fps: params[:fps],
thermal_state: sanitized_thermal_state,
last_seen_at: Time.current
)
Sessions::ApplyClientInfo.call(@session, params[:client]) if params[:client].present?
sync_publisher_when_streaming!(params[:fps].to_f)
SessionChannel.broadcast_message(@session, state.as_cable_payload)
head :no_content
end
@@ -66,6 +100,15 @@ module Api
}
end
def regia_link
access = Sessions::RegiaAccess.new(@session)
token = access.issue_token!
render json: {
regia_url: access.url(token),
expires_at: @session.regia_token_expires_at
}
end
def claim_pairing
token = params.require(:pairing_token)
digest = Digest::SHA256.hexdigest(token)
@@ -79,13 +122,38 @@ module Api
end
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!(
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
)
ready = params[:upload_mbps].to_f >= (@session.target_bitrate / 1_000_000.0 * 0.8)
render json: { ready: ready, target_upload_mbps: @session.target_bitrate / 1_000_000.0 }
target_upload = preset[: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
def min_quality
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
render json: session_json(@session)
rescue ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def youtube_stats
@@ -101,13 +169,32 @@ module Api
private
def set_session
@session = StreamSession.joins(match: :team)
.merge(current_user.teams)
team_ids = current_user.streamable_teams.map(&:id)
@session = StreamSession.joins(:match)
.where(matches: { team_id: team_ids })
.find(params[:id])
end
def sanitized_thermal_state
value = params[:thermal_state].to_s.downcase.presence
value if value.in?(%w[nominal fair serious critical])
end
def session_params
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps)
params.permit(
:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel,
client: %i[os client_os app_version app_build version build build_number
device_manufacturer manufacturer device_model model
os_version system_version carrier network_operator operator]
)
end
def score_sync_params
params.permit(
: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]
)
end
def session_json(session, detail: false)
@@ -118,13 +205,19 @@ module Api
platform: session.platform,
rtmp_ingest_url: session.rtmp_ingest_url,
hls_playback_url: session.hls_playback_url,
watch_page_url: session.watch_page_url,
stream_node: session.stream_node&.slug,
watch_page_url: session.matchlivetv_platform? ? session.watch_page_url : nil,
share_url: session.share_url,
youtube_watch_url: session.youtube_watch_url,
youtube_broadcast_id: session.youtube_broadcast_id,
youtube_ready: session.youtube_ready?,
privacy_status: session.privacy_status,
quality_preset: session.quality_preset,
min_quality_preset: session.min_quality_preset,
target_bitrate: session.target_bitrate,
started_at: session.started_at,
disconnection_count: session.disconnection_count
disconnection_count: session.disconnection_count,
audio_muted: session.audio_muted
}
if detail
data[:score] = session.score_state&.as_cable_payload
@@ -141,6 +234,19 @@ module Api
occurred_at: event.occurred_at
}
end
# L'app invia fps ogni ~10s; poll status.json per allineare stato sessione e relay YouTube.
def sync_publisher_when_streaming!(fps)
return if fps < 1
return if @session.terminal? || @session.paused?
return unless @session.platform == "youtube"
return unless @session.status.in?(%w[connecting live reconnecting])
Mediamtx::PublisherSync.new(@session).call
if @session.platform == "youtube" && @session.status.in?(%w[live connecting reconnecting])
Mediamtx::PublisherSync.schedule_youtube_pipeline!(@session, force: false)
end
end
end
end
end
@@ -1,31 +1,38 @@
module Api
module V1
class TeamsController < BaseController
include Api::UrlHelper
include BrandingAttachments
def index
teams = current_user.teams.includes(:matches)
teams = current_user.streamable_teams
render json: teams.map { |t| team_json(t) }
end
def show
team = current_user.teams.find(params[:id])
team = current_user.manageable_teams.find(params[:id])
render json: team_json(team, detail: true)
end
def create
if current_user.user_teams.where(role: "owner").count >= 1
if current_user.owned_clubs.exists?
return render json: {
error: "Puoi gestire una sola squadra. Usa il sito per inviti o contattaci."
error: "Registra o gestisci la società dal sito web per aggiungere squadre."
}, status: :unprocessable_entity
end
team = Team.create!(team_params)
UserTeam.create!(user: current_user, team: team, role: "owner")
Billing::AssignPlan.call(team: team, plan_slug: "free")
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")
ClubMembership.create!(user: current_user, club: club, role: "owner")
team = club.teams.create!(name: team_params[:name], sport_key: sport_key)
Billing::AssignPlan.call(club: club, plan_slug: "free")
render json: team_json(team), status: :created
end
def recordings
team = current_user.teams.find(params[:id])
team = current_user.streamable_teams.find { |t| t.id.to_s == params[:id].to_s }
raise ActiveRecord::RecordNotFound unless team
unless team.entitlements.can_access_recordings?
return render json: {
error: "Archivio gare disponibile con Premium Light o Full",
@@ -34,18 +41,24 @@ module Api
}, status: :forbidden
end
recs = team.recordings.ready.includes(stream_session: :match).order(created_at: :desc).limit(50)
recs = team.recordings.not_deleted
.where(status: %w[ready processing])
.where("expires_at IS NULL OR expires_at > ?", Time.current)
.includes(stream_session: :match)
.order(recorded_at: :desc, created_at: :desc)
.limit(50)
render json: recs.map { |r| recording_json(r) }
end
def update
team = current_user.teams.find(params[:id])
team = current_user.manageable_teams.find(params[:id])
team.update!(team_params)
attach_team_logo(team)
render json: team_json(team)
end
def add_member
team = current_user.teams.find(params[:id])
team = current_user.manageable_teams.find(params[:id])
team.entitlements.assert_can_invite!
member = User.find(params[:user_id])
UserTeam.find_or_create_by!(user: member, team: team) { |ut| ut.role = "member" }
@@ -53,7 +66,7 @@ module Api
end
def remove_member
team = current_user.teams.find(params[:id])
team = current_user.manageable_teams.find(params[:id])
ut = team.user_teams.find_by!(user_id: params[:user_id], role: "member")
ut.destroy!
head :no_content
@@ -62,17 +75,48 @@ module Api
private
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
def team_json(team, detail: false)
ent = team.entitlements
yt = Youtube::TeamStatus.new(team)
resolver = Youtube::CredentialResolver.new(team)
data = {
id: team.id,
name: team.name,
sport: team.sport,
logo_url: team.logo_url,
youtube_connected: team.youtube_credential.present?,
sport: team.sport_key,
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,
secondary_color: team.effective_secondary_color,
club_id: team.club_id,
club_name: team.club.name,
youtube_connected: yt.connected?,
youtube_selectable: yt.selectable?,
youtube_channel_title: yt.channel_title,
youtube_uses_platform_channel: yt.uses_platform_channel?,
youtube_platform_available: resolver.platform_available?,
youtube_team_channel_available: resolver.team_channel_available?,
youtube_team_channel_title: team.club.youtube_credential&.channel_title,
plan_slug: ent.plan.slug,
plan_name: ent.plan.name,
premium_active: ent.premium_active?,
@@ -83,16 +127,18 @@ module Api
staff_manage_url: ent.staff_manage_url,
max_staff: ent.max_staff,
max_staff_transmission: ent.max_staff_transmission,
max_staff_regia: ent.max_staff_regia,
staff_used: ent.staff_count,
staff_transmission_used: ent.staff_count_for("transmission"),
staff_regia_used: ent.staff_count_for("regia"),
concurrent_streams_used: ent.concurrent_streams_used,
concurrent_streams_limit: ent.concurrent_streams_limit,
recordings_enabled: ent.can_access_recordings?,
phone_download_enabled: ent.phone_download_enabled?,
youtube_enabled: ent.youtube_enabled?,
youtube_mode: ent.plan.youtube_mode
youtube_mode: ent.plan.youtube_mode,
staff_role: current_user.staff_role_for(team),
can_stream: current_user.can_stream_for?(team),
can_connect_youtube: team.club.owned_by?(current_user) && ent.premium_full? && ent.youtube_enabled?,
custom_cover_enabled: ent.can_use_custom_cover?
}
if detail
data[:members] = team.user_teams.includes(:user).where.not(role: "owner").map do |ut|
@@ -105,16 +151,38 @@ module Api
def recording_json(recording)
session = recording.stream_session
match = session.match
ent = recording.team.entitlements
{
id: recording.id,
session_id: session.id,
match_id: match.id,
title: recording.title_or_default,
opponent_name: match.opponent_name,
team_name: match.team.name,
status: recording.status,
status_label: recording.status_label,
privacy_status: recording.privacy_status,
ended_at: session.ended_at,
recorded_at: recording.recorded_at_or_fallback,
duration_secs: recording.duration_secs,
duration_label: recording.duration_label,
byte_size: recording.byte_size,
byte_size_label: recording.byte_size_label,
replay_url: recording.replay_url,
download_url: recording.replay_url,
expires_at: recording.expires_at
playback_url: recording.playback_stream_url,
thumbnail_url: recording.thumbnail_url,
download_enabled: ent.phone_download_enabled? && recording.storage_key.present?,
view_count: recording.view_count,
views_label: recording.views_label,
youtube_video_id: recording.youtube_video_id,
youtube_watch_url: recording.youtube_watch_url,
youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled? && recording.storage_key.present? && recording.youtube_video_id.blank?,
replay_source: recording.replay_source,
source_platform: recording.source_platform,
source_platform_label: recording.source_platform_label,
expires_at: recording.expires_at,
days_until_expiry: recording.days_until_expiry,
metadata: recording.metadata
}
end
end
@@ -2,24 +2,21 @@ module Api
module V1
class YoutubeController < BaseController
def authorize
team = current_user.teams.find(params[:team_id])
team.entitlements.assert_can_connect_youtube!
url = Youtube::OauthUrl.build(team_id: team.id, redirect_uri: ENV.fetch("YOUTUBE_REDIRECT_URI"))
render json: { authorization_url: url }
end
team = current_user.streamable_teams.find { |t| t.id.to_s == params[:team_id].to_s }
raise ActiveRecord::RecordNotFound unless team
unless team.club.owned_by?(current_user)
return render json: { error: "Solo il titolare della società può collegare YouTube" }, status: :forbidden
end
def callback
team = current_user.teams.find(params[:state])
tokens = Youtube::OauthExchange.call(params[:code])
cred = team.youtube_credential || team.build_youtube_credential
cred.update!(
access_token: tokens[:access_token],
refresh_token: tokens[:refresh_token],
expires_at: Time.current + tokens[:expires_in].seconds,
channel_id: tokens[:channel_id],
channel_title: tokens[:channel_title]
team.entitlements.assert_can_connect_youtube!
return_app = ActiveModel::Type::Boolean.new.cast(params[:return_app])
state = Youtube::OauthState.for_team(
team_id: team.id,
user_id: current_user.id,
return_app: return_app
)
redirect_to "/admin/teams/#{team.id}?youtube=connected"
url = Youtube::OauthUrl.build(state: state, redirect_uri: ENV.fetch("YOUTUBE_REDIRECT_URI"))
render json: { authorization_url: url, return_app: return_app }
end
end
end
@@ -1,17 +1,25 @@
class ApplicationController < ActionController::API
include ActionController::HttpAuthentication::Token::ControllerMethods
before_action :set_api_locale
before_action :authenticate_request!
attr_reader :current_user
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!
token = bearer_token
payload = JsonWebToken.decode(token)
@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
def bearer_token
@@ -0,0 +1,16 @@
module Api
module CoverJson
extend ActiveSupport::Concern
private
def match_cover_json(match)
resolver = Streams::CoverResolver.new(match)
{
effective_cover_url: api_absolute_url(resolver.effective_cover_url),
cover_source: resolver.cover_source,
custom_cover_enabled: match.team.entitlements.can_use_custom_cover?
}
end
end
end
@@ -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,58 @@
module BrandingAttachments
extend ActiveSupport::Concern
private
def attach_branding_logo(record)
file = params.dig(:branding, :logo_file)
record.logo_file.attach(file) if file.present?
end
def attach_cover_image(record, entitlements:)
remove_flag = params[:remove_cover].to_s == "1" || params.dig(:match, :remove_cover).to_s == "1"
if remove_flag
record.cover_image.purge if record.cover_image.attached?
record.cover_slate.purge if record.cover_slate.attached?
return
end
file = params.dig(:branding, :cover_image) || params[:cover_image]
return if file.blank?
entitlements.assert_can_upload_cover!
validate_cover_file!(file)
record.cover_slate.purge if record.cover_slate.attached?
record.cover_image.attach(file)
end
def validate_cover_file!(file)
allowed_types = Coverable::COVER_IMAGE_TYPES
# Rileva MIME dal contenuto (magic bytes) ignorando il declared_type del client.
io = file.respond_to?(:tempfile) ? file.tempfile : file
io.rewind if io.respond_to?(:rewind)
detected = Marcel::MimeType.for(io, name: file.respond_to?(:original_filename) ? file.original_filename : nil)
io.rewind if io.respond_to?(:rewind)
unless detected.in?(allowed_types)
raise CoverUploadError, I18n.t("coverable.errors.invalid_type")
end
byte_size = file.respond_to?(:size) ? file.size : File.size(file.path)
if byte_size > Coverable::MAX_COVER_BYTES
raise CoverUploadError, I18n.t("coverable.errors.too_large")
end
end
class CoverUploadError < StandardError; end
def enqueue_cover_slate_generation(record)
GenerateCoverSlateJob.enqueue_for(record) if record.cover_image.attached?
end
def normalize_hex_color(value, fallback)
v = value.to_s.strip
v = "##{v}" if v.match?(/\A[0-9A-Fa-f]{6}\z/)
v.match?(Brandable::HEX_COLOR) ? v : fallback
end
end
@@ -0,0 +1,43 @@
module MediamtxPlayback
extend ActiveSupport::Concern
private
def mediamtx_paths_index
mediamtx_paths_index_for_url(MatchLiveTv.mediamtx_api_url)
end
def mediamtx_paths_index_for(session)
mediamtx_paths_index_for_url(session.mediamtx_api_base_url)
end
def mediamtx_paths_index_for_url(api_url)
@mediamtx_paths_by_origin ||= {}
@mediamtx_paths_by_origin[api_url] ||= Mediamtx::Client.new(base_url: api_url).list_paths.index_by { |i| i["name"] }
end
def mediamtx_path_info(session, path_name: mediamtx_playback_path_name(session))
mediamtx_paths_index_for(session)[path_name]
end
def mediamtx_playback_path_name(session)
session.effective_hls_path_name
end
# Player HLS: path grezzo MediaMTX (live/match_{uuid}).
def mediamtx_stream_playable?(session)
return false if session.terminal?
mediamtx_path_has_output?(session)
end
def mediamtx_path_has_output?(session)
info = mediamtx_path_info(session)
info && (info["ready"] || info["available"] || info["online"])
end
def mediamtx_publisher_online?(session)
info = mediamtx_paths_index_for(session)[session.mediamtx_path_name]
info && info["online"]
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
+93 -21
View File
@@ -1,38 +1,110 @@
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
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?
upstream = "#{MatchLiveTv.mediamtx_hls_url}/#{upstream_path}"
origin = hls_origin_for(upstream_path)
upstream = "#{origin}/#{upstream_path}"
upstream = "#{upstream}?#{request.query_string}" if request.query_string.present?
cookie = request.headers["Cookie"].presence || "cookieCheck=1"
response = hls_conn.get(upstream) { |req| req.headers["Cookie"] = cookie }
if response.status == 302
location = response.headers["location"].to_s
follow_url = if location.start_with?("http")
location
else
"#{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 }
5.times do
response = hls_conn.get(upstream) { |req| req.headers["Cookie"] = cookie }
if response.status.in?([301, 302, 307, 308])
location = response.headers["location"].to_s
cookie = cookie_from_set_header(response.headers["set-cookie"]).presence || cookie
upstream = resolve_upstream_url(location, origin)
next
end
return render_proxied_response(response)
end
response.headers.each do |key, value|
next if %w[transfer-encoding connection].include?(key.downcase)
headers[key] = value
end
render body: response.body, status: response.status
head :bad_gateway
rescue Faraday::Error => e
Rails.logger.warn("[HlsProxy] #{upstream_path}: #{e.message}")
head :bad_gateway
end
private
def hls_origin_for(upstream_path)
session_id = upstream_path[/\Alive\/match_([0-9a-f-]{36})/i, 1]
if session_id
session = StreamSession.find_by(id: session_id)
node_hls = session&.stream_node&.internal_hls_url.presence
return node_hls.sub(%r{/$}, "") if node_hls.present?
end
MatchLiveTv.mediamtx_hls_url.sub(%r{/$}, "")
end
def resolve_upstream_url(location, origin)
return location if location.start_with?("http://", "https://")
base = origin.to_s.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
@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
@@ -0,0 +1,120 @@
module Public
class ClubBillingController < WebBaseController
before_action :require_login!
before_action :set_club
before_action :require_club_owner_for_club!
def profile
end
def update_profile
@club.assign_attributes(billing_profile_params)
if @club.save(context: :billing_profile)
if premium_checkout_return_params.present? && @club.billing_profile_complete?
if @club.active_billing_quote.present?
redirect_to public_club_billing_path(@club), notice: t("flash.club_billing.profile_updated")
else
redirect_to public_club_checkout_path(
@club,
plan: premium_checkout_return_params[:plan],
interval: premium_checkout_return_params[:interval]
), notice: t("flash.club_billing.profile_saved_proceed_payment")
end
else
redirect_to public_club_billing_path(@club), notice: t("flash.club_billing.profile_updated")
end
else
flash.now[:alert] = @club.errors.full_messages.join(", ")
render :profile, status: :unprocessable_entity
end
end
def cancel_subscription
unless MatchLiveTv.stripe_enabled?
redirect_to public_club_billing_path(@club), alert: t("flash.club_billing.stripe_not_configured")
return
end
Billing::Stripe::CancelSubscription.call(club: @club)
sub = @club.subscription.reload
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),
notice: t("flash.club_billing.subscription_canceled", date: date)
rescue ArgumentError, RuntimeError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ::Stripe::StripeError => e
redirect_to public_club_billing_path(@club), alert: t("flash.club_billing.stripe_error", message: e.message)
end
def request_bank_transfer
if @club.subscription&.admin_comped?
redirect_to public_club_billing_path(@club), alert: t("flash.clubs.comped_change_denied")
return
end
unless @club.billing_profile_complete?
redirect_to public_club_billing_profile_path(@club, plan: params[:plan], interval: params[:interval]),
alert: t("flash.clubs.complete_billing_first")
return
end
Billing::RequestBankTransfer.call(
club: @club,
user: current_user,
plan_slug: params[:plan],
interval: params[:interval]
)
redirect_to public_club_billing_path(@club), notice: t("flash.club_billing.bank_transfer_requested")
rescue Billing::RequestBankTransfer::Error, ArgumentError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
def download_invoice
invoice = @club.billing_invoices.find(params[:invoice_id])
unless invoice.pdf.attached?
redirect_to public_club_billing_path(@club), alert: t("flash.club_billing.invoice_pdf_unavailable")
return
end
redirect_to rails_blob_path(invoice.pdf, disposition: "attachment"), allow_other_host: true
end
private
def set_club
@club = current_user.owned_clubs.find(params[:id])
end
def require_club_owner_for_club!
require_club_owner!(@club)
end
def premium_checkout_return_params
plan = params[:plan].presence_in(%w[premium_light premium_full])
return nil if plan.blank?
{
plan: plan,
interval: params[:interval].presence || Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL
}
end
def billing_profile_params
params.require(:club).permit(
:billing_entity_type,
:billing_legal_name,
:billing_vat_number,
:billing_fiscal_code,
:billing_email,
:billing_phone,
:billing_address_line,
:billing_city,
:billing_province,
:billing_postal_code,
:billing_country,
:billing_recipient_code,
:billing_pec
)
end
end
end
@@ -0,0 +1,85 @@
module Public
class ClubMatchesController < WebBaseController
before_action :require_login!
before_action :set_club
before_action :load_schedulable_teams
before_action :require_any_schedulable_team!
def new
@match = default_match_for_form
@selected_team = resolve_selected_team
end
def create
@selected_team = @schedulable_teams.find { |t| t.id.to_s == params[:team_id].to_s }
unless @selected_team
@match = default_match_for_form
flash.now[:alert] = t("flash.club_matches.invalid_team")
return render :new, status: :unprocessable_entity
end
@match = @selected_team.matches.create!(
match_params.merge(sport: @selected_team.sport, sets_to_win: 3)
)
redirect_to public_live_index_path(club_id: @club.id),
notice: t("flash.club_matches.scheduled", team: @selected_team.name, opponent: @match.opponent_name)
rescue ActiveRecord::RecordInvalid => e
@match = @selected_team.matches.build(match_params)
flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity
end
private
def set_club
@club = Club.find(params[:club_id])
end
def load_schedulable_teams
@schedulable_teams = current_user.schedulable_teams_for(@club)
@club_admin = @club.owned_by?(current_user)
end
def require_any_schedulable_team!
return if @schedulable_teams.any?
redirect_to public_live_index_path(club_id: @club.id),
alert: t("flash.club_matches.no_schedulable_teams")
end
def resolve_selected_team
if params[:team_id].present?
@schedulable_teams.find { |t| t.id.to_s == params[:team_id].to_s }
end || @schedulable_teams.first
end
def default_match_for_form
team = resolve_selected_team
Match.new(
sport: team&.sport || @club.sport,
sets_to_win: 3,
scheduled_at: default_scheduled_at
)
end
def match_params
p = params.require(:match).permit(:opponent_name, :location, :scheduled_at)
p[:scheduled_at] = parse_scheduled_at(p[:scheduled_at]) if p[:scheduled_at].present?
p
end
def parse_scheduled_at(value)
raw = value.to_s.strip
return nil if raw.blank?
Time.zone.strptime(raw, "%Y-%m-%dT%H:%M")
rescue ArgumentError
Time.zone.parse(raw)
end
def default_scheduled_at
t = Time.current + 2.hours
Time.zone.local(t.year, t.month, t.day, t.hour, 0, 0)
end
end
end
@@ -0,0 +1,28 @@
module Public
class ClubRecordingsController < WebBaseController
include Recordings::ClubArchiveActions
helper RecordingsArchiveHelper
before_action :require_login!
before_action :require_club_recording_manager!
private
def archive_namespace
:public
end
def authorize_recording_management!
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
@@ -0,0 +1,268 @@
module Public
class ClubsController < WebBaseController
include BrandingAttachments
before_action :require_login!
before_action :set_club, only: %i[show edit update billing checkout portal youtube_connect youtube_disconnect]
before_action :require_billing_profile_for_premium_checkout!, only: :checkout
def new
redirect_to public_club_path(current_user.primary_club) if current_user.primary_club
end
def create
if current_user.owned_clubs.exists?
redirect_to public_club_path(current_user.primary_club), alert: t("flash.clubs.already_registered")
return
end
club = Club.new(club_params)
attach_branding_logo(club)
club.save!
ClubMembership.create!(user: current_user, club: club, role: "owner")
first_team_name = params.dig(:first_team, :name).presence || t("club.new.default_first_team_name")
first_team = club.teams.create!(
name: first_team_name,
sport: club.sport
)
desired_plan = params[:plan].presence_in(%w[free premium_light premium_full]) || "free"
Billing::AssignPlan.call(club: club, plan_slug: "free")
Teams::StaffAssignment.designate_club_owner!(team: first_team, user: current_user)
if desired_plan.in?(%w[premium_light premium_full])
interval = Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL
if MatchLiveTv.stripe_enabled?
unless club.billing_profile_complete?
redirect_to public_club_billing_profile_path(club, plan: desired_plan, interval: interval),
alert: t("flash.clubs.complete_billing_first")
return
end
redirect_to public_club_checkout_path(club, plan: desired_plan, interval: interval)
else
redirect_to public_club_billing_path(club), notice: t("flash.clubs.created_complete_subscription")
end
else
redirect_to public_club_path(club), notice: t("flash.clubs.created")
end
rescue ActiveRecord::RecordInvalid => e
flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity
end
def show
require_club_owner!(@club)
apply_checkout_flash!
@entitlements_team = @club.teams.first
@entitlements = @entitlements_team&.entitlements
@teams = @club.teams.order(:name)
@replay_stats = Recordings::ClubStats.new(@club).call if @entitlements&.can_access_recordings?
end
def edit
require_club_owner!(@club)
@entitlements = @club.teams.first&.entitlements
end
def update
require_club_owner!(@club)
@entitlements = @club.teams.first!.entitlements
@club.assign_attributes(club_params)
attach_branding_logo(@club)
attach_cover_image(@club, entitlements: @entitlements)
@club.save!
enqueue_cover_slate_generation(@club)
redirect_to public_club_path(@club), notice: t("flash.clubs.updated")
rescue ActiveRecord::RecordInvalid => e
@entitlements = @club.teams.first&.entitlements
flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity
rescue Teams::EntitlementError, BrandingAttachments::CoverUploadError => e
@entitlements = @club.teams.first&.entitlements
flash.now[:alert] = e.message
render :edit, status: :unprocessable_entity
end
def billing
require_club_owner!(@club)
sync_checkout_return!
finalize_subscription_if_due!
@subscription = @club.subscription&.reload
apply_checkout_flash!
@team = @club.teams.first!
@entitlements = @team.entitlements
@plans = Plan.ordered
@payments = @club.billing_payments.recent.includes(:invoice).limit(50)
@quote = @club.active_billing_quote
@pending_transfer = @club.billing_transfer_orders.awaiting_payment.first
end
def checkout
require_club_owner!(@club)
if @club.subscription&.admin_comped?
redirect_to public_club_billing_path(@club),
alert: t("flash.clubs.comped_change_denied")
return
end
if @club.active_billing_quote.present?
redirect_to public_club_billing_path(@club),
alert: t("flash.club_billing.quote_checkout_denied")
return
end
unless MatchLiveTv.stripe_enabled?
redirect_to public_club_billing_path(@club), alert: t("flash.clubs.stripe_not_configured")
return
end
plan_slug = params[:plan].presence_in(%w[premium_light premium_full]) || "premium_light"
interval = checkout_interval_param
target_plan = Plan[plan_slug]
sub = @club.subscription
if sub&.stripe_subscription_id.present? && sub.active? &&
sub.plan.slug == plan_slug && sub.billing_interval == interval && !sub.plan_change_pending?
redirect_to public_club_billing_path(@club),
notice: t("flash.clubs.already_on_plan", plan: target_plan.name, price: Billing::Stripe::PriceCatalog.label(plan_slug: plan_slug, interval: interval))
return
end
if sub&.plan_change_pending? && sub.pending_plan&.slug == plan_slug &&
sub.pending_billing_interval == interval
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),
notice: t("flash.clubs.change_already_scheduled", plan: target_plan.name, date: when_label)
return
end
if sub&.stripe_subscription_id.present? && sub.active?
result = Billing::Stripe::ChangePlan.call(club: @club, plan_slug: plan_slug, interval: interval)
redirect_to public_club_billing_path(@club),
notice: Billing::Stripe::PlanChangeMessages.flash_notice(
result,
current_plan: sub.plan
)
else
url = Billing::Stripe::CheckoutSession.new(
club: @club, user: current_user, plan_slug: plan_slug, interval: interval
).url
redirect_to url, allow_other_host: true
end
rescue ArgumentError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ::Stripe::StripeError => e
Rails.logger.warn("[Stripe checkout] #{e.message}")
redirect_to public_club_billing_path(@club), alert: t("flash.clubs.stripe_error", message: e.message)
end
def youtube_connect
require_club_owner!(@club)
entitlements = @club.teams.first!.entitlements
entitlements.assert_can_connect_youtube!
if ENV["YOUTUBE_CLIENT_ID"].blank?
redirect_to public_club_path(@club), alert: t("flash.clubs.youtube_oauth_not_configured")
return
end
return_app = params[:return_app] == "1"
state = Youtube::OauthState.for_club(
club_id: @club.id,
user_id: current_user.id,
return_app: return_app
)
redirect_to Youtube::OauthUrl.build(state: state), allow_other_host: true
rescue Teams::EntitlementError => e
redirect_to public_club_path(@club), alert: e.message
end
def youtube_disconnect
require_club_owner!(@club)
@club.youtube_credential&.destroy!
redirect_to public_club_path(@club), notice: t("flash.clubs.youtube_disconnected")
end
def portal
require_club_owner!(@club)
unless MatchLiveTv.stripe_enabled?
redirect_to public_club_billing_path(@club), alert: t("flash.clubs.portal_not_configured")
return
end
url = Billing::Stripe::CheckoutSession.new(club: @club, user: current_user).portal_url
redirect_to url, allow_other_host: true
end
private
def set_club
@club = current_user.owned_clubs.find(params[:id])
end
def club_params
p = params.require(:club).permit(
:name, :sport, :logo_url, :primary_color, :secondary_color,
:billing_entity_type, :billing_legal_name, :billing_vat_number, :billing_fiscal_code,
:billing_email, :billing_phone, :billing_address_line, :billing_city, :billing_province,
:billing_postal_code, :billing_country, :billing_recipient_code, :billing_pec
)
p[:primary_color] = normalize_hex_color(p[:primary_color], "#e53935")
p[:secondary_color] = normalize_hex_color(p[:secondary_color], "#ffffff")
p[:sport] = Sports::Catalog.normalize_key(p[:sport]) if p[:sport].present?
p
end
def require_billing_profile_for_premium_checkout!
require_club_owner!(@club)
plan_slug = params[:plan].presence_in(%w[premium_light premium_full])
return if plan_slug.blank?
return if @club.billing_profile_complete?
redirect_to public_club_billing_profile_path(
@club,
plan: plan_slug,
interval: params[:interval].presence
), alert: t("flash.clubs.complete_billing_first")
end
def checkout_interval_param
Billing::Stripe::PriceCatalog.normalize_interval(params[:interval])
rescue ArgumentError
Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL
end
def finalize_subscription_if_due!
return unless MatchLiveTv.stripe_enabled?
Billing::Stripe::FinalizeSubscription.call(club: @club)
rescue ::Stripe::StripeError => e
Rails.logger.warn("[Stripe finalize subscription] #{e.message}")
end
def sync_checkout_return!
return unless params[:checkout] == "success"
return unless MatchLiveTv.stripe_enabled?
Billing::Stripe::CheckoutSync.call(
club: @club,
session_id: params[:session_id].presence
)
rescue ::Stripe::StripeError => e
Rails.logger.warn("[Stripe checkout sync] #{e.message}")
end
def apply_checkout_flash!
case params[:checkout]
when "success"
plan_name = @subscription&.plan&.name || "Premium"
flash.now[:notice] = t("flash.clubs.checkout_success", plan: plan_name)
when "canceled"
flash.now[:alert] = t("flash.clubs.checkout_canceled")
end
end
end
end
@@ -0,0 +1,46 @@
module Public
class ContactsController < WebBaseController
def show
@inquiry = empty_inquiry
end
def create
result = Contacts::SubmitInquiry.call(params: inquiry_params, ip: request.remote_ip)
if result.ok
redirect_to public_contatti_path, notice: t("flash.contacts.sent")
return
end
@inquiry = inquiry_params
flash.now[:alert] = t("flash.contacts.#{result.error}")
render :show, status: result.error == :throttled ? :too_many_requests : :unprocessable_entity
end
private
def inquiry_params
{
name: params[:name].to_s,
email: params[:email].to_s,
club_name: params[:club_name].to_s,
topic: params[:topic].to_s,
message: params[:message].to_s,
website: params[:website].to_s,
accept_privacy: params[:accept_privacy].to_s
}
end
def empty_inquiry
{
name: "",
email: "",
club_name: "",
topic: "commercial",
message: "",
website: "",
accept_privacy: ""
}
end
end
end
@@ -4,24 +4,24 @@ module Public
@token = params[:token]
@invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(@token.to_s))
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
def accept
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 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
end
invitation.accept!(current_user)
redirect_to public_team_dashboard_path(invitation.team), notice: "Sei entrato nella squadra!"
redirect_to public_team_details_path(invitation.team), notice: t("flash.invitations.joined_team")
else
session[:pending_invite_token] = params[:token]
redirect_to public_signup_path, notice: "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
@@ -1,47 +1,87 @@
module Public
class LiveController < SiteBaseController
include MediamtxPlayback
layout "marketing_live"
ACTIVE_STATUSES = %w[live connecting reconnecting].freeze
def index
@query = params[:q].to_s.strip
@sessions = StreamSession
@club = Club.find_by(id: params[:club_id]) if params[:club_id].present?
team_ids = @club&.teams&.pluck(:id)
sessions = StreamSession
.broadcasting
.includes(:score_state, match: :team)
.publicly_listed
.where(platform: "matchlivetv")
.includes(:score_state, match: { team: :club })
sessions = sessions.joins(:match).where(matches: { team_id: team_ids }) if team_ids.present?
@sessions = sessions
.search_by_team_or_opponent(@query)
.order(Arel.sql("started_at DESC NULLS LAST"), created_at: :desc)
# Solo dirette già in onda: le partite «programmate» restano visibili anche se c’è sessione idle.
broadcasting_match_ids = StreamSession.broadcasting.select(:match_id)
upcoming = Match.scheduled_for_live.includes(team: :club)
upcoming = upcoming.where(team_id: team_ids) if team_ids.present?
@upcoming_matches = upcoming
.search_teams_or_opponents(@query)
.where.not(id: broadcasting_match_ids)
.order(scheduled_at: :asc)
.limit(50)
@online_paths = Mediamtx::Client.new.online_path_names
return unless logged_in? && @club
@schedulable_teams = current_user.schedulable_teams_for(@club)
@can_schedule_match = @schedulable_teams.any?
end
def show
@session = StreamSession.includes(match: :team).find(params[:id])
@session = StreamSession.includes(match: { team: :club }).find(params[:id])
@session = Mediamtx::PublisherSync.new(@session).call unless @session.terminal?
@match = @session.match
@stream_closed = @session.terminal?
@on_air = !@stream_closed && mediamtx_online?(@session)
@on_air = !@stream_closed && (
mediamtx_stream_playable?(@session) ||
@session.status.in?(%w[connecting live reconnecting paused])
)
@publisher_online = !@stream_closed && mediamtx_publisher_online?(@session)
@cover_poster_url = Streams::CoverResolver.new(@match).effective_cover_url
end
def status
session = StreamSession.includes(match: :team).find(params[:id])
# Mai cache: il tabellone deve restare allineato con l'app (poll ogni ~1,5s).
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
response.headers["Pragma"] = "no-cache"
session = StreamSession.includes(match: { team: :club }).find(params[:id])
session = Mediamtx::PublisherSync.new(session).call unless session.terminal?
closed = session.terminal?
publisher_online = !closed && mediamtx_publisher_online?(session)
playable = !closed && mediamtx_stream_playable?(session)
render json: {
status: session.status,
paused: session.paused?,
stream_closed: closed,
live: !closed && (session.live? || mediamtx_online?(session)),
on_air: !closed && mediamtx_online?(session),
live: !closed && (
session.live? || session.paused? || session.connecting? || session.reconnecting? ||
mediamtx_publisher_online?(session)
),
on_air: playable || (!closed && session.status.in?(%w[connecting live reconnecting paused])),
publisher_online: publisher_online,
awaiting_signal: !closed && !mediamtx_path_has_output?(session),
showing_cover: !closed && mediamtx_path_has_output?(session) &&
(session.paused? || !mediamtx_publisher_online?(session)),
ended_at: session.ended_at,
home_name: session.match.team.name,
away_name: session.match.opponent_name,
score: session.score_state&.as_cable_payload
}
end
private
def mediamtx_online?(session)
@online_paths_cache ||= Mediamtx::Client.new.online_path_names
@online_paths_cache.include?(session.mediamtx_path_name)
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
@@ -0,0 +1,100 @@
module Public
class MatchesController < WebBaseController
before_action :require_login!
before_action :set_team
before_action :require_can_schedule!
before_action :set_match, only: %i[edit update destroy]
def index
@matches = @team.matches
.includes(:stream_sessions)
.order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :desc)
@can_manage = @team.club.owned_by?(current_user)
end
def new
@match = @team.matches.build(
sport: @team.sport,
sets_to_win: 3,
scheduled_at: default_scheduled_at
)
end
def create
@match = @team.matches.create!(
match_params.merge(sport: @team.sport, sets_to_win: match_params[:sets_to_win].presence || 3)
)
redirect_to public_team_matches_path(@team),
notice: t("flash.matches.scheduled", opponent: @match.opponent_name)
rescue ActiveRecord::RecordInvalid => e
@match = @team.matches.build(match_params)
flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity
end
def edit
end
def update
@match.update!(match_params)
redirect_to public_team_matches_path(@team), notice: t("flash.matches.updated")
rescue ActiveRecord::RecordInvalid => e
flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity
end
def destroy
active = active_session_for(@match)
if active&.resumable?
redirect_to public_team_matches_path(@team),
alert: t("flash.matches.close_live_before_delete")
return
end
@match.destroy!
redirect_to public_team_matches_path(@team), notice: t("flash.matches.deleted")
end
private
def set_team
@team = current_user.manageable_teams.find(params[:team_id])
@club = @team.club
end
def require_can_schedule!
return if current_user.can_schedule_for?(@team)
redirect_to public_team_details_path(@team),
alert: t("flash.matches.schedule_denied")
end
def set_match
@match = @team.matches.find(params[:id])
end
def match_params
p = params.require(:match).permit(:opponent_name, :location, :scheduled_at, :sport, :sets_to_win)
p[:scheduled_at] = parse_scheduled_at(p[:scheduled_at]) if p[:scheduled_at].present?
p
end
def parse_scheduled_at(value)
raw = value.to_s.strip
return nil if raw.blank?
Time.zone.strptime(raw, "%Y-%m-%dT%H:%M")
rescue ArgumentError
Time.zone.parse(raw)
end
def default_scheduled_at
t = Time.current + 2.hours
Time.zone.local(t.year, t.month, t.day, t.hour, 0, 0)
end
def active_session_for(match)
match.active_stream_session
end
end
end
@@ -8,6 +8,11 @@ module Public
def pricing
@plans = Plan.ordered
load_club_billing_context_for_pricing
end
def support
@app_store_review_chrome = true
end
def privacy
@@ -15,5 +20,27 @@ module Public
def terms
end
def cookies
end
def faq
end
def pallavolo
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
@@ -0,0 +1,54 @@
module Public
class PasswordResetsController < WebBaseController
def new
end
def create
Users::RequestPasswordReset.call(email: params[:email])
redirect_to public_login_path,
notice: t("flash.password_resets.email_sent")
end
def edit
@user = User.find_by_password_reset_token(params[:token])
if @user.nil? || @user.password_reset_expired?
redirect_to public_password_forgot_path,
alert: t("flash.password_resets.invalid_or_expired_link")
return
end
@token = params[:token]
end
def update
@user = User.find_by_password_reset_token(params[:token])
if @user.nil? || @user.password_reset_expired?
redirect_to public_password_forgot_path,
alert: t("flash.password_resets.invalid_or_expired_link")
return
end
if params[:password] != params[:password_confirmation]
flash.now[:alert] = t("flash.password_resets.password_mismatch")
@token = params[:token]
return render :edit, status: :unprocessable_entity
end
if (code = PasswordComplexity.violation(params[:password]))
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]
return render :edit, status: :unprocessable_entity
end
@user.update!(password: params[:password])
@user.clear_password_reset!
redirect_to public_login_path, notice: t("flash.password_resets.password_updated")
end
end
end
@@ -0,0 +1,115 @@
module Public
class RegiaController < SiteBaseController
include Public::LiveHelper
include Public::RegiaHelper
include MediamtxPlayback
layout "regia"
protect_from_forgery with: :null_session
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop audio_mute min_quality]
before_action :set_session_from_token
def show
@match = @session.match
@team = @match.team
@score = @session.score_state || Scoring::Engine.ensure_score_for(@session)
@rules = Scoring::Rules.from_match(@match)
@stream_closed = @session.terminal?
@on_air = !@stream_closed && (
mediamtx_stream_playable?(@session) ||
@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
def status
render json: status_payload
end
def score
cmd = params[:cmd].presence || params[:score_action].presence
result = Scoring::ApplyAction.new(session: @session, action: cmd).call
@session.reload
render json: status_payload.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 pause
Sessions::Pause.new(@session).call
render json: status_payload
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
Sessions::Stop.new(@session).call
render json: status_payload
end
def audio_mute
muted = params.key?(:muted) ? params[:muted] : !@session.audio_muted
Sessions::SetAudioMute.new(@session, muted: muted).call
render json: status_payload
end
def min_quality
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
render json: status_payload
rescue ArgumentError => e
render json: status_payload.merge(error: e.message), status: :unprocessable_entity
end
private
def camera_telemetry_payload(publisher_online)
cam = @session.device_states.find_by(device_role: "camera")
(cam&.as_regia_payload || {}).merge(publisher_online: publisher_online)
end
def set_session_from_token
@session = Sessions::RegiaAccess.find_session_by_token(params[:token])
return if @session
head :not_found
end
def status_payload
score = @session.score_state
closed = @session.terminal?
playable = !closed && mediamtx_stream_playable?(@session)
publisher_online = !closed && mediamtx_publisher_online?(@session)
{
status: @session.status,
paused: @session.paused?,
audio_muted: @session.audio_muted,
stream_closed: closed,
live: !closed && (@session.live? || @session.paused? || publisher_online),
on_air: playable || (!closed && @session.status.in?(%w[connecting live reconnecting paused])),
publisher_online: publisher_online,
ended_at: @session.ended_at,
home_name: @session.match.team.name,
away_name: @session.match.opponent_name,
points_target: Scoring::Rules.points_target_for_match(@session.match, current_set: score&.current_set || 1),
score: score&.as_cable_payload,
min_quality_preset: @session.min_quality_preset,
min_quality_options: Sessions::SelectQuality.allowed_min_ids(@session.quality_preset),
telemetry: camera_telemetry_payload(publisher_online)
}
end
end
end
@@ -1,11 +1,17 @@
module Public
class RegistrationsController < WebBaseController
def new
redirect_to public_team_dashboard_path(current_user.teams.first) if logged_in? && current_user.teams.any?
redirect_to public_club_path(current_user.primary_club) if logged_in? && current_user.primary_club
@user = User.new
end
def create
unless params[:accept_terms] == "1"
@user = User.new(user_params.merge(role: "coach"))
flash.now[:alert] = t("flash.registrations.accept_terms_required")
return render :new, status: :unprocessable_entity
end
@user = User.new(user_params.merge(role: "coach"))
if @user.save
session[:user_id] = @user.id
@@ -14,10 +20,10 @@ module Public
invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(token))
if invitation && invitation.email.downcase == @user.email.downcase
invitation.accept!(@user)
return redirect_to public_team_dashboard_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
redirect_to new_public_team_path, notice: "Account creato. Ora crea la tua squadra."
redirect_to public_new_club_path, notice: t("flash.registrations.account_created")
else
flash.now[:alert] = @user.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity
@@ -1,15 +1,117 @@
module Public
class ReplayController < SiteBaseController
include Public::LiveHelper
layout "marketing_live"
def show
@session = StreamSession.includes(match: :team).find(params[:id])
@recording = Recording.ready.find_by(stream_session: @session)
@match = @session.match
before_action :set_session_and_recording, only: %i[show stream thumbnail download]
before_action :authorize_replay_access!, only: %i[show stream thumbnail download]
unless @recording
redirect_to public_live_path(@session), alert: "Replay non disponibile"
def index
@query = params[:q].to_s.strip
@recordings = Recording.ready.publicly_listed
.includes(stream_session: { match: { team: :club } })
.search_replays(@query)
.order(recorded_at: :desc, created_at: :desc)
.limit(100)
if params[:club_id].present?
@recordings = @recordings.joins(:team).where(teams: { club_id: params[:club_id] })
end
if params[:team_id].present?
@recordings = @recordings.where(team_id: params[:team_id])
end
@clubs = Club.joins(teams: :recordings)
.merge(Recording.ready.publicly_listed)
.distinct
.order(:name)
@filter_club = @clubs.find { |c| c.id.to_s == params[:club_id].to_s } if params[:club_id].present?
end
def show
@match = @session.match
end
def stream
Recordings::IncrementViews.new(@recording).call
serve_video(disposition: "inline")
end
def thumbnail
serve_thumbnail
end
def download
unless download_allowed?
redirect_to public_replay_path(@session), alert: t("flash.replay.download_unavailable")
return
end
serve_video(disposition: "attachment")
end
private
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(
key: @recording.storage_key,
content_type: "video/mp4",
disposition: disposition,
filename: disposition == "attachment" ? download_filename : nil
).call(self)
rescue ArgumentError
head :not_found
end
def serve_thumbnail
key = @recording.thumbnail_storage_key
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(
key: key,
content_type: "image/jpeg",
disposition: "inline"
).call(self)
rescue ArgumentError
head :not_found
end
def download_allowed?
ent = @recording.team.entitlements
return false unless ent.phone_download_enabled?
Recordings::AccessPolicy.new(@recording, viewer: current_user).allowed? ||
Recordings::AccessPolicy.new(@recording, viewer: current_user).manage_allowed?
end
def download_filename
slug = @recording.title_or_default.parameterize.presence || "replay"
"#{slug}.mp4"
end
def set_session_and_recording
@session = StreamSession.includes(match: { team: :club }).find(params[:id])
@recording = Recording.not_deleted.find_by(stream_session: @session)
return if @recording
redirect_to public_live_path(@session), alert: t("flash.replay.not_available")
end
def authorize_replay_access!
return if Recordings::AccessPolicy.new(@recording, viewer: current_user).allowed?
redirect_to public_live_index_path, alert: t("flash.replay.not_available")
end
end
end
@@ -1,24 +1,36 @@
module Public
class SessionsController < WebBaseController
# Logout must succeed even with a stale CSRF token (old tab after deploy /
# session rotate). SameSite=Lax already blocks cross-site cookie POSTs.
skip_before_action :verify_authenticity_token, only: :destroy
def new
redirect_to public_team_dashboard_path(current_user.teams.first) if logged_in? && current_user.teams.any?
if logged_in? && current_user.primary_club
redirect_to public_club_path(current_user.primary_club)
end
end
def create
user = User.find_by(email: params[:email]&.downcase)
if user&.authenticate(params[:password])
session[:user_id] = user.id
dest = user.teams.first ? public_team_dashboard_path(user.teams.first) : new_public_team_path
redirect_to dest, notice: "Bentornato!"
dest = if user.primary_club
public_club_path(user.primary_club)
elsif user.manageable_teams.first
public_team_details_path(user.manageable_teams.first)
else
public_new_club_path
end
redirect_to dest, notice: t("flash.sessions.welcome_back")
else
flash.now[:alert] = "Email o password non validi"
flash.now[:alert] = t("flash.sessions.invalid_credentials")
render :new, status: :unauthorized
end
end
def destroy
reset_session
redirect_to public_pricing_path, notice: "Disconnesso"
redirect_to public_pricing_path, notice: t("flash.sessions.logged_out")
end
end
end
@@ -2,10 +2,36 @@ module Public
class SiteBaseController < ActionController::Base
layout "marketing"
helper_method :current_user, :logged_in?
include ::SeoHelper
include ::LegalHelper
helper ApplicationHelper
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 :load_site_announcements
private
def set_site_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 current_user
return @current_user if defined?(@current_user)
@@ -15,5 +41,27 @@ module Public
def logged_in?
current_user.present?
end
PRIVATE_WEB_CONTROLLERS = %w[
accounts clubs teams club_recordings club_billing
club_matches matches team_roster_members
].freeze
def load_site_announcements
@site_announcements = if skip_site_announcements?
AppAnnouncement.none
else
channel = private_web_area? ? :web_private : :web_public
AppAnnouncement.for_channel(channel).newest
end
end
def skip_site_announcements?
controller_name == "regia"
end
def private_web_area?
PRIVATE_WEB_CONTROLLERS.include?(controller_name)
end
end
end
@@ -0,0 +1,33 @@
module Public
class SitemapController < ActionController::Base
def show
base = MatchLiveTv.app_public_url.chomp("/")
@entries = [
{ loc: "#{base}/", changefreq: "weekly", priority: "1.0" },
{ loc: "#{base}/funzionalita", changefreq: "monthly", priority: "0.9" },
{ loc: "#{base}/prezzi", changefreq: "monthly", priority: "0.9" },
{ loc: "#{base}/pallavolo-giovanile", changefreq: "monthly", priority: "0.85" },
{ loc: "#{base}/faq", changefreq: "monthly", priority: "0.8" },
{ loc: "#{base}/contatti", changefreq: "monthly", priority: "0.6" },
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
{ loc: "#{base}/support", changefreq: "yearly", priority: "0.3" },
{ loc: "#{base}/cookie", 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|
format.xml { render layout: false }
end
end
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
@@ -0,0 +1,63 @@
module Public
class TeamRosterMembersController < WebBaseController
before_action :require_login!
before_action :set_team
before_action :require_team_owner!
before_action :set_member, only: %i[edit update destroy]
def create
member = @team.roster_members.build(member_params)
attach_photo(member)
member.save!
redirect_to public_team_details_path(@team), notice: t("flash.team_roster_members.added", name: member.full_name)
rescue ActiveRecord::RecordInvalid => e
redirect_to public_team_details_path(@team), alert: e.record.errors.full_messages.join(", ")
end
def edit
@club = @team.club
end
def update
@member.assign_attributes(member_params)
attach_photo(@member)
@member.save!
redirect_to public_team_details_path(@team), notice: t("flash.team_roster_members.updated")
rescue ActiveRecord::RecordInvalid => e
@club = @team.club
flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity
end
def destroy
name = @member.full_name
@member.destroy!
redirect_to public_team_details_path(@team), notice: t("flash.team_roster_members.removed", name: name)
end
private
def set_team
@team = current_user.manageable_teams.find(params[:team_id])
end
def require_team_owner!
require_club_owner_for_team!(@team)
end
def set_member
@member = @team.roster_members.find(params[:id])
end
def member_params
params.require(:team_roster_member).permit(
:category, :full_name, :role_label, :jersey_number, :bio, :position
)
end
def attach_photo(member)
file = params.dig(:team_roster_member, :photo_file)
member.photo_file.attach(file) if file.present?
end
end
end
@@ -1,119 +1,209 @@
module Public
class TeamsController < WebBaseController
include BrandingAttachments
before_action :require_login!
before_action :set_club, only: %i[new create]
before_action :set_team, only: %i[
dashboard billing checkout portal invite create_invitation
remove_member destroy_invitation
details dashboard roster edit update invite create_invitation
assign_self_staff clear_self_staff remove_member destroy_invitation
youtube_connect youtube_disconnect
]
def new
redirect_to public_team_dashboard_path(current_user.teams.first) if current_user.teams.any?
redirect_to public_new_club_path if current_user.owned_clubs.none?
require_club_owner!(@club)
end
def create
if current_user.user_teams.where(role: "owner").exists?
redirect_to public_team_dashboard_path(current_user.teams.first), alert: "Hai già una squadra"
return
end
require_club_owner!(@club)
team = @club.teams.create!(team_params)
attach_branding_logo(team)
Teams::StaffAssignment.designate_club_owner!(team: team, user: current_user)
redirect_to public_club_path(@club), notice: t("flash.teams.added", name: team.name)
rescue ActiveRecord::RecordInvalid => e
flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity
end
team = Team.create!(team_params)
UserTeam.create!(user: current_user, team: team, role: "owner")
plan = params[:plan].presence_in(%w[free premium_light premium_full]) || "free"
Billing::AssignPlan.call(team: team, plan_slug: plan)
if plan.in?(%w[premium_light premium_full]) && MatchLiveTv.stripe_enabled?
redirect_to public_team_checkout_path(team, plan: plan)
else
redirect_to public_team_dashboard_path(team), notice: "Squadra creata con piano Free"
end
def details
load_team_details!
render :details
end
def dashboard
require_team_owner!(@team)
redirect_to public_team_details_path(params[:id])
end
def roster
redirect_to public_team_details_path(params[:id])
end
def edit
require_club_owner_for_team!(@team)
@club = @team.club
@entitlements = @team.entitlements
@recordings = @team.recordings.ready.includes(stream_session: :match).order(created_at: :desc).limit(10)
@members = @team.user_teams.includes(:user).where.not(role: "owner")
@pending_invitations = @team.team_invitations.pending.order(created_at: :desc)
end
def billing
require_team_owner!(@team)
def update
require_club_owner_for_team!(@team)
@team.assign_attributes(team_params)
attach_branding_logo(@team)
attach_cover_image(@team, entitlements: @team.entitlements)
attach_team_photo(@team)
@team.save!
enqueue_cover_slate_generation(@team)
redirect_to public_team_details_path(@team), notice: t("flash.teams.updated")
rescue ActiveRecord::RecordInvalid => e
@club = @team.club
@entitlements = @team.entitlements
@plans = Plan.ordered
end
def checkout
require_team_owner!(@team)
unless MatchLiveTv.stripe_enabled?
redirect_to public_team_billing_path(@team), alert: "Pagamenti non ancora configurati sul server"
return
end
plan_slug = params[:plan].presence_in(%w[premium_light premium_full]) || "premium_light"
url = Billing::Stripe::CheckoutSession.new(team: @team, user: current_user, plan_slug: plan_slug).url
redirect_to url, allow_other_host: true
end
def portal
require_team_owner!(@team)
unless MatchLiveTv.stripe_enabled?
redirect_to public_team_billing_path(@team), alert: "Portale pagamenti non configurato"
return
end
url = Billing::Stripe::CheckoutSession.new(team: @team, user: current_user).portal_url
redirect_to url, allow_other_host: true
flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity
rescue Teams::EntitlementError, BrandingAttachments::CoverUploadError => e
@club = @team.club
@entitlements = @team.entitlements
flash.now[:alert] = e.message
render :edit, status: :unprocessable_entity
end
def invite
require_team_owner!(@team)
@entitlements = @team.entitlements
require_club_owner_for_team!(@team)
redirect_to public_team_details_path(@team, anchor: "invita-trasmissione")
end
def assign_self_staff
require_club_owner_for_team!(@team)
membership = current_user.user_teams.find_or_initialize_by(team: @team)
membership.role = "member" if membership.new_record?
Teams::StaffAssignment.call(team: @team, user: current_user, membership: membership)
redirect_to public_team_details_path(@team), notice: t("flash.teams.now_staff")
rescue Teams::StaffAssignmentError, Teams::EntitlementError => e
redirect_to public_team_details_path(@team), alert: e.message
end
def clear_self_staff
require_club_owner_for_team!(@team)
membership = current_user.user_teams.find_by(team: @team)
membership&.update!(staff_kind: nil)
redirect_to public_team_details_path(@team), notice: t("flash.teams.staff_role_removed")
end
def create_invitation
require_team_owner!(@team)
require_club_owner_for_team!(@team)
@entitlements = @team.entitlements
staff_kind = params[:staff_kind].presence_in(TeamInvitation::STAFF_KINDS) || "transmission"
staff_kind = "transmission"
@entitlements.assert_can_invite!(staff_kind: staff_kind)
email = params[:email]&.downcase&.strip
Teams::StaffEmailValidator.assert_available!(team: @team, email: email, staff_kind: staff_kind)
token = TeamInvitation.generate_token
@team.team_invitations.create!(
invitation = @team.team_invitations.create!(
email: email,
token_digest: Digest::SHA256.hexdigest(token),
role: "member",
staff_kind: staff_kind,
expires_at: 7.days.from_now
)
@invite_url = join_public_invitation_url(token: token)
flash.now[:notice] = "Link invito generato (valido 7 giorni)"
render :invite
rescue Teams::EntitlementError => e
redirect_to public_team_invite_path(@team), alert: e.message
invite_url = public_invitation_url(token: token)
flash[:invite_url] = invite_url
begin
Teams::InvitationMailer.transmission_invite(
team: @team,
invitation: invitation,
invite_url: invite_url,
invited_by: current_user
).deliver_now
flash[:notice] = t("flash.teams.invite_email_sent", email: email)
rescue StandardError => e
Rails.logger.error("[invite_email] #{e.class}: #{e.message}")
flash[:alert] = t("flash.teams.invite_email_failed", email: email)
end
redirect_to public_team_details_path(@team, anchor: "invito-generato")
rescue Teams::EntitlementError, Teams::StaffAssignmentError => e
redirect_to public_team_details_path(@team, anchor: "invita-trasmissione"), alert: e.message
end
def remove_member
require_team_owner!(@team)
require_club_owner_for_team!(@team)
ut = @team.user_teams.find_by!(user_id: params[:user_id], role: "member")
ut.destroy!
redirect_to public_team_dashboard_path(@team), notice: "Accesso revocato"
redirect_to public_team_details_path(@team), notice: t("flash.teams.access_revoked")
end
def destroy_invitation
require_team_owner!(@team)
require_club_owner_for_team!(@team)
inv = @team.team_invitations.pending.find(params[:invitation_id])
inv.destroy!
redirect_to public_team_dashboard_path(@team), notice: "Invito annullato"
redirect_to public_team_details_path(@team), notice: t("flash.teams.invitation_canceled")
end
def youtube_connect
redirect_to public_club_youtube_connect_path(
@team.club_id,
return_app: params[:return_app]
)
end
def youtube_disconnect
require_club_owner_for_team!(@team)
@team.club.youtube_credential&.destroy!
redirect_to public_club_path(@team.club), notice: t("flash.teams.youtube_disconnected")
end
private
def load_team_details!
@club = @team.club
@can_manage = @club.owned_by?(current_user)
@entitlements = @team.entitlements
@roster_by_category = @team.roster_by_category
if @can_manage
@roster_member = @team.roster_members.build(
category: params[:category].presence_in(TeamRosterMember::CATEGORIES) || "player"
)
end
@recordings = @team.recordings.not_deleted
.where(status: %w[ready processing])
.includes(stream_session: :match)
.order(recorded_at: :desc, created_at: :desc)
.limit(10)
@owner_membership = current_user.user_teams.find_by(team: @team)
@staff_memberships = @team.user_teams.includes(:user)
.where("user_teams.role = 'member' OR (user_teams.staff_kind IS NOT NULL)")
@pending_invitations = @team.team_invitations.pending.order(created_at: :desc)
end
def set_club
@club = current_user.owned_clubs.find(params[:club_id])
end
def set_team
@team = current_user.teams.find(params[:id])
@team = current_user.manageable_teams.find(params[:id])
end
def attach_team_photo(team)
file = params.dig(:team, :photo_file)
team.photo_file.attach(file) if file.present?
end
def team_params
params.require(:team).permit(:name, :sport, :logo_url)
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[:secondary_color] = p[:secondary_color].presence
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
end
end
@@ -7,12 +7,15 @@ module Public
def require_login!
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
def require_team_owner!(team)
membership = current_user.user_teams.find_by(team: team)
redirect_to public_prezzi_path, alert: "Accesso non consentito" unless membership&.role == "owner"
def require_club_owner!(club)
redirect_to public_prezzi_path, alert: t("flash.web_base.access_denied") unless club.owned_by?(current_user)
end
def require_club_owner_for_team!(team)
redirect_to public_prezzi_path, alert: t("flash.web_base.access_denied") unless team.club&.owned_by?(current_user)
end
end
end
@@ -0,0 +1,49 @@
# Callback OAuth YouTube (HTML). Non usare Api::V1 — ActionController::API non renderizza le view.
class YoutubeOauthCallbackController < ActionController::Base
def show
return redirect_to_oauth_error("Codice OAuth mancante") if params[:code].blank?
ctx = Youtube::OauthState.verify!(params[:state])
tokens = Youtube::OauthExchange.call(params[:code])
if ctx[:kind] == :platform
@refresh_token = tokens[:refresh_token]
@channel_title = tokens[:channel_title]
return render "admin/youtube/platform_token", layout: false
end
user = User.find(ctx[:user_id])
club = Club.find(ctx[:club_id])
unless club.owned_by?(user)
return redirect_to_oauth_error("Solo il titolare della società può collegare il canale YouTube")
end
cred = club.youtube_credential || club.build_youtube_credential
cred.update!(
access_token: tokens[:access_token],
refresh_token: tokens[:refresh_token] || cred.refresh_token,
expires_at: Time.current + tokens[:expires_in].seconds,
channel_id: tokens[:channel_id],
channel_title: tokens[:channel_title]
)
if ctx[:return_app]
redirect_to "matchlivetv://youtube-connected?club_id=#{club.id}", allow_other_host: true
else
redirect_to "#{public_base_url}/clubs/#{club.id}?youtube=connected", allow_other_host: true
end
rescue ArgumentError, Youtube::BroadcastService::Error => e
redirect_to_oauth_error(e.message)
end
private
def public_base_url
MatchLiveTv.app_public_url.chomp("/")
end
def redirect_to_oauth_error(message)
redirect_to "#{public_base_url}/prezzi?youtube_error=#{ERB::Util.url_encode(message)}",
allow_other_host: true
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
+174
View File
@@ -0,0 +1,174 @@
module AdminHelper
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
def format_duration_minutes(minutes)
return "0 min" if minutes.to_i <= 0
hours = minutes / 60
mins = minutes % 60
hours.positive? ? "#{hours}h #{mins}m" : "#{mins} min"
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_session_ingest_badge_class(role_or_node)
role = role_or_node.respond_to?(:role) ? role_or_node.role : role_or_node
case role.to_s
when "home" then "badge--ingest-home"
when "lab" then "badge--ingest-lab"
when "cloud" then "badge--ingest-cloud"
else "badge--paused"
end
end
def admin_session_ingest_role_label(role_or_node)
role = role_or_node.respond_to?(:role) ? role_or_node.role : role_or_node
I18n.t("admin.sessions.ingest.role.#{role}", default: role.to_s.humanize)
end
def admin_session_status_badge_class(status)
case status.to_s
when "live" then "badge--live"
when "connecting", "reconnecting" then "badge--connecting"
when "paused", "idle" then "badge--paused"
when "ended" then "badge--ended"
when "error" then "badge--error"
else "badge--paused"
end
end
def admin_datetime(time, with_seconds: false)
return I18n.t("admin.common.dash") if time.blank?
format = with_seconds ? "%d/%m/%Y %H:%M:%S" : "%d/%m/%Y %H:%M"
time.in_time_zone.strftime(format)
end
def admin_session_duration_label(session)
secs = session.total_duration_secs.to_i
if secs <= 0 && session.started_at
end_time = session.ended_at || (session.terminal? ? session.updated_at : Time.current)
secs = (end_time - session.started_at).to_i if end_time
end
return I18n.t("admin.common.dash") if secs <= 0
hours = secs / 3600
mins = (secs % 3600) / 60
rem = secs % 60
if hours.positive?
format("%dh %02dm %02ds", hours, mins, rem)
elsif mins.positive?
format("%dm %02ds", mins, rem)
else
"#{rem}s"
end
end
def admin_session_client_os_label(session)
case session.client_os.to_s
when "android" then "Android"
when "ios" then "iOS"
else I18n.t("admin.common.dash")
end
end
def admin_session_client_summary(session)
parts = []
parts << admin_session_client_os_label(session) if session.client_os.present?
if session.app_version.present?
ver = session.app_version
ver = "#{ver} (#{session.app_build})" if session.app_build.present?
parts << "app #{ver}"
end
device = [session.device_manufacturer, session.device_model].compact_blank.join(" ")
parts << device if device.present?
parts << "OS #{session.os_version}" if session.os_version.present?
parts << session.carrier if session.carrier.present?
parts.presence&.join(" · ") || I18n.t("admin.common.dash")
end
def admin_format_event_meta(metadata)
return content_tag(:span, I18n.t("admin.common.dash"), class: "muted") if metadata.blank?
pairs = metadata.to_h
return content_tag(:span, I18n.t("admin.common.dash"), class: "muted") if pairs.empty?
content_tag(:dl, class: "admin-event-meta") do
safe_join(
pairs.map do |key, value|
content_tag(:div, class: "admin-event-meta__row") do
content_tag(:dt, key.to_s) +
content_tag(:dd, admin_event_meta_value(value))
end
end
)
end
end
def admin_event_meta_value(value)
case value
when Hash, Array
content_tag(:code, value.to_json)
when TrueClass, FalseClass
value ? "true" : "false"
when NilClass
I18n.t("admin.common.dash")
else
value.to_s
end
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
def announcement_status_badge(item)
case item.status
when "published" then item.severity == "critical" ? "live" : "ready"
when "archived" then "paused"
else "connecting"
end
end
def announcement_window_label(item)
start_label = item.starts_at&.in_time_zone&.strftime("%d/%m %H:%M")
end_label = item.ends_at&.in_time_zone&.strftime("%d/%m %H:%M")
if start_label && end_label
"#{start_label}#{end_label}"
elsif start_label
I18n.t("admin.announcements.window.from", time: start_label)
elsif end_label
I18n.t("admin.announcements.window.until", time: end_label)
else
I18n.t("admin.announcements.window.always")
end
end
def announcement_channels_label(item)
labels = item.selected_channels.map { |key| I18n.t("admin.announcements.channels.#{key}") }
labels.presence&.join(" · ") || I18n.t("admin.common.dash")
end
end
+66
View File
@@ -0,0 +1,66 @@
module ApplicationHelper
PLAN_ICONS = {
"free" => "fa-solid fa-seedling",
"premium_light" => "fa-solid fa-rocket",
"premium_full" => "fa-solid fa-crown"
}.freeze
def plan_icon_class(plan)
PLAN_ICONS[plan.slug] || "fa-solid fa-circle"
end
# Data/ora nel fuso dell'app (Europe/Rome) e nella lingua corrente.
def l_local(date_or_time, format: :long)
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
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
+9
View File
@@ -0,0 +1,9 @@
module LegalHelper
def legal_last_updated
"20 agosto 2026"
end
def cookie_policy_last_updated
"3 giugno 2026"
end
end
@@ -0,0 +1,173 @@
module Public
module BillingHelper
def plan_billing_action(current_slug:, target_plan:, stripe_subscription_active:, current_interval: nil, subscription: nil, club: nil)
if target_plan.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: :contact, label: I18n.t("billing.actions.contact_for_free") }
end
quote = club&.active_billing_quote
if quote
if quote.plan_slug == target_plan.slug
if current_paid_plan?(subscription, target_plan)
return {
kind: :current,
label: "#{I18n.t('billing.actions.current_plan')}#{quote.price_label}"
}
end
return { kind: :quoted, plan: target_plan, quote: quote, intervals: [quote.billing_interval] }
end
return { kind: :quoted_other, label: I18n.t("billing.bank_transfer.quoted_other") }
end
intervals = bank_transfer_intervals_for(target_plan)
unless MatchLiveTv.stripe_enabled?
if current_paid_plan?(subscription, target_plan)
return { kind: :current, label: current_plan_label(target_plan, subscription) }
end
if MatchLiveTv.bank_transfer_configured?
return { kind: :bank_only, plan: target_plan, intervals: intervals }
end
return { kind: :disabled, label: I18n.t("billing.actions.stripe_not_configured") }
end
stripe_intervals = Billing::Stripe::PriceCatalog.available_intervals(plan_slug: target_plan.slug)
if stripe_intervals.empty?
if current_paid_plan?(subscription, target_plan)
return { kind: :current, label: current_plan_label(target_plan, subscription) }
end
if MatchLiveTv.bank_transfer_configured?
return { kind: :bank_only, plan: target_plan, intervals: intervals }
end
return { kind: :disabled, label: I18n.t("billing.actions.stripe_prices_not_configured") }
end
active_interval = current_interval.presence || Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL
if current_slug == target_plan.slug && stripe_subscription_active && !subscription&.plan_change_pending?
other_intervals = stripe_intervals - [active_interval]
if other_intervals.empty?
label = "#{I18n.t('billing.actions.current_plan')}#{Billing::Stripe::PriceCatalog.label(plan_slug: target_plan.slug, interval: active_interval)}"
return { kind: :current, label: label }
end
return {
kind: :interval_switch,
plan: target_plan,
intervals: other_intervals,
current_interval: active_interval,
current_slug: current_slug,
subscription: subscription
}
end
if current_paid_plan?(subscription, target_plan)
return { kind: :current, label: current_plan_label(target_plan, subscription) }
end
if current_slug == "free" || !stripe_subscription_active
{ kind: :checkout_options, plan: target_plan, intervals: stripe_intervals.presence || intervals, subscription: subscription }
else
{
kind: :change_options,
plan: target_plan,
intervals: stripe_intervals,
current_slug: current_slug,
current_interval: active_interval,
subscription: subscription
}
end
end
def plan_interval_checkout_path(club, plan, interval)
public_club_checkout_path(club, plan: plan.slug, interval: interval)
end
def plan_interval_button_label(plan, interval, kind: :checkout, **)
btn_kind = kind == :checkout_options ? :checkout : :change
Billing::Stripe::PlanChangeMessages.button_label(plan: plan, interval: interval, kind: btn_kind)
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)
Billing::Stripe::PlanChangeMessages.billing_info_lines(subscription: subscription)
end
def stripe_subscription_active?(subscription)
subscription&.stripe_subscription_id.present? && subscription.active?
end
def stripe_interval_available?(plan_slug, interval)
Billing::Stripe::PriceCatalog.configured?(plan_slug: plan_slug, interval: interval)
end
def plan_interval_button_class(interval, **)
if interval.to_s == "yearly"
"btn btn-primary plan-interval-btn plan-interval-btn--yearly"
else
"btn btn-outline plan-interval-btn plan-interval-btn--monthly"
end
end
def plan_intervals_for_display(intervals)
Array(intervals).sort_by { |i| i.to_s == "yearly" ? 0 : 1 }
end
def billing_profile_blocks_premium?(club)
club.present? && !club.billing_profile_complete?
end
def billing_profile_incomplete_message(club)
missing = club.billing_profile_errors
return I18n.t("billing.actions.profile_incomplete_default") if missing.empty?
I18n.t("billing.actions.profile_missing", fields: missing.join(", "))
end
def bank_transfer_intervals_for(plan)
Billing::Stripe::PriceCatalog.catalog_intervals(plan_slug: plan.slug)
end
def bank_transfer_price_label(plan, interval, quote: nil)
if quote&.matches?(plan.slug, interval)
quote.price_label
else
Billing::Stripe::PriceCatalog.format_interval_price(
Billing::Stripe::PriceCatalog.amount_cents(plan_slug: plan.slug, interval: interval),
interval
)
end
end
def show_bank_transfer_for?(club:, plan:, interval:, quote: nil, pending_transfer: nil)
return false unless club.present? && plan.slug != "free"
return false unless MatchLiveTv.bank_transfer_configured?
return false if quote && !quote.matches?(plan.slug, interval)
return false if pending_transfer&.awaiting_payment?
return false if current_paid_plan?(club.subscription, plan)
true
end
def current_paid_plan?(subscription, target_plan)
return false unless subscription&.active? && subscription.premium?
return false if subscription.plan_change_pending?
subscription.plan.slug == target_plan.slug
end
def current_plan_label(target_plan, subscription)
interval = subscription&.billing_interval.presence || Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL
"#{I18n.t('billing.actions.current_plan')}#{Billing::Stripe::PriceCatalog.label(plan_slug: target_plan.slug, interval: interval)}"
end
end
end
+83 -7
View File
@@ -1,13 +1,30 @@
module Public
module LiveHelper
def live_score_sets_label(score_state)
def live_score_sets_label(score_state, match = nil)
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
def live_score_partials_label(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)
return nil if partials.empty?
@@ -15,10 +32,66 @@ module Public
partials.map { |p| format_set_partial_entry(p) }.join(" · ")
end
def live_score_points_label(match, score_state)
return "" unless score_state
def live_scheduled_label(datetime)
return nil unless datetime
"#{match.team.name} #{score_state.home_points} - #{score_state.away_points} #{match.opponent_name}"
datetime.in_time_zone.strftime(t("score.scheduled_date_format"))
end
def live_scheduled_relative(datetime)
return nil unless datetime
if datetime.to_date == Time.zone.today
t("score.scheduled_today", time: datetime.strftime("%H:%M"))
elsif datetime.to_date == Time.zone.tomorrow
t("score.scheduled_tomorrow", time: datetime.strftime("%H:%M"))
else
live_scheduled_label(datetime)
end
end
def live_match_card_heading(match, link_team: true)
team = match.team
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
safe_join([
content_tag(:span, club_name, class: "live-card__club"),
content_tag(:span, safe_join([team_label, " vs ", match.opponent_name]), class: "live-card__matchup")
])
end
end
def live_match_page_heading(match)
team = match.team
club_name = team.club&.name.presence || t("score.default_club_name")
content_tag(:div, class: "live-page-heading") do
safe_join([
content_tag(:p, club_name, class: "live-page-heading__club"),
content_tag(:h1, "#{team.name} vs #{match.opponent_name}", class: "live-page-heading__matchup")
])
end
end
def live_score_points_label(match, score_state)
return t("score.fallback") unless score_state
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
t("score.points_label", team: match.team.name, home: home, away: away, opponent: match.opponent_name)
end
private
@@ -28,8 +101,11 @@ module Public
set_no = data[:set] || data["set"]
home = data[:home] || data["home"]
away = data[:away] || data["away"]
label = set_no.present? ? "Set #{set_no}" : "Set"
"#{label} #{home}-#{away}"
if set_no.present?
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
+114
View File
@@ -0,0 +1,114 @@
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"),
muted: t("regia.js.muted"),
unmuted: t("regia.js.unmuted"),
closed: t("regia.js.closed"),
resumeError: t("regia.js.resume_error"),
pauseError: t("regia.js.pause_error"),
muteError: t("regia.js.mute_error"),
closeError: t("regia.js.close_error"),
closeConfirm: t("regia.js.close_confirm"),
pauseConfirmTitle: t("regia.js.pause_confirm_title"),
pauseConfirmBody: t("regia.js.pause_confirm_body"),
resumeConfirmTitle: t("regia.js.resume_confirm_title"),
resumeConfirmBody: t("regia.js.resume_confirm_body"),
muteConfirmTitle: t("regia.js.mute_confirm_title"),
muteConfirmBody: t("regia.js.mute_confirm_body"),
unmuteConfirmTitle: t("regia.js.unmute_confirm_title"),
unmuteConfirmBody: t("regia.js.unmute_confirm_body"),
advancePeriodTitle: t("regia.js.advance_period_title"),
advancePeriodBody: t("regia.js.advance_period_body"),
confirmAction: t("regia.modal.confirm_action"),
stopConfirmTitle: t("regia.js.stop_confirm_title"),
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"),
minQualityUpdated: t("regia.js.min_quality_updated"),
minQualityError: t("regia.js.min_quality_error"),
minQualityAuto: t("regia.min_quality.auto"),
previewWaiting: t("regia.preview_waiting"),
streamEnded: t("regia.preview_ended"),
previewHide: t("regia.preview_hide"),
previewShow: t("regia.preview_show"),
previewOffHint: t("regia.preview_off_hint"),
resumeLabel: t("regia.resume"),
pauseLabel: t("regia.pause"),
muteLabel: t("regia.mute"),
unmuteLabel: t("regia.unmute"),
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"),
telemetryWaiting: t("regia.telemetry.waiting"),
telemetryStale: t("regia.telemetry.stale"),
telemetryJustNow: t("regia.telemetry.just_now"),
telemetrySecondsAgo: t("regia.telemetry.seconds_ago"),
telemetryPublisherOn: t("regia.telemetry.publisher_on"),
telemetryPublisherOff: t("regia.telemetry.publisher_off"),
telemetryThermalNominal: t("regia.telemetry.thermal_nominal"),
telemetryThermalFair: t("regia.telemetry.thermal_fair"),
telemetryThermalSerious: t("regia.telemetry.thermal_serious"),
telemetryThermalCritical: t("regia.telemetry.thermal_critical"),
telemetryBitrateMax: t("regia.telemetry.bitrate_max"),
telemetryBitrateTitle: t("regia.telemetry.bitrate_title"),
telemetryBitrateTap: t("regia.telemetry.bitrate_tap")
}.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
+33
View File
@@ -0,0 +1,33 @@
module RosterHelper
def roster_person_avatar(person, team, size: 96)
primary = team.effective_primary_color
secondary = team.effective_secondary_color
style = "--avatar-primary:#{primary};--avatar-secondary:#{secondary};width:#{size}px;height:#{size}px"
if person.photo_url.present?
image_tag person.photo_url, alt: person.full_name, class: "roster-avatar roster-avatar--photo",
style: style, width: size, height: size
else
tag.div(
person.initials,
class: "roster-avatar roster-avatar--placeholder",
style: "#{style};font-size:#{(size * 0.34).round}px",
aria: { label: person.full_name }
)
end
end
def roster_category_options
TeamRosterMember::DISPLAY_ORDER.map do |cat|
[TeamRosterMember.category_label(cat), cat]
end
end
def roster_player_role_options(team, selected = nil)
roles = Sports::PlayerRoles.for_team(team)
options_for_select(
[[t("roster.role_select_blank"), ""]] + roles.map { |r| [r, r] },
selected
)
end
end
+33
View File
@@ -0,0 +1,33 @@
module SeoHelper
DEFAULT_OG_IMAGE_PATH = "/og-share.png"
def seo_site_url
MatchLiveTv.app_public_url.chomp("/")
end
def seo_absolute_url(path)
path = "/#{path}" unless path.start_with?("/")
"#{seo_site_url}#{path}"
end
def seo_canonical_url
if content_for?(:canonical_url)
content_for(:canonical_url)
else
request.original_url.split("?").first
end
end
def seo_og_image_url
if content_for?(:og_image)
img = content_for(:og_image)
img.start_with?("http") ? img : seo_absolute_url(img)
else
seo_absolute_url(DEFAULT_OG_IMAGE_PATH)
end
end
def seo_robots_content
content_for?(:robots) ? content_for(:robots) : "index, follow"
end
end
+9
View File
@@ -0,0 +1,9 @@
module StaffHelper
def staff_role_label(membership, team)
Teams::StaffCoverage.new(team).role_label_for(membership)
end
def staff_coverage_for(team)
Teams::StaffCoverage.new(team)
end
end
@@ -0,0 +1,25 @@
# frozen_string_literal: true
module Analytics
class AggregateJob < ApplicationJob
queue_as :default
def perform(purge: false)
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
lock_key = "analytics:aggregate:lock"
acquired = redis.set(lock_key, "1", nx: true, ex: 120)
return unless acquired
begin
service = Analytics::Aggregate.new
service.call
service.purge_old! if purge
ensure
redis.del(lock_key)
end
rescue StandardError => e
Rails.logger.error("[analytics] aggregate failed: #{e.class} #{e.message}")
raise
end
end
end
@@ -6,7 +6,7 @@ class CleanupExpiredSessionsJob
.where("updated_at < ?", 6.hours.ago)
.find_each do |session|
session.fail! if session.may_fail?
Mediamtx::Client.new.delete_path(session)
Mediamtx::Client.for_session(session).delete_path(session)
end
end
end
@@ -0,0 +1,42 @@
# Passa a Free le società con disdetta programmata e periodo scaduto (anche senza visita pagina billing).
class ExpireEndedSubscriptionsJob
include Sidekiq::Job
def perform
free_plan = Plan["free"]
Subscription.where(cancel_at_period_end: true)
.where("current_period_end <= ?", Time.current)
.where.not(plan_id: free_plan.id)
.where(admin_comped: false)
.find_each do |sub|
expire!(sub)
rescue StandardError => e
Rails.logger.warn("[ExpireEndedSubscriptions] club=#{sub.club_id} #{e.message}")
end
end
private
def expire!(sub)
if sub.stripe_subscription_id.present?
Billing::Stripe::FinalizeSubscription.call(club: sub.club)
else
Billing::AssignPlan.call(
club: sub.club,
plan_slug: "free",
status: "active",
stripe_attrs: {
stripe_subscription_id: nil,
stripe_schedule_id: nil,
current_period_start: nil,
current_period_end: nil,
cancel_at_period_end: false,
billing_interval: nil,
pending_plan_id: nil,
pending_billing_interval: nil
}
)
end
end
end
@@ -0,0 +1,19 @@
class GenerateCoverSlateJob < ApplicationJob
queue_as :default
def self.enqueue_for(record)
return unless record&.cover_image&.attached?
queue = record.is_a?(Match) ? :critical : :default
set(queue: queue).perform_later(record.class.name, record.id)
end
def perform(class_name, record_id)
record = class_name.constantize.find_by(id: record_id)
return unless record&.cover_image&.attached?
Streams::GenerateCoverSlate.call(record)
rescue Streams::GenerateCoverSlate::Error => e
Rails.logger.warn("[GenerateCoverSlateJob] #{class_name}##{record_id} #{e.message}")
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,19 @@
# frozen_string_literal: true
module Recordings
class ClearTemporaryMediaJob
include Sidekiq::Job
sidekiq_options retry: 3, queue: "default"
def perform(recording_id, reason = "verified")
recording = Recording.find_by(id: recording_id)
return unless recording
Recordings::ClearTemporaryMedia.new(
recording,
reason: reason.to_sym,
force: reason.to_s == "verified"
).call
end
end
end
@@ -0,0 +1,18 @@
module Recordings
class ExpiryWarningJob
include Sidekiq::Job
sidekiq_options retry: 1, queue: "default"
def perform
Recording.ready
.where(expiry_warning_sent_at: nil)
.where.not(expires_at: nil)
.where(expires_at: ..7.days.from_now)
.find_each do |recording|
Recordings::NotifyExpiring.new(recording).call
rescue StandardError => e
Rails.logger.warn("[Recordings::ExpiryWarningJob] #{recording.id}: #{e.message}")
end
end
end
end
@@ -0,0 +1,40 @@
# frozen_string_literal: true
module Recordings
class PostProcessJob
include Sidekiq::Job
sidekiq_options retry: 2, queue: "default"
def perform(recording_id)
recording = Recording.find_by(id: recording_id)
return unless recording&.ready?
begin
Recordings::NotifyReady.new(recording).call
rescue StandardError => e
# SMTP/ntfy giù non deve bloccare VerifyYoutubeReplay né il resto del post-process.
Rails.logger.warn(
"[Recordings::PostProcessJob] notify_failed recording=#{recording.id} " \
"#{e.class}: #{e.message}"
)
end
if recording.temporary_storage?
grace = MatchLiveTv.youtube_replay_verify_grace_secs
Rails.logger.info(
"[Recordings::PostProcessJob] schedule VerifyYoutubeReplay " \
"recording=#{recording.id} grace=#{grace}s"
)
Recordings::VerifyYoutubeReplayJob.perform_in(grace.seconds, recording.id, 0)
return
end
# Solo MatchLiveTV-only: eventuale re-upload manuale/flag legacy (non per live YouTube).
return unless recording.auto_publish_youtube?
return if recording.youtube_video_id.present?
return if recording.source_platform == "youtube"
Recordings::PublishToYoutubeJob.perform_async(recording.id)
end
end
end
@@ -0,0 +1,17 @@
module Recordings
class PublishToYoutubeJob
include Sidekiq::Job
sidekiq_options retry: 2, queue: "default"
def perform(recording_id)
recording = Recording.find_by(id: recording_id)
return unless recording&.ready?
Recordings::PublishToYoutube.new(recording).call
rescue Recordings::PublishToYoutube::Error => e
meta = recording.metadata.merge("youtube_publish_error" => e.message, "youtube_publish_failed_at" => Time.current.iso8601)
recording.update!(metadata: meta)
Rails.logger.warn("[Recordings::PublishToYoutubeJob] #{recording_id}: #{e.message}")
end
end
end
@@ -0,0 +1,14 @@
module Recordings
class PurgeExpiredJob
include Sidekiq::Job
sidekiq_options retry: 1, queue: "default"
def perform
Recording.expired_pending_purge.find_each do |recording|
Recordings::Delete.new(recording, reason: :expired).call
rescue StandardError => e
Rails.logger.warn("[Recordings::PurgeExpiredJob] #{recording.id}: #{e.message}")
end
end
end
end
@@ -0,0 +1,35 @@
# frozen_string_literal: true
module Recordings
# Safety net: elimina copie temp scadute (anche se verify non è mai riuscito).
class PurgeTemporaryMediaJob
include Sidekiq::Job
sidekiq_options retry: 1, queue: "default"
def perform
scope = Recording.temporary_media_pending_purge
count = 0
scope.find_each do |recording|
unless recording.youtube_verified_at.present?
Rails.logger.warn(
"[Recordings::PurgeTemporaryMediaJob] anomaly_unverified_expiry " \
"recording=#{recording.id} session=#{recording.stream_session_id} " \
"youtube_video_id=#{recording.youtube_video_id.inspect} " \
"temp_expires_at=#{recording.temp_expires_at.inspect}"
)
end
Recordings::ClearTemporaryMedia.new(
recording,
reason: :max_retention,
force: true
).call
count += 1
end
Rails.logger.info("[Recordings::PurgeTemporaryMediaJob] processed=#{count}")
count
end
end
end
@@ -0,0 +1,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
+14
View File
@@ -0,0 +1,14 @@
module Recordings
class UploadJob
include Sidekiq::Job
sidekiq_options retry: 3, queue: "default"
def perform(session_id)
session = StreamSession.find_by(id: session_id)
return unless session
Recordings::UploadFromSession.new(session).call
Recordings::PurgeExpiredJob.perform_async
end
end
end
@@ -0,0 +1,47 @@
# frozen_string_literal: true
module Recordings
class VerifyYoutubeReplayJob
include Sidekiq::Job
sidekiq_options retry: 0, queue: "default"
def perform(recording_id, attempt = 0)
recording = Recording.find_by(id: recording_id)
return unless recording&.temporary_storage?
return if recording.deleted?
attempt = attempt.to_i
max = MatchLiveTv.youtube_replay_verify_max_attempts
result = Recordings::VerifyYoutubeReplay.new(recording).call
if result.ok?
Recordings::ClearTemporaryMediaJob.perform_async(recording.id, "verified")
return
end
if result.retriable? && attempt + 1 < max
delay = backoff_secs(attempt)
Rails.logger.info(
"[Recordings::VerifyYoutubeReplayJob] retry recording=#{recording.id} " \
"attempt=#{attempt + 1}/#{max} in=#{delay}s msg=#{result.message}"
)
self.class.perform_in(delay.seconds, recording.id, attempt + 1)
return
end
Rails.logger.warn(
"[Recordings::VerifyYoutubeReplayJob] give_up recording=#{recording.id} " \
"attempts=#{attempt + 1} msg=#{result.message} " \
"temp_expires_at=#{recording.temp_expires_at.inspect}"
)
end
private
def backoff_secs(attempt)
base = MatchLiveTv.youtube_replay_verify_base_interval_secs
# 300, 600, 1200, ... capped at 1h
[base * (2**attempt), 3600].min
end
end
end

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