Stabilizza live YouTube/RTMP e fix crash rotazione mobile.
Pipeline YouTube con relay, overlay e publisher sync lato backend; fix GL pauseGlDuringRotation su Android per evitare crash in rotazione durante lo streaming Flutter. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
28
backend/app/services/youtube/api_throttle.rb
Normal file
28
backend/app/services/youtube/api_throttle.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
module Youtube
|
||||
# Evita tempeste di chiamate YouTube Data API (quota / userRequestsExceedRateLimit).
|
||||
class ApiThrottle
|
||||
KEY = "youtube_api:rate_limited_until"
|
||||
|
||||
class << self
|
||||
def rate_limited?
|
||||
until_ts = redis.get(KEY).to_i
|
||||
until_ts.positive? && until_ts > Time.now.to_i
|
||||
end
|
||||
|
||||
def mark_rate_limited!(seconds: 120)
|
||||
redis.set(KEY, (Time.now.to_i + seconds), ex: seconds + 30)
|
||||
end
|
||||
|
||||
def rate_limit_error?(message)
|
||||
msg = message.to_s
|
||||
msg.include?("userRequestsExceedRateLimit") || msg.include?("rate limit")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def redis
|
||||
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -47,14 +47,16 @@ module Youtube
|
||||
rtmp_url: stream_result.cdn.ingestion_info.ingestion_address
|
||||
}
|
||||
rescue Google::Apis::Error => e
|
||||
ApiThrottle.mark_rate_limited! if ApiThrottle.rate_limit_error?(e.message)
|
||||
raise Error, friendly_api_error(e)
|
||||
end
|
||||
|
||||
# Porta in onda appena c’è ingest su YouTube (non attendere scheduledStartTime).
|
||||
# @return [Symbol] :live, :waiting_ingest, :transitioned, :skipped
|
||||
# @return [Symbol] :live, :waiting_ingest, :transitioned, :skipped, :rate_limited
|
||||
def activate_broadcast!(broadcast_id)
|
||||
return :skipped if broadcast_id.blank?
|
||||
return :skipped if @credential.blank? || missing_oauth_config?
|
||||
return :rate_limited if ApiThrottle.rate_limited?
|
||||
|
||||
client = authorized_client
|
||||
item = client.list_live_broadcasts("status,contentDetails", id: broadcast_id).items&.first
|
||||
@@ -68,7 +70,11 @@ module Youtube
|
||||
if stream_id.present?
|
||||
stream = client.list_live_streams("status", id: stream_id).items&.first
|
||||
stream_status = stream&.status&.stream_status.to_s
|
||||
Rails.logger.info("[Youtube::BroadcastService] activate #{broadcast_id}: lifecycle=#{status} stream_status=#{stream_status.presence || "(blank)"}")
|
||||
return :waiting_ingest unless stream_status == "active"
|
||||
else
|
||||
Rails.logger.info("[Youtube::BroadcastService] activate #{broadcast_id}: lifecycle=#{status} stream_status=(unbound)")
|
||||
return :waiting_ingest
|
||||
end
|
||||
|
||||
if %w[ready created].include?(status)
|
||||
@@ -82,6 +88,11 @@ module Youtube
|
||||
|
||||
:skipped
|
||||
rescue Google::Apis::Error => e
|
||||
if ApiThrottle.rate_limit_error?(e.message)
|
||||
ApiThrottle.mark_rate_limited!
|
||||
Rails.logger.warn("[Youtube::BroadcastService] activate rate limited #{broadcast_id}")
|
||||
return :rate_limited
|
||||
end
|
||||
Rails.logger.warn("[Youtube::BroadcastService] activate #{broadcast_id}: #{e.message}")
|
||||
:skipped
|
||||
end
|
||||
@@ -133,6 +144,8 @@ module Youtube
|
||||
return "Orario programmazione YouTube non valido. Riprova: la diretta partirà appena invii il segnale RTMP."
|
||||
end
|
||||
|
||||
return "YouTube temporaneamente occupato. Riprova tra 1–2 minuti." if ApiThrottle.rate_limit_error?(msg)
|
||||
|
||||
msg
|
||||
end
|
||||
|
||||
|
||||
107
backend/app/services/youtube/live_pipeline.rb
Normal file
107
backend/app/services/youtube/live_pipeline.rb
Normal file
@@ -0,0 +1,107 @@
|
||||
module Youtube
|
||||
# Broadcast YouTube (API) + relay ffmpeg copy da MediaMTX → RTMPS. Nessun overlay.
|
||||
class LivePipeline
|
||||
PIPELINE_DEBOUNCE_KEY = "youtube_pipeline:debounce:%s"
|
||||
ACTIVATE_DEBOUNCE_KEY = "youtube_activate:debounce:%s"
|
||||
SETUP_DEBOUNCE_KEY = "youtube_setup:debounce:%s"
|
||||
PUBLISHER_SEEN_KEY = "publisher_online:seen:%s"
|
||||
|
||||
class << self
|
||||
def schedule!(session, force: false)
|
||||
return unless pipeline_eligible?(session)
|
||||
|
||||
ensure_broadcast_setup!(session) unless broadcast_ready?(session)
|
||||
|
||||
return unless force || redis.set(format(PIPELINE_DEBOUNCE_KEY, session.id), "1", nx: true, ex: 8)
|
||||
|
||||
Streams::YoutubeRelay.ensure_publishing!(session) if broadcast_ready?(session)
|
||||
if broadcast_ready?(session)
|
||||
YoutubeIngestWatchdogJob.schedule_chain(session.id)
|
||||
schedule_activate!(session, force: true, wait: 20.seconds)
|
||||
end
|
||||
Rails.logger.info("[Youtube::LivePipeline] scheduled session=#{session.id} broadcast_ready=#{broadcast_ready?(session)}")
|
||||
end
|
||||
|
||||
def schedule_activate!(session, force: false, wait: 8.seconds)
|
||||
return unless pipeline_eligible?(session)
|
||||
return unless broadcast_ready?(session)
|
||||
|
||||
debounce_ok = force || redis.set(format(ACTIVATE_DEBOUNCE_KEY, session.id), "1", nx: true, ex: 12)
|
||||
return unless debounce_ok
|
||||
|
||||
YoutubeBroadcastActivateJob.set(wait: wait).perform_later(session.id, 1)
|
||||
end
|
||||
|
||||
def ensure_broadcast_setup!(session)
|
||||
return unless pipeline_eligible?(session)
|
||||
return if broadcast_ready?(session)
|
||||
return if redis.exists?(format(YoutubeBroadcastSetupJob::RUN_LOCK_KEY, session.id)).positive?
|
||||
|
||||
return unless redis.set(format(SETUP_DEBOUNCE_KEY, session.id), "1", nx: true, ex: 90)
|
||||
|
||||
YoutubeBroadcastSetupJob.perform_later(session.id)
|
||||
Rails.logger.info("[Youtube::LivePipeline] queued YoutubeBroadcastSetupJob session=#{session.id}")
|
||||
end
|
||||
|
||||
def tick_session!(session)
|
||||
return unless pipeline_eligible?(session)
|
||||
|
||||
ensure_broadcast_setup!(session) unless broadcast_ready?(session)
|
||||
|
||||
online = Mediamtx::PublisherOnline.active?(session)
|
||||
seen_key = format(PUBLISHER_SEEN_KEY, session.id)
|
||||
was_online = redis.get(seen_key) == "1"
|
||||
redis.set(seen_key, online ? "1" : "0", ex: 4.hours.to_i)
|
||||
|
||||
unless online
|
||||
Streams::YoutubeRelay.ensure_publishing!(session) if broadcast_ready?(session)
|
||||
return
|
||||
end
|
||||
|
||||
if session.paused?
|
||||
Streams::YoutubeRelay.ensure_publishing!(session) if broadcast_ready?(session)
|
||||
return
|
||||
end
|
||||
|
||||
Mediamtx::Client.new.set_always_available(session, enabled: false)
|
||||
session.go_live! if session.may_go_live?
|
||||
session.reconnect! if session.reconnecting? && session.may_reconnect?
|
||||
|
||||
if !was_online
|
||||
Rails.logger.info("[Youtube::LivePipeline] publisher online session=#{session.id}")
|
||||
schedule!(session, force: true)
|
||||
return
|
||||
end
|
||||
|
||||
if Streams::YoutubeRelay.running?(session.id)
|
||||
schedule_activate!(session)
|
||||
else
|
||||
schedule!(session)
|
||||
end
|
||||
rescue Mediamtx::Client::Error => e
|
||||
Rails.logger.warn("[Youtube::LivePipeline] tick session=#{session.id}: #{e.message}")
|
||||
end
|
||||
|
||||
def pipeline_eligible?(session)
|
||||
session.present? &&
|
||||
session.platform == "youtube" &&
|
||||
!session.terminal? &&
|
||||
session.status.in?(%w[live connecting reconnecting paused])
|
||||
end
|
||||
|
||||
def broadcast_ready?(session)
|
||||
session.youtube_broadcast_id.present? && session.stream_key.present?
|
||||
end
|
||||
|
||||
def eligible?(session)
|
||||
pipeline_eligible?(session) && broadcast_ready?(session)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def redis
|
||||
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -36,7 +36,7 @@ module Youtube
|
||||
@session.reload
|
||||
return @session if @session.terminal?
|
||||
return @session if @session.platform != "youtube"
|
||||
return @session if @session.youtube_broadcast_id.present?
|
||||
return @session if @session.youtube_broadcast_id.present? && @session.stream_key.present?
|
||||
|
||||
yt = BroadcastService.new(@session.match.team, youtube_channel: @youtube_channel)
|
||||
title = "#{@session.match.team.name} vs #{@session.match.opponent_name}"
|
||||
@@ -53,10 +53,7 @@ module Youtube
|
||||
rtmp_url: broadcast[:rtmp_url]
|
||||
)
|
||||
|
||||
# Relay solo quando _air pubblica (overlay pronto); altrimenti ffmpeg esce subito.
|
||||
if Streams::OverlayRelay.running?(@session.id)
|
||||
Streams::YoutubeRelay.start(@session)
|
||||
end
|
||||
# Relay copy MediaMTX → YouTube parte subito (copertina slate se il telefono non c'è ancora).
|
||||
@session.stream_events.create!(
|
||||
event_type: "youtube_ready",
|
||||
metadata: { broadcast_id: broadcast[:broadcast_id] },
|
||||
@@ -71,6 +68,7 @@ module Youtube
|
||||
youtube_broadcast_id: @session.youtube_broadcast_id
|
||||
}
|
||||
)
|
||||
Youtube::LivePipeline.schedule!(@session, force: true)
|
||||
@session
|
||||
rescue BroadcastService::Error => e
|
||||
@session.stream_events.create!(
|
||||
|
||||
Reference in New Issue
Block a user