Compare commits

...
Author SHA1 Message Date
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
31 changed files with 699 additions and 142 deletions
+1 -1
View File
@@ -156,7 +156,7 @@ module Ops
force_path_style: MatchLiveTv.replay_storage_force_path_style?
)
client.head_bucket(bucket: MatchLiveTv.replay_storage_bucket)
ok_finding("garage_storage:ok", "Garage storage OK")
ok_finding("garage_storage:head", "Garage storage OK")
rescue StandardError => e
fail_finding("garage_storage", "warning", "garage_storage:head", "Garage storage non raggiungibile", e.message)
end
+13 -14
View File
@@ -116,31 +116,30 @@ module Streams
raise Error, "ffmpeg non disponibile: #{e.message}"
end
# RTMP grezzo da telefono (overlay bruciato lato app); fallback HLS via proxy Rails.
# Remux verso YouTube: video+audio copy (AAC già da app/slate a 48k mono).
# HLS (ADTS) → FLV richiede -bsf:a aac_adtstoasc; RTMP ha già ASC in FLV tags.
def youtube_ffmpeg_args(intake_source, output)
mode, url = intake_source
common_head = [
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
"-fflags", "+genpts+discardcorrupt"
]
copy_tail = ["-c:v", "copy", "-c:a", "copy", "-f", "flv", output]
if mode == :rtmp
return [
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
"-fflags", "+genpts+discardcorrupt",
return common_head + [
"-analyzeduration", "10000000", "-probesize", "10000000",
"-rw_timeout", "15000000",
"-i", url,
"-c:v", "copy",
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
"-bsf:a", "aac_adtstoasc",
"-f", "flv", output
]
"-i", url
] + copy_tail
end
[
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
"-fflags", "+genpts+discardcorrupt",
common_head + [
"-rw_timeout", "15000000",
"-live_start_index", "-1",
"-i", url,
"-c:v", "copy",
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
"-c:a", "copy",
"-bsf:a", "aac_adtstoasc",
"-f", "flv", output
]
@@ -13,7 +13,7 @@
<% if @subscription.admin_comped_reason.present? %>
(<%= @subscription.admin_comped_reason %>)
<% end %>.
<%= raw t("club.billing.comped.no_payment_html", email_link: mail_to("info@matchlive.it", "info@matchlive.it")) %>
<%= raw t("club.billing.comped.no_payment_html", email_link: mail_to("info@matchlivetv.it", "info@matchlivetv.it")) %>
</p>
</div>
<% else %>
@@ -40,6 +40,6 @@
<div class="card" style="margin-top:32px;text-align:center">
<h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3>
<p style="color:#aaa;margin-bottom:16px"><%= t("pages.pricing.different_body") %></p>
<%= mail_to "info@matchlive.it", t("pages.pricing.contact_button"), class: "btn btn-primary" %>
<%= mail_to "info@matchlivetv.it", t("pages.pricing.contact_button"), class: "btn btn-primary" %>
</div>
</div>
@@ -37,7 +37,7 @@
</div>
<div class="card" style="margin-top:24px;text-align:center">
<p style="color:#aaa"><%= t("team.billing.contact_lead") %> <%= mail_to "info@matchlive.it", t("team.billing.contact_cta") %> <%= t("team.billing.contact_trailer") %></p>
<p style="color:#aaa"><%= t("team.billing.contact_lead") %> <%= mail_to "info@matchlivetv.it", t("team.billing.contact_cta") %> <%= t("team.billing.contact_trailer") %></p>
</div>
<p style="margin-top:20px"><%= link_to t("team.billing.club_payments_link"), public_club_billing_path(@team.club) %></p>
+1 -1
View File
@@ -78,7 +78,7 @@ module MatchLiveTv
end
def privacy_controller_email
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlive.it")
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlivetv.it")
end
def privacy_controller_address
@@ -0,0 +1,16 @@
[
{
"relation": [
"delegate_permission/common.handle_all_urls",
"delegate_permission/common.get_login_creds"
],
"target": {
"namespace": "android_app",
"package_name": "com.matchlivetv.match_live_tv",
"sha256_cert_fingerprints": [
"C3:F0:89:51:0B:00:3A:FE:10:BD:ED:68:45:1B:4F:1D:B8:5A:63:EE:8A:DA:F7:2F:5A:D6:1D:97:C9:A3:95:47",
"09:69:25:CD:8B:EC:BA:75:9A:5E:49:32:E1:35:BD:F1:AF:1E:5A:29:93:E2:65:85:8A:41:E2:11:DA:64:97:F0"
]
}
}
]
@@ -0,0 +1,24 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Streams::YoutubeRelay do
describe ".youtube_ffmpeg_args (private)" do
def args(mode, url)
described_class.send(:youtube_ffmpeg_args, [mode, url], "rtmps://a.rtmps.youtube.com/live2/key")
end
it "remuxes RTMP with video and audio copy (no AAC re-encode)" do
cmd = args(:rtmp, "rtmp://mediamtx:1935/live/match_x")
expect(cmd).to include("-c:v", "copy", "-c:a", "copy", "-f", "flv")
expect(cmd).not_to include("aac")
expect(cmd.join(" ")).not_to include("-b:a")
end
it "remuxes HLS with AAC bitstream filter for FLV" do
cmd = args(:hls, "http://mediamtx:8888/live/match_x/index.m3u8")
expect(cmd).to include("-c:v", "copy", "-c:a", "copy", "-bsf:a", "aac_adtstoasc", "-f", "flv")
expect(cmd).not_to include("-b:a")
end
end
end
+41
View File
@@ -0,0 +1,41 @@
# Android App Links / Digital Asset Links
Play Console verifica `https://www.matchlivetv.it/.well-known/assetlinks.json`.
## File
- Servito da **edge** (nginx): `infra/nginx-edge/well-known/assetlinks.json`
- Specchio in Rails public: `backend/public/.well-known/assetlinks.json`
Package: `com.matchlivetv.match_live_tv`
Path App Links: `https://www.matchlivetv.it/join…` (`pathPrefix="/join"`)
Nel JSON ci sono:
1. SHA-256 del **upload key** (`native/android/keystore/matchlivetv-upload.jks`)
2. SHA-256 del certificato **App signing** di Play (da Play Console → Integrità dellapp)
Relazioni richieste da Play per la condivisione credenziali:
- `delegate_permission/common.handle_all_urls`
- `delegate_permission/common.get_login_creds`
Se Play rigenera il JSON, sostituisci `infra/nginx-edge/well-known/assetlinks.json` (e lo specchio in `backend/public/.well-known/`) e ricarica edge.
## Deploy edge (senza rebuild Rails)
Sul server:
```bash
cd /opt/matchlivetv/infra
# dopo git pull del repo
docker compose -f docker-compose.prod.yml --env-file .env up -d edge
curl -sS -D- https://www.matchlivetv.it/.well-known/assetlinks.json | head
```
Verifica Google:
```bash
curl -sS "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://www.matchlivetv.it&relation=delegate_permission/common.handle_all_urls"
```
Poi in Play Console → Link diretti → ripeti i controlli dominio.
+4 -4
View File
@@ -47,7 +47,7 @@ Poi tocca la card o conferma dal foglio: si apre il wizard (Partita → Trasmiss
1. Nel wizard, seleziona **YouTube Live** (se il piano lo consente e il canale è pronto)
2. Completa i passi → **Camera**
3. Il telefono invia RTMP a MediaMTX; il server avvia **automaticamente** overlay (tabellone + logo) e RTMPS verso YouTube — nessun intervento manuale
3. Il telefono invia RTMP a MediaMTX (con overlay tabellone già nel flusso, se attivo); il server avvia **automaticamente** il relay RTMPS verso YouTube (`Streams::YoutubeRelay`) — nessun intervento manuale
## Sviluppo locale
@@ -67,9 +67,9 @@ Vedi [YOUTUBE_MORNING_CHECKLIST.md](./YOUTUBE_MORNING_CHECKLIST.md) — APK **1.
| YouTube disabilitato | Premium Light o Full |
| YouTube non selezionabile | Token canale Match Live TV sul server (`admin` → YouTube piattaforma) |
| OAuth «app non verificata» | Utente di test Google + Avanzate |
| Live non su YouTube | Pipeline automatica (`Youtube::LivePipeline`): overlay → ingest active → activate. Controlla `docker logs infra-sidekiq-1` per `[OverlayRelay] started` e `[YoutubeBroadcastActivate]`. |
| YouTube «In programma» / copertina | Nessun intervento manuale: entro ~2090s il server avvia overlay (tee RTMPS) e attiva la broadcast quando ingest è `active`. |
| YouTube resta in «waiting» | `log/overlay_relay_<session_id>.log` in **sidekiq**; poll ogni 5s (`StreamPublisherSyncJob`). Attivazione ritenta ~6 min. |
| Live non su YouTube | Pipeline `Youtube::LivePipeline` + `YoutubeRelay`. Controlla `docker logs infra-sidekiq-1` per `[YoutubeRelay] started` / `[YoutubeBroadcastActivate]`. |
| YouTube «In programma» / copertina | Entro ~2090s il server avvia il relay RTMPS e attiva la broadcast quando lingest è pronto; in pausa MediaMTX manda la slate. |
| YouTube resta in «waiting» | Log `log/youtube_relay_*.log` in **sidekiq**; poll `StreamPublisherSyncJob`. Attivazione ritenta ~6 min. |
| Banner «sync limitata: Cannot add event after closing» | APK aggiornato: il wizard non apre un secondo WebSocket `controller` dopo la camera. |
| Timer «IN DIRETTA» sbagliato (es. 10:33 subito) | `started_at` arriva dal server al primo frame RTMP; fino ad allora il timer resta a 0. |
| App 0 Mbps / 0 fps ma LIVE | RTMP non pubblica: controlla permessi camera, messaggio «Connessione RTMP»; relay YouTube non parte finché il telefono non è online su MediaMTX. |
+25 -19
View File
@@ -2,7 +2,7 @@
Documento di riferimento per l'intero sistema: rete, container Docker, flussi video, replay, monitoraggio ops e rilasci.
**Ultimo aggiornamento:** 2026-06-14
**Ultimo aggiornamento:** 2026-07-29
**Produzione:** `eminux@192.168.1.146``/opt/matchlivetv`
---
@@ -193,39 +193,41 @@ Vedi anche [`LIVE_STREAMING.md`](LIVE_STREAMING.md) per pause, overlay e player
### Flusso ingest
```
App Android (RTMP 720p, AAC mono)
App (Android/iOS) RTMP 720p AAC mono
+ overlay GPU (tabellone/watermark) se overlay_kind ≠ none
MediaMTX :1935 — path live/match_{uuid} (grezzo, telefono)
▼ overlay ffmpeg (Streams::OverlayRelay)
path live/match_{uuid}_air (tabellone + badge bruciati nel video)
MediaMTX :1935 — path live/match_{uuid}
├──► HLS :8888 ──► edge /hls/ ──► spettatori web
└──► ffmpeg -c copy ──► YouTube RTMP (Streams::YoutubeRelay, Premium)
└──► (solo platform=youtube)
Streams::YoutubeRelay in Sidekiq
ffmpeg: -c:v copy, -c:a copy ──► YouTube RTMPS
```
| Componente | Ruolo |
|------------|--------|
| `Mediamtx::PathManager` | Crea path dinamici via API :9997 |
| Path dinamici via API :9997 | `Mediamtx::Client` (`create_path`, recording patch, …) |
| `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 |
| Overlay tabellone | **App nativa** (non più ricodifica server) |
| `Streams::YoutubeRelay` | Legge RTMP (o HLS) da MediaMTX; remux copy A/V → YouTube |
| `Mediamtx::PublisherSync` | Publisher online/offline, go_live, recording on/off |
Dettaglio pause, player e ruolo ffmpeg: [`LIVE_STREAMING.md`](LIVE_STREAMING.md).
### 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).
3. Se attivo, 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).
**Recording:** path creato con `record: false`; acceso solo con publisher online e entitlement (`PublisherSync`). 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`
- Player: HLS.js su `HLS_PUBLIC_URL/live/match_{uuid}/index.m3u8`
- Stato: `GET /live/:id/status.json` (badge, recovery buffer, no-store)
---
@@ -278,8 +280,8 @@ App Android ◄──── REST API (/api/v1/...) ────► Rails
Sidekiq ◄── Redis ◄──────┘
├── HealthMonitorJob, UploadJob, overlay refresh
├── YoutubeRelay, OverlayRelay (ffmpeg)
├── HealthMonitorJob, UploadJob (merge/thumbnail ffmpeg)
├── YoutubeRelay (ffmpeg remux copy A/V, solo dirette YouTube)
└── PublishToYoutubeJob, purge replay, ecc.
```
@@ -395,10 +397,12 @@ cd infra && cp .env.example .env && docker compose up -d --build
| 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 |
| [`infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md`](infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md) | Piano overflow relay YouTube su Hetzner Cloud (picchi) |
| [`ANDROID_APP_LINKS.md`](ANDROID_APP_LINKS.md) | Digital Asset Links / deep link Play (`assetlinks.json`) |
| [`LIVE_STREAMING.md`](LIVE_STREAMING.md) | MediaMTX, pausa, HLS, ruolo ffmpeg |
| [`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 |
| [`TABELLONI_E_OVERLAY.md`](TABELLONI_E_OVERLAY.md) | Tabelloni e overlay (app nativa) |
| [`PRODUCT_PREMIUM.md`](PRODUCT_PREMIUM.md) | Piani e funzionalità premium |
| [`native/android/README.md`](../native/android/README.md) | App Android |
@@ -412,5 +416,7 @@ cd infra && cp .env.example .env && docker compose up -d --build
| 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 |
| 2026-06 | `RAILS_MAX_THREADS=5`; relay YouTube da MediaMTX (non via Puma) |
| 2026-06 | Cleanup path MediaMTX orfani (`Mediamtx::CleanupOrphanPaths`); delete path sempre a fine diretta |
| 2026-07 | Rimosso overlay server (`OverlayRelay`); tabellone bruciato in app; HLS su path camera (non `*_air`); `YoutubeRelay` = copy video + AAC |
| 2026-08 | `YoutubeRelay` = remux copy video+audio (niente ricodifica AAC); profilo app/slate AAC 48k mono |
+49 -34
View File
@@ -1,23 +1,25 @@
# Diretta live — architettura MediaMTX
## Idea (non è folle)
**Ultimo aggiornamento:** 2026-07-29
## Idea
MediaMTX espone **un unico flusso di uscita** per path (`live/match_{uuid}`):
1. **Telefono in onda** → publisher RTMP (camera).
1. **Telefono in onda** → publisher RTMP (camera + **overlay grafico bruciato in app**).
2. **Pausa / rete assente**`alwaysAvailable` con file slate (`/slates/offline.mp4`) **senza interrompere** lHLS verso il sito.
3. **YouTube** `ffmpeg` in container Rails/Sidekiq legge **sempre** quelluscita (`-c copy`) e inoltra a `rtmp://a.rtmp.youtube.com/live2/...` per tutta la sessione.
3. **YouTube** (solo se `platform=youtube`) → `Streams::YoutubeRelay` in **Sidekiq**: un processo `ffmpeg` legge RTMP/HLS da MediaMTX e inoltra a YouTube (`-c:v copy`, `-c:a copy`; lAAC 48 kHz mono è già prodotto dallapp / slate).
Il telefono **non** invia la copertina: risparmia banda; lo switch è lato server.
Il telefono **non** invia la copertina di pausa: risparmia banda; lo switch slate ↔ camera è lato MediaMTX.
## 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) |
| App Android / iOS | RTMP 720p AAC mono; overlay tabellone/watermark in GPU sul flusso |
| MediaMTX | Path dinamico, `alwaysAvailable` + slate, HLS |
| `Streams::YoutubeRelay` | Relay continuo verso YouTube (solo Sidekiq con `YOUTUBE_RELAY_WORKER=1`) |
| `Mediamtx::PublisherSync` | Stato Rails da API paths (publisher online) + recording on/off |
| `/hls/...` (edge → MediaMTX in prod; Rails proxy in dev) | Player web |
## Pausa e continuità
@@ -27,36 +29,44 @@ Il telefono **non** invia la copertina: risparmia banda; lo switch è lato serve
3. MediaMTX → passa alla slate sul **medesimo path** (senza interrompere luscita 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.
6. **YouTube** se attivo, il relay ffmpeg continua sulla stessa uscita MediaMTX (vede slate in pausa).
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`).
**Recording**: sul path live parte `record: false`; viene acceso via API solo con publisher online e entitlement (`PublisherSync`). I replay finiscono in Garage 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) ──► edge /hls/ → MediaMTX (prod) o proxy Rails (dev) ──► player web (HLS.js)
└─► RTMP lettura ──► Streams::YoutubeRelay (ffmpeg -c copy) ──► YouTube
Telefono RTMP (camera + overlay app)
MediaMTX path live/match_{uuid}
publisher ON → camera H.264/AAC (già con tabellone se overlay ≠ none)
publisher OFF → alwaysAvailable → /slates/offline.mp4
├──► HLS (segmenti ~1s) ──► edge /hls/ → MediaMTX ──► player web (HLS.js)
└──► (solo platform=youtube)
Streams::YoutubeRelay
ffmpeg: -c:v copy, -c:a copy ──► RTMPS 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`) |
| Tabellone / watermark sullo stream | **App nativa** (GPU → RTMP) |
| Continuità YouTube | **`Streams::YoutubeRelay`** (ffmpeg in Sidekiq) |
| Badge «In onda» / stato pausa | **Rails** (`status.json`, `PublisherSync`) + UI web |
| 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.
URL HLS pubblico: `https://www.matchlivetv.it/hls/live/match_{uuid}/index.m3u8` (`StreamSession#effective_hls_path_name` = path camera, **non** più `*_air`).
### Player web — anti-scatti (HLS.js)
Configurazione in `backend/app/views/public/live/show.html.erb`:
@@ -67,27 +77,32 @@ Configurazione in `backend/app/views/public/live/show.html.erb`:
- 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)
### Grafica nel video (overlay)
Tabellone, badge stato («In onda», «In pausa», «Copertina», …) e banner **Match Live TV** sono **bruciati nel flusso** da `Streams::OverlayRelay`:
**Non** c’è più un relay server `Streams::OverlayRelay` che ricodifica con PNG.
```
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)
```
Il tabellone è composito **sul telefono** prima dellRTMP (vedi [`TABELLONI_E_OVERLAY.md`](TABELLONI_E_OVERLAY.md)):
- `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** (lapp 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`.
1. `OverlayState` da `effectiveOverlayKind` + `ScoreState`
2. `OverlayRenderer` / canvas → bitmap
3. Filtro GPU sul encoder RTMP
La pagina live può mostrare badge HTML di stato (in onda / pausa / attesa); non sostituisce il tabellone nel video.
### Ruolo di ffmpeg oggi
| Quando | Cosa | Peso CPU |
|--------|------|----------|
| Diretta `platform=youtube` | `YoutubeRelay`: demux + **copy video/audio** → RTMPS (remux) | Basso **per diretta**, scala con N processi |
| Diretta solo `matchlivetv` | Nessun ffmpeg in live | Quasi zero |
| Fine diretta | `UploadFromSession`: concat segmenti (`-c copy`) | Picco breve |
| Post-process | `GenerateThumbnail`: un frame → JPEG | Trascurabile |
Non esiste più la ricodifica H.264 server-side delloverlay (era il carico principale).
### Punteggio (persistenza)
Lapp mobile persiste il punteggio con `PATCH /api/v1/sessions/:id/score` (`Scoring::SyncState`), poi refresh overlay.
Lapp mobile persiste il punteggio con `PATCH /api/v1/sessions/:id/score` (o `score_action`) → `Scoring::SyncState`, poi aggiorna loverlay locale in camera.
`GET /live/:id/status.json` resta per messaggi sotto il player e recovery HLS; `Cache-Control: no-store`.
+1 -1
View File
@@ -15,7 +15,7 @@
Il punteggio in diretta si gestisce tramite **link regia** condivisibile (WhatsApp, email, SMS…): non serve un account per chi fa il tabellone.
Esigenze custom: contatto `info@matchlive.it`.
Esigenze custom: contatto `info@matchlivetv.it`.
## Sito
+5 -1
View File
@@ -2,6 +2,8 @@
Questo documento descrive come Match Live TV modella gli sport, applica i regolamenti di punteggio e li traduce in **tabelloni** (logica + controlli) e **overlay** (grafica sul video in diretta).
**Importante (2026-07):** loverlay video è composito **solo sullapp nativa** (Android/iOS) e viaggia già nel RTMP verso MediaMTX. Non esiste più un relay server (`Streams::OverlayRelay`) che ricodifica con PNG. Vedi anche [`LIVE_STREAMING.md`](LIVE_STREAMING.md).
---
## Panoramica
@@ -162,12 +164,14 @@ Esempio: il basket ammette `[basket, none]` — si può trasmettere con tabellon
### Layout app mobile (Android e iOS)
Sullapp nativa loverlay è composito in GPU senza ricodifica aggiuntiva:
Sullapp nativa loverlay è composito in GPU **prima** dellencode RTMP (nessuna ricodifica server aggiuntiva):
1. `BroadcastScreen` costruisce un `OverlayState` a partire da `effectiveOverlayKind` e `ScoreState`.
2. `OverlayRenderer``OverlayCanvasRenderer` disegna gli elementi su bitmap trasparente.
3. Il bitmap viene applicato come filtro sul flusso RTMP (OpenGL su Android, HaishinKit su iOS).
MediaMTX e `YoutubeRelay` ricevono quindi un flusso già “brandizzato”; il relay YouTube fa solo remux (`-c copy` video e audio).
Elementi grafici (`OverlayCanvasRenderer`):
| Elemento | Quando è attivo |
+18 -42
View File
@@ -1,56 +1,32 @@
# YouTube Live — verificato ✅ (5 giu 2026)
# YouTube Live — checklist operativa
## Test completato automaticamente
**Aggiornato:** 2026-07-29
Pipeline attuale: telefono → MediaMTX → `Streams::YoutubeRelay` (ffmpeg remux copy A/V in Sidekiq) → YouTube. Overlay tabellone in **app**, non più `OverlayRelay` server.
Pipeline end-to-end **funzionante** sul server di produzione:
| Step | Esito |
|------|--------|
| Setup broadcast + stream_key | ✅ |
| Overlay ffmpeg con tee RTMPS YouTube | ✅ |
| Copertina / tabellone su YouTube | ✅ |
| RTMP simulato (test pattern) | ✅ |
| Broadcast `lifecycle=live` | ✅ |
**Prova visiva:** https://www.youtube.com/watch?v=0WmCsW_Luik
(Titolo: *Tigers Volley vs Avversario prova* — sessione di test, ora terminata)
## APK da installare sul telefono
```bash
# Sul PC (già compilato):
mobile/build/app/outputs/flutter-apk/app-release.apk
# Versione: 1.2.10+13 — API https://www.matchlivetv.it
```
Installa sul Redmi (sostituisce versioni precedenti).
## Avvio diretta dallapp (2 minuti)
1. Partita → **YouTube Live** → wizard → **Camera**
2. Attendi sul passo rete che compaia il link YouTube (poll automatico, fino a 3 min)
3. Avvia: entro ~90s su YouTube compare **copertina**, poi video con tabellone quando il telefono pubblica RTMP
## Test server senza telefono
## Verifica rapida su server
```bash
ssh eminux@192.168.1.146
docker exec infra-rails-1 bin/rails youtube:e2e
docker exec infra-sidekiq-1 pgrep -a ffmpeg # deve contenere rtmps://...youtube.com/live2
docker exec infra-rails-1 bin/rails youtube:e2e # se il task è ancora presente
docker exec infra-sidekiq-1 pgrep -a ffmpeg # deve contenere rtmps://...youtube.com/live2
docker logs infra-sidekiq-1 2>&1 | grep -E 'YoutubeRelay|YoutubeBroadcastActivate|LivePipeline' | tail -40
```
## Avvio diretta dallapp
1. Partita → **YouTube Live** → wizard → **Camera**
2. Attendi sul passo rete che compaia il link YouTube (poll automatico)
3. Avvia: entro ~90s su YouTube compare video (slate MediaMTX se il telefono non pubblica ancora; tabellone quando lapp pubblica RTMP con overlay)
## Se qualcosa non va
| Sintomo | Cosa fare |
|---------|-----------|
| «YouTube temporaneamente occupato» | Attendi 1520 min (rate limit Google). **Non** creare molte sessioni di fila. |
| Pagina YouTube vuota | `docker logs infra-sidekiq-1 \| grep OverlayRelay` — manca `stream_key` o tee RTMPS |
| App errore avvio diretta | APK 1.2.10+13; controlla rete verso matchlivetv.it |
| Pagina YouTube vuota | `docker logs infra-sidekiq-1 \| grep YoutubeRelay` — manca `stream_key` o relay non partito |
| App 0 Mbps / 0 fps ma LIVE | RTMP non pubblica: permessi camera / messaggio «Connessione RTMP»; relay YouTube non parte finché MediaMTX non vede il publisher |
| YouTube resta in waiting | Log relay in sidekiq; `StreamPublisherSyncJob` + activate retry |
## Fix deployati (server)
## Note storiche
- Retry setup broadcast su rate limit (fino a 15 tentativi)
- Un solo job setup per sessione (lock Redis)
- Throttle API YouTube globale
- Riavvio overlay quando arriva `stream_key` dopo start senza tee
- Activate solo con overlay attivo + broadcast pronta
Checklist precedente (giu 2026) citava `OverlayRelay` / tee RTMPS dalloverlay server: **deprecato**. Non cercare più log `OverlayRelay` o `overlay_relay_*.log`.
+2 -2
View File
@@ -7,7 +7,7 @@ Match Live TV supporta due modalità YouTube, legate al piano società:
| **Premium Light** | `matchlivetv_light` | Canale YouTube **Match Live TV** (gestito da te) |
| **Premium Full** | `team` | Canale YouTube **della società** (OAuth del club) |
Flusso tecnico: telefono → RTMP MediaMTX → (relay ffmpeg) → YouTube Live.
Flusso tecnico: telefono (camera + overlay app) → RTMP MediaMTX → `Streams::YoutubeRelay` (ffmpeg `-c:v copy -c:a copy`) → YouTube Live.
---
@@ -85,5 +85,5 @@ YOUTUBE_MOCK_STREAM_KEY=mock-stream-key
## 6. Produzione
- `YOUTUBE_REDIRECT_URI` deve essere **esattamente** quello registrato in Google (HTTPS, dominio pubblico).
- Il relay ffmpeg gira nel container MediaMTX (`runOnReady`).
- Il relay ffmpeg (`Streams::YoutubeRelay`) gira nel container **sidekiq** (`YOUTUBE_RELAY_WORKER=1`), non su MediaMTX `runOnReady`.
- Apri RTMP **1935** sul router verso il server.
@@ -0,0 +1,408 @@
# Piano: overflow relay YouTube su Hetzner Cloud
**Stato:** piano / design — non implementato
**Ultimo aggiornamento:** 2026-07-29
**Contesto:** produzione attuale su host dedicato (es. Proxmox/Hetzner) con MediaMTX + Sidekiq sullo stesso box; obiettivo >10 live YouTube contemporanee senza abbandonare il middle-tier.
Documenti correlati: [`LIVE_STREAMING.md`](../LIVE_STREAMING.md), [`ARCHITECTURE.md`](../ARCHITECTURE.md), [`OPS_MONITORING.md`](../OPS_MONITORING.md).
---
## 1. Scenario
### Problema di prodotto
Match Live TV disaccoppia il telefono da YouTube:
```text
Telefono (rete mobile instabile)
│ RTMP
MediaMTX (sempre attivo, slate / alwaysAvailable)
├── HLS → sito
└── YoutubeRelay (ffmpeg) → RTMPS YouTube
```
Se cade solo il tratto telefono→MediaMTX, YouTube resta in onda (slate).
Se il telefono andasse diretto a YouTube (o a un ingest cloud senza hold), la live pubblica cadrebbe con la rete mobile. Inoltre YouTube non è pensato come “Go Live dallapp mobile” senza vincoli di canale: noi usiamo API + RTMPS dal server.
### Problema di capacità
Ogni diretta `platform=youtube` avvia un processo `ffmpeg` in Sidekiq (`Streams::YoutubeRelay`: video+audio **copy** / remux).
Il carico scala con **N processi**, non con gli spettatori HLS.
Sul box attuale (~4 CPU / 8 GB, stack completo sullo stesso host):
| Live YT contemporanee | Situazione attesa |
|-----------------------|-------------------|
| 14 | Comodo |
| 58 | Teso (CPU/RAM, latenza job Sidekiq) |
| ≥10 | Rischioso (relay instabili, watchdog, slate prolungate su YT) |
**Obiettivo:** mantenere MediaMTX (e Rails) sul dedicated; quando N supera la soglia comoda, **accendere capacità Hetzner Cloud solo per i relay**, per il tempo del picco, poi spegnerla.
### Cosa NON è questo piano
- Non sostituisce MediaMTX con Mux/AWS MediaLive.
- Non sposta lingest RTMP del telefono sul cloud (cold start e sticky URL sono incompatibili con “zero nodi a riposo”).
- Non è autoscaling del monolite Docker Compose intero.
---
## 2. Principio di progettazione
| Componente | Dove resta | Perché |
|------------|------------|--------|
| MediaMTX (RTMP ingest + slate + HLS) | **Dedicated sempre acceso** | URL stabile per lapp; continuità YouTube via slate |
| Rails / Postgres / Redis / Garage / edge | **Dedicated** (o gestiti fissi) | Stato, auth, replay; non sono il collo di bottiglia video live |
| `YoutubeRelay` (ffmpeg) | **Dedicated fino a soglia + overflow Cloud** | Unico pezzo che moltiplica CPU con N live YT |
| Job Sidekiq non-video (mail, ops, post-process) | Dedicated (coda separata) | Non devono competere con ffmpeg sui worker overflow |
Formula mentale:
```text
capacità_yt ≈ core_dedicati_relay + Σ core_vm_overflow_calde
```
---
## 3. Architettura target (overflow)
```text
[ telefoni RTMP ]
┌─────────────────────────┐
│ Dedicated (sempre on) │
│ MediaMTX · Rails · DB │
│ Redis · Garage · edge │
│ Sidekiq "core" │
│ (+ pochi relay locali) │
└───────────┬─────────────┘
intake RTMP/HLS │ (rete privata / tunnel / IP allowlist)
┌───────────────┼───────────────┐
▼ ▼ ▼
[relay-local] [overflow-1] [overflow-N]
Sidekiq+ffmpeg Hetzner Cloud Hetzner Cloud
YOUTUBE_RELAY=1 a ore a ore
│ │ │
└───────────────┴───────────────┘
YouTube RTMPS
```
### Ruolo delle VM overflow
Immagine minimale (o Compose snello):
- Sidekiq con `YOUTUBE_RELAY_WORKER=1`
- `ffmpeg` disponibile
- Connessione a **Redis** del dedicated (coda + lock `youtube_relay:owner:*`)
- Lettura intake da MediaMTX (RTMP preferito, HLS fallback come oggi)
- **Niente** Postgres scrittura diretta obbligatoria se i job relay usano già Redis + API; in pratica oggi Rails/Sidekiq legge Postgres → le VM overflow devono raggiungere anche DB **oppure** si isola una coda “relay-only” con worker che riceve payload già risolto (vedi §5.2)
Stato attuale del codice: `YoutubeRelay` gira in processo Sidekiq Rails completo (accesso a `StreamSession`, MediaMTX client, Redis PID/owner). Le VM overflow sono quindi **worker Sidekiq Rails**, non demoni ffmpeg nudi — almeno nella fase 1.
---
## 4. Colli di bottiglia (cosa può rompersi comunque)
Anche con overflow infinito di CPU, restano limiti. Ordine di probabilità/impatto:
### 4.1 MediaMTX sul dedicated (alto)
- N publisher RTMP + N reader (HLS siti + N ffmpeg che rileggono lo stesso path).
- Ogni relay in più è un **reader** su MediaMTX (RTMP pull o HLS).
- Sintomi: path `ready` flaky, HLS a scatti sul sito, relay che rientrano in fallback HLS, CPU MediaMTX alta anche con relay scarichi.
**Mitigazione futura (fuori da questo overflow):** secondo nodo MediaMTX o sharding path; per ora monitorare `readers`, CPU `mediamtx`, bitrate aggregato.
### 4.2 Uplink Internet del dedicated (alto)
- Telefoni → dedicated (ingresso RTMP).
- Dedicated → YouTube **se** i relay restano locali.
- Con overflow Cloud: il dedicated manda **copia dello stream** verso le VM (pull dalle VM = traffico **uscita** dal dedicated verso Hetzner Cloud), poi le VM mandano a YouTube.
Attenzione al verso:
| Dove gira il relay | Traffico tipico |
|--------------------|-----------------|
| Sul dedicated | In: telefoni. Out: N× verso YouTube |
| Su Hetzner Cloud | Out dedicated → Cloud (intake). Out Cloud → YouTube |
Se luplink casa/datacenter verso Internet è stretto, spostare i relay sul Cloud **sposta** il carico out YouTube fuori dal dedicated, ma **aggiunge** out dedicated→Cloud (stesso ordine di grandezza del video).
Vantaggio reale solo se:
- luplink del dedicated satura soprattutto per **CPU** (oggi sì), oppure
- dedicated e Cloud sono in **stessa regione Hetzner con rete privata** (traffico interno economico/veloce) e luscita verso YouTube esce dalla rete Hetzner Cloud (migliore peering).
**Decisione di rete obbligatoria prima di implementare** (vedi §6).
### 4.3 Redis / lock owner (medio)
Oggi:
- `youtube_relay:pid:%s` — PID locale al worker
- `youtube_relay:owner:%s``HOSTNAME` del worker
- `running?` considera attivo un owner remoto se TTL > 30s
Con più worker:
- Due ensure concorrenti possono avviare due ffmpeg (debounce 5s aiuta poco cross-host).
- `stop` da Rails manda job: deve raggiungere il worker **owner**, non un overflow a caso.
- Kill del PID funziona solo sulla macchina owner (`/proc`).
Serve disciplina di coda / routing (§5.3).
### 4.4 Cold start VM (medio)
Creare una CPX/CCX Hetzner: ordine di **3090+ secondi** (API + boot + pull image + Sidekiq ready).
Se la 15ª live parte e non c’è capacità, la broadcast YouTube resta “in attesa” finché non c’è worker.
**Requisito prodotto:** overflow **a caldo** = pool minimo già acceso nei weekend / fasce note, non “da zero al fischio”.
### 4.5 YouTube / account / quote (bassomedio, esterno)
- Limiti API, token OAuth, ban temporanei, ingest RTMPS rifiutato.
- Non risolvibili con più CPU; restano nel runbook ops esistente.
### 4.6 Post-process replay (basso in live, alto a fine giornata)
`UploadFromSession` / thumbnail competono su Sidekiq e disco.
I worker overflow **non** devono prendere coda `default` di merge se sono pensati solo per relay — altrimenti spegnendo le VM a fine picco uccidete job a metà.
### 4.7 Costo e idle (operativo)
VM dimenticate accese = bolletta. Serve TTL, scale-in aggressivo, alert “overflow acceso da >Xh con 0 relay”.
---
## 5. Complessità di implementazione
### 5.1 Rete: come le VM leggono MediaMTX
Opzioni (sceglierne **una** in fase 0):
| Opzione | Pro | Contro |
|---------|-----|--------|
| **A. Stessa location Hetzner + private network** (dedicated Robot + Cloud nella stessa rete) | Bassa latenza, traffico interno, modello pulito | Richiede che il dedicated sia (o diventi) in ecosistema Hetzner collegabile |
| **B. WireGuard/Tailscale** dedicated ↔ Cloud | Funziona anche da Proxmox casa | Ops tunnel, MTU, failover; latenza |
| **C. Esporre RTMP/HLS intake in lettura** (IP allowlist VM overflow) | Semplice | Superficie attacco; non esporre senza auth/TLS dove possibile |
| **D. Relay locale che pusha a Cloud** | Dedicated controlla egress | Doppia hop, più pezzi |
Raccomandazione di piano: **A se il dedicated è/andrà su Hetzner; altrimenti B**. Evitare C in chiaro su WAN.
Variabili da prevedere sulle VM:
- `MEDIAMTX_INTERNAL_RTMP_URL` → URL raggiungibile dalle VM (non `rtmp://mediamtx:1935` Docker-locale)
- `MEDIAMTX_HLS_URL` → idem
- MediaMTX API (`:9997`) raggiungibile in privato per `intake_available?` / PublisherOnline, **mai** su Internet aperto
### 5.2 Accesso Postgres e segreti
Worker Sidekiq Rails oggi necessitano:
- `DATABASE_URL` (o replica read + job che non scrivono — irrealistico allinizio)
- `REDIS_URL`
- `SECRET_KEY_BASE` / credenziali YouTube via DB
- stessi env di produzione (ridotti)
Complessità: ogni overflow è un **nodo fidato** della rete app.
Immagine Docker identica a `sidekiq` prod, env da secret manager o file scp/cloud-init, **nessuna** porta Rails pubblica sulle VM.
### 5.3 Code Sidekiq e affinità relay
Stato oggi: ensure/stop via job + processo locale con owner in Redis.
Per multi-host serve esplicitare:
1. **Coda dedicata** `youtube_relay` (solo worker con `YOUTUBE_RELAY_WORKER=1`).
2. **Cap locale**: ogni worker ha `RELAY_MAX_CONCURRENT` (es. cores1).
3. **Scheduling**:
- Fase 1 (manuale): overflow sempre in ascolto sulla coda; Sidekiq distribuisce i job; `ensure_on_worker!` no-op se a cap (re-enqueue o lascia ad altro worker).
- Fase 2: controller di capacità che alza/abbassa N VM in base a `relay_attivi` e profondità coda.
4. **Stop**: job `YoutubeRelayStopJob` deve eseguire **solo sullowner** (Sidekiq unique + check `owner == HOSTNAME`, altrimenti requeue con delay breve).
Nota: i PID in Redis non sono globalmente killabili — il modello owner è già abbozzato; va reso robusto cross-host.
### 5.4 Scale-out / scale-in (lifecycle VM)
```text
Metriche → Decisione → hcloud CLI/API → cloud-init → Sidekiq ready → in coda
↘ fallisce → alert ops, non spegnere dedicated
```
**Scale-out trigger (esempi):**
- `youtube_relay` depth > 0 per >60s **oppure**
- `relay_attivi_local >= RELAY_SOFT_CAP` **e** nuove sessioni YT in `connecting|live`
**Scale-in trigger:**
- `relay_attivi` sulle VM overflow = 0 per >1530 min **e**
- fuori dalla finestra “weekend caldo” (opzionale)
**Warm pool:** sabato 8:00 → min 12 VM già up; domenica 23:00 → min 0.
### 5.5 Idempotenza e split-brain
Scenario da evitare: dedicated e overflow avviano entrambi ffmpeg sulla stessa `stream_key` → YouTube flappa.
Mitigazioni:
- Lock Redis con fencing token / TTL heartbeat rinnovato dal processo owner ogni N secondi
- Watchdog (`YoutubeIngestWatchdogJob`) deve rispettare owner remoto (già parzialmente così via TTL)
- Un solo writer della broadcast activate
### 5.6 Observability
Senza metriche loverflow è cieco. Minimo:
| Metrica | Dove |
|---------|------|
| N relay ffmpeg vivi (local / per host) | Redis + `pgrep` / heartbeat |
| CPU MediaMTX, CPU Sidekiq | node exporter / docker stats |
| Profondità coda `youtube_relay` | Sidekiq API |
| Bitrate / errori ffmpeg per session | log già in `log/youtube_relay_*.log` |
| VM overflow accese, € stimati | tag Hetzner + cron report |
| Latenza intake Cloud (RTT dedicated↔VM) | check periodico |
Alert ntfy esistenti: estendere con `relay_overflow_saturated`, `mediamtx_cpu_high`, `overflow_vm_orphan`.
### 5.7 Sicurezza
- Firewall: VM → solo Redis, Postgres, MediaMTX (porte interne), YouTube egress 443
- Nessun SSH password; chiave o console Hetzner
- Stream key YouTube solo in DB/encrypted; non in user-data in chiaro se evitabile
- Image aggiornata; spegnimento = destroy, non “pausare” dischi dimenticati per mesi
---
## 6. Decisioni aperte (bloccare prima del codice)
| # | Domanda | Impatto |
|---|---------|---------|
| D1 | Dedicated resta su LAN casa/Proxmox o migra/affianca Hetzner Robot? | Sceglie rete A vs B (§5.1) |
| D2 | Soft cap relay sul dedicated (es. 4? 6?) | Quando scatta overflow |
| D3 | Warm pool fisso weekend vs solo reactive | Cold start vs costo |
| D4 | Tipo VM (CPX shared vs CCX dedicated CPU) | €/live e jitter AAC |
| D5 | Stessa immagine `sidekiq` vs worker slim | Tempo boot e superficie |
| D6 | Chi orchestra le VM? (script cron su dedicated, Terraform, small Go/Ruby service) | Ops ownership |
Finché D1 non è chiusa, non stimare bandwitdh né SLA del picco.
---
## 7. Dimensionamento indicativo
Ipotesi: 720p, remux copy A/V — **~0.150.4 vCPU** medi per relay (picchi su analyze/reconnect).
| Target live YT | Dedicated (relay) | Overflow tipico | Note |
|----------------|-------------------|-----------------|------|
| ≤6 | Tutto locale | 0 | Situazione attuale “comoda” se box dedicato ai relay |
| 1015 | soft cap 46 | 1× VM 48 vCPU | Warm consigliato in giornata evento |
| 2030 | soft cap 46 | 24× VM | MediaMTX e uplink diventano critici |
| 50+ | softire anche ingest | N VM + 2° MediaMTX | Fuori scope overflow-only |
Questi numeri vanno **calibati** con un load test (N sessioni fake + ffmpeg contro slate MediaMTX) prima di promettere SLA commerciali.
---
## 8. Piano a fasi (pronti a reagire, non a over-build)
### Fase 0 — Misura e soglie (12 giorni ops)
- [ ] Dashboard/manuale: N live YT, CPU `sidekiq`, CPU `mediamtx`, uplink
- [ ] Definire `RELAY_SOFT_CAP` empirico sul dedicated
- [ ] Chiudere **D1** (rete)
- [ ] Load test controllato: 812 relay solo slate (senza telefoni) per vedere dove rompe
**Criterio go/no-go overflow:** saturazione CPU Sidekiq/ffmpeg **prima** di MediaMTX/uplink. Se saturano prima rete o MediaMTX, overflow Cloud non basta.
### Fase 1 — Overflow manuale “a caldo” (MVP ops)
- [ ] Snapshot/immagine Sidekiq relay-ready su Hetzner Cloud
- [ ] cloud-init: env, WireGuard o private net, `docker compose up sidekiq`
- [ ] Runbook: “accendi 1 VM”, verifica `YoutubeRelay` su HOSTNAME nuovo, “spegni”
- [ ] Soft cap sul dedicated: nuovi ensure preferiscono coda se locali pieni (anche patch minima)
- [ ] Alert se VM overflow up da >3h con 0 owner keys
**Reazione a incident:** operatore umano accende/spegne; nessun autoscaler ancora.
### Fase 2 — Routing coda robusto
- [ ] Coda `youtube_relay` isolata
- [ ] Heartbeat owner Redis
- [ ] Stop/ensure sticky allowner
- [ ] Cap per worker + requeue
### Fase 3 — Autoscaling controllato
- [ ] Job/cron: se saturazione → `hcloud server create` fino a `OVERFLOW_MAX`
- [ ] Scale-in solo se idle e fuori warm window
- [ ] Budget mensile + kill switch
### Fase 4 — Solo se necessario
- Secondo MediaMTX / sharding
- ~~Audio copy se lencoder telefono è già YouTube-compatibile (meno CPU)~~ → fatto (app AAC 48k mono + relay `-c:a copy`)
- Dedicated più grosso come baseline (spesso più economico delloverflow cronico ogni weekend)
---
## 9. Runbook di reazione (anche prima dellautoscaler)
### Sintomo: YouTube “in programma” / niente video con molte live
1. `docker stats` / CPU Sidekiq sul dedicated
2. Contare relay: log `[YoutubeRelay] started` vs sessioni `platform=youtube` live
3. Se CPU ~100% e MediaMTX ok → **accendere overflow** (Fase 1) o abbassare nuove live YT
4. Se MediaMTX alto / path non ready → overflow **non** aiuta; ridurre reader o spostare HLS, non aggiungere pull Cloud ciechi
### Sintomo: live YT che si spezza solo sulle VM Cloud
1. RTT e packet loss dedicated↔VM
2. Verificare che intake usi RTMP interno, non HLS pubblico via Internet
3. Controllare doppio owner / due ffmpeg sulla stessa key
### Sintomo: bolletta Cloud anomala
1. Lista server con label `matchlivetv-overflow`
2. Destroy idle
3. Abbassare warm pool
### Sintomo: job replay morti dopo scale-in
1. Verificare che overflow **non** consumi coda `default` / `recordings`
2. Riprocessare dead set Sidekiq
---
## 10. Criteri di successo
| Criterio | Misura |
|----------|--------|
| Picco gestito | ≥15 live YT contemporanee stabili in test |
| Continuità prodotto | Drop telefono → slate YouTube ancora attiva (invariato) |
| Costo | Overflow acceso solo in finestre picco; €/h documentato |
| Ops | Runbook Fase 1 eseguibile in &lt;10 min da operatore |
| Non regressione | Live solo-sito (`matchlivetv`) e replay invariati |
---
## 11. Riepilogo esecutivo
Il pezzo che manca oltre ~10 live YouTube è **CPU dei relay**, non lidea MediaMTX.
Hetzner Cloud è la leva economica giusta se:
1. scalate **solo** i worker `YoutubeRelay`,
2. tenete MediaMTX caldo sul dedicated,
3. risolvete **rete privata** dedicated↔Cloud,
4. partite da **warm pool + runbook manuale**, poi automate.
Il rischio principale non è “creare una VM”, è **leggere N volte MediaMTX e saturare uplink/MediaMTX** mentre pensate di aver risolto solo la CPU. La Fase 0 (misura) decide se loverflow è sufficiente o se serve anche più ingest/baseline hardware.
)
+1 -1
View File
@@ -14,7 +14,7 @@ YOUTUBE_MOCK_STREAM_KEY=mock-stream-key
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
PRIVACY_CONTACT_EMAIL=privacy@matchlive.it
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
MAILER_FROM=Match Live TV <noreply@matchlivetv.it>
PASSWORD_RESET_EXPIRY_HOURS=2
+1 -1
View File
@@ -38,7 +38,7 @@ RAILS_LOG_LEVEL=info
# Titolare trattamento (GDPR) — obbligatorio in produzione
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
PRIVACY_CONTACT_EMAIL=privacy@matchlive.it
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
PRIVACY_CONTROLLER_VAT=
# Email transazionali (reset password, inviti)
+2 -1
View File
@@ -72,7 +72,7 @@ services:
YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-}
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlive.it}
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
PRIVACY_CONTROLLER_VAT: ${PRIVACY_CONTROLLER_VAT:-}
@@ -150,6 +150,7 @@ services:
volumes:
- ./nginx-edge/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx-edge/maintenance.html:/usr/share/nginx/maintenance/index.html:ro
- ./nginx-edge/well-known:/usr/share/nginx/well-known:ro
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:80/edge-health"]
interval: 15s
+2 -2
View File
@@ -70,8 +70,8 @@ services:
RAILS_LOG_TO_STDOUT: "true"
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlive.it}
MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlive.it>}
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlivetv.it>}
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
+2
View File
@@ -0,0 +1,2 @@
garage.prod.toml
prod-credentials.env
+7
View File
@@ -98,6 +98,13 @@ http {
return 200 "ok\n";
}
# Android App Links (Play Console / Digital Asset Links)
location = /.well-known/assetlinks.json {
default_type application/json;
add_header Cache-Control "public, max-age=300";
alias /usr/share/nginx/well-known/assetlinks.json;
}
# Health check ops: pass-through senza pagina di cortesia.
location = /up {
proxy_pass http://$rails_backend;
@@ -0,0 +1,16 @@
[
{
"relation": [
"delegate_permission/common.handle_all_urls",
"delegate_permission/common.get_login_creds"
],
"target": {
"namespace": "android_app",
"package_name": "com.matchlivetv.match_live_tv",
"sha256_cert_fingerprints": [
"C3:F0:89:51:0B:00:3A:FE:10:BD:ED:68:45:1B:4F:1D:B8:5A:63:EE:8A:DA:F7:2F:5A:D6:1D:97:C9:A3:95:47",
"09:69:25:CD:8B:EC:BA:75:9A:5E:49:32:E1:35:BD:F1:AF:1E:5A:29:93:E2:65:85:8A:41:E2:11:DA:64:97:F0"
]
}
}
]
+6 -2
View File
@@ -20,8 +20,8 @@ android {
applicationId = "com.matchlivetv.match_live_tv"
minSdk = 24
targetSdk = 36
versionCode = 26
versionName = "2.0.5-native"
versionCode = 29
versionName = "2.0.8-native"
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
?: "https://www.matchlivetv.it"
@@ -47,6 +47,10 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
// Simboli .so per crash/ANR su Play Console (evita l'avviso "native code senza debug symbols")
ndk {
debugSymbolLevel = "SYMBOL_TABLE"
}
signingConfig = if (keystorePropertiesFile.exists()) {
signingConfigs.getByName("release")
} else {
@@ -40,12 +40,13 @@ class ThermalStateManager(
fun start() {
monitor.onStateChanged = { next ->
val previous = _state.value
if (next == previous) return@onStateChanged
Log.i(TAG, "[Thermal] $previous$next")
_state.value = next
_noticeMessage.value = userNotice(next, previous)
applyAdaptation(next, force = false)
updateCriticalWatch(next)
if (next != previous) {
Log.i(TAG, "[Thermal] $previous$next")
_state.value = next
_noticeMessage.value = userNotice(next, previous)
applyAdaptation(next, force = false)
updateCriticalWatch(next)
}
}
monitor.start()
_state.value = monitor.currentState
@@ -1,5 +1,9 @@
package com.matchlivetv.match_live_tv.streaming
/**
* Profilo RTMP allineato a MediaMTX slate + relay YouTube (copy audio/video).
* Audio: AAC 48 kHz mono 128 kbps — stesso contratto di `infra/scripts/generate_slate.sh`.
*/
data class BroadcastConfig(
val rtmpUrl: String,
val width: Int = 1280,
@@ -11,7 +15,13 @@ data class BroadcastConfig(
val portrait: Boolean = false,
val maxReconnectAttempts: Int = 10,
val reconnectDelayMs: Long = 5_000L,
)
) {
companion object {
const val AUDIO_SAMPLE_RATE_HZ = 48_000
/** false = mono in RootEncoder `prepareAudio(sampleRate, isStereo, bitrate)`. */
const val AUDIO_STEREO = false
}
}
enum class BroadcastPhase {
IDLE,
@@ -316,7 +316,11 @@ class LiveBroadcastEngine(
rotation = cfg.rotation,
profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline,
level = h264Level,
) && stream.prepareAudio(48_000, false, cfg.audioBitrate)
) && stream.prepareAudio(
BroadcastConfig.AUDIO_SAMPLE_RATE_HZ,
BroadcastConfig.AUDIO_STEREO,
cfg.audioBitrate,
)
}.getOrElse {
Log.w(TAG, "preparePipeline: ${it.message}")
false
@@ -1,5 +1,7 @@
import Foundation
/// Profilo RTMP allineato a MediaMTX slate + YoutubeRelay (copy A/V).
/// Audio: AAC 48 kHz mono 128 kbps.
struct BroadcastConfig: Sendable {
let rtmpUrl: String
var width: Int = 1280
@@ -11,6 +13,9 @@ struct BroadcastConfig: Sendable {
var portrait: Bool = false
var maxReconnectAttempts: Int = 10
var reconnectDelayMs: Int = 5_000
static let audioSampleRateHz: Float64 = 48_000
static let audioChannels: UInt32 = 1
}
enum BroadcastPhase: String, Sendable {
@@ -214,6 +214,14 @@ final class LiveBroadcastEngine: ObservableObject {
if let microphone = AVCaptureDevice.default(for: .audio) {
try await mixer.attachAudio(microphone, track: 0)
}
// AAC 48 kHz mono allineato ad Android, slate MediaMTX e YoutubeRelay (-c:a copy).
try await mixer.setAudioMixerSettings(
AudioMixerSettings(
sampleRate: BroadcastConfig.audioSampleRateHz,
channels: BroadcastConfig.audioChannels,
tracks: [0: AudioMixerTrackSettings(downmix: true, channelMap: [0])]
)
)
if let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
try await mixer.attachVideo(camera, track: 0) { videoUnit in
videoUnit.isVideoMirrored = false
@@ -355,10 +363,10 @@ final class LiveBroadcastEngine: ObservableObject {
var audioSettings = await stream.audioSettings
audioSettings = AudioCodecSettings(
bitRate: config.audioBitrate,
downmix: audioSettings.downmix,
channelMap: audioSettings.channelMap,
sampleRate: 48_000,
format: audioSettings.format
downmix: true,
channelMap: [0],
sampleRate: BroadcastConfig.audioSampleRateHz,
format: .aac
)
try await stream.setAudioSettings(audioSettings)
}
+11 -1
View File
@@ -13,10 +13,17 @@ if ssh "$SERVER" 'command -v rsync >/dev/null 2>&1'; then
--exclude '.git' \
--exclude 'native/android/build' \
--exclude 'native/android/.gradle' \
--exclude 'native/android/app/build' \
--exclude 'native/android/dist' \
--exclude 'native/android/keystore' \
--exclude 'native/android/keystore.properties' \
--exclude 'native/android/local.properties' \
--exclude 'backend/log' \
--exclude 'backend/tmp' \
--exclude 'backend/.bundle' \
--exclude 'infra/.env' \
--exclude 'infra/garage/garage.prod.toml' \
--exclude 'infra/garage/prod-credentials.env' \
--exclude 'log/' \
--exclude 'node_modules' \
"$SRC/" "$SERVER:$DEST/"
@@ -24,7 +31,10 @@ else
echo "rsync non disponibile sul server, uso tar+scp..."
tar -C "$SRC" -czf /tmp/matchlivetv-deploy.tar.gz \
--exclude='./native/android/build' --exclude='./native/android/.gradle' \
--exclude='./backend/log' --exclude='./backend/tmp' --exclude='./infra/.env' --exclude='./.git' .
--exclude='./native/android/app/build' --exclude='./native/android/dist' \
--exclude='./native/android/keystore' --exclude='./native/android/keystore.properties' \
--exclude='./backend/log' --exclude='./backend/tmp' --exclude='./infra/.env' \
--exclude='./infra/garage/garage.prod.toml' --exclude='./.git' .
scp /tmp/matchlivetv-deploy.tar.gz "$SERVER:~/matchlivetv-deploy.tar.gz"
ssh "$SERVER" "mkdir -p $DEST && tar -xzf ~/matchlivetv-deploy.tar.gz -C $DEST"
fi