YouTube chiudeva la diretta (complete) senza ingest stabile; ora enable_auto_stop è false e SetupBroadcast.recreate! rigenera link/stream key. Co-authored-by: Cursor <cursoragent@cursor.com>
158 lines
5.5 KiB
Ruby
158 lines
5.5 KiB
Ruby
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: false
|
||
)
|
||
)
|
||
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).
|
||
# @return [Symbol] :live, :waiting_ingest, :transitioned, :skipped
|
||
def activate_broadcast!(broadcast_id)
|
||
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 :skipped unless item
|
||
|
||
status = item.status.life_cycle_status.to_s
|
||
return :live if status == "live"
|
||
return :broadcast_ended if status == "complete"
|
||
|
||
stream_id = item.content_details&.bound_stream_id
|
||
if stream_id.present?
|
||
stream = client.list_live_streams("status", id: stream_id).items&.first
|
||
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
|
||
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}")
|
||
:skipped
|
||
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 + 20.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
|