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>
416 lines
17 KiB
Markdown
416 lines
17 KiB
Markdown
# Architettura — Match Live TV
|
||
|
||
Documento di riferimento per l'intero sistema: rete, container Docker, flussi video, replay, monitoraggio ops e rilasci.
|
||
|
||
**Ultimo aggiornamento:** 2026-06-14
|
||
**Produzione:** `eminux@192.168.1.146` → `/opt/matchlivetv`
|
||
|
||
---
|
||
|
||
## Indice
|
||
|
||
1. [Panoramica](#panoramica)
|
||
2. [Topologia di rete](#topologia-di-rete)
|
||
3. [Stack Docker (produzione)](#stack-docker-produzione)
|
||
4. [Proxy edge (nginx)](#proxy-edge-nginx)
|
||
5. [Diretta live](#diretta-live)
|
||
6. [Replay e storage](#replay-e-storage)
|
||
7. [Controllo applicativo](#controllo-applicativo)
|
||
8. [Monitoraggio ops](#monitoraggio-ops)
|
||
9. [Rilasci e downtime](#rilasci-e-downtime)
|
||
10. [Sviluppo locale vs produzione](#sviluppo-locale-vs-produzione)
|
||
11. [Documentazione correlata](#documentazione-correlata)
|
||
|
||
---
|
||
|
||
## Panoramica
|
||
|
||
Match Live TV è una piattaforma mobile-first per streaming di partite sportive giovanili.
|
||
|
||
| Area | Tecnologia |
|
||
|------|------------|
|
||
| Backend | Rails 7.2 (API REST, Action Cable, admin, pagine pubbliche) |
|
||
| Job asincroni | Sidekiq + Redis |
|
||
| Database | PostgreSQL 16 |
|
||
| Ingest video | MediaMTX (RTMP :1935) |
|
||
| Playback live | HLS (MediaMTX :8888) |
|
||
| Replay | Garage (S3-compatible) + MP4 |
|
||
| App mobile | Android nativo (Kotlin + Compose, RTMP); iOS nativo (SwiftUI + HaishinKit, RTMP) — parità UI/API |
|
||
| Edge LAN | Nginx (`edge`) sulla porta host **3000** |
|
||
| HTTPS pubblico | Nginx Proxy Manager (NPM) → `192.168.1.146:3000` |
|
||
|
||
**Principio guida:** il traffico video pesante (HLS live, MP4 replay) **non passa da Puma**. Rails gestisce autenticazione, logica di business e redirect verso storage/streaming; nginx edge e MediaMTX servono i byte.
|
||
|
||
---
|
||
|
||
## Topologia di rete
|
||
|
||
```
|
||
Internet
|
||
│
|
||
┌──────────────────┼──────────────────┐
|
||
│ │ │
|
||
Router :443 Router :1935 (opz. :8888)
|
||
│ │ │
|
||
▼ ▼ ▼
|
||
Nginx Proxy Manager MediaMTX RTMP MediaMTX HLS
|
||
(LAN, TLS) (host :1935) (host :8888, opz.)
|
||
│
|
||
│ forward http://192.168.1.146:3000
|
||
▼
|
||
┌──────────────────────────────────────────────────────────┐
|
||
│ Server 192.168.1.146 — Docker Compose (rete interna) │
|
||
│ │
|
||
│ ┌─────────┐ ┌─────────┐ ┌──────────┐ │
|
||
│ │ edge │────►│ rails │────►│ postgres │ │
|
||
│ │ nginx │ │ Puma │ └──────────┘ │
|
||
│ │ :3000 │ └────┬────┘ ┌──────────┐ │
|
||
│ └────┬────┘ │ │ redis │ │
|
||
│ │ │ └──────────┘ │
|
||
│ │ ┌────▼────┐ ┌──────────┐ │
|
||
│ │ │ sidekiq │────►│ garage │ │
|
||
│ │ └────┬────┘ │ (S3) │ │
|
||
│ │ │ └──────────┘ │
|
||
│ ├───────────────┼────────► mediamtx :8888/:1935 │
|
||
│ └───────────────┴────────► garage :3900 │
|
||
│ │
|
||
│ ntfy :8090 (LAN) — notifiche push ops │
|
||
└──────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
Telefono Android ──RTMP──► :1935 (WAN)
|
||
```
|
||
|
||
### Porte
|
||
|
||
| Porta | Esposizione | Servizio | Note |
|
||
|-------|-------------|----------|------|
|
||
| **443** | NPM (altro host LAN) | HTTPS sito | Forward a `:3000` (edge) |
|
||
| **1935** | WAN → server | RTMP ingest | Obbligatoria per streaming da 4G |
|
||
| **3000** | Solo LAN | edge nginx | Punto di ingresso NPM |
|
||
| **8090** | Solo LAN | ntfy | Proxy HTTPS opzionale (`ntfy.matchlivetv.it`) |
|
||
| **8888** | Opzionale WAN | HLS MediaMTX | Di solito non serve: HLS passa da NPM → edge → `/hls/` |
|
||
| 5432, 6379, 3900, 9997 | Solo Docker | DB, Redis, Garage API, MediaMTX API | Mai su WAN |
|
||
|
||
---
|
||
|
||
## Stack Docker (produzione)
|
||
|
||
File: [`infra/docker-compose.prod.yml`](../infra/docker-compose.prod.yml)
|
||
|
||
| Servizio | Immagine / build | Ruolo |
|
||
|----------|------------------|-------|
|
||
| **postgres** | `postgres:16-alpine` | Dati applicativi |
|
||
| **redis** | `redis:7-alpine` | Sidekiq, cache, campioni latenza ops |
|
||
| **mediamtx** | `bluenviron/mediamtx` | RTMP ingest, HLS, API path dinamici |
|
||
| **garage** | `dxflrs/garage` | Object storage S3 per replay MP4/thumbnail |
|
||
| **rails** | build `backend/` | API, web, Action Cable, Puma |
|
||
| **sidekiq** | build `backend/` | Job, relay ffmpeg (YouTube, overlay), ops |
|
||
| **edge** | `nginx:alpine` | Reverse proxy LAN, routing video, pagina cortesia |
|
||
| **ntfy** | `binwiederhier/ntfy` | Push ops su telefono |
|
||
|
||
### Volumi persistenti
|
||
|
||
Root dati: `${MATCHLIVETV_VIDEOS_ROOT}` (produzione: `/media/videos/matchlivetv`)
|
||
|
||
| Path host | Contenuto |
|
||
|-----------|-----------|
|
||
| `.../recordings` | Segmenti registrazione MediaMTX, staging replay |
|
||
| `.../active_storage` | Allegati Rails (loghi società, ecc.) |
|
||
| `.../log` | Log applicativi + cron (`cron-replay.log`, `cron-ops.log`) |
|
||
| `.../garage/meta`, `.../garage/data` | Metadati e blob Garage |
|
||
| `postgres_data`, `redis_data`, `ntfy_data` | Volumi Docker nominati |
|
||
|
||
### Puma
|
||
|
||
`RAILS_MAX_THREADS=5` in produzione. I thread Puma sono riservati ad API, pagine HTML, WebSocket e auth — non al proxy di flussi video lunghi.
|
||
|
||
---
|
||
|
||
## Proxy edge (nginx)
|
||
|
||
File: [`infra/nginx-edge/nginx.conf`](../infra/nginx-edge/nginx.conf)
|
||
|
||
NPM termina TLS e inoltra **tutto** il traffico HTTP a `edge:80` (host `:3000`). Edge smista per path:
|
||
|
||
| Location | Destinazione | Passa da Puma? |
|
||
|----------|--------------|----------------|
|
||
| `/hls/` | `mediamtx:8888` | **No** |
|
||
| `/media/` | `garage:3900` | **No** |
|
||
| `/up`, `/up/deep` | `rails:3000` | Sì (pass-through, no cortesia) |
|
||
| `/cable` | `rails:3000` | Sì (WebSocket) |
|
||
| `/` | `rails:3000` | Sì (con pagina cortesia su 502/503/504) |
|
||
|
||
### HLS live (`/hls/`)
|
||
|
||
Introdotto per evitare saturazione Puma durante dirette con molti spettatori (migliaia di richieste segmenti `.ts` / `.m3u8`).
|
||
|
||
```
|
||
Browser ──► NPM ──► edge /hls/live/match_{uuid}/index.m3u8
|
||
│ rewrite → /live/match_{uuid}/index.m3u8
|
||
└──► mediamtx:8888
|
||
```
|
||
|
||
- Cookie MediaMTX: se il browser non invia cookie, edge aggiunge `cookieCheck=1` (map nginx).
|
||
- `proxy_buffering off`, `proxy_read_timeout 3600s` per streaming lungo.
|
||
- URL pubblico: `https://www.matchlivetv.it/hls/live/match_{uuid}/index.m3u8` (`HLS_PUBLIC_URL`).
|
||
|
||
**NPM:** non serve più una custom location `/hls` verso `:8888`. Un unico Proxy Host verso `:3000` è sufficiente; edge instrada internamente.
|
||
|
||
**Fallback dev:** `HlsProxyController` (`/hls/*` su Rails) resta per sviluppo locale senza container `edge`.
|
||
|
||
### Replay media (`/media/`)
|
||
|
||
Introdotto per evitare che Puma faccia da proxy dei file MP4 (decine/hundreds di MB per richiesta).
|
||
|
||
```
|
||
Browser ──► /replay/:id/stream
|
||
│
|
||
▼
|
||
Rails: auth + Recordings::PublicMediaUrl
|
||
│
|
||
▼ 302 redirect
|
||
/media/replays/...?X-Amz-Signature=...
|
||
│
|
||
▼
|
||
edge ──► garage:3900 (Host: garage:3900, Range support)
|
||
```
|
||
|
||
- `REPLAY_MEDIA_REDIRECT=true` (default produzione).
|
||
- Rails genera URL presigned S3 sull'endpoint interno e lo riscrive su `APP_PUBLIC_URL/media/...`.
|
||
- Fallback senza Garage: `Recordings::ServeFromStorage` (proxy Rails, solo dev/legacy).
|
||
|
||
### Pagina di cortesia
|
||
|
||
Durante restart Rails, `edge` risponde con HTML statico (`@maintenance`) invece di lasciare NPM con 502. Eccezioni: `/up`, `/up/deep` (monitoraggio ops).
|
||
|
||
---
|
||
|
||
## Diretta live
|
||
|
||
Vedi anche [`LIVE_STREAMING.md`](LIVE_STREAMING.md) per pause, overlay e player HLS.js.
|
||
|
||
### Flusso ingest
|
||
|
||
```
|
||
App Android (RTMP 720p, AAC mono)
|
||
│
|
||
▼
|
||
MediaMTX :1935 — path live/match_{uuid} (grezzo, telefono)
|
||
│
|
||
▼ overlay ffmpeg (Streams::OverlayRelay)
|
||
path live/match_{uuid}_air (tabellone + badge bruciati nel video)
|
||
│
|
||
├──► HLS :8888 ──► edge /hls/ ──► spettatori web
|
||
└──► ffmpeg -c copy ──► YouTube RTMP (Streams::YoutubeRelay, Premium)
|
||
```
|
||
|
||
| Componente | Ruolo |
|
||
|------------|--------|
|
||
| `Mediamtx::PathManager` | Crea path dinamici via API :9997 |
|
||
| `alwaysAvailable` + slate | Copertina `/slates/offline.mp4` senza interrompere HLS |
|
||
| `Streams::OverlayRelay` | Ricodifica con PNG 1280×720 aggiornata ogni ~2s |
|
||
| `Streams::YoutubeRelay` | Legge HLS da `mediamtx:8888` (non più via Rails) |
|
||
| `Mediamtx::PublisherSync` | Stato publisher online/offline in Rails |
|
||
|
||
### Pausa e continuità
|
||
|
||
1. App chiama `PATCH /sessions/:id/pause` → stop RTMP.
|
||
2. MediaMTX passa alla slate sullo **stesso path** (stesso URL HLS).
|
||
3. YouTube relay continua sulla stessa uscita (vede slate).
|
||
4. Ripresa: `resume` → app ripubblica RTMP; MediaMTX concatena camera senza nuovo URL.
|
||
|
||
**Recording inline MediaMTX disabilitato** (`record: false` su path live): il recorder al switch slate→camera distruggeva il muxer HLS. I replay usano job dedicato (vedi sotto).
|
||
|
||
### Pagina live web
|
||
|
||
- URL: `/live/:session_id`
|
||
- Player: HLS.js su `HLS_PUBLIC_URL/live/match_{uuid}_air/index.m3u8`
|
||
- Stato: `GET /live/:id/status.json` (badge, recovery buffer, no-store)
|
||
|
||
---
|
||
|
||
## Replay e storage
|
||
|
||
Vedi [`REPLAY_MODULE.md`](REPLAY_MODULE.md) per piani, retention, cron e API.
|
||
|
||
### Pipeline post-diretta
|
||
|
||
```
|
||
Sessions::Stop → FinalizeSession → UploadJob
|
||
│
|
||
▼
|
||
Garage bucket matchlivetv-replays (MP4 + thumbnail)
|
||
│
|
||
├──► Email «replay pronto»
|
||
├──► /replay/:id (player web)
|
||
└──► PublishToYoutubeJob (Premium Full, se diretta era su YouTube)
|
||
```
|
||
|
||
### Delivery al browser
|
||
|
||
| Endpoint Rails | Azione |
|
||
|----------------|--------|
|
||
| `GET /replay/:id` | Pagina player |
|
||
| `GET /replay/:id/stream` | Auth → redirect 302 → `/media/...` |
|
||
| `GET /replay/:id/thumbnail` | Auth → redirect 302 → `/media/...` |
|
||
| `GET /replay/:id/download` | Auth premium → redirect attachment |
|
||
|
||
### YouTube — doppia presenza (Premium Full)
|
||
|
||
| Tab / canale | Contenuto |
|
||
|--------------|-----------|
|
||
| **Live** (diretta YouTube) | Archivio stream live sul canale |
|
||
| **Video** (VOD) | Republicazione MP4 automatica via `PublishToYoutubeJob` |
|
||
|
||
---
|
||
|
||
## Controllo applicativo
|
||
|
||
```
|
||
App Android ◄──── REST API (/api/v1/...) ────► Rails
|
||
│ │
|
||
│ Action Cable (/cable) │
|
||
└──────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
PostgreSQL
|
||
│
|
||
Sidekiq ◄── Redis ◄──────┘
|
||
│
|
||
├── HealthMonitorJob, UploadJob, overlay refresh
|
||
├── YoutubeRelay, OverlayRelay (ffmpeg)
|
||
└── PublishToYoutubeJob, purge replay, ecc.
|
||
```
|
||
|
||
| Canale | Uso |
|
||
|--------|-----|
|
||
| REST JWT | Login, sessioni, punteggio, configurazione stream |
|
||
| Action Cable | Tabellone live, aggiornamenti regia (in sviluppo su app) |
|
||
| Webhook MediaMTX | Eventi publisher connect/disconnect |
|
||
|
||
---
|
||
|
||
## Monitoraggio ops
|
||
|
||
Vedi [`OPS_MONITORING.md`](OPS_MONITORING.md) per setup ntfy e troubleshooting.
|
||
|
||
### Componenti
|
||
|
||
| Pezzo | Ruolo |
|
||
|-------|--------|
|
||
| `Ops::HealthMonitorJob` | Sidekiq, ogni ~3 min |
|
||
| `Ops::HealthChecks` | Esegue tutti i check, apre/chiude incidenti |
|
||
| `Ops::UpLatencyTracker` | Campioni latenza in Redis, calcolo **p95** |
|
||
| `Ops::LogScanner` | Cron ogni 10 min su log Docker |
|
||
| `Ops::Incident` + `/admin/ops` | Dashboard e storico |
|
||
| `GET /up/deep` | JSON esteso (token `X-Ops-Token`) |
|
||
|
||
### Check HTTP (evoluzione 2026-06)
|
||
|
||
| Kind | Severità | Cosa verifica | Frequenza |
|
||
|------|----------|---------------|-----------|
|
||
| `http_rails` | **critical** | `GET http://edge/up` (rete Docker, no hairpin NPM) | Ogni ciclo (~3 min) |
|
||
| `rails_latency` | warning / critical | p95 latenza campioni `http_rails` | Ogni ciclo (dopo ≥5 campioni) |
|
||
| `http_public` | **warning** | `GET https://www.matchlivetv.it/up` | Ogni 15 min (`OPS_HTTP_PUBLIC_INTERVAL_SECS`) |
|
||
|
||
**Perché la separazione:** il check dall'interno del container Rails verso l'URL pubblico può fallire per hairpin NAT / timeout NPM anche quando il sito è raggiungibile dall'esterno. `http_rails` misura la salute reale dello stack; `http_public` è un controllo complementare non bloccante.
|
||
|
||
### Soglie latenza p95
|
||
|
||
| Variabile | Default |
|
||
|-----------|---------|
|
||
| `OPS_UP_LATENCY_WARN_MS` | 2000 |
|
||
| `OPS_UP_LATENCY_CRIT_MS` | 5000 |
|
||
| `OPS_UP_LATENCY_SAMPLES` | 60 |
|
||
| `OPS_UP_LATENCY_MIN_SAMPLES` | 5 |
|
||
|
||
`http_rails` registra ogni misura in Redis; `check_rails_latency` calcola p95 e apre incidente `rails_latency` se supera soglia.
|
||
|
||
### Altri check
|
||
|
||
Disco root, dimensione archivio registrazioni, PostgreSQL, Redis, MediaMTX API, Garage head-bucket, heartbeat Sidekiq, dead queue Sidekiq.
|
||
|
||
### Notifiche
|
||
|
||
Default `OPS_NOTIFY_SEVERITIES=critical` → `http_public` (warning) e `rails_latency` (warning) **non** notificano; critical sì (ntfy + email opzionale).
|
||
|
||
---
|
||
|
||
## Rilasci e downtime
|
||
|
||
Script: [`scripts/deploy/release_production.sh`](../scripts/deploy/release_production.sh)
|
||
|
||
```
|
||
Dev machine Server produzione
|
||
│ │
|
||
│ rsync (sync_to_server.sh) │
|
||
├─────────────────────────────────────►│
|
||
│ │
|
||
│ build rails + sidekiq
|
||
│ migrate DB
|
||
│ force-recreate edge
|
||
│ recreate rails (+ wait /up)
|
||
│ recreate sidekiq
|
||
│ install cron
|
||
```
|
||
|
||
| Fase | Impatto utente |
|
||
|------|----------------|
|
||
| `edge` recreate | Nessuno (nginx torna subito) |
|
||
| `rails` recreate | Pagina cortesia ~30–90s; `/up` brevemente down |
|
||
| RTMP / diretta attiva | **Non interrotta** (MediaMTX indipendente) |
|
||
| Replay in riproduzione | Continuano via `/media/` → Garage (edge resta su) |
|
||
|
||
Smoke test post-rilascio:
|
||
|
||
```bash
|
||
curl -sf https://www.matchlivetv.it/up
|
||
curl -sI https://www.matchlivetv.it/hls/ # 404 MediaMTX = routing OK
|
||
docker compose exec rails bundle exec rails runner 'puts Ops::HealthChecks.new.summary[:status]'
|
||
```
|
||
|
||
---
|
||
|
||
## Sviluppo locale vs produzione
|
||
|
||
| Aspetto | Dev (`docker compose`) | Produzione |
|
||
|---------|------------------------|------------|
|
||
| Edge nginx | Assente (NPM simulato con porta 3000 → rails) | Container `edge` obbligatorio |
|
||
| HLS playback | `HlsProxyController` su Rails | edge → MediaMTX |
|
||
| Replay stream | Redirect o proxy Rails | Redirect → edge → Garage |
|
||
| Codice Rails | Volume mount hot-reload | Immagine Docker buildata |
|
||
| MediaMTX :1935/:8888 | Esposti su localhost | :1935 WAN, :8888 opzionale |
|
||
|
||
Avvio locale:
|
||
|
||
```bash
|
||
cd infra && cp .env.example .env && docker compose up -d --build
|
||
```
|
||
|
||
---
|
||
|
||
## Documentazione correlata
|
||
|
||
| Documento | Contenuto |
|
||
|-----------|-----------|
|
||
| [`infrastructure/SERVER_DEPLOYMENT.md`](infrastructure/SERVER_DEPLOYMENT.md) | Bootstrap server, NPM, cron, backup |
|
||
| [`LIVE_STREAMING.md`](LIVE_STREAMING.md) | MediaMTX, pause, overlay, HLS.js |
|
||
| [`REPLAY_MODULE.md`](REPLAY_MODULE.md) | Garage, retention, YouTube VOD |
|
||
| [`OPS_MONITORING.md`](OPS_MONITORING.md) | ntfy, variabili ops, troubleshooting |
|
||
| [`TABELLONI_E_OVERLAY.md`](TABELLONI_E_OVERLAY.md) | Grafica in stream |
|
||
| [`PRODUCT_PREMIUM.md`](PRODUCT_PREMIUM.md) | Piani e funzionalità premium |
|
||
| [`native/android/README.md`](../native/android/README.md) | App Android |
|
||
|
||
---
|
||
|
||
## Cronologia modifiche architetturali rilevanti
|
||
|
||
| Data | Modifica |
|
||
|------|----------|
|
||
| 2026-05 | Introduzione container `edge` + pagina cortesia ai rilasci |
|
||
| 2026-06 | Replay: redirect 302 a `/media/` → Garage (fuori da Puma) |
|
||
| 2026-06 | HLS live: edge → MediaMTX (fuori da Puma) |
|
||
| 2026-06 | Ops: check `http_rails` + `http_public` separati, latenza p95 `UpLatencyTracker` |
|
||
| 2026-06 | `RAILS_MAX_THREADS=5`; relay YouTube legge `mediamtx:8888` diretto |
|