Initial commit: monorepo Match Live TV.

Rails API, app Flutter, infrastruttura Docker/MediaMTX, sito marketing e documentazione di deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-26 17:45:37 +02:00
commit bba6df52c0
381 changed files with 20599 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
module Billing
class AssignPlan
def self.call(team:, plan_slug:, status: "active", stripe_attrs: {})
plan = Plan[plan_slug]
sub = team.subscription || team.build_subscription
attrs = { plan: plan, status: status }.merge(stripe_attrs.symbolize_keys)
sub.assign_attributes(attrs)
sub.save!
sub
end
end
end

View File

@@ -0,0 +1,79 @@
module Billing
module Stripe
class CheckoutSession
VALID_PLANS = %w[premium_light premium_full].freeze
def initialize(team:, user:, plan_slug: "premium_light")
@team = team
@user = user
@plan_slug = plan_slug.to_s
end
def url
raise "Stripe non configurato" unless MatchLiveTv.stripe_enabled?
raise "Piano non valido" unless VALID_PLANS.include?(@plan_slug)
plan = Plan[@plan_slug]
price_id = plan.stripe_price_id.presence || stripe_price_id_for(@plan_slug)
raise "Price ID Stripe mancante per #{@plan_slug}" if price_id.blank?
customer_id = ensure_customer_id!
session = ::Stripe::Checkout::Session.create(
mode: "subscription",
customer: customer_id,
line_items: [{ price: price_id, quantity: 1 }],
success_url: success_url,
cancel_url: cancel_url,
metadata: { team_id: @team.id, user_id: @user.id, plan_slug: @plan_slug },
subscription_data: { metadata: { team_id: @team.id, plan_slug: @plan_slug } }
)
session.url
end
def portal_url
raise "Stripe non configurato" unless MatchLiveTv.stripe_enabled?
customer_id = ensure_customer_id!
session = ::Stripe::BillingPortal::Session.create(
customer: customer_id,
return_url: dashboard_url
)
session.url
end
private
def stripe_price_id_for(slug)
case slug
when "premium_light" then MatchLiveTv.stripe_premium_light_price_id
when "premium_full" then MatchLiveTv.stripe_premium_full_price_id
end
end
def ensure_customer_id!
sub = @team.subscription || @team.build_subscription(plan: Plan["free"], status: "active")
return sub.stripe_customer_id if sub.stripe_customer_id.present?
customer = ::Stripe::Customer.create(
email: @user.email,
name: @team.name,
metadata: { team_id: @team.id }
)
sub.update!(stripe_customer_id: customer.id)
customer.id
end
def success_url
"#{dashboard_url}?checkout=success"
end
def cancel_url
"#{dashboard_url}?checkout=canceled"
end
def dashboard_url
"#{MatchLiveTv.app_public_url.chomp('/')}/teams/#{@team.id}/dashboard"
end
end
end
end

View File

