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:
@@ -27,9 +27,6 @@ class OverlayRefreshJob < ApplicationJob
|
||||
|
||||
Streams::Overlay::Refresh.call(session)
|
||||
Mediamtx::PublisherSync.new(session).call if session.status.in?(%w[connecting reconnecting live paused])
|
||||
if session.platform == "youtube"
|
||||
Streams::YoutubeRelay.ensure_publishing!(session)
|
||||
end
|
||||
self.class.set(wait: INTERVAL).perform_later(session_id)
|
||||
end
|
||||
end
|
||||
|
||||
80
backend/app/jobs/overlay_relay_ensure_job.rb
Normal file
80
backend/app/jobs/overlay_relay_ensure_job.rb
Normal file
@@ -0,0 +1,80 @@
|
||||
# Avvia overlay ffmpeg solo in Sidekiq (un solo job per sessione alla volta).
|
||||
class OverlayRelayEnsureJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
LOCK_KEY = "overlay_ensure:lock:%s"
|
||||
LOCK_TTL = 55
|
||||
RETRY_WAIT = 5.seconds
|
||||
|
||||
class << self
|
||||
def enqueue_unique(session_id, attempt: 1, force_restart: false, wait: 0.seconds)
|
||||
key = format(LOCK_KEY, session_id)
|
||||
r = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||
return unless r.set(key, "1", nx: true, ex: LOCK_TTL)
|
||||
|
||||
set(wait: wait).perform_later(session_id, attempt, force_restart: force_restart)
|
||||
end
|
||||
end
|
||||
|
||||
def perform(session_id, attempt = 1, force_restart: false)
|
||||
session = StreamSession.find_by(id: session_id)
|
||||
unless session && !session.terminal?
|
||||
clear_lock(session_id)
|
||||
return
|
||||
end
|
||||
return unless session.status.in?(%w[live connecting reconnecting paused])
|
||||
|
||||
begin
|
||||
run_ensure(session, session_id, attempt, force_restart)
|
||||
ensure
|
||||
clear_lock(session_id)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def run_ensure(session, session_id, attempt, force_restart)
|
||||
if Mediamtx::PublisherOnline.video_publishing?(session) && !session.paused?
|
||||
session.go_live! if session.may_go_live?
|
||||
session.reconnect! if session.reconnecting? && session.may_reconnect?
|
||||
Mediamtx::Client.new.set_always_available(session, enabled: false)
|
||||
end
|
||||
|
||||
if force_restart
|
||||
Streams::OverlayRelay.stop(session)
|
||||
Streams::OverlayRelay.terminate_all_session_processes!(session.id)
|
||||
end
|
||||
|
||||
Streams::OverlayRelay.ensure_publishing!(session)
|
||||
|
||||
if Streams::OverlayRelay.healthy?(session)
|
||||
Youtube::LivePipeline.schedule_activate!(session, force: true, wait: 5.seconds)
|
||||
return
|
||||
end
|
||||
|
||||
return if Streams::OverlayRelay.running?(session.id)
|
||||
|
||||
Rails.logger.info("[OverlayRelayEnsureJob] overlay not running, retry session=#{session_id} attempt=#{attempt}")
|
||||
reschedule(session_id, attempt, false) if attempt < 30
|
||||
rescue Mediamtx::Client::Error => e
|
||||
Rails.logger.warn("[OverlayRelayEnsureJob] session=#{session_id}: #{e.message}")
|
||||
end
|
||||
|
||||
def reschedule(session_id, attempt, force_restart)
|
||||
clear_lock(session_id)
|
||||
self.class.enqueue_unique(
|
||||
session_id,
|
||||
attempt: attempt + 1,
|
||||
force_restart: force_restart,
|
||||
wait: RETRY_WAIT
|
||||
)
|
||||
end
|
||||
|
||||
def clear_lock(session_id)
|
||||
redis.del(format(LOCK_KEY, session_id))
|
||||
end
|
||||
|
||||
def redis
|
||||
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||
end
|
||||
end
|
||||
12
backend/app/jobs/overlay_score_refresh_job.rb
Normal file
12
backend/app/jobs/overlay_score_refresh_job.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
# Aggiorna tabellone overlay in background (evita blocchi HTTP al +1 punto).
|
||||
class OverlayScoreRefreshJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(session_id)
|
||||
session = StreamSession.find_by(id: session_id)
|
||||
return unless session
|
||||
return if session.terminal?
|
||||
|
||||
Streams::Overlay::Refresh.call(session)
|
||||
end
|
||||
end
|
||||
37
backend/app/jobs/stream_publisher_sync_job.rb
Normal file
37
backend/app/jobs/stream_publisher_sync_job.rb
Normal file
@@ -0,0 +1,37 @@
|
||||
# Poll MediaMTX + pipeline YouTube automatica (webhook MediaMTX non disponibile in prod).
|
||||
class StreamPublisherSyncJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
INTERVAL = 5.seconds
|
||||
ACTIVE_STATUSES = %w[connecting live reconnecting paused].freeze
|
||||
REDIS_CHAIN_KEY = "stream_publisher_sync:chain"
|
||||
RUN_LOCK_KEY = "stream_publisher_sync:run_lock"
|
||||
|
||||
def self.ensure_chain
|
||||
return if redis.get(REDIS_CHAIN_KEY)
|
||||
|
||||
redis.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL.to_i * 4)
|
||||
set(wait: INTERVAL).perform_later
|
||||
end
|
||||
|
||||
def self.redis
|
||||
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||
end
|
||||
|
||||
def perform
|
||||
return unless self.class.redis.set(RUN_LOCK_KEY, "1", nx: true, ex: INTERVAL.to_i)
|
||||
|
||||
begin
|
||||
self.class.redis.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL.to_i * 4)
|
||||
|
||||
StreamSession
|
||||
.where(status: ACTIVE_STATUSES)
|
||||
.find_each do |session|
|
||||
Mediamtx::PublisherSync.new(session).call
|
||||
end
|
||||
ensure
|
||||
self.class.set(wait: INTERVAL).perform_later
|
||||
self.class.redis.del(RUN_LOCK_KEY)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,32 +1,54 @@
|
||||
class YoutubeBroadcastActivateJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
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
|
||||
MAX_ATTEMPTS = 30
|
||||
RETRY_WAIT = 15.seconds
|
||||
RATE_LIMIT_WAIT = 90.seconds
|
||||
LIVE_STATUSES = %w[live connecting reconnecting paused].freeze
|
||||
|
||||
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)
|
||||
return unless LIVE_STATUSES.include?(session.status)
|
||||
return unless Youtube::LivePipeline.broadcast_ready?(session)
|
||||
|
||||
result = Youtube::BroadcastService.new(session.match.team).activate_broadcast!(session.youtube_broadcast_id)
|
||||
return if result == :live || result == :transitioned
|
||||
unless Streams::YoutubeRelay.running?(session.id)
|
||||
Streams::YoutubeRelay.ensure_publishing!(session)
|
||||
return reschedule(session_id, attempt + 1) if attempt < MAX_ATTEMPTS
|
||||
|
||||
if result == :broadcast_ended
|
||||
Youtube::SetupBroadcast.recreate!(session.reload)
|
||||
return
|
||||
end
|
||||
|
||||
return unless result == :waiting_ingest
|
||||
result = Youtube::BroadcastService.new(session.match.team).activate_broadcast!(session.youtube_broadcast_id)
|
||||
Rails.logger.info("[YoutubeBroadcastActivate] session=#{session.id} attempt=#{attempt} result=#{result}")
|
||||
|
||||
return if result == :live || result == :transitioned
|
||||
|
||||
if result == :rate_limited
|
||||
reschedule(session_id, attempt, wait: RATE_LIMIT_WAIT)
|
||||
return
|
||||
end
|
||||
|
||||
if result == :broadcast_ended
|
||||
Youtube::SetupBroadcast.recreate!(session.reload)
|
||||
Youtube::LivePipeline.schedule!(session.reload, force: true)
|
||||
return
|
||||
end
|
||||
|
||||
if result == :waiting_ingest && (attempt % 4).zero?
|
||||
Streams::YoutubeRelay.ensure_publishing!(session)
|
||||
end
|
||||
|
||||
return if attempt >= MAX_ATTEMPTS
|
||||
|
||||
self.class.schedule_with_retries(session_id, attempt: attempt + 1)
|
||||
reschedule(session_id, attempt + 1)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def reschedule(session_id, attempt, wait: RETRY_WAIT)
|
||||
self.class.set(wait: wait).perform_later(session_id, attempt)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,12 +1,61 @@
|
||||
class YoutubeBroadcastSetupJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
retry_on Youtube::SetupBroadcast::Error, wait: 10.seconds, attempts: 3
|
||||
RUN_LOCK_KEY = "youtube_setup:running:%s"
|
||||
MAX_RATE_LIMIT_ATTEMPTS = 15
|
||||
RATE_LIMIT_WAITS = [30, 45, 60, 90, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120, 120].freeze
|
||||
|
||||
def perform(session_id, youtube_channel = nil)
|
||||
def perform(session_id, youtube_channel = nil, attempt = 1)
|
||||
session = StreamSession.find_by(id: session_id)
|
||||
return unless session
|
||||
return unless session&.platform == "youtube"
|
||||
return if session.terminal?
|
||||
return if session.youtube_broadcast_id.present? && session.stream_key.present?
|
||||
return unless acquire_run_lock!(session_id, attempt)
|
||||
|
||||
Youtube::SetupBroadcast.call(session, youtube_channel: youtube_channel.presence)
|
||||
release_run_lock!(session_id)
|
||||
rescue Youtube::SetupBroadcast::Error => e
|
||||
if rate_limit?(e) && attempt < MAX_RATE_LIMIT_ATTEMPTS
|
||||
Youtube::ApiThrottle.mark_rate_limited!
|
||||
wait = RATE_LIMIT_WAITS[attempt - 1] || 120
|
||||
extend_run_lock!(session_id, wait + 90)
|
||||
Rails.logger.warn(
|
||||
"[YoutubeBroadcastSetupJob] rate limit session=#{session_id} attempt=#{attempt} retry_in=#{wait}s"
|
||||
)
|
||||
self.class.set(wait: wait.seconds).perform_later(session_id, youtube_channel, attempt + 1)
|
||||
return
|
||||
end
|
||||
release_run_lock!(session_id)
|
||||
raise
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def rate_limit?(error)
|
||||
msg = error.message.to_s
|
||||
msg.include?("rate limit") ||
|
||||
msg.include?("userRequestsExceedRateLimit") ||
|
||||
msg.include?("temporaneamente occupato")
|
||||
end
|
||||
|
||||
def acquire_run_lock!(session_id, attempt)
|
||||
key = format(RUN_LOCK_KEY, session_id)
|
||||
if attempt > 1
|
||||
return redis.exists?(key).positive?
|
||||
end
|
||||
|
||||
redis.set(key, "1", nx: true, ex: 90)
|
||||
end
|
||||
|
||||
def extend_run_lock!(session_id, seconds)
|
||||
redis.expire(format(RUN_LOCK_KEY, session_id), seconds)
|
||||
end
|
||||
|
||||
def release_run_lock!(session_id)
|
||||
redis.del(format(RUN_LOCK_KEY, session_id))
|
||||
end
|
||||
|
||||
def redis
|
||||
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||
end
|
||||
end
|
||||
|
||||
57
backend/app/jobs/youtube_ingest_watchdog_job.rb
Normal file
57
backend/app/jobs/youtube_ingest_watchdog_job.rb
Normal file
@@ -0,0 +1,57 @@
|
||||
# Controlli ripetuti finché YouTube non ha ingest active (poi attiva la broadcast).
|
||||
class YoutubeIngestWatchdogJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
FIRST_WAIT = 20.seconds
|
||||
NEXT_WAITS = [35.seconds, 45.seconds, 60.seconds].freeze
|
||||
|
||||
def perform(session_id, phase = 0)
|
||||
session = StreamSession.find_by(id: session_id)
|
||||
return unless Youtube::LivePipeline.broadcast_ready?(session)
|
||||
return if Youtube::ApiThrottle.rate_limited?
|
||||
|
||||
ingest = youtube_ingest_status(session)
|
||||
if ingest == "active"
|
||||
Youtube::LivePipeline.schedule_activate!(session, force: true, wait: 3.seconds)
|
||||
return
|
||||
end
|
||||
|
||||
if Streams::YoutubeRelay.running?(session.id)
|
||||
Rails.logger.info("[YoutubeIngestWatchdog] relay ok ingest=#{ingest} phase=#{phase} session=#{session.id}")
|
||||
else
|
||||
Rails.logger.warn("[YoutubeIngestWatchdog] avvio relay ingest=#{ingest} session=#{session.id}")
|
||||
Streams::YoutubeRelay.ensure_publishing!(session)
|
||||
end
|
||||
|
||||
schedule_next(session_id, phase)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("[YoutubeIngestWatchdog] session=#{session_id}: #{e.message}")
|
||||
schedule_next(session_id, phase)
|
||||
end
|
||||
|
||||
def self.schedule_chain(session_id)
|
||||
set(wait: FIRST_WAIT).perform_later(session_id, 0)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def schedule_next(session_id, phase)
|
||||
wait = NEXT_WAITS[phase]
|
||||
return unless wait
|
||||
|
||||
self.class.set(wait: wait).perform_later(session_id, phase + 1)
|
||||
end
|
||||
|
||||
def youtube_ingest_status(session)
|
||||
return nil if session.youtube_broadcast_id.blank?
|
||||
|
||||
client = Youtube::BroadcastService.new(session.match.team).send(:authorized_client)
|
||||
b = client.list_live_broadcasts("status,contentDetails", id: session.youtube_broadcast_id).items&.first
|
||||
return nil unless b
|
||||
|
||||
sid = b.content_details.bound_stream_id
|
||||
client.list_live_streams("status", id: sid).items.first&.status&.stream_status
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
end
|
||||
11
backend/app/jobs/youtube_relay_ensure_job.rb
Normal file
11
backend/app/jobs/youtube_relay_ensure_job.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
# Avvia/riavvia il relay YouTube solo nel container sidekiq (YOUTUBE_RELAY_WORKER=1).
|
||||
class YoutubeRelayEnsureJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(session_id)
|
||||
session = StreamSession.find_by(id: session_id)
|
||||
return unless session
|
||||
|
||||
Streams::YoutubeRelay.ensure_on_worker!(session)
|
||||
end
|
||||
end
|
||||
10
backend/app/jobs/youtube_relay_stop_job.rb
Normal file
10
backend/app/jobs/youtube_relay_stop_job.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
class YoutubeRelayStopJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(session_id)
|
||||
session = StreamSession.find_by(id: session_id)
|
||||
return unless session
|
||||
|
||||
Streams::YoutubeRelay.stop_on_worker!(session) if Streams::YoutubeRelay.worker?
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user