From 7b3256c8a003c783a82c906b1b60a51cf05b72c3 Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Wed, 5 Aug 2026 19:25:40 +0200 Subject: [PATCH] Alleggerisce il relay YouTube e rilascia Android 2.0.8. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/services/streams/youtube_relay.rb | 27 +++++++++---------- .../services/streams/youtube_relay_spec.rb | 24 +++++++++++++++++ docs/ARCHITECTURE.md | 7 ++--- docs/LIVE_STREAMING.md | 6 ++--- docs/TABELLONI_E_OVERLAY.md | 2 +- docs/YOUTUBE_MORNING_CHECKLIST.md | 2 +- docs/YOUTUBE_SETUP.md | 2 +- .../HETZNER_CLOUD_RELAY_OVERFLOW.md | 6 ++--- native/android/app/build.gradle.kts | 4 +-- .../streaming/BroadcastModels.kt | 12 ++++++++- .../streaming/LiveBroadcastEngine.kt | 6 ++++- .../Streaming/BroadcastModels.swift | 5 ++++ .../Streaming/LiveBroadcastEngine.swift | 16 ++++++++--- scripts/deploy/sync_to_server.sh | 12 ++++++++- 14 files changed, 96 insertions(+), 35 deletions(-) create mode 100644 backend/spec/services/streams/youtube_relay_spec.rb diff --git a/backend/app/services/streams/youtube_relay.rb b/backend/app/services/streams/youtube_relay.rb index 24f80c2..3381474 100644 --- a/backend/app/services/streams/youtube_relay.rb +++ b/backend/app/services/streams/youtube_relay.rb @@ -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 ] diff --git a/backend/spec/services/streams/youtube_relay_spec.rb b/backend/spec/services/streams/youtube_relay_spec.rb new file mode 100644 index 0000000..df31b96 --- /dev/null +++ b/backend/spec/services/streams/youtube_relay_spec.rb @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b26162d..0ec450b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -202,7 +202,7 @@ MediaMTX :1935 — path live/match_{uuid} ├──► HLS :8888 ──► edge /hls/ ──► spettatori web └──► (solo platform=youtube) Streams::YoutubeRelay in Sidekiq - ffmpeg: -c:v copy, -c:a aac ──► YouTube RTMPS + ffmpeg: -c:v copy, -c:a copy ──► YouTube RTMPS ``` | Componente | Ruolo | @@ -210,7 +210,7 @@ MediaMTX :1935 — path live/match_{uuid} | Path dinamici via API :9997 | `Mediamtx::Client` (`create_path`, recording patch, …) | | `alwaysAvailable` + slate | Copertina `/slates/offline.mp4` senza interrompere HLS | | Overlay tabellone | **App nativa** (non più ricodifica server) | -| `Streams::YoutubeRelay` | Legge RTMP (o HLS) da MediaMTX; copy video + AAC → YouTube | +| `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). @@ -281,7 +281,7 @@ App Android ◄──── REST API (/api/v1/...) ────► Rails Sidekiq ◄── Redis ◄──────┘ │ ├── HealthMonitorJob, UploadJob (merge/thumbnail ffmpeg) - ├── YoutubeRelay (ffmpeg copy+AAC, solo dirette YouTube) + ├── YoutubeRelay (ffmpeg remux copy A/V, solo dirette YouTube) └── PublishToYoutubeJob, purge replay, ecc. ``` @@ -419,3 +419,4 @@ cd infra && cp .env.example .env && docker compose up -d --build | 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 | diff --git a/docs/LIVE_STREAMING.md b/docs/LIVE_STREAMING.md index 3e4d991..7f5a292 100644 --- a/docs/LIVE_STREAMING.md +++ b/docs/LIVE_STREAMING.md @@ -8,7 +8,7 @@ MediaMTX espone **un unico flusso di uscita** per path (`live/match_{uuid}`): 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** l’HLS verso il sito. -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`, audio ricodificato in AAC). +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`; l’AAC 48 kHz mono è già prodotto dall’app / slate). Il telefono **non** invia la copertina di pausa: risparmia banda; lo switch slate ↔ camera è lato MediaMTX. @@ -51,7 +51,7 @@ MediaMTX path live/match_{uuid} ├──► HLS (segmenti ~1s) ──► edge /hls/ → MediaMTX ──► player web (HLS.js) └──► (solo platform=youtube) Streams::YoutubeRelay - ffmpeg: -c:v copy, -c:a aac ──► RTMPS YouTube + ffmpeg: -c:v copy, -c:a copy ──► RTMPS YouTube ``` | Responsabilità | Dove | @@ -93,7 +93,7 @@ La pagina live può mostrare badge HTML di stato (in onda / pausa / attesa); non | Quando | Cosa | Peso CPU | |--------|------|----------| -| Diretta `platform=youtube` | `YoutubeRelay`: demux + **copy video** + AAC audio → RTMPS | Medio-basso **per diretta**, scala con N processi | +| 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 | diff --git a/docs/TABELLONI_E_OVERLAY.md b/docs/TABELLONI_E_OVERLAY.md index 751e31a..7d6653e 100644 --- a/docs/TABELLONI_E_OVERLAY.md +++ b/docs/TABELLONI_E_OVERLAY.md @@ -170,7 +170,7 @@ Sull’app nativa l’overlay è composito in GPU **prima** dell’encode RTMP ( 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 `-c:v copy` (+ AAC). +MediaMTX e `YoutubeRelay` ricevono quindi un flusso già “brandizzato”; il relay YouTube fa solo remux (`-c copy` video e audio). Elementi grafici (`OverlayCanvasRenderer`): diff --git a/docs/YOUTUBE_MORNING_CHECKLIST.md b/docs/YOUTUBE_MORNING_CHECKLIST.md index dfe1ca3..21d5305 100644 --- a/docs/YOUTUBE_MORNING_CHECKLIST.md +++ b/docs/YOUTUBE_MORNING_CHECKLIST.md @@ -1,7 +1,7 @@ # YouTube Live — checklist operativa **Aggiornato:** 2026-07-29 -Pipeline attuale: telefono → MediaMTX → `Streams::YoutubeRelay` (ffmpeg copy+AAC in Sidekiq) → YouTube. Overlay tabellone in **app**, non più `OverlayRelay` server. +Pipeline attuale: telefono → MediaMTX → `Streams::YoutubeRelay` (ffmpeg remux copy A/V in Sidekiq) → YouTube. Overlay tabellone in **app**, non più `OverlayRelay` server. ## Verifica rapida su server diff --git a/docs/YOUTUBE_SETUP.md b/docs/YOUTUBE_SETUP.md index 9fd9531..042675a 100644 --- a/docs/YOUTUBE_SETUP.md +++ b/docs/YOUTUBE_SETUP.md @@ -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 (camera + overlay app) → RTMP MediaMTX → `Streams::YoutubeRelay` (ffmpeg `-c:v copy`, AAC) → YouTube Live. +Flusso tecnico: telefono (camera + overlay app) → RTMP MediaMTX → `Streams::YoutubeRelay` (ffmpeg `-c:v copy -c:a copy`) → YouTube Live. --- diff --git a/docs/infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md b/docs/infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md index 0c66c06..2d59937 100644 --- a/docs/infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md +++ b/docs/infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md @@ -29,7 +29,7 @@ Se il telefono andasse diretto a YouTube (o a un ingest cloud senza hold), la li ### Problema di capacità -Ogni diretta `platform=youtube` avvia un processo `ffmpeg` in Sidekiq (`Streams::YoutubeRelay`: video **copy**, audio **AAC**). +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): @@ -299,7 +299,7 @@ Finché D1 non è chiusa, non stimare bandwitdh né SLA del picco. ## 7. Dimensionamento indicativo -Ipotesi: 720p, video copy, AAC mono — **~0.3–0.7 vCPU** medi per relay (picchi più alti su analyze/reconnect). +Ipotesi: 720p, remux copy A/V — **~0.15–0.4 vCPU** medi per relay (picchi su analyze/reconnect). | Target live YT | Dedicated (relay) | Overflow tipico | Note | |----------------|-------------------|-----------------|------| @@ -349,7 +349,7 @@ Questi numeri vanno **calibati** con un load test (N sessioni fake + ffmpeg cont ### Fase 4 — Solo se necessario - Secondo MediaMTX / sharding -- Audio copy se l’encoder telefono è già YouTube-compatibile (meno CPU) +- ~~Audio copy se l’encoder telefono è già YouTube-compatibile (meno CPU)~~ → fatto (app AAC 48k mono + relay `-c:a copy`) - Dedicated più grosso come baseline (spesso più economico dell’overflow cronico ogni weekend) --- diff --git a/native/android/app/build.gradle.kts b/native/android/app/build.gradle.kts index 034b8e9..d8b1424 100644 --- a/native/android/app/build.gradle.kts +++ b/native/android/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "com.matchlivetv.match_live_tv" minSdk = 24 targetSdk = 36 - versionCode = 28 - versionName = "2.0.7-native" + versionCode = 29 + versionName = "2.0.8-native" val apiBaseUrl = project.findProperty("API_BASE_URL") as String? ?: "https://www.matchlivetv.it" diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/BroadcastModels.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/BroadcastModels.kt index 59e86b4..8455ffc 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/BroadcastModels.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/BroadcastModels.kt @@ -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, diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt index 34083c0..52f8629 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt @@ -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 diff --git a/native/ios/MatchLiveTv/Streaming/BroadcastModels.swift b/native/ios/MatchLiveTv/Streaming/BroadcastModels.swift index 044980d..488c4cd 100644 --- a/native/ios/MatchLiveTv/Streaming/BroadcastModels.swift +++ b/native/ios/MatchLiveTv/Streaming/BroadcastModels.swift @@ -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 { diff --git a/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift b/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift index 0887508..3e6f18d 100644 --- a/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift +++ b/native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift @@ -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) } diff --git a/scripts/deploy/sync_to_server.sh b/scripts/deploy/sync_to_server.sh index e10a975..999932e 100755 --- a/scripts/deploy/sync_to_server.sh +++ b/scripts/deploy/sync_to_server.sh @@ -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