@@ -0,0 +1,131 @@
module Billing
module Stripe
class WebhookHandler
def self.call(event)
new(event).call
end
def initialize(event)
@event = event
end
def call
case @event.type
when "checkout.session.completed"
handle_checkout_completed(@event.data.object)
when "customer.subscription.updated", "customer.subscription.created"
sync_subscription(@event.data.object)
when "customer.subscription.deleted"
downgrade_to_free(@event.data.object)
when "invoice.payment_failed"
mark_past_due(@event.data.object)
end
end
private
def handle_checkout_completed(session)
team_id = metadata_team_id(session)
return if team_id.blank?
team = Team.find_by(id: team_id)
return unless team
sub = team.subscription || team.build_subscription
sub.update!(stripe_customer_id: session.customer) if session.customer.present?
if session.subscription.present?
stripe_sub = ::Stripe::Subscription.retrieve(session.subscription)
plan_slug = metadata_plan_slug(session) || "premium_light"
plan_slug = "premium_full" if plan_slug == "premium"
sync_subscription(stripe_sub, team: team, plan_slug: plan_slug)
end
end
def sync_subscription(stripe_sub, team: nil, plan_slug: nil)
team ||= Team.joins(:subscription)
.find_by(subscriptions: { stripe_subscription_id: stripe_sub.id })
team ||= Team.find_by(id: metadata_team_id(stripe_sub))
return unless team
plan_slug ||= metadata_plan_slug(stripe_sub) || "premium_light"
plan_slug = "premium_full" if plan_slug == "premium"
plan = Plan[plan_slug]
status = map_status(stripe_sub.status)
period_start = unix_time(stripe_sub.current_period_start)
period_end = unix_time(stripe_sub.current_period_end)
Billing::AssignPlan.call(
team: team,
plan_slug: plan.slug,
status: status,
stripe_attrs: {
stripe_customer_id: stripe_sub.customer,
stripe_subscription_id: stripe_sub.id,
current_period_start: period_start,
current_period_end: period_end,
cancel_at_period_end: stripe_sub.cancel_at_period_end
}
)
end
def downgrade_to_free(stripe_sub)
team = Team.joins(:subscription)
.find_by(subscriptions: { stripe_subscription_id: stripe_sub.id })
return unless team
Billing::AssignPlan.call(
team: team,
plan_slug: "free",
status: "active",
stripe_attrs: {
stripe_subscription_id: nil,
current_period_start: nil,
current_period_end: nil,
cancel_at_period_end: false
}
)
end
def mark_past_due(invoice)
return if invoice.subscription.blank?
team = Team.joins(:subscription)
.find_by(subscriptions: { stripe_subscription_id: invoice.subscription })
return unless team&.subscription
team.subscription.update!(status: "past_due")
end
def unix_time(value)
return nil if value.blank?
Time.zone.at(value.to_i)
end
def metadata_plan_slug(obj)
meta = obj.metadata
return nil if meta.blank?
meta["plan_slug"] || meta[:plan_slug] || (meta.respond_to?(:plan_slug) ? meta.plan_slug : nil)
end
def metadata_team_id(obj)
meta = obj.metadata
return nil if meta.blank?
meta["team_id"] || meta[:team_id] || (meta.respond_to?(:team_id) ? meta.team_id : nil)
end
def map_status(stripe_status)
case stripe_status
when "trialing" then "trialing"
when "active" then "active"
when "past_due", "unpaid" then "past_due"
when "canceled", "incomplete_expired" then "canceled"
else "incomplete"
end
end
end
end
end

View File

@@ -0,0 +1,101 @@
require "set"
module Mediamtx
class Client
class Error < StandardError; end
def initialize(base_url: MatchLiveTv.mediamtx_api_url)
@conn = Faraday.new(url: base_url) do |f|
f.request :json
f.response :json
f.adapter Faraday.default_adapter
end
end
def create_path(session)
path = session.mediamtx_path_name
record = session.match.team.entitlements.recording_enabled_for_mediamtx?
body = {
source: "publisher",
overridePublisher: true,
record: record,
recordPath: "/recordings/%path/%Y-%m-%d_%H-%M-%S",
recordSegmentDuration: "60s"
}
unless session.matchlivetv_platform?
body[:runOnReady] = run_on_ready_script(session)
body[:runOnReadyRestart] = true
body[:runOnNotReady] = webhook_curl(session, "disconnect")
end
response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body)
unless response.success?
err = response.body.is_a?(Hash) ? response.body["error"] : response.body
raise Error, "MediaMTX path create failed: #{response.status} #{err}"
end
true
end
def delete_path(session)
delete_path_name(session.mediamtx_path_name)
end
def delete_path_name(path_name)
@conn.post("/v3/config/paths/delete/#{CGI.escape(path_name)}")
end
def list_paths
response = @conn.get("/v3/paths/list")
return [] unless response.success?
body = response.body
body.is_a?(Hash) ? (body["items"] || []) : []
rescue Error, Faraday::Error
[]
end
def online_path_names
Set.new(list_paths.filter_map { |item| item["name"] if item["online"] })
end
private
def publish_webhook(session)
webhook_curl(session, "connect")
end
def disconnect_webhook(session)
webhook_curl(session, "disconnect")
end
def webhook_curl(session, event)
secret = MatchLiveTv.mediamtx_webhook_secret
rails = ENV.fetch("RAILS_WEBHOOK_URL", "http://rails:3000")
payload = %({"session_id":"#{session.id}"})
# MediaMTX image non include curl; wget è disponibile nell'immagine ufficiale
<<~SCRIPT.squish
wget -q -O- --post-data='#{payload}' --header='Content-Type: application/json'
--header="X-MediaMTX-Signature: $(printf '%s' '#{payload}' | openssl dgst -sha256 -hmac '#{secret}' | cut -d' ' -f2)"
#{rails}/webhooks/mediamtx/#{event}
SCRIPT
end
def run_on_ready_script(session)
connect = webhook_curl(session, "connect")
return connect if session.matchlivetv_platform?
"#{connect} & #{relay_script(session)}"
end
def relay_script(session)
return "echo 'no youtube key'" if session.stream_key.blank?
return "echo 'youtube relay skipped'" unless session.platform == "youtube"
key = session.stream_key
path = session.mediamtx_path_name
<<~SCRIPT.squish
ffmpeg -re -i rtmp://127.0.0.1:1935/#{path} -c copy -f flv
rtmp://a.rtmp.youtube.com/live2/#{key} 2>/var/log/ffmpeg-#{path}.log
SCRIPT
end
end
end

