module Youtube class BroadcastService class Error < StandardError; end def initialize(team, youtube_channel: nil) @team = team @resolver = CredentialResolver.new(team, channel: youtube_channel) @credential = @resolver.resolve end def create_broadcast!(title:, privacy_status: "public", scheduled_at: nil) return mock_broadcast if @credential.blank? || missing_oauth_config? client = authorized_client start_time = normalize_scheduled_start_time(scheduled_at) broadcast = Google::Apis::YoutubeV3::LiveBroadcast.new( snippet: Google::Apis::YoutubeV3::LiveBroadcastSnippet.new( title: title, scheduled_start_time: start_time.iso8601 ), status: Google::Apis::YoutubeV3::LiveBroadcastStatus.new( privacy_status: privacy_status, self_declared_made_for_kids: false ), content_details: Google::Apis::YoutubeV3::LiveBroadcastContentDetails.new( enable_auto_start: true, enable_auto_stop: true ) ) result = client.insert_live_broadcast("snippet,status,contentDetails", broadcast) stream = Google::Apis::YoutubeV3::LiveStream.new( snippet: Google::Apis::YoutubeV3::LiveStreamSnippet.new(title: "#{title} stream"), cdn: Google::Apis::YoutubeV3::CdnSettings.new( frame_rate: "30fps", ingestion_type: "rtmp", resolution: "720p" ) ) stream_result = client.insert_live_stream("snippet,cdn", stream) client.bind_live_broadcast(result.id, "id,contentDetails", stream_id: stream_result.id) { broadcast_id: result.id, stream_id: stream_result.id, stream_key: stream_result.cdn.ingestion_info.stream_name, rtmp_url: stream_result.cdn.ingestion_info.ingestion_address } rescue Google::Apis::Error => e raise Error, friendly_api_error(e) end # Porta in onda appena c’è ingest su YouTube (non attendere scheduledStartTime). def activate_broadcast!(broadcast_id) return if broadcast_id.blank? return if @credential.blank? || missing_oauth_config? client = authorized_client item = client.list_live_broadcasts("status", id: broadcast_id).items&.first return unless item status = item.status.life_cycle_status.to_s return if status == "live" 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" rescue Google::Apis::Error => e Rails.logger.warn("[Youtube::BroadcastService] activate #{broadcast_id}: #{e.message}") nil end def complete_broadcast!(broadcast_id) return if broadcast_id.blank? return if @credential.blank? || missing_oauth_config? client = authorized_client client.transition_live_broadcast("complete", broadcast_id, "id,status") rescue Google::Apis::Error nil end def viewer_count(broadcast_id) return 0 if @credential.blank? || missing_oauth_config? client = authorized_client resp = client.list_live_broadcasts("statistics", id: broadcast_id) resp.items&.first&.statistics&.concurrent_viewers.to_i rescue Google::Apis::Error 0 end private def mock_broadcast { broadcast_id: "mock_#{SecureRandom.hex(8)}", stream_id: "mock_stream", stream_key: ENV.fetch("YOUTUBE_MOCK_STREAM_KEY", "mock-stream-key"), rtmp_url: "rtmp://a.rtmp.youtube.com/live2" } end def missing_oauth_config? ENV["YOUTUBE_CLIENT_ID"].blank? end def authorized_client OauthRefresh.new(@credential).apply!(Google::Apis::YoutubeV3::YouTubeService.new) end def friendly_api_error(error) msg = error.message.to_s return "Il canale YouTube non ha la diretta attiva. Apri YouTube Studio con l’account del canale Match Live TV, abilita la live (verifica telefono se richiesto) e riprova." if msg.include?("liveStreamingNotEnabled") return "Credenziali YouTube non valide. Ricollega il canale dalla sezione Admin → YouTube." if msg.match?(/Unauthorized|invalid_grant/i) if msg.match?(/invalidScheduledStartTime|scheduledStartTimeRequired/i) return "Orario programmazione YouTube non valido. Riprova: la diretta partirà appena invii il segnale RTMP." end msg end # 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 maximum = now + 7.days candidate = if scheduled_at.blank? minimum else scheduled_at.utc end candidate = minimum if candidate < minimum candidate = maximum if candidate > maximum candidate end end end