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:
12
backend/app/services/billing/assign_plan.rb
Normal file
12
backend/app/services/billing/assign_plan.rb
Normal 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
|
||||
79
backend/app/services/billing/stripe/checkout_session.rb
Normal file
79
backend/app/services/billing/stripe/checkout_session.rb
Normal 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
|
||||
131
backend/app/services/billing/stripe/webhook_handler.rb
Normal file
131
backend/app/services/billing/stripe/webhook_handler.rb
Normal 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
|
||||
101
backend/app/services/mediamtx/client.rb
Normal file
101
backend/app/services/mediamtx/client.rb
Normal 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
|
||||
25
backend/app/services/recordings/index_session.rb
Normal file
25
backend/app/services/recordings/index_session.rb
Normal 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
|
||||
61
backend/app/services/sessions/create.rb
Normal file
61
backend/app/services/sessions/create.rb
Normal 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
|
||||
20
backend/app/services/sessions/pause.rb
Normal file
20
backend/app/services/sessions/pause.rb
Normal 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
|
||||
21
backend/app/services/sessions/start.rb
Normal file
21
backend/app/services/sessions/start.rb
Normal 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
|
||||
30
backend/app/services/sessions/stop.rb
Normal file
30
backend/app/services/sessions/stop.rb
Normal 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
|
||||
11
backend/app/services/teams/entitlement_error.rb
Normal file
11
backend/app/services/teams/entitlement_error.rb
Normal 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
|
||||
239
backend/app/services/teams/entitlements.rb
Normal file
239
backend/app/services/teams/entitlements.rb
Normal 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
|
||||
94
backend/app/services/webhooks/mediamtx_handler.rb
Normal file
94
backend/app/services/webhooks/mediamtx_handler.rb
Normal 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
|
||||
14
backend/app/services/webhooks/signature.rb
Normal file
14
backend/app/services/webhooks/signature.rb
Normal 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
|
||||
89
backend/app/services/youtube/broadcast_service.rb
Normal file
89
backend/app/services/youtube/broadcast_service.rb
Normal 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
|
||||
25
backend/app/services/youtube/oauth_exchange.rb
Normal file
25
backend/app/services/youtube/oauth_exchange.rb
Normal 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
|
||||
31
backend/app/services/youtube/oauth_refresh.rb
Normal file
31
backend/app/services/youtube/oauth_refresh.rb
Normal 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
|
||||
22
backend/app/services/youtube/oauth_url.rb
Normal file
22
backend/app/services/youtube/oauth_url.rb
Normal 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
|
||||
Reference in New Issue
Block a user