View File

@@ -0,0 +1,25 @@
module Recordings
class IndexSession
def initialize(session)
@session = session
end
def call
team = @session.match.team
return unless team.entitlements.can_access_recordings?
retention_days = team.entitlements.recording_retention_days
expires_at = retention_days.positive? ? retention_days.days.from_now : nil
recording = Recording.find_or_initialize_by(stream_session: @session)
recording.assign_attributes(
team: team,
status: "ready",
storage_path: @session.mediamtx_path_name,
expires_at: expires_at
)
recording.save!
recording
end
end
end

View File

@@ -0,0 +1,61 @@
module Sessions
class Create
def initialize(user:, match:, params:)
@user = user
@match = match
@params = params
end
def call
platform = @params[:platform].presence || "matchlivetv"
ent = @match.team.entitlements
ent.assert_can_stream_on!(platform)
ent.assert_concurrent_stream!
session = StreamSession.new(
match: @match,
user: @user,
platform: platform,
privacy_status: @params[:privacy_status] || "unlisted",
quality_preset: @params[:quality_preset] || "720p_30_2.5mbps",
target_bitrate: @params[:target_bitrate] || 2_500_000,
target_fps: @params[:target_fps] || 30,
status: "idle"
)
if session.platform == "youtube"
attach_youtube_broadcast!(session)
end
StreamSession.transaction do
session.save!
session.create_score_state!
Mediamtx::Client.new.create_path(session)
log_event(session, "pairing", { created: true, platform: session.platform })
end
session
end
private
def attach_youtube_broadcast!(session)
yt = Youtube::BroadcastService.new(session.match.team)
title = "#{session.match.team.name} vs #{session.match.opponent_name}"
broadcast = yt.create_broadcast!(
title: title,
privacy_status: session.privacy_status,
scheduled_at: session.match.scheduled_at
)
session.youtube_broadcast_id = broadcast[:broadcast_id]
session.youtube_stream_id = broadcast[:stream_id]
session.stream_key = broadcast[:stream_key]
session.rtmp_url = broadcast[:rtmp_url]
end
def log_event(session, type, metadata)
session.stream_events.create!(event_type: type, metadata: metadata, occurred_at: Time.current)
end
end
end

View File

@@ -0,0 +1,20 @@
module Sessions
class Pause
def initialize(session)
@session = session
end
def call
@session.pause! if @session.may_pause?
log_event("paused")
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
@session
end
private
def log_event(type)
@session.stream_events.create!(event_type: type, occurred_at: Time.current, metadata: {})
end
end
end

View File

@@ -0,0 +1,21 @@
module Sessions
class Start
def initialize(session)
@session = session
end
def call
@session.match.team.entitlements.assert_concurrent_stream!(excluding_session: @session)
@session.begin_connect! if @session.may_begin_connect?
@session.update!(status: "connecting") unless @session.connecting?
broadcast_status("connecting")
@session
end
private
def broadcast_status(status)
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "starting", status: status })
end
end
end

