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,65 @@
module Api
module V1
class AuthController < ApplicationController
skip_before_action :authenticate_request!, only: %i[login refresh register]
def register
user = User.new(
email: params[:email]&.downcase,
name: params[:name],
password: params[:password],
role: "coach"
)
if user.save
render json: token_response(user), status: :created
else
render json: { error: user.errors.full_messages.join(", ") }, status: :unprocessable_entity
end
end
def login
user = User.find_by(email: params[:email]&.downcase)
if user&.authenticate(params[:password])
render json: token_response(user), status: :ok
else
render json: { error: "Invalid credentials" }, status: :unauthorized
end
end
def logout
render json: { message: "Logged out" }
end
def refresh
payload = JsonWebToken.decode(params[:refresh_token] || bearer_token)
user = User.find_by(id: payload&.dig(:user_id))
return render json: { error: "Invalid token" }, status: :unauthorized unless user
render json: token_response(user)
end
def me
render json: user_json(current_user)
end
private
def token_response(user)
{
user: user_json(user),
access_token: JsonWebToken.encode({ user_id: user.id, type: "access" }),
refresh_token: JsonWebToken.encode({ user_id: user.id, type: "refresh" }, exp: 30.days.from_now)
}
end
def user_json(user)
{
id: user.id,
email: user.email,
name: user.name,
role: user.role
}
end
end
end
end

View File

@@ -0,0 +1,17 @@
module Api
module V1
class BaseController < ApplicationController
rescue_from Teams::EntitlementError, with: :render_entitlement_error
private
def render_entitlement_error(error)
render json: {
error: error.message,
error_code: error.code,
billing_url: error.billing_url
}, status: :forbidden
end
end
end
end

View File

@@ -0,0 +1,85 @@
module Api
module V1
class MatchesController < BaseController
before_action :set_team, only: %i[index create]
before_action :set_match, only: %i[show update destroy]
def index
matches = @team.matches
.includes(:team, :stream_sessions)
.order(scheduled_at: :desc)
render json: matches.map { |m| match_json(m) }
end
def create
match = @team.matches.create!(match_params)
render json: match_json(match), status: :created
end
def show
render json: match_json(@match, detail: true)
end
def update
@match.update!(match_params)
render json: match_json(@match)
end
def destroy
active = active_session_for(@match)
if active&.resumable?
return render json: {
error: "Chiudi la diretta prima di eliminare questa partita"
}, status: :unprocessable_entity
end
@match.destroy!
head :no_content
end
private
def set_team
@team = current_user.teams.find(params[:team_id])
end
def set_match
@match = Match.joins(:team).merge(current_user.teams).find(params[:id])
end
def match_params
params.require(:match).permit(
:opponent_name, :location, :scheduled_at, :sport, :sets_to_win,
:category, :phase, roster_numbers: [],
scoring_rules: %i[points_per_set points_deciding_set min_point_lead]
)
end
def match_json(match, detail: false)
active = active_session_for(match)
{
id: match.id,
team_id: match.team_id,
team_name: match.team.name,
opponent_name: match.opponent_name,
location: match.location,
scheduled_at: match.scheduled_at,
sport: match.sport,
sets_to_win: match.sets_to_win,
scoring_rules: match.scoring_rules.presence,
roster_numbers: match.roster_numbers,
category: match.category,
phase: match.phase,
active_session_id: active&.id,
active_session_status: active&.status
}
end
def active_session_for(match)
match.stream_sessions
.order(created_at: :desc)
.find { |s| !%w[ended error].include?(s.status) }
end
end
end
end

View File

@@ -0,0 +1,146 @@
module Api
module V1
class StreamSessionsController < BaseController
before_action :set_session, except: :create
def create
match = Match.joins(:team).merge(current_user.teams).find(params[:match_id])
session = Sessions::Create.new(user: current_user, match: match, params: session_params).call
render json: session_json(session), status: :created
end
def show
render json: session_json(@session, detail: true)
end
def start
Sessions::Start.new(@session).call
render json: session_json(@session)
end
def stop
Sessions::Stop.new(@session).call
render json: session_json(@session)
end
def pause
Sessions::Pause.new(@session).call
render json: session_json(@session)
end
def events
events = @session.stream_events.recent.limit(100)
render json: events.map { |e| event_json(e) }
end
def telemetry
role = params.require(:device_role)
state = @session.device_states.find_or_initialize_by(device_role: role)
state.update!(
battery_level: params[:battery_level],
network_type: params[:network_type],
signal_strength: params[:signal_strength],
current_bitrate: params[:current_bitrate],
target_bitrate: params[:target_bitrate],
fps: params[:fps],
last_seen_at: Time.current
)
SessionChannel.broadcast_message(@session, state.as_cable_payload)
head :no_content
end
def pairing_token
token = SecureRandom.urlsafe_base64(24)
@session.update!(
pairing_token_digest: Digest::SHA256.hexdigest(token),
pairing_token_expires_at: 15.minutes.from_now
)
render json: {
pairing_token: token,
expires_at: @session.pairing_token_expires_at,
qr_payload: {
session_id: @session.id,
pairing_token: token,
api_url: request.base_url
}
}
end
def claim_pairing
token = params.require(:pairing_token)
digest = Digest::SHA256.hexdigest(token)
unless @session.pairing_token_digest == digest &&
@session.pairing_token_expires_at&.future?
return render json: { error: "Invalid or expired token" }, status: :unauthorized
end
@session.device_states.find_or_create_by!(device_role: params[:device_role] || "controller")
render json: session_json(@session)
end
def network_test
@session.stream_events.create!(
event_type: "network_test",
metadata: params.permit(:download_mbps, :upload_mbps, :latency_ms, :network_type).to_h,
occurred_at: Time.current
)
ready = params[:upload_mbps].to_f >= (@session.target_bitrate / 1_000_000.0 * 0.8)
render json: { ready: ready, target_upload_mbps: @session.target_bitrate / 1_000_000.0 }
end
def youtube_stats
count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present?
Youtube::BroadcastService.new(@session.match.team)
.viewer_count(@session.youtube_broadcast_id)
else
0
end
render json: { concurrent_viewers: count, live: @session.live? }
end
private
def set_session
@session = StreamSession.joins(match: :team)
.merge(current_user.teams)
.find(params[:id])
end
def session_params
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps)
end
def session_json(session, detail: false)
data = {
id: session.id,
match_id: session.match_id,
status: session.status,
platform: session.platform,
rtmp_ingest_url: session.rtmp_ingest_url,
hls_playback_url: session.hls_playback_url,
watch_page_url: session.watch_page_url,
youtube_broadcast_id: session.youtube_broadcast_id,
privacy_status: session.privacy_status,
quality_preset: session.quality_preset,
target_bitrate: session.target_bitrate,
started_at: session.started_at,
disconnection_count: session.disconnection_count
}
if detail
data[:score] = session.score_state&.as_cable_payload
data[:devices] = session.device_states.map(&:as_cable_payload)
end
data
end
def event_json(event)
{
id: event.id,
event_type: event.event_type,
metadata: event.metadata,
occurred_at: event.occurred_at
}
end
end
end
end

