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>
This commit is contained in:
2026-06-14 21:26:35 +02:00
parent a44d9fbadd
commit 498f6069d0
5 changed files with 436 additions and 14 deletions

415
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,415 @@
# 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) |
| 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 ~3090s; `/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 |