View File

@@ -0,0 +1,30 @@
module Sessions
class Stop
def initialize(session)
@session = session
end
def call
cancel_timeout_job
@session.end_stream!
Youtube::BroadcastService.new(@session.match.team).complete_broadcast!(@session.youtube_broadcast_id)
Recordings::IndexSession.new(@session).call
Mediamtx::Client.new.delete_path(@session)
log_event("ended")
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "ended" })
@session
end
private
def cancel_timeout_job
return if @session.timeout_job_id.blank?
Sidekiq::ScheduledSet.new.find_job(@session.timeout_job_id)&.delete
end
def log_event(type)
@session.stream_events.create!(event_type: type, occurred_at: Time.current, metadata: {})
end
end
end

View File

@@ -0,0 +1,11 @@
module Teams
class EntitlementError < StandardError
attr_reader :code, :billing_url
def initialize(message, code: "premium_required", billing_url: nil)
super(message)
@code = code
@billing_url = billing_url
end
end
end

View File

@@ -0,0 +1,239 @@
module Teams
class Entitlements
ACTIVE_STREAM_STATUSES = %w[idle connecting live reconnecting paused].freeze
def initialize(team)
@team = team
end
def subscription
@subscription ||= @team.subscription || ensure_free_subscription!
end
def plan
subscription.plan
end
def active?
subscription.active?
end
def premium_active?
subscription.premium?
end
def premium_full?
subscription.premium_full?
end
def youtube_enabled?
active? && plan.youtube_enabled?
end
def phone_download_enabled?
active? && plan.phone_download_enabled?
end
def max_staff_transmission
plan.max_staff_transmission
end
def max_staff_regia
plan.max_staff_regia
end
def max_staff
plan.max_staff
end
def staff_count
staff_count_for("transmission") + staff_count_for("regia")
end
def staff_count_for(kind)
members_count_for(kind) + pending_invitations_count_for(kind)
end
def members_count
@team.user_teams.where(role: "member").count
end
def members_count_for(kind)
@team.user_teams.where(role: "member", staff_kind: kind).count
end
def pending_invitations_count
@team.team_invitations.pending.count
end
def pending_invitations_count_for(kind)
@team.team_invitations.pending.where(staff_kind: kind).count
end
def concurrent_streams_limit
plan.concurrent_streams_limit
end
def concurrent_streams_used
StreamSession.joins(:match)
.where(matches: { team_id: @team.id })
.where(status: ACTIVE_STREAM_STATUSES)
.count
end
def concurrent_streams_limit_reached?
limit = concurrent_streams_limit
return false if limit.nil?
concurrent_streams_used >= limit
end
def can_stream_on?(platform)
return false unless active?
platform = platform.to_s
return false unless plan.allows_platform?(platform)
true
end
def assert_can_stream_on!(platform)
platform = platform.to_s
unless active?
raise EntitlementError.new(
"Abbonamento non attivo per questa squadra",
code: "subscription_inactive",
billing_url: billing_url
)
end
if platform == "youtube" && !youtube_enabled?
raise EntitlementError.new(
"YouTube richiede un piano Premium",
code: "premium_required",
billing_url: billing_url
)
end
if platform == "youtube" && premium_full? == false && plan.youtube_mode != "matchlivetv_light"
raise EntitlementError.new(
"Il canale YouTube della società richiede Premium Full",
code: "premium_full_required",
billing_url: billing_url
)
end
return if plan.allows_platform?(platform)
raise EntitlementError.new("Piattaforma non consentita", code: "platform_denied", billing_url: billing_url)
end
def assert_concurrent_stream!(excluding_session: nil)
return unless active?
used = concurrent_streams_used
used -= 1 if excluding_session && ACTIVE_STREAM_STATUSES.include?(excluding_session.status)
limit = concurrent_streams_limit
return if limit.nil?
return if used < limit
raise EntitlementError.new(
"Limite dirette concorrenti raggiunto (#{limit}). Chiudi una diretta o passa a un piano superiore.",
code: "concurrent_limit_reached",
billing_url: billing_url
)
end
def assert_can_invite!(staff_kind: "transmission")
return unless active?
kind = staff_kind.to_s
unless TeamInvitation::STAFF_KINDS.include?(kind)
raise EntitlementError.new("Ruolo staff non valido", code: "invalid_staff_kind")
end
limit = staff_limit_for(kind)
return if limit.nil?
used = staff_count_for(kind)
return if used < limit
label = kind == "regia" ? "regia" : "trasmissione"
raise EntitlementError.new(
"Limite staff #{label} raggiunto (#{limit} all'anno). Passa a un piano superiore.",
code: "staff_limit_reached",
billing_url: billing_url
)
end
def staff_limit_for(kind)
kind == "regia" ? max_staff_regia : max_staff_transmission
end
def assert_can_connect_youtube!
unless premium_full?
raise EntitlementError.new(
"Collegamento al canale YouTube della società richiede Premium Full",
code: "premium_full_required",
billing_url: billing_url
)
end
assert_can_stream_on!("youtube")
end
def can_access_recordings?
active? && plan.recordings_enabled?
end
def recording_enabled_for_mediamtx?
can_access_recordings?
end
def recording_retention_days
plan.recording_days
end
def billing_url
"#{MatchLiveTv.app_public_url.chomp('/')}/teams/#{@team.id}/billing"
end
def staff_manage_url
"#{MatchLiveTv.app_public_url.chomp('/')}/teams/#{@team.id}/dashboard"
end
def as_json
{
plan_slug: plan.slug,
plan_name: plan.name,
premium_active: premium_active?,
premium_full: premium_full?,
subscription_status: subscription.status,
features: plan.features,
billing_url: billing_url,
staff_manage_url: staff_manage_url,
max_staff: max_staff,
max_staff_transmission: max_staff_transmission,
max_staff_regia: max_staff_regia,
staff_used: staff_count,
staff_transmission_used: staff_count_for("transmission"),
staff_regia_used: staff_count_for("regia"),
concurrent_streams_used: concurrent_streams_used,
concurrent_streams_limit: concurrent_streams_limit,
recordings_enabled: can_access_recordings?,
recording_retention_days: recording_retention_days,
phone_download_enabled: phone_download_enabled?,
youtube_enabled: youtube_enabled?,
youtube_mode: plan.youtube_mode
}
end
private
def ensure_free_subscription!
Billing::AssignPlan.call(team: @team, plan_slug: "free")
@team.reload.subscription
end
end
end

