- <% if @stream_closed %> - Diretta chiusa - <% elsif @on_air %> - In onda - <% else %> - In attesa - <% end %> -
- -<%= live_score_sets_label(@session.score_state) || "—" %>
-> - Parziali: <%= live_score_partials_label(@session.score_state) %> -
-<%= live_score_points_label(@match, @session.score_state) %>
-La diretta non è ancora disponibile. Si aggiorna automaticamente quando torna in onda.
+In attesa del segnale dal telefono — apri la camera nell'app e verifica che la diretta sia avviata (non in pausa).
+Trasmissione in pausa — il flusso continua (copertina server).
<% end %> diff --git a/backend/app/views/public/replay/show.html.erb b/backend/app/views/public/replay/show.html.erb index b60c0d6..e5a471e 100644 --- a/backend/app/views/public/replay/show.html.erb +++ b/backend/app/views/public/replay/show.html.erb @@ -17,7 +17,7 @@Registrazione in elaborazione. Riceverai un’email quando sarà pronta.
Il file video non è più disponibile (archivio rimosso o in migrazione).
+Durata registrata: <%= @recording.duration_label %> · <%= @recording.byte_size_label %>
+Replay non disponibile: <%= @recording.error_message.presence || "errore di elaborazione" %>.
diff --git a/backend/bin/overlay_png_feeder.rb b/backend/bin/overlay_png_feeder.rb new file mode 100644 index 0000000..3de695e --- /dev/null +++ b/backend/bin/overlay_png_feeder.rb @@ -0,0 +1,32 @@ +#!/usr/bin/env ruby +# Invia overlay.png aggiornata a ffmpeg via image2pipe (FIFO). +# ffmpeg con -loop 1 legge il file una sola volta: questo processo rilegge il PNG +# quando cambia mtime (punteggio, badge stato, …). +require "fileutils" + +pipe_path = ARGV.fetch(0) +png_path = ARGV.fetch(1) +poll_sec = (ARGV[2] || ENV.fetch("OVERLAY_PIPE_POLL_SEC", "0.4")).to_f + +abort "pipe mancante: #{pipe_path}" unless File.exist?(pipe_path) +abort "png mancante: #{png_path}" unless File.exist?(png_path) + +stop = false +trap("TERM") { stop = true } +trap("INT") { stop = true } + +# image2pipe richiede frame continui: inviamo la PNG a ritmo fisso ( anche se invariata ). +File.open(pipe_path, "wb") do |pipe| + loop do + break if stop + + if File.exist?(png_path) + pipe.write(File.binread(png_path)) + pipe.flush + end + + sleep poll_sec + end +rescue Errno::EPIPE + # ffmpeg chiuso +end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index f1ebd1a..4bf7ca4 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -41,6 +41,7 @@ Rails.application.routes.draw do patch :stop patch :pause patch :resume + patch :score get :events post :telemetry post :pairing_token diff --git a/backend/lib/tasks/stream_overlay.rake b/backend/lib/tasks/stream_overlay.rake new file mode 100644 index 0000000..981fae1 --- /dev/null +++ b/backend/lib/tasks/stream_overlay.rake @@ -0,0 +1,18 @@ +namespace :stream_overlay do + desc "Avvia path _air e OverlayRelay per sessioni attive (dopo deploy overlay server)" + task start_active: :environment do + sessions = StreamSession.where(status: %w[live connecting reconnecting paused]) + mtx = Mediamtx::Client.new + sessions.find_each do |session| + begin + mtx.create_overlay_path(session) + rescue Mediamtx::Client::Error => e + Rails.logger.info("[stream_overlay:start_active] path #{session.id}: #{e.message}") + end + Streams::OverlayRelay.start(session) + puts "Overlay avviato per sessione #{session.id}" + rescue StandardError => e + warn "Sessione #{session.id}: #{e.message}" + end + end +end diff --git a/backend/public/images/copertina-canale.png b/backend/public/images/copertina-canale.png new file mode 100644 index 0000000..5896113 Binary files /dev/null and b/backend/public/images/copertina-canale.png differ diff --git a/backend/public/live.css b/backend/public/live.css index bd4d5e5..a9512b7 100644 --- a/backend/public/live.css +++ b/backend/public/live.css @@ -5,11 +5,285 @@ padding: 24px 20px 48px; } -.live-main video { +.live-main .live-player-wrap { + position: relative; width: 100%; - max-height: 70vh; - background: #000; + aspect-ratio: 16 / 9; + max-height: min(70vh, calc(100vw * 9 / 16)); + min-height: 200px; border-radius: 12px; + overflow: hidden; + background: #000; + contain: layout style; +} + +.live-main video { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + max-height: none; + object-fit: contain; + background: #000; + border-radius: 0; + display: block; +} + +/* Sovraimpressioni chiare sul video (copertina scura). */ +.live-main .live-player-overlays { + position: absolute; + inset: 0; + z-index: 3; + pointer-events: none; +} + +.live-main .live-ovl-badge { + position: absolute; + top: 12px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.65); +} + +.live-main .live-ovl-badge--right { + right: 12px; + left: auto; +} + +/* Tabellone broadcast: tabella per colonne allineate (set / punteggi). */ +.live-main .live-scorebug { + position: absolute; + top: 12px; + left: 12px; + width: max-content; + max-width: min(92%, 320px); + padding: 6px 8px 5px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.94); + color: #111; + box-shadow: 0 2px 14px rgba(0, 0, 0, 0.45); + font-size: 0.8rem; + line-height: 1.2; +} + +.live-main .live-scorebug__table { + border-collapse: collapse; + table-layout: fixed; + width: auto; +} + +.live-main .live-scorebug__col-team { + width: 7.25rem; +} + +.live-main .live-scorebug__col-set { + width: 1.85rem; +} + +.live-main .live-scorebug__corner { + width: 7.25rem; + padding: 0; + border: none; + font-size: 0; + line-height: 0; + visibility: hidden; +} + +.live-main .live-scorebug thead th { + padding: 0 0 4px; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + font-size: 0.68rem; + font-weight: 700; + color: #666; + text-align: center; + vertical-align: bottom; +} + +.live-main .live-scorebug__col-h { + letter-spacing: 0.02em; +} + +.live-main .live-scorebug tbody th, +.live-main .live-scorebug tbody td { + padding: 3px 0; + vertical-align: middle; +} + +.live-main .live-scorebug tbody tr + tr th, +.live-main .live-scorebug tbody tr + tr td { + padding-top: 2px; +} + +.live-main .live-scorebug__team { + font-weight: 700; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 7.25rem; + padding-right: 8px; +} + +.live-main .live-scorebug__row--home .live-scorebug__team { + color: var(--sb-home-primary, #111); +} + +.live-main .live-scorebug__row--away .live-scorebug__team { + color: var(--sb-away-primary, #1565c0); +} + +.live-main .live-scorebug__pts { + text-align: center; + font-weight: 800; + font-variant-numeric: tabular-nums; + color: #111; + padding-left: 0; + padding-right: 0; +} + +.live-main .live-scorebug__pts--live-home { + background: color-mix(in srgb, var(--sb-home-primary, #e53935) 16%, #fff); + border-radius: 3px; + color: var(--sb-home-primary, #111); + font-weight: 900; +} + +.live-main .live-scorebug__pts--live-away { + background: color-mix(in srgb, var(--sb-away-primary, #1565c0) 16%, #fff); + border-radius: 3px; + color: var(--sb-away-primary, #111); + font-weight: 900; +} + +.live-main .live-scorebug__brand { + margin-top: 5px; + padding: 4px 10px; + border-radius: 4px; + background: linear-gradient(135deg, #14141c 0%, #2a1214 55%, #1a1a24 100%); + border: 1px solid rgba(229, 57, 53, 0.35); + text-align: center; + line-height: 1.1; +} + +.live-main .live-scorebug__brand-text { + font-size: 0.7rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #fff; +} + +.live-main .live-scorebug__brand-accent { + color: #e53935; +} + +.live-main .live-scorebug .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 520px) { + .live-main .live-scorebug { + font-size: 0.72rem; + max-width: 88%; + padding: 5px 6px 4px; + } + + .live-main .live-scorebug__col-team, + .live-main .live-scorebug__corner { + width: 5.75rem; + } + + .live-main .live-scorebug__team { + max-width: 5.75rem; + } + + .live-main .live-scorebug__col-set { + width: 1.55rem; + } +} + +.live-main .live-status-msg { + margin: 12px 0 0; + color: #f5f5f5; + font-size: 0.95rem; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); +} + +.live-main .live-status-msg[hidden] { + display: none !important; +} + +.live-main .live-play-hint { + position: absolute; + left: 50%; + top: 50%; + z-index: 5; + transform: translate(-50%, -50%); + padding: 12px 22px; + border: none; + border-radius: 999px; + background: rgba(229, 57, 53, 0.92); + color: #fff; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + pointer-events: auto; +} + +.live-main .live-play-hint[hidden] { + display: none; +} + +.live-main .live-cover-veil { + z-index: 4; +} + +.live-main .live-cover-veil__hint { + color: #ffffff; + background: rgba(0, 0, 0, 0.72); + border: 1px solid rgba(255, 255, 255, 0.25); +} + +/* Messaggio pausa sopra il video: la copertina brand è nello stream HLS (MediaMTX). */ +.live-main .live-cover-veil { + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; + display: flex; + align-items: flex-end; + justify-content: center; + padding: 0 16px 14%; + background: linear-gradient(180deg, transparent 55%, rgba(0, 0, 0, 0.55) 100%); + opacity: 0; + transition: opacity 0.4s ease; +} + +.live-main .live-cover-veil[hidden] { + display: none !important; +} + +.live-main .live-cover-veil.is-active { + opacity: 1; +} + +.live-main .live-cover-veil__hint { + margin: 0; + padding: 10px 18px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.65); + color: #fff; + font-size: clamp(0.9rem, 2.5vw, 1.1rem); + font-weight: 600; + text-align: center; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8); } .live-main .badge { @@ -24,17 +298,24 @@ .live-main .badge-live { background: #e53935; color: #fff; } .live-main .badge-on-air { background: #2e7d32; color: #fff; } -.live-main .badge-wait { background: #444; color: #ddd; } +.live-main .badge-wait { background: rgba(0, 0, 0, 0.65); color: #fff; border: 1px solid rgba(255, 255, 255, 0.35); } .live-main .badge-connecting { background: #f57c00; color: #fff; } .live-main .badge-ended { background: #374151; color: #ddd; } .live-main .stream-ended { + position: absolute; + inset: 0; + z-index: 4; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; text-align: center; - padding: 48px 24px; - margin: 16px 0; + padding: 24px; + margin: 0; background: linear-gradient(160deg, #14141c 0%, #0a0a0e 100%); - border: 1px dashed #2a2a36; - border-radius: 16px; + border: none; + border-radius: 0; } .live-main .stream-ended-icon { diff --git a/backend/spec/services/scoring/sync_state_spec.rb b/backend/spec/services/scoring/sync_state_spec.rb new file mode 100644 index 0000000..8c6aa94 --- /dev/null +++ b/backend/spec/services/scoring/sync_state_spec.rb @@ -0,0 +1,33 @@ +require "rails_helper" + +RSpec.describe Scoring::SyncState do + let!(:user) { User.create!(email: "sync@test.it", name: "U", password: "password123", role: "coach") } + let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + let!(:team) { club.teams.create!(name: "T", sport: "volleyball") } + let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball", sets_to_win: 3) } + let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live") } + + it "persiste punti zero dopo chiusura set" do + session.create_score_state!( + home_sets: 1, away_sets: 0, home_points: 25, away_points: 20, current_set: 2, + set_partials: [{ "set" => 1, "home" => 25, "away" => 20 }] + ) + + described_class.new( + session: session, + payload: { + "home_sets" => 1, + "away_sets" => 0, + "home_points" => 0, + "away_points" => 0, + "current_set" => 2, + "set_partials" => [{ "set" => 1, "home" => 25, "away" => 20 }] + } + ).call + + score = session.score_state.reload + expect(score.home_points).to eq(0) + expect(score.away_points).to eq(0) + expect(score.current_set).to eq(2) + end +end diff --git a/docs/LIVE_STREAMING.md b/docs/LIVE_STREAMING.md new file mode 100644 index 0000000..e2769c3 --- /dev/null +++ b/docs/LIVE_STREAMING.md @@ -0,0 +1,102 @@ +# Diretta live — architettura MediaMTX + +## Idea (non è folle) + +MediaMTX espone **un unico flusso di uscita** per path (`live/match_{uuid}`): + +1. **Telefono in onda** → publisher RTMP (camera). +2. **Pausa / rete assente** → `alwaysAvailable` con file slate (`/slates/offline.mp4`) **senza interrompere** l’HLS verso il sito. +3. **YouTube** → `ffmpeg` in container Rails/Sidekiq legge **sempre** quell’uscita (`-c copy`) e inoltra a `rtmp://a.rtmp.youtube.com/live2/...` per tutta la sessione. + +Il telefono **non** invia la copertina: risparmia banda; lo switch è lato server. + +## Componenti + +| Pezzo | Ruolo | +|--------|--------| +| App Android | RTMP 48 kHz mono, 720p — solo quando in onda | +| MediaMTX | Path dinamico, `alwaysAvailable` + slate | +| `Streams::YoutubeRelay` | Relay continuo verso YouTube (no hook wget su distroless) | +| `Mediamtx::PublisherSync` | Stato Rails da API paths (publisher online) | +| `/hls/...` (Rails proxy) | Player web | + +## Pausa e continuità + +1. API `PATCH /sessions/:id/pause` → stato `paused`, registrazione off. +2. App → `pauseStream()` (stop RTMP). +3. MediaMTX → passa alla slate sul **medesimo path** (senza interrompere l’uscita HLS). +4. **Ripresa** → API `resume` → `connecting`, recording on, app `resumeStream()`; MediaMTX concatena di nuovo camera sulla stessa uscita HLS. +5. **Sito** → **un solo** player HLS per tutta la sessione (mai reload in pausa/ripresa). Copertina brand (`brand/CopertinaCanale_6.png` → `infra/slates/offline.mp4`) muxata da MediaMTX; in pausa solo un messaggio leggero sopra il video. +6. **YouTube** → relay ffmpeg continuo; vede la stessa uscita MediaMTX. + +Non fare `patch` del path MediaMTX in pausa: ricarica il path e interrompe gli HLS reader. + +**Recording**: disabilitato sul path live (`record: false`) — il recorder MediaMTX al switch slate→camera distruggeva il muxer HLS (`too many reordered frames`). I replay vanno gestiti con job dedicato (vedi `REPLAY_MODULE.md`). + +Il player web resta attivo finché `ready|available|online` sul path (non solo quando il telefono è `online`). + +## Infrastruttura vs browser + +``` +Telefono RTMP ──► MediaMTX path live/match_{uuid} + │ + publisher ON ├─► camera (H.264/AAC) + publisher OFF└─► alwaysAvailable → /slates/offline.mp4 + │ + ├─► HLS (segmenti ~1s) ──► proxy Rails /hls/... ──► player web (HLS.js) + └─► RTMP lettura ──► Streams::YoutubeRelay (ffmpeg -c copy) ──► YouTube +``` + +| Responsabilità | Dove | +|----------------|------| +| Switch copertina ↔ live | **MediaMTX** (stesso path, stesso URL HLS) | +| Continuità YouTube | **Relay ffmpeg** sulla stessa uscita | +| Badge «In onda» / stato pausa | **Rails** (`status.json`, `PublisherSync`) | +| Uscire dal buffer copertina dopo ripresa | **Browser** (`pendingLiveRecovery`, reload HLS) | +| Fluidità playback | **Browser** (evitare seek continui su `liveSyncPosition`) | + +Lo switch slate→camera **non** richiede un nuovo URL lato player: MediaMTX concatena sulla playlist. Il sito può però restare «indietro» nel buffer HLS (ancora segmenti della slate) anche con `publisher_online: true` — da qui i recovery controllati in `show.html.erb`, **senza** chiamare `jumpToLiveEdge()` a ogni frammento bufferizzato. + +### Player web — anti-scatti (HLS.js) + +Configurazione in `backend/app/views/public/live/show.html.erb`: + +- `maxLiveSyncPlaybackRate: 1.08` (evita accelerazioni visibili; prima 1.5 causava micro-scatti). +- `jumpToLiveEdge(force)` con throttle: seek solo se lag > 2s o `force`, e non più di una volta ogni ~8s se lag < 4s. +- **Niente** seek su `FRAG_BUFFERED` né nel poll ogni 1.5s. +- Sync iniziale una volta su `LEVEL_LOADED`; recovery solo se `liveEdgeLagSec() > 4` o `pendingLiveRecovery`. +- Interval 6s solo durante recovery attivo (`tryEscapeCoverBuffer`). + +### Grafica nel video (overlay server) + +Tabellone, badge stato («In onda», «In pausa», «Copertina», …) e banner **Match Live TV** sono **bruciati nel flusso** da `Streams::OverlayRelay`: + +``` +Telefono → live/match_{uuid} (grezzo) + └─► ffmpeg + PNG 1280×720 (Streams::Overlay::Refresh ogni ~2s) + └─► live/match_{uuid}_air + ├─► HLS (sito, anche fullscreen) + └─► YouTube relay (-c copy) +``` + +- `Streams::Overlay::SvgBuilder` + `rsvg-convert` generano `tmp/stream_overlays/{session_id}/overlay.png` +- `Streams::Overlay::Refresh` + `bin/overlay_png_feeder.rb` aggiornano la PNG ogni ~2s (e su punteggio/stato); il feeder la invia a ffmpeg via FIFO perché `-loop 1` non rilegge il file su disco +- Volume Docker condiviso `stream_overlays` tra **rails** (ffmpeg) e **sidekiq** (OverlayRefreshJob); altrimenti il job scrive PNG su un filesystem diverso e badge/punteggio restano congelati +- La pagina live **non** sovrappone più HTML sul player (evita incongruenze con fullscreen / YouTube) +- **Qualità video**: il relay normalizza a **30 fps CFR** (l’app RTMP può inviare timestamp errati) e ricodifica a **≥4.5 Mbps** (`fast`, no B-frame). Override: `OVERLAY_RELAY_VIDEO_KBPS`, `OVERLAY_RELAY_X264_PRESET`, `OVERLAY_RELAY_FPS`. + +### Punteggio (persistenza) + +L’app mobile persiste il punteggio con `PATCH /api/v1/sessions/:id/score` (`Scoring::SyncState`), poi refresh overlay. + +`GET /live/:id/status.json` resta per messaggi sotto il player e recovery HLS; `Cache-Control: no-store`. + +## Rigenerare la slate + +```bash +cd infra +bash scripts/generate_slate.sh +# Copia su server: infra/slates/offline.mp4 montato in /slates nel container mediamtx +``` + +Formato obbligatorio: **H.264 1280×720 30fps, AAC 48 kHz mono** (allineato all’app). diff --git a/docs/REPLAY_MODULE.md b/docs/REPLAY_MODULE.md index abbfa22..a14f12c 100644 --- a/docs/REPLAY_MODULE.md +++ b/docs/REPLAY_MODULE.md @@ -17,6 +17,8 @@ Al termine di ogni diretta Premium, MediaMTX registra i segmenti. Sidekiq unisce ### 1. Registrazione automatica Pipeline: `Sessions::Stop` → `FinalizeSession` → `UploadJob` → storage. +MediaMTX registra **solo mentre il telefono pubblica** (RTMP connesso). In pausa o con sola slate `alwaysAvailable` la registrazione è disattivata, così il replay non contiene minuti di schermo «Trasmissione in pausa». + ### 2. Retention e purge `Recordings::PurgeExpiredJob` / `rails recordings:purge_expired` @@ -86,11 +88,31 @@ bash scripts/setup_garage_production.sh # garage.prod.toml, bucket, chiavi → - `REPLAY_STORAGE_ACCESS_KEY_ID` = Key ID Garage (es. `GK...`) - `REPLAY_STORAGE_SECRET_ACCESS_KEY` = Secret mostrato una sola volta alla creazione chiave -## Cron consigliati (produzione) +## Cron (produzione) -```cron -0 3 * * * cd /opt/matchlivetv/infra && docker compose exec -T rails bundle exec rails recordings:purge_expired -0 8 * * * cd /opt/matchlivetv/infra && docker compose exec -T rails bundle exec rails recordings:expiry_warnings +Installazione sul server: + +```bash +cd /opt/matchlivetv/infra +bash scripts/install_production_cron.sh +``` + +Job attivi (utente `eminux`): + +| Orario | Task | +|--------|------| +| 03:00 | `recordings:purge_expired` — elimina replay scaduti (DB + Garage) | +| 08:00 | `recordings:expiry_warnings` — email avviso scadenza (7 gg) | + +Log: `/opt/matchlivetv/log/cron-replay.log` + +### Capacità Garage + +All’installazione, `setup_garage_production.sh` assegna ~**(disco libero − 20 GB)** al nodo. +Per ridimensionare dopo: + +```bash +bash scripts/garage_set_capacity.sh 85G # oppure senza argomento: calcolo automatico ``` ## API mobile diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml index af57683..0b80718 100644 --- a/infra/docker-compose.prod.yml +++ b/infra/docker-compose.prod.yml @@ -115,6 +115,7 @@ services: condition: service_started volumes: - recordings:/recordings + - stream_overlays:/app/tmp/stream_overlays healthcheck: test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/up"] interval: 15s @@ -150,6 +151,7 @@ services: REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-} REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-} REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true} + OVERLAY_RELAY_PROCESS_CHECK: "false" depends_on: rails: condition: service_healthy @@ -157,6 +159,7 @@ services: condition: service_started volumes: - recordings:/recordings + - stream_overlays:/app/tmp/stream_overlays garage: image: dxflrs/garage:v1.0.1 @@ -172,5 +175,6 @@ volumes: postgres_data: redis_data: recordings: + stream_overlays: garage_meta: garage_data: diff --git a/infra/mediamtx.yml b/infra/mediamtx.yml index 2959ce8..707151f 100644 --- a/infra/mediamtx.yml +++ b/infra/mediamtx.yml @@ -22,11 +22,15 @@ rtmpAddress: :1935 hls: yes hlsAddress: :8888 hlsVariant: mpegts +# Allineato a slate (1s) e camera; evita salti 1s→6s che glitchano HLS.js +hlsSegmentDuration: 1s +hlsSegmentMaxSize: 50M pathDefaults: source: publisher overridePublisher: yes - record: yes + # Recording solo via job dedicato: il recorder inline crasha il muxer HLS al switch slate→live + record: false recordPath: /recordings/%path/%Y-%m-%d_%H-%M-%S recordSegmentDuration: 60s recordDeleteAfter: 48h @@ -37,9 +41,5 @@ paths: # match_{uuid}: # alwaysAvailable: true # alwaysAvailableFile: /slates/offline.mp4 - # alwaysAvailableTracks: - # - codec: H264 - # - codec: MPEG4Audio - # sampleRate: 48000 - # channelCount: 2 + # (non usare alwaysAvailableTracks insieme al file — MediaMTX >= 2026.02) all_others: diff --git a/infra/scripts/cron/run_rails_task.sh b/infra/scripts/cron/run_rails_task.sh new file mode 100755 index 0000000..b7e481c --- /dev/null +++ b/infra/scripts/cron/run_rails_task.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Esegue un task Rails in produzione (usato da crontab). +set -euo pipefail + +ROOT="${MATCHLIVETV_INFRA:-/opt/matchlivetv/infra}" +cd "$ROOT" + +exec docker compose -f docker-compose.prod.yml --env-file .env exec -T rails \ + bundle exec rails "$@" diff --git a/infra/scripts/garage_set_capacity.sh b/infra/scripts/garage_set_capacity.sh new file mode 100755 index 0000000..aa47db4 --- /dev/null +++ b/infra/scripts/garage_set_capacity.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Ridimensiona capacità Garage (layout assign + apply). Uso: garage_set_capacity.sh 85G +set -euo pipefail + +CAPACITY="${1:-}" +RESERVE_GB="${GARAGE_RESERVE_GB:-20}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}" +ENV_FILE="${ENV_FILE:-${ROOT}/.env}" +GARAGE_CONTAINER="${GARAGE_CONTAINER:-}" + +if [ -z "$CAPACITY" ]; then + avail="$(df -BG "${ROOT}" 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G' || echo 0)" + if [ "$avail" -lt 1 ]; then + avail="$(df -BG / 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G')" + fi + cap_gb=$((avail - RESERVE_GB)) + [ "$cap_gb" -lt 10 ] && cap_gb=10 + CAPACITY="${cap_gb}G" + echo "Spazio disponibile ~${avail}G, riservo ${RESERVE_GB}G → capacità Garage ${CAPACITY}" +fi + +cd "$ROOT" +if [ -z "$GARAGE_CONTAINER" ]; then + GARAGE_CONTAINER="$(docker ps --format '{{.Names}}' | grep -E 'garage-1$' | head -1)" +fi +[ -n "$GARAGE_CONTAINER" ] || { echo "Container garage non trovato"; exit 1; } + +node_id="$(docker exec "$GARAGE_CONTAINER" /garage status 2>/dev/null | awk '/^[0-9a-f]{16,}/{print $1; exit}')" +[ -n "$node_id" ] || { echo "Node id non trovato"; exit 1; } + +echo "Node: $node_id → capacità $CAPACITY" +docker exec "$GARAGE_CONTAINER" /garage layout assign -z dc1 -c "$CAPACITY" "$node_id" +ver="$(docker exec "$GARAGE_CONTAINER" /garage layout show 2>/dev/null | awk '/layout version:/{print $NF; exit}')" +next=$((ver + 1)) +docker exec "$GARAGE_CONTAINER" /garage layout apply --version "$next" +docker exec "$GARAGE_CONTAINER" /garage layout show diff --git a/infra/scripts/generate_slate.sh b/infra/scripts/generate_slate.sh index 4385943..e64e2cb 100755 --- a/infra/scripts/generate_slate.sh +++ b/infra/scripts/generate_slate.sh @@ -1,13 +1,33 @@ #!/bin/sh -# Generate offline slate MP4 for MediaMTX alwaysAvailable (pausa / attesa segnale) +# Slate MP4 per MediaMTX alwaysAvailable (pausa / attesa / perdita rete). +# H.264 1280x720 30fps + AAC 48kHz mono (allineato all'app Android). set -e -OUT_DIR="$(dirname "$0")/../slates" +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +OUT_DIR="$(cd "$(dirname "$0")/../slates" && pwd)" +BRAND_IMAGE="${BRAND_IMAGE:-$ROOT/brand/CopertinaCanale_6.png}" OUT="$OUT_DIR/offline.mp4" + +if [ ! -f "$BRAND_IMAGE" ]; then + echo "Manca immagine brand: $BRAND_IMAGE" >&2 + exit 1 +fi + mkdir -p "$OUT_DIR" -docker run --rm -v "$OUT_DIR:/out" linuxserver/ffmpeg:latest \ - -f lavfi -i color=c=0x1a1a1a:s=1280x720:r=30 \ - -vf "drawtext=text='Match Live TV':fontcolor=white:fontsize=48:x=(w-text_w)/2:y=(h-text_h)/2-40,drawtext=text='Trasmissione in pausa':fontcolor=0xaaaaaa:fontsize=28:x=(w-text_w)/2:y=(h-text_h)/2+20" \ - -f lavfi -i sine=frequency=440:sample_rate=48000 \ - -t 30 -c:v libx264 -pix_fmt yuv420p -g 30 -preset fast \ - -c:a aac -b:a 128k -y /out/offline.mp4 -echo "Created $OUT" +BRAND_DIR="$(cd "$(dirname "$BRAND_IMAGE")" && pwd)" +BRAND_FILE="$(basename "$BRAND_IMAGE")" + +docker run --rm \ + -v "$OUT_DIR:/out" \ + -v "$BRAND_DIR:/brand:ro" \ + linuxserver/ffmpeg:latest \ + -loop 1 -framerate 30 -i "/brand/$BRAND_FILE" \ + -f lavfi -i anullsrc=r=48000:cl=mono \ + -filter:v "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=0x0a0a0e" \ + -map 0:v -map 1:a \ + -t 30 -c:v libx264 -pix_fmt yuv420p -profile:v baseline -level 3.1 \ + -x264-params "keyint=30:min-keyint=30:scenecut=0:bframes=0" \ + -g 30 -keyint_min 30 -force_key_frames "expr:gte(t,n_forced*1)" \ + -preset fast \ + -c:a aac -b:a 128k -ac 1 -shortest -y /out/offline.mp4 + +echo "Created $OUT from $BRAND_IMAGE" diff --git a/infra/scripts/install_production_cron.sh b/infra/scripts/install_production_cron.sh new file mode 100755 index 0000000..1c3c60f --- /dev/null +++ b/infra/scripts/install_production_cron.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Installa crontab replay su server produzione (utente corrente). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +RUNNER="${ROOT}/scripts/cron/run_rails_task.sh" +LOG_DIR="${MATCHLIVETV_LOG_DIR:-/opt/matchlivetv/log}" +LOG_FILE="${LOG_DIR}/cron-replay.log" + +chmod +x "$RUNNER" +mkdir -p "$LOG_DIR" + +MARKER="# matchlivetv-replay-cron" +CRON_BLOCK="${MARKER} +0 3 * * * ${RUNNER} recordings:purge_expired >> ${LOG_FILE} 2>&1 +0 8 * * * ${RUNNER} recordings:expiry_warnings >> ${LOG_FILE} 2>&1 +" + +existing="$(crontab -l 2>/dev/null || true)" +if echo "$existing" | grep -q "$MARKER"; then + echo "Crontab replay già presente, aggiorno..." + echo "$existing" | awk -v block="$CRON_BLOCK" ' + BEGIN { skip=0 } + /# matchlivetv-replay-cron/ { skip=1; next } + skip && /^[^#]/ && $0 !~ /^$/ { skip=0 } + skip { next } + { print } + END { print block } + ' | crontab - +else + (echo "$existing"; echo "$CRON_BLOCK") | crontab - +fi + +echo "Crontab installato:" +crontab -l | grep -A3 "$MARKER" diff --git a/infra/scripts/setup_garage_production.sh b/infra/scripts/setup_garage_production.sh index 3f8ffae..63dad06 100755 --- a/infra/scripts/setup_garage_production.sh +++ b/infra/scripts/setup_garage_production.sh @@ -14,6 +14,13 @@ bash "${ROOT}/scripts/ensure_garage_prod_config.sh" echo "Avvio Garage..." docker compose -f docker-compose.prod.yml --env-file .env up -d garage +avail_gb="$(df -BG / 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G')" +reserve="${GARAGE_RESERVE_GB:-20}" +cap_gb=$((avail_gb - reserve)) +[ "$cap_gb" -lt 10 ] && cap_gb=10 +export GARAGE_NODE_CAPACITY="${GARAGE_NODE_CAPACITY:-${cap_gb}G}" +echo "Capacità nodo Garage: ${GARAGE_NODE_CAPACITY} (disco ~${avail_gb}G, riserva ${reserve}G)" + bash "${ROOT}/scripts/setup_garage_replays.sh" echo "Riavvio Rails e Sidekiq..." diff --git a/infra/scripts/setup_garage_replays.sh b/infra/scripts/setup_garage_replays.sh index 58aab1f..e5c7b1f 100755 --- a/infra/scripts/setup_garage_replays.sh +++ b/infra/scripts/setup_garage_replays.sh @@ -46,7 +46,8 @@ ensure_layout() { exit 1 fi echo "Node id: $node_id" - gexec layout assign -z dc1 -c 1G "$node_id" 2>/dev/null || true + local cap="${GARAGE_NODE_CAPACITY:-1G}" + gexec layout assign -z dc1 -c "$cap" "$node_id" 2>/dev/null || true gexec layout apply --version 1 2>/dev/null || gexec layout apply 2>/dev/null || true } diff --git a/infra/slates/README.md b/infra/slates/README.md index 69a6ec3..e3322c4 100644 --- a/infra/slates/README.md +++ b/infra/slates/README.md @@ -1,6 +1,12 @@ # Slate video (offline) -Place `offline.mp4` here — H.264 + AAC, 1280x720, 30fps, 48kHz stereo. +Genera da `brand/CopertinaCanale_6.png`: + +```bash +cd infra && bash scripts/generate_slate.sh +``` + +Output: `offline.mp4` — H.264 + AAC, 1280×720, 30fps, 48 kHz mono. Must match phone encoder settings for seamless `alwaysAvailable` merge. Generate a test slate with ffmpeg: diff --git a/infra/slates/offline.mp4 b/infra/slates/offline.mp4 index c870254..12df62e 100644 Binary files a/infra/slates/offline.mp4 and b/infra/slates/offline.mp4 differ diff --git a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngine.kt b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngine.kt index 7d10154..cab0e3a 100644 --- a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngine.kt +++ b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngine.kt @@ -1,6 +1,7 @@ package com.matchlivetv.match_live_tv import android.content.Context +import android.media.MediaCodecInfo import android.os.Build import android.os.Handler import android.os.Looper @@ -21,8 +22,9 @@ data class StreamingConfig( val audioBitrate: Int = 128_000, val fps: Int = 30, val rotation: Int = 0, - val sampleRate: Int = 44_100, - val stereo: Boolean = true, + // Allineato a MediaMTX alwaysAvailable slate (offline.mp4: 48 kHz mono) + val sampleRate: Int = 48_000, + val stereo: Boolean = false, val maxReconnectAttempts: Int = 10, val reconnectDelayMs: Long = 5_000L, ) @@ -144,11 +146,14 @@ class StreamingEngine( stopPreviewAndStreamForPrepare(stream) val prepared = try { stream.prepareVideo( - cfg.width, - cfg.height, - cfg.videoBitrate, - cfg.fps, + width = cfg.width, + height = cfg.height, + bitrate = cfg.videoBitrate, + fps = cfg.fps, + iFrameInterval = 1, rotation = cfg.rotation, + profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline, + level = MediaCodecInfo.CodecProfileLevel.AVCLevel31, ) && stream.prepareAudio( cfg.sampleRate, cfg.stereo, @@ -181,6 +186,45 @@ class StreamingEngine( } } + /** Ripresa dopo pausa: stop completo poi ripubblica (evita doppio start e RTMP bloccato). */ + fun resumeStream(streamConfig: StreamingConfig) { + ensureMainThread() + config = streamConfig + lastError = null + reconnectAttempts.set(0) + stopMetricsLoop() + + genericStream?.let { stream -> + pausingIntentionally.set(true) + try { + if (stream.isStreaming) { + stream.stopStream() + } + } finally { + pausingIntentionally.set(false) + } + } + + if (state == StreamingState.STREAMING || + state == StreamingState.CONNECTING || + state == StreamingState.RECONNECTING + ) { + pauseStream() + } + + streamStartedAtMs = 0L + currentBitrateKbps = 0L + currentFps = 0 + updateState(StreamingState.PREVIEWING) + startMetricsLoop() + + mainHandler.postDelayed({ + if (!isReleased.get()) { + startStream(streamConfig) + } + }, 200) + } + fun startStream(streamConfig: StreamingConfig) { ensureMainThread() if (isReleased.get()) { @@ -204,11 +248,14 @@ class StreamingEngine( val prepared = try { stream.prepareVideo( - streamConfig.width, - streamConfig.height, - streamConfig.videoBitrate, - streamConfig.fps, + width = streamConfig.width, + height = streamConfig.height, + bitrate = streamConfig.videoBitrate, + fps = streamConfig.fps, + iFrameInterval = 1, rotation = streamConfig.rotation, + profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline, + level = MediaCodecInfo.CodecProfileLevel.AVCLevel31, ) && stream.prepareAudio( streamConfig.sampleRate, streamConfig.stereo, @@ -347,12 +394,7 @@ class StreamingEngine( override fun onNewBitrate(bitrate: Long) { postMain { currentBitrateKbps = bitrate / 1000 - genericStream?.let { stream -> - bitrateAdapter.adaptBitrate( - bitrate, - stream.getStreamClient().hasCongestion(), - ) - } + // Bitrate fissa: l'adattamento on-the-fly causa glitch e GOP instabili su MediaMTX/HLS. emitMetrics() } } @@ -470,8 +512,8 @@ class StreamingEngine( val elapsedMs = now - lastFpsSampleAtMs if (elapsedMs >= 1_000L) { - val frameDelta = sentFrames - lastVideoFrameCount - currentFps = ((frameDelta * 1000L) / elapsedMs).toInt() + val frameDelta = (sentFrames - lastVideoFrameCount).coerceAtLeast(0L) + currentFps = ((frameDelta * 1000L) / elapsedMs).toInt().coerceIn(0, 120) lastVideoFrameCount = sentFrames lastFpsSampleAtMs = now } diff --git a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPlugin.kt b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPlugin.kt index d226295..af43f84 100644 --- a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPlugin.kt +++ b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPlugin.kt @@ -127,6 +127,7 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh override fun onMethodCall(call: MethodCall, result: Result) { when (call.method) { "startStream" -> startStream(call, result) + "resumeStream" -> resumeStream(call, result) "stopStream" -> stopStream(result) "pauseStream" -> pauseStream(result) "getMetrics" -> getMetrics(result) @@ -194,6 +195,53 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh } } + private fun resumeStream(call: MethodCall, result: Result) { + if (activityBinding?.activity == null) { + result.error("no_activity", "Activity non disponibile per lo streaming", null) + return + } + + val args = call.arguments as? Map<*, *> + val rtmpUrl = args?.get("rtmpUrl") as? String + if (rtmpUrl.isNullOrBlank()) { + result.error("invalid_args", "rtmpUrl is required", null) + return + } + + val config = StreamingConfig( + rtmpUrl = rtmpUrl, + width = (args["width"] as? Number)?.toInt() ?: 1280, + height = (args["height"] as? Number)?.toInt() ?: 720, + videoBitrate = (args["videoBitrate"] as? Number)?.toInt() ?: 2_500_000, + audioBitrate = (args["audioBitrate"] as? Number)?.toInt() ?: 128_000, + fps = (args["fps"] as? Number)?.toInt() ?: 30, + rotation = (args["rotation"] as? Number)?.toInt() ?: 0, + maxReconnectAttempts = (args["maxReconnectAttempts"] as? Number)?.toInt() ?: 10, + reconnectDelayMs = (args["reconnectDelayMs"] as? Number)?.toLong() ?: 5_000L, + ) + + val engine = getOrCreateEngine() + engine.removeListener(engineListener) + engine.addListener(engineListener) + attachPreviewIfPossible() + + val intent = StreamingForegroundService.startIntent(appContext) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + appContext.startForegroundService(intent) + } else { + appContext.startService(intent) + } + bindServiceIfNeeded() + + try { + engine.resumeStream(config) + result.success(true) + } catch (exception: Exception) { + appContext.startService(StreamingForegroundService.stopIntent(appContext)) + result.error("resume_failed", exception.message, null) + } + } + private fun stopStream(result: Result) { appContext.startService(StreamingForegroundService.stopIntent(appContext)) getEngine()?.let { engine -> diff --git a/mobile/lib/features/camera_mode/camera_screen.dart b/mobile/lib/features/camera_mode/camera_screen.dart index 7a1c768..7fcc26e 100644 --- a/mobile/lib/features/camera_mode/camera_screen.dart +++ b/mobile/lib/features/camera_mode/camera_screen.dart @@ -14,6 +14,7 @@ import '../../platform/streaming_channel.dart'; import '../../platform/streaming_preview.dart'; import '../../providers/match_rules_provider.dart'; import '../../providers/score_provider.dart'; +import '../../providers/score_sync_provider.dart'; import '../../shared/api_client.dart'; import '../../shared/regia_share.dart'; import '../../shared/watch_share.dart'; @@ -42,6 +43,7 @@ class _CameraScreenState extends ConsumerState