View File

@@ -0,0 +1,122 @@
module Api
module V1
class TeamsController < BaseController
def index
teams = current_user.teams.includes(:matches)
render json: teams.map { |t| team_json(t) }
end
def show
team = current_user.teams.find(params[:id])
render json: team_json(team, detail: true)
end
def create
if current_user.user_teams.where(role: "owner").count >= 1
return render json: {
error: "Puoi gestire una sola squadra. Usa il sito per inviti o contattaci."
}, status: :unprocessable_entity
end
team = Team.create!(team_params)
UserTeam.create!(user: current_user, team: team, role: "owner")
Billing::AssignPlan.call(team: team, plan_slug: "free")
render json: team_json(team), status: :created
end
def recordings
team = current_user.teams.find(params[:id])
unless team.entitlements.can_access_recordings?
return render json: {
error: "Archivio gare disponibile con Premium Light o Full",
error_code: "premium_required",
billing_url: team.entitlements.billing_url
}, status: :forbidden
end
recs = team.recordings.ready.includes(stream_session: :match).order(created_at: :desc).limit(50)
render json: recs.map { |r| recording_json(r) }
end
def update
team = current_user.teams.find(params[:id])
team.update!(team_params)
render json: team_json(team)
end
def add_member
team = current_user.teams.find(params[:id])
team.entitlements.assert_can_invite!
member = User.find(params[:user_id])
UserTeam.find_or_create_by!(user: member, team: team) { |ut| ut.role = "member" }
head :no_content
end
def remove_member
team = current_user.teams.find(params[:id])
ut = team.user_teams.find_by!(user_id: params[:user_id], role: "member")
ut.destroy!
head :no_content
end
private
def team_params
params.require(:team).permit(:name, :sport, :logo_url)
end
def team_json(team, detail: false)
ent = team.entitlements
data = {
id: team.id,
name: team.name,
sport: team.sport,
logo_url: team.logo_url,
youtube_connected: team.youtube_credential.present?,
plan_slug: ent.plan.slug,
plan_name: ent.plan.name,
premium_active: ent.premium_active?,
premium_full: ent.premium_full?,
subscription_status: ent.subscription.status,
features: ent.plan.features,
billing_url: ent.billing_url,
staff_manage_url: ent.staff_manage_url,
max_staff: ent.max_staff,
max_staff_transmission: ent.max_staff_transmission,
max_staff_regia: ent.max_staff_regia,
staff_used: ent.staff_count,
staff_transmission_used: ent.staff_count_for("transmission"),
staff_regia_used: ent.staff_count_for("regia"),
concurrent_streams_used: ent.concurrent_streams_used,
concurrent_streams_limit: ent.concurrent_streams_limit,
recordings_enabled: ent.can_access_recordings?,
phone_download_enabled: ent.phone_download_enabled?,
youtube_enabled: ent.youtube_enabled?,
youtube_mode: ent.plan.youtube_mode
}
if detail
data[:members] = team.user_teams.includes(:user).where.not(role: "owner").map do |ut|
{ id: ut.user.id, name: ut.user.name, email: ut.user.email, role: ut.role }
end
end
data
end
def recording_json(recording)
session = recording.stream_session
match = session.match
{
id: recording.id,
session_id: session.id,
match_id: match.id,
opponent_name: match.opponent_name,
team_name: match.team.name,
ended_at: session.ended_at,
replay_url: recording.replay_url,
download_url: recording.replay_url,
expires_at: recording.expires_at
}
end
end
end
end

View File

@@ -0,0 +1,26 @@
module Api
module V1
class YoutubeController < BaseController
def authorize
team = current_user.teams.find(params[:team_id])
team.entitlements.assert_can_connect_youtube!
url = Youtube::OauthUrl.build(team_id: team.id, redirect_uri: ENV.fetch("YOUTUBE_REDIRECT_URI"))
render json: { authorization_url: url }
end
def callback
team = current_user.teams.find(params[:state])
tokens = Youtube::OauthExchange.call(params[:code])
cred = team.youtube_credential || team.build_youtube_credential
cred.update!(
access_token: tokens[:access_token],
refresh_token: tokens[:refresh_token],
expires_at: Time.current + tokens[:expires_in].seconds,
channel_id: tokens[:channel_id],
channel_title: tokens[:channel_title]
)
redirect_to "/admin/teams/#{team.id}?youtube=connected"
end
end
end
end