View File

@@ -0,0 +1,94 @@
module Webhooks
class MediamtxHandler
def initialize(event:, session_id:, metadata: {})
@event = event
@session_id = session_id
@metadata = metadata
end
def call
session = StreamSession.find_by(id: @session_id)
return unless session
case @event
when "connect"
handle_connect(session)
when "disconnect"
handle_disconnect(session)
when "ready"
handle_ready(session)
end
end
private
def handle_connect(session)
return if session.live? && session.stream_events.where(event_type: "connected").exists?
if session.reconnecting?
session.reconnect! if session.may_reconnect?
cancel_timeout(session)
log(session, "reconnected")
broadcast(session, "reconnected")
elsif session.connecting? || session.idle?
session.go_live! if session.may_go_live?
log(session, "connected")
broadcast(session, "connected")
end
end
def handle_disconnect(session)
return unless session.live? || session.connecting?
if session.connecting?
session.update!(status: "reconnecting")
elsif session.may_lose_connection?
session.lose_connection!
end
log(session, "disconnected", @metadata)
broadcast(session, "disconnected")
schedule_timeout(session)
end
def handle_ready(session)
log(session, "connected", { ready: true })
end
def schedule_timeout(session)
job = DisconnectionTimeoutJob.perform_in(
MatchLiveTv.reconnect_timeout_seconds.seconds,
session.id
)
session.update!(timeout_job_id: job)
end
def cancel_timeout(session)
return if session.timeout_job_id.blank?
Sidekiq::ScheduledSet.new.find_job(session.timeout_job_id)&.delete
session.update!(timeout_job_id: nil)
end
def log(session, type, meta = {})
return if duplicate_event?(session, type, meta)
session.stream_events.create!(
event_type: type,
metadata: meta.merge(source: "mediamtx"),
occurred_at: Time.current
)
end
def duplicate_event?(session, type, meta)
session.stream_events
.where(event_type: type)
.where("occurred_at > ?", 2.seconds.ago)
.where(metadata: meta.merge(source: "mediamtx"))
.exists?
end
def broadcast(session, event)
SessionChannel.broadcast_message(session, { type: "stream_event", event: event })
end
end
end

