From 826347514642319c92df7f1070908cd30efb2674 Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Thu, 4 Jun 2026 08:37:47 +0200 Subject: [PATCH] Fix YouTube in attesa: audio AAC di riserva, retry attivazione broadcast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il relay verso YouTube ora gestisce _air senza traccia audio, ritenta activate_broadcast finché l'ingest è active e riduce la finestra scheduledStartTime a ~20s. Co-authored-by: Cursor --- .../jobs/youtube_broadcast_activate_job.rb | 18 ++++++++- backend/app/services/streams/youtube_relay.rb | 38 ++++++++++++++++--- .../app/services/youtube/broadcast_service.rb | 23 +++++++---- docs/APP_YOUTUBE_LIVE.md | 3 +- 4 files changed, 65 insertions(+), 17 deletions(-) diff --git a/backend/app/jobs/youtube_broadcast_activate_job.rb b/backend/app/jobs/youtube_broadcast_activate_job.rb index aeb3847..aaf0cc1 100644 --- a/backend/app/jobs/youtube_broadcast_activate_job.rb +++ b/backend/app/jobs/youtube_broadcast_activate_job.rb @@ -1,13 +1,27 @@ class YoutubeBroadcastActivateJob < ApplicationJob queue_as :default - def perform(session_id) + MAX_ATTEMPTS = 36 + RETRY_WAIT = 10.seconds + class << self + def schedule_with_retries(session_id, attempt: 1) + set(wait: attempt == 1 ? 8.seconds : RETRY_WAIT).perform_later(session_id, attempt) + end + end + + def perform(session_id, attempt = 1) session = StreamSession.find_by(id: session_id) return unless session&.platform == "youtube" return if session.terminal? return if session.youtube_broadcast_id.blank? return unless Streams::YoutubeRelay.running?(session.id) - Youtube::BroadcastService.new(session.match.team).activate_broadcast!(session.youtube_broadcast_id) + result = Youtube::BroadcastService.new(session.match.team).activate_broadcast!(session.youtube_broadcast_id) + return if result == :live || result == :transitioned + + return unless result == :waiting_ingest + return if attempt >= MAX_ATTEMPTS + + self.class.schedule_with_retries(session_id, attempt: attempt + 1) end end diff --git a/backend/app/services/streams/youtube_relay.rb b/backend/app/services/streams/youtube_relay.rb index 7dcfd9b..a0ab84a 100644 --- a/backend/app/services/streams/youtube_relay.rb +++ b/backend/app/services/streams/youtube_relay.rb @@ -21,7 +21,7 @@ module Streams # YouTube richiede FLV H.264/AAC stabili; -c copy lascia spesso streamStatus=inactive. pid = Process.spawn( - *youtube_ffmpeg_args(intake, output, log_path), + *youtube_ffmpeg_args(intake, output, log_path, with_audio: intake_has_audio?(intake)), pgroup: true ) Process.detach(pid) @@ -68,13 +68,28 @@ module Streams private - def youtube_ffmpeg_args(intake, output, log_path) - [ + def youtube_ffmpeg_args(intake, output, log_path, with_audio:) + base = [ "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning", "-fflags", "+genpts+discardcorrupt", "-use_wallclock_as_timestamps", "1", "-rw_timeout", "15000000", - "-i", intake, - "-map", "0:v:0", "-map", "0:a:0", + "-i", intake + ] + encode = youtube_encode_flags(output, log_path) + + if with_audio + base + ["-map", "0:v:0", "-map", "0:a:0"] + encode + else + # _air senza audio (overlay 0:a?): YouTube richiede comunque AAC. + base + [ + "-f", "lavfi", "-i", "anullsrc=channel_layout=mono:sample_rate=48000", + "-map", "0:v:0", "-map", "1:a:0" + ] + encode + end + end + + def youtube_encode_flags(output, log_path) + [ "-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency", "-pix_fmt", "yuv420p", "-r", "30", "-g", "30", "-keyint_min", "30", "-sc_threshold", "0", "-b:v", "4000k", "-maxrate", "4000k", "-bufsize", "8000k", @@ -84,10 +99,21 @@ module Streams ] end + def intake_has_audio?(url) + require "open3" + stdout, _stderr, status = Open3.capture3( + "ffprobe", "-v", "error", "-rw_timeout", "5000000", + "-select_streams", "a", "-show_entries", "stream=index", "-of", "csv=p=0", url + ) + status.success? && stdout.strip.present? + rescue StandardError + false + end + def schedule_youtube_activate(session) return if session.youtube_broadcast_id.blank? - YoutubeBroadcastActivateJob.set(wait: 8.seconds).perform_later(session.id) + YoutubeBroadcastActivateJob.schedule_with_retries(session.id) end def mediamtx_intake_url(session) diff --git a/backend/app/services/youtube/broadcast_service.rb b/backend/app/services/youtube/broadcast_service.rb index 375b253..ab6680f 100644 --- a/backend/app/services/youtube/broadcast_service.rb +++ b/backend/app/services/youtube/broadcast_service.rb @@ -51,31 +51,38 @@ module Youtube end # Porta in onda appena c’è ingest su YouTube (non attendere scheduledStartTime). + # @return [Symbol] :live, :waiting_ingest, :transitioned, :skipped def activate_broadcast!(broadcast_id) - return if broadcast_id.blank? - return if @credential.blank? || missing_oauth_config? + return :skipped if broadcast_id.blank? + return :skipped if @credential.blank? || missing_oauth_config? client = authorized_client item = client.list_live_broadcasts("status,contentDetails", id: broadcast_id).items&.first - return unless item + return :skipped unless item status = item.status.life_cycle_status.to_s - return if status == "live" + return :live if status == "live" stream_id = item.content_details&.bound_stream_id if stream_id.present? stream = client.list_live_streams("status", id: stream_id).items&.first - return if stream&.status&.stream_status != "active" + stream_status = stream&.status&.stream_status.to_s + return :waiting_ingest unless stream_status == "active" end if %w[ready created].include?(status) client.transition_live_broadcast("testing", broadcast_id, "id,status") status = "testing" end - client.transition_live_broadcast("live", broadcast_id, "id,status") if status == "testing" + if status == "testing" + client.transition_live_broadcast("live", broadcast_id, "id,status") + return :transitioned + end + + :skipped rescue Google::Apis::Error => e Rails.logger.warn("[Youtube::BroadcastService] activate #{broadcast_id}: #{e.message}") - nil + :skipped end def complete_broadcast!(broadcast_id) @@ -131,7 +138,7 @@ module Youtube # YouTube richiede scheduledStartTime nel futuro; per «vai subito» usiamo ~1 min (auto_start al RTMP). def normalize_scheduled_start_time(scheduled_at) now = Time.current.utc - minimum = now + 90.seconds + minimum = now + 20.seconds maximum = now + 7.days candidate = diff --git a/docs/APP_YOUTUBE_LIVE.md b/docs/APP_YOUTUBE_LIVE.md index 4d29945..38d42c7 100644 --- a/docs/APP_YOUTUBE_LIVE.md +++ b/docs/APP_YOUTUBE_LIVE.md @@ -64,4 +64,5 @@ Poi tocca la card o conferma dal foglio: si apre il wizard (Partita → Trasmiss | 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 | Verifica relay ffmpeg / `stream_key` in sessione | -| YouTube «tra X minuti» | Il relay verso YouTube non era attivo; riavvia diretta dopo deploy. Con ingest attivo la live parte subito (non più +5 min). | +| YouTube «tra X minuti» / COPERTINA | Relay ffmpeg verso YouTube non attivo o ingest `inactive`. Dopo deploy, riavvia la diretta dall’app; la live dovrebbe partire entro ~30s con segnale su `_air`. | +| YouTube resta in «waiting» | Verifica su server `log/youtube_relay_.log` nel container **sidekiq**; attendi che `streamStatus` diventi `active` (il job ritenta l’attivazione per ~6 min). |