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:
3
backend/app/models/application_record.rb
Normal file
3
backend/app/models/application_record.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
primary_abstract_class
|
||||
end
|
||||
0
backend/app/models/concerns/.keep
Normal file
0
backend/app/models/concerns/.keep
Normal file
19
backend/app/models/device_state.rb
Normal file
19
backend/app/models/device_state.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
class DeviceState < ApplicationRecord
|
||||
ROLES = %w[camera controller].freeze
|
||||
|
||||
belongs_to :stream_session
|
||||
|
||||
validates :device_role, inclusion: { in: ROLES }
|
||||
validates :device_role, uniqueness: { scope: :stream_session_id }
|
||||
|
||||
def as_cable_payload
|
||||
{
|
||||
type: "device_state",
|
||||
battery: battery_level,
|
||||
network: network_type,
|
||||
bitrate: current_bitrate,
|
||||
fps: fps,
|
||||
status: stream_session.status
|
||||
}
|
||||
end
|
||||
end
|
||||
7
backend/app/models/match.rb
Normal file
7
backend/app/models/match.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class Match < ApplicationRecord
|
||||
belongs_to :team
|
||||
has_many :stream_sessions, dependent: :destroy
|
||||
|
||||
validates :opponent_name, presence: true
|
||||
validates :sets_to_win, numericality: { greater_than: 0, less_than: 6 }
|
||||
end
|
||||
86
backend/app/models/plan.rb
Normal file
86
backend/app/models/plan.rb
Normal file
@@ -0,0 +1,86 @@
|
||||
class Plan < ApplicationRecord
|
||||
SLUGS = %w[free premium_light premium_full].freeze
|
||||
EXTERNAL_PLATFORMS = %w[youtube facebook twitch].freeze
|
||||
PREMIUM_SLUGS = %w[premium_light premium_full].freeze
|
||||
|
||||
has_many :subscriptions, dependent: :restrict_with_error
|
||||
|
||||
validates :slug, presence: true, uniqueness: true, inclusion: { in: SLUGS }
|
||||
validates :name, presence: true
|
||||
|
||||
scope :ordered, -> {
|
||||
order(Arel.sql("CASE slug WHEN 'free' THEN 0 WHEN 'premium_light' THEN 1 ELSE 2 END"))
|
||||
}
|
||||
|
||||
def self.[](slug)
|
||||
find_by!(slug: slug.to_s)
|
||||
end
|
||||
|
||||
def allows_platform?(platform)
|
||||
return false if platform.to_s == "youtube" && !youtube_enabled?
|
||||
|
||||
Array(features["platforms"]).map(&:to_s).include?(platform.to_s)
|
||||
end
|
||||
|
||||
def feature(key)
|
||||
features[key.to_s]
|
||||
end
|
||||
|
||||
def recordings_enabled?
|
||||
feature("recordings_enabled") == true
|
||||
end
|
||||
|
||||
def recording_days
|
||||
feature("recording_days").to_i
|
||||
end
|
||||
|
||||
def max_staff_transmission
|
||||
val = feature("max_staff_transmission")
|
||||
return legacy_max_staff if val.nil? && feature("max_staff").present?
|
||||
|
||||
val.nil? ? nil : val.to_i
|
||||
end
|
||||
|
||||
def max_staff_regia
|
||||
val = feature("max_staff_regia")
|
||||
return legacy_max_staff if val.nil? && feature("max_staff").present?
|
||||
|
||||
val.nil? ? nil : val.to_i
|
||||
end
|
||||
|
||||
def max_staff
|
||||
t = max_staff_transmission
|
||||
r = max_staff_regia
|
||||
return legacy_max_staff if legacy_max_staff.positive? && t.nil? && r.nil? && feature("max_staff").present?
|
||||
return nil if t.nil? && r.nil?
|
||||
|
||||
(t || 0) + (r || 0)
|
||||
end
|
||||
|
||||
def youtube_mode
|
||||
feature("youtube_mode")&.to_s
|
||||
end
|
||||
|
||||
def concurrent_streams_limit
|
||||
val = feature("concurrent_streams_limit")
|
||||
val.nil? ? nil : val.to_i
|
||||
end
|
||||
|
||||
def phone_download_enabled?
|
||||
feature("phone_download_enabled") == true
|
||||
end
|
||||
|
||||
def youtube_enabled?
|
||||
feature("youtube_enabled") == true
|
||||
end
|
||||
|
||||
def premium?
|
||||
slug.in?(PREMIUM_SLUGS)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def legacy_max_staff
|
||||
feature("max_staff").to_i
|
||||
end
|
||||
end
|
||||
21
backend/app/models/recording.rb
Normal file
21
backend/app/models/recording.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
class Recording < ApplicationRecord
|
||||
STATUSES = %w[processing ready expired].freeze
|
||||
|
||||
belongs_to :stream_session
|
||||
belongs_to :team
|
||||
|
||||
validates :status, inclusion: { in: STATUSES }
|
||||
|
||||
scope :ready, -> { where(status: "ready").where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
||||
scope :for_team, ->(team) { where(team: team) }
|
||||
|
||||
def replay_url
|
||||
return nil unless ready? && stream_session_id.present?
|
||||
|
||||
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}"
|
||||
end
|
||||
|
||||
def ready?
|
||||
status == "ready" && (expires_at.nil? || expires_at.future?)
|
||||
end
|
||||
end
|
||||
17
backend/app/models/score_state.rb
Normal file
17
backend/app/models/score_state.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
class ScoreState < ApplicationRecord
|
||||
belongs_to :stream_session
|
||||
|
||||
def as_cable_payload
|
||||
{
|
||||
type: "score_update",
|
||||
home_sets: home_sets,
|
||||
away_sets: away_sets,
|
||||
home_points: home_points,
|
||||
away_points: away_points,
|
||||
current_set: current_set,
|
||||
set_partials: set_partials || [],
|
||||
timeout_home: timeout_home,
|
||||
timeout_away: timeout_away
|
||||
}
|
||||
end
|
||||
end
|
||||
13
backend/app/models/stream_event.rb
Normal file
13
backend/app/models/stream_event.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
class StreamEvent < ApplicationRecord
|
||||
EVENT_TYPES = %w[
|
||||
connected disconnected reconnected quality_changed error paused resumed
|
||||
started ended network_test pairing
|
||||
].freeze
|
||||
|
||||
belongs_to :stream_session
|
||||
|
||||
validates :event_type, inclusion: { in: EVENT_TYPES }
|
||||
validates :occurred_at, presence: true
|
||||
|
||||
scope :recent, -> { order(occurred_at: :desc) }
|
||||
end
|
||||
150
backend/app/models/stream_session.rb
Normal file
150
backend/app/models/stream_session.rb
Normal file
@@ -0,0 +1,150 @@
|
||||
class StreamSession < ApplicationRecord
|
||||
include AASM
|
||||
|
||||
PLATFORMS = %w[matchlivetv youtube facebook twitch].freeze
|
||||
STATUSES = %w[idle connecting live reconnecting paused ended error].freeze
|
||||
|
||||
belongs_to :match
|
||||
belongs_to :user
|
||||
has_many :stream_events, dependent: :destroy
|
||||
has_one :score_state, dependent: :destroy
|
||||
has_many :device_states, dependent: :destroy
|
||||
|
||||
validates :platform, inclusion: { in: PLATFORMS }
|
||||
validates :status, inclusion: { in: STATUSES }
|
||||
|
||||
before_validation :ensure_publish_token, on: :create
|
||||
|
||||
scope :broadcasting, -> { where(status: %w[live connecting reconnecting]) }
|
||||
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).where(
|
||||
"teams.name ILIKE :term OR matches.opponent_name 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: :live, to: :paused
|
||||
end
|
||||
|
||||
event :resume do
|
||||
transitions from: :paused, to: :live
|
||||
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 rtmp_ingest_url
|
||||
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
|
||||
# MediaMTX path = live/match_{uuid} (no ?token= nel path).
|
||||
"#{MatchLiveTv.mediamtx_rtmp_url}/#{mediamtx_path_name}"
|
||||
end
|
||||
|
||||
def mediamtx_path_name
|
||||
"live/match_#{id}"
|
||||
end
|
||||
|
||||
def matchlivetv_platform?
|
||||
platform == "matchlivetv"
|
||||
end
|
||||
|
||||
def hls_playback_url
|
||||
base = MatchLiveTv.hls_public_url.chomp("/")
|
||||
"#{base}/#{mediamtx_path_name}/index.m3u8"
|
||||
end
|
||||
|
||||
def watch_page_url
|
||||
"#{MatchLiveTv.app_public_url.chomp('/')}/live/#{id}"
|
||||
end
|
||||
|
||||
def terminal?
|
||||
status.in?(%w[ended error])
|
||||
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 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
|
||||
28
backend/app/models/subscription.rb
Normal file
28
backend/app/models/subscription.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
class Subscription < ApplicationRecord
|
||||
STATUSES = %w[active trialing past_due canceled incomplete].freeze
|
||||
ACTIVE_STATUSES = %w[active trialing].freeze
|
||||
|
||||
belongs_to :team
|
||||
belongs_to :plan
|
||||
|
||||
validates :status, inclusion: { in: STATUSES }
|
||||
validates :team_id, uniqueness: true
|
||||
|
||||
scope :active, -> { where(status: ACTIVE_STATUSES) }
|
||||
|
||||
def active?
|
||||
status.in?(ACTIVE_STATUSES)
|
||||
end
|
||||
|
||||
def premium?
|
||||
active? && plan.premium?
|
||||
end
|
||||
|
||||
def premium_full?
|
||||
active? && plan.slug == "premium_full"
|
||||
end
|
||||
|
||||
def free?
|
||||
plan.slug == "free"
|
||||
end
|
||||
end
|
||||
19
backend/app/models/team.rb
Normal file
19
backend/app/models/team.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
class Team < ApplicationRecord
|
||||
has_many :user_teams, dependent: :destroy
|
||||
has_many :users, through: :user_teams
|
||||
has_many :matches, dependent: :destroy
|
||||
has_one :youtube_credential, dependent: :destroy
|
||||
has_one :subscription, dependent: :destroy
|
||||
has_many :recordings, dependent: :destroy
|
||||
has_many :team_invitations, dependent: :destroy
|
||||
|
||||
validates :name, presence: true
|
||||
|
||||
def entitlements
|
||||
@entitlements ||= Teams::Entitlements.new(self)
|
||||
end
|
||||
|
||||
def owner
|
||||
user_teams.find_by(role: "owner")&.user
|
||||
end
|
||||
end
|
||||
28
backend/app/models/team_invitation.rb
Normal file
28
backend/app/models/team_invitation.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
class TeamInvitation < ApplicationRecord
|
||||
STAFF_KINDS = %w[transmission regia].freeze
|
||||
|
||||
belongs_to :team
|
||||
|
||||
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
validates :token_digest, presence: true, uniqueness: true
|
||||
validates :role, inclusion: { in: UserTeam::ROLES }
|
||||
validates :staff_kind, inclusion: { in: STAFF_KINDS }
|
||||
|
||||
scope :pending, -> { where(accepted_at: nil).where("expires_at > ?", Time.current) }
|
||||
|
||||
def self.generate_token
|
||||
SecureRandom.urlsafe_base64(32)
|
||||
end
|
||||
|
||||
def accept!(user)
|
||||
ut = UserTeam.find_or_initialize_by(user: user, team: team)
|
||||
ut.role = role
|
||||
ut.staff_kind = staff_kind
|
||||
ut.save!
|
||||
update!(accepted_at: Time.current)
|
||||
end
|
||||
|
||||
def expired?
|
||||
expires_at.past?
|
||||
end
|
||||
end
|
||||
17
backend/app/models/user.rb
Normal file
17
backend/app/models/user.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
class User < ApplicationRecord
|
||||
ROLES = %w[admin coach parent volunteer].freeze
|
||||
|
||||
has_secure_password
|
||||
|
||||
has_many :user_teams, dependent: :destroy
|
||||
has_many :teams, through: :user_teams
|
||||
has_many :stream_sessions, dependent: :nullify
|
||||
|
||||
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
validates :name, presence: true
|
||||
validates :role, inclusion: { in: ROLES }
|
||||
|
||||
def admin?
|
||||
role == "admin"
|
||||
end
|
||||
end
|
||||
10
backend/app/models/user_team.rb
Normal file
10
backend/app/models/user_team.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
class UserTeam < ApplicationRecord
|
||||
ROLES = %w[owner member].freeze
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :team
|
||||
|
||||
validates :role, inclusion: { in: ROLES }
|
||||
validates :staff_kind, inclusion: { in: TeamInvitation::STAFF_KINDS }, allow_nil: true
|
||||
validates :user_id, uniqueness: { scope: :team_id }
|
||||
end
|
||||
22
backend/app/models/youtube_credential.rb
Normal file
22
backend/app/models/youtube_credential.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class YoutubeCredential < ApplicationRecord
|
||||
belongs_to :team
|
||||
|
||||
attr_encrypted :access_token,
|
||||
key: :encryption_key,
|
||||
attribute: "access_token_encrypted",
|
||||
mode: :single_iv_salt
|
||||
attr_encrypted :refresh_token,
|
||||
key: :encryption_key,
|
||||
attribute: "refresh_token_encrypted",
|
||||
mode: :single_iv_salt
|
||||
|
||||
def expired?
|
||||
expires_at.present? && expires_at < Time.current
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def encryption_key
|
||||
Rails.application.secret_key_base[0, 32]
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user