Fix YouTube in attesa: audio AAC di riserva, retry attivazione broadcast

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 <cursoragent@cursor.com>
This commit is contained in:
2026-06-04 08:37:47 +02:00
parent bac655aca8
commit 8263475146
4 changed files with 65 additions and 17 deletions

View File

@@ -1,13 +1,27 @@
class YoutubeBroadcastActivateJob < ApplicationJob class YoutubeBroadcastActivateJob < ApplicationJob
queue_as :default 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) session = StreamSession.find_by(id: session_id)
return unless session&.platform == "youtube" return unless session&.platform == "youtube"
return if session.terminal? return if session.terminal?
return if session.youtube_broadcast_id.blank? return if session.youtube_broadcast_id.blank?
return unless Streams::YoutubeRelay.running?(session.id) 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
end end

View File

@@ -21,7 +21,7 @@ module Streams
# YouTube richiede FLV H.264/AAC stabili; -c copy lascia spesso streamStatus=inactive. # YouTube richiede FLV H.264/AAC stabili; -c copy lascia spesso streamStatus=inactive.
pid = Process.spawn( 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 pgroup: true
) )
Process.detach(pid) Process.detach(pid)
@@ -68,13 +68,28 @@ module Streams
private 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", "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
"-fflags", "+genpts+discardcorrupt", "-use_wallclock_as_timestamps", "1", "-fflags", "+genpts+discardcorrupt", "-use_wallclock_as_timestamps", "1",
"-rw_timeout", "15000000", "-rw_timeout", "15000000",
"-i", intake, "-i", intake
"-map", "0:v:0", "-map", "0:a:0", ]
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", "-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency",
"-pix_fmt", "yuv420p", "-r", "30", "-g", "30", "-keyint_min", "30", "-sc_threshold", "0", "-pix_fmt", "yuv420p", "-r", "30", "-g", "30", "-keyint_min", "30", "-sc_threshold", "0",
"-b:v", "4000k", "-maxrate", "4000k", "-bufsize", "8000k", "-b:v", "4000k", "-maxrate", "4000k", "-bufsize", "8000k",
@@ -84,10 +99,21 @@ module Streams
] ]
end 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) def schedule_youtube_activate(session)
return if session.youtube_broadcast_id.blank? return if session.youtube_broadcast_id.blank?
YoutubeBroadcastActivateJob.set(wait: 8.seconds).perform_later(session.id) YoutubeBroadcastActivateJob.schedule_with_retries(session.id)
end end
def mediamtx_intake_url(session) def mediamtx_intake_url(session)

View File

@@ -51,31 +51,38 @@ module Youtube
end end
# Porta in onda appena cè ingest su YouTube (non attendere scheduledStartTime). # Porta in onda appena cè ingest su YouTube (non attendere scheduledStartTime).
# @return [Symbol] :live, :waiting_ingest, :transitioned, :skipped
def activate_broadcast!(broadcast_id) def activate_broadcast!(broadcast_id)
return if broadcast_id.blank? return :skipped if broadcast_id.blank?
return if @credential.blank? || missing_oauth_config? return :skipped if @credential.blank? || missing_oauth_config?
client = authorized_client client = authorized_client
item = client.list_live_broadcasts("status,contentDetails", id: broadcast_id).items&.first 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 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 stream_id = item.content_details&.bound_stream_id
if stream_id.present? if stream_id.present?
stream = client.list_live_streams("status", id: stream_id).items&.first 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 end
if %w[ready created].include?(status) if %w[ready created].include?(status)
client.transition_live_broadcast("testing", broadcast_id, "id,status") client.transition_live_broadcast("testing", broadcast_id, "id,status")
status = "testing" status = "testing"
end 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 rescue Google::Apis::Error => e
Rails.logger.warn("[Youtube::BroadcastService] activate #{broadcast_id}: #{e.message}") Rails.logger.warn("[Youtube::BroadcastService] activate #{broadcast_id}: #{e.message}")
nil :skipped
end end
def complete_broadcast!(broadcast_id) 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). # YouTube richiede scheduledStartTime nel futuro; per «vai subito» usiamo ~1 min (auto_start al RTMP).
def normalize_scheduled_start_time(scheduled_at) def normalize_scheduled_start_time(scheduled_at)
now = Time.current.utc now = Time.current.utc
minimum = now + 90.seconds minimum = now + 20.seconds
maximum = now + 7.days maximum = now + 7.days
candidate = candidate =

View File

@@ -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) | | YouTube non selezionabile | Token canale Match Live TV sul server (`admin` → YouTube piattaforma) |
| OAuth «app non verificata» | Utente di test Google + Avanzate | | OAuth «app non verificata» | Utente di test Google + Avanzate |
| Live non su YouTube | Verifica relay ffmpeg / `stream_key` in sessione | | 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 dallapp; la live dovrebbe partire entro ~30s con segnale su `_air`. |
| YouTube resta in «waiting» | Verifica su server `log/youtube_relay_<session_id>.log` nel container **sidekiq**; attendi che `streamStatus` diventi `active` (il job ritenta lattivazione per ~6 min). |