View File

@@ -0,0 +1,14 @@
module Webhooks
class Signature
def self.valid?(payload, signature)
return false if signature.blank?
expected = OpenSSL::HMAC.hexdigest(
"SHA256",
MatchLiveTv.mediamtx_webhook_secret,
payload
)
ActiveSupport::SecurityUtils.secure_compare(expected, signature)
end
end
end

View File

@@ -0,0 +1,89 @@
module Youtube
class BroadcastService
class Error < StandardError; end
def initialize(team)
@team = team
@credential = team.youtube_credential
end
def create_broadcast!(title:, privacy_status: "unlisted", scheduled_at: nil)
return mock_broadcast if @credential.blank? || missing_oauth_config?
client = authorized_client
broadcast = Google::Apis::YoutubeV3::LiveBroadcast.new(
snippet: Google::Apis::YoutubeV3::LiveBroadcastSnippet.new(
title: title,
scheduled_start_time: scheduled_at&.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_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, e.message
end
def complete_broadcast!(broadcast_id)
return if @credential.blank? || missing_oauth_config?
client = authorized_client
client.transition_live_broadcast(broadcast_id, "complete")
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
end
end

View File

@@ -0,0 +1,25 @@
module Youtube
class OauthExchange
TOKEN_URL = "https://oauth2.googleapis.com/token".freeze
def self.call(code)
response = Faraday.post(TOKEN_URL, {
code: code,
client_id: ENV["YOUTUBE_CLIENT_ID"],
client_secret: ENV["YOUTUBE_CLIENT_SECRET"],
redirect_uri: ENV["YOUTUBE_REDIRECT_URI"],
grant_type: "authorization_code"
})
data = JSON.parse(response.body)
raise BroadcastService::Error, data["error_description"] if data["error"]
{
access_token: data["access_token"],
refresh_token: data["refresh_token"],
expires_in: data["expires_in"].to_i,
channel_id: nil,
channel_title: nil
}
end
end
end

View File

@@ -0,0 +1,31 @@
module Youtube
class OauthRefresh
TOKEN_URL = "https://oauth2.googleapis.com/token".freeze
def initialize(credential)
@credential = credential
end
def apply!(service)
refresh! if @credential.expired?
service.authorization = @credential.access_token
service
end
def refresh!
response = Faraday.post(TOKEN_URL, {
client_id: ENV["YOUTUBE_CLIENT_ID"],
client_secret: ENV["YOUTUBE_CLIENT_SECRET"],
refresh_token: @credential.refresh_token,
grant_type: "refresh_token"
})
data = JSON.parse(response.body)
raise Youtube::BroadcastService::Error, data["error"] if data["error"]
@credential.update!(
access_token: data["access_token"],
expires_at: Time.current + data["expires_in"].to_i.seconds
)
end
end
end

View File

@@ -0,0 +1,22 @@
module Youtube
class OauthUrl
AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth".freeze
SCOPES = [
"https://www.googleapis.com/auth/youtube",
"https://www.googleapis.com/auth/youtube.force-ssl"
].join(" ").freeze
def self.build(team_id:, redirect_uri:)
params = {
client_id: ENV.fetch("YOUTUBE_CLIENT_ID", "not_configured"),
redirect_uri: redirect_uri,
response_type: "code",
scope: SCOPES,
access_type: "offline",
prompt: "consent",
state: team_id
}
"#{AUTH_URL}?#{URI.encode_www_form(params)}"
end
end
end