Blocca la seconda diretta sullo stesso utente, registra gli abusi in admin e presenta la copertina come grafica caricata dalla società, con demo Team MLTV. Co-authored-by: Cursor <cursoragent@cursor.com>
271 lines
7.2 KiB
Ruby
271 lines
7.2 KiB
Ruby
class StreamSession < ApplicationRecord
|
|
include AASM
|
|
|
|
LIVE_PLATFORMS = %w[matchlivetv youtube].freeze
|
|
PLATFORMS = (LIVE_PLATFORMS + %w[facebook twitch]).freeze
|
|
STATUSES = %w[idle connecting live reconnecting paused ended error].freeze
|
|
PRIVACY_STATUSES = %w[public unlisted private].freeze
|
|
|
|
belongs_to :match
|
|
belongs_to :user
|
|
belongs_to :stream_node, optional: true
|
|
has_many :stream_events, dependent: :destroy
|
|
has_many :occupying_concurrency_violations, class_name: "StreamConcurrencyViolation",
|
|
foreign_key: :occupying_session_id, dependent: :nullify, inverse_of: :occupying_session
|
|
has_many :attempted_concurrency_violations, class_name: "StreamConcurrencyViolation",
|
|
foreign_key: :attempted_session_id, dependent: :nullify, inverse_of: :attempted_session
|
|
has_one :score_state, dependent: :destroy
|
|
has_one :recording
|
|
has_many :device_states, dependent: :destroy
|
|
|
|
validates :platform, inclusion: { in: PLATFORMS }
|
|
validates :status, inclusion: { in: STATUSES }
|
|
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
|
|
validates :min_quality_preset, inclusion: { in: Sessions::SelectQuality::MIN_QUALITY_IDS }
|
|
|
|
before_validation :normalize_privacy_status
|
|
before_validation :ensure_publish_token, on: :create
|
|
before_validation :snapshot_ingest_from_node, if: -> { stream_node.present? }
|
|
|
|
scope :broadcasting, -> { where(status: %w[live connecting reconnecting paused]) }
|
|
scope :publicly_listed, -> { where(privacy_status: "public") }
|
|
scope :search_by_team_or_opponent, lambda { |query|
|
|
q = query.to_s.strip
|
|
return all if q.blank?
|
|
|
|
term = "%#{sanitize_sql_like(q)}%"
|
|
joins(match: { team: :club }).where(
|
|
"teams.name ILIKE :term OR matches.opponent_name ILIKE :term OR clubs.name ILIKE :term OR matches.location ILIKE :term",
|
|
term: term
|
|
)
|
|
}
|
|
|
|
aasm column: :status do
|
|
state :idle, initial: true
|
|
state :connecting, :live, :reconnecting, :paused, :ended, :error
|
|
|
|
event :begin_connect do
|
|
transitions from: %i[idle paused], to: :connecting
|
|
end
|
|
|
|
event :go_live do
|
|
transitions from: %i[connecting reconnecting], to: :live
|
|
after { update!(started_at: Time.current) if started_at.nil? }
|
|
end
|
|
|
|
event :lose_connection do
|
|
transitions from: :live, to: :reconnecting
|
|
after { increment!(:disconnection_count) }
|
|
end
|
|
|
|
event :reconnect do
|
|
transitions from: :reconnecting, to: :live
|
|
end
|
|
|
|
event :pause do
|
|
transitions from: %i[live reconnecting connecting], to: :paused
|
|
end
|
|
|
|
event :resume do
|
|
transitions from: :paused, to: :connecting
|
|
end
|
|
|
|
event :finish do
|
|
transitions from: %i[live reconnecting paused connecting], to: :ended
|
|
after { close_session! }
|
|
end
|
|
|
|
event :fail do
|
|
transitions from: %i[connecting reconnecting idle], to: :error
|
|
end
|
|
end
|
|
|
|
def ingest_slug_display
|
|
stream_node&.slug.presence || ingest_slug
|
|
end
|
|
|
|
def ingest_role_display
|
|
stream_node&.role.presence || ingest_role
|
|
end
|
|
|
|
def match_label
|
|
team_name = match&.team&.name
|
|
opponent = match&.opponent_name
|
|
return id.to_s if team_name.blank?
|
|
|
|
opponent.present? ? "#{team_name} vs #{opponent}" : team_name
|
|
end
|
|
|
|
def client_device_label
|
|
parts = []
|
|
parts << client_os if client_os.present?
|
|
device = [device_manufacturer, device_model].compact_blank.join(" ")
|
|
parts << device if device.present?
|
|
parts << "OS #{os_version}" if os_version.present?
|
|
parts.join(" · ").presence
|
|
end
|
|
|
|
def client_device_key
|
|
[client_os, device_manufacturer, device_model].map { |v| v.to_s.strip.downcase }.join("|")
|
|
end
|
|
|
|
def self.devices_differ?(left, right)
|
|
ka = left.client_device_key
|
|
kb = right.client_device_key
|
|
return false if ka.delete("|").blank? || kb.delete("|").blank?
|
|
|
|
ka != kb
|
|
end
|
|
|
|
def rtmp_ingest_url
|
|
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
|
|
# MediaMTX path = live/match_{uuid} (no ?token= nel path).
|
|
"#{rtmp_base_url.chomp('/')}/#{mediamtx_path_name}"
|
|
end
|
|
|
|
def mediamtx_path_name
|
|
"live/match_#{id}"
|
|
end
|
|
|
|
def mediamtx_overlay_path_name
|
|
"#{mediamtx_path_name}_air"
|
|
end
|
|
|
|
def matchlivetv_platform?
|
|
platform == "matchlivetv"
|
|
end
|
|
|
|
def hls_playback_url
|
|
"#{hls_base_url.chomp('/')}/#{effective_hls_path_name}/index.m3u8"
|
|
end
|
|
|
|
def rtmp_base_url
|
|
stream_node&.rtmp_base_url.presence || MatchLiveTv.mediamtx_rtmp_url
|
|
end
|
|
|
|
def hls_base_url
|
|
stream_node&.hls_base_url.presence || MatchLiveTv.hls_public_url
|
|
end
|
|
|
|
def mediamtx_api_base_url
|
|
stream_node&.api_base_url.presence || MatchLiveTv.mediamtx_api_url
|
|
end
|
|
|
|
def mediamtx_internal_rtmp_url
|
|
stream_node&.internal_rtmp_url.presence ||
|
|
ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935")
|
|
end
|
|
|
|
def mediamtx_internal_hls_url
|
|
stream_node&.internal_hls_url.presence ||
|
|
ENV.fetch("MEDIAMTX_HLS_URL", "http://mediamtx:8888")
|
|
end
|
|
|
|
def effective_hls_path_name
|
|
mediamtx_path_name
|
|
end
|
|
|
|
def watch_page_url
|
|
"#{MatchLiveTv.app_public_url.chomp('/')}/live/#{id}"
|
|
end
|
|
|
|
def youtube_watch_url
|
|
return nil if youtube_broadcast_id.blank?
|
|
return nil if youtube_broadcast_id.to_s.start_with?("mock_")
|
|
|
|
"https://www.youtube.com/watch?v=#{youtube_broadcast_id}"
|
|
end
|
|
|
|
def share_url
|
|
return youtube_watch_url if platform == "youtube"
|
|
|
|
watch_page_url
|
|
end
|
|
|
|
def youtube_ready?
|
|
platform == "youtube" && youtube_watch_url.present?
|
|
end
|
|
|
|
def link_only?
|
|
privacy_status.in?(%w[unlisted private])
|
|
end
|
|
|
|
def publicly_listed?
|
|
privacy_status == "public"
|
|
end
|
|
|
|
def terminal?
|
|
status.in?(%w[ended error])
|
|
end
|
|
|
|
def regia_token_active?
|
|
regia_token_digest.present? &&
|
|
regia_token_expires_at&.future? &&
|
|
!terminal?
|
|
end
|
|
|
|
def resumable?
|
|
status.in?(%w[connecting live reconnecting paused])
|
|
end
|
|
|
|
def end_stream!
|
|
return if terminal?
|
|
|
|
if may_finish?
|
|
finish!
|
|
else
|
|
update!(status: "ended")
|
|
record_ended_timestamps!
|
|
end
|
|
end
|
|
|
|
def stream_key
|
|
return @stream_key if instance_variable_defined?(:@stream_key)
|
|
return nil if stream_key_encrypted.blank?
|
|
|
|
stream_key_encryptor.decrypt_and_verify(stream_key_encrypted)
|
|
rescue ActiveSupport::MessageEncryptor::InvalidMessage
|
|
nil
|
|
end
|
|
|
|
def stream_key=(value)
|
|
@stream_key = value
|
|
self.stream_key_encrypted =
|
|
value.present? ? stream_key_encryptor.encrypt_and_sign(value) : nil
|
|
end
|
|
|
|
private
|
|
|
|
def stream_key_encryptor
|
|
key = ActiveSupport::KeyGenerator.new(Rails.application.secret_key_base)
|
|
.generate_key("stream_session_stream_key", 32)
|
|
ActiveSupport::MessageEncryptor.new(key)
|
|
end
|
|
|
|
def ensure_publish_token
|
|
self.publish_token ||= SecureRandom.urlsafe_base64(32)
|
|
end
|
|
|
|
def normalize_privacy_status
|
|
self.privacy_status = "public" if privacy_status.blank?
|
|
self.privacy_status = "unlisted" if privacy_status == "private"
|
|
end
|
|
|
|
def snapshot_ingest_from_node
|
|
self.ingest_slug = stream_node.slug
|
|
self.ingest_role = stream_node.role
|
|
end
|
|
|
|
def record_ended_timestamps!
|
|
now = Time.current
|
|
update!(ended_at: now) if ended_at.nil?
|
|
if started_at && total_duration_secs.to_i.zero?
|
|
update!(total_duration_secs: (now - started_at).to_i)
|
|
end
|
|
end
|
|
|
|
def close_session!
|
|
record_ended_timestamps!
|
|
end
|
|
end
|