Compare commits
25
Commits
db8b14ba4e
...
dd94bf7e66
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd94bf7e66 | ||
|
|
baa6283a15 | ||
|
|
bfb3115a64 | ||
|
|
46244fe066 | ||
|
|
b05b1acd7a | ||
|
|
c777853c97 | ||
|
|
9225318e1a | ||
|
|
061b7fad8b | ||
|
|
be7957c078 | ||
|
|
91667e1bfd | ||
|
|
f92ab3a813 | ||
|
|
1fecccaafd | ||
|
|
d100c8030f | ||
|
|
f1906517a9 | ||
|
|
00fde25c50 | ||
|
|
4c5af99efa | ||
|
|
8bf12e7721 | ||
|
|
5a0ca8ef1c | ||
|
|
714602f3da | ||
|
|
baac3a512b | ||
|
|
559284f0b2 | ||
|
|
e5fc925bae | ||
|
|
318a319608 | ||
|
|
0b55d5a8fa | ||
|
|
c3878cdc6d |
@@ -55,6 +55,12 @@ class SessionChannel < ApplicationCable::Channel
|
|||||||
Sessions::Pause.new(session).call
|
Sessions::Pause.new(session).call
|
||||||
when "resume_stream"
|
when "resume_stream"
|
||||||
Sessions::Resume.new(session).call
|
Sessions::Resume.new(session).call
|
||||||
|
when "mute_audio"
|
||||||
|
Sessions::SetAudioMute.new(session, muted: true).call
|
||||||
|
when "unmute_audio"
|
||||||
|
Sessions::SetAudioMute.new(session, muted: false).call
|
||||||
|
when "set_min_quality"
|
||||||
|
Sessions::SetMinQuality.new(session, preset: data["min_quality_preset"]).call
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Admin
|
||||||
|
class StreamNodesController < Admin::BaseController
|
||||||
|
def index
|
||||||
|
Streams::NodeRegistry.ensure_home_from_env!
|
||||||
|
@nodes = StreamNode.order(:role, :slug)
|
||||||
|
@dns_provider = ENV.fetch("STREAM_DNS_PROVIDER", "lab")
|
||||||
|
@cloud_provider = ENV.fetch("STREAM_CLOUD_PROVIDER", "local_lab")
|
||||||
|
@lab_hosts = lab_dns_snippet
|
||||||
|
@hetzner_configured = ENV["HCLOUD_TOKEN"].present?
|
||||||
|
@autoscale_metrics = Streams::Autoscaler.metrics
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
kind = params[:kind].to_s
|
||||||
|
node =
|
||||||
|
if kind == "cloud"
|
||||||
|
Streams::NodeProvisioner.new.provision_cloud!
|
||||||
|
else
|
||||||
|
Streams::NodeProvisioner.new.provision_lab!
|
||||||
|
end
|
||||||
|
redirect_to admin_stream_nodes_path,
|
||||||
|
notice: t("admin.flash.stream_node_created", slug: node.slug)
|
||||||
|
rescue Streams::NodeProvisioner::Error, Streams::CloudProviders::Error, Streams::DnsProviders::Error,
|
||||||
|
KeyError => e
|
||||||
|
redirect_to admin_stream_nodes_path, alert: e.message
|
||||||
|
end
|
||||||
|
|
||||||
|
def drain
|
||||||
|
node = StreamNode.find(params[:id])
|
||||||
|
Streams::NodeProvisioner.new.drain!(node)
|
||||||
|
redirect_to admin_stream_nodes_path, notice: t("admin.flash.stream_node_draining", slug: node.slug)
|
||||||
|
rescue Streams::NodeProvisioner::Error => e
|
||||||
|
redirect_to admin_stream_nodes_path, alert: e.message
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
node = StreamNode.find(params[:id])
|
||||||
|
Streams::NodeProvisioner.new.decommission!(node)
|
||||||
|
redirect_to admin_stream_nodes_path, notice: t("admin.flash.stream_node_destroyed", slug: node.slug)
|
||||||
|
rescue Streams::NodeProvisioner::BusyError, Streams::NodeProvisioner::Error,
|
||||||
|
Streams::CloudProviders::Error, Streams::DnsProviders::Error => e
|
||||||
|
redirect_to admin_stream_nodes_path, alert: e.message
|
||||||
|
end
|
||||||
|
|
||||||
|
def kill_switch
|
||||||
|
Streams::Autoscaler.engage_kill_switch!
|
||||||
|
redirect_to admin_stream_nodes_path, notice: t("admin.flash.autoscale_kill_on")
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_kill_switch
|
||||||
|
Streams::Autoscaler.clear_kill_switch!
|
||||||
|
redirect_to admin_stream_nodes_path, notice: t("admin.flash.autoscale_kill_off")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def lab_dns_snippet
|
||||||
|
return "" unless @dns_provider == "lab"
|
||||||
|
|
||||||
|
Streams::DnsProviders::Lab.new.hosts_file_snippet
|
||||||
|
rescue Redis::BaseError
|
||||||
|
""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -34,6 +34,12 @@ module Api
|
|||||||
render json: session_json(@session)
|
render json: session_json(@session)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def audio_mute
|
||||||
|
muted = params.key?(:muted) ? params[:muted] : true
|
||||||
|
Sessions::SetAudioMute.new(@session, muted: muted).call
|
||||||
|
render json: session_json(@session)
|
||||||
|
end
|
||||||
|
|
||||||
def score
|
def score
|
||||||
Scoring::SyncState.new(session: @session, payload: score_sync_params).call
|
Scoring::SyncState.new(session: @session, payload: score_sync_params).call
|
||||||
render json: session_json(@session, detail: true)
|
render json: session_json(@session, detail: true)
|
||||||
@@ -68,6 +74,7 @@ module Api
|
|||||||
current_bitrate: params[:current_bitrate],
|
current_bitrate: params[:current_bitrate],
|
||||||
target_bitrate: params[:target_bitrate],
|
target_bitrate: params[:target_bitrate],
|
||||||
fps: params[:fps],
|
fps: params[:fps],
|
||||||
|
thermal_state: sanitized_thermal_state,
|
||||||
last_seen_at: Time.current
|
last_seen_at: Time.current
|
||||||
)
|
)
|
||||||
sync_publisher_when_streaming!(params[:fps].to_f)
|
sync_publisher_when_streaming!(params[:fps].to_f)
|
||||||
@@ -141,6 +148,13 @@ module Api
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def min_quality
|
||||||
|
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
|
||||||
|
render json: session_json(@session)
|
||||||
|
rescue ArgumentError => e
|
||||||
|
render json: { error: e.message }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
def youtube_stats
|
def youtube_stats
|
||||||
count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present?
|
count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present?
|
||||||
Youtube::BroadcastService.new(@session.match.team)
|
Youtube::BroadcastService.new(@session.match.team)
|
||||||
@@ -160,6 +174,11 @@ module Api
|
|||||||
.find(params[:id])
|
.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sanitized_thermal_state
|
||||||
|
value = params[:thermal_state].to_s.downcase.presence
|
||||||
|
value if value.in?(%w[nominal fair serious critical])
|
||||||
|
end
|
||||||
|
|
||||||
def session_params
|
def session_params
|
||||||
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel)
|
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel)
|
||||||
end
|
end
|
||||||
@@ -180,6 +199,7 @@ module Api
|
|||||||
platform: session.platform,
|
platform: session.platform,
|
||||||
rtmp_ingest_url: session.rtmp_ingest_url,
|
rtmp_ingest_url: session.rtmp_ingest_url,
|
||||||
hls_playback_url: session.hls_playback_url,
|
hls_playback_url: session.hls_playback_url,
|
||||||
|
stream_node: session.stream_node&.slug,
|
||||||
watch_page_url: session.matchlivetv_platform? ? session.watch_page_url : nil,
|
watch_page_url: session.matchlivetv_platform? ? session.watch_page_url : nil,
|
||||||
share_url: session.share_url,
|
share_url: session.share_url,
|
||||||
youtube_watch_url: session.youtube_watch_url,
|
youtube_watch_url: session.youtube_watch_url,
|
||||||
@@ -187,9 +207,11 @@ module Api
|
|||||||
youtube_ready: session.youtube_ready?,
|
youtube_ready: session.youtube_ready?,
|
||||||
privacy_status: session.privacy_status,
|
privacy_status: session.privacy_status,
|
||||||
quality_preset: session.quality_preset,
|
quality_preset: session.quality_preset,
|
||||||
|
min_quality_preset: session.min_quality_preset,
|
||||||
target_bitrate: session.target_bitrate,
|
target_bitrate: session.target_bitrate,
|
||||||
started_at: session.started_at,
|
started_at: session.started_at,
|
||||||
disconnection_count: session.disconnection_count
|
disconnection_count: session.disconnection_count,
|
||||||
|
audio_muted: session.audio_muted
|
||||||
}
|
}
|
||||||
if detail
|
if detail
|
||||||
data[:score] = session.score_state&.as_cable_payload
|
data[:score] = session.score_state&.as_cable_payload
|
||||||
|
|||||||
@@ -4,11 +4,20 @@ module MediamtxPlayback
|
|||||||
private
|
private
|
||||||
|
|
||||||
def mediamtx_paths_index
|
def mediamtx_paths_index
|
||||||
@mediamtx_paths_index ||= Mediamtx::Client.new.list_paths.index_by { |i| i["name"] }
|
mediamtx_paths_index_for_url(MatchLiveTv.mediamtx_api_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
def mediamtx_paths_index_for(session)
|
||||||
|
mediamtx_paths_index_for_url(session.mediamtx_api_base_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
def mediamtx_paths_index_for_url(api_url)
|
||||||
|
@mediamtx_paths_by_origin ||= {}
|
||||||
|
@mediamtx_paths_by_origin[api_url] ||= Mediamtx::Client.new(base_url: api_url).list_paths.index_by { |i| i["name"] }
|
||||||
end
|
end
|
||||||
|
|
||||||
def mediamtx_path_info(session, path_name: mediamtx_playback_path_name(session))
|
def mediamtx_path_info(session, path_name: mediamtx_playback_path_name(session))
|
||||||
mediamtx_paths_index[path_name]
|
mediamtx_paths_index_for(session)[path_name]
|
||||||
end
|
end
|
||||||
|
|
||||||
def mediamtx_playback_path_name(session)
|
def mediamtx_playback_path_name(session)
|
||||||
@@ -28,7 +37,7 @@ module MediamtxPlayback
|
|||||||
end
|
end
|
||||||
|
|
||||||
def mediamtx_publisher_online?(session)
|
def mediamtx_publisher_online?(session)
|
||||||
info = mediamtx_paths_index[session.mediamtx_path_name]
|
info = mediamtx_paths_index_for(session)[session.mediamtx_path_name]
|
||||||
info && info["online"]
|
info && info["online"]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ class HlsProxyController < ActionController::Base
|
|||||||
def proxy_mediamtx_path(upstream_path)
|
def proxy_mediamtx_path(upstream_path)
|
||||||
return head :not_found if upstream_path.blank?
|
return head :not_found if upstream_path.blank?
|
||||||
|
|
||||||
upstream = "#{MatchLiveTv.mediamtx_hls_url}/#{upstream_path}"
|
origin = hls_origin_for(upstream_path)
|
||||||
|
upstream = "#{origin}/#{upstream_path}"
|
||||||
upstream = "#{upstream}?#{request.query_string}" if request.query_string.present?
|
upstream = "#{upstream}?#{request.query_string}" if request.query_string.present?
|
||||||
cookie = request.headers["Cookie"].presence || "cookieCheck=1"
|
cookie = request.headers["Cookie"].presence || "cookieCheck=1"
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ class HlsProxyController < ActionController::Base
|
|||||||
if response.status.in?([301, 302, 307, 308])
|
if response.status.in?([301, 302, 307, 308])
|
||||||
location = response.headers["location"].to_s
|
location = response.headers["location"].to_s
|
||||||
cookie = cookie_from_set_header(response.headers["set-cookie"]).presence || cookie
|
cookie = cookie_from_set_header(response.headers["set-cookie"]).presence || cookie
|
||||||
upstream = resolve_upstream_url(location)
|
upstream = resolve_upstream_url(location, origin)
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -43,10 +44,21 @@ class HlsProxyController < ActionController::Base
|
|||||||
head :bad_gateway
|
head :bad_gateway
|
||||||
end
|
end
|
||||||
|
|
||||||
def resolve_upstream_url(location)
|
def hls_origin_for(upstream_path)
|
||||||
|
session_id = upstream_path[/\Alive\/match_([0-9a-f-]{36})/i, 1]
|
||||||
|
if session_id
|
||||||
|
session = StreamSession.find_by(id: session_id)
|
||||||
|
node_hls = session&.stream_node&.internal_hls_url.presence
|
||||||
|
return node_hls.sub(%r{/$}, "") if node_hls.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
MatchLiveTv.mediamtx_hls_url.sub(%r{/$}, "")
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve_upstream_url(location, origin)
|
||||||
return location if location.start_with?("http://", "https://")
|
return location if location.start_with?("http://", "https://")
|
||||||
|
|
||||||
base = MatchLiveTv.mediamtx_hls_url.sub(%r{/$}, "")
|
base = origin.to_s.sub(%r{/$}, "")
|
||||||
location.start_with?("/") ? "#{base}#{location}" : "#{base}/#{location}"
|
location.start_with?("/") ? "#{base}#{location}" : "#{base}/#{location}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ module Public
|
|||||||
layout "regia"
|
layout "regia"
|
||||||
|
|
||||||
protect_from_forgery with: :null_session
|
protect_from_forgery with: :null_session
|
||||||
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop]
|
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop audio_mute min_quality]
|
||||||
|
|
||||||
before_action :set_session_from_token
|
before_action :set_session_from_token
|
||||||
|
|
||||||
@@ -61,8 +61,26 @@ module Public
|
|||||||
render json: status_payload
|
render json: status_payload
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def audio_mute
|
||||||
|
muted = params.key?(:muted) ? params[:muted] : !@session.audio_muted
|
||||||
|
Sessions::SetAudioMute.new(@session, muted: muted).call
|
||||||
|
render json: status_payload
|
||||||
|
end
|
||||||
|
|
||||||
|
def min_quality
|
||||||
|
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
|
||||||
|
render json: status_payload
|
||||||
|
rescue ArgumentError => e
|
||||||
|
render json: status_payload.merge(error: e.message), status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def camera_telemetry_payload(publisher_online)
|
||||||
|
cam = @session.device_states.find_by(device_role: "camera")
|
||||||
|
(cam&.as_regia_payload || {}).merge(publisher_online: publisher_online)
|
||||||
|
end
|
||||||
|
|
||||||
def set_session_from_token
|
def set_session_from_token
|
||||||
@session = Sessions::RegiaAccess.find_session_by_token(params[:token])
|
@session = Sessions::RegiaAccess.find_session_by_token(params[:token])
|
||||||
return if @session
|
return if @session
|
||||||
@@ -78,6 +96,7 @@ module Public
|
|||||||
{
|
{
|
||||||
status: @session.status,
|
status: @session.status,
|
||||||
paused: @session.paused?,
|
paused: @session.paused?,
|
||||||
|
audio_muted: @session.audio_muted,
|
||||||
stream_closed: closed,
|
stream_closed: closed,
|
||||||
live: !closed && (@session.live? || @session.paused? || publisher_online),
|
live: !closed && (@session.live? || @session.paused? || publisher_online),
|
||||||
on_air: playable || (!closed && @session.status.in?(%w[connecting live reconnecting paused])),
|
on_air: playable || (!closed && @session.status.in?(%w[connecting live reconnecting paused])),
|
||||||
@@ -86,7 +105,10 @@ module Public
|
|||||||
home_name: @session.match.team.name,
|
home_name: @session.match.team.name,
|
||||||
away_name: @session.match.opponent_name,
|
away_name: @session.match.opponent_name,
|
||||||
points_target: Scoring::Rules.points_target_for_match(@session.match, current_set: score&.current_set || 1),
|
points_target: Scoring::Rules.points_target_for_match(@session.match, current_set: score&.current_set || 1),
|
||||||
score: score&.as_cable_payload
|
score: score&.as_cable_payload,
|
||||||
|
min_quality_preset: @session.min_quality_preset,
|
||||||
|
min_quality_options: Sessions::SelectQuality.allowed_min_ids(@session.quality_preset),
|
||||||
|
telemetry: camera_telemetry_payload(publisher_online)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -44,9 +44,12 @@ module Public
|
|||||||
partialsPrefix: t("regia.board.partials_prefix"),
|
partialsPrefix: t("regia.board.partials_prefix"),
|
||||||
resumed: t("regia.js.resumed"),
|
resumed: t("regia.js.resumed"),
|
||||||
pausedCover: t("regia.js.paused_cover"),
|
pausedCover: t("regia.js.paused_cover"),
|
||||||
|
muted: t("regia.js.muted"),
|
||||||
|
unmuted: t("regia.js.unmuted"),
|
||||||
closed: t("regia.js.closed"),
|
closed: t("regia.js.closed"),
|
||||||
resumeError: t("regia.js.resume_error"),
|
resumeError: t("regia.js.resume_error"),
|
||||||
pauseError: t("regia.js.pause_error"),
|
pauseError: t("regia.js.pause_error"),
|
||||||
|
muteError: t("regia.js.mute_error"),
|
||||||
closeError: t("regia.js.close_error"),
|
closeError: t("regia.js.close_error"),
|
||||||
closeConfirm: t("regia.js.close_confirm"),
|
closeConfirm: t("regia.js.close_confirm"),
|
||||||
linkUnavailable: t("regia.js.link_unavailable"),
|
linkUnavailable: t("regia.js.link_unavailable"),
|
||||||
@@ -54,15 +57,36 @@ module Public
|
|||||||
copyPrompt: t("regia.js.copy_prompt"),
|
copyPrompt: t("regia.js.copy_prompt"),
|
||||||
shareRegiaText: t("regia.js.share_regia_text"),
|
shareRegiaText: t("regia.js.share_regia_text"),
|
||||||
shareLiveText: t("regia.js.share_live_text"),
|
shareLiveText: t("regia.js.share_live_text"),
|
||||||
|
minQualityUpdated: t("regia.js.min_quality_updated"),
|
||||||
|
minQualityError: t("regia.js.min_quality_error"),
|
||||||
|
minQualityAuto: t("regia.min_quality.auto"),
|
||||||
previewWaiting: t("regia.preview_waiting"),
|
previewWaiting: t("regia.preview_waiting"),
|
||||||
streamEnded: t("regia.preview_ended"),
|
streamEnded: t("regia.preview_ended"),
|
||||||
|
previewHide: t("regia.preview_hide"),
|
||||||
|
previewShow: t("regia.preview_show"),
|
||||||
|
previewOffHint: t("regia.preview_off_hint"),
|
||||||
resumeLabel: t("regia.resume"),
|
resumeLabel: t("regia.resume"),
|
||||||
pauseLabel: t("regia.pause"),
|
pauseLabel: t("regia.pause"),
|
||||||
|
muteLabel: t("regia.mute"),
|
||||||
|
unmuteLabel: t("regia.unmute"),
|
||||||
endedBadge: t("regia.status.ended"),
|
endedBadge: t("regia.status.ended"),
|
||||||
pausedBadge: t("regia.status.paused"),
|
pausedBadge: t("regia.status.paused"),
|
||||||
liveBadge: t("regia.status.live"),
|
liveBadge: t("regia.status.live"),
|
||||||
waitingBadge: t("regia.status.waiting"),
|
waitingBadge: t("regia.status.waiting"),
|
||||||
subtitlePrefix: t("regia.subtitle_prefix")
|
subtitlePrefix: t("regia.subtitle_prefix"),
|
||||||
|
telemetryWaiting: t("regia.telemetry.waiting"),
|
||||||
|
telemetryStale: t("regia.telemetry.stale"),
|
||||||
|
telemetryJustNow: t("regia.telemetry.just_now"),
|
||||||
|
telemetrySecondsAgo: t("regia.telemetry.seconds_ago"),
|
||||||
|
telemetryPublisherOn: t("regia.telemetry.publisher_on"),
|
||||||
|
telemetryPublisherOff: t("regia.telemetry.publisher_off"),
|
||||||
|
telemetryThermalNominal: t("regia.telemetry.thermal_nominal"),
|
||||||
|
telemetryThermalFair: t("regia.telemetry.thermal_fair"),
|
||||||
|
telemetryThermalSerious: t("regia.telemetry.thermal_serious"),
|
||||||
|
telemetryThermalCritical: t("regia.telemetry.thermal_critical"),
|
||||||
|
telemetryBitrateMax: t("regia.telemetry.bitrate_max"),
|
||||||
|
telemetryBitrateTitle: t("regia.telemetry.bitrate_title"),
|
||||||
|
telemetryBitrateTap: t("regia.telemetry.bitrate_tap")
|
||||||
}.to_json
|
}.to_json
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ class CleanupExpiredSessionsJob
|
|||||||
.where("updated_at < ?", 6.hours.ago)
|
.where("updated_at < ?", 6.hours.ago)
|
||||||
.find_each do |session|
|
.find_each do |session|
|
||||||
session.fail! if session.may_fail?
|
session.fail! if session.may_fail?
|
||||||
Mediamtx::Client.new.delete_path(session)
|
Mediamtx::Client.for_session(session).delete_path(session)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Streams
|
||||||
|
class AutoscalerJob
|
||||||
|
include Sidekiq::Job
|
||||||
|
|
||||||
|
sidekiq_options retry: 1, queue: "default"
|
||||||
|
|
||||||
|
INTERVAL_SECS = ENV.fetch("STREAM_AUTOSCALE_INTERVAL_SECS", "60").to_i
|
||||||
|
REDIS_CHAIN_KEY = "streams:autoscaler:chain"
|
||||||
|
|
||||||
|
def self.ensure_chain
|
||||||
|
return unless redis
|
||||||
|
return if redis.get(REDIS_CHAIN_KEY)
|
||||||
|
|
||||||
|
redis.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL_SECS * 2)
|
||||||
|
perform_in(INTERVAL_SECS)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.redis
|
||||||
|
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||||
|
rescue Redis::CannotConnectError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform
|
||||||
|
Streams::Autoscaler.reconcile!
|
||||||
|
ensure
|
||||||
|
self.class.redis&.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL_SECS * 2)
|
||||||
|
self.class.perform_in(INTERVAL_SECS)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Avvia/riavvia il relay YouTube solo nel container sidekiq (YOUTUBE_RELAY_WORKER=1).
|
# Avvia/riavvia il relay YouTube sul worker del nodo (coda youtube_relay_<slug>).
|
||||||
class YoutubeRelayEnsureJob < ApplicationJob
|
class YoutubeRelayEnsureJob < ApplicationJob
|
||||||
queue_as :default
|
queue_as Streams::YoutubeRelay::QUEUE
|
||||||
|
|
||||||
def perform(session_id)
|
def perform(session_id)
|
||||||
session = StreamSession.find_by(id: session_id)
|
session = StreamSession.find_by(id: session_id)
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
|
# Ferma il relay solo sull'owner. Se il job gira su un altro host, requeue breve.
|
||||||
class YoutubeRelayStopJob < ApplicationJob
|
class YoutubeRelayStopJob < ApplicationJob
|
||||||
queue_as :default
|
queue_as Streams::YoutubeRelay::QUEUE
|
||||||
|
|
||||||
def perform(session_id)
|
discard_on ActiveJob::DeserializationError
|
||||||
|
|
||||||
|
def perform(session_id, attempts = 0)
|
||||||
session = StreamSession.find_by(id: session_id)
|
session = StreamSession.find_by(id: session_id)
|
||||||
return unless session
|
return unless session
|
||||||
|
|
||||||
Streams::YoutubeRelay.stop_on_worker!(session) if Streams::YoutubeRelay.worker?
|
unless Streams::YoutubeRelay.worker?
|
||||||
|
# Solo i worker relay processano questa coda in modo utile.
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
result = Streams::YoutubeRelay.stop_on_worker!(session)
|
||||||
|
return unless result == :wrong_host
|
||||||
|
return if attempts >= 30
|
||||||
|
|
||||||
|
self.class.set(wait: 2.seconds, queue: Streams::YoutubeRelay.queue_for(session))
|
||||||
|
.perform_later(session_id, attempts + 1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -16,4 +16,17 @@ class DeviceState < ApplicationRecord
|
|||||||
status: stream_session.status
|
status: stream_session.status
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def as_regia_payload
|
||||||
|
{
|
||||||
|
battery: battery_level,
|
||||||
|
network: network_type,
|
||||||
|
signal: signal_strength,
|
||||||
|
bitrate: current_bitrate,
|
||||||
|
target_bitrate: target_bitrate,
|
||||||
|
fps: fps,
|
||||||
|
thermal_state: thermal_state,
|
||||||
|
last_seen_at: last_seen_at&.iso8601
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ module Ops
|
|||||||
|
|
||||||
KINDS = %w[
|
KINDS = %w[
|
||||||
disk_space recordings_size service_down http_public http_rails rails_latency
|
disk_space recordings_size service_down http_public http_rails rails_latency
|
||||||
sidekiq_stale sidekiq_dead log_pattern garage_storage
|
sidekiq_stale sidekiq_dead log_pattern garage_storage stream_overflow
|
||||||
].freeze
|
].freeze
|
||||||
SEVERITIES = %w[critical warning info].freeze
|
SEVERITIES = %w[critical warning info].freeze
|
||||||
STATUSES = %w[open acknowledged resolved].freeze
|
STATUSES = %w[open acknowledged resolved].freeze
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class StreamEvent < ApplicationRecord
|
class StreamEvent < ApplicationRecord
|
||||||
EVENT_TYPES = %w[
|
EVENT_TYPES = %w[
|
||||||
connected disconnected reconnected quality_changed error paused resumed
|
connected disconnected reconnected quality_changed error paused resumed
|
||||||
started ended network_test pairing youtube_ready
|
started ended network_test pairing youtube_ready audio_muted audio_unmuted
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
belongs_to :stream_session
|
belongs_to :stream_session
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Nodo streaming (MediaMTX [+ relay ffmpeg]). Fase 0: registry + assignment URL.
|
||||||
|
class StreamNode < ApplicationRecord
|
||||||
|
ROLES = %w[home cloud lab].freeze
|
||||||
|
STATUSES = %w[provisioning ready draining offline error].freeze
|
||||||
|
PROVIDERS = %w[local proxmox_lab hetzner].freeze
|
||||||
|
|
||||||
|
has_many :stream_sessions, dependent: :nullify
|
||||||
|
|
||||||
|
validates :slug, presence: true, uniqueness: true
|
||||||
|
validates :hostname, presence: true
|
||||||
|
validates :role, inclusion: { in: ROLES }
|
||||||
|
validates :status, inclusion: { in: STATUSES }
|
||||||
|
validates :provider, inclusion: { in: PROVIDERS }
|
||||||
|
validates :rtmp_base_url, :hls_base_url, :api_base_url, presence: true
|
||||||
|
validates :max_publishers, :max_relays, numericality: { greater_than: 0 }
|
||||||
|
|
||||||
|
scope :ready, -> { where(status: "ready") }
|
||||||
|
scope :allocatable, -> { ready }
|
||||||
|
|
||||||
|
# Sessioni che occupano uno slot path MediaMTX su questo nodo.
|
||||||
|
def occupying_sessions
|
||||||
|
stream_sessions.where(status: %w[idle connecting live reconnecting paused])
|
||||||
|
end
|
||||||
|
|
||||||
|
def active_publishers
|
||||||
|
occupying_sessions.count
|
||||||
|
end
|
||||||
|
|
||||||
|
def free_slots
|
||||||
|
[max_publishers - active_publishers, 0].max
|
||||||
|
end
|
||||||
|
|
||||||
|
def allocatable?
|
||||||
|
status == "ready" && free_slots.positive?
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -7,6 +7,7 @@ class StreamSession < ApplicationRecord
|
|||||||
|
|
||||||
belongs_to :match
|
belongs_to :match
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
belongs_to :stream_node, optional: true
|
||||||
has_many :stream_events, dependent: :destroy
|
has_many :stream_events, dependent: :destroy
|
||||||
has_one :score_state, dependent: :destroy
|
has_one :score_state, dependent: :destroy
|
||||||
has_many :device_states, dependent: :destroy
|
has_many :device_states, dependent: :destroy
|
||||||
@@ -14,6 +15,7 @@ class StreamSession < ApplicationRecord
|
|||||||
validates :platform, inclusion: { in: PLATFORMS }
|
validates :platform, inclusion: { in: PLATFORMS }
|
||||||
validates :status, inclusion: { in: STATUSES }
|
validates :status, inclusion: { in: STATUSES }
|
||||||
validates :privacy_status, inclusion: { in: PRIVACY_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 :normalize_privacy_status
|
||||||
before_validation :ensure_publish_token, on: :create
|
before_validation :ensure_publish_token, on: :create
|
||||||
@@ -74,7 +76,7 @@ class StreamSession < ApplicationRecord
|
|||||||
def rtmp_ingest_url
|
def rtmp_ingest_url
|
||||||
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
|
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
|
||||||
# MediaMTX path = live/match_{uuid} (no ?token= nel path).
|
# MediaMTX path = live/match_{uuid} (no ?token= nel path).
|
||||||
"#{MatchLiveTv.mediamtx_rtmp_url}/#{mediamtx_path_name}"
|
"#{rtmp_base_url.chomp('/')}/#{mediamtx_path_name}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def mediamtx_path_name
|
def mediamtx_path_name
|
||||||
@@ -90,8 +92,29 @@ class StreamSession < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def hls_playback_url
|
def hls_playback_url
|
||||||
base = MatchLiveTv.hls_public_url.chomp("/")
|
"#{hls_base_url.chomp('/')}/#{effective_hls_path_name}/index.m3u8"
|
||||||
"#{base}/#{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
|
end
|
||||||
|
|
||||||
def effective_hls_path_name
|
def effective_hls_path_name
|
||||||
|
|||||||
@@ -4,19 +4,33 @@ class YoutubeCredential < ApplicationRecord
|
|||||||
attr_encrypted :access_token,
|
attr_encrypted :access_token,
|
||||||
key: :encryption_key,
|
key: :encryption_key,
|
||||||
attribute: "access_token_encrypted",
|
attribute: "access_token_encrypted",
|
||||||
mode: :single_iv_salt
|
mode: :single_iv_and_salt,
|
||||||
|
algorithm: "aes-256-cbc",
|
||||||
|
iv: :encryption_iv
|
||||||
attr_encrypted :refresh_token,
|
attr_encrypted :refresh_token,
|
||||||
key: :encryption_key,
|
key: :encryption_key,
|
||||||
attribute: "refresh_token_encrypted",
|
attribute: "refresh_token_encrypted",
|
||||||
mode: :single_iv_salt
|
mode: :single_iv_and_salt,
|
||||||
|
algorithm: "aes-256-cbc",
|
||||||
|
iv: :encryption_iv
|
||||||
|
|
||||||
def expired?
|
def expired?
|
||||||
expires_at.present? && expires_at < Time.current
|
expires_at.present? && expires_at < Time.current
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def usable?
|
||||||
|
refresh_token.present?
|
||||||
|
rescue ArgumentError, OpenSSL::Cipher::CipherError, NoMethodError
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def encryption_key
|
def encryption_key
|
||||||
Rails.application.secret_key_base[0, 32]
|
Rails.application.secret_key_base[0, 32]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def encryption_iv
|
||||||
|
encryption_key[0, 16]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ module Mediamtx
|
|||||||
class Client
|
class Client
|
||||||
class Error < StandardError; end
|
class Error < StandardError; end
|
||||||
|
|
||||||
|
def self.for_session(session)
|
||||||
|
new(base_url: session.mediamtx_api_base_url)
|
||||||
|
end
|
||||||
|
|
||||||
def initialize(base_url: MatchLiveTv.mediamtx_api_url)
|
def initialize(base_url: MatchLiveTv.mediamtx_api_url)
|
||||||
|
@base_url = base_url
|
||||||
@conn = Faraday.new(url: base_url) do |f|
|
@conn = Faraday.new(url: base_url) do |f|
|
||||||
f.request :json
|
f.request :json
|
||||||
f.response :json
|
f.response :json
|
||||||
@@ -12,11 +17,12 @@ module Mediamtx
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
attr_reader :base_url
|
||||||
|
|
||||||
def create_path(session)
|
def create_path(session)
|
||||||
path = session.mediamtx_path_name
|
path = session.mediamtx_path_name
|
||||||
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe
|
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe
|
||||||
# solo la slate (schermo nero) in pausa/attesa.
|
# solo la slate in pausa/attesa. Slate resta accesa anche su YouTube (copertina se l'app cade).
|
||||||
# YouTube: niente slate sul path camera (maschera il video al relay ffmpeg).
|
|
||||||
body = recording_body(session, enabled: false).merge(
|
body = recording_body(session, enabled: false).merge(
|
||||||
source: "publisher",
|
source: "publisher",
|
||||||
overridePublisher: true
|
overridePublisher: true
|
||||||
@@ -69,7 +75,7 @@ module Mediamtx
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Slate alwaysAvailable: copertina sullo stesso path quando il telefono è offline.
|
# Slate alwaysAvailable: copertina sullo stesso path quando il telefono è offline.
|
||||||
# Disattivare quando il publisher è in onda; riattivare in pausa/disconnessione.
|
# Resta accesa anche con publisher in onda (MediaMTX usa il publisher se presente).
|
||||||
def set_always_available(session, enabled:)
|
def set_always_available(session, enabled:)
|
||||||
path = session.mediamtx_path_name
|
path = session.mediamtx_path_name
|
||||||
return true if always_available_remembered?(path, enabled: enabled)
|
return true if always_available_remembered?(path, enabled: enabled)
|
||||||
@@ -98,6 +104,16 @@ module Mediamtx
|
|||||||
[]
|
[]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def list_rtmp_conns
|
||||||
|
response = @conn.get("/v3/rtmpconns/list")
|
||||||
|
return [] unless response.success?
|
||||||
|
|
||||||
|
body = response.body
|
||||||
|
body.is_a?(Hash) ? (body["items"] || []) : []
|
||||||
|
rescue Error, Faraday::Error
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
def online_path_names
|
def online_path_names
|
||||||
Set.new(list_paths.filter_map { |item| item["name"] if item["online"] })
|
Set.new(list_paths.filter_map { |item| item["name"] if item["online"] })
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,17 +4,37 @@ module Mediamtx
|
|||||||
module_function
|
module_function
|
||||||
|
|
||||||
def active?(session)
|
def active?(session)
|
||||||
|
return true if rtmp_publisher?(session)
|
||||||
|
|
||||||
active_path?(path_info(session))
|
active_path?(path_info(session))
|
||||||
end
|
end
|
||||||
|
|
||||||
def path_info(session)
|
def path_info(session)
|
||||||
Client.new.list_paths.find { |i| i["name"] == session.mediamtx_path_name }
|
Client.for_session(session).list_paths.find { |i| i["name"] == session.mediamtx_path_name }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# MediaMTX <=1.19: online + source.type=rtmpConn.
|
||||||
|
# MediaMTX 1.20+: online/source spesso null anche con publisher; usare rtmpconns.
|
||||||
def active_path?(info)
|
def active_path?(info)
|
||||||
return false unless info
|
return false unless info
|
||||||
|
return true if info["online"] == true && rtmp_source?(info.dig("source", "type"))
|
||||||
|
|
||||||
info["online"] == true && info.dig("source", "type") == "rtmpConn"
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def rtmp_publisher?(session)
|
||||||
|
path = session.mediamtx_path_name.to_s
|
||||||
|
return false if path.blank?
|
||||||
|
|
||||||
|
Client.for_session(session).list_rtmp_conns.any? do |conn|
|
||||||
|
conn_path = conn["path"].to_s.sub(%r{\A/}, "")
|
||||||
|
next false unless conn_path == path
|
||||||
|
|
||||||
|
state = conn["state"].to_s
|
||||||
|
state.empty? || state == "publish" || state == "idle"
|
||||||
|
end
|
||||||
|
rescue StandardError
|
||||||
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
def h264_video?(info)
|
def h264_video?(info)
|
||||||
@@ -26,12 +46,14 @@ module Mediamtx
|
|||||||
end
|
end
|
||||||
|
|
||||||
def video_publishing?(session)
|
def video_publishing?(session)
|
||||||
info = path_info(session)
|
return false unless active?(session)
|
||||||
return false unless active_path?(info)
|
|
||||||
# Slate alwaysAvailable ha H264 ma non è il telefono.
|
|
||||||
return false unless info.dig("source", "type") == "rtmpConn"
|
|
||||||
|
|
||||||
|
info = path_info(session)
|
||||||
h264_video?(info)
|
h264_video?(info)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def rtmp_source?(type)
|
||||||
|
type.to_s.match?(/\Artmps?Conn\z/)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -12,14 +12,13 @@ module Mediamtx
|
|||||||
return @session if @session.terminal?
|
return @session if @session.terminal?
|
||||||
|
|
||||||
path_info = Mediamtx::PublisherOnline.path_info(@session)
|
path_info = Mediamtx::PublisherOnline.path_info(@session)
|
||||||
publisher_online = Mediamtx::PublisherOnline.active_path?(path_info)
|
publisher_online = Mediamtx::PublisherOnline.active?(@session)
|
||||||
|
|
||||||
if publisher_online
|
if publisher_online
|
||||||
clear_publisher_misses!(@session.id)
|
clear_publisher_misses!(@session.id)
|
||||||
if @session.paused?
|
if @session.paused?
|
||||||
# RTMP ancora connesso in pausa: non forzare live/reconnect.
|
# RTMP ancora connesso in pausa: non forzare live/reconnect.
|
||||||
else
|
else
|
||||||
enable_live_path_once!(@session)
|
|
||||||
if @session.may_go_live?
|
if @session.may_go_live?
|
||||||
@session.go_live!
|
@session.go_live!
|
||||||
@session.reload
|
@session.reload
|
||||||
@@ -75,27 +74,14 @@ module Mediamtx
|
|||||||
@session.update!(timeout_job_id: job)
|
@session.update!(timeout_job_id: job)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Compatibilità con webhook / controller.
|
|
||||||
def self.schedule_youtube_pipeline!(session, force: false)
|
def self.schedule_youtube_pipeline!(session, force: false)
|
||||||
Youtube::LivePipeline.schedule!(session, force: force)
|
Youtube::LivePipeline.schedule!(session, force: force)
|
||||||
end
|
end
|
||||||
|
|
||||||
def enable_live_path_once!(session)
|
|
||||||
return unless session.platform == "youtube"
|
|
||||||
|
|
||||||
key = format("youtube:slate_disabled:%s", session.id)
|
|
||||||
return unless redis.set(key, "1", nx: true, ex: 48.hours.to_i)
|
|
||||||
|
|
||||||
Client.new.set_always_available(session, enabled: false)
|
|
||||||
rescue Client::Error => e
|
|
||||||
redis.del(format("youtube:slate_disabled:%s", session.id))
|
|
||||||
Rails.logger.warn("[PublisherSync] disable slate session=#{session.id}: #{e.message}")
|
|
||||||
end
|
|
||||||
|
|
||||||
def restore_slate_path!(session)
|
def restore_slate_path!(session)
|
||||||
return if session.platform == "matchlivetv"
|
return if session.platform == "matchlivetv"
|
||||||
|
|
||||||
Client.new.set_always_available(session, enabled: true)
|
Client.for_session(session).set_always_available(session, enabled: true)
|
||||||
rescue Client::Error => e
|
rescue Client::Error => e
|
||||||
Rails.logger.warn("[PublisherSync] enable slate session=#{session.id}: #{e.message}")
|
Rails.logger.warn("[PublisherSync] enable slate session=#{session.id}: #{e.message}")
|
||||||
end
|
end
|
||||||
@@ -128,7 +114,7 @@ module Mediamtx
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
Client.new.set_path_recording(session, enabled: enabled)
|
Client.for_session(session).set_path_recording(session, enabled: enabled)
|
||||||
redis.set(key, desired, ex: 48.hours.to_i)
|
redis.set(key, desired, ex: 48.hours.to_i)
|
||||||
mark_recording_patch!(session.id)
|
mark_recording_patch!(session.id)
|
||||||
rescue Client::Error => e
|
rescue Client::Error => e
|
||||||
@@ -183,7 +169,7 @@ module Mediamtx
|
|||||||
key = format("youtube_relay:sched:%s", session.id)
|
key = format("youtube_relay:sched:%s", session.id)
|
||||||
return unless redis.set(key, "1", nx: true, ex: 10)
|
return unless redis.set(key, "1", nx: true, ex: 10)
|
||||||
|
|
||||||
YoutubeRelayEnsureJob.perform_later(session.id)
|
Streams::YoutubeRelay.enqueue_ensure!(session)
|
||||||
end
|
end
|
||||||
|
|
||||||
def redis
|
def redis
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ module Ops
|
|||||||
check_sidekiq_heartbeat,
|
check_sidekiq_heartbeat,
|
||||||
check_sidekiq_dead,
|
check_sidekiq_dead,
|
||||||
check_http_rails,
|
check_http_rails,
|
||||||
check_rails_latency
|
check_rails_latency,
|
||||||
|
check_stream_overflow
|
||||||
]
|
]
|
||||||
findings << check_http_public if public_check_due?
|
findings << check_http_public if public_check_due?
|
||||||
findings
|
findings
|
||||||
@@ -161,6 +162,58 @@ module Ops
|
|||||||
fail_finding("garage_storage", "warning", "garage_storage:head", "Garage storage non raggiungibile", e.message)
|
fail_finding("garage_storage", "warning", "garage_storage:head", "Garage storage non raggiungibile", e.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def check_stream_overflow
|
||||||
|
return ok_finding("stream_overflow:skip", "Nodi stream non migrati") unless ActiveRecord::Base.connection.data_source_exists?("stream_nodes")
|
||||||
|
|
||||||
|
orphan_hours = ENV.fetch("STREAM_OVERFLOW_ORPHAN_HOURS", "3").to_i
|
||||||
|
orphans = StreamNode.where.not(slug: "home").where(status: %w[ready draining]).select do |n|
|
||||||
|
n.active_publishers.zero? && n.created_at < orphan_hours.hours.ago
|
||||||
|
end
|
||||||
|
metrics = Streams::Autoscaler.metrics
|
||||||
|
over_budget = !metrics[:within_budget]
|
||||||
|
at_max = metrics[:overflow_nodes] >= metrics[:max_overflow_nodes] && metrics[:free_slots] <= metrics[:soft_free_slots]
|
||||||
|
|
||||||
|
if orphans.any?
|
||||||
|
return Finding.new(
|
||||||
|
kind: "stream_overflow",
|
||||||
|
severity: "warning",
|
||||||
|
healthy: false,
|
||||||
|
title: "Nodi stream overflow idle",
|
||||||
|
message: "#{orphans.size} nodo/i idle da >#{orphan_hours}h: #{orphans.map(&:slug).join(', ')}",
|
||||||
|
metadata: { "slugs" => orphans.map(&:slug) },
|
||||||
|
fingerprint: "stream_overflow:orphan_idle"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if over_budget
|
||||||
|
return Finding.new(
|
||||||
|
kind: "stream_overflow",
|
||||||
|
severity: "warning",
|
||||||
|
healthy: false,
|
||||||
|
title: "Budget overflow streaming",
|
||||||
|
message: "Stima €#{metrics[:estimated_monthly_eur]}/mese > budget €#{metrics[:monthly_budget_eur]}",
|
||||||
|
metadata: metrics.transform_keys(&:to_s),
|
||||||
|
fingerprint: "stream_overflow:budget"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if at_max
|
||||||
|
return Finding.new(
|
||||||
|
kind: "stream_overflow",
|
||||||
|
severity: "warning",
|
||||||
|
healthy: false,
|
||||||
|
title: "Capacità stream al massimo",
|
||||||
|
message: "overflow=#{metrics[:overflow_nodes]}/#{metrics[:max_overflow_nodes]} free_slots=#{metrics[:free_slots]}",
|
||||||
|
metadata: metrics.transform_keys(&:to_s),
|
||||||
|
fingerprint: "stream_overflow:at_max"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
ok_finding("stream_overflow:ok", "Overflow streaming OK")
|
||||||
|
rescue StandardError => e
|
||||||
|
fail_finding("stream_overflow", "warning", "stream_overflow:error", "Check overflow fallito", e.message)
|
||||||
|
end
|
||||||
|
|
||||||
def check_sidekiq_heartbeat
|
def check_sidekiq_heartbeat
|
||||||
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||||
last = redis.get(Ops::HealthMonitorJob::HEARTBEAT_KEY).to_i
|
last = redis.get(Ops::HealthMonitorJob::HEARTBEAT_KEY).to_i
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ module Recordings
|
|||||||
def cleanup_mediamtx_path(session)
|
def cleanup_mediamtx_path(session)
|
||||||
return unless session.status.in?(%w[ended error])
|
return unless session.status.in?(%w[ended error])
|
||||||
|
|
||||||
Mediamtx::Client.new.delete_path(session)
|
Mediamtx::Client.for_session(session).delete_path(session)
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
@logger.warn("[Recordings::CleanupLocal] delete_path #{session.id}: #{e.message}")
|
@logger.warn("[Recordings::CleanupLocal] delete_path #{session.id}: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ module Recordings
|
|||||||
|
|
||||||
def cleanup_mediamtx_path
|
def cleanup_mediamtx_path
|
||||||
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
||||||
Mediamtx::Client.new.delete_path(@session)
|
Mediamtx::Client.for_session(@session).delete_path(@session)
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}")
|
Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -34,11 +34,19 @@ module Sessions
|
|||||||
end
|
end
|
||||||
|
|
||||||
StreamSession.transaction do
|
StreamSession.transaction do
|
||||||
|
session.stream_node = Streams::NodeRegistry.allocate!
|
||||||
session.save!
|
session.save!
|
||||||
Scoring::Engine.ensure_score_for(session)
|
Scoring::Engine.ensure_score_for(session)
|
||||||
mtx = Mediamtx::Client.new
|
Mediamtx::Client.for_session(session).create_path(session)
|
||||||
mtx.create_path(session)
|
log_event(
|
||||||
log_event(session, "pairing", { created: true, platform: session.platform })
|
session,
|
||||||
|
"pairing",
|
||||||
|
{
|
||||||
|
created: true,
|
||||||
|
platform: session.platform,
|
||||||
|
stream_node: session.stream_node&.slug
|
||||||
|
}
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
if session.platform == "youtube"
|
if session.platform == "youtube"
|
||||||
@@ -46,6 +54,8 @@ module Sessions
|
|||||||
end
|
end
|
||||||
|
|
||||||
session
|
session
|
||||||
|
rescue Streams::NodeRegistry::NoCapacityError => e
|
||||||
|
raise Teams::EntitlementError.new(e.message, code: "stream_capacity_exhausted")
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ module Sessions
|
|||||||
@session.pause! if @session.may_pause?
|
@session.pause! if @session.may_pause?
|
||||||
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
||||||
begin
|
begin
|
||||||
Mediamtx::Client.new.set_path_recording(@session, enabled: false)
|
mtx = Mediamtx::Client.for_session(@session)
|
||||||
|
mtx.set_path_recording(@session, enabled: false)
|
||||||
|
mtx.set_always_available(@session, enabled: true)
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
Rails.logger.warn("[Sessions::Pause] disable recording: #{e.message}")
|
Rails.logger.warn("[Sessions::Pause] MediaMTX: #{e.message}")
|
||||||
end
|
end
|
||||||
Mediamtx::Client.new.set_always_available(@session, enabled: true)
|
|
||||||
# Slate su path per HLS in pausa; RTMP telefono si ferma via comando app.
|
# Slate su path per HLS in pausa; RTMP telefono si ferma via comando app.
|
||||||
log_event("paused")
|
log_event("paused")
|
||||||
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
|
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ module Sessions
|
|||||||
class SelectQuality
|
class SelectQuality
|
||||||
UPLOAD_HEADROOM = 0.8
|
UPLOAD_HEADROOM = 0.8
|
||||||
UPLOAD_HEADROOM_1080P = 1.0
|
UPLOAD_HEADROOM_1080P = 1.0
|
||||||
|
AUTO_ID = "auto"
|
||||||
|
AUTO_FLOOR_BITRATE = 800_000
|
||||||
|
|
||||||
PRESETS = [
|
PRESETS = [
|
||||||
{ id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 },
|
{ id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 },
|
||||||
@@ -11,6 +13,32 @@ module Sessions
|
|||||||
{ id: "720p_30_1.5mbps", target_bitrate: 1_500_000, target_fps: 30 }
|
{ id: "720p_30_1.5mbps", target_bitrate: 1_500_000, target_fps: 30 }
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
|
MIN_QUALITY_IDS = ([AUTO_ID] + PRESETS.map { |preset| preset[:id] }).freeze
|
||||||
|
|
||||||
|
def self.floor_bitrate(preset_id)
|
||||||
|
return AUTO_FLOOR_BITRATE if preset_id.blank? || preset_id == AUTO_ID
|
||||||
|
|
||||||
|
PRESETS.find { |preset| preset[:id] == preset_id }&.fetch(:target_bitrate) || AUTO_FLOOR_BITRATE
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.allowed_min_ids(session_preset)
|
||||||
|
ids = [AUTO_ID] + PRESETS.reject { |preset| preset[:id].start_with?("1080p") }.reverse.map { |preset| preset[:id] }
|
||||||
|
ids << "1080p_30_4.5mbps" if session_preset.to_s.start_with?("1080p")
|
||||||
|
ids
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.display_name(preset_id)
|
||||||
|
return AUTO_ID if preset_id.blank? || preset_id == AUTO_ID
|
||||||
|
|
||||||
|
preset = PRESETS.find { |item| item[:id] == preset_id }
|
||||||
|
return preset_id unless preset
|
||||||
|
|
||||||
|
resolution = preset[:id].start_with?("1080p") ? "1080p" : "720p"
|
||||||
|
mbps = preset[:target_bitrate] / 1_000_000.0
|
||||||
|
mbps_text = (mbps == mbps.to_i) ? mbps.to_i.to_s : format("%.1f", mbps)
|
||||||
|
"#{resolution} · #{mbps_text} Mbps"
|
||||||
|
end
|
||||||
|
|
||||||
def self.call(upload_mbps:)
|
def self.call(upload_mbps:)
|
||||||
new(upload_mbps: upload_mbps).call
|
new(upload_mbps: upload_mbps).call
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
module Sessions
|
||||||
|
# Silenzia o riattiva il microfono della camera in onda.
|
||||||
|
# Lo stream continua a inviare AAC silenzioso (YouTube/MediaMTX richiedono la traccia audio).
|
||||||
|
class SetAudioMute
|
||||||
|
def initialize(session, muted:)
|
||||||
|
@session = session
|
||||||
|
@muted = ActiveModel::Type::Boolean.new.cast(muted)
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
return @session if @session.audio_muted == @muted
|
||||||
|
|
||||||
|
@session.update!(audio_muted: @muted)
|
||||||
|
action = @muted ? "mute_audio" : "unmute_audio"
|
||||||
|
event_type = @muted ? "audio_muted" : "audio_unmuted"
|
||||||
|
log_event(event_type)
|
||||||
|
SessionChannel.broadcast_message(@session, { type: "command", action: action, muted: @muted })
|
||||||
|
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "audio_muted", muted: @muted })
|
||||||
|
@session
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def log_event(type)
|
||||||
|
@session.stream_events.create!(event_type: type, occurred_at: Time.current, metadata: { muted: @muted })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Sessions
|
||||||
|
class SetMinQuality
|
||||||
|
def initialize(session, preset:)
|
||||||
|
@session = session
|
||||||
|
@preset = preset.to_s.strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
id = normalize
|
||||||
|
unless SelectQuality.allowed_min_ids(@session.quality_preset).include?(id)
|
||||||
|
raise ArgumentError, "Qualità minima non valida"
|
||||||
|
end
|
||||||
|
|
||||||
|
return @session if @session.min_quality_preset == id
|
||||||
|
|
||||||
|
@session.update!(min_quality_preset: id)
|
||||||
|
@session.stream_events.create!(
|
||||||
|
event_type: "quality_changed",
|
||||||
|
occurred_at: Time.current,
|
||||||
|
metadata: { "min_quality_preset" => id }
|
||||||
|
)
|
||||||
|
SessionChannel.broadcast_message(
|
||||||
|
@session,
|
||||||
|
{ type: "command", action: "set_min_quality", min_quality_preset: id }
|
||||||
|
)
|
||||||
|
@session
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def normalize
|
||||||
|
return SelectQuality::AUTO_ID if @preset.blank? || @preset == SelectQuality::AUTO_ID
|
||||||
|
|
||||||
|
@preset
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -33,7 +33,7 @@ module Sessions
|
|||||||
end
|
end
|
||||||
|
|
||||||
def remove_mediamtx_paths!
|
def remove_mediamtx_paths!
|
||||||
Mediamtx::Client.new.delete_path(@session)
|
Mediamtx::Client.for_session(@session).delete_path(@session)
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
Rails.logger.warn("[Sessions::Stop] delete_path #{@session.id}: #{e.message}")
|
Rails.logger.warn("[Sessions::Stop] delete_path #{@session.id}: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Streams
|
||||||
|
# Scale-out / warm spare / scale-in dei nodi overflow (lab o Hetzner).
|
||||||
|
# Kill-switch: STREAM_AUTOSCALE_ENABLED!=1 OPPURE Redis streams:autoscaler:kill_switch=1.
|
||||||
|
class Autoscaler
|
||||||
|
Result = Struct.new(:actions, :metrics, :skipped, :error, keyword_init: true)
|
||||||
|
|
||||||
|
LOCK_KEY = "streams:autoscaler:lock"
|
||||||
|
KILL_SWITCH_KEY = "streams:autoscaler:kill_switch"
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def enabled?
|
||||||
|
return false if kill_switch_engaged?
|
||||||
|
return false unless ENV["STREAM_AUTOSCALE_ENABLED"] == "1"
|
||||||
|
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def kill_switch_engaged?
|
||||||
|
redis_get(KILL_SWITCH_KEY) == "1"
|
||||||
|
end
|
||||||
|
|
||||||
|
def engage_kill_switch!
|
||||||
|
redis_set(KILL_SWITCH_KEY, "1")
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_kill_switch!
|
||||||
|
redis_del(KILL_SWITCH_KEY)
|
||||||
|
end
|
||||||
|
|
||||||
|
def soft_free_slots
|
||||||
|
ENV.fetch("STREAM_AUTOSCALE_SOFT_FREE_SLOTS", "2").to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def warm_spare_min
|
||||||
|
ENV.fetch("STREAM_AUTOSCALE_WARM_SPARE", "1").to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def idle_minutes
|
||||||
|
ENV.fetch("STREAM_AUTOSCALE_IDLE_MINUTES", "30").to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def max_overflow_nodes
|
||||||
|
ENV.fetch("STREAM_AUTOSCALE_MAX_NODES", "5").to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def kind
|
||||||
|
ENV.fetch("STREAM_AUTOSCALE_KIND", "lab") # lab|cloud
|
||||||
|
end
|
||||||
|
|
||||||
|
def allow_cloud?
|
||||||
|
ENV["STREAM_AUTOSCALE_ALLOW_CLOUD"] == "1" && ENV["HCLOUD_TOKEN"].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def node_eur_per_hour
|
||||||
|
ENV.fetch("STREAM_AUTOSCALE_NODE_EUR_PER_HOUR", "0.015").to_f
|
||||||
|
end
|
||||||
|
|
||||||
|
def monthly_budget_eur
|
||||||
|
ENV.fetch("STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR", "40").to_f
|
||||||
|
end
|
||||||
|
|
||||||
|
def estimated_monthly_eur(overflow_count = nil)
|
||||||
|
count = overflow_count || metrics[:overflow_nodes]
|
||||||
|
# Worst case: nodi sempre accesi 24/7
|
||||||
|
(count * node_eur_per_hour * 24 * 30).round(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
def within_budget?(overflow_count = nil)
|
||||||
|
estimated_monthly_eur(overflow_count) <= monthly_budget_eur
|
||||||
|
end
|
||||||
|
|
||||||
|
def reconcile!(provisioner: nil)
|
||||||
|
return Result.new(skipped: true, actions: [], metrics: metrics) unless enabled?
|
||||||
|
|
||||||
|
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||||
|
unless redis.set(LOCK_KEY, worker_id, nx: true, ex: 55)
|
||||||
|
return Result.new(skipped: true, actions: [], metrics: metrics, error: "locked")
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
new(provisioner: provisioner).reconcile!
|
||||||
|
ensure
|
||||||
|
redis.del(LOCK_KEY)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def metrics
|
||||||
|
NodeRegistry.ensure_home_from_env!
|
||||||
|
nodes = StreamNode.ready.to_a
|
||||||
|
overflow = StreamNode.where.not(slug: NodeRegistry::HOME_SLUG)
|
||||||
|
.where(status: %w[ready draining provisioning]).to_a
|
||||||
|
{
|
||||||
|
free_slots: nodes.sum(&:free_slots),
|
||||||
|
ready_nodes: nodes.size,
|
||||||
|
spare_ready: nodes.count { |n| n.slug != NodeRegistry::HOME_SLUG && n.active_publishers.zero? },
|
||||||
|
overflow_nodes: overflow.size,
|
||||||
|
soft_free_slots: soft_free_slots,
|
||||||
|
warm_spare_min: warm_spare_min,
|
||||||
|
max_overflow_nodes: max_overflow_nodes,
|
||||||
|
enabled: enabled?,
|
||||||
|
env_enabled: ENV["STREAM_AUTOSCALE_ENABLED"] == "1",
|
||||||
|
kill_switch: kill_switch_engaged?,
|
||||||
|
kind: kind,
|
||||||
|
allow_cloud: allow_cloud?,
|
||||||
|
estimated_monthly_eur: estimated_monthly_eur(overflow.size),
|
||||||
|
monthly_budget_eur: monthly_budget_eur,
|
||||||
|
within_budget: within_budget?(overflow.size)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def worker_id
|
||||||
|
ENV.fetch("HOSTNAME", "autoscaler")
|
||||||
|
end
|
||||||
|
|
||||||
|
def redis_get(key)
|
||||||
|
Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")).get(key)
|
||||||
|
rescue Redis::BaseError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def redis_set(key, value)
|
||||||
|
Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")).set(key, value)
|
||||||
|
end
|
||||||
|
|
||||||
|
def redis_del(key)
|
||||||
|
Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")).del(key)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(provisioner: nil)
|
||||||
|
@provisioner = provisioner || NodeProvisioner.new
|
||||||
|
@provisioned_this_round = []
|
||||||
|
end
|
||||||
|
|
||||||
|
def reconcile!
|
||||||
|
actions = []
|
||||||
|
m = self.class.metrics
|
||||||
|
|
||||||
|
if need_capacity?(m) && can_provision?(m)
|
||||||
|
provision_overflow!
|
||||||
|
actions << :scale_out
|
||||||
|
m = self.class.metrics
|
||||||
|
elsif need_capacity?(m) && !can_provision?(m)
|
||||||
|
actions << :blocked_capacity
|
||||||
|
Rails.logger.warn("[Streams::Autoscaler] capacity needed but blocked metrics=#{m.inspect}")
|
||||||
|
end
|
||||||
|
|
||||||
|
if warm_spare_desired?(m) && m[:spare_ready] < self.class.warm_spare_min && can_provision?(m)
|
||||||
|
provision_overflow!
|
||||||
|
actions << :warm_spare
|
||||||
|
m = self.class.metrics
|
||||||
|
end
|
||||||
|
|
||||||
|
scale_in_candidates.each do |node|
|
||||||
|
next if keep_as_warm_spare?(node)
|
||||||
|
next if @provisioned_this_round.include?(node.id)
|
||||||
|
|
||||||
|
safe_scale_in!(node)
|
||||||
|
actions << :"scale_in_#{node.slug}"
|
||||||
|
m = self.class.metrics
|
||||||
|
rescue NodeProvisioner::BusyError, NodeProvisioner::Error => e
|
||||||
|
Rails.logger.warn("[Streams::Autoscaler] scale-in #{node.slug}: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info("[Streams::Autoscaler] actions=#{actions.inspect} metrics=#{m.inspect}")
|
||||||
|
Result.new(actions: actions, metrics: m, skipped: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def need_capacity?(m)
|
||||||
|
m[:free_slots] <= self.class.soft_free_slots
|
||||||
|
end
|
||||||
|
|
||||||
|
def warm_spare_desired?(m)
|
||||||
|
return false if self.class.warm_spare_min <= 0
|
||||||
|
|
||||||
|
need_capacity?(m) || overflow_in_use?
|
||||||
|
end
|
||||||
|
|
||||||
|
def overflow_in_use?
|
||||||
|
StreamNode.ready.where.not(slug: NodeRegistry::HOME_SLUG).any? { |n| n.active_publishers.positive? }
|
||||||
|
end
|
||||||
|
|
||||||
|
def can_provision?(m)
|
||||||
|
return false if m[:overflow_nodes] >= self.class.max_overflow_nodes
|
||||||
|
return false unless self.class.within_budget?(m[:overflow_nodes] + 1)
|
||||||
|
return false if self.class.kind == "cloud" && !self.class.allow_cloud?
|
||||||
|
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def provision_overflow!
|
||||||
|
node =
|
||||||
|
case self.class.kind
|
||||||
|
when "cloud"
|
||||||
|
raise NodeProvisioner::Error, "Cloud autoscale disabilitato (STREAM_AUTOSCALE_ALLOW_CLOUD / HCLOUD_TOKEN)" unless self.class.allow_cloud?
|
||||||
|
|
||||||
|
@provisioner.provision_cloud!
|
||||||
|
else
|
||||||
|
@provisioner.provision_lab!
|
||||||
|
end
|
||||||
|
@provisioned_this_round << node.id if node
|
||||||
|
node
|
||||||
|
end
|
||||||
|
|
||||||
|
def safe_scale_in!(node)
|
||||||
|
@provisioner.drain!(node) unless node.status == "draining"
|
||||||
|
node.reload
|
||||||
|
raise NodeProvisioner::BusyError, "sessioni ancora attive" if node.occupying_sessions.exists?
|
||||||
|
raise NodeProvisioner::Error, "idle insufficiente" unless idle_long_enough?(node)
|
||||||
|
|
||||||
|
@provisioner.decommission!(node)
|
||||||
|
end
|
||||||
|
|
||||||
|
def scale_in_candidates
|
||||||
|
StreamNode.where.not(slug: NodeRegistry::HOME_SLUG)
|
||||||
|
.where(status: %w[ready draining])
|
||||||
|
.order(:created_at)
|
||||||
|
.select { |n| n.active_publishers.zero? && idle_long_enough?(n) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def idle_long_enough?(node)
|
||||||
|
idle_since(node) <= self.class.idle_minutes.minutes.ago
|
||||||
|
end
|
||||||
|
|
||||||
|
def idle_since(node)
|
||||||
|
last_end = node.stream_sessions.where(status: %w[ended error]).maximum(:ended_at)
|
||||||
|
last_end || node.created_at
|
||||||
|
end
|
||||||
|
|
||||||
|
def keep_as_warm_spare?(node)
|
||||||
|
return false unless warm_spare_desired?(self.class.metrics)
|
||||||
|
|
||||||
|
spares = StreamNode.ready.where.not(slug: NodeRegistry::HOME_SLUG).select { |n| n.active_publishers.zero? }
|
||||||
|
spares.size <= self.class.warm_spare_min && spares.map(&:id).include?(node.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Streams
|
||||||
|
module CloudProviders
|
||||||
|
def self.build(name = ENV.fetch("STREAM_CLOUD_PROVIDER", "local_lab"))
|
||||||
|
case name.to_s
|
||||||
|
when "local_lab" then LocalLab.new
|
||||||
|
when "proxmox_lab" then ProxmoxLab.new
|
||||||
|
when "hetzner" then Hetzner.new
|
||||||
|
else
|
||||||
|
raise Error, "STREAM_CLOUD_PROVIDER sconosciuto: #{name}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Streams
|
||||||
|
module CloudProviders
|
||||||
|
class Error < StandardError; end
|
||||||
|
|
||||||
|
# Descrittore restituito da create_node / list.
|
||||||
|
Instance = Struct.new(
|
||||||
|
:id, :name, :public_ip, :private_ip, :status, :raw,
|
||||||
|
keyword_init: true
|
||||||
|
)
|
||||||
|
|
||||||
|
class Base
|
||||||
|
def create_node(name:, labels: {})
|
||||||
|
raise NotImplementedError
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy_node(instance_id)
|
||||||
|
raise NotImplementedError
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_nodes(labels: {})
|
||||||
|
raise NotImplementedError
|
||||||
|
end
|
||||||
|
|
||||||
|
def wait_until_running(instance_id, timeout: 120)
|
||||||
|
raise NotImplementedError
|
||||||
|
end
|
||||||
|
|
||||||
|
def public_ip(instance_id)
|
||||||
|
raise NotImplementedError
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "faraday"
|
||||||
|
|
||||||
|
module Streams
|
||||||
|
module CloudProviders
|
||||||
|
# Hetzner Cloud — create/destroy server per nodi stream.
|
||||||
|
#
|
||||||
|
# ENV:
|
||||||
|
# HCLOUD_TOKEN (obbligatorio)
|
||||||
|
# HCLOUD_LOCATION (default fsn1)
|
||||||
|
# HCLOUD_SERVER_TYPE (default cpx21)
|
||||||
|
# HCLOUD_IMAGE (default debian-12)
|
||||||
|
# HCLOUD_SSH_KEY (nome chiave, default matchlivetv-stream)
|
||||||
|
# HCLOUD_NETWORK_ID (opzionale, private network / WireGuard prep)
|
||||||
|
# HCLOUD_USER_DATA_FILE (opzionale, cloud-init path)
|
||||||
|
class Hetzner < Base
|
||||||
|
# Trailing slash obbligatorio: path assoluti tipo "/servers" altrimenti droppano /v1.
|
||||||
|
API = "https://api.hetzner.cloud/v1/"
|
||||||
|
|
||||||
|
def initialize(token: ENV.fetch("HCLOUD_TOKEN"), conn: nil)
|
||||||
|
@token = token
|
||||||
|
@conn = conn
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_node(name:, labels: {})
|
||||||
|
body = {
|
||||||
|
name: name,
|
||||||
|
server_type: ENV.fetch("HCLOUD_SERVER_TYPE", "cpx12"),
|
||||||
|
image: ENV.fetch("HCLOUD_IMAGE", "debian-12"),
|
||||||
|
location: ENV.fetch("HCLOUD_LOCATION", "nbg1"),
|
||||||
|
start_after_create: true,
|
||||||
|
labels: default_labels.merge(stringify_labels(labels)),
|
||||||
|
ssh_keys: [ENV.fetch("HCLOUD_SSH_KEY", "matchlivetv-stream-hetzner")],
|
||||||
|
public_net: {
|
||||||
|
enable_ipv4: true,
|
||||||
|
enable_ipv6: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
network_id = ENV["HCLOUD_NETWORK_ID"].presence
|
||||||
|
body[:networks] = [network_id.to_i] if network_id
|
||||||
|
user_data = cloud_init_user_data
|
||||||
|
body[:user_data] = user_data
|
||||||
|
|
||||||
|
data = post("servers", body)
|
||||||
|
server = data["server"] || {}
|
||||||
|
action = data["action"]
|
||||||
|
wait_action!(action) if action
|
||||||
|
instance = wait_until_running(server["id"].to_s)
|
||||||
|
instance.name = name
|
||||||
|
instance
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy_node(instance_id)
|
||||||
|
delete("servers/#{instance_id}")
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_nodes(labels: {})
|
||||||
|
params = {}
|
||||||
|
label_selector = labels.map { |k, v| "#{k}=#{v}" }.join(",")
|
||||||
|
params[:label_selector] = label_selector if label_selector.present?
|
||||||
|
params[:label_selector] ||= "matchlivetv=true,role=stream-node"
|
||||||
|
|
||||||
|
data = get("servers", params)
|
||||||
|
Array(data["servers"]).map { |s| instance_from_server(s) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def wait_until_running(instance_id, timeout: 180)
|
||||||
|
deadline = Time.now + timeout
|
||||||
|
loop do
|
||||||
|
data = get("servers/#{instance_id}")
|
||||||
|
server = data["server"]
|
||||||
|
status = server["status"]
|
||||||
|
if status == "running"
|
||||||
|
return instance_from_server(server)
|
||||||
|
end
|
||||||
|
raise Error, "Timeout attesa server Hetzner #{instance_id} (status=#{status})" if Time.now >= deadline
|
||||||
|
|
||||||
|
sleep 3
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def public_ip(instance_id)
|
||||||
|
wait_until_running(instance_id).public_ip
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def default_labels
|
||||||
|
{
|
||||||
|
"matchlivetv" => "true",
|
||||||
|
"role" => "stream-node",
|
||||||
|
"env" => ENV.fetch("STREAM_NODE_ENV", "prod")
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def stringify_labels(labels)
|
||||||
|
labels.to_h.transform_keys(&:to_s).transform_values(&:to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
def instance_from_server(server)
|
||||||
|
public_ip = server.dig("public_net", "ipv4", "ip")
|
||||||
|
private_ip = Array(server["private_net"]).first&.dig("ip")
|
||||||
|
Instance.new(
|
||||||
|
id: server["id"].to_s,
|
||||||
|
name: server["name"],
|
||||||
|
public_ip: public_ip,
|
||||||
|
private_ip: private_ip.presence || public_ip,
|
||||||
|
status: server["status"],
|
||||||
|
raw: server
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def cloud_init_user_data
|
||||||
|
path = ENV["HCLOUD_USER_DATA_FILE"].presence
|
||||||
|
if path.present?
|
||||||
|
raise Error, "HCLOUD_USER_DATA_FILE non leggibile nel container: #{path}" unless File.file?(path)
|
||||||
|
|
||||||
|
return File.read(path)
|
||||||
|
end
|
||||||
|
|
||||||
|
inline = ENV["HCLOUD_USER_DATA"].presence
|
||||||
|
raise Error, "Manca cloud-init: imposta HCLOUD_USER_DATA_FILE (montato) o HCLOUD_USER_DATA" if inline.blank?
|
||||||
|
|
||||||
|
inline
|
||||||
|
end
|
||||||
|
|
||||||
|
def conn
|
||||||
|
@conn ||= Faraday.new(url: API) do |f|
|
||||||
|
f.request :json
|
||||||
|
f.response :json, content_type: /\bjson$/
|
||||||
|
f.adapter Faraday.default_adapter
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def auth_headers
|
||||||
|
{ "Authorization" => "Bearer #{@token}" }
|
||||||
|
end
|
||||||
|
|
||||||
|
def get(path, params = {})
|
||||||
|
tries = 0
|
||||||
|
begin
|
||||||
|
response = conn.get(path) do |req|
|
||||||
|
req.headers.update(auth_headers)
|
||||||
|
req.params.update(params)
|
||||||
|
end
|
||||||
|
unwrap!(response)
|
||||||
|
rescue Faraday::SSLError, Faraday::ConnectionFailed, Faraday::TimeoutError
|
||||||
|
tries += 1
|
||||||
|
raise if tries >= 5
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
retry
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def post(path, body)
|
||||||
|
response = conn.post(path) do |req|
|
||||||
|
req.headers.update(auth_headers)
|
||||||
|
req.body = body
|
||||||
|
end
|
||||||
|
unwrap!(response)
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete(path)
|
||||||
|
response = conn.delete(path) do |req|
|
||||||
|
req.headers.update(auth_headers)
|
||||||
|
end
|
||||||
|
return {} if response.status == 204
|
||||||
|
|
||||||
|
unwrap!(response)
|
||||||
|
end
|
||||||
|
|
||||||
|
def unwrap!(response)
|
||||||
|
unless response.success?
|
||||||
|
raise Error, "Hetzner Cloud API #{response.status}: #{response.body.inspect}"
|
||||||
|
end
|
||||||
|
|
||||||
|
response.body.is_a?(Hash) ? response.body : {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def wait_action!(action, timeout: 120)
|
||||||
|
return unless action.is_a?(Hash) && action["id"]
|
||||||
|
|
||||||
|
deadline = Time.now + timeout
|
||||||
|
id = action["id"]
|
||||||
|
loop do
|
||||||
|
data = get("actions/#{id}")
|
||||||
|
status = data.dig("action", "status")
|
||||||
|
return if status == "success"
|
||||||
|
raise Error, "Hetzner action #{id} failed: #{data.inspect}" if status == "error"
|
||||||
|
raise Error, "Timeout action Hetzner #{id}" if Time.now >= deadline
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Streams
|
||||||
|
module CloudProviders
|
||||||
|
# Lab senza API Proxmox: simula create/destroy e riusa MediaMTX home per i path.
|
||||||
|
# Utile per testare registry, assignment e admin senza secondi host.
|
||||||
|
class LocalLab < Base
|
||||||
|
def create_node(name:, labels: {})
|
||||||
|
Instance.new(
|
||||||
|
id: "sim-#{name}",
|
||||||
|
name: name,
|
||||||
|
public_ip: labels[:public_ip].presence || "127.0.0.1",
|
||||||
|
private_ip: labels[:private_ip].presence || "127.0.0.1",
|
||||||
|
status: "running",
|
||||||
|
raw: { simulated: true, labels: labels }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy_node(instance_id)
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_nodes(labels: {})
|
||||||
|
StreamNode.where(provider: "local", role: "lab").map do |node|
|
||||||
|
Instance.new(
|
||||||
|
id: node.provider_instance_id,
|
||||||
|
name: node.slug,
|
||||||
|
public_ip: node.metadata["public_ip"],
|
||||||
|
private_ip: node.metadata["private_ip"],
|
||||||
|
status: node.status == "ready" ? "running" : node.status,
|
||||||
|
raw: node.metadata
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def wait_until_running(instance_id, timeout: 120)
|
||||||
|
Instance.new(id: instance_id, name: instance_id, status: "running")
|
||||||
|
end
|
||||||
|
|
||||||
|
def public_ip(instance_id)
|
||||||
|
"127.0.0.1"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "faraday"
|
||||||
|
|
||||||
|
module Streams
|
||||||
|
module CloudProviders
|
||||||
|
# Clone/start/stop di VM template su Proxmox VE (API token).
|
||||||
|
#
|
||||||
|
# ENV richiesti:
|
||||||
|
# PROXMOX_API_URL, PROXMOX_TOKEN_ID, PROXMOX_TOKEN_SECRET,
|
||||||
|
# PROXMOX_NODE, PROXMOX_TEMPLATE_VMID
|
||||||
|
class ProxmoxLab < Base
|
||||||
|
def initialize(
|
||||||
|
api_url: ENV.fetch("PROXMOX_API_URL"),
|
||||||
|
token_id: ENV.fetch("PROXMOX_TOKEN_ID"),
|
||||||
|
token_secret: ENV.fetch("PROXMOX_TOKEN_SECRET"),
|
||||||
|
node: ENV.fetch("PROXMOX_NODE"),
|
||||||
|
template_vmid: ENV.fetch("PROXMOX_TEMPLATE_VMID"),
|
||||||
|
verify_ssl: ENV.fetch("PROXMOX_VERIFY_SSL", "false") == "true"
|
||||||
|
)
|
||||||
|
@api_url = api_url.to_s.chomp("/")
|
||||||
|
@token_id = token_id
|
||||||
|
@token_secret = token_secret
|
||||||
|
@node = node
|
||||||
|
@template_vmid = template_vmid.to_i
|
||||||
|
@verify_ssl = verify_ssl
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_node(name:, labels: {})
|
||||||
|
newid = next_vmid
|
||||||
|
post("/nodes/#{@node}/qemu/#{@template_vmid}/clone", {
|
||||||
|
newid: newid,
|
||||||
|
name: name,
|
||||||
|
full: 1,
|
||||||
|
target: @node
|
||||||
|
})
|
||||||
|
post("/nodes/#{@node}/qemu/#{newid}/status/start", {})
|
||||||
|
wait_until_running(newid.to_s)
|
||||||
|
ip = public_ip(newid.to_s)
|
||||||
|
Instance.new(
|
||||||
|
id: newid.to_s,
|
||||||
|
name: name,
|
||||||
|
public_ip: ip,
|
||||||
|
private_ip: ip,
|
||||||
|
status: "running",
|
||||||
|
raw: { node: @node, vmid: newid, labels: labels }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy_node(instance_id)
|
||||||
|
vmid = instance_id.to_i
|
||||||
|
begin
|
||||||
|
post("/nodes/#{@node}/qemu/#{vmid}/status/stop", { timeout: 30 })
|
||||||
|
rescue Error
|
||||||
|
# già spenta
|
||||||
|
end
|
||||||
|
sleep 2
|
||||||
|
delete("/nodes/#{@node}/qemu/#{vmid}", { purge: 1 })
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_nodes(labels: {})
|
||||||
|
items = get("/nodes/#{@node}/qemu")
|
||||||
|
Array(items).filter_map do |row|
|
||||||
|
name = row["name"].to_s
|
||||||
|
next unless name.start_with?("mltv-stream-") || name.start_with?("ingest-")
|
||||||
|
|
||||||
|
Instance.new(
|
||||||
|
id: row["vmid"].to_s,
|
||||||
|
name: name,
|
||||||
|
public_ip: nil,
|
||||||
|
private_ip: nil,
|
||||||
|
status: row["status"],
|
||||||
|
raw: row
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def wait_until_running(instance_id, timeout: 180)
|
||||||
|
deadline = Time.now + timeout
|
||||||
|
loop do
|
||||||
|
status = get("/nodes/#{@node}/qemu/#{instance_id}/status/current")
|
||||||
|
return Instance.new(id: instance_id.to_s, status: "running", raw: status) if status["status"] == "running"
|
||||||
|
raise Error, "Timeout attesa VM #{instance_id}" if Time.now >= deadline
|
||||||
|
|
||||||
|
sleep 3
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def public_ip(instance_id)
|
||||||
|
agent = get("/nodes/#{@node}/qemu/#{instance_id}/agent/network-get-interfaces")
|
||||||
|
interfaces = agent.is_a?(Hash) ? agent["result"] : nil
|
||||||
|
Array(interfaces).each do |iface|
|
||||||
|
Array(iface["ip-addresses"]).each do |addr|
|
||||||
|
ip = addr["ip-address"].to_s
|
||||||
|
next if ip.blank? || ip.start_with?("127.") || ip.include?(":")
|
||||||
|
|
||||||
|
return ip
|
||||||
|
end
|
||||||
|
end
|
||||||
|
ENV["STREAM_LAB_FALLBACK_IP"].presence || "127.0.0.1"
|
||||||
|
rescue Error
|
||||||
|
ENV["STREAM_LAB_FALLBACK_IP"].presence || "127.0.0.1"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def next_vmid
|
||||||
|
used = Array(get("/cluster/resources", type: "vm")).map { |r| r["vmid"].to_i }
|
||||||
|
candidate = ENV.fetch("PROXMOX_VMID_START", "9100").to_i
|
||||||
|
candidate += 1 while used.include?(candidate)
|
||||||
|
candidate
|
||||||
|
end
|
||||||
|
|
||||||
|
def conn
|
||||||
|
@conn ||= Faraday.new(url: "#{@api_url}/api2/json") do |f|
|
||||||
|
f.request :url_encoded
|
||||||
|
f.response :json, content_type: /\bjson$/
|
||||||
|
f.adapter Faraday.default_adapter
|
||||||
|
f.ssl[:verify] = @verify_ssl
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def auth_headers
|
||||||
|
{ "Authorization" => "PVEAPIToken=#{@token_id}=#{@token_secret}" }
|
||||||
|
end
|
||||||
|
|
||||||
|
def get(path, params = {})
|
||||||
|
response = conn.get(path) do |req|
|
||||||
|
req.headers.update(auth_headers)
|
||||||
|
req.params.update(params)
|
||||||
|
end
|
||||||
|
unwrap!(response)
|
||||||
|
end
|
||||||
|
|
||||||
|
def post(path, body = {})
|
||||||
|
response = conn.post(path) do |req|
|
||||||
|
req.headers.update(auth_headers)
|
||||||
|
req.body = body
|
||||||
|
end
|
||||||
|
unwrap!(response)
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete(path, params = {})
|
||||||
|
response = conn.delete(path) do |req|
|
||||||
|
req.headers.update(auth_headers)
|
||||||
|
req.params.update(params)
|
||||||
|
end
|
||||||
|
unwrap!(response)
|
||||||
|
end
|
||||||
|
|
||||||
|
def unwrap!(response)
|
||||||
|
unless response.success?
|
||||||
|
raise Error, "Proxmox API #{response.status}: #{response.body.inspect}"
|
||||||
|
end
|
||||||
|
|
||||||
|
body = response.body
|
||||||
|
body.is_a?(Hash) && body.key?("data") ? body["data"] : body
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Streams
|
||||||
|
module DnsProviders
|
||||||
|
def self.build(name = ENV.fetch("STREAM_DNS_PROVIDER", "lab"))
|
||||||
|
case name.to_s
|
||||||
|
when "lab" then Lab.new
|
||||||
|
when "hetzner" then Hetzner.new
|
||||||
|
else
|
||||||
|
raise Error, "STREAM_DNS_PROVIDER sconosciuto: #{name}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Streams
|
||||||
|
module DnsProviders
|
||||||
|
class Error < StandardError; end
|
||||||
|
|
||||||
|
class Base
|
||||||
|
def upsert_a(name, ip)
|
||||||
|
raise NotImplementedError
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_a(name)
|
||||||
|
raise NotImplementedError
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve(name)
|
||||||
|
raise NotImplementedError
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "faraday"
|
||||||
|
require "cgi"
|
||||||
|
|
||||||
|
module Streams
|
||||||
|
module DnsProviders
|
||||||
|
# Hetzner Cloud DNS (Console) — zone mltv-stream.net.
|
||||||
|
#
|
||||||
|
# ENV:
|
||||||
|
# HCLOUD_TOKEN (stesso del Cloud)
|
||||||
|
# STREAM_DNS_ZONE (default mltv-stream.net)
|
||||||
|
# STREAM_DNS_TTL (default 60)
|
||||||
|
class Hetzner < Base
|
||||||
|
# Trailing slash obbligatorio: path assoluti altrimenti droppano /v1.
|
||||||
|
API = "https://api.hetzner.cloud/v1/"
|
||||||
|
|
||||||
|
def initialize(token: ENV.fetch("HCLOUD_TOKEN"), zone: nil, conn: nil)
|
||||||
|
@token = token
|
||||||
|
@zone = zone || ENV.fetch("STREAM_DNS_ZONE", "mltv-stream.net")
|
||||||
|
@ttl = ENV.fetch("STREAM_DNS_TTL", "60").to_i
|
||||||
|
@conn = conn
|
||||||
|
end
|
||||||
|
|
||||||
|
def upsert_a(name, ip)
|
||||||
|
rr_name = relative_name(name)
|
||||||
|
delete_a(name)
|
||||||
|
post("zones/#{CGI.escape(@zone)}/rrsets", {
|
||||||
|
name: rr_name,
|
||||||
|
type: "A",
|
||||||
|
ttl: @ttl,
|
||||||
|
records: [{ value: ip.to_s, comment: "matchlivetv stream-node" }],
|
||||||
|
labels: { "matchlivetv" => "true", "role" => "stream-node" }
|
||||||
|
})
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_a(name)
|
||||||
|
rr_name = relative_name(name)
|
||||||
|
encoded = CGI.escape(rr_name)
|
||||||
|
response = conn.delete("zones/#{CGI.escape(@zone)}/rrsets/#{encoded}/A") do |req|
|
||||||
|
req.headers.update(auth_headers)
|
||||||
|
end
|
||||||
|
return true if response.status == 404 || response.status == 204 || response.success?
|
||||||
|
|
||||||
|
raise Error, "Hetzner DNS API #{response.status}: #{response.body.inspect}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve(name)
|
||||||
|
rr_name = relative_name(name)
|
||||||
|
data = get("zones/#{CGI.escape(@zone)}/rrsets", name: rr_name, type: "A")
|
||||||
|
rrset = Array(data["rrsets"]).first
|
||||||
|
Array(rrset&.dig("records")).first&.dig("value")
|
||||||
|
rescue Error
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def relative_name(name)
|
||||||
|
host = name.to_s.strip.downcase.delete_suffix(".")
|
||||||
|
suffix = ".#{@zone}"
|
||||||
|
return "@" if host == @zone
|
||||||
|
return host.delete_suffix(suffix) if host.end_with?(suffix)
|
||||||
|
|
||||||
|
host
|
||||||
|
end
|
||||||
|
|
||||||
|
def conn
|
||||||
|
@conn ||= Faraday.new(url: API) do |f|
|
||||||
|
f.request :json
|
||||||
|
f.response :json, content_type: /\bjson$/
|
||||||
|
f.adapter Faraday.default_adapter
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def auth_headers
|
||||||
|
{ "Authorization" => "Bearer #{@token}" }
|
||||||
|
end
|
||||||
|
|
||||||
|
def get(path, params = {})
|
||||||
|
response = conn.get(path) do |req|
|
||||||
|
req.headers.update(auth_headers)
|
||||||
|
req.params.update(params)
|
||||||
|
end
|
||||||
|
unwrap!(response)
|
||||||
|
end
|
||||||
|
|
||||||
|
def post(path, body)
|
||||||
|
response = conn.post(path) do |req|
|
||||||
|
req.headers.update(auth_headers)
|
||||||
|
req.body = body
|
||||||
|
end
|
||||||
|
unwrap!(response)
|
||||||
|
end
|
||||||
|
|
||||||
|
def unwrap!(response)
|
||||||
|
unless response.success?
|
||||||
|
raise Error, "Hetzner DNS API #{response.status}: #{response.body.inspect}"
|
||||||
|
end
|
||||||
|
|
||||||
|
response.body.is_a?(Hash) ? response.body : {}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Streams
|
||||||
|
module DnsProviders
|
||||||
|
# DNS lab in Redis (e dump hosts). Nessuna chiamata al registrar.
|
||||||
|
class Lab < Base
|
||||||
|
REDIS_KEY = "stream_dns:a_records"
|
||||||
|
|
||||||
|
def initialize(redis: nil)
|
||||||
|
@redis = redis
|
||||||
|
end
|
||||||
|
|
||||||
|
def upsert_a(name, ip)
|
||||||
|
host = normalize(name)
|
||||||
|
redis.hset(REDIS_KEY, host, ip.to_s)
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_a(name)
|
||||||
|
redis.hdel(REDIS_KEY, normalize(name))
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve(name)
|
||||||
|
redis.hget(REDIS_KEY, normalize(name))
|
||||||
|
end
|
||||||
|
|
||||||
|
def all_records
|
||||||
|
redis.hgetall(REDIS_KEY)
|
||||||
|
end
|
||||||
|
|
||||||
|
def hosts_file_snippet
|
||||||
|
all_records.sort.map { |host, ip| "#{ip}\t#{host}" }.join("\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def normalize(name)
|
||||||
|
name.to_s.strip.downcase.delete_suffix(".")
|
||||||
|
end
|
||||||
|
|
||||||
|
def redis
|
||||||
|
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Streams
|
||||||
|
# Provisiona / decommissiona nodi stream (lab o cloud) e aggiorna DNS + registry.
|
||||||
|
class NodeProvisioner
|
||||||
|
class Error < StandardError; end
|
||||||
|
class BusyError < Error; end
|
||||||
|
|
||||||
|
LAB_DNS_SUFFIX = -> { ENV.fetch("STREAM_LAB_DNS_SUFFIX", "lab.mltv-stream.net") }
|
||||||
|
CLOUD_DNS_SUFFIX = -> { ENV.fetch("STREAM_CLOUD_DNS_SUFFIX", ENV.fetch("STREAM_DNS_ZONE", "mltv-stream.net")) }
|
||||||
|
|
||||||
|
def initialize(cloud: nil, dns: nil)
|
||||||
|
@cloud = cloud
|
||||||
|
@dns = dns
|
||||||
|
end
|
||||||
|
|
||||||
|
def provision_lab!(prefix: "ingest-lab")
|
||||||
|
provision!(
|
||||||
|
prefix: prefix,
|
||||||
|
role: "lab",
|
||||||
|
dns_suffix: LAB_DNS_SUFFIX.call,
|
||||||
|
cloud: cloud_provider(ENV.fetch("STREAM_CLOUD_PROVIDER", "local_lab")),
|
||||||
|
dns: dns_provider(ENV.fetch("STREAM_DNS_PROVIDER", "lab")),
|
||||||
|
max: ENV.fetch("STREAM_LAB_MAX_PUBLISHERS", "2").to_i,
|
||||||
|
use_node_hostname: ENV["STREAM_LAB_USE_NODE_HOSTNAME"] == "1"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def provision_cloud!(prefix: "ingest")
|
||||||
|
provision!(
|
||||||
|
prefix: prefix,
|
||||||
|
role: "cloud",
|
||||||
|
dns_suffix: CLOUD_DNS_SUFFIX.call,
|
||||||
|
cloud: cloud_provider("hetzner"),
|
||||||
|
dns: dns_provider("hetzner"),
|
||||||
|
max: ENV.fetch("STREAM_CLOUD_MAX_PUBLISHERS", "4").to_i,
|
||||||
|
use_node_hostname: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def decommission!(node)
|
||||||
|
raise Error, "Non si può decommissionare il nodo home" if node.slug == Streams::NodeRegistry::HOME_SLUG
|
||||||
|
if node.occupying_sessions.exists?
|
||||||
|
raise BusyError, "Nodo #{node.slug} ha ancora sessioni attive"
|
||||||
|
end
|
||||||
|
|
||||||
|
node.update!(status: "draining")
|
||||||
|
cloud = cloud_for_node(node)
|
||||||
|
dns = dns_for_node(node)
|
||||||
|
cloud.destroy_node(node.provider_instance_id) if node.provider_instance_id.present?
|
||||||
|
dns.delete_a(node.hostname) if node.hostname.present?
|
||||||
|
node.destroy!
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def drain!(node)
|
||||||
|
raise Error, "Non si può mettere in drain il nodo home" if node.slug == Streams::NodeRegistry::HOME_SLUG
|
||||||
|
|
||||||
|
node.update!(status: "draining")
|
||||||
|
node
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def provision!(prefix:, role:, dns_suffix:, cloud:, dns:, max:, use_node_hostname:)
|
||||||
|
Streams::NodeRegistry.ensure_home_from_env!
|
||||||
|
home = StreamNode.find_by!(slug: Streams::NodeRegistry::HOME_SLUG)
|
||||||
|
slug = next_slug(prefix)
|
||||||
|
hostname = "#{slug}.#{dns_suffix}"
|
||||||
|
|
||||||
|
instance = cloud.create_node(
|
||||||
|
name: "mltv-stream-#{slug}",
|
||||||
|
labels: { role: "stream-node", env: role == "cloud" ? "prod" : "lab" }
|
||||||
|
)
|
||||||
|
ip = instance.public_ip.presence || "127.0.0.1"
|
||||||
|
private_ip = instance.private_ip.presence || ip
|
||||||
|
dns.upsert_a(hostname, ip)
|
||||||
|
|
||||||
|
simulated = instance.raw.is_a?(Hash) && (instance.raw[:simulated] || instance.raw["simulated"])
|
||||||
|
urls = urls_for_node(role: role, home: home, hostname: hostname, simulated: simulated,
|
||||||
|
private_ip: private_ip, public_ip: ip, use_node_hostname: use_node_hostname)
|
||||||
|
|
||||||
|
StreamNode.create!(
|
||||||
|
slug: slug,
|
||||||
|
hostname: hostname,
|
||||||
|
role: role,
|
||||||
|
status: "ready",
|
||||||
|
provider: provider_name_for(cloud, role: role),
|
||||||
|
provider_instance_id: instance.id,
|
||||||
|
rtmp_base_url: urls.fetch(:rtmp_base_url),
|
||||||
|
hls_base_url: urls.fetch(:hls_base_url),
|
||||||
|
api_base_url: urls.fetch(:api_base_url),
|
||||||
|
internal_rtmp_url: urls.fetch(:internal_rtmp_url),
|
||||||
|
internal_hls_url: urls.fetch(:internal_hls_url),
|
||||||
|
max_publishers: max,
|
||||||
|
max_relays: max,
|
||||||
|
last_health_at: Time.current,
|
||||||
|
metadata: {
|
||||||
|
"public_ip" => ip,
|
||||||
|
"private_ip" => private_ip,
|
||||||
|
"simulated" => simulated,
|
||||||
|
"cloud_raw" => instance.raw
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def urls_for_node(role:, home:, hostname:, simulated:, private_ip:, public_ip: nil, use_node_hostname:)
|
||||||
|
if role == "lab" && ENV["MEDIAMTX_LAB_API_URL"].present?
|
||||||
|
return {
|
||||||
|
api_base_url: ENV.fetch("MEDIAMTX_LAB_API_URL"),
|
||||||
|
internal_rtmp_url: ENV.fetch("MEDIAMTX_LAB_INTERNAL_RTMP_URL", "rtmp://mediamtx_lab:1935"),
|
||||||
|
internal_hls_url: ENV.fetch("MEDIAMTX_LAB_HLS_URL", "http://mediamtx_lab:8888"),
|
||||||
|
rtmp_base_url: ENV.fetch("MEDIAMTX_LAB_RTMP_URL", "rtmp://127.0.0.1:11935"),
|
||||||
|
# HLS pubblico resta sul proxy del sito: il controller instrada al MediaMTX del nodo.
|
||||||
|
hls_base_url: home.hls_base_url
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
control_ip = control_ip_for(role: role, private_ip: private_ip, public_ip: public_ip)
|
||||||
|
{
|
||||||
|
api_base_url: simulated ? home.api_base_url : "http://#{control_ip}:9997",
|
||||||
|
internal_rtmp_url: simulated ? home.internal_rtmp_url : "rtmp://#{control_ip}:1935",
|
||||||
|
internal_hls_url: simulated ? home.internal_hls_url : "http://#{control_ip}:8888",
|
||||||
|
rtmp_base_url: use_node_hostname ? "rtmp://#{hostname}:1935" : home.rtmp_base_url,
|
||||||
|
# Player del sito: proxy Rails/edge. HTTPS diretto sul nodo arriva dopo TLS/Caddy.
|
||||||
|
hls_base_url: home.hls_base_url
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Senza WireGuard/network privata Rails deve parlare con l'IPv4 pubblico (API+HLS).
|
||||||
|
def control_ip_for(role:, private_ip:, public_ip:)
|
||||||
|
public_ip = public_ip.presence
|
||||||
|
return private_ip if role != "cloud" || public_ip.blank?
|
||||||
|
return public_ip if ENV["STREAM_CLOUD_PUBLIC_CONTROL"] == "1"
|
||||||
|
return public_ip if private_ip.blank? || private_ip == public_ip
|
||||||
|
|
||||||
|
private_ip
|
||||||
|
end
|
||||||
|
|
||||||
|
def next_slug(prefix)
|
||||||
|
used = StreamNode.where("slug LIKE ?", "#{prefix}-%").pluck(:slug)
|
||||||
|
n = 1
|
||||||
|
loop do
|
||||||
|
candidate = format("%s-%02d", prefix, n)
|
||||||
|
return candidate unless used.include?(candidate)
|
||||||
|
|
||||||
|
n += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def cloud_provider(name)
|
||||||
|
@cloud || Streams::CloudProviders.build(name)
|
||||||
|
end
|
||||||
|
|
||||||
|
def dns_provider(name)
|
||||||
|
@dns || Streams::DnsProviders.build(name)
|
||||||
|
end
|
||||||
|
|
||||||
|
def provider_name_for(cloud, role: nil)
|
||||||
|
return "hetzner" if role.to_s == "cloud"
|
||||||
|
|
||||||
|
case cloud
|
||||||
|
when Streams::CloudProviders::ProxmoxLab then "proxmox_lab"
|
||||||
|
when Streams::CloudProviders::Hetzner then "hetzner"
|
||||||
|
else "local"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def cloud_for_node(node)
|
||||||
|
case node.provider
|
||||||
|
when "hetzner" then Streams::CloudProviders::Hetzner.new
|
||||||
|
when "proxmox_lab" then Streams::CloudProviders::ProxmoxLab.new
|
||||||
|
else Streams::CloudProviders::LocalLab.new
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def dns_for_node(node)
|
||||||
|
case node.provider
|
||||||
|
when "hetzner" then Streams::DnsProviders::Hetzner.new
|
||||||
|
else Streams::DnsProviders::Lab.new
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Streams
|
||||||
|
# Assegna un StreamNode a una nuova sessione.
|
||||||
|
# Preferisce home finché ha slot; poi least-loaded tra i nodi overflow ready.
|
||||||
|
# Garantisce il nodo "home" derivato dagli ENV MediaMTX attuali.
|
||||||
|
class NodeRegistry
|
||||||
|
class NoCapacityError < StandardError; end
|
||||||
|
|
||||||
|
HOME_SLUG = "home"
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def ensure_home_from_env!
|
||||||
|
StreamNode.find_or_initialize_by(slug: HOME_SLUG).tap do |node|
|
||||||
|
attrs = {
|
||||||
|
hostname: home_hostname,
|
||||||
|
role: "home",
|
||||||
|
provider: "local",
|
||||||
|
rtmp_base_url: MatchLiveTv.mediamtx_rtmp_url,
|
||||||
|
hls_base_url: MatchLiveTv.hls_public_url,
|
||||||
|
api_base_url: MatchLiveTv.mediamtx_api_url,
|
||||||
|
internal_rtmp_url: ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935"),
|
||||||
|
internal_hls_url: ENV.fetch("MEDIAMTX_HLS_URL", "http://mediamtx:8888"),
|
||||||
|
max_publishers: ENV.fetch("STREAM_NODE_HOME_MAX_PUBLISHERS", "6").to_i,
|
||||||
|
max_relays: ENV.fetch("STREAM_NODE_HOME_MAX_RELAYS", "6").to_i
|
||||||
|
}
|
||||||
|
# Non sovrascrivere drain/offline/error (ops) a ogni allocate!
|
||||||
|
attrs[:status] = "ready" if node.new_record? || !%w[draining offline error].include?(node.status)
|
||||||
|
node.assign_attributes(attrs)
|
||||||
|
node.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def allocate!
|
||||||
|
ensure_home_from_env!
|
||||||
|
|
||||||
|
# Overflow: riempi prima home; i nodi cloud/lab sono solo quando home è pieno
|
||||||
|
# (altrimenti lo warm spare ruberebbe tutte le sessioni).
|
||||||
|
home = StreamNode.find_by(slug: HOME_SLUG)
|
||||||
|
return home if home&.allocatable?
|
||||||
|
|
||||||
|
node = StreamNode.ready
|
||||||
|
.where.not(slug: HOME_SLUG)
|
||||||
|
.to_a
|
||||||
|
.select(&:allocatable?)
|
||||||
|
.min_by { |n| [n.active_publishers, n.slug] }
|
||||||
|
|
||||||
|
raise NoCapacityError, "Nessun nodo streaming con slot liberi" if node.nil?
|
||||||
|
|
||||||
|
node
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def home_hostname
|
||||||
|
ENV["STREAM_NODE_HOME_HOSTNAME"].presence ||
|
||||||
|
begin
|
||||||
|
uri = URI.parse(MatchLiveTv.mediamtx_rtmp_url.sub(/\Artmps?:\/\//, "http://"))
|
||||||
|
uri.host.presence
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
nil
|
||||||
|
end || "home"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,29 +1,66 @@
|
|||||||
|
require "json"
|
||||||
|
require "net/http"
|
||||||
|
require "uri"
|
||||||
|
|
||||||
module Streams
|
module Streams
|
||||||
# Relay verso YouTube: legge RTMP/HLS da MediaMTX e inoltra su RTMPS (-c copy). Nessun overlay.
|
# Relay verso YouTube: legge HLS da MediaMTX e inoltra su RTMPS (-c copy).
|
||||||
# ffmpeg gira solo nel container sidekiq (YOUTUBE_RELAY_WORKER=1).
|
# Sul nodo cloud ffmpeg è avviato dall'agent locale (immagine CPX) all'avvio diretta.
|
||||||
class YoutubeRelay
|
class YoutubeRelay
|
||||||
class Error < StandardError; end
|
class Error < StandardError; end
|
||||||
|
|
||||||
REDIS_KEY = "youtube_relay:pid:%s"
|
REDIS_KEY = "youtube_relay:pid:%s"
|
||||||
OWNER_KEY = "youtube_relay:owner:%s"
|
OWNER_KEY = "youtube_relay:owner:%s"
|
||||||
|
OWNED_SET = "youtube_relay:owned:%s"
|
||||||
|
QUEUE = :youtube_relay
|
||||||
|
QUEUE_PREFIX = "youtube_relay"
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def worker?
|
def worker?
|
||||||
ENV["YOUTUBE_RELAY_WORKER"] == "1"
|
ENV["YOUTUBE_RELAY_WORKER"] == "1"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def max_concurrent
|
||||||
|
ENV.fetch("RELAY_MAX_CONCURRENT", "4").to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def local_node_slug
|
||||||
|
ENV["STREAM_NODE_SLUG"].presence || NodeRegistry::HOME_SLUG
|
||||||
|
end
|
||||||
|
|
||||||
|
def assigned_node_slug(session)
|
||||||
|
session.stream_node&.slug.presence || NodeRegistry::HOME_SLUG
|
||||||
|
end
|
||||||
|
|
||||||
|
def node_matches?(session)
|
||||||
|
assigned_node_slug(session) == local_node_slug
|
||||||
|
end
|
||||||
|
|
||||||
|
def queue_for(session)
|
||||||
|
"#{QUEUE_PREFIX}_#{assigned_node_slug(session)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def enqueue_ensure!(session, wait: nil)
|
||||||
|
opts = { queue: queue_for(session) }
|
||||||
|
opts[:wait] = wait if wait
|
||||||
|
YoutubeRelayEnsureJob.set(**opts).perform_later(session.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def enqueue_stop!(session)
|
||||||
|
YoutubeRelayStopJob.set(queue: queue_for(session)).perform_later(session.id)
|
||||||
|
end
|
||||||
|
|
||||||
def start(session)
|
def start(session)
|
||||||
return unless worker?
|
return enqueue_ensure!(session) unless worker? && node_matches?(session)
|
||||||
|
|
||||||
start_on_worker!(session)
|
start_on_worker!(session)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Non cancella owner/pid qui: solo il worker owner deve killare ffmpeg.
|
||||||
def stop(session)
|
def stop(session)
|
||||||
clear_pid(session.id)
|
if worker? && owner_is_local?(session.id) && node_matches?(session)
|
||||||
redis.del(format(OWNER_KEY, session.id))
|
|
||||||
if worker?
|
|
||||||
stop_on_worker!(session)
|
stop_on_worker!(session)
|
||||||
else
|
else
|
||||||
YoutubeRelayStopJob.perform_later(session.id)
|
enqueue_stop!(session)
|
||||||
end
|
end
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
@@ -31,11 +68,13 @@ module Streams
|
|||||||
def running?(session_id)
|
def running?(session_id)
|
||||||
owner = redis.get(format(OWNER_KEY, session_id))
|
owner = redis.get(format(OWNER_KEY, session_id))
|
||||||
pid = pid_for(session_id)
|
pid = pid_for(session_id)
|
||||||
|
return false if pid.blank? && owner.blank?
|
||||||
|
return false if pid.blank? && owner.present? && owner != worker_id && redis.ttl(format(OWNER_KEY, session_id)) <= 30
|
||||||
return false if pid.blank?
|
return false if pid.blank?
|
||||||
|
|
||||||
return process_alive?(pid) if owner.blank? || owner == worker_id
|
return process_alive?(pid, session_id: session_id) if owner.blank? || owner == worker_id
|
||||||
|
|
||||||
# Relay avviato in un altro container: consideralo attivo se il lock è recente.
|
# Relay su altro host: attivo se lock owner ancora fresco.
|
||||||
redis.ttl(format(OWNER_KEY, session_id)) > 30
|
redis.ttl(format(OWNER_KEY, session_id)) > 30
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -44,15 +83,22 @@ module Streams
|
|||||||
return if session.terminal?
|
return if session.terminal?
|
||||||
return if session.stream_key.blank?
|
return if session.stream_key.blank?
|
||||||
|
|
||||||
if worker?
|
if worker? && node_matches?(session)
|
||||||
ensure_on_worker!(session)
|
ensure_on_worker!(session)
|
||||||
else
|
else
|
||||||
YoutubeRelayEnsureJob.perform_later(session.id)
|
enqueue_ensure!(session)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def ensure_on_worker!(session)
|
def ensure_on_worker!(session)
|
||||||
return unless worker?
|
return :not_worker unless worker?
|
||||||
|
unless node_matches?(session)
|
||||||
|
enqueue_ensure!(session, wait: 2.seconds)
|
||||||
|
Rails.logger.info(
|
||||||
|
"[YoutubeRelay] wrong_node local=#{local_node_slug} assigned=#{assigned_node_slug(session)} session=#{session.id}"
|
||||||
|
)
|
||||||
|
return :wrong_host
|
||||||
|
end
|
||||||
return unless session.platform == "youtube"
|
return unless session.platform == "youtube"
|
||||||
return if session.terminal?
|
return if session.terminal?
|
||||||
return if session.stream_key.blank?
|
return if session.stream_key.blank?
|
||||||
@@ -60,28 +106,64 @@ module Streams
|
|||||||
return unless intake_available?(session)
|
return unless intake_available?(session)
|
||||||
|
|
||||||
pid = pid_for(session.id)
|
pid = pid_for(session.id)
|
||||||
clear_pid(session.id) if pid.present? && !process_alive?(pid.to_i)
|
if pid.present? && !process_alive?(pid.to_i, session_id: session.id)
|
||||||
|
clear_local_ownership(session.id)
|
||||||
|
end
|
||||||
|
|
||||||
return if running?(session.id)
|
if running?(session.id)
|
||||||
|
touch_owner!(session.id) if owner_is_local?(session.id)
|
||||||
|
return :already_running
|
||||||
|
end
|
||||||
|
|
||||||
|
if at_capacity?
|
||||||
|
enqueue_ensure!(session, wait: 5.seconds)
|
||||||
|
Rails.logger.info("[YoutubeRelay] at capacity worker=#{worker_id} node=#{local_node_slug} session=#{session.id} requeue")
|
||||||
|
return :at_capacity
|
||||||
|
end
|
||||||
|
|
||||||
last_restart = redis.get(restart_debounce_key(session.id)).to_i
|
last_restart = redis.get(restart_debounce_key(session.id)).to_i
|
||||||
return if last_restart.positive? && (Time.now.to_i - last_restart) < 5
|
return :debounced if last_restart.positive? && (Time.now.to_i - last_restart) < 5
|
||||||
|
|
||||||
start_on_worker!(session)
|
start_on_worker!(session)
|
||||||
redis.set(restart_debounce_key(session.id), Time.now.to_i, ex: 300)
|
redis.set(restart_debounce_key(session.id), Time.now.to_i, ex: 300)
|
||||||
|
:started
|
||||||
rescue Error => e
|
rescue Error => e
|
||||||
Rails.logger.warn("[YoutubeRelay] ensure_on_worker session=#{session.id}: #{e.message}")
|
Rails.logger.warn("[YoutubeRelay] ensure_on_worker session=#{session.id}: #{e.message}")
|
||||||
|
:error
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Symbol] :stopped, :wrong_host, :noop
|
||||||
def stop_on_worker!(session)
|
def stop_on_worker!(session)
|
||||||
pid = pid_for(session.id)
|
return :not_worker unless worker?
|
||||||
return false if pid.blank?
|
|
||||||
|
|
||||||
terminate_pid(pid)
|
owner = redis.get(format(OWNER_KEY, session.id))
|
||||||
clear_pid(session.id)
|
if owner.present? && owner != worker_id
|
||||||
redis.del(format(OWNER_KEY, session.id))
|
return :wrong_host
|
||||||
Rails.logger.info("[YoutubeRelay] stopped pid=#{pid} session=#{session.id}")
|
end
|
||||||
true
|
|
||||||
|
pid = pid_for(session.id)
|
||||||
|
if pid.blank?
|
||||||
|
clear_local_ownership(session.id)
|
||||||
|
return :noop
|
||||||
|
end
|
||||||
|
|
||||||
|
terminate_pid(pid, session_id: session.id)
|
||||||
|
clear_local_ownership(session.id)
|
||||||
|
Rails.logger.info("[YoutubeRelay] stopped pid=#{pid} session=#{session.id} worker=#{worker_id}")
|
||||||
|
:stopped
|
||||||
|
end
|
||||||
|
|
||||||
|
def local_owned_count
|
||||||
|
redis.scard(format(OWNED_SET, worker_id)).to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def at_capacity?
|
||||||
|
local_owned_count >= max_concurrent
|
||||||
|
end
|
||||||
|
|
||||||
|
def owner_is_local?(session_id)
|
||||||
|
owner = redis.get(format(OWNER_KEY, session_id))
|
||||||
|
owner.blank? || owner == worker_id
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -92,30 +174,92 @@ module Streams
|
|||||||
return if session.terminal?
|
return if session.terminal?
|
||||||
return unless intake_available?(session)
|
return unless intake_available?(session)
|
||||||
|
|
||||||
return pid_for(session.id).to_i if running?(session.id)
|
return pid_for(session.id).to_i if running?(session.id) && owner_is_local?(session.id)
|
||||||
|
|
||||||
stop_on_worker!(session) if pid_for(session.id).present?
|
stop_on_worker!(session) if pid_for(session.id).present? && owner_is_local?(session.id)
|
||||||
|
|
||||||
|
# Due EnsureJob concorrenti (concurrency Sidekiq > 1) non devono spawnare due ffmpeg.
|
||||||
|
unless redis.set(format("youtube_relay:startlock:%s", session.id), worker_id, nx: true, ex: 20)
|
||||||
|
return pid_for(session.id).to_i
|
||||||
|
end
|
||||||
|
|
||||||
log_path = log_file(session)
|
log_path = log_file(session)
|
||||||
FileUtils.mkdir_p(File.dirname(log_path))
|
FileUtils.mkdir_p(File.dirname(log_path))
|
||||||
intake_source = mediamtx_intake_source(session)
|
intake_source = mediamtx_intake_source(session)
|
||||||
output = "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}"
|
output = "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}"
|
||||||
|
|
||||||
pid = Process.spawn(
|
pid = if relay_agent_enabled?
|
||||||
*youtube_ffmpeg_args(intake_source, output),
|
start_via_agent!(session)
|
||||||
%i[out err] => log_path,
|
else
|
||||||
pgroup: true
|
spawn_ffmpeg!(youtube_ffmpeg_args(intake_source, output), log_path)
|
||||||
)
|
end
|
||||||
Process.detach(pid)
|
|
||||||
store_pid(session.id, pid)
|
store_pid(session.id, pid)
|
||||||
redis.set(format(OWNER_KEY, session.id), worker_id, ex: 48.hours.to_i)
|
claim_ownership!(session.id)
|
||||||
Rails.logger.info("[YoutubeRelay] started pid=#{pid} session=#{session.id} intake=#{intake_source.join(":")}")
|
Rails.logger.info(
|
||||||
|
"[YoutubeRelay] started pid=#{pid} session=#{session.id} intake=#{intake_source.join(':')} " \
|
||||||
|
"worker=#{worker_id} agent=#{relay_agent_url || '-'}"
|
||||||
|
)
|
||||||
schedule_youtube_activate(session)
|
schedule_youtube_activate(session)
|
||||||
pid
|
pid
|
||||||
rescue Errno::ENOENT => e
|
rescue Errno::ENOENT => e
|
||||||
raise Error, "ffmpeg non disponibile: #{e.message}"
|
raise Error, "ffmpeg non disponibile: #{e.message}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def relay_agent_url
|
||||||
|
ENV["STREAM_NODE_RELAY_AGENT_URL"].presence
|
||||||
|
end
|
||||||
|
|
||||||
|
def relay_agent_enabled?
|
||||||
|
relay_agent_url.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def relay_agent_secret
|
||||||
|
ENV["STREAM_NODE_AGENT_SECRET"].presence || ENV["MEDIAMTX_WEBHOOK_SECRET"].presence || ""
|
||||||
|
end
|
||||||
|
|
||||||
|
def spawn_ffmpeg!(args, log_path)
|
||||||
|
pid = Process.spawn(*args, %i[out err] => log_path, pgroup: true)
|
||||||
|
Process.detach(pid)
|
||||||
|
pid
|
||||||
|
end
|
||||||
|
|
||||||
|
def start_via_agent!(session)
|
||||||
|
payload = {
|
||||||
|
"session_id" => session.id,
|
||||||
|
"path" => session.mediamtx_path_name,
|
||||||
|
"rtmps" => "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}"
|
||||||
|
}
|
||||||
|
res = agent_request(Net::HTTP::Post, "/relays", payload)
|
||||||
|
pid = res["pid"].to_i
|
||||||
|
raise Error, "relay agent spawn failed: #{res.inspect}" if pid <= 0
|
||||||
|
|
||||||
|
File.write(
|
||||||
|
log_file(session),
|
||||||
|
"agent=#{relay_agent_url} pid=#{pid} path=#{session.mediamtx_path_name}\n"
|
||||||
|
)
|
||||||
|
pid
|
||||||
|
end
|
||||||
|
|
||||||
|
def agent_request(http_class, path, payload = nil)
|
||||||
|
uri = URI.parse("#{relay_agent_url.chomp('/')}#{path}")
|
||||||
|
http = Net::HTTP.new(uri.host, uri.port)
|
||||||
|
http.open_timeout = 5
|
||||||
|
http.read_timeout = 10
|
||||||
|
req = http_class.new(uri)
|
||||||
|
req["Authorization"] = "Bearer #{relay_agent_secret}" if relay_agent_secret.present?
|
||||||
|
req["Content-Type"] = "application/json"
|
||||||
|
req.body = JSON.generate(payload) if payload
|
||||||
|
res = http.request(req)
|
||||||
|
body = res.body.present? ? JSON.parse(res.body) : {}
|
||||||
|
unless res.is_a?(Net::HTTPSuccess)
|
||||||
|
raise Error, "relay agent #{path} HTTP #{res.code} #{body.inspect}"
|
||||||
|
end
|
||||||
|
|
||||||
|
body
|
||||||
|
rescue JSON::ParserError => e
|
||||||
|
raise Error, "relay agent JSON: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
# Remux verso YouTube: video+audio copy (AAC già da app/slate a 48k mono).
|
# Remux verso YouTube: video+audio copy (AAC già da app/slate a 48k mono).
|
||||||
# HLS (ADTS) → FLV richiede -bsf:a aac_adtstoasc; RTMP ha già ASC in FLV tags.
|
# HLS (ADTS) → FLV richiede -bsf:a aac_adtstoasc; RTMP ha già ASC in FLV tags.
|
||||||
def youtube_ffmpeg_args(intake_source, output)
|
def youtube_ffmpeg_args(intake_source, output)
|
||||||
@@ -135,6 +279,9 @@ module Streams
|
|||||||
end
|
end
|
||||||
|
|
||||||
common_head + [
|
common_head + [
|
||||||
|
"-reconnect", "1",
|
||||||
|
"-reconnect_streamed", "1",
|
||||||
|
"-reconnect_delay_max", "2",
|
||||||
"-rw_timeout", "15000000",
|
"-rw_timeout", "15000000",
|
||||||
"-live_start_index", "-1",
|
"-live_start_index", "-1",
|
||||||
"-i", url,
|
"-i", url,
|
||||||
@@ -146,20 +293,35 @@ module Streams
|
|||||||
end
|
end
|
||||||
|
|
||||||
def mediamtx_intake_source(session)
|
def mediamtx_intake_source(session)
|
||||||
base = ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935")
|
rtmp_base, hls_base = intake_bases(session)
|
||||||
if Mediamtx::PublisherOnline.active?(session)
|
if Mediamtx::PublisherOnline.active?(session)
|
||||||
return [:rtmp, "#{base.chomp('/')}/#{session.mediamtx_path_name}"]
|
return [:rtmp, "#{rtmp_base.chomp('/')}/#{session.mediamtx_path_name}"]
|
||||||
end
|
end
|
||||||
|
|
||||||
path = session.mediamtx_path_name
|
path = session.mediamtx_path_name
|
||||||
hls = ENV.fetch("MEDIAMTX_HLS_URL", "http://mediamtx:8888").chomp("/")
|
[:hls, "#{hls_base.chomp('/')}/#{path}/index.m3u8"]
|
||||||
[:hls, "#{hls}/#{path}/index.m3u8"]
|
end
|
||||||
|
|
||||||
|
# Sul nodo assegnato ffmpeg legge MediaMTX in loopback (o hostname Docker locale).
|
||||||
|
def intake_bases(session)
|
||||||
|
if relay_agent_enabled? && node_matches?(session)
|
||||||
|
return ["rtmp://127.0.0.1:1935", "http://127.0.0.1:8888"]
|
||||||
|
end
|
||||||
|
|
||||||
|
if node_matches?(session)
|
||||||
|
[
|
||||||
|
ENV["STREAM_NODE_LOCAL_RTMP_URL"].presence || session.mediamtx_internal_rtmp_url,
|
||||||
|
ENV["STREAM_NODE_LOCAL_HLS_URL"].presence || session.mediamtx_internal_hls_url
|
||||||
|
]
|
||||||
|
else
|
||||||
|
[session.mediamtx_internal_rtmp_url, session.mediamtx_internal_hls_url]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def intake_available?(session)
|
def intake_available?(session)
|
||||||
return true if Mediamtx::PublisherOnline.active?(session)
|
return true if Mediamtx::PublisherOnline.active?(session)
|
||||||
|
|
||||||
info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == session.mediamtx_path_name }
|
info = Mediamtx::Client.for_session(session).list_paths.find { |i| i["name"] == session.mediamtx_path_name }
|
||||||
info && (info["ready"] || info["online"] || info["available"])
|
info && (info["ready"] || info["online"] || info["available"])
|
||||||
rescue StandardError
|
rescue StandardError
|
||||||
false
|
false
|
||||||
@@ -185,6 +347,22 @@ module Streams
|
|||||||
format("youtube_relay:debounce:%s", session_id)
|
format("youtube_relay:debounce:%s", session_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def claim_ownership!(session_id)
|
||||||
|
redis.set(format(OWNER_KEY, session_id), worker_id, ex: 48.hours.to_i)
|
||||||
|
redis.sadd(format(OWNED_SET, worker_id), session_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def touch_owner!(session_id)
|
||||||
|
redis.expire(format(OWNER_KEY, session_id), 48.hours.to_i)
|
||||||
|
redis.expire(format(REDIS_KEY, session_id), 48.hours.to_i)
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_local_ownership(session_id)
|
||||||
|
redis.del(format(REDIS_KEY, session_id))
|
||||||
|
redis.del(format(OWNER_KEY, session_id))
|
||||||
|
redis.srem(format(OWNED_SET, worker_id), session_id)
|
||||||
|
end
|
||||||
|
|
||||||
def store_pid(session_id, pid)
|
def store_pid(session_id, pid)
|
||||||
redis.set(format(REDIS_KEY, session_id), pid, ex: 48.hours.to_i)
|
redis.set(format(REDIS_KEY, session_id), pid, ex: 48.hours.to_i)
|
||||||
end
|
end
|
||||||
@@ -197,7 +375,9 @@ module Streams
|
|||||||
redis.get(format(REDIS_KEY, session_id))
|
redis.get(format(REDIS_KEY, session_id))
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_alive?(pid)
|
def process_alive?(pid, session_id: nil)
|
||||||
|
return agent_session_running?(session_id) if relay_agent_enabled? && session_id.present?
|
||||||
|
|
||||||
stat = File.read("/proc/#{pid.to_i}/stat")
|
stat = File.read("/proc/#{pid.to_i}/stat")
|
||||||
return false if stat.split[2] == "Z"
|
return false if stat.split[2] == "Z"
|
||||||
|
|
||||||
@@ -207,12 +387,26 @@ module Streams
|
|||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
def terminate_pid(pid)
|
def agent_session_running?(session_id)
|
||||||
|
res = agent_request(Net::HTTP::Get, "/relays/#{session_id}")
|
||||||
|
res["running"] == true
|
||||||
|
rescue Error
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def terminate_pid(pid, session_id: nil)
|
||||||
|
if relay_agent_enabled? && session_id.present?
|
||||||
|
agent_request(Net::HTTP::Delete, "/relays/#{session_id}")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
Process.kill("TERM", -pid.to_i)
|
Process.kill("TERM", -pid.to_i)
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
rescue Errno::ESRCH
|
rescue Errno::ESRCH
|
||||||
nil
|
nil
|
||||||
else
|
else
|
||||||
|
return if relay_agent_enabled?
|
||||||
|
|
||||||
begin
|
begin
|
||||||
Process.kill("KILL", -pid.to_i)
|
Process.kill("KILL", -pid.to_i)
|
||||||
rescue Errno::ESRCH
|
rescue Errno::ESRCH
|
||||||
|
|||||||
@@ -68,13 +68,13 @@ module Webhooks
|
|||||||
def enable_recording(session)
|
def enable_recording(session)
|
||||||
return unless session.match.team.entitlements.recording_enabled_for_mediamtx?
|
return unless session.match.team.entitlements.recording_enabled_for_mediamtx?
|
||||||
|
|
||||||
Mediamtx::Client.new.set_path_recording(session, enabled: true)
|
Mediamtx::Client.for_session(session).set_path_recording(session, enabled: true)
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
Rails.logger.warn("[MediamtxHandler] enable recording: #{e.message}")
|
Rails.logger.warn("[MediamtxHandler] enable recording: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|
||||||
def disable_recording(session)
|
def disable_recording(session)
|
||||||
Mediamtx::Client.new.set_path_recording(session, enabled: false)
|
Mediamtx::Client.for_session(session).set_path_recording(session, enabled: false)
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
Rails.logger.warn("[MediamtxHandler] disable recording: #{e.message}")
|
Rails.logger.warn("[MediamtxHandler] disable recording: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ module Youtube
|
|||||||
end
|
end
|
||||||
|
|
||||||
def team_channel_available?
|
def team_channel_available?
|
||||||
@ent.youtube_enabled? && @ent.premium_full? && @mode == "team" && @team.club.youtube_credential.present?
|
@ent.youtube_enabled? && @ent.premium_full? && @mode == "team" &&
|
||||||
|
@team.club.youtube_credential&.usable?
|
||||||
end
|
end
|
||||||
|
|
||||||
def effective_channel
|
def effective_channel
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ module Youtube
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
Mediamtx::Client.new.set_always_available(session, enabled: false)
|
|
||||||
session.go_live! if session.may_go_live?
|
session.go_live! if session.may_go_live?
|
||||||
session.reconnect! if session.reconnecting? && session.may_reconnect?
|
session.reconnect! if session.reconnecting? && session.may_reconnect?
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ module Youtube
|
|||||||
when "matchlivetv_light"
|
when "matchlivetv_light"
|
||||||
PlatformCredential.configured?
|
PlatformCredential.configured?
|
||||||
when "team"
|
when "team"
|
||||||
@team.club.youtube_credential.present? || PlatformCredential.configured?
|
@team.club.youtube_credential&.usable? || PlatformCredential.configured?
|
||||||
else
|
else
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
@@ -31,7 +31,7 @@ module Youtube
|
|||||||
if @mode == "matchlivetv_light"
|
if @mode == "matchlivetv_light"
|
||||||
PlatformCredential.configured?
|
PlatformCredential.configured?
|
||||||
elsif @mode == "team"
|
elsif @mode == "team"
|
||||||
@team.club.youtube_credential.blank? && PlatformCredential.configured?
|
!@team.club.youtube_credential&.usable? && PlatformCredential.configured?
|
||||||
else
|
else
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
@@ -46,7 +46,7 @@ module Youtube
|
|||||||
end
|
end
|
||||||
|
|
||||||
def needs_team_oauth?
|
def needs_team_oauth?
|
||||||
@mode == "team" && @ent.premium_full? && @team.club.youtube_credential.blank? && !PlatformCredential.configured?
|
@mode == "team" && @ent.premium_full? && !@team.club.youtube_credential&.usable? && !PlatformCredential.configured?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<% content_for :body_class, "admin-body" %>
|
||||||
|
|
||||||
|
<div class="admin-page-head">
|
||||||
|
<h2 class="admin-page-title"><%= t("admin.stream_nodes.title") %></h2>
|
||||||
|
<p class="muted admin-page-sub">
|
||||||
|
<%= t("admin.stream_nodes.providers", cloud: @cloud_provider, dns: @dns_provider) %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% m = @autoscale_metrics %>
|
||||||
|
<section class="kpi-grid">
|
||||||
|
<div class="kpi-card <%= m[:enabled] ? 'kpi-card--accent' : '' %> <%= 'kpi-card--danger' if m[:kill_switch] %>">
|
||||||
|
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.autoscaler") %></div>
|
||||||
|
<div class="kpi-value"><%= m[:kill_switch] ? t("admin.stream_nodes.kpi.kill_switch") : (m[:enabled] ? "ON" : "OFF") %></div>
|
||||||
|
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.kind", kind: m[:kind]) %></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card <%= m[:free_slots] <= m[:soft_free_slots] ? 'kpi-card--danger' : '' %>">
|
||||||
|
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.free_slots") %></div>
|
||||||
|
<div class="kpi-value"><%= m[:free_slots] %></div>
|
||||||
|
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.soft_slots", soft: m[:soft_free_slots]) %></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.spare") %></div>
|
||||||
|
<div class="kpi-value"><%= m[:spare_ready] %><span class="kpi-value-unit">/<%= m[:warm_spare_min] %></span></div>
|
||||||
|
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.warm_spare") %></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.overflow") %></div>
|
||||||
|
<div class="kpi-value"><%= m[:overflow_nodes] %><span class="kpi-value-unit">/<%= m[:max_overflow_nodes] %></span></div>
|
||||||
|
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.overflow_nodes") %></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card <%= m[:within_budget] ? '' : 'kpi-card--danger' %>">
|
||||||
|
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.budget") %></div>
|
||||||
|
<div class="kpi-value kpi-value--sm">€<%= m[:estimated_monthly_eur] %></div>
|
||||||
|
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.budget_of", budget: m[:monthly_budget_eur], ok: (m[:within_budget] ? "OK" : "OVER")) %></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="panel" style="margin-bottom:1.5rem">
|
||||||
|
<div class="admin-panel-head">
|
||||||
|
<h2><%= t("admin.stream_nodes.actions_title") %></h2>
|
||||||
|
</div>
|
||||||
|
<div class="admin-toolbar">
|
||||||
|
<%= button_to t("admin.stream_nodes.provision_lab"), admin_stream_nodes_path, method: :post, params: { kind: "lab" }, class: "admin-btn admin-btn--secondary" %>
|
||||||
|
<% if @hetzner_configured %>
|
||||||
|
<%= button_to t("admin.stream_nodes.provision_cloud"), admin_stream_nodes_path, method: :post, params: { kind: "cloud" }, class: "admin-btn admin-btn--primary",
|
||||||
|
form: { data: { confirm: t("admin.stream_nodes.provision_cloud_confirm") } } %>
|
||||||
|
<% else %>
|
||||||
|
<span class="muted"><%= t("admin.stream_nodes.hetzner_token_missing") %></span>
|
||||||
|
<% end %>
|
||||||
|
<% if m[:kill_switch] %>
|
||||||
|
<%= button_to t("admin.stream_nodes.clear_kill_switch"), clear_kill_switch_admin_stream_nodes_path, method: :delete, class: "admin-btn admin-btn--secondary" %>
|
||||||
|
<% else %>
|
||||||
|
<%= button_to t("admin.stream_nodes.engage_kill_switch"), kill_switch_admin_stream_nodes_path, method: :post, class: "admin-btn admin-btn--danger",
|
||||||
|
form: { data: { confirm: t("admin.stream_nodes.kill_switch_confirm") } } %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2><%= t("admin.stream_nodes.table_title") %></h2>
|
||||||
|
<% if @nodes.any? %>
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><%= t("admin.stream_nodes.col.slug") %></th>
|
||||||
|
<th><%= t("admin.stream_nodes.col.role") %></th>
|
||||||
|
<th><%= t("admin.stream_nodes.col.status") %></th>
|
||||||
|
<th><%= t("admin.stream_nodes.col.slots") %></th>
|
||||||
|
<th><%= t("admin.stream_nodes.col.hostname") %></th>
|
||||||
|
<th><%= t("admin.stream_nodes.col.provider") %></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% @nodes.each do |node| %>
|
||||||
|
<%
|
||||||
|
badge =
|
||||||
|
case node.status
|
||||||
|
when "ready" then "badge--ready"
|
||||||
|
when "provisioning", "draining" then "badge--connecting"
|
||||||
|
when "error", "offline" then "badge--paused"
|
||||||
|
else "badge--paused"
|
||||||
|
end
|
||||||
|
%>
|
||||||
|
<tr>
|
||||||
|
<td><strong><%= node.slug %></strong></td>
|
||||||
|
<td class="muted"><%= node.role %></td>
|
||||||
|
<td><span class="badge <%= badge %>"><%= node.status %></span></td>
|
||||||
|
<td>
|
||||||
|
<strong><%= node.active_publishers %></strong>
|
||||||
|
<span class="muted">/ <%= node.max_publishers %></span>
|
||||||
|
<div class="muted" style="font-size:0.8rem"><%= t("admin.stream_nodes.free_slots", count: node.free_slots) %></div>
|
||||||
|
</td>
|
||||||
|
<td class="admin-mono"><%= node.hostname %></td>
|
||||||
|
<td class="muted">
|
||||||
|
<%= node.provider %>
|
||||||
|
<% if node.provider_instance_id.present? %>
|
||||||
|
<div class="admin-mono" style="font-size:0.75rem;margin-top:0.2rem"><%= node.provider_instance_id %></div>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="admin-actions">
|
||||||
|
<% if node.slug != "home" %>
|
||||||
|
<% if node.status == "ready" %>
|
||||||
|
<%= button_to t("admin.stream_nodes.drain"), drain_admin_stream_node_path(node), method: :post, class: "admin-btn admin-btn--sm admin-btn--secondary" %>
|
||||||
|
<% end %>
|
||||||
|
<%= button_to t("admin.stream_nodes.destroy"), admin_stream_node_path(node), method: :delete, class: "admin-btn admin-btn--sm admin-btn--danger",
|
||||||
|
form: { data: { confirm: t("admin.stream_nodes.destroy_confirm", slug: node.slug) } } %>
|
||||||
|
<% else %>
|
||||||
|
<span class="muted">—</span>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="empty"><%= t("admin.stream_nodes.empty") %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @lab_hosts.present? %>
|
||||||
|
<div class="panel" style="margin-top:1.5rem">
|
||||||
|
<h2><%= t("admin.stream_nodes.hosts_title") %></h2>
|
||||||
|
<pre class="admin-pre"><%= @lab_hosts %></pre>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<title><%= t("admin.layout.title") %></title>
|
<title><%= t("admin.layout.title") %></title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="robots" content="noindex, nofollow">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<link rel="stylesheet" href="/admin.css?v=2">
|
<link rel="stylesheet" href="/admin.css?v=4">
|
||||||
<% if content_for?(:replay_archive_styles) %>
|
<% if content_for?(:replay_archive_styles) %>
|
||||||
<link rel="stylesheet" href="/marketing.css?v=42">
|
<link rel="stylesheet" href="/marketing.css?v=42">
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
<%= link_to t("admin.layout.nav.billing"), admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %>
|
<%= link_to t("admin.layout.nav.billing"), admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %>
|
||||||
<%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
|
<%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
|
||||||
<%= link_to t("admin.layout.nav.sessions"), admin_sessions_path, class: ("active" if controller_name == "sessions") %>
|
<%= link_to t("admin.layout.nav.sessions"), admin_sessions_path, class: ("active" if controller_name == "sessions") %>
|
||||||
|
<%= link_to t("admin.layout.nav.stream_nodes"), admin_stream_nodes_path, class: ("active" if controller_name == "stream_nodes") %>
|
||||||
<%= link_to t("admin.layout.nav.password"), edit_admin_password_path %>
|
<%= link_to t("admin.layout.nav.password"), edit_admin_password_path %>
|
||||||
<%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %>
|
<%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<meta name="theme-color" content="#0a0a0e">
|
<meta name="theme-color" content="#0a0a0e">
|
||||||
<title><%= content_for?(:title) ? yield(:title) : t("regia.meta_title_fallback") %></title>
|
<title><%= content_for?(:title) ? yield(:title) : t("regia.meta_title_fallback") %></title>
|
||||||
<%= render "shared/meta_tags" %>
|
<%= render "shared/meta_tags" %>
|
||||||
<link rel="stylesheet" href="/regia.css?v=1">
|
<link rel="stylesheet" href="/regia.css?v=11">
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
<body class="regia-body">
|
<body class="regia-body">
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p id="offline-msg" class="live-status-msg" hidden><%= t("live.show.offline_msg") %></p>
|
<p id="offline-msg" class="live-status-msg" hidden><%= t("live.show.offline_msg") %></p>
|
||||||
<p id="awaiting-msg" class="live-status-msg" hidden><%= t("live.show.awaiting_msg") %></p>
|
<p id="awaiting-msg" class="live-status-msg" hidden><%= t("live.show.awaiting_msg") %></p>
|
||||||
|
<p id="reconnecting-msg" class="live-status-msg" hidden><%= t("live.show.js_reconnecting_awaiting") %></p>
|
||||||
<p id="paused-msg" class="live-status-msg" hidden><%= t("live.show.paused_msg") %></p>
|
<p id="paused-msg" class="live-status-msg" hidden><%= t("live.show.paused_msg") %></p>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -63,25 +64,26 @@
|
|||||||
const offlineMsg = document.getElementById("offline-msg");
|
const offlineMsg = document.getElementById("offline-msg");
|
||||||
const pausedMsg = document.getElementById("paused-msg");
|
const pausedMsg = document.getElementById("paused-msg");
|
||||||
const awaitingMsg = document.getElementById("awaiting-msg");
|
const awaitingMsg = document.getElementById("awaiting-msg");
|
||||||
|
const reconnectingMsg = document.getElementById("reconnecting-msg");
|
||||||
const playHint = document.getElementById("play-hint");
|
const playHint = document.getElementById("play-hint");
|
||||||
const streamBadge = document.getElementById("live-stream-badge");
|
const streamBadge = document.getElementById("live-stream-badge");
|
||||||
|
|
||||||
function syncStreamBadge(data) {
|
function syncStreamBadge(data) {
|
||||||
if (!streamBadge) return;
|
if (!streamBadge) return;
|
||||||
if (data.stream_closed) {
|
if (data.stream_closed) {
|
||||||
streamBadge.textContent = "<%= j t("live.overlays.badge_ended") %>";
|
streamBadge.textContent = <%= raw t("live.overlays.badge_ended").to_json %>;
|
||||||
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-ended";
|
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-ended";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const paused = !!(data.paused || data.status === "paused");
|
const paused = !!(data.paused || data.status === "paused");
|
||||||
if (paused) {
|
if (paused) {
|
||||||
streamBadge.textContent = "<%= j t("live.index.badge_paused") %>";
|
streamBadge.textContent = <%= raw t("live.index.badge_paused").to_json %>;
|
||||||
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait";
|
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait";
|
||||||
} else if (data.on_air) {
|
} else if (data.on_air) {
|
||||||
streamBadge.textContent = "<%= j t("live.index.badge_live") %>";
|
streamBadge.textContent = <%= raw t("live.index.badge_live").to_json %>;
|
||||||
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-live";
|
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-live";
|
||||||
} else {
|
} else {
|
||||||
streamBadge.textContent = "<%= j t("live.overlays.badge_waiting") %>";
|
streamBadge.textContent = <%= raw t("live.overlays.badge_waiting").to_json %>;
|
||||||
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait";
|
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,9 +284,10 @@
|
|||||||
if (onAir || sessionLive) {
|
if (onAir || sessionLive) {
|
||||||
offlineMsg.hidden = true;
|
offlineMsg.hidden = true;
|
||||||
if (!publisherOnline && showingCover && !paused) {
|
if (!publisherOnline && showingCover && !paused) {
|
||||||
awaitingMsg.hidden = false;
|
awaitingMsg.hidden = true;
|
||||||
awaitingMsg.textContent = "<%= j t("live.show.js_reconnecting_awaiting") %>";
|
if (reconnectingMsg) reconnectingMsg.hidden = false;
|
||||||
} else {
|
} else {
|
||||||
|
if (reconnectingMsg) reconnectingMsg.hidden = true;
|
||||||
awaitingMsg.hidden = !awaitingSignal;
|
awaitingMsg.hidden = !awaitingSignal;
|
||||||
}
|
}
|
||||||
pausedMsg.hidden = !paused;
|
pausedMsg.hidden = !paused;
|
||||||
@@ -299,6 +302,7 @@
|
|||||||
} else {
|
} else {
|
||||||
offlineMsg.hidden = false;
|
offlineMsg.hidden = false;
|
||||||
awaitingMsg.hidden = true;
|
awaitingMsg.hidden = true;
|
||||||
|
if (reconnectingMsg) reconnectingMsg.hidden = true;
|
||||||
pausedMsg.hidden = true;
|
pausedMsg.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,4 +21,4 @@
|
|||||||
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px">−</button>
|
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px">−</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="regia-btn regia-btn--yellow" data-action="close_set" style="margin-top:12px"><%= t("regia.board.close_set") %></button>
|
<button type="button" class="regia-btn regia-btn--yellow" data-action="close_set"><%= t("regia.board.close_set") %></button>
|
||||||
|
|||||||
@@ -15,7 +15,10 @@
|
|||||||
data-score-url="<%= j public_regia_score_path(params[:token]) %>"
|
data-score-url="<%= j public_regia_score_path(params[:token]) %>"
|
||||||
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
|
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
|
||||||
data-resume-url="<%= j public_regia_resume_path(params[:token]) %>"
|
data-resume-url="<%= j public_regia_resume_path(params[:token]) %>"
|
||||||
|
data-audio-mute-url="<%= j public_regia_audio_mute_path(params[:token]) %>"
|
||||||
data-stop-url="<%= j public_regia_stop_path(params[:token]) %>"
|
data-stop-url="<%= j public_regia_stop_path(params[:token]) %>"
|
||||||
|
data-min-quality-url="<%= j public_regia_min_quality_path(params[:token]) %>"
|
||||||
|
data-min-quality="<%= j @session.min_quality_preset %>"
|
||||||
data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>"
|
data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>"
|
||||||
data-hls-url="<%= j @session.hls_playback_url %>"
|
data-hls-url="<%= j @session.hls_playback_url %>"
|
||||||
data-home-name="<%= j @team.name %>"
|
data-home-name="<%= j @team.name %>"
|
||||||
@@ -26,58 +29,126 @@
|
|||||||
data-live-share-title="<%= j @live_share_title %>"
|
data-live-share-title="<%= j @live_share_title %>"
|
||||||
data-points-target="<%= @match.effective_board_type.in?(%w[volley racket]) ? Scoring::Rules.from_match(@match).points_target(@score.current_set) : 0 %>"
|
data-points-target="<%= @match.effective_board_type.in?(%w[volley racket]) ? Scoring::Rules.from_match(@match).points_target(@score.current_set) : 0 %>"
|
||||||
data-stream-closed="<%= @stream_closed %>"
|
data-stream-closed="<%= @stream_closed %>"
|
||||||
data-initial-paused="<%= @session.paused? %>">
|
data-initial-paused="<%= @session.paused? %>"
|
||||||
|
data-initial-audio-muted="<%= @session.audio_muted %>">
|
||||||
|
|
||||||
<header class="regia-header">
|
<header class="regia-header">
|
||||||
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
|
<div class="regia-header__text">
|
||||||
<p id="regia-subtitle"><%= t("regia.subtitle_prefix") %> <%= regia_header_subtitle(@match, @score) %></p>
|
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
|
||||||
|
<p id="regia-subtitle"><%= t("regia.subtitle_prefix") %> <%= regia_header_subtitle(@match, @score) %></p>
|
||||||
|
</div>
|
||||||
<span id="regia-badge" class="regia-badge <%= @stream_closed ? 'regia-badge--ended' : (@session.paused? ? 'regia-badge--wait' : (@on_air ? 'regia-badge--live' : 'regia-badge--wait')) %>">
|
<span id="regia-badge" class="regia-badge <%= @stream_closed ? 'regia-badge--ended' : (@session.paused? ? 'regia-badge--wait' : (@on_air ? 'regia-badge--live' : 'regia-badge--wait')) %>">
|
||||||
<%= @stream_closed ? t("regia.status.ended") : (@session.paused? ? t("regia.status.paused") : (@on_air ? t("regia.status.live") : t("regia.status.waiting"))) %>
|
<%= @stream_closed ? t("regia.status.ended") : (@session.paused? ? t("regia.status.paused") : (@on_air ? t("regia.status.live") : t("regia.status.waiting"))) %>
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<% if @live_share_url.present? %>
|
<nav class="regia-toolbar" aria-label="<%= t("regia.links_nav") %>">
|
||||||
<div class="regia-share-group">
|
<% if @live_share_url.present? %>
|
||||||
<p class="regia-share-label"><%= t("regia.live_share_label") %></p>
|
<div class="regia-tool-group" role="group" aria-label="<%= t("regia.live_share_label") %>">
|
||||||
<div class="regia-share">
|
<span class="regia-tool-group__label"><%= t("regia.live_share_label") %></span>
|
||||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-share-live" style="margin:0">
|
<div class="regia-tool-group__btns">
|
||||||
<%= t("regia.share_live") %>
|
<button type="button" class="regia-icon-btn" id="btn-share-live" aria-label="<%= t("regia.share_live") %>" title="<%= t("regia.share_live") %>">
|
||||||
|
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="regia-icon-btn" id="btn-copy-live" aria-label="<%= t("regia.copy_live") %>" title="<%= t("regia.copy_live") %>">
|
||||||
|
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<div class="regia-tool-group" role="group" aria-label="<%= t("regia.regia_share_label") %>">
|
||||||
|
<span class="regia-tool-group__label"><%= t("regia.regia_share_label") %></span>
|
||||||
|
<div class="regia-tool-group__btns">
|
||||||
|
<button type="button" class="regia-icon-btn" id="btn-share-regia" aria-label="<%= t("regia.share_regia") %>" title="<%= t("regia.share_regia") %>">
|
||||||
|
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-copy-live" style="margin:0">
|
<button type="button" class="regia-icon-btn" id="btn-copy-regia" aria-label="<%= t("regia.copy_regia") %>" title="<%= t("regia.copy_regia") %>">
|
||||||
<%= t("regia.copy_link") %>
|
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="regia-share-group">
|
|
||||||
<p class="regia-share-label"><%= t("regia.regia_share_label") %></p>
|
|
||||||
<div class="regia-share">
|
|
||||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-share-regia" style="margin:0">
|
|
||||||
<%= t("regia.share_regia") %>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-copy-regia" style="margin:0">
|
|
||||||
<%= t("regia.copy_link") %>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="regia-preview">
|
|
||||||
<% unless @stream_closed %>
|
<% unless @stream_closed %>
|
||||||
<video id="regia-preview" playsinline muted autoplay></video>
|
<div class="regia-tool-group regia-tool-group--stream" role="group" aria-label="<%= t("regia.stream_controls_label") %>">
|
||||||
|
<span class="regia-tool-group__label" aria-hidden="true"> </span>
|
||||||
|
<div class="regia-tool-group__btns">
|
||||||
|
<button type="button" class="regia-icon-btn" id="btn-toggle-preview" aria-label="<%= t("regia.preview_hide") %>" title="<%= t("regia.preview_hide") %>" aria-pressed="true">
|
||||||
|
<svg class="icon-fill icon-eye" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>
|
||||||
|
<svg class="icon-fill icon-eye-off" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78 3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="regia-icon-btn<%= " is-paused" if @session.paused? %>" id="btn-pause" aria-label="<%= @session.paused? ? t("regia.resume") : t("regia.pause") %>" title="<%= @session.paused? ? t("regia.resume") : t("regia.pause") %>">
|
||||||
|
<svg class="icon-fill icon-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||||
|
<svg class="icon-fill icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="regia-icon-btn<%= " is-muted" if @session.audio_muted %>" id="btn-audio-mute" aria-label="<%= @session.audio_muted ? t("regia.unmute") : t("regia.mute") %>" title="<%= @session.audio_muted ? t("regia.unmute") : t("regia.mute") %>">
|
||||||
|
<svg class="icon-fill icon-mic" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5-3c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>
|
||||||
|
<svg class="icon-fill icon-mic-off" viewBox="0 0 24 24" aria-hidden="true"><path d="M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.02.17c0-.06.02-.11.02-.17V5c0-1.66-1.34-3-3-3S9 3.34 9 5v.18l5.98 5.99zM4.27 3L3 4.27l6.01 6.01V11c0 1.66 1.33 3 2.99 3 .22 0 .44-.03.65-.08l1.66 1.66c-.71.33-1.5.52-2.31.52-2.76 0-5.3-2.1-5.3-5.1H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c.91-.13 1.77-.45 2.54-.9L19.73 21 21 19.73 4.27 3z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="regia-icon-btn regia-icon-btn--danger" id="btn-stop" aria-label="<%= t("regia.stop") %>" title="<%= t("regia.stop") %>">
|
||||||
|
<svg class="icon-fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6h12v12H6z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<div id="regia-preview-placeholder" class="regia-preview__placeholder">
|
</nav>
|
||||||
<%= @stream_closed ? t("regia.preview_ended") : t("regia.preview_waiting") %>
|
|
||||||
|
<section class="regia-preview-wrap" id="regia-preview-wrap">
|
||||||
|
<div class="regia-preview" id="regia-preview-box">
|
||||||
|
<% unless @stream_closed %>
|
||||||
|
<video id="regia-preview" playsinline muted autoplay></video>
|
||||||
|
<% end %>
|
||||||
|
<div id="regia-preview-placeholder" class="regia-preview__placeholder">
|
||||||
|
<%= @stream_closed ? t("regia.preview_ended") : t("regia.preview_waiting") %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<section class="regia-scoreboard">
|
<section class="regia-scoreboard">
|
||||||
<%= render regia_board_partial(@match) %>
|
<%= render regia_board_partial(@match) %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<% unless @stream_closed %>
|
<% unless @stream_closed %>
|
||||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-pause"><%= @session.paused? ? t("regia.resume") : t("regia.pause") %></button>
|
<section class="regia-telemetry" id="regia-telemetry" aria-label="<%= t("regia.telemetry.label") %>">
|
||||||
<button type="button" class="regia-btn regia-btn--danger" id="btn-stop"><%= t("regia.stop") %></button>
|
<div class="regia-telemetry__row">
|
||||||
|
<span class="regia-chip" id="tel-battery" title="<%= t("regia.telemetry.battery") %>">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 4h-1V3h-6v1H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H8V6h8v14z"/></svg>
|
||||||
|
<span class="regia-chip__label"><%= t("regia.telemetry.battery") %></span>
|
||||||
|
<span class="regia-chip__value">—</span>
|
||||||
|
</span>
|
||||||
|
<span class="regia-chip" id="tel-network" title="<%= t("regia.telemetry.network") %>">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z"/></svg>
|
||||||
|
<span class="regia-chip__label"><%= t("regia.telemetry.network") %></span>
|
||||||
|
<span class="regia-chip__value">—</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" class="regia-chip regia-chip--action" id="tel-bitrate" aria-expanded="false" aria-controls="regia-quality" title="<%= t("regia.telemetry.bitrate_tap") %>">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 13h2v-2H3v2zm4 4h2v-6H7v6zm4 4h2V7h-2v14zm4-8h2V9h-2v4zm4-4h2V5h-2v4z"/></svg>
|
||||||
|
<span class="regia-chip__label"><%= t("regia.telemetry.bitrate") %></span>
|
||||||
|
<span class="regia-chip__value">—</span>
|
||||||
|
<span class="regia-chip__hint" hidden></span>
|
||||||
|
</button>
|
||||||
|
<span class="regia-chip" id="tel-fps" title="<%= t("regia.telemetry.fps") %>">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
|
||||||
|
<span class="regia-chip__label"><%= t("regia.telemetry.fps") %></span>
|
||||||
|
<span class="regia-chip__value">—</span>
|
||||||
|
</span>
|
||||||
|
<span class="regia-chip" id="tel-thermal" title="<%= t("regia.telemetry.thermal") %>">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 13V5c0-1.66-1.34-3-3-3S9 3.34 9 5v8c-1.21.91-2 2.37-2 4 0 2.76 2.24 5 5 5s5-2.24 5-5c0-1.63-.79-3.09-2-4zm-3 8c-1.66 0-3-1.34-3-3 0-1.19.7-2.23 1.71-2.72L11 14.73V5c0-.55.45-1 1-1s1 .45 1 1v9.73l.29.18C14.3 15.77 15 16.81 15 18c0 1.66-1.34 3-3 3z"/></svg>
|
||||||
|
<span class="regia-chip__label"><%= t("regia.telemetry.thermal") %></span>
|
||||||
|
<span class="regia-chip__value">—</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="regia-quality" id="regia-quality" hidden>
|
||||||
|
<p class="regia-quality__hint"><%= t("regia.min_quality.recommend") %></p>
|
||||||
|
<label for="min-quality"><%= t("regia.min_quality.label") %></label>
|
||||||
|
<select id="min-quality" title="<%= t("regia.min_quality.hint") %>">
|
||||||
|
<% Sessions::SelectQuality.allowed_min_ids(@session.quality_preset).each do |id| %>
|
||||||
|
<option value="<%= id %>" <%= "selected" if @session.min_quality_preset == id %>>
|
||||||
|
<%= id == Sessions::SelectQuality::AUTO_ID ? t("regia.min_quality.auto") : Sessions::SelectQuality.display_name(id) %>
|
||||||
|
</option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p class="regia-telemetry__meta" id="tel-meta"><%= t("regia.telemetry.waiting") %></p>
|
||||||
|
</section>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -94,4 +165,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/regia.js?v=8"></script>
|
<script src="/regia.js?v=19"></script>
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ Rails.application.configure do
|
|||||||
# Annotate rendered view with file names.
|
# Annotate rendered view with file names.
|
||||||
config.action_view.annotate_rendered_view_with_filenames = true
|
config.action_view.annotate_rendered_view_with_filenames = true
|
||||||
|
|
||||||
# Uncomment if you wish to allow Action Cable access from any origin.
|
# Permette Action Cable dall'emulatore Android (10.0.2.2) e da altri host locali.
|
||||||
# config.action_cable.disable_request_forgery_protection = true
|
config.action_cable.disable_request_forgery_protection = true
|
||||||
|
|
||||||
# Raise error when a before_action's only/except options reference missing actions.
|
# Raise error when a before_action's only/except options reference missing actions.
|
||||||
config.action_controller.raise_on_missing_callback_actions = true
|
config.action_controller.raise_on_missing_callback_actions = true
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Sidekiq.configure_server do |config|
|
|||||||
config.on(:startup) do
|
config.on(:startup) do
|
||||||
StreamPublisherSyncJob.ensure_chain
|
StreamPublisherSyncJob.ensure_chain
|
||||||
Ops::HealthMonitorJob.ensure_chain
|
Ops::HealthMonitorJob.ensure_chain
|
||||||
|
Streams::AutoscalerJob.ensure_chain
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ de:
|
|||||||
billing: Abrechnung
|
billing: Abrechnung
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sitzungen
|
sessions: Sitzungen
|
||||||
|
stream_nodes: Stream-Knoten
|
||||||
password: Passwort
|
password: Passwort
|
||||||
logout: Abmelden
|
logout: Abmelden
|
||||||
flash:
|
flash:
|
||||||
@@ -26,6 +27,11 @@ de:
|
|||||||
ops_acknowledged: Vorfall übernommen
|
ops_acknowledged: Vorfall übernommen
|
||||||
ops_resolved: Vorfall gelöst
|
ops_resolved: Vorfall gelöst
|
||||||
ops_muted: Benachrichtigungen für 24 Stunden stummgeschaltet
|
ops_muted: Benachrichtigungen für 24 Stunden stummgeschaltet
|
||||||
|
stream_node_created: "Lab-Knoten %{slug} bereitgestellt."
|
||||||
|
stream_node_destroyed: "Knoten %{slug} entfernt."
|
||||||
|
stream_node_draining: "Knoten %{slug} im Drain-Modus."
|
||||||
|
autoscale_kill_on: "Autoscaler-Kill-Switch aktiv. Kein automatisches Scale-out."
|
||||||
|
autoscale_kill_off: "Autoscaler-Kill-Switch aus (STREAM_AUTOSCALE_ENABLED=1 weiterhin nötig)."
|
||||||
comped_granted: "Kostenloses Abonnement %{plan} für %{club} aktiviert."
|
comped_granted: "Kostenloses Abonnement %{plan} für %{club} aktiviert."
|
||||||
comped_revoked: "Kostenloses Abonnement für %{club} widerrufen."
|
comped_revoked: "Kostenloses Abonnement für %{club} widerrufen."
|
||||||
session_already_terminated: "Sitzung bereits beendet (%{status})."
|
session_already_terminated: "Sitzung bereits beendet (%{status})."
|
||||||
@@ -111,6 +117,48 @@ de:
|
|||||||
title: Teams
|
title: Teams
|
||||||
matches_count: "%{count} Spiele"
|
matches_count: "%{count} Spiele"
|
||||||
view_all: "Vereine ansehen (%{count} Teams)"
|
view_all: "Vereine ansehen (%{count} Teams)"
|
||||||
|
stream_nodes:
|
||||||
|
title: Stream-Knoten
|
||||||
|
providers: "Cloud: %{cloud} · DNS: %{dns}"
|
||||||
|
actions_title: Aktionen
|
||||||
|
table_title: Registrierte Knoten
|
||||||
|
empty: Keine Knoten registriert.
|
||||||
|
free_slots:
|
||||||
|
one: "%{count} frei"
|
||||||
|
other: "%{count} frei"
|
||||||
|
kpi:
|
||||||
|
autoscaler: Autoscaler
|
||||||
|
kill_switch: KILL
|
||||||
|
kind: "Modus %{kind}"
|
||||||
|
free_slots: Freie Slots
|
||||||
|
soft_slots: "Soft-Schwelle ≤ %{soft}"
|
||||||
|
spare: Spare
|
||||||
|
warm_spare: Warm-Spares bereit
|
||||||
|
overflow: Overflow
|
||||||
|
overflow_nodes: Cloud/Lab-Knoten
|
||||||
|
budget: Geschätztes Budget
|
||||||
|
budget_of: "von €%{budget}/Monat (%{ok})"
|
||||||
|
autoscale: "Autoscaler %{enabled} · free_slots=%{free} (soft≤%{soft}) · spare=%{spare}/%{warm} · overflow=%{overflow}/%{max} · kind=%{kind}"
|
||||||
|
autoscale_budget: "Budget €%{eur}/€%{budget} (%{ok})"
|
||||||
|
kill_switch_active: "KILL-SWITCH AKTIV"
|
||||||
|
engage_kill_switch: "Kill-switch ON"
|
||||||
|
clear_kill_switch: "Kill-switch OFF"
|
||||||
|
kill_switch_confirm: "Autoscaler sofort blockieren?"
|
||||||
|
provision_lab: Lab bereitstellen
|
||||||
|
provision_cloud: Hetzner bereitstellen
|
||||||
|
provision_cloud_confirm: "Hetzner Cloud Server + DNS auf mltv-stream.net erstellen?"
|
||||||
|
hetzner_token_missing: "HCLOUD_TOKEN setzen, um Cloud-Provisioning zu aktivieren."
|
||||||
|
drain: Drain
|
||||||
|
destroy: Löschen
|
||||||
|
destroy_confirm: "Knoten %{slug} löschen?"
|
||||||
|
hosts_title: Lab-DNS (/etc/hosts)
|
||||||
|
col:
|
||||||
|
slug: Slug
|
||||||
|
role: Rolle
|
||||||
|
status: Status
|
||||||
|
slots: Slots
|
||||||
|
hostname: Hostname
|
||||||
|
provider: Provider
|
||||||
ops:
|
ops:
|
||||||
kpi:
|
kpi:
|
||||||
critical: Kritisch offen
|
critical: Kritisch offen
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ en:
|
|||||||
billing: Billing
|
billing: Billing
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sessions
|
sessions: Sessions
|
||||||
|
stream_nodes: Stream nodes
|
||||||
password: Password
|
password: Password
|
||||||
logout: Log out
|
logout: Log out
|
||||||
flash:
|
flash:
|
||||||
@@ -26,6 +27,11 @@ en:
|
|||||||
ops_acknowledged: Incident acknowledged
|
ops_acknowledged: Incident acknowledged
|
||||||
ops_resolved: Incident resolved
|
ops_resolved: Incident resolved
|
||||||
ops_muted: Notifications muted for 24 hours
|
ops_muted: Notifications muted for 24 hours
|
||||||
|
stream_node_created: "Lab node %{slug} provisioned."
|
||||||
|
stream_node_destroyed: "Node %{slug} removed."
|
||||||
|
stream_node_draining: "Node %{slug} is draining (no new sessions)."
|
||||||
|
autoscale_kill_on: "Autoscaler kill-switch engaged. No automatic scale-out."
|
||||||
|
autoscale_kill_off: "Autoscaler kill-switch cleared (still needs STREAM_AUTOSCALE_ENABLED=1)."
|
||||||
comped_granted: "%{plan} complimentary subscription activated for %{club}."
|
comped_granted: "%{plan} complimentary subscription activated for %{club}."
|
||||||
comped_revoked: "Complimentary subscription revoked for %{club}."
|
comped_revoked: "Complimentary subscription revoked for %{club}."
|
||||||
session_already_terminated: "Session already ended (%{status})."
|
session_already_terminated: "Session already ended (%{status})."
|
||||||
@@ -111,6 +117,48 @@ en:
|
|||||||
title: Teams
|
title: Teams
|
||||||
matches_count: "%{count} matches"
|
matches_count: "%{count} matches"
|
||||||
view_all: "View clubs (%{count} teams)"
|
view_all: "View clubs (%{count} teams)"
|
||||||
|
stream_nodes:
|
||||||
|
title: Streaming nodes
|
||||||
|
providers: "Cloud: %{cloud} · DNS: %{dns}"
|
||||||
|
actions_title: Actions
|
||||||
|
table_title: Registered nodes
|
||||||
|
empty: No nodes registered.
|
||||||
|
free_slots:
|
||||||
|
one: "%{count} free"
|
||||||
|
other: "%{count} free"
|
||||||
|
kpi:
|
||||||
|
autoscaler: Autoscaler
|
||||||
|
kill_switch: KILL
|
||||||
|
kind: "%{kind} mode"
|
||||||
|
free_slots: Free slots
|
||||||
|
soft_slots: "soft threshold ≤ %{soft}"
|
||||||
|
spare: Spare
|
||||||
|
warm_spare: warm spares ready
|
||||||
|
overflow: Overflow
|
||||||
|
overflow_nodes: cloud/lab nodes
|
||||||
|
budget: Estimated budget
|
||||||
|
budget_of: "of €%{budget}/mo (%{ok})"
|
||||||
|
autoscale: "Autoscaler %{enabled} · free_slots=%{free} (soft≤%{soft}) · spare=%{spare}/%{warm} · overflow=%{overflow}/%{max} · kind=%{kind}"
|
||||||
|
autoscale_budget: "budget €%{eur}/€%{budget} (%{ok})"
|
||||||
|
kill_switch_active: "KILL-SWITCH ACTIVE"
|
||||||
|
engage_kill_switch: "Kill-switch ON"
|
||||||
|
clear_kill_switch: "Kill-switch OFF"
|
||||||
|
kill_switch_confirm: "Immediately block the autoscaler? Existing nodes stay up."
|
||||||
|
provision_lab: Provision lab
|
||||||
|
provision_cloud: Provision Hetzner
|
||||||
|
provision_cloud_confirm: "Create a Hetzner Cloud server + DNS record on mltv-stream.net? Billing applies until destroyed."
|
||||||
|
hetzner_token_missing: "Set HCLOUD_TOKEN to enable Cloud provisioning."
|
||||||
|
drain: Drain
|
||||||
|
destroy: Delete
|
||||||
|
destroy_confirm: "Delete node %{slug}?"
|
||||||
|
hosts_title: Lab DNS records (/etc/hosts snippet)
|
||||||
|
col:
|
||||||
|
slug: Slug
|
||||||
|
role: Role
|
||||||
|
status: Status
|
||||||
|
slots: Slots
|
||||||
|
hostname: Hostname
|
||||||
|
provider: Provider
|
||||||
ops:
|
ops:
|
||||||
kpi:
|
kpi:
|
||||||
critical: Critical open
|
critical: Critical open
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ es:
|
|||||||
billing: Facturación
|
billing: Facturación
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sesiones
|
sessions: Sesiones
|
||||||
|
stream_nodes: Nodos stream
|
||||||
password: Contraseña
|
password: Contraseña
|
||||||
logout: Salir
|
logout: Salir
|
||||||
flash:
|
flash:
|
||||||
@@ -26,6 +27,11 @@ es:
|
|||||||
ops_acknowledged: Incidencia asumida
|
ops_acknowledged: Incidencia asumida
|
||||||
ops_resolved: Incidencia resuelta
|
ops_resolved: Incidencia resuelta
|
||||||
ops_muted: Notificaciones silenciadas durante 24 horas
|
ops_muted: Notificaciones silenciadas durante 24 horas
|
||||||
|
stream_node_created: "Nodo lab %{slug} provisionado."
|
||||||
|
stream_node_destroyed: "Nodo %{slug} eliminado."
|
||||||
|
stream_node_draining: "Nodo %{slug} en drain."
|
||||||
|
autoscale_kill_on: "Kill-switch del autoscaler activado. Sin scale-out automático."
|
||||||
|
autoscale_kill_off: "Kill-switch del autoscaler desactivado (hace falta STREAM_AUTOSCALE_ENABLED=1)."
|
||||||
comped_granted: "Suscripción de cortesía %{plan} activada para %{club}."
|
comped_granted: "Suscripción de cortesía %{plan} activada para %{club}."
|
||||||
comped_revoked: "Suscripción de cortesía revocada para %{club}."
|
comped_revoked: "Suscripción de cortesía revocada para %{club}."
|
||||||
session_already_terminated: "La sesión ya ha finalizado (%{status})."
|
session_already_terminated: "La sesión ya ha finalizado (%{status})."
|
||||||
@@ -111,6 +117,48 @@ es:
|
|||||||
title: Equipos
|
title: Equipos
|
||||||
matches_count: "%{count} partidos"
|
matches_count: "%{count} partidos"
|
||||||
view_all: "Ver clubes (%{count} equipos)"
|
view_all: "Ver clubes (%{count} equipos)"
|
||||||
|
stream_nodes:
|
||||||
|
title: Nodos streaming
|
||||||
|
providers: "Cloud: %{cloud} · DNS: %{dns}"
|
||||||
|
actions_title: Acciones
|
||||||
|
table_title: Nodos registrados
|
||||||
|
empty: No hay nodos registrados.
|
||||||
|
free_slots:
|
||||||
|
one: "%{count} libre"
|
||||||
|
other: "%{count} libres"
|
||||||
|
kpi:
|
||||||
|
autoscaler: Autoscaler
|
||||||
|
kill_switch: KILL
|
||||||
|
kind: "modo %{kind}"
|
||||||
|
free_slots: Slots libres
|
||||||
|
soft_slots: "umbral soft ≤ %{soft}"
|
||||||
|
spare: Spare
|
||||||
|
warm_spare: warm spares listos
|
||||||
|
overflow: Overflow
|
||||||
|
overflow_nodes: nodos cloud/lab
|
||||||
|
budget: Presupuesto estimado
|
||||||
|
budget_of: "de €%{budget}/mes (%{ok})"
|
||||||
|
autoscale: "Autoscaler %{enabled} · free_slots=%{free} (soft≤%{soft}) · spare=%{spare}/%{warm} · overflow=%{overflow}/%{max} · kind=%{kind}"
|
||||||
|
autoscale_budget: "presupuesto €%{eur}/€%{budget} (%{ok})"
|
||||||
|
kill_switch_active: "KILL-SWITCH ACTIVO"
|
||||||
|
engage_kill_switch: "Kill-switch ON"
|
||||||
|
clear_kill_switch: "Kill-switch OFF"
|
||||||
|
kill_switch_confirm: "¿Bloquear el autoscaler inmediatamente?"
|
||||||
|
provision_lab: Provisionar lab
|
||||||
|
provision_cloud: Provisionar Hetzner
|
||||||
|
provision_cloud_confirm: "¿Crear un servidor Hetzner Cloud + DNS en mltv-stream.net?"
|
||||||
|
hetzner_token_missing: "Configura HCLOUD_TOKEN para habilitar el provisioning Cloud."
|
||||||
|
drain: Drain
|
||||||
|
destroy: Eliminar
|
||||||
|
destroy_confirm: "¿Eliminar el nodo %{slug}?"
|
||||||
|
hosts_title: DNS lab (/etc/hosts)
|
||||||
|
col:
|
||||||
|
slug: Slug
|
||||||
|
role: Rol
|
||||||
|
status: Estado
|
||||||
|
slots: Slots
|
||||||
|
hostname: Hostname
|
||||||
|
provider: Provider
|
||||||
ops:
|
ops:
|
||||||
kpi:
|
kpi:
|
||||||
critical: Críticas abiertas
|
critical: Críticas abiertas
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ fr:
|
|||||||
billing: Facturation
|
billing: Facturation
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sessions
|
sessions: Sessions
|
||||||
|
stream_nodes: Nœuds stream
|
||||||
password: Mot de passe
|
password: Mot de passe
|
||||||
logout: Déconnexion
|
logout: Déconnexion
|
||||||
flash:
|
flash:
|
||||||
@@ -26,6 +27,11 @@ fr:
|
|||||||
ops_acknowledged: Incident pris en charge
|
ops_acknowledged: Incident pris en charge
|
||||||
ops_resolved: Incident résolu
|
ops_resolved: Incident résolu
|
||||||
ops_muted: Notifications suspendues pendant 24 heures
|
ops_muted: Notifications suspendues pendant 24 heures
|
||||||
|
stream_node_created: "Nœud lab %{slug} provisionné."
|
||||||
|
stream_node_destroyed: "Nœud %{slug} supprimé."
|
||||||
|
stream_node_draining: "Nœud %{slug} en drain."
|
||||||
|
autoscale_kill_on: "Kill-switch autoscaler activé. Pas de scale-out automatique."
|
||||||
|
autoscale_kill_off: "Kill-switch autoscaler désactivé (nécessite aussi STREAM_AUTOSCALE_ENABLED=1)."
|
||||||
comped_granted: "Abonnement offert %{plan} activé pour %{club}."
|
comped_granted: "Abonnement offert %{plan} activé pour %{club}."
|
||||||
comped_revoked: "Abonnement offert révoqué pour %{club}."
|
comped_revoked: "Abonnement offert révoqué pour %{club}."
|
||||||
session_already_terminated: "Session déjà terminée (%{status})."
|
session_already_terminated: "Session déjà terminée (%{status})."
|
||||||
@@ -111,6 +117,48 @@ fr:
|
|||||||
title: Équipes
|
title: Équipes
|
||||||
matches_count: "%{count} matchs"
|
matches_count: "%{count} matchs"
|
||||||
view_all: "Voir les clubs (%{count} équipes)"
|
view_all: "Voir les clubs (%{count} équipes)"
|
||||||
|
stream_nodes:
|
||||||
|
title: Nœuds streaming
|
||||||
|
providers: "Cloud: %{cloud} · DNS: %{dns}"
|
||||||
|
actions_title: Actions
|
||||||
|
table_title: Nœuds enregistrés
|
||||||
|
empty: Aucun nœud enregistré.
|
||||||
|
free_slots:
|
||||||
|
one: "%{count} libre"
|
||||||
|
other: "%{count} libres"
|
||||||
|
kpi:
|
||||||
|
autoscaler: Autoscaler
|
||||||
|
kill_switch: KILL
|
||||||
|
kind: "mode %{kind}"
|
||||||
|
free_slots: Slots libres
|
||||||
|
soft_slots: "seuil soft ≤ %{soft}"
|
||||||
|
spare: Spare
|
||||||
|
warm_spare: warm spares prêts
|
||||||
|
overflow: Overflow
|
||||||
|
overflow_nodes: nœuds cloud/lab
|
||||||
|
budget: Budget estimé
|
||||||
|
budget_of: "sur €%{budget}/mois (%{ok})"
|
||||||
|
autoscale: "Autoscaler %{enabled} · free_slots=%{free} (soft≤%{soft}) · spare=%{spare}/%{warm} · overflow=%{overflow}/%{max} · kind=%{kind}"
|
||||||
|
autoscale_budget: "budget €%{eur}/€%{budget} (%{ok})"
|
||||||
|
kill_switch_active: "KILL-SWITCH ACTIF"
|
||||||
|
engage_kill_switch: "Kill-switch ON"
|
||||||
|
clear_kill_switch: "Kill-switch OFF"
|
||||||
|
kill_switch_confirm: "Bloquer immédiatement l'autoscaler ?"
|
||||||
|
provision_lab: Provisionner lab
|
||||||
|
provision_cloud: Provisionner Hetzner
|
||||||
|
provision_cloud_confirm: "Créer un serveur Hetzner Cloud + DNS sur mltv-stream.net ?"
|
||||||
|
hetzner_token_missing: "Définir HCLOUD_TOKEN pour activer le provisioning Cloud."
|
||||||
|
drain: Drain
|
||||||
|
destroy: Supprimer
|
||||||
|
destroy_confirm: "Supprimer le nœud %{slug} ?"
|
||||||
|
hosts_title: DNS lab (/etc/hosts)
|
||||||
|
col:
|
||||||
|
slug: Slug
|
||||||
|
role: Rôle
|
||||||
|
status: Statut
|
||||||
|
slots: Slots
|
||||||
|
hostname: Hostname
|
||||||
|
provider: Provider
|
||||||
ops:
|
ops:
|
||||||
kpi:
|
kpi:
|
||||||
critical: Critiques ouverts
|
critical: Critiques ouverts
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ it:
|
|||||||
billing: Fatturazione
|
billing: Fatturazione
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sessioni
|
sessions: Sessioni
|
||||||
|
stream_nodes: Nodi stream
|
||||||
password: Password
|
password: Password
|
||||||
logout: Esci
|
logout: Esci
|
||||||
flash:
|
flash:
|
||||||
@@ -26,6 +27,11 @@ it:
|
|||||||
ops_acknowledged: Incidente preso in carico
|
ops_acknowledged: Incidente preso in carico
|
||||||
ops_resolved: Incidente risolto
|
ops_resolved: Incidente risolto
|
||||||
ops_muted: Notifiche sospese per 24 ore
|
ops_muted: Notifiche sospese per 24 ore
|
||||||
|
stream_node_created: "Nodo lab %{slug} provisionato."
|
||||||
|
stream_node_destroyed: "Nodo %{slug} rimosso."
|
||||||
|
stream_node_draining: "Nodo %{slug} in drain (niente nuove sessioni)."
|
||||||
|
autoscale_kill_on: "Kill-switch autoscaler attivato. Nessun scale-out automatico."
|
||||||
|
autoscale_kill_off: "Kill-switch autoscaler disattivato (serve comunque STREAM_AUTOSCALE_ENABLED=1)."
|
||||||
comped_granted: "Abbonamento omaggio %{plan} attivato per %{club}."
|
comped_granted: "Abbonamento omaggio %{plan} attivato per %{club}."
|
||||||
comped_revoked: "Abbonamento omaggio revocato per %{club}."
|
comped_revoked: "Abbonamento omaggio revocato per %{club}."
|
||||||
session_already_terminated: "Sessione già terminata (%{status})."
|
session_already_terminated: "Sessione già terminata (%{status})."
|
||||||
@@ -111,6 +117,48 @@ it:
|
|||||||
title: Squadre
|
title: Squadre
|
||||||
matches_count: "%{count} partite"
|
matches_count: "%{count} partite"
|
||||||
view_all: "Vedi società (%{count} squadre)"
|
view_all: "Vedi società (%{count} squadre)"
|
||||||
|
stream_nodes:
|
||||||
|
title: Nodi streaming
|
||||||
|
providers: "Cloud: %{cloud} · DNS: %{dns}"
|
||||||
|
actions_title: Azioni
|
||||||
|
table_title: Nodi registrati
|
||||||
|
empty: Nessun nodo registrato.
|
||||||
|
free_slots:
|
||||||
|
one: "%{count} libero"
|
||||||
|
other: "%{count} liberi"
|
||||||
|
kpi:
|
||||||
|
autoscaler: Autoscaler
|
||||||
|
kill_switch: KILL
|
||||||
|
kind: "modalità %{kind}"
|
||||||
|
free_slots: Slot liberi
|
||||||
|
soft_slots: "soglia soft ≤ %{soft}"
|
||||||
|
spare: Spare
|
||||||
|
warm_spare: warm spare pronti
|
||||||
|
overflow: Overflow
|
||||||
|
overflow_nodes: nodi cloud/lab
|
||||||
|
budget: Budget stimato
|
||||||
|
budget_of: "su €%{budget}/mese (%{ok})"
|
||||||
|
autoscale: "Autoscaler %{enabled} · free_slots=%{free} (soft≤%{soft}) · spare=%{spare}/%{warm} · overflow=%{overflow}/%{max} · kind=%{kind}"
|
||||||
|
autoscale_budget: "budget €%{eur}/€%{budget} (%{ok})"
|
||||||
|
kill_switch_active: "KILL-SWITCH ATTIVO"
|
||||||
|
engage_kill_switch: "Kill-switch ON"
|
||||||
|
clear_kill_switch: "Kill-switch OFF"
|
||||||
|
kill_switch_confirm: "Bloccare immediatamente l'autoscaler? I nodi esistenti restano accesi."
|
||||||
|
provision_lab: Provisiona lab
|
||||||
|
provision_cloud: Provisiona Hetzner
|
||||||
|
provision_cloud_confirm: "Creare un server Hetzner Cloud + record DNS su mltv-stream.net? Verrà addebitato fino allo spegnimento."
|
||||||
|
hetzner_token_missing: "Imposta HCLOUD_TOKEN per abilitare il provisioning Cloud."
|
||||||
|
drain: Drain
|
||||||
|
destroy: Elimina
|
||||||
|
destroy_confirm: "Eliminare il nodo %{slug}?"
|
||||||
|
hosts_title: Record DNS lab (snippet /etc/hosts)
|
||||||
|
col:
|
||||||
|
slug: Slug
|
||||||
|
role: Ruolo
|
||||||
|
status: Stato
|
||||||
|
slots: Slot
|
||||||
|
hostname: Hostname
|
||||||
|
provider: Provider
|
||||||
ops:
|
ops:
|
||||||
kpi:
|
kpi:
|
||||||
critical: Critici aperti
|
critical: Critici aperti
|
||||||
|
|||||||
@@ -439,16 +439,53 @@ de:
|
|||||||
paused: Pausiert
|
paused: Pausiert
|
||||||
live: Live
|
live: Live
|
||||||
waiting: Wartet
|
waiting: Wartet
|
||||||
live_share_label: "Live-Link (Zuschauer)"
|
live_share_label: Live
|
||||||
share_live: Livestream teilen
|
share_live: Livestream teilen
|
||||||
|
share_action: Teilen
|
||||||
copy_link: Link kopieren
|
copy_link: Link kopieren
|
||||||
regia_share_label: "Regie-Link (Punktestand)"
|
copy_action: Kopieren
|
||||||
|
copy_live: Live kopieren
|
||||||
|
copy_regia: Regie kopieren
|
||||||
|
links_nav: Schnellaktionen
|
||||||
|
regia_share_label: Regie
|
||||||
|
stream_controls_label: Live
|
||||||
share_regia: Regie-Link teilen
|
share_regia: Regie-Link teilen
|
||||||
preview_ended: Livestream beendet
|
preview_ended: Livestream beendet
|
||||||
preview_waiting: Vorschau wartet auf Signal…
|
preview_waiting: Vorschau wartet auf Signal…
|
||||||
|
preview_hide: Vorschau ausblenden
|
||||||
|
preview_show: Vorschau anzeigen
|
||||||
|
preview_off_hint: Vorschau aus · kein Video-Download
|
||||||
pause: Pausieren
|
pause: Pausieren
|
||||||
resume: Livestream fortsetzen
|
resume: Livestream fortsetzen
|
||||||
|
mute: Audio stummschalten
|
||||||
|
unmute: Audio einschalten
|
||||||
stop: Livestream beenden
|
stop: Livestream beenden
|
||||||
|
min_quality:
|
||||||
|
label: Minimale Übertragungsqualität
|
||||||
|
summary: Qualität
|
||||||
|
hint: Der Encoder fällt nicht unter dieses Niveau. Automatisch passt die Bitrate an, um Aussetzer zu reduzieren.
|
||||||
|
recommend: "Automatisch wird empfohlen: passt sich an Netz und Telefon an, ohne das Maximum zu erzwingen."
|
||||||
|
auto: Automatisch
|
||||||
|
telemetry:
|
||||||
|
label: Telefon
|
||||||
|
waiting: Warte auf das Telefon…
|
||||||
|
stale: Telefon antwortet nicht
|
||||||
|
just_now: jetzt
|
||||||
|
seconds_ago: "vor %{n}s"
|
||||||
|
publisher_on: RTMP ok
|
||||||
|
publisher_off: RTMP fehlt
|
||||||
|
battery: Akku
|
||||||
|
network: Netz
|
||||||
|
bitrate: Bitrate
|
||||||
|
bitrate_max: "max %{n}"
|
||||||
|
bitrate_title: "%{current} Mbps aktuell · max. %{target} Mbps"
|
||||||
|
bitrate_tap: Tippen, um die Mindestqualität festzulegen
|
||||||
|
fps: FPS
|
||||||
|
thermal: Temp.
|
||||||
|
thermal_nominal: OK
|
||||||
|
thermal_fair: Warm
|
||||||
|
thermal_serious: Heiß
|
||||||
|
thermal_critical: Kritisch
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Satz gewonnen
|
set_won_title: Satz gewonnen
|
||||||
confirm: Satz beenden
|
confirm: Satz beenden
|
||||||
@@ -483,9 +520,12 @@ de:
|
|||||||
unknown_period: "—"
|
unknown_period: "—"
|
||||||
resumed: Übertragung fortgesetzt
|
resumed: Übertragung fortgesetzt
|
||||||
paused_cover: "Übertragung pausiert — Deckbild auf Sendung"
|
paused_cover: "Übertragung pausiert — Deckbild auf Sendung"
|
||||||
|
muted: "Audio stumm — YouTube empfängt das Mikrofon nicht"
|
||||||
|
unmuted: "Mikrofon-Audio wieder aktiv"
|
||||||
closed: Übertragung beendet
|
closed: Übertragung beendet
|
||||||
resume_error: Fehler beim Fortsetzen der Übertragung
|
resume_error: Fehler beim Fortsetzen der Übertragung
|
||||||
pause_error: Fehler beim Pausieren der Übertragung
|
pause_error: Fehler beim Pausieren der Übertragung
|
||||||
|
mute_error: Fehler beim Stummschalten
|
||||||
close_error: Fehler beim Beenden
|
close_error: Fehler beim Beenden
|
||||||
close_confirm: Übertragung endgültig beenden?
|
close_confirm: Übertragung endgültig beenden?
|
||||||
link_unavailable: Link nicht verfügbar
|
link_unavailable: Link nicht verfügbar
|
||||||
@@ -493,6 +533,8 @@ de:
|
|||||||
copy_prompt: "Link kopieren:"
|
copy_prompt: "Link kopieren:"
|
||||||
share_regia_text: "Öffne diesen Link, um den Live-Spielstand zu verwalten:"
|
share_regia_text: "Öffne diesen Link, um den Live-Spielstand zu verwalten:"
|
||||||
share_live_text: "Sieh dir die Übertragung auf Match Live TV an:"
|
share_live_text: "Sieh dir die Übertragung auf Match Live TV an:"
|
||||||
|
min_quality_updated: Mindestqualität aktualisiert
|
||||||
|
min_quality_error: Fehler beim Aktualisieren der Mindestqualität
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -434,16 +434,53 @@ en:
|
|||||||
paused: Paused
|
paused: Paused
|
||||||
live: Live
|
live: Live
|
||||||
waiting: Waiting
|
waiting: Waiting
|
||||||
live_share_label: "Live link (viewers)"
|
live_share_label: Live
|
||||||
share_live: Share live link
|
share_live: Share live link
|
||||||
|
share_action: Share
|
||||||
copy_link: Copy link
|
copy_link: Copy link
|
||||||
regia_share_label: "Control room link (score)"
|
copy_action: Copy
|
||||||
|
copy_live: Copy live
|
||||||
|
copy_regia: Copy room
|
||||||
|
links_nav: Quick actions
|
||||||
|
regia_share_label: Control
|
||||||
|
stream_controls_label: Live
|
||||||
share_regia: Share control room link
|
share_regia: Share control room link
|
||||||
preview_ended: Stream ended
|
preview_ended: Stream ended
|
||||||
preview_waiting: Waiting for signal…
|
preview_waiting: Waiting for signal…
|
||||||
|
preview_hide: Hide preview
|
||||||
|
preview_show: Show preview
|
||||||
|
preview_off_hint: Preview off · no video download
|
||||||
pause: Pause stream
|
pause: Pause stream
|
||||||
resume: Resume stream
|
resume: Resume stream
|
||||||
|
mute: Mute audio
|
||||||
|
unmute: Unmute audio
|
||||||
stop: End stream
|
stop: End stream
|
||||||
|
min_quality:
|
||||||
|
label: Minimum streaming quality
|
||||||
|
summary: Quality
|
||||||
|
hint: The encoder will not go below this level. Automatic adapts bitrate to reduce stalls.
|
||||||
|
recommend: "Automatic is recommended: it adapts to the network and the phone, without forcing maximum quality."
|
||||||
|
auto: Automatic
|
||||||
|
telemetry:
|
||||||
|
label: Phone
|
||||||
|
waiting: Waiting for the phone…
|
||||||
|
stale: Phone not responding
|
||||||
|
just_now: now
|
||||||
|
seconds_ago: "%{n}s ago"
|
||||||
|
publisher_on: RTMP ok
|
||||||
|
publisher_off: RTMP down
|
||||||
|
battery: Battery
|
||||||
|
network: Network
|
||||||
|
bitrate: Bitrate
|
||||||
|
bitrate_max: "max %{n}"
|
||||||
|
bitrate_title: "%{current} Mbps current · max %{target} Mbps"
|
||||||
|
bitrate_tap: Tap to set the minimum quality
|
||||||
|
fps: FPS
|
||||||
|
thermal: Temp.
|
||||||
|
thermal_nominal: OK
|
||||||
|
thermal_fair: Warm
|
||||||
|
thermal_serious: Hot
|
||||||
|
thermal_critical: Critical
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set won
|
set_won_title: Set won
|
||||||
confirm: Close set
|
confirm: Close set
|
||||||
@@ -478,9 +515,12 @@ en:
|
|||||||
unknown_period: "—"
|
unknown_period: "—"
|
||||||
resumed: Stream resumed
|
resumed: Stream resumed
|
||||||
paused_cover: "Stream paused — cover on air"
|
paused_cover: "Stream paused — cover on air"
|
||||||
|
muted: "Audio muted — YouTube is not receiving the microphone"
|
||||||
|
unmuted: "Microphone audio restored"
|
||||||
closed: Stream closed
|
closed: Stream closed
|
||||||
resume_error: Error resuming the stream
|
resume_error: Error resuming the stream
|
||||||
pause_error: Error pausing the stream
|
pause_error: Error pausing the stream
|
||||||
|
mute_error: Error muting audio
|
||||||
close_error: Error closing
|
close_error: Error closing
|
||||||
close_confirm: Permanently close the live stream?
|
close_confirm: Permanently close the live stream?
|
||||||
link_unavailable: Link not available
|
link_unavailable: Link not available
|
||||||
@@ -488,6 +528,8 @@ en:
|
|||||||
copy_prompt: "Copy the link:"
|
copy_prompt: "Copy the link:"
|
||||||
share_regia_text: "Open this link to manage the live score:"
|
share_regia_text: "Open this link to manage the live score:"
|
||||||
share_live_text: "Watch the live stream on Match Live TV:"
|
share_live_text: "Watch the live stream on Match Live TV:"
|
||||||
|
min_quality_updated: Minimum quality updated
|
||||||
|
min_quality_error: Error updating minimum quality
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -439,16 +439,53 @@ es:
|
|||||||
paused: En pausa
|
paused: En pausa
|
||||||
live: En directo
|
live: En directo
|
||||||
waiting: En espera
|
waiting: En espera
|
||||||
live_share_label: "Enlace del directo (espectadores)"
|
live_share_label: Directo
|
||||||
share_live: Compartir directo
|
share_live: Compartir directo
|
||||||
|
share_action: Compartir
|
||||||
copy_link: Copiar enlace
|
copy_link: Copiar enlace
|
||||||
regia_share_label: "Enlace de control (marcador)"
|
copy_action: Copiar
|
||||||
|
copy_live: Copiar directo
|
||||||
|
copy_regia: Copiar control
|
||||||
|
links_nav: Acciones rápidas
|
||||||
|
regia_share_label: Control
|
||||||
|
stream_controls_label: Live
|
||||||
share_regia: Compartir enlace de control
|
share_regia: Compartir enlace de control
|
||||||
preview_ended: Directo finalizado
|
preview_ended: Directo finalizado
|
||||||
preview_waiting: Vista previa a la espera de señal…
|
preview_waiting: Vista previa a la espera de señal…
|
||||||
|
preview_hide: Ocultar vista previa
|
||||||
|
preview_show: Mostrar vista previa
|
||||||
|
preview_off_hint: Vista previa apagada · sin descarga de vídeo
|
||||||
pause: Pausar directo
|
pause: Pausar directo
|
||||||
resume: Reanudar directo
|
resume: Reanudar directo
|
||||||
|
mute: Silenciar audio
|
||||||
|
unmute: Reactivar audio
|
||||||
stop: Finalizar directo
|
stop: Finalizar directo
|
||||||
|
min_quality:
|
||||||
|
label: Calidad mínima de transmisión
|
||||||
|
summary: Calidad
|
||||||
|
hint: El encoder no baja de este nivel. Automática adapta el bitrate para reducir los cortes.
|
||||||
|
recommend: "Se recomienda Automática: se adapta a la red y al teléfono, sin forzar el máximo."
|
||||||
|
auto: Automática
|
||||||
|
telemetry:
|
||||||
|
label: Teléfono
|
||||||
|
waiting: Esperando al teléfono…
|
||||||
|
stale: El teléfono no responde
|
||||||
|
just_now: ahora
|
||||||
|
seconds_ago: "hace %{n}s"
|
||||||
|
publisher_on: RTMP ok
|
||||||
|
publisher_off: RTMP ausente
|
||||||
|
battery: Batería
|
||||||
|
network: Red
|
||||||
|
bitrate: Bitrate
|
||||||
|
bitrate_max: "máx %{n}"
|
||||||
|
bitrate_title: "%{current} Mbps actuales · máximo %{target} Mbps"
|
||||||
|
bitrate_tap: Toca para definir la calidad mínima
|
||||||
|
fps: FPS
|
||||||
|
thermal: Temp.
|
||||||
|
thermal_nominal: OK
|
||||||
|
thermal_fair: Caliente
|
||||||
|
thermal_serious: Muy caliente
|
||||||
|
thermal_critical: Crítica
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set ganado
|
set_won_title: Set ganado
|
||||||
confirm: Cerrar set
|
confirm: Cerrar set
|
||||||
@@ -483,9 +520,12 @@ es:
|
|||||||
unknown_period: "—"
|
unknown_period: "—"
|
||||||
resumed: Retransmisión reanudada
|
resumed: Retransmisión reanudada
|
||||||
paused_cover: "Retransmisión en pausa — cortinilla al aire"
|
paused_cover: "Retransmisión en pausa — cortinilla al aire"
|
||||||
|
muted: "Audio silenciado — YouTube no recibe el micrófono"
|
||||||
|
unmuted: "Audio del micrófono reactivado"
|
||||||
closed: Retransmisión finalizada
|
closed: Retransmisión finalizada
|
||||||
resume_error: Error al reanudar la retransmisión
|
resume_error: Error al reanudar la retransmisión
|
||||||
pause_error: Error al pausar la retransmisión
|
pause_error: Error al pausar la retransmisión
|
||||||
|
mute_error: Error al silenciar el audio
|
||||||
close_error: Error al cerrar
|
close_error: Error al cerrar
|
||||||
close_confirm: "¿Cerrar definitivamente la retransmisión?"
|
close_confirm: "¿Cerrar definitivamente la retransmisión?"
|
||||||
link_unavailable: Enlace no disponible
|
link_unavailable: Enlace no disponible
|
||||||
@@ -493,6 +533,8 @@ es:
|
|||||||
copy_prompt: "Copia el enlace:"
|
copy_prompt: "Copia el enlace:"
|
||||||
share_regia_text: "Abre este enlace para gestionar el marcador en directo:"
|
share_regia_text: "Abre este enlace para gestionar el marcador en directo:"
|
||||||
share_live_text: "Mira la retransmisión en Match Live TV:"
|
share_live_text: "Mira la retransmisión en Match Live TV:"
|
||||||
|
min_quality_updated: Calidad mínima actualizada
|
||||||
|
min_quality_error: Error al actualizar la calidad mínima
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -439,16 +439,53 @@ fr:
|
|||||||
paused: En pause
|
paused: En pause
|
||||||
live: En direct
|
live: En direct
|
||||||
waiting: En attente
|
waiting: En attente
|
||||||
live_share_label: "Lien direct (spectateurs)"
|
live_share_label: Direct
|
||||||
share_live: Partager le direct
|
share_live: Partager le direct
|
||||||
|
share_action: Partager
|
||||||
copy_link: Copier le lien
|
copy_link: Copier le lien
|
||||||
regia_share_label: "Lien régie (score)"
|
copy_action: Copier
|
||||||
|
copy_live: Copier live
|
||||||
|
copy_regia: Copier régie
|
||||||
|
links_nav: Actions rapides
|
||||||
|
regia_share_label: Régie
|
||||||
|
stream_controls_label: Live
|
||||||
share_regia: Partager le lien régie
|
share_regia: Partager le lien régie
|
||||||
preview_ended: Direct terminé
|
preview_ended: Direct terminé
|
||||||
preview_waiting: Aperçu en attente du signal…
|
preview_waiting: Aperçu en attente du signal…
|
||||||
|
preview_hide: Masquer l'aperçu
|
||||||
|
preview_show: Afficher l'aperçu
|
||||||
|
preview_off_hint: Aperçu désactivé · aucun téléchargement vidéo
|
||||||
pause: Mettre en pause
|
pause: Mettre en pause
|
||||||
resume: Reprendre le direct
|
resume: Reprendre le direct
|
||||||
|
mute: Couper l'audio
|
||||||
|
unmute: Réactiver l'audio
|
||||||
stop: Terminer le direct
|
stop: Terminer le direct
|
||||||
|
min_quality:
|
||||||
|
label: Qualité minimale de transmission
|
||||||
|
summary: Qualité
|
||||||
|
hint: L'encodeur ne descend pas en dessous de ce niveau. Automatique adapte le débit pour limiter les coupures.
|
||||||
|
recommend: "Automatique est conseillé : s'adapte au réseau et au téléphone, sans forcer le maximum."
|
||||||
|
auto: Automatique
|
||||||
|
telemetry:
|
||||||
|
label: Téléphone
|
||||||
|
waiting: En attente du téléphone…
|
||||||
|
stale: Téléphone sans réponse
|
||||||
|
just_now: maintenant
|
||||||
|
seconds_ago: "il y a %{n}s"
|
||||||
|
publisher_on: RTMP ok
|
||||||
|
publisher_off: RTMP absent
|
||||||
|
battery: Batterie
|
||||||
|
network: Réseau
|
||||||
|
bitrate: Débit
|
||||||
|
bitrate_max: "max %{n}"
|
||||||
|
bitrate_title: "%{current} Mbps actuels · max %{target} Mbps"
|
||||||
|
bitrate_tap: Touchez pour définir la qualité minimale
|
||||||
|
fps: FPS
|
||||||
|
thermal: Temp.
|
||||||
|
thermal_nominal: OK
|
||||||
|
thermal_fair: Chaude
|
||||||
|
thermal_serious: Très chaude
|
||||||
|
thermal_critical: Critique
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set gagné
|
set_won_title: Set gagné
|
||||||
confirm: Terminer le set
|
confirm: Terminer le set
|
||||||
@@ -483,9 +520,12 @@ fr:
|
|||||||
unknown_period: "—"
|
unknown_period: "—"
|
||||||
resumed: Direct repris
|
resumed: Direct repris
|
||||||
paused_cover: "Direct en pause — habillage à l'antenne"
|
paused_cover: "Direct en pause — habillage à l'antenne"
|
||||||
|
muted: "Audio coupé — YouTube ne reçoit pas le micro"
|
||||||
|
unmuted: "Audio du micro rétabli"
|
||||||
closed: Direct terminé
|
closed: Direct terminé
|
||||||
resume_error: Erreur lors de la reprise du direct
|
resume_error: Erreur lors de la reprise du direct
|
||||||
pause_error: Erreur lors de la mise en pause du direct
|
pause_error: Erreur lors de la mise en pause du direct
|
||||||
|
mute_error: Erreur lors de la coupure audio
|
||||||
close_error: Erreur de clôture
|
close_error: Erreur de clôture
|
||||||
close_confirm: Clôturer définitivement le direct ?
|
close_confirm: Clôturer définitivement le direct ?
|
||||||
link_unavailable: Lien non disponible
|
link_unavailable: Lien non disponible
|
||||||
@@ -493,6 +533,8 @@ fr:
|
|||||||
copy_prompt: "Copiez le lien :"
|
copy_prompt: "Copiez le lien :"
|
||||||
share_regia_text: "Ouvrez ce lien pour gérer le score du direct :"
|
share_regia_text: "Ouvrez ce lien pour gérer le score du direct :"
|
||||||
share_live_text: "Regardez le direct sur Match Live TV :"
|
share_live_text: "Regardez le direct sur Match Live TV :"
|
||||||
|
min_quality_updated: Qualité minimale mise à jour
|
||||||
|
min_quality_error: Erreur de mise à jour de la qualité minimale
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -434,16 +434,53 @@ it:
|
|||||||
paused: In pausa
|
paused: In pausa
|
||||||
live: In onda
|
live: In onda
|
||||||
waiting: In attesa
|
waiting: In attesa
|
||||||
live_share_label: "Link diretta (spettatori)"
|
live_share_label: Diretta
|
||||||
share_live: Condividi diretta
|
share_live: Condividi diretta
|
||||||
|
share_action: Condividi
|
||||||
copy_link: Copia link
|
copy_link: Copia link
|
||||||
regia_share_label: "Link regia (punteggio)"
|
copy_action: Copia
|
||||||
|
copy_live: Copia live
|
||||||
|
copy_regia: Copia regia
|
||||||
|
links_nav: Azioni rapide
|
||||||
|
regia_share_label: Regia
|
||||||
|
stream_controls_label: Live
|
||||||
share_regia: Condividi link regia
|
share_regia: Condividi link regia
|
||||||
preview_ended: Diretta terminata
|
preview_ended: Diretta terminata
|
||||||
preview_waiting: Anteprima in attesa del segnale…
|
preview_waiting: Anteprima in attesa del segnale…
|
||||||
|
preview_hide: Nascondi anteprima
|
||||||
|
preview_show: Mostra anteprima
|
||||||
|
preview_off_hint: Anteprima spenta · nessun download video
|
||||||
pause: Metti in pausa
|
pause: Metti in pausa
|
||||||
resume: Riprendi diretta
|
resume: Riprendi diretta
|
||||||
|
mute: Silenzia audio
|
||||||
|
unmute: Riattiva audio
|
||||||
stop: Chiudi diretta
|
stop: Chiudi diretta
|
||||||
|
min_quality:
|
||||||
|
label: Qualità minima di trasmissione
|
||||||
|
summary: Qualità
|
||||||
|
hint: L'encoder non scende sotto questo livello. Automatica adatta il bitrate per ridurre i salti.
|
||||||
|
recommend: "Consigliata Automatica: si adatta alla rete e al telefono, senza forzare il massimo."
|
||||||
|
auto: Automatica
|
||||||
|
telemetry:
|
||||||
|
label: Telefono
|
||||||
|
waiting: In attesa del telefono…
|
||||||
|
stale: Telefono non risponde
|
||||||
|
just_now: ora
|
||||||
|
seconds_ago: "%{n}s fa"
|
||||||
|
publisher_on: RTMP ok
|
||||||
|
publisher_off: RTMP assente
|
||||||
|
battery: Batteria
|
||||||
|
network: Rete
|
||||||
|
bitrate: Bitrate
|
||||||
|
bitrate_max: "max %{n}"
|
||||||
|
bitrate_title: "%{current} Mbps attuali · massimo %{target} Mbps"
|
||||||
|
bitrate_tap: Tocca per impostare la qualità minima
|
||||||
|
fps: FPS
|
||||||
|
thermal: Temp.
|
||||||
|
thermal_nominal: OK
|
||||||
|
thermal_fair: Calda
|
||||||
|
thermal_serious: Molto calda
|
||||||
|
thermal_critical: Critica
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set vinto
|
set_won_title: Set vinto
|
||||||
confirm: Chiudi set
|
confirm: Chiudi set
|
||||||
@@ -478,9 +515,12 @@ it:
|
|||||||
unknown_period: "—"
|
unknown_period: "—"
|
||||||
resumed: Diretta ripresa
|
resumed: Diretta ripresa
|
||||||
paused_cover: "Diretta in pausa — copertina in onda"
|
paused_cover: "Diretta in pausa — copertina in onda"
|
||||||
|
muted: "Audio silenziato — YouTube non riceve il microfono"
|
||||||
|
unmuted: "Audio microfono riattivato"
|
||||||
closed: Diretta chiusa
|
closed: Diretta chiusa
|
||||||
resume_error: Errore ripresa diretta
|
resume_error: Errore ripresa diretta
|
||||||
pause_error: Errore pausa diretta
|
pause_error: Errore pausa diretta
|
||||||
|
mute_error: Errore silenziamento audio
|
||||||
close_error: Errore chiusura
|
close_error: Errore chiusura
|
||||||
close_confirm: Chiudere definitivamente la diretta?
|
close_confirm: Chiudere definitivamente la diretta?
|
||||||
link_unavailable: Link non disponibile
|
link_unavailable: Link non disponibile
|
||||||
@@ -488,6 +528,8 @@ it:
|
|||||||
copy_prompt: "Copia il link:"
|
copy_prompt: "Copia il link:"
|
||||||
share_regia_text: "Apri questo link per gestire il punteggio della diretta:"
|
share_regia_text: "Apri questo link per gestire il punteggio della diretta:"
|
||||||
share_live_text: "Guarda la diretta su Match Live TV:"
|
share_live_text: "Guarda la diretta su Match Live TV:"
|
||||||
|
min_quality_updated: Qualità minima aggiornata
|
||||||
|
min_quality_error: Errore aggiornamento qualità minima
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ Rails.application.routes.draw do
|
|||||||
patch :stop
|
patch :stop
|
||||||
patch :pause
|
patch :pause
|
||||||
patch :resume
|
patch :resume
|
||||||
|
patch :audio_mute
|
||||||
patch :score
|
patch :score
|
||||||
post :score_action
|
post :score_action
|
||||||
get :events
|
get :events
|
||||||
@@ -55,6 +56,7 @@ Rails.application.routes.draw do
|
|||||||
post :claim_pairing
|
post :claim_pairing
|
||||||
post :regia_link
|
post :regia_link
|
||||||
post :network_test
|
post :network_test
|
||||||
|
patch :min_quality
|
||||||
get :youtube_stats
|
get :youtube_stats
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -110,6 +112,15 @@ Rails.application.routes.draw do
|
|||||||
post :regia_link
|
post :regia_link
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
resources :stream_nodes, only: %i[index create destroy] do
|
||||||
|
member do
|
||||||
|
post :drain
|
||||||
|
end
|
||||||
|
collection do
|
||||||
|
post :kill_switch
|
||||||
|
delete :clear_kill_switch
|
||||||
|
end
|
||||||
|
end
|
||||||
get "youtube/platform", to: "youtube#platform", as: :youtube_platform
|
get "youtube/platform", to: "youtube#platform", as: :youtube_platform
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -142,7 +153,9 @@ Rails.application.routes.draw do
|
|||||||
patch "regia/:token/score", to: "public/regia#score", as: :public_regia_score
|
patch "regia/:token/score", to: "public/regia#score", as: :public_regia_score
|
||||||
post "regia/:token/pause", to: "public/regia#pause", as: :public_regia_pause
|
post "regia/:token/pause", to: "public/regia#pause", as: :public_regia_pause
|
||||||
post "regia/:token/resume", to: "public/regia#resume", as: :public_regia_resume
|
post "regia/:token/resume", to: "public/regia#resume", as: :public_regia_resume
|
||||||
|
post "regia/:token/audio_mute", to: "public/regia#audio_mute", as: :public_regia_audio_mute
|
||||||
post "regia/:token/stop", to: "public/regia#stop", as: :public_regia_stop
|
post "regia/:token/stop", to: "public/regia#stop", as: :public_regia_stop
|
||||||
|
patch "regia/:token/min_quality", to: "public/regia#min_quality", as: :public_regia_min_quality
|
||||||
|
|
||||||
root to: "public/pages#home"
|
root to: "public/pages#home"
|
||||||
get "sitemap.xml", to: "public/sitemap#show", defaults: { format: :xml }
|
get "sitemap.xml", to: "public/sitemap#show", defaults: { format: :xml }
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
:concurrency: 5
|
:concurrency: 5
|
||||||
:queues:
|
:queues:
|
||||||
- default
|
|
||||||
- critical
|
- critical
|
||||||
|
- youtube_relay_home
|
||||||
|
- youtube_relay
|
||||||
|
- default
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreateStreamNodes < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
create_table :stream_nodes, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
|
||||||
|
t.string :slug, null: false
|
||||||
|
t.string :hostname, null: false
|
||||||
|
t.string :role, null: false, default: "home"
|
||||||
|
t.string :status, null: false, default: "ready"
|
||||||
|
t.string :rtmp_base_url, null: false
|
||||||
|
t.string :hls_base_url, null: false
|
||||||
|
t.string :api_base_url, null: false
|
||||||
|
t.string :internal_rtmp_url
|
||||||
|
t.string :internal_hls_url
|
||||||
|
t.integer :max_publishers, null: false, default: 6
|
||||||
|
t.integer :max_relays, null: false, default: 6
|
||||||
|
t.string :provider, null: false, default: "local"
|
||||||
|
t.string :provider_instance_id
|
||||||
|
t.datetime :last_health_at
|
||||||
|
t.jsonb :metadata, null: false, default: {}
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :stream_nodes, :slug, unique: true
|
||||||
|
add_index :stream_nodes, :status
|
||||||
|
add_index :stream_nodes, :role
|
||||||
|
|
||||||
|
add_reference :stream_sessions, :stream_node, type: :uuid, foreign_key: true, null: true, index: true
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddAudioMutedToStreamSessions < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :stream_sessions, :audio_muted, :boolean, default: false, null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddMinQualityPresetToStreamSessions < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :stream_sessions, :min_quality_preset, :string, default: "auto", null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddThermalStateToDeviceStates < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :device_states, :thermal_state, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
Generated
+30
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
@@ -138,6 +138,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
|||||||
t.datetime "last_seen_at"
|
t.datetime "last_seen_at"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.string "thermal_state"
|
||||||
t.index ["stream_session_id", "device_role"], name: "index_device_states_on_stream_session_id_and_device_role", unique: true
|
t.index ["stream_session_id", "device_role"], name: "index_device_states_on_stream_session_id_and_device_role", unique: true
|
||||||
t.index ["stream_session_id"], name: "index_device_states_on_stream_session_id"
|
t.index ["stream_session_id"], name: "index_device_states_on_stream_session_id"
|
||||||
end
|
end
|
||||||
@@ -255,6 +256,29 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
|||||||
t.index ["stream_session_id"], name: "index_stream_events_on_stream_session_id"
|
t.index ["stream_session_id"], name: "index_stream_events_on_stream_session_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "stream_nodes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.string "slug", null: false
|
||||||
|
t.string "hostname", null: false
|
||||||
|
t.string "role", default: "home", null: false
|
||||||
|
t.string "status", default: "ready", null: false
|
||||||
|
t.string "rtmp_base_url", null: false
|
||||||
|
t.string "hls_base_url", null: false
|
||||||
|
t.string "api_base_url", null: false
|
||||||
|
t.string "internal_rtmp_url"
|
||||||
|
t.string "internal_hls_url"
|
||||||
|
t.integer "max_publishers", default: 6, null: false
|
||||||
|
t.integer "max_relays", default: 6, null: false
|
||||||
|
t.string "provider", default: "local", null: false
|
||||||
|
t.string "provider_instance_id"
|
||||||
|
t.datetime "last_health_at"
|
||||||
|
t.jsonb "metadata", default: {}, null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["role"], name: "index_stream_nodes_on_role"
|
||||||
|
t.index ["slug"], name: "index_stream_nodes_on_slug", unique: true
|
||||||
|
t.index ["status"], name: "index_stream_nodes_on_status"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "stream_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "stream_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.uuid "match_id", null: false
|
t.uuid "match_id", null: false
|
||||||
t.uuid "user_id", null: false
|
t.uuid "user_id", null: false
|
||||||
@@ -280,10 +304,14 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
|||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.string "regia_token_digest"
|
t.string "regia_token_digest"
|
||||||
t.datetime "regia_token_expires_at"
|
t.datetime "regia_token_expires_at"
|
||||||
|
t.uuid "stream_node_id"
|
||||||
|
t.string "min_quality_preset", default: "auto", null: false
|
||||||
|
t.boolean "audio_muted", default: false, null: false
|
||||||
t.index ["match_id"], name: "index_stream_sessions_on_match_id"
|
t.index ["match_id"], name: "index_stream_sessions_on_match_id"
|
||||||
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
|
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
|
||||||
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
|
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
|
||||||
t.index ["status"], name: "index_stream_sessions_on_status"
|
t.index ["status"], name: "index_stream_sessions_on_status"
|
||||||
|
t.index ["stream_node_id"], name: "index_stream_sessions_on_stream_node_id"
|
||||||
t.index ["user_id"], name: "index_stream_sessions_on_user_id"
|
t.index ["user_id"], name: "index_stream_sessions_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -411,6 +439,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
|||||||
add_foreign_key "score_states", "stream_sessions"
|
add_foreign_key "score_states", "stream_sessions"
|
||||||
add_foreign_key "stream_events", "stream_sessions"
|
add_foreign_key "stream_events", "stream_sessions"
|
||||||
add_foreign_key "stream_sessions", "matches"
|
add_foreign_key "stream_sessions", "matches"
|
||||||
|
add_foreign_key "stream_sessions", "stream_nodes"
|
||||||
add_foreign_key "stream_sessions", "users"
|
add_foreign_key "stream_sessions", "users"
|
||||||
add_foreign_key "subscriptions", "admin_accounts", column: "admin_comped_by_id"
|
add_foreign_key "subscriptions", "admin_accounts", column: "admin_comped_by_id"
|
||||||
add_foreign_key "subscriptions", "clubs"
|
add_foreign_key "subscriptions", "clubs"
|
||||||
|
|||||||
@@ -43,3 +43,6 @@ end
|
|||||||
|
|
||||||
puts "Seed OK: coach@matchlivetv.test / Password123"
|
puts "Seed OK: coach@matchlivetv.test / Password123"
|
||||||
puts "Club: #{club.name}, Team: #{team.name}, Match: #{match.opponent_name}"
|
puts "Club: #{club.name}, Team: #{team.name}, Match: #{match.opponent_name}"
|
||||||
|
|
||||||
|
home = Streams::NodeRegistry.ensure_home_from_env!
|
||||||
|
puts "Stream node home: #{home.slug} rtmp=#{home.rtmp_base_url}"
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
namespace :streams do
|
||||||
|
namespace :nodes do
|
||||||
|
desc "Assicura il nodo home dagli ENV MediaMTX"
|
||||||
|
task ensure_home: :environment do
|
||||||
|
node = Streams::NodeRegistry.ensure_home_from_env!
|
||||||
|
puts "home ready slug=#{node.slug} rtmp=#{node.rtmp_base_url} max=#{node.max_publishers}"
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Provisiona un nodo lab (STREAM_CLOUD_PROVIDER=local_lab|proxmox_lab)"
|
||||||
|
task provision_lab: :environment do
|
||||||
|
node = Streams::NodeProvisioner.new.provision_lab!
|
||||||
|
puts "lab node ready slug=#{node.slug} host=#{node.hostname} id=#{node.provider_instance_id}"
|
||||||
|
if ENV.fetch("STREAM_DNS_PROVIDER", "lab") == "lab"
|
||||||
|
puts "DNS lab snippet:"
|
||||||
|
puts Streams::DnsProviders::Lab.new.hosts_file_snippet
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Provisiona un nodo Hetzner Cloud + DNS mltv-stream.net (richiede HCLOUD_TOKEN)"
|
||||||
|
task provision_cloud: :environment do
|
||||||
|
node = Streams::NodeProvisioner.new.provision_cloud!
|
||||||
|
puts "cloud node ready slug=#{node.slug} host=#{node.hostname} id=#{node.provider_instance_id} ip=#{node.metadata['public_ip']}"
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Dump record DNS lab (Redis)"
|
||||||
|
task dns_lab_dump: :environment do
|
||||||
|
puts Streams::DnsProviders::Lab.new.hosts_file_snippet
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
namespace :relay do
|
||||||
|
desc "Mostra coda e slug nodo per i worker ffmpeg YouTube"
|
||||||
|
task queues: :environment do
|
||||||
|
puts "local_slug=#{Streams::YoutubeRelay.local_node_slug} worker=#{Streams::YoutubeRelay.worker?}"
|
||||||
|
StreamNode.order(:slug).find_each do |node|
|
||||||
|
puts "node=#{node.slug} queue=youtube_relay_#{node.slug} status=#{node.status} relays=#{node.max_relays}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Simula un telefono RTMP e verifica che ffmpeg parta sul worker del nodo lab"
|
||||||
|
task simulate: :environment do
|
||||||
|
abort "Esegui questo task nel container Rails (docker compose exec rails ...)" unless File.exist?("/.dockerenv") || ENV["FORCE_RELAY_SIMULATE"] == "1"
|
||||||
|
|
||||||
|
home = Streams::NodeRegistry.ensure_home_from_env!
|
||||||
|
lab = StreamNode.find_by(slug: ENV.fetch("STREAM_NODE_SLUG", "ingest-lab-01"))
|
||||||
|
lab ||= Streams::NodeProvisioner.new.provision_lab!
|
||||||
|
user = User.find_by!(email: ENV.fetch("E2E_USER_EMAIL", "coach@matchlivetv.test"))
|
||||||
|
team = user.teams.first || user.clubs.first&.teams&.first || Team.first
|
||||||
|
abort "Nessun team per #{user.email}" unless team
|
||||||
|
match = team.matches.first || team.matches.create!(opponent_name: "Relay sim")
|
||||||
|
|
||||||
|
previous_max = home.max_publishers
|
||||||
|
filler = nil
|
||||||
|
session = nil
|
||||||
|
ffmpeg_pid = nil
|
||||||
|
begin
|
||||||
|
home.update!(max_publishers: 1, status: "ready")
|
||||||
|
filler = occupy_home!(home, user, match)
|
||||||
|
session = build_lab_youtube_session!(lab, user, match)
|
||||||
|
Mediamtx::Client.for_session(session).create_path(session)
|
||||||
|
session.begin_connect! if session.may_begin_connect?
|
||||||
|
session.update!(status: "connecting") unless session.status.in?(%w[connecting live reconnecting])
|
||||||
|
|
||||||
|
puts "filler_home=#{filler.id} lab_session=#{session.id} node=#{session.stream_node.slug} queue=#{Streams::YoutubeRelay.queue_for(session)}"
|
||||||
|
ffmpeg_pid = spawn_test_rtmp!(session)
|
||||||
|
wait_for!("publisher MediaMTX", 30) { Mediamtx::PublisherOnline.active?(session.reload) }
|
||||||
|
|
||||||
|
Streams::YoutubeRelay.enqueue_ensure!(session)
|
||||||
|
wait_for!("owner Redis sul nodo lab", 25) do
|
||||||
|
owner_for(session.id) == lab.slug
|
||||||
|
end
|
||||||
|
|
||||||
|
owner = owner_for(session.id)
|
||||||
|
home_owns = relay_redis.sismember(format(Streams::YoutubeRelay::OWNED_SET, "home"), session.id)
|
||||||
|
puts "owner=#{owner} home_owns=#{home_owns} queue=#{Streams::YoutubeRelay.queue_for(session)}"
|
||||||
|
abort "FAIL: owner atteso #{lab.slug}, trovato #{owner.inspect}" unless owner == lab.slug
|
||||||
|
abort "FAIL: il worker home ha reclamato la sessione lab" if home_owns
|
||||||
|
puts "OK — ffmpeg assegnato al worker #{lab.slug}, non a home"
|
||||||
|
ensure
|
||||||
|
begin
|
||||||
|
Process.kill("TERM", ffmpeg_pid) if ffmpeg_pid
|
||||||
|
rescue Errno::ESRCH
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
session&.update!(status: "ended", ended_at: Time.current) if session&.persisted?
|
||||||
|
filler&.update!(status: "ended", ended_at: Time.current) if filler&.persisted?
|
||||||
|
home.update!(max_publishers: previous_max)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def occupy_home!(home, user, match)
|
||||||
|
StreamSession.create!(
|
||||||
|
match: match,
|
||||||
|
user: user,
|
||||||
|
platform: "matchlivetv",
|
||||||
|
status: "live",
|
||||||
|
privacy_status: "unlisted",
|
||||||
|
stream_node: home,
|
||||||
|
publish_token: SecureRandom.hex(8),
|
||||||
|
started_at: Time.current
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_lab_youtube_session!(lab, user, match)
|
||||||
|
StreamSession.create!(
|
||||||
|
match: match,
|
||||||
|
user: user,
|
||||||
|
platform: "youtube",
|
||||||
|
status: "idle",
|
||||||
|
privacy_status: "unlisted",
|
||||||
|
stream_node: lab,
|
||||||
|
publish_token: SecureRandom.hex(8),
|
||||||
|
stream_key: "e2e-dummy-#{SecureRandom.hex(6)}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def spawn_test_rtmp!(session)
|
||||||
|
intake = "#{session.mediamtx_internal_rtmp_url.chomp('/')}/#{session.mediamtx_path_name}"
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||||
|
"-re", "-f", "lavfi", "-i", "testsrc=size=1280x720:rate=30",
|
||||||
|
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000",
|
||||||
|
"-t", "40",
|
||||||
|
"-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p", "-b:v", "1500k",
|
||||||
|
"-c:a", "aac", "-ac", "1", "-ar", "48000", "-b:a", "96k",
|
||||||
|
"-f", "flv", intake
|
||||||
|
]
|
||||||
|
pid = Process.spawn(*cmd, %i[out err] => "/tmp/relay_sim_ffmpeg.log")
|
||||||
|
Process.detach(pid)
|
||||||
|
puts "telefono simulato pid=#{pid} rtmp=#{intake}"
|
||||||
|
pid
|
||||||
|
end
|
||||||
|
|
||||||
|
def wait_for!(label, timeout_sec)
|
||||||
|
deadline = Time.now + timeout_sec
|
||||||
|
loop do
|
||||||
|
return true if yield
|
||||||
|
abort "FAIL timeout: #{label}" if Time.now >= deadline
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def owner_for(session_id)
|
||||||
|
relay_redis.get(format(Streams::YoutubeRelay::OWNER_KEY, session_id))
|
||||||
|
end
|
||||||
|
|
||||||
|
def relay_redis
|
||||||
|
@relay_redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://redis:6379/0"))
|
||||||
|
end
|
||||||
+103
-10
@@ -228,6 +228,8 @@ body.admin-body {
|
|||||||
.badge--live { background: var(--red); color: #fff; }
|
.badge--live { background: var(--red); color: #fff; }
|
||||||
.badge--connecting { background: #ff9800; color: #111; }
|
.badge--connecting { background: #ff9800; color: #111; }
|
||||||
.badge--paused { background: #555; color: #fff; }
|
.badge--paused { background: #555; color: #fff; }
|
||||||
|
.badge--ready { background: #1b5e20; color: #c8e6c9; }
|
||||||
|
.badge--ok { background: #1b5e20; color: #c8e6c9; }
|
||||||
|
|
||||||
.team-list {
|
.team-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -345,28 +347,40 @@ body.admin-body {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-btn--secondary {
|
|
||||||
background: #333;
|
|
||||||
color: #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-btn--secondary:hover {
|
|
||||||
background: #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-btn {
|
.admin-btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.45rem 0.9rem;
|
padding: 0.45rem 0.9rem;
|
||||||
border: none;
|
border: 1px solid transparent;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
background: #2a2a36;
|
||||||
|
color: #eee;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-btn:hover { filter: brightness(1.08); }
|
||||||
|
|
||||||
.admin-btn--sm { padding: 0.25rem 0.55rem; font-size: 0.75rem; }
|
.admin-btn--sm { padding: 0.25rem 0.55rem; font-size: 0.75rem; }
|
||||||
|
|
||||||
|
.admin-btn--primary {
|
||||||
|
background: var(--red) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border-color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn--secondary {
|
||||||
|
background: #333;
|
||||||
|
color: #eee;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn--secondary:hover {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-btn--danger {
|
.admin-btn--danger {
|
||||||
background: var(--red);
|
background: var(--red);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -374,6 +388,85 @@ body.admin-body {
|
|||||||
|
|
||||||
.admin-btn--danger:hover { filter: brightness(1.1); }
|
.admin-btn--danger:hover { filter: brightness(1.1); }
|
||||||
|
|
||||||
|
.admin-btn--outline {
|
||||||
|
background: transparent;
|
||||||
|
border-color: #555;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page-head {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page-title {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page-sub {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.55rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-toolbar form {
|
||||||
|
display: inline;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: #0a0a0e;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #cfcfd8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-value-unit {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-left: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-value--sm {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.muted { color: var(--muted); }
|
.muted { color: var(--muted); }
|
||||||
.empty { color: var(--muted); font-size: 0.9rem; margin: 0; }
|
.empty { color: var(--muted); font-size: 0.9rem; margin: 0; }
|
||||||
|
|
||||||
|
|||||||
+291
-41
@@ -4,6 +4,8 @@
|
|||||||
--regia-surface: #14141c;
|
--regia-surface: #14141c;
|
||||||
--regia-red: #e53935;
|
--regia-red: #e53935;
|
||||||
--regia-yellow: #f9a825;
|
--regia-yellow: #f9a825;
|
||||||
|
--regia-green: #43a047;
|
||||||
|
--regia-orange: #fb8c00;
|
||||||
--regia-text: #f5f5f5;
|
--regia-text: #f5f5f5;
|
||||||
--regia-muted: #9ca3af;
|
--regia-muted: #9ca3af;
|
||||||
--regia-safe-bottom: env(safe-area-inset-bottom, 0px);
|
--regia-safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
@@ -22,37 +24,176 @@
|
|||||||
.regia-page {
|
.regia-page {
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 12px 16px calc(24px + var(--regia-safe-bottom));
|
padding: 8px 12px calc(12px + var(--regia-safe-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-header__text {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-header h1 {
|
.regia-header h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.1rem;
|
font-size: 1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-header p {
|
.regia-header p {
|
||||||
margin: 4px 0 0;
|
margin: 2px 0 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.78rem;
|
||||||
color: var(--regia-muted);
|
color: var(--regia-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-badge {
|
.regia-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 8px;
|
margin-top: 0;
|
||||||
padding: 4px 10px;
|
padding: 3px 8px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 0.7rem;
|
font-size: 0.65rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-badge--live { background: #2e7d32; color: #fff; }
|
.regia-badge--live { background: #2e7d32; color: #fff; }
|
||||||
.regia-badge--wait { background: #444; color: #ddd; }
|
.regia-badge--wait { background: #444; color: #ddd; }
|
||||||
.regia-badge--ended { background: #374151; color: #ddd; }
|
.regia-badge--ended { background: #374151; color: #ddd; }
|
||||||
|
|
||||||
|
.regia-toolbar {
|
||||||
|
margin: 8px 0 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-tool-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-tool-group__label {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-tool-group__btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-tool-group--stream {
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #16161f;
|
||||||
|
color: var(--regia-text);
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn svg.icon-fill {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn svg.icon-outline {
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn .icon-eye-off,
|
||||||
|
.regia-icon-btn .icon-play,
|
||||||
|
.regia-icon-btn .icon-mic-off {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn.is-off .icon-eye {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn.is-off .icon-eye-off {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn.is-paused .icon-pause {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn.is-paused .icon-play {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn.is-muted .icon-mic {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn.is-muted .icon-mic-off {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn:active { transform: scale(0.96); }
|
||||||
|
|
||||||
|
.regia-icon-btn.is-off,
|
||||||
|
.regia-icon-btn.is-muted {
|
||||||
|
color: var(--regia-yellow);
|
||||||
|
border-color: #5a4a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-icon-btn--danger {
|
||||||
|
color: var(--regia-red);
|
||||||
|
border-color: #7a2a28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-preview-wrap {
|
||||||
|
margin: 6px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-preview-wrap.is-off {
|
||||||
|
margin: 0;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.regia-preview {
|
.regia-preview {
|
||||||
margin: 14px 0;
|
margin: 0;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #000;
|
background: #000;
|
||||||
@@ -90,29 +231,29 @@
|
|||||||
.regia-scoreboard {
|
.regia-scoreboard {
|
||||||
background: var(--regia-surface);
|
background: var(--regia-surface);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 16px;
|
padding: 10px 12px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-sets {
|
.regia-sets {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.82rem;
|
font-size: 0.78rem;
|
||||||
color: var(--regia-muted);
|
color: var(--regia-muted);
|
||||||
margin: 0 0 8px;
|
margin: 0 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-score-line {
|
.regia-score-line {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 1.5rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-partials {
|
.regia-partials {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.78rem;
|
font-size: 0.75rem;
|
||||||
color: var(--regia-muted);
|
color: var(--regia-muted);
|
||||||
margin: 8px 0 0;
|
margin: 4px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-team-row {
|
.regia-team-row {
|
||||||
@@ -120,7 +261,7 @@
|
|||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 16px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-team-name {
|
.regia-team-name {
|
||||||
@@ -131,7 +272,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.regia-points {
|
.regia-points {
|
||||||
font-size: 2rem;
|
font-size: 1.55rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -146,7 +287,7 @@
|
|||||||
.regia-btn {
|
.regia-btn {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 16px 12px;
|
padding: 10px 8px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -156,8 +297,8 @@
|
|||||||
|
|
||||||
.regia-btn:active { transform: scale(0.98); }
|
.regia-btn:active { transform: scale(0.98); }
|
||||||
.regia-btn--point { background: var(--regia-red); color: #fff; }
|
.regia-btn--point { background: var(--regia-red); color: #fff; }
|
||||||
.regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 12px; }
|
.regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 8px; }
|
||||||
.regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 10px; }
|
.regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 8px; padding: 12px; }
|
||||||
.regia-btn--outline {
|
.regia-btn--outline {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid #444;
|
border: 1px solid #444;
|
||||||
@@ -165,6 +306,10 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
.regia-btn--outline.is-muted {
|
||||||
|
border-color: var(--regia-yellow);
|
||||||
|
color: var(--regia-yellow);
|
||||||
|
}
|
||||||
.regia-btn--danger {
|
.regia-btn--danger {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--regia-red);
|
border: 1px solid var(--regia-red);
|
||||||
@@ -173,26 +318,6 @@
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-share-group {
|
|
||||||
margin: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-share-label {
|
|
||||||
margin: 0 0 6px;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--regia-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-share {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-share .regia-btn { flex: 1; font-size: 0.9rem; padding: 12px; }
|
|
||||||
|
|
||||||
.regia-toast {
|
.regia-toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: calc(16px + var(--regia-safe-bottom));
|
bottom: calc(16px + var(--regia-safe-bottom));
|
||||||
@@ -240,7 +365,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--regia-muted);
|
color: var(--regia-muted);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
padding-top: 24px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-clock-line {
|
.regia-clock-line {
|
||||||
@@ -255,3 +380,128 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.regia-quality {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding: 8px 10px 10px;
|
||||||
|
background: var(--regia-surface);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-quality[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
.regia-quality__hint {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-quality label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-quality select {
|
||||||
|
width: 100%;
|
||||||
|
background: #0a0a0e;
|
||||||
|
color: var(--regia-text);
|
||||||
|
border: 1px solid #2a2a35;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-telemetry {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-telemetry__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px 2px 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--regia-surface);
|
||||||
|
color: var(--regia-text);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip--action {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip--action:focus-visible {
|
||||||
|
outline: 2px solid var(--regia-yellow);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip--action.is-open {
|
||||||
|
border-color: #3a3a48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip__label {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip__value {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.15;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip__hint {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-chip__hint[hidden] { display: none; }
|
||||||
|
|
||||||
|
.regia-chip.is-ok { color: var(--regia-green); }
|
||||||
|
.regia-chip.is-warn { color: var(--regia-orange); }
|
||||||
|
.regia-chip.is-bad { color: var(--regia-red); }
|
||||||
|
|
||||||
|
.regia-telemetry__meta {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-telemetry.is-stale .regia-telemetry__meta {
|
||||||
|
color: var(--regia-yellow);
|
||||||
|
}
|
||||||
|
|||||||
+301
-5
@@ -25,9 +25,12 @@
|
|||||||
partialsPrefix: "Parziali:",
|
partialsPrefix: "Parziali:",
|
||||||
resumed: "Diretta ripresa",
|
resumed: "Diretta ripresa",
|
||||||
pausedCover: "Diretta in pausa — copertina in onda",
|
pausedCover: "Diretta in pausa — copertina in onda",
|
||||||
|
muted: "Audio silenziato — YouTube non riceve il microfono",
|
||||||
|
unmuted: "Audio microfono riattivato",
|
||||||
closed: "Diretta chiusa",
|
closed: "Diretta chiusa",
|
||||||
resumeError: "Errore ripresa diretta",
|
resumeError: "Errore ripresa diretta",
|
||||||
pauseError: "Errore pausa diretta",
|
pauseError: "Errore pausa diretta",
|
||||||
|
muteError: "Errore silenziamento audio",
|
||||||
closeError: "Errore chiusura",
|
closeError: "Errore chiusura",
|
||||||
closeConfirm: "Chiudere definitivamente la diretta?",
|
closeConfirm: "Chiudere definitivamente la diretta?",
|
||||||
linkUnavailable: "Link non disponibile",
|
linkUnavailable: "Link non disponibile",
|
||||||
@@ -37,13 +40,34 @@
|
|||||||
shareLiveText: "Guarda la diretta su Match Live TV:",
|
shareLiveText: "Guarda la diretta su Match Live TV:",
|
||||||
previewWaiting: "Anteprima in attesa del segnale…",
|
previewWaiting: "Anteprima in attesa del segnale…",
|
||||||
streamEnded: "Diretta terminata",
|
streamEnded: "Diretta terminata",
|
||||||
|
previewHide: "Nascondi anteprima",
|
||||||
|
previewShow: "Mostra anteprima",
|
||||||
|
previewOffHint: "Anteprima spenta · nessun download video",
|
||||||
resumeLabel: "Riprendi diretta",
|
resumeLabel: "Riprendi diretta",
|
||||||
pauseLabel: "Metti in pausa",
|
pauseLabel: "Metti in pausa",
|
||||||
|
muteLabel: "Silenzia audio",
|
||||||
|
unmuteLabel: "Riattiva audio",
|
||||||
endedBadge: "Terminata",
|
endedBadge: "Terminata",
|
||||||
pausedBadge: "In pausa",
|
pausedBadge: "In pausa",
|
||||||
liveBadge: "In onda",
|
liveBadge: "In onda",
|
||||||
waitingBadge: "In attesa",
|
waitingBadge: "In attesa",
|
||||||
subtitlePrefix: "Regia ·"
|
subtitlePrefix: "Regia ·",
|
||||||
|
minQualityUpdated: "Qualità minima aggiornata",
|
||||||
|
minQualityError: "Errore aggiornamento qualità minima",
|
||||||
|
minQualityAuto: "Automatica",
|
||||||
|
telemetryWaiting: "In attesa del telefono…",
|
||||||
|
telemetryStale: "Telefono non risponde",
|
||||||
|
telemetryJustNow: "ora",
|
||||||
|
telemetrySecondsAgo: "%{n}s fa",
|
||||||
|
telemetryPublisherOn: "RTMP ok",
|
||||||
|
telemetryPublisherOff: "RTMP assente",
|
||||||
|
telemetryThermalNominal: "OK",
|
||||||
|
telemetryThermalFair: "Calda",
|
||||||
|
telemetryThermalSerious: "Calda",
|
||||||
|
telemetryThermalCritical: "Critica",
|
||||||
|
telemetryBitrateMax: "max %{n}",
|
||||||
|
telemetryBitrateTitle: "%{current} Mbps attuali · massimo %{target} Mbps",
|
||||||
|
telemetryBitrateTap: "Tocca per impostare la qualità minima"
|
||||||
};
|
};
|
||||||
|
|
||||||
function loadI18n() {
|
function loadI18n() {
|
||||||
@@ -76,7 +100,9 @@
|
|||||||
scoreUrl: root.dataset.scoreUrl,
|
scoreUrl: root.dataset.scoreUrl,
|
||||||
pauseUrl: root.dataset.pauseUrl,
|
pauseUrl: root.dataset.pauseUrl,
|
||||||
resumeUrl: root.dataset.resumeUrl,
|
resumeUrl: root.dataset.resumeUrl,
|
||||||
|
audioMuteUrl: root.dataset.audioMuteUrl,
|
||||||
stopUrl: root.dataset.stopUrl,
|
stopUrl: root.dataset.stopUrl,
|
||||||
|
minQualityUrl: root.dataset.minQualityUrl,
|
||||||
cableUrl: root.dataset.cableUrl,
|
cableUrl: root.dataset.cableUrl,
|
||||||
hlsUrl: root.dataset.hlsUrl,
|
hlsUrl: root.dataset.hlsUrl,
|
||||||
homeName: root.dataset.homeName,
|
homeName: root.dataset.homeName,
|
||||||
@@ -102,15 +128,30 @@
|
|||||||
modalCancel: document.getElementById("modal-cancel"),
|
modalCancel: document.getElementById("modal-cancel"),
|
||||||
preview: document.getElementById("regia-preview"),
|
preview: document.getElementById("regia-preview"),
|
||||||
previewPlaceholder: document.getElementById("regia-preview-placeholder"),
|
previewPlaceholder: document.getElementById("regia-preview-placeholder"),
|
||||||
|
previewWrap: document.getElementById("regia-preview-wrap"),
|
||||||
|
btnTogglePreview: document.getElementById("btn-toggle-preview"),
|
||||||
btnPause: document.getElementById("btn-pause"),
|
btnPause: document.getElementById("btn-pause"),
|
||||||
subtitle: document.getElementById("regia-subtitle")
|
btnAudioMute: document.getElementById("btn-audio-mute"),
|
||||||
|
minQuality: document.getElementById("min-quality"),
|
||||||
|
subtitle: document.getElementById("regia-subtitle"),
|
||||||
|
telemetry: document.getElementById("regia-telemetry"),
|
||||||
|
telBattery: document.getElementById("tel-battery"),
|
||||||
|
telNetwork: document.getElementById("tel-network"),
|
||||||
|
telBitrate: document.getElementById("tel-bitrate"),
|
||||||
|
telFps: document.getElementById("tel-fps"),
|
||||||
|
telThermal: document.getElementById("tel-thermal"),
|
||||||
|
telMeta: document.getElementById("tel-meta"),
|
||||||
|
qualityPanel: document.getElementById("regia-quality")
|
||||||
};
|
};
|
||||||
|
|
||||||
let pendingAction = null;
|
let pendingAction = null;
|
||||||
let hls = null;
|
let hls = null;
|
||||||
let previewStarted = false;
|
let previewStarted = false;
|
||||||
|
let previewWanted = true;
|
||||||
let wasPausedPreview = false;
|
let wasPausedPreview = false;
|
||||||
let streamPaused = root.dataset.initialPaused === "true";
|
let streamPaused = root.dataset.initialPaused === "true";
|
||||||
|
let audioMuted = root.dataset.initialAudioMuted === "true";
|
||||||
|
const PREVIEW_PREF_KEY = "mltv-regia-preview";
|
||||||
|
|
||||||
function hlsUrlFresh() {
|
function hlsUrlFresh() {
|
||||||
const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
|
const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
|
||||||
@@ -139,8 +180,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readPreviewPref() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(PREVIEW_PREF_KEY) !== "0";
|
||||||
|
} catch (_) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePreviewPref(on) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PREVIEW_PREF_KEY, on ? "1" : "0");
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPreviewToggleUi() {
|
||||||
|
if (!els.previewWrap || !els.btnTogglePreview) return;
|
||||||
|
const label = previewWanted ? I18N.previewHide : I18N.previewShow;
|
||||||
|
els.previewWrap.classList.toggle("is-off", !previewWanted);
|
||||||
|
els.btnTogglePreview.classList.toggle("is-off", !previewWanted);
|
||||||
|
els.btnTogglePreview.setAttribute("aria-pressed", previewWanted ? "true" : "false");
|
||||||
|
els.btnTogglePreview.setAttribute("aria-label", label);
|
||||||
|
els.btnTogglePreview.title = previewWanted ? I18N.previewHide : I18N.previewOffHint;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPreviewWanted(on) {
|
||||||
|
previewWanted = !!on;
|
||||||
|
writePreviewPref(previewWanted);
|
||||||
|
syncPreviewToggleUi();
|
||||||
|
if (previewWanted) {
|
||||||
|
startPreview();
|
||||||
|
} else {
|
||||||
|
destroyPreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function refreshPreviewForCover() {
|
function refreshPreviewForCover() {
|
||||||
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed) return;
|
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed || !previewWanted) return;
|
||||||
destroyPreview();
|
destroyPreview();
|
||||||
previewStarted = false;
|
previewStarted = false;
|
||||||
startPreview();
|
startPreview();
|
||||||
@@ -243,11 +319,26 @@
|
|||||||
if (!els.btnPause) return;
|
if (!els.btnPause) return;
|
||||||
const paused = !!(data.paused || data.status === "paused");
|
const paused = !!(data.paused || data.status === "paused");
|
||||||
streamPaused = paused;
|
streamPaused = paused;
|
||||||
els.btnPause.textContent = paused ? I18N.resumeLabel : I18N.pauseLabel;
|
const label = paused ? I18N.resumeLabel : I18N.pauseLabel;
|
||||||
|
els.btnPause.classList.toggle("is-paused", paused);
|
||||||
|
els.btnPause.setAttribute("aria-label", label);
|
||||||
|
els.btnPause.title = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncMuteButton(data) {
|
||||||
|
if (!els.btnAudioMute) return;
|
||||||
|
if (typeof data.audio_muted === "boolean") {
|
||||||
|
audioMuted = data.audio_muted;
|
||||||
|
}
|
||||||
|
const label = audioMuted ? I18N.unmuteLabel : I18N.muteLabel;
|
||||||
|
els.btnAudioMute.classList.toggle("is-muted", audioMuted);
|
||||||
|
els.btnAudioMute.setAttribute("aria-label", label);
|
||||||
|
els.btnAudioMute.title = label;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBadge(data) {
|
function setBadge(data) {
|
||||||
syncPauseButton(data);
|
syncPauseButton(data);
|
||||||
|
syncMuteButton(data);
|
||||||
if (data.stream_closed) {
|
if (data.stream_closed) {
|
||||||
els.badge.textContent = I18N.endedBadge;
|
els.badge.textContent = I18N.endedBadge;
|
||||||
els.badge.className = "regia-badge regia-badge--ended";
|
els.badge.className = "regia-badge regia-badge--ended";
|
||||||
@@ -364,6 +455,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const paused = data.paused || data.status === "paused";
|
const paused = data.paused || data.status === "paused";
|
||||||
|
if (!previewWanted) {
|
||||||
|
destroyPreview();
|
||||||
|
wasPausedPreview = paused;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data.on_air) {
|
if (data.on_air) {
|
||||||
if (!previewStarted) {
|
if (!previewStarted) {
|
||||||
startPreview();
|
startPreview();
|
||||||
@@ -382,6 +478,119 @@
|
|||||||
wasPausedPreview = paused;
|
wasPausedPreview = paused;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mbpsNumber(bps) {
|
||||||
|
const n = Number(bps);
|
||||||
|
if (!n || n <= 0) return null;
|
||||||
|
const mbps = n >= 10_000 ? n / 1e6 : n / 1e3;
|
||||||
|
return mbps >= 10 ? mbps.toFixed(0) : mbps.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAge(iso) {
|
||||||
|
if (!iso) return null;
|
||||||
|
const then = Date.parse(iso);
|
||||||
|
if (!then) return null;
|
||||||
|
const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
|
||||||
|
if (secs < 8) return I18N.telemetryJustNow;
|
||||||
|
return fillTemplate(I18N.telemetrySecondsAgo, { n: secs });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setChip(el, text, level, hint) {
|
||||||
|
if (!el) return;
|
||||||
|
const valueEl = el.querySelector(".regia-chip__value") || el;
|
||||||
|
valueEl.textContent = text;
|
||||||
|
el.classList.toggle("is-ok", level === "ok");
|
||||||
|
el.classList.toggle("is-warn", level === "warn");
|
||||||
|
el.classList.toggle("is-bad", level === "bad");
|
||||||
|
const hintEl = el.querySelector(".regia-chip__hint");
|
||||||
|
if (hintEl) {
|
||||||
|
hintEl.textContent = hint || "";
|
||||||
|
hintEl.hidden = !hint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatThermal(state) {
|
||||||
|
const key = String(state || "").toLowerCase();
|
||||||
|
if (key === "nominal") return { text: I18N.telemetryThermalNominal, level: "ok" };
|
||||||
|
if (key === "fair") return { text: I18N.telemetryThermalFair, level: "warn" };
|
||||||
|
if (key === "serious") return { text: I18N.telemetryThermalSerious, level: "bad" };
|
||||||
|
if (key === "critical") return { text: I18N.telemetryThermalCritical, level: "bad" };
|
||||||
|
return { text: "—", level: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bitrateLevel(current, target, paused) {
|
||||||
|
if (paused || !current || current <= 0) return null;
|
||||||
|
if (target && target > 0) {
|
||||||
|
const ratio = current / target;
|
||||||
|
if (ratio >= 0.7) return "ok";
|
||||||
|
if (ratio >= 0.4) return "warn";
|
||||||
|
return "bad";
|
||||||
|
}
|
||||||
|
const mbps = current >= 10_000 ? current / 1e6 : current / 1e3;
|
||||||
|
if (mbps >= 2.5) return "ok";
|
||||||
|
if (mbps >= 1.5) return "warn";
|
||||||
|
return "bad";
|
||||||
|
}
|
||||||
|
|
||||||
|
function bitrateTitle(currentMbps, targetMbps) {
|
||||||
|
const tap = I18N.telemetryBitrateTap;
|
||||||
|
if (currentMbps && targetMbps) {
|
||||||
|
return `${fillTemplate(I18N.telemetryBitrateTitle, { current: currentMbps, target: targetMbps })} · ${tap}`;
|
||||||
|
}
|
||||||
|
if (currentMbps) return `${currentMbps} Mbps · ${tap}`;
|
||||||
|
return tap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTelemetry(data) {
|
||||||
|
if (!els.telemetry) return;
|
||||||
|
const tel = data.telemetry || {};
|
||||||
|
const battery = tel.battery;
|
||||||
|
const fps = tel.fps;
|
||||||
|
const bitrate = tel.bitrate;
|
||||||
|
const target = tel.target_bitrate || tel.targetBitrate;
|
||||||
|
const lastSeen = tel.last_seen_at || tel.lastSeenAt;
|
||||||
|
const ageSecs = lastSeen ? Math.max(0, Math.round((Date.now() - Date.parse(lastSeen)) / 1000)) : null;
|
||||||
|
const stale = ageSecs == null || ageSecs > 25;
|
||||||
|
const publishing = !!tel.publisher_online || !!tel.publisherOnline;
|
||||||
|
|
||||||
|
let battLevel = null;
|
||||||
|
let battText = "—";
|
||||||
|
if (battery != null && battery !== "") {
|
||||||
|
battText = `${battery}%`;
|
||||||
|
if (battery <= 15) battLevel = "bad";
|
||||||
|
else if (battery <= 25) battLevel = "warn";
|
||||||
|
}
|
||||||
|
setChip(els.telBattery, battText, battLevel);
|
||||||
|
setChip(els.telNetwork, tel.network || "—");
|
||||||
|
|
||||||
|
const currentMbps = mbpsNumber(bitrate);
|
||||||
|
const targetMbps = mbpsNumber(target);
|
||||||
|
const rateText = currentMbps || "—";
|
||||||
|
const rateHint = currentMbps && targetMbps
|
||||||
|
? fillTemplate(I18N.telemetryBitrateMax, { n: targetMbps })
|
||||||
|
: null;
|
||||||
|
if (els.telBitrate) els.telBitrate.title = bitrateTitle(currentMbps, targetMbps);
|
||||||
|
setChip(els.telBitrate, rateText, bitrateLevel(Number(bitrate), Number(target), data.paused), rateHint);
|
||||||
|
|
||||||
|
let fpsLevel = null;
|
||||||
|
if (fps != null && fps !== "" && Number(fps) > 0 && Number(fps) < 20) fpsLevel = "warn";
|
||||||
|
setChip(els.telFps, fps && Number(fps) > 0 ? String(fps) : "—", fpsLevel);
|
||||||
|
|
||||||
|
const thermal = formatThermal(tel.thermal_state || tel.thermalState);
|
||||||
|
setChip(els.telThermal, thermal.text, thermal.level);
|
||||||
|
|
||||||
|
els.telemetry.classList.toggle("is-stale", stale && !data.paused && !data.stream_closed);
|
||||||
|
if (!els.telMeta) return;
|
||||||
|
if (stale && !lastSeen) {
|
||||||
|
els.telMeta.textContent = I18N.telemetryWaiting;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parts = [publishing ? I18N.telemetryPublisherOn : I18N.telemetryPublisherOff];
|
||||||
|
const age = formatAge(lastSeen);
|
||||||
|
if (age) parts.push(age);
|
||||||
|
if (stale && !data.paused && !data.stream_closed) parts.push(I18N.telemetryStale);
|
||||||
|
els.telMeta.textContent = parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
async function pollStatus() {
|
async function pollStatus() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } });
|
const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } });
|
||||||
@@ -389,6 +598,8 @@
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
applyScorePayload(data);
|
applyScorePayload(data);
|
||||||
setBadge(data);
|
setBadge(data);
|
||||||
|
syncMinQuality(data);
|
||||||
|
syncTelemetry(data);
|
||||||
syncPreviewFromStatus(data);
|
syncPreviewFromStatus(data);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
@@ -401,7 +612,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startPreview() {
|
function startPreview() {
|
||||||
if (cfg.streamClosed || !els.preview || !cfg.hlsUrl || previewStarted) return;
|
if (cfg.streamClosed || !previewWanted || !els.preview || !cfg.hlsUrl || previewStarted) return;
|
||||||
|
|
||||||
previewStarted = true;
|
previewStarted = true;
|
||||||
const url = hlsUrlFresh();
|
const url = hlsUrlFresh();
|
||||||
@@ -454,10 +665,30 @@
|
|||||||
const wasPaused = streamPaused;
|
const wasPaused = streamPaused;
|
||||||
applyScorePayload(data);
|
applyScorePayload(data);
|
||||||
setBadge(data);
|
setBadge(data);
|
||||||
|
syncMinQuality(data);
|
||||||
|
syncTelemetry(data);
|
||||||
syncPreviewFromStatus(data);
|
syncPreviewFromStatus(data);
|
||||||
toast(wasPaused ? I18N.resumed : I18N.pausedCover);
|
toast(wasPaused ? I18N.resumed : I18N.pausedCover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleMuteAudio() {
|
||||||
|
if (!cfg.audioMuteUrl) return;
|
||||||
|
const nextMuted = !audioMuted;
|
||||||
|
const res = await fetch(cfg.audioMuteUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify({ muted: nextMuted })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || I18N.muteError);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
applyScorePayload(data);
|
||||||
|
setBadge(data);
|
||||||
|
toast(audioMuted ? I18N.muted : I18N.unmuted);
|
||||||
|
}
|
||||||
|
|
||||||
async function stopStream() {
|
async function stopStream() {
|
||||||
const ok = confirm(I18N.closeConfirm);
|
const ok = confirm(I18N.closeConfirm);
|
||||||
if (!ok) return null;
|
if (!ok) return null;
|
||||||
@@ -471,8 +702,73 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message)));
|
els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message)));
|
||||||
|
els.btnAudioMute?.addEventListener("click", () => toggleMuteAudio().catch((e) => toast(e.message)));
|
||||||
document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message)));
|
document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message)));
|
||||||
|
|
||||||
|
let minQualityBusy = false;
|
||||||
|
|
||||||
|
function setQualityPanelOpen(open) {
|
||||||
|
if (!els.qualityPanel || !els.telBitrate) return;
|
||||||
|
els.qualityPanel.hidden = !open;
|
||||||
|
els.telBitrate.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
els.telBitrate.classList.toggle("is-open", open);
|
||||||
|
if (open && els.minQuality) {
|
||||||
|
window.requestAnimationFrame(() => els.minQuality.focus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncMinQuality(data) {
|
||||||
|
if (!els.minQuality || minQualityBusy) return;
|
||||||
|
const value = data.min_quality_preset || "auto";
|
||||||
|
if (els.minQuality.value !== value) els.minQuality.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setMinQuality(preset) {
|
||||||
|
if (!cfg.minQualityUrl) return;
|
||||||
|
minQualityBusy = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(cfg.minQualityUrl, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify({ min_quality_preset: preset })
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || I18N.minQualityError);
|
||||||
|
minQualityBusy = false;
|
||||||
|
syncMinQuality(data);
|
||||||
|
toast(I18N.minQualityUpdated);
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || I18N.minQualityError);
|
||||||
|
} finally {
|
||||||
|
minQualityBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
els.minQuality?.addEventListener("change", () => {
|
||||||
|
setMinQuality(els.minQuality.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
els.telBitrate?.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const opening = !!(els.qualityPanel && els.qualityPanel.hidden);
|
||||||
|
setQualityPanelOpen(opening);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
if (!els.qualityPanel || els.qualityPanel.hidden) return;
|
||||||
|
if (els.qualityPanel.contains(e.target) || els.telBitrate?.contains(e.target)) return;
|
||||||
|
setQualityPanelOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape") setQualityPanelOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
els.btnTogglePreview?.addEventListener("click", () => setPreviewWanted(!previewWanted));
|
||||||
|
previewWanted = readPreviewPref();
|
||||||
|
syncPreviewToggleUi();
|
||||||
|
if (!previewWanted) destroyPreview();
|
||||||
|
|
||||||
async function shareLink(url, title, text) {
|
async function shareLink(url, title, text) {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
toast(I18N.linkUnavailable);
|
toast(I18N.linkUnavailable);
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe YoutubeRelayStopJob, type: :job do
|
||||||
|
it "requeues when stop runs on the wrong host" do
|
||||||
|
session = instance_double(StreamSession, id: SecureRandom.uuid, stream_node: nil)
|
||||||
|
allow(StreamSession).to receive(:find_by).and_return(session)
|
||||||
|
allow(Streams::YoutubeRelay).to receive(:worker?).and_return(true)
|
||||||
|
allow(Streams::YoutubeRelay).to receive(:stop_on_worker!).and_return(:wrong_host)
|
||||||
|
|
||||||
|
job_proxy = double("ConfiguredJob")
|
||||||
|
expect(described_class).to receive(:set).with(wait: 2.seconds, queue: "youtube_relay_home").and_return(job_proxy)
|
||||||
|
expect(job_proxy).to receive(:perform_later).with(session.id, 1)
|
||||||
|
|
||||||
|
described_class.new.perform(session.id, 0)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe StreamSession do
|
||||||
|
it "builds ingest/hls URLs from the assigned stream node" do
|
||||||
|
node = StreamNode.create!(
|
||||||
|
slug: "ingest-01",
|
||||||
|
hostname: "ingest-01.mltv-stream.net",
|
||||||
|
role: "cloud",
|
||||||
|
status: "ready",
|
||||||
|
provider: "hetzner",
|
||||||
|
rtmp_base_url: "rtmp://ingest-01.mltv-stream.net:1935",
|
||||||
|
hls_base_url: "https://ingest-01.mltv-stream.net/hls",
|
||||||
|
api_base_url: "http://10.0.0.2:9997",
|
||||||
|
internal_rtmp_url: "rtmp://10.0.0.2:1935",
|
||||||
|
max_publishers: 4,
|
||||||
|
max_relays: 4
|
||||||
|
)
|
||||||
|
user = User.create!(email: "s@example.com", name: "S", password: "Password123", role: "coach")
|
||||||
|
club = Club.create!(name: "Club", sport: "volleyball")
|
||||||
|
team = club.teams.create!(name: "Team", sport: "volleyball", slug: "team-url")
|
||||||
|
match = team.matches.create!(opponent_name: "Opp", scheduled_at: 1.hour.from_now)
|
||||||
|
session = StreamSession.create!(
|
||||||
|
match: match, user: user, platform: "matchlivetv", status: "idle", stream_node: node
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(session.rtmp_ingest_url).to eq(
|
||||||
|
"rtmp://ingest-01.mltv-stream.net:1935/live/match_#{session.id}"
|
||||||
|
)
|
||||||
|
expect(session.hls_playback_url).to eq(
|
||||||
|
"https://ingest-01.mltv-stream.net/hls/live/match_#{session.id}/index.m3u8"
|
||||||
|
)
|
||||||
|
expect(session.mediamtx_api_base_url).to eq("http://10.0.0.2:9997")
|
||||||
|
expect(session.mediamtx_internal_rtmp_url).to eq("rtmp://10.0.0.2:1935")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "Api::V1 stream session audio mute", type: :request do
|
||||||
|
let!(:user) { User.create!(email: "mute-api@test.it", name: "U", password: "Password123", role: "coach") }
|
||||||
|
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
|
let!(:owner) { club.club_memberships.create!(user: user, role: "owner") }
|
||||||
|
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
||||||
|
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
|
||||||
|
let!(:session) { StreamSession.create!(match: match, user: user, platform: "youtube", status: "live") }
|
||||||
|
|
||||||
|
def auth_headers
|
||||||
|
post "/api/v1/auth/login", params: { email: user.email, password: "Password123" }
|
||||||
|
token = response.parsed_body["access_token"]
|
||||||
|
{ "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" }
|
||||||
|
end
|
||||||
|
|
||||||
|
it "include audio_muted nello show della sessione" do
|
||||||
|
get "/api/v1/sessions/#{session.id}", headers: auth_headers
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.parsed_body["audio_muted"]).to eq(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "silenzia e riattiva l'audio via PATCH" do
|
||||||
|
patch "/api/v1/sessions/#{session.id}/audio_mute",
|
||||||
|
params: { muted: true }.to_json,
|
||||||
|
headers: auth_headers
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.parsed_body["audio_muted"]).to eq(true)
|
||||||
|
expect(session.reload.audio_muted).to eq(true)
|
||||||
|
|
||||||
|
patch "/api/v1/sessions/#{session.id}/audio_mute",
|
||||||
|
params: { muted: false }.to_json,
|
||||||
|
headers: auth_headers
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.parsed_body["audio_muted"]).to eq(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,6 +4,7 @@ require "rails_helper"
|
|||||||
|
|
||||||
RSpec.describe "HLS proxy", type: :request do
|
RSpec.describe "HLS proxy", type: :request do
|
||||||
let(:mediamtx_hls) { "http://mediamtx.test:8888" }
|
let(:mediamtx_hls) { "http://mediamtx.test:8888" }
|
||||||
|
let(:session_id) { "cc7e90b2-c672-4401-b5d3-51fdcdc7a214" }
|
||||||
let(:playlist) do
|
let(:playlist) do
|
||||||
<<~M3U8
|
<<~M3U8
|
||||||
#EXTM3U
|
#EXTM3U
|
||||||
@@ -13,24 +14,89 @@ RSpec.describe "HLS proxy", type: :request do
|
|||||||
/live/match_#{session_id}/segment0.ts
|
/live/match_#{session_id}/segment0.ts
|
||||||
M3U8
|
M3U8
|
||||||
end
|
end
|
||||||
let(:session_id) { "cc7e90b2-c672-4401-b5d3-51fdcdc7a214" }
|
|
||||||
|
def stub_hls_get(url, status:, body: "", headers: {}, location: nil)
|
||||||
|
response_headers = headers.dup
|
||||||
|
response_headers["location"] = location if location
|
||||||
|
faraday_response = instance_double(
|
||||||
|
Faraday::Response,
|
||||||
|
status: status,
|
||||||
|
body: body,
|
||||||
|
headers: response_headers
|
||||||
|
)
|
||||||
|
conn = instance_double(Faraday::Connection)
|
||||||
|
allow_any_instance_of(HlsProxyController).to receive(:hls_conn).and_return(conn)
|
||||||
|
allow(conn).to receive(:get) do |requested, &block|
|
||||||
|
expect(requested).to start_with(url.split("?").first)
|
||||||
|
block&.call(instance_double(Faraday::Request, headers: {}))
|
||||||
|
faraday_response
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
before do
|
before do
|
||||||
allow(MatchLiveTv).to receive(:mediamtx_hls_url).and_return(mediamtx_hls)
|
allow(MatchLiveTv).to receive(:mediamtx_hls_url).and_return(mediamtx_hls)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "GET /live/match_:session_id/*" do
|
describe "GET /live/match_:session_id/*" do
|
||||||
it "proxies MediaMTX redirect and rewrites playlist paths to /hls/" do
|
it "rewrites playlist paths to /hls/" do
|
||||||
stub_request(:get, "#{mediamtx_hls}/live/match_#{session_id}/index.m3u8")
|
stub_hls_get(
|
||||||
.to_return(status: 302, headers: { "Location" => "/live/match_#{session_id}/index.m3u8?cookieCheck=1" })
|
"#{mediamtx_hls}/live/match_#{session_id}/index.m3u8",
|
||||||
stub_request(:get, "#{mediamtx_hls}/live/match_#{session_id}/index.m3u8?cookieCheck=1")
|
status: 200,
|
||||||
.to_return(status: 200, body: playlist, headers: { "Content-Type" => "application/vnd.apple.mpegurl" })
|
body: playlist,
|
||||||
|
headers: { "content-type" => "application/vnd.apple.mpegurl" }
|
||||||
|
)
|
||||||
|
|
||||||
get "/live/match_#{session_id}/index.m3u8"
|
get "/live/match_#{session_id}/index.m3u8"
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(response.body).to include("/hls/live/match_#{session_id}/segment0.ts")
|
expect(response.body).to include("/hls/live/match_#{session_id}/segment0.ts")
|
||||||
expect(response.body).not_to include("/live/match_#{session_id}/segment0.ts")
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET /hls/live/match_:id on a lab node" do
|
||||||
|
let!(:lab) do
|
||||||
|
StreamNode.create!(
|
||||||
|
slug: "ingest-lab-hls-spec",
|
||||||
|
hostname: "ingest-lab-hls-spec.lab.mltv-stream.net",
|
||||||
|
role: "lab",
|
||||||
|
status: "ready",
|
||||||
|
provider: "local",
|
||||||
|
rtmp_base_url: "rtmp://127.0.0.1:11935",
|
||||||
|
hls_base_url: "http://localhost:3000/hls",
|
||||||
|
api_base_url: "http://mediamtx_lab:9997",
|
||||||
|
internal_rtmp_url: "rtmp://mediamtx_lab:1935",
|
||||||
|
internal_hls_url: "http://mediamtx_lab:8888"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
user = User.create!(email: "hls-lab@test.com", name: "H", password: "Password123", role: "coach")
|
||||||
|
club = Club.create!(name: "HlsLab", sport: "volleyball")
|
||||||
|
team = club.teams.create!(name: "T", sport: "volleyball", slug: "hls-lab-t")
|
||||||
|
match = team.matches.create!(opponent_name: "X")
|
||||||
|
StreamSession.create!(id: session_id, match: match, user: user, platform: "matchlivetv", stream_node: lab)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "proxies HLS to the session MediaMTX, not home" do
|
||||||
|
requested = []
|
||||||
|
faraday_response = instance_double(
|
||||||
|
Faraday::Response,
|
||||||
|
status: 200,
|
||||||
|
body: playlist,
|
||||||
|
headers: { "content-type" => "application/vnd.apple.mpegurl" }
|
||||||
|
)
|
||||||
|
conn = instance_double(Faraday::Connection)
|
||||||
|
allow_any_instance_of(HlsProxyController).to receive(:hls_conn).and_return(conn)
|
||||||
|
allow(conn).to receive(:get) do |url, &block|
|
||||||
|
requested << url
|
||||||
|
block&.call(instance_double(Faraday::Request, headers: {}))
|
||||||
|
faraday_response
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/hls/live/match_#{session_id}/index.m3u8"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(requested.first).to eq("http://mediamtx_lab:8888/live/match_#{session_id}/index.m3u8")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
require "rails_helper"
|
require "rails_helper"
|
||||||
|
|
||||||
RSpec.describe "Public regia", type: :request do
|
RSpec.describe "Public regia", type: :request do
|
||||||
let!(:user) { User.create!(email: "regia@test.it", name: "U", password: "password123", role: "coach") }
|
let!(:user) { User.create!(email: "regia@test.it", name: "U", password: "Password123", role: "coach") }
|
||||||
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
||||||
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
|
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
|
||||||
@@ -12,7 +12,18 @@ RSpec.describe "Public regia", type: :request do
|
|||||||
get public_regia_path(token)
|
get public_regia_path(token)
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(response.body).to include("Condividi diretta")
|
expect(response.body).to include("Condividi diretta")
|
||||||
|
expect(response.body).to include("Copia live")
|
||||||
expect(response.body).to include("Condividi link regia")
|
expect(response.body).to include("Condividi link regia")
|
||||||
|
expect(response.body).to include("Copia regia")
|
||||||
|
expect(response.body).to include(">Diretta<")
|
||||||
|
expect(response.body).to include(">Regia<")
|
||||||
|
expect(response.body).to include("regia-tool-group")
|
||||||
|
expect(response.body).to include("Nascondi anteprima")
|
||||||
|
expect(response.body).to include('id="btn-pause"')
|
||||||
|
expect(response.body).to include('id="btn-stop"')
|
||||||
|
expect(response.body).to include("Qualità minima di trasmissione")
|
||||||
|
expect(response.body).to include('id="tel-bitrate"')
|
||||||
|
expect(response.body).to include('aria-controls="regia-quality"')
|
||||||
expect(response.body).to include("/live/#{session.id}")
|
expect(response.body).to include("/live/#{session.id}")
|
||||||
expect(response.body).not_to include("Aruba")
|
expect(response.body).not_to include("Aruba")
|
||||||
end
|
end
|
||||||
@@ -148,6 +159,29 @@ RSpec.describe "Public regia", type: :request do
|
|||||||
expect(session.reload.status).to eq("connecting")
|
expect(session.reload.status).to eq("connecting")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "silenzia e riattiva l'audio della diretta" do
|
||||||
|
token = Sessions::RegiaAccess.new(session).issue_token!
|
||||||
|
expect(session.audio_muted).to eq(false)
|
||||||
|
|
||||||
|
post public_regia_audio_mute_path(token), params: { muted: true }, as: :json
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.parsed_body["audio_muted"]).to eq(true)
|
||||||
|
expect(session.reload.audio_muted).to eq(true)
|
||||||
|
|
||||||
|
post public_regia_audio_mute_path(token), params: { muted: false }, as: :json
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.parsed_body["audio_muted"]).to eq(false)
|
||||||
|
expect(session.reload.audio_muted).to eq(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "mostra il pulsante mute sulla pagina regia" do
|
||||||
|
token = Sessions::RegiaAccess.new(session).issue_token!
|
||||||
|
get public_regia_path(token)
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include("Silenzia audio")
|
||||||
|
expect(response.body).to include("btn-audio-mute")
|
||||||
|
end
|
||||||
|
|
||||||
it "emette token valido anche oltre le 8 ore dalla partenza" do
|
it "emette token valido anche oltre le 8 ore dalla partenza" do
|
||||||
session.update!(started_at: 9.hours.ago)
|
session.update!(started_at: 9.hours.ago)
|
||||||
token = Sessions::RegiaAccess.new(session).issue_token!
|
token = Sessions::RegiaAccess.new(session).issue_token!
|
||||||
@@ -156,4 +190,69 @@ RSpec.describe "Public regia", type: :request do
|
|||||||
get public_regia_path(token)
|
get public_regia_path(token)
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "aggiorna la qualità minima di trasmissione" do
|
||||||
|
token = Sessions::RegiaAccess.new(session).issue_token!
|
||||||
|
patch public_regia_min_quality_path(token), params: { min_quality_preset: "720p_30_1.5mbps" }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(session.reload.min_quality_preset).to eq("720p_30_1.5mbps")
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["min_quality_preset"]).to eq("720p_30_1.5mbps")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "espone la telemetria della camera nello status" do
|
||||||
|
session.device_states.create!(
|
||||||
|
device_role: "camera",
|
||||||
|
battery_level: 81,
|
||||||
|
network_type: "WiFi",
|
||||||
|
current_bitrate: 2_400_000,
|
||||||
|
target_bitrate: 4_500_000,
|
||||||
|
fps: 30,
|
||||||
|
thermal_state: "nominal",
|
||||||
|
last_seen_at: Time.current
|
||||||
|
)
|
||||||
|
token = Sessions::RegiaAccess.new(session).issue_token!
|
||||||
|
get public_regia_path(token)
|
||||||
|
expect(response.body).to include('id="regia-telemetry"')
|
||||||
|
|
||||||
|
get public_regia_status_path(token)
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
tel = response.parsed_body["telemetry"]
|
||||||
|
expect(tel["battery"]).to eq(81)
|
||||||
|
expect(tel["network"]).to eq("WiFi")
|
||||||
|
expect(tel["fps"]).to eq(30)
|
||||||
|
expect(tel["bitrate"]).to eq(2_400_000)
|
||||||
|
expect(tel["thermal_state"]).to eq("nominal")
|
||||||
|
expect(tel).to have_key("publisher_online")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "legge publisher_online dal MediaMTX del nodo assegnato" do
|
||||||
|
node = StreamNode.create!(
|
||||||
|
slug: "ingest-regia-cloud",
|
||||||
|
hostname: "ingest-regia-cloud.mltv-stream.net",
|
||||||
|
role: "cloud",
|
||||||
|
status: "ready",
|
||||||
|
provider: "hetzner",
|
||||||
|
rtmp_base_url: "rtmp://ingest-regia-cloud.mltv-stream.net:1935",
|
||||||
|
hls_base_url: "http://localhost:3000/hls",
|
||||||
|
api_base_url: "http://203.0.113.9:9997",
|
||||||
|
internal_hls_url: "http://203.0.113.9:8888",
|
||||||
|
max_publishers: 4,
|
||||||
|
max_relays: 4
|
||||||
|
)
|
||||||
|
session.update!(stream_node: node)
|
||||||
|
home_client = instance_double(Mediamtx::Client, list_paths: [])
|
||||||
|
cloud_client = instance_double(
|
||||||
|
Mediamtx::Client,
|
||||||
|
list_paths: [{ "name" => session.mediamtx_path_name, "online" => true, "ready" => true }]
|
||||||
|
)
|
||||||
|
allow(Mediamtx::Client).to receive(:new).and_call_original
|
||||||
|
allow(Mediamtx::Client).to receive(:new).with(hash_including(base_url: MatchLiveTv.mediamtx_api_url)).and_return(home_client)
|
||||||
|
allow(Mediamtx::Client).to receive(:new).with(hash_including(base_url: "http://203.0.113.9:9997")).and_return(cloud_client)
|
||||||
|
|
||||||
|
token = Sessions::RegiaAccess.new(session).issue_token!
|
||||||
|
get public_regia_status_path(token)
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.parsed_body["publisher_online"]).to eq(true)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
require "rails_helper"
|
require "rails_helper"
|
||||||
|
|
||||||
RSpec.describe Mediamtx::PublisherSync do
|
RSpec.describe Mediamtx::PublisherSync do
|
||||||
let(:user) { User.create!(email: "sync@test.com", name: "Sync", password: "password123", role: "coach") }
|
let(:user) { User.create!(email: "sync@test.com", name: "Sync", password: "Password123", role: "coach") }
|
||||||
let(:club) { Club.create!(name: "Sync Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
let(:club) { Club.create!(name: "Sync Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
let(:team) { club.teams.create!(name: "Under 16", sport: "volleyball") }
|
let(:team) { club.teams.create!(name: "Under 16", sport: "volleyball") }
|
||||||
let!(:match) { team.matches.create!(opponent_name: "Avversario") }
|
let!(:match) { team.matches.create!(opponent_name: "Avversario") }
|
||||||
@@ -20,8 +20,11 @@ RSpec.describe Mediamtx::PublisherSync do
|
|||||||
before do
|
before do
|
||||||
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
|
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
|
||||||
allow(Mediamtx::Client).to receive(:new).and_return(client)
|
allow(Mediamtx::Client).to receive(:new).and_return(client)
|
||||||
|
allow(Mediamtx::Client).to receive(:for_session).and_return(client)
|
||||||
allow(Mediamtx::PublisherOnline).to receive(:path_info).and_return(path_info)
|
allow(Mediamtx::PublisherOnline).to receive(:path_info).and_return(path_info)
|
||||||
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(true)
|
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(true)
|
||||||
|
allow(Mediamtx::PublisherOnline).to receive(:rtmp_publisher?).and_return(false)
|
||||||
|
allow(Mediamtx::PublisherOnline).to receive(:active?).and_return(true)
|
||||||
allow_any_instance_of(described_class).to receive(:redis).and_return(redis)
|
allow_any_instance_of(described_class).to receive(:redis).and_return(redis)
|
||||||
allow(client).to receive(:set_path_recording)
|
allow(client).to receive(:set_path_recording)
|
||||||
end
|
end
|
||||||
@@ -36,6 +39,7 @@ RSpec.describe Mediamtx::PublisherSync do
|
|||||||
|
|
||||||
it "non abilita la registrazione in connecting senza publisher online" do
|
it "non abilita la registrazione in connecting senza publisher online" do
|
||||||
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(false)
|
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(false)
|
||||||
|
allow(Mediamtx::PublisherOnline).to receive(:active?).and_return(false)
|
||||||
path_info["online"] = false
|
path_info["online"] = false
|
||||||
|
|
||||||
described_class.new(session).call
|
described_class.new(session).call
|
||||||
@@ -53,6 +57,7 @@ RSpec.describe Mediamtx::PublisherSync do
|
|||||||
it "non disabilita la registrazione su reconnecting con publisher offline" do
|
it "non disabilita la registrazione su reconnecting con publisher offline" do
|
||||||
session.update!(status: "reconnecting")
|
session.update!(status: "reconnecting")
|
||||||
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(false)
|
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(false)
|
||||||
|
allow(Mediamtx::PublisherOnline).to receive(:active?).and_return(false)
|
||||||
|
|
||||||
described_class.new(session).call
|
described_class.new(session).call
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ RSpec.describe Ops::HealthChecks do
|
|||||||
%i[
|
%i[
|
||||||
check_recordings_size check_postgres check_redis check_mediamtx check_garage
|
check_recordings_size check_postgres check_redis check_mediamtx check_garage
|
||||||
check_sidekiq_heartbeat check_sidekiq_dead check_http_rails check_rails_latency
|
check_sidekiq_heartbeat check_sidekiq_dead check_http_rails check_rails_latency
|
||||||
|
check_stream_overflow
|
||||||
].each do |method|
|
].each do |method|
|
||||||
allow_any_instance_of(described_class).to receive(method).and_return(
|
allow_any_instance_of(described_class).to receive(method).and_return(
|
||||||
described_class::Finding.new(
|
described_class::Finding.new(
|
||||||
@@ -52,6 +53,7 @@ RSpec.describe Ops::HealthChecks do
|
|||||||
%i[
|
%i[
|
||||||
check_recordings_size check_postgres check_redis check_mediamtx check_garage
|
check_recordings_size check_postgres check_redis check_mediamtx check_garage
|
||||||
check_sidekiq_heartbeat check_sidekiq_dead check_http_rails check_rails_latency
|
check_sidekiq_heartbeat check_sidekiq_dead check_http_rails check_rails_latency
|
||||||
|
check_stream_overflow
|
||||||
].each do |method|
|
].each do |method|
|
||||||
allow_any_instance_of(described_class).to receive(method).and_return(
|
allow_any_instance_of(described_class).to receive(method).and_return(
|
||||||
described_class::Finding.new(
|
described_class::Finding.new(
|
||||||
@@ -65,7 +67,7 @@ RSpec.describe Ops::HealthChecks do
|
|||||||
summary = described_class.new.summary
|
summary = described_class.new.summary
|
||||||
|
|
||||||
expect(summary[:status]).to eq("ok")
|
expect(summary[:status]).to eq("ok")
|
||||||
expect(summary[:checks].size).to eq(10)
|
expect(summary[:checks].size).to eq(11)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "degraded quando la latenza p95 supera la soglia warning" do
|
it "degraded quando la latenza p95 supera la soglia warning" do
|
||||||
|
|||||||
@@ -11,4 +11,11 @@ RSpec.describe Sessions::SelectQuality do
|
|||||||
|
|
||||||
expect(preset[:id]).to eq("1080p_30_4.5mbps")
|
expect(preset[:id]).to eq("1080p_30_4.5mbps")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "offre 1080p come qualità minima solo se la sessione è 1080p" do
|
||||||
|
expect(described_class.allowed_min_ids("720p_30_2.5mbps")).to eq(
|
||||||
|
%w[auto 720p_30_1.5mbps 720p_30_2.5mbps 720p_30_4mbps]
|
||||||
|
)
|
||||||
|
expect(described_class.allowed_min_ids("1080p_30_4.5mbps")).to include("1080p_30_4.5mbps")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Sessions::SetAudioMute do
|
||||||
|
let!(:user) { User.create!(email: "mute@test.it", name: "U", password: "Password123", role: "coach") }
|
||||||
|
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
|
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
||||||
|
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
|
||||||
|
let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live") }
|
||||||
|
|
||||||
|
it "attiva il mute e persiste lo stato" do
|
||||||
|
described_class.new(session, muted: true).call
|
||||||
|
expect(session.reload.audio_muted).to eq(true)
|
||||||
|
expect(session.stream_events.order(:occurred_at).last.event_type).to eq("audio_muted")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "disattiva il mute" do
|
||||||
|
session.update!(audio_muted: true)
|
||||||
|
described_class.new(session, muted: false).call
|
||||||
|
expect(session.reload.audio_muted).to eq(false)
|
||||||
|
expect(session.stream_events.order(:occurred_at).last.event_type).to eq("audio_unmuted")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "è idempotente se lo stato è già quello richiesto" do
|
||||||
|
described_class.new(session, muted: false).call
|
||||||
|
expect(session.stream_events.count).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Sessions::SetMinQuality do
|
||||||
|
let!(:user) { User.create!(email: "abr@test.it", name: "U", password: "Password123", role: "coach") }
|
||||||
|
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
|
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
||||||
|
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
|
||||||
|
let!(:session) do
|
||||||
|
StreamSession.create!(
|
||||||
|
match: match,
|
||||||
|
user: user,
|
||||||
|
platform: "matchlivetv",
|
||||||
|
status: "live",
|
||||||
|
quality_preset: "720p_30_2.5mbps"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "salva il pavimento e notifica i client" do
|
||||||
|
expect(SessionChannel).to receive(:broadcast_message).with(
|
||||||
|
session,
|
||||||
|
hash_including(action: "set_min_quality", min_quality_preset: "720p_30_1.5mbps")
|
||||||
|
)
|
||||||
|
|
||||||
|
described_class.new(session, preset: "720p_30_1.5mbps").call
|
||||||
|
|
||||||
|
expect(session.reload.min_quality_preset).to eq("720p_30_1.5mbps")
|
||||||
|
expect(session.stream_events.where(event_type: "quality_changed")).to exist
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rifiuta 1080p se la sessione è 720p" do
|
||||||
|
expect {
|
||||||
|
described_class.new(session, preset: "1080p_30_4.5mbps").call
|
||||||
|
}.to raise_error(ArgumentError)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "accetta auto come qualità minima" do
|
||||||
|
session.update!(min_quality_preset: "720p_30_1.5mbps")
|
||||||
|
described_class.new(session, preset: "auto").call
|
||||||
|
expect(session.reload.min_quality_preset).to eq("auto")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Streams::Autoscaler do
|
||||||
|
def with_env(vars)
|
||||||
|
previous = vars.keys.index_with { |k| ENV[k] }
|
||||||
|
vars.each { |k, v| ENV[k] = v }
|
||||||
|
yield
|
||||||
|
ensure
|
||||||
|
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:redis) { Redis.new(url: ENV.fetch("REDIS_URL", "redis://redis:6379/0")) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
redis.del(Streams::Autoscaler::LOCK_KEY)
|
||||||
|
redis.del(Streams::Autoscaler::KILL_SWITCH_KEY)
|
||||||
|
redis.del(Streams::DnsProviders::Lab::REDIS_KEY)
|
||||||
|
StreamSession.where.not(stream_node_id: nil).update_all(stream_node_id: nil, status: "ended", ended_at: Time.current)
|
||||||
|
StreamNode.where.not(slug: Streams::NodeRegistry::HOME_SLUG).delete_all
|
||||||
|
end
|
||||||
|
|
||||||
|
it "is a no-op when disabled" do
|
||||||
|
with_env("STREAM_AUTOSCALE_ENABLED" => "0") do
|
||||||
|
result = described_class.reconcile!
|
||||||
|
expect(result.skipped).to eq(true)
|
||||||
|
expect(result.actions).to eq([])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "scales out and creates a warm spare when free slots are low" do
|
||||||
|
with_env(
|
||||||
|
"STREAM_AUTOSCALE_ENABLED" => "1",
|
||||||
|
"STREAM_AUTOSCALE_SOFT_FREE_SLOTS" => "2",
|
||||||
|
"STREAM_AUTOSCALE_WARM_SPARE" => "1",
|
||||||
|
"STREAM_AUTOSCALE_KIND" => "lab",
|
||||||
|
"STREAM_AUTOSCALE_MAX_NODES" => "5",
|
||||||
|
"STREAM_CLOUD_PROVIDER" => "local_lab",
|
||||||
|
"STREAM_DNS_PROVIDER" => "lab",
|
||||||
|
"STREAM_NODE_HOME_MAX_PUBLISHERS" => "2",
|
||||||
|
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
|
||||||
|
"MEDIAMTX_RTMP_URL" => "rtmp://home.example:1935",
|
||||||
|
"HLS_PUBLIC_URL" => "https://home.example/hls"
|
||||||
|
) do
|
||||||
|
home = Streams::NodeRegistry.ensure_home_from_env!
|
||||||
|
user = User.create!(email: "as@example.com", name: "A", password: "Password123", role: "coach")
|
||||||
|
club = Club.create!(name: "AS", sport: "volleyball")
|
||||||
|
team = club.teams.create!(name: "T", sport: "volleyball", slug: "as-t")
|
||||||
|
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
|
||||||
|
# Fill home (2/2) → free_slots=0
|
||||||
|
2.times do
|
||||||
|
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home)
|
||||||
|
end
|
||||||
|
|
||||||
|
provisioner = instance_double(Streams::NodeProvisioner)
|
||||||
|
created = []
|
||||||
|
allow(provisioner).to receive(:provision_lab!) do
|
||||||
|
node = StreamNode.create!(
|
||||||
|
slug: "ingest-lab-#{created.size + 1}",
|
||||||
|
hostname: "h#{created.size}.lab",
|
||||||
|
role: "lab",
|
||||||
|
status: "ready",
|
||||||
|
provider: "local",
|
||||||
|
rtmp_base_url: "rtmp://h:1935",
|
||||||
|
hls_base_url: "https://h/hls",
|
||||||
|
api_base_url: "http://h:9997",
|
||||||
|
max_publishers: 2,
|
||||||
|
max_relays: 2
|
||||||
|
)
|
||||||
|
created << node
|
||||||
|
node
|
||||||
|
end
|
||||||
|
|
||||||
|
result = described_class.reconcile!(provisioner: provisioner)
|
||||||
|
expect(result.actions).to include(:scale_out)
|
||||||
|
expect(created.size).to eq(1)
|
||||||
|
expect(result.metrics[:free_slots]).to be >= 2
|
||||||
|
|
||||||
|
# Riempi il nodo overflow → spare_ready=0 ma carico presente → warm spare
|
||||||
|
StreamSession.create!(
|
||||||
|
match: match, user: user, platform: "matchlivetv", status: "live", stream_node: created.first
|
||||||
|
)
|
||||||
|
StreamSession.create!(
|
||||||
|
match: match, user: user, platform: "matchlivetv", status: "live", stream_node: created.first
|
||||||
|
)
|
||||||
|
|
||||||
|
result2 = described_class.reconcile!(provisioner: provisioner)
|
||||||
|
expect(result2.actions).to include(:warm_spare).or include(:scale_out)
|
||||||
|
expect(created.size).to be >= 2
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not scale in a node provisioned in the same reconcile round" do
|
||||||
|
with_env(
|
||||||
|
"STREAM_AUTOSCALE_ENABLED" => "1",
|
||||||
|
"STREAM_AUTOSCALE_SOFT_FREE_SLOTS" => "2",
|
||||||
|
"STREAM_AUTOSCALE_WARM_SPARE" => "0",
|
||||||
|
"STREAM_AUTOSCALE_IDLE_MINUTES" => "0",
|
||||||
|
"STREAM_AUTOSCALE_KIND" => "lab",
|
||||||
|
"STREAM_AUTOSCALE_MAX_NODES" => "5",
|
||||||
|
"STREAM_CLOUD_PROVIDER" => "local_lab",
|
||||||
|
"STREAM_DNS_PROVIDER" => "lab",
|
||||||
|
"STREAM_NODE_HOME_MAX_PUBLISHERS" => "2",
|
||||||
|
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
|
||||||
|
"MEDIAMTX_RTMP_URL" => "rtmp://home.example:1935",
|
||||||
|
"HLS_PUBLIC_URL" => "https://home.example/hls"
|
||||||
|
) do
|
||||||
|
home = Streams::NodeRegistry.ensure_home_from_env!
|
||||||
|
user = User.create!(email: "same@example.com", name: "S", password: "Password123", role: "coach")
|
||||||
|
club = Club.create!(name: "Same", sport: "volleyball")
|
||||||
|
team = club.teams.create!(name: "T", sport: "volleyball", slug: "same-t")
|
||||||
|
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
|
||||||
|
2.times do
|
||||||
|
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home)
|
||||||
|
end
|
||||||
|
|
||||||
|
provisioner = instance_double(Streams::NodeProvisioner)
|
||||||
|
created = nil
|
||||||
|
allow(provisioner).to receive(:provision_lab!) do
|
||||||
|
created = StreamNode.create!(
|
||||||
|
slug: "ingest-lab-new",
|
||||||
|
hostname: "new.lab",
|
||||||
|
role: "lab",
|
||||||
|
status: "ready",
|
||||||
|
provider: "local",
|
||||||
|
rtmp_base_url: "rtmp://h:1935",
|
||||||
|
hls_base_url: "https://h/hls",
|
||||||
|
api_base_url: "http://h:9997",
|
||||||
|
max_publishers: 4,
|
||||||
|
max_relays: 2
|
||||||
|
)
|
||||||
|
end
|
||||||
|
expect(provisioner).not_to receive(:decommission!)
|
||||||
|
|
||||||
|
result = described_class.reconcile!(provisioner: provisioner)
|
||||||
|
expect(result.actions).to include(:scale_out)
|
||||||
|
expect(result.actions.map(&:to_s)).not_to include("scale_in_ingest-lab-new")
|
||||||
|
expect(StreamNode.find_by(slug: "ingest-lab-new")).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "scales in an idle overflow node when warm spare is not required" do
|
||||||
|
with_env(
|
||||||
|
"STREAM_AUTOSCALE_ENABLED" => "1",
|
||||||
|
"STREAM_AUTOSCALE_SOFT_FREE_SLOTS" => "0",
|
||||||
|
"STREAM_AUTOSCALE_WARM_SPARE" => "0",
|
||||||
|
"STREAM_AUTOSCALE_IDLE_MINUTES" => "0",
|
||||||
|
"STREAM_NODE_HOME_MAX_PUBLISHERS" => "6",
|
||||||
|
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
|
||||||
|
"MEDIAMTX_RTMP_URL" => "rtmp://home.example:1935",
|
||||||
|
"HLS_PUBLIC_URL" => "https://home.example/hls"
|
||||||
|
) do
|
||||||
|
Streams::NodeRegistry.ensure_home_from_env!
|
||||||
|
idle = StreamNode.create!(
|
||||||
|
slug: "ingest-lab-99",
|
||||||
|
hostname: "idle.lab",
|
||||||
|
role: "lab",
|
||||||
|
status: "ready",
|
||||||
|
provider: "local",
|
||||||
|
provider_instance_id: "sim-x",
|
||||||
|
rtmp_base_url: "rtmp://h:1935",
|
||||||
|
hls_base_url: "https://h/hls",
|
||||||
|
api_base_url: "http://h:9997",
|
||||||
|
max_publishers: 2,
|
||||||
|
max_relays: 2,
|
||||||
|
created_at: 2.hours.ago
|
||||||
|
)
|
||||||
|
|
||||||
|
provisioner = instance_double(Streams::NodeProvisioner)
|
||||||
|
allow(provisioner).to receive(:drain!)
|
||||||
|
expect(provisioner).to receive(:decommission!).with(idle)
|
||||||
|
|
||||||
|
result = described_class.reconcile!(provisioner: provisioner)
|
||||||
|
expect(result.actions.map(&:to_s)).to include("scale_in_ingest-lab-99")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "skips when Redis kill-switch is engaged even if ENV is enabled" do
|
||||||
|
redis.del(described_class::KILL_SWITCH_KEY)
|
||||||
|
with_env("STREAM_AUTOSCALE_ENABLED" => "1") do
|
||||||
|
described_class.engage_kill_switch!
|
||||||
|
expect(described_class.enabled?).to eq(false)
|
||||||
|
result = described_class.reconcile!
|
||||||
|
expect(result.skipped).to eq(true)
|
||||||
|
ensure
|
||||||
|
described_class.clear_kill_switch!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "blocks scale-out when next node would exceed monthly budget" do
|
||||||
|
with_env(
|
||||||
|
"STREAM_AUTOSCALE_ENABLED" => "1",
|
||||||
|
"STREAM_AUTOSCALE_SOFT_FREE_SLOTS" => "2",
|
||||||
|
"STREAM_AUTOSCALE_WARM_SPARE" => "0",
|
||||||
|
"STREAM_AUTOSCALE_KIND" => "lab",
|
||||||
|
"STREAM_AUTOSCALE_MAX_NODES" => "5",
|
||||||
|
"STREAM_AUTOSCALE_NODE_EUR_PER_HOUR" => "1",
|
||||||
|
"STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR" => "10",
|
||||||
|
"STREAM_CLOUD_PROVIDER" => "local_lab",
|
||||||
|
"STREAM_DNS_PROVIDER" => "lab",
|
||||||
|
"STREAM_NODE_HOME_MAX_PUBLISHERS" => "1",
|
||||||
|
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
|
||||||
|
"MEDIAMTX_RTMP_URL" => "rtmp://home.example:1935",
|
||||||
|
"HLS_PUBLIC_URL" => "https://home.example/hls"
|
||||||
|
) do
|
||||||
|
StreamSession.where.not(stream_node_id: nil).update_all(stream_node_id: nil, status: "ended", ended_at: Time.current)
|
||||||
|
StreamNode.where.not(slug: Streams::NodeRegistry::HOME_SLUG).delete_all
|
||||||
|
home = Streams::NodeRegistry.ensure_home_from_env!
|
||||||
|
home.update!(max_publishers: 1)
|
||||||
|
user = User.create!(email: "budget@example.com", name: "B", password: "Password123", role: "coach")
|
||||||
|
club = Club.create!(name: "Budget", sport: "volleyball")
|
||||||
|
team = club.teams.create!(name: "T", sport: "volleyball", slug: "budget-t")
|
||||||
|
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
|
||||||
|
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home)
|
||||||
|
|
||||||
|
provisioner = instance_double(Streams::NodeProvisioner)
|
||||||
|
expect(provisioner).not_to receive(:provision_lab!)
|
||||||
|
|
||||||
|
result = described_class.reconcile!(provisioner: provisioner)
|
||||||
|
expect(result.actions).to include(:blocked_capacity)
|
||||||
|
# 0 overflow nodes → stima 0€ ≤ budget; il blocco è sul nodo successivo (+1)
|
||||||
|
expect(described_class.within_budget?(0)).to eq(true)
|
||||||
|
expect(described_class.within_budget?(1)).to eq(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Streams::CloudProviders::Hetzner do
|
||||||
|
let(:stubs) { Faraday::Adapter::Test::Stubs.new }
|
||||||
|
let(:conn) do
|
||||||
|
Faraday.new(url: Streams::CloudProviders::Hetzner::API) do |f|
|
||||||
|
f.request :json
|
||||||
|
f.response :json
|
||||||
|
f.adapter :test, stubs
|
||||||
|
end
|
||||||
|
end
|
||||||
|
let(:provider) { described_class.new(token: "test-token", conn: conn) }
|
||||||
|
|
||||||
|
def with_env(vars)
|
||||||
|
previous = vars.keys.index_with { |k| ENV[k] }
|
||||||
|
vars.each { |k, v| ENV[k] = v }
|
||||||
|
yield
|
||||||
|
ensure
|
||||||
|
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
|
||||||
|
end
|
||||||
|
|
||||||
|
after { stubs.verify_stubbed_calls }
|
||||||
|
|
||||||
|
it "creates a server and waits until running" do
|
||||||
|
with_env(
|
||||||
|
"HCLOUD_LOCATION" => "nbg1",
|
||||||
|
"HCLOUD_SERVER_TYPE" => "cpx12",
|
||||||
|
"HCLOUD_IMAGE" => "debian-12",
|
||||||
|
"HCLOUD_SSH_KEY" => "matchlivetv-stream-hetzner",
|
||||||
|
"HCLOUD_USER_DATA" => "#cloud-config\n"
|
||||||
|
) do
|
||||||
|
stubs.post("https://api.hetzner.cloud/v1/servers") do |env|
|
||||||
|
body = JSON.parse(env.body)
|
||||||
|
expect(body["name"]).to eq("mltv-stream-ingest-01")
|
||||||
|
expect(body["location"]).to eq("nbg1")
|
||||||
|
[
|
||||||
|
201,
|
||||||
|
{ "Content-Type" => "application/json" },
|
||||||
|
{
|
||||||
|
"server" => {
|
||||||
|
"id" => 42,
|
||||||
|
"name" => "mltv-stream-ingest-01",
|
||||||
|
"status" => "initializing",
|
||||||
|
"public_net" => { "ipv4" => { "ip" => "49.13.1.2" } },
|
||||||
|
"private_net" => []
|
||||||
|
},
|
||||||
|
"action" => { "id" => 7, "status" => "running" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
stubs.get("https://api.hetzner.cloud/v1/actions/7") do
|
||||||
|
[200, { "Content-Type" => "application/json" }, { "action" => { "id" => 7, "status" => "success" } }]
|
||||||
|
end
|
||||||
|
stubs.get("https://api.hetzner.cloud/v1/servers/42") do
|
||||||
|
[
|
||||||
|
200,
|
||||||
|
{ "Content-Type" => "application/json" },
|
||||||
|
{
|
||||||
|
"server" => {
|
||||||
|
"id" => 42,
|
||||||
|
"name" => "mltv-stream-ingest-01",
|
||||||
|
"status" => "running",
|
||||||
|
"public_net" => { "ipv4" => { "ip" => "49.13.1.2" } },
|
||||||
|
"private_net" => []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
instance = provider.create_node(name: "mltv-stream-ingest-01", labels: { role: "stream-node" })
|
||||||
|
expect(instance.id).to eq("42")
|
||||||
|
expect(instance.public_ip).to eq("49.13.1.2")
|
||||||
|
expect(instance.status).to eq("running")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "destroys a server" do
|
||||||
|
stubs.delete("https://api.hetzner.cloud/v1/servers/42") { [204, {}, ""] }
|
||||||
|
expect(provider.destroy_node("42")).to eq(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Streams::DnsProviders::Hetzner do
|
||||||
|
let(:stubs) { Faraday::Adapter::Test::Stubs.new }
|
||||||
|
let(:conn) do
|
||||||
|
Faraday.new(url: Streams::DnsProviders::Hetzner::API) do |f|
|
||||||
|
f.request :json
|
||||||
|
f.response :json
|
||||||
|
f.adapter :test, stubs
|
||||||
|
end
|
||||||
|
end
|
||||||
|
let(:provider) { described_class.new(token: "test-token", zone: "mltv-stream.net", conn: conn) }
|
||||||
|
|
||||||
|
after { stubs.verify_stubbed_calls }
|
||||||
|
|
||||||
|
it "upserts an A record (delete then create)" do
|
||||||
|
stubs.delete("https://api.hetzner.cloud/v1/zones/mltv-stream.net/rrsets/ingest-01/A") { [404, {}, ""] }
|
||||||
|
stubs.post("https://api.hetzner.cloud/v1/zones/mltv-stream.net/rrsets") do |env|
|
||||||
|
body = JSON.parse(env.body)
|
||||||
|
expect(body["name"]).to eq("ingest-01")
|
||||||
|
expect(body["type"]).to eq("A")
|
||||||
|
expect(body["records"].first["value"]).to eq("49.13.1.2")
|
||||||
|
[201, { "Content-Type" => "application/json" }, { "rrset" => { "name" => "ingest-01" } }]
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(provider.upsert_a("ingest-01.mltv-stream.net", "49.13.1.2")).to eq(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "deletes an A record" do
|
||||||
|
stubs.delete("https://api.hetzner.cloud/v1/zones/mltv-stream.net/rrsets/ingest-01/A") { [204, {}, ""] }
|
||||||
|
expect(provider.delete_a("ingest-01.mltv-stream.net")).to eq(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "Streams::NodeProvisioner cloud" do
|
||||||
|
it "registers a cloud node using injected providers" do
|
||||||
|
cloud = instance_double(
|
||||||
|
Streams::CloudProviders::Hetzner,
|
||||||
|
create_node: Streams::CloudProviders::Instance.new(
|
||||||
|
id: "99",
|
||||||
|
name: "mltv-stream-ingest-01",
|
||||||
|
public_ip: "49.13.9.9",
|
||||||
|
private_ip: "10.0.0.9",
|
||||||
|
status: "running",
|
||||||
|
raw: {}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
dns = instance_double(Streams::DnsProviders::Hetzner)
|
||||||
|
allow(dns).to receive(:upsert_a)
|
||||||
|
|
||||||
|
ENV["MEDIAMTX_API_URL"] = "http://mtx-home:9997"
|
||||||
|
ENV["MEDIAMTX_RTMP_URL"] = "rtmp://home.example:1935"
|
||||||
|
ENV["HLS_PUBLIC_URL"] = "https://home.example/hls"
|
||||||
|
ENV["STREAM_CLOUD_DNS_SUFFIX"] = "mltv-stream.net"
|
||||||
|
ENV["STREAM_CLOUD_MAX_PUBLISHERS"] = "4"
|
||||||
|
ENV["STREAM_CLOUD_PUBLIC_CONTROL"] = "0"
|
||||||
|
|
||||||
|
node = Streams::NodeProvisioner.new(cloud: cloud, dns: dns).provision_cloud!
|
||||||
|
expect(node.slug).to eq("ingest-01")
|
||||||
|
expect(node.role).to eq("cloud")
|
||||||
|
expect(node.provider).to eq("hetzner")
|
||||||
|
expect(node.hostname).to eq("ingest-01.mltv-stream.net")
|
||||||
|
expect(node.rtmp_base_url).to eq("rtmp://ingest-01.mltv-stream.net:1935")
|
||||||
|
expect(node.api_base_url).to eq("http://10.0.0.9:9997")
|
||||||
|
expect(node.hls_base_url).to eq("https://home.example/hls")
|
||||||
|
expect(node.internal_hls_url).to eq("http://10.0.0.9:8888")
|
||||||
|
expect(dns).to have_received(:upsert_a).with("ingest-01.mltv-stream.net", "49.13.9.9")
|
||||||
|
ensure
|
||||||
|
%w[
|
||||||
|
MEDIAMTX_API_URL MEDIAMTX_RTMP_URL HLS_PUBLIC_URL
|
||||||
|
STREAM_CLOUD_DNS_SUFFIX STREAM_CLOUD_MAX_PUBLISHERS STREAM_CLOUD_PUBLIC_CONTROL
|
||||||
|
].each { |k| ENV.delete(k) }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Streams::NodeProvisioner do
|
||||||
|
def with_env(vars)
|
||||||
|
previous = vars.keys.index_with { |k| ENV[k] }
|
||||||
|
vars.each { |k, v| ENV[k] = v }
|
||||||
|
yield
|
||||||
|
ensure
|
||||||
|
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:redis) { Redis.new(url: ENV.fetch("REDIS_URL", "redis://redis:6379/0")) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
redis.del(Streams::DnsProviders::Lab::REDIS_KEY)
|
||||||
|
StreamSession.where.not(stream_node_id: nil).update_all(stream_node_id: nil, status: "ended", ended_at: Time.current)
|
||||||
|
StreamNode.where.not(slug: Streams::NodeRegistry::HOME_SLUG).delete_all
|
||||||
|
end
|
||||||
|
|
||||||
|
it "provisions and decommissions a simulated lab node" do
|
||||||
|
with_env(
|
||||||
|
"STREAM_CLOUD_PROVIDER" => "local_lab",
|
||||||
|
"STREAM_DNS_PROVIDER" => "lab",
|
||||||
|
"STREAM_LAB_DNS_SUFFIX" => "lab.mltv-stream.net",
|
||||||
|
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
|
||||||
|
"MEDIAMTX_RTMP_URL" => "rtmp://ingest-home.example:1935",
|
||||||
|
"HLS_PUBLIC_URL" => "https://ingest-home.example/hls",
|
||||||
|
"STREAM_LAB_MAX_PUBLISHERS" => "2",
|
||||||
|
"MEDIAMTX_LAB_API_URL" => nil
|
||||||
|
) do
|
||||||
|
provisioner = described_class.new
|
||||||
|
node = provisioner.provision_lab!
|
||||||
|
|
||||||
|
expect(node.slug).to eq("ingest-lab-01")
|
||||||
|
expect(node.role).to eq("lab")
|
||||||
|
expect(node.status).to eq("ready")
|
||||||
|
expect(node.hostname).to eq("ingest-lab-01.lab.mltv-stream.net")
|
||||||
|
expect(node.api_base_url).to eq("http://mtx-home:9997")
|
||||||
|
expect(Streams::DnsProviders::Lab.new.resolve(node.hostname)).to eq("127.0.0.1")
|
||||||
|
|
||||||
|
provisioner.decommission!(node)
|
||||||
|
expect(StreamNode.find_by(slug: "ingest-lab-01")).to be_nil
|
||||||
|
expect(Streams::DnsProviders::Lab.new.resolve("ingest-lab-01.lab.mltv-stream.net")).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "attaches a dedicated lab MediaMTX when MEDIAMTX_LAB_API_URL is set" do
|
||||||
|
with_env(
|
||||||
|
"STREAM_CLOUD_PROVIDER" => "local_lab",
|
||||||
|
"STREAM_DNS_PROVIDER" => "lab",
|
||||||
|
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
|
||||||
|
"MEDIAMTX_RTMP_URL" => "rtmp://ingest-home.example:1935",
|
||||||
|
"HLS_PUBLIC_URL" => "https://ingest-home.example/hls",
|
||||||
|
"MEDIAMTX_LAB_API_URL" => "http://mediamtx_lab:9997",
|
||||||
|
"MEDIAMTX_LAB_HLS_URL" => "http://mediamtx_lab:8888",
|
||||||
|
"MEDIAMTX_LAB_INTERNAL_RTMP_URL" => "rtmp://mediamtx_lab:1935",
|
||||||
|
"MEDIAMTX_LAB_RTMP_URL" => "rtmp://127.0.0.1:11935"
|
||||||
|
) do
|
||||||
|
node = described_class.new.provision_lab!
|
||||||
|
expect(node.api_base_url).to eq("http://mediamtx_lab:9997")
|
||||||
|
expect(node.internal_rtmp_url).to eq("rtmp://mediamtx_lab:1935")
|
||||||
|
expect(node.internal_hls_url).to eq("http://mediamtx_lab:8888")
|
||||||
|
expect(node.rtmp_base_url).to eq("rtmp://127.0.0.1:11935")
|
||||||
|
expect(node.hls_base_url).to eq("https://ingest-home.example/hls")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "refuses to decommission a busy node" do
|
||||||
|
with_env(
|
||||||
|
"STREAM_CLOUD_PROVIDER" => "local_lab",
|
||||||
|
"STREAM_DNS_PROVIDER" => "lab",
|
||||||
|
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
|
||||||
|
"MEDIAMTX_RTMP_URL" => "rtmp://ingest-home.example:1935",
|
||||||
|
"HLS_PUBLIC_URL" => "https://ingest-home.example/hls",
|
||||||
|
"MEDIAMTX_LAB_API_URL" => nil
|
||||||
|
) do
|
||||||
|
node = described_class.new.provision_lab!
|
||||||
|
user = User.create!(email: "lab@example.com", name: "L", password: "Password123", role: "coach")
|
||||||
|
club = Club.create!(name: "LabClub", sport: "volleyball")
|
||||||
|
team = club.teams.create!(name: "T", sport: "volleyball", slug: "lab-t")
|
||||||
|
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
|
||||||
|
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live", stream_node: node)
|
||||||
|
|
||||||
|
expect { described_class.new.decommission!(node) }.to raise_error(Streams::NodeProvisioner::BusyError)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Streams::NodeRegistry do
|
||||||
|
def with_env(vars)
|
||||||
|
previous = vars.keys.index_with { |k| ENV[k] }
|
||||||
|
vars.each { |k, v| ENV[k] = v }
|
||||||
|
yield
|
||||||
|
ensure
|
||||||
|
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:home_env) do
|
||||||
|
{
|
||||||
|
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
|
||||||
|
"MEDIAMTX_RTMP_URL" => "rtmp://ingest-home.example:1935",
|
||||||
|
"HLS_PUBLIC_URL" => "https://ingest-home.example/hls",
|
||||||
|
"MEDIAMTX_INTERNAL_RTMP_URL" => "rtmp://mtx-home:1935",
|
||||||
|
"MEDIAMTX_HLS_URL" => "http://mtx-home:8888",
|
||||||
|
"STREAM_NODE_HOME_MAX_PUBLISHERS" => "2"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
StreamSession.where.not(stream_node_id: nil).update_all(stream_node_id: nil, status: "ended", ended_at: Time.current)
|
||||||
|
StreamNode.where.not(slug: Streams::NodeRegistry::HOME_SLUG).delete_all
|
||||||
|
StreamNode.find_by(slug: Streams::NodeRegistry::HOME_SLUG)&.update!(max_publishers: 2, status: "ready")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "creates home node from ENV" do
|
||||||
|
with_env(home_env) do
|
||||||
|
node = described_class.ensure_home_from_env!
|
||||||
|
expect(node.slug).to eq("home")
|
||||||
|
expect(node.rtmp_base_url).to eq("rtmp://ingest-home.example:1935")
|
||||||
|
expect(node.api_base_url).to eq("http://mtx-home:9997")
|
||||||
|
expect(node.max_publishers).to eq(2)
|
||||||
|
expect(node.status).to eq("ready")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not reset home drain/offline/error status on ensure" do
|
||||||
|
with_env(home_env) do
|
||||||
|
home = described_class.ensure_home_from_env!
|
||||||
|
home.update!(status: "draining")
|
||||||
|
|
||||||
|
expect(described_class.ensure_home_from_env!.status).to eq("draining")
|
||||||
|
expect { described_class.allocate! }.to raise_error(Streams::NodeRegistry::NoCapacityError)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "prefers home while it still has free slots even if cloud is idle" do
|
||||||
|
with_env(home_env) do
|
||||||
|
home = described_class.ensure_home_from_env!
|
||||||
|
StreamNode.create!(
|
||||||
|
slug: "ingest-01",
|
||||||
|
hostname: "ingest-01.mltv-stream.net",
|
||||||
|
role: "cloud",
|
||||||
|
status: "ready",
|
||||||
|
provider: "hetzner",
|
||||||
|
rtmp_base_url: "rtmp://ingest-01.mltv-stream.net:1935",
|
||||||
|
hls_base_url: "https://ingest-01.mltv-stream.net/hls",
|
||||||
|
api_base_url: "http://10.0.0.2:9997",
|
||||||
|
max_publishers: 2,
|
||||||
|
max_relays: 2
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(described_class.allocate!).to eq(home)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "allocates the least loaded cloud node when home is full" do
|
||||||
|
with_env(home_env) do
|
||||||
|
home = described_class.ensure_home_from_env!
|
||||||
|
cloud = StreamNode.create!(
|
||||||
|
slug: "ingest-01",
|
||||||
|
hostname: "ingest-01.mltv-stream.net",
|
||||||
|
role: "cloud",
|
||||||
|
status: "ready",
|
||||||
|
provider: "hetzner",
|
||||||
|
rtmp_base_url: "rtmp://ingest-01.mltv-stream.net:1935",
|
||||||
|
hls_base_url: "https://ingest-01.mltv-stream.net/hls",
|
||||||
|
api_base_url: "http://10.0.0.2:9997",
|
||||||
|
max_publishers: 2,
|
||||||
|
max_relays: 2
|
||||||
|
)
|
||||||
|
cloud_busy = StreamNode.create!(
|
||||||
|
slug: "ingest-02",
|
||||||
|
hostname: "ingest-02.mltv-stream.net",
|
||||||
|
role: "cloud",
|
||||||
|
status: "ready",
|
||||||
|
provider: "hetzner",
|
||||||
|
rtmp_base_url: "rtmp://ingest-02.mltv-stream.net:1935",
|
||||||
|
hls_base_url: "https://ingest-02.mltv-stream.net/hls",
|
||||||
|
api_base_url: "http://10.0.0.3:9997",
|
||||||
|
max_publishers: 2,
|
||||||
|
max_relays: 2
|
||||||
|
)
|
||||||
|
|
||||||
|
user = User.create!(email: "n@example.com", name: "N", password: "Password123", role: "coach")
|
||||||
|
club = Club.create!(name: "C", sport: "volleyball")
|
||||||
|
team = club.teams.create!(name: "T", sport: "volleyball", slug: "t-node")
|
||||||
|
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
|
||||||
|
match2 = team.matches.create!(opponent_name: "Y", scheduled_at: 2.hours.from_now)
|
||||||
|
|
||||||
|
# Riempie home (max 2)
|
||||||
|
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home)
|
||||||
|
StreamSession.create!(match: match2, user: user, platform: "matchlivetv", status: "live", stream_node: home)
|
||||||
|
# Un publisher già su ingest-02 → least-loaded = ingest-01
|
||||||
|
StreamSession.create!(
|
||||||
|
match: team.matches.create!(opponent_name: "Z", scheduled_at: 3.hours.from_now),
|
||||||
|
user: user, platform: "matchlivetv", status: "live", stream_node: cloud_busy
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(described_class.allocate!).to eq(cloud)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "raises when no capacity remains" do
|
||||||
|
with_env(home_env.merge("STREAM_NODE_HOME_MAX_PUBLISHERS" => "1")) do
|
||||||
|
home = described_class.ensure_home_from_env!
|
||||||
|
user = User.create!(email: "full@example.com", name: "F", password: "Password123", role: "coach")
|
||||||
|
club = Club.create!(name: "Full", sport: "volleyball")
|
||||||
|
team = club.teams.create!(name: "T", sport: "volleyball", slug: "t-full")
|
||||||
|
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
|
||||||
|
StreamSession.create!(
|
||||||
|
match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home
|
||||||
|
)
|
||||||
|
|
||||||
|
expect { described_class.allocate! }.to raise_error(Streams::NodeRegistry::NoCapacityError)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -18,7 +18,174 @@ RSpec.describe Streams::YoutubeRelay do
|
|||||||
it "remuxes HLS with AAC bitstream filter for FLV" do
|
it "remuxes HLS with AAC bitstream filter for FLV" do
|
||||||
cmd = args(:hls, "http://mediamtx:8888/live/match_x/index.m3u8")
|
cmd = args(:hls, "http://mediamtx:8888/live/match_x/index.m3u8")
|
||||||
expect(cmd).to include("-c:v", "copy", "-c:a", "copy", "-bsf:a", "aac_adtstoasc", "-f", "flv")
|
expect(cmd).to include("-c:v", "copy", "-c:a", "copy", "-bsf:a", "aac_adtstoasc", "-f", "flv")
|
||||||
|
expect(cmd).to include("-reconnect", "1")
|
||||||
expect(cmd).not_to include("-b:a")
|
expect(cmd).not_to include("-b:a")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "node queue affinity" do
|
||||||
|
def with_env(vars)
|
||||||
|
previous = vars.keys.index_with { |k| ENV[k] }
|
||||||
|
vars.each { |k, v| ENV[k] = v }
|
||||||
|
yield
|
||||||
|
ensure
|
||||||
|
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:session_id) { SecureRandom.uuid }
|
||||||
|
let(:lab_node) { instance_double(StreamNode, slug: "ingest-lab-01") }
|
||||||
|
|
||||||
|
def session_double(stream_node: nil)
|
||||||
|
instance_double(
|
||||||
|
StreamSession,
|
||||||
|
id: session_id,
|
||||||
|
platform: "youtube",
|
||||||
|
terminal?: false,
|
||||||
|
stream_key: "yt-key",
|
||||||
|
status: "live",
|
||||||
|
stream_node: stream_node,
|
||||||
|
mediamtx_path_name: "live/match_#{session_id}",
|
||||||
|
mediamtx_internal_rtmp_url: "rtmp://10.0.0.9:1935",
|
||||||
|
mediamtx_internal_hls_url: "http://10.0.0.9:8888"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "routes home sessions to youtube_relay_home" do
|
||||||
|
expect(described_class.queue_for(session_double)).to eq("youtube_relay_home")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "routes overflow sessions to the node slug queue" do
|
||||||
|
expect(described_class.queue_for(session_double(stream_node: lab_node))).to eq("youtube_relay_ingest-lab-01")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requeues ensure to the assigned node when the local worker does not match" do
|
||||||
|
with_env("STREAM_NODE_SLUG" => "home", "YOUTUBE_RELAY_WORKER" => "1") do
|
||||||
|
allow(described_class).to receive(:worker?).and_return(true)
|
||||||
|
job_proxy = double("ConfiguredJob", perform_later: true)
|
||||||
|
expect(YoutubeRelayEnsureJob).to receive(:set).with(hash_including(queue: "youtube_relay_ingest-lab-01", wait: 2.seconds)).and_return(job_proxy)
|
||||||
|
|
||||||
|
expect(described_class.ensure_on_worker!(session_double(stream_node: lab_node))).to eq(:wrong_host)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "multi-host sticky stop/capacity" do
|
||||||
|
let(:redis) { Redis.new(url: ENV.fetch("REDIS_URL", "redis://redis:6379/0")) }
|
||||||
|
let(:session_id) { SecureRandom.uuid }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(described_class).to receive(:worker?).and_return(true)
|
||||||
|
allow(described_class).to receive(:worker_id).and_return("worker-a")
|
||||||
|
allow(described_class).to receive(:max_concurrent).and_return(1)
|
||||||
|
allow(described_class).to receive(:local_node_slug).and_return("home")
|
||||||
|
redis.flushdb
|
||||||
|
end
|
||||||
|
|
||||||
|
def session_double
|
||||||
|
instance_double(
|
||||||
|
StreamSession,
|
||||||
|
id: session_id,
|
||||||
|
platform: "youtube",
|
||||||
|
terminal?: false,
|
||||||
|
stream_key: "yt-key",
|
||||||
|
status: "live",
|
||||||
|
stream_node: nil
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not stop ffmpeg owned by another worker" do
|
||||||
|
redis.set(format(Streams::YoutubeRelay::OWNER_KEY, session_id), "worker-b", ex: 3600)
|
||||||
|
redis.set(format(Streams::YoutubeRelay::REDIS_KEY, session_id), "12345", ex: 3600)
|
||||||
|
|
||||||
|
expect(described_class.stop_on_worker!(session_double)).to eq(:wrong_host)
|
||||||
|
expect(redis.get(format(Streams::YoutubeRelay::OWNER_KEY, session_id))).to eq("worker-b")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requeues ensure on the node queue when at capacity" do
|
||||||
|
other_id = SecureRandom.uuid
|
||||||
|
redis.sadd(format(Streams::YoutubeRelay::OWNED_SET, "worker-a"), other_id)
|
||||||
|
allow(described_class).to receive(:intake_available?).and_return(true)
|
||||||
|
expect(YoutubeRelayEnsureJob).to receive(:set).with(hash_including(wait: 5.seconds, queue: "youtube_relay_home")).and_return(
|
||||||
|
double(perform_later: true)
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(described_class.ensure_on_worker!(session_double)).to eq(:at_capacity)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe ".mediamtx_intake_source (private)" do
|
||||||
|
def with_env(vars)
|
||||||
|
previous = vars.keys.index_with { |k| ENV[k] }
|
||||||
|
vars.each { |k, v| ENV[k] = v }
|
||||||
|
yield
|
||||||
|
ensure
|
||||||
|
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:session) do
|
||||||
|
instance_double(
|
||||||
|
StreamSession,
|
||||||
|
stream_node: instance_double(StreamNode, slug: "ingest-lab-01"),
|
||||||
|
mediamtx_path_name: "live/match_abc",
|
||||||
|
mediamtx_internal_rtmp_url: "rtmp://10.0.0.9:1935",
|
||||||
|
mediamtx_internal_hls_url: "http://10.0.0.9:8888"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "uses loopback RTMP when the worker is on the assigned node" do
|
||||||
|
with_env(
|
||||||
|
"STREAM_NODE_SLUG" => "ingest-lab-01",
|
||||||
|
"STREAM_NODE_LOCAL_RTMP_URL" => "rtmp://127.0.0.1:1935"
|
||||||
|
) do
|
||||||
|
allow(Mediamtx::PublisherOnline).to receive(:active?).with(session).and_return(true)
|
||||||
|
expect(described_class.send(:mediamtx_intake_source, session)).to eq(
|
||||||
|
[:rtmp, "rtmp://127.0.0.1:1935/live/match_abc"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "uses loopback when the node relay agent is enabled" do
|
||||||
|
with_env(
|
||||||
|
"STREAM_NODE_SLUG" => "ingest-lab-01",
|
||||||
|
"STREAM_NODE_RELAY_AGENT_URL" => "http://203.0.113.10:9100",
|
||||||
|
"STREAM_NODE_LOCAL_RTMP_URL" => "rtmp://mediamtx_lab:1935"
|
||||||
|
) do
|
||||||
|
allow(Mediamtx::PublisherOnline).to receive(:active?).with(session).and_return(true)
|
||||||
|
expect(described_class.send(:mediamtx_intake_source, session)).to eq(
|
||||||
|
[:rtmp, "rtmp://127.0.0.1:1935/live/match_abc"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "relay agent spawn" do
|
||||||
|
def with_env(vars)
|
||||||
|
previous = vars.keys.index_with { |k| ENV[k] }
|
||||||
|
vars.each { |k, v| ENV[k] = v }
|
||||||
|
yield
|
||||||
|
ensure
|
||||||
|
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
|
||||||
|
end
|
||||||
|
|
||||||
|
it "asks the node agent to start ffmpeg and stores the pid" do
|
||||||
|
session = instance_double(
|
||||||
|
StreamSession,
|
||||||
|
id: "sess-1",
|
||||||
|
mediamtx_path_name: "live/match_abc",
|
||||||
|
stream_key: "yt-key"
|
||||||
|
)
|
||||||
|
allow(described_class).to receive(:log_file).and_return("/tmp/youtube_relay_test.log")
|
||||||
|
allow(File).to receive(:write)
|
||||||
|
expect(described_class).to receive(:agent_request).with(
|
||||||
|
Net::HTTP::Post,
|
||||||
|
"/relays",
|
||||||
|
hash_including("session_id" => "sess-1", "path" => "live/match_abc")
|
||||||
|
).and_return("pid" => 4242)
|
||||||
|
|
||||||
|
pid = with_env("STREAM_NODE_RELAY_AGENT_URL" => "http://203.0.113.10:9100") do
|
||||||
|
described_class.send(:start_via_agent!, session)
|
||||||
|
end
|
||||||
|
expect(pid).to eq(4242)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Youtube::LivePipeline do
|
||||||
|
let(:user) { User.create!(email: "ytpipe@test.it", name: "U", password: "Password123", role: "coach") }
|
||||||
|
let(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
|
let(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
||||||
|
let(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
|
||||||
|
let(:session) do
|
||||||
|
StreamSession.create!(
|
||||||
|
match: match,
|
||||||
|
user: user,
|
||||||
|
platform: "youtube",
|
||||||
|
status: "live",
|
||||||
|
youtube_broadcast_id: "bcast",
|
||||||
|
stream_key: "yt-key"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
let(:client) { instance_double(Mediamtx::Client) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Mediamtx::Client).to receive(:for_session).and_return(client)
|
||||||
|
allow(Mediamtx::PublisherOnline).to receive(:active?).and_return(true)
|
||||||
|
allow(Streams::YoutubeRelay).to receive(:ensure_publishing!)
|
||||||
|
allow(Streams::YoutubeRelay).to receive(:running?).and_return(true)
|
||||||
|
allow(described_class).to receive(:schedule!)
|
||||||
|
allow(described_class).to receive(:schedule_activate!)
|
||||||
|
allow(client).to receive(:set_always_available)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "non spegne la slate quando il publisher è online" do
|
||||||
|
described_class.tick_session!(session)
|
||||||
|
|
||||||
|
expect(client).not_to have_received(:set_always_available)
|
||||||
|
end
|
||||||
|
end
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 720 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
Documento di riferimento per l'intero sistema: rete, container Docker, flussi video, replay, monitoraggio ops e rilasci.
|
Documento di riferimento per l'intero sistema: rete, container Docker, flussi video, replay, monitoraggio ops e rilasci.
|
||||||
|
|
||||||
**Ultimo aggiornamento:** 2026-07-29
|
**Ultimo aggiornamento:** 2026-08-09
|
||||||
**Produzione:** `eminux@192.168.1.146` → `/opt/matchlivetv`
|
**Produzione:** `eminux@192.168.1.146` → `/opt/matchlivetv` (Proxmox; control plane attuale per autoscale)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -397,9 +397,11 @@ cd infra && cp .env.example .env && docker compose up -d --build
|
|||||||
| Documento | Contenuto |
|
| Documento | Contenuto |
|
||||||
|-----------|-----------|
|
|-----------|-----------|
|
||||||
| [`infrastructure/SERVER_DEPLOYMENT.md`](infrastructure/SERVER_DEPLOYMENT.md) | Bootstrap server, NPM, cron, backup |
|
| [`infrastructure/SERVER_DEPLOYMENT.md`](infrastructure/SERVER_DEPLOYMENT.md) | Bootstrap server, NPM, cron, backup |
|
||||||
| [`infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md`](infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md) | Piano overflow relay YouTube su Hetzner Cloud (picchi) |
|
| [`infrastructure/STREAMING_AUTOSCALE.md`](infrastructure/STREAMING_AUTOSCALE.md) | **Design attuale:** Proxmox prod + Hetzner Cloud warm spare (MediaMTX+ffmpeg), lab, DNS, disco; Auction in futuro |
|
||||||
|
| [`infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md`](infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md) | Piano storico overflow-only relay YouTube (superseded dal doc sopra) |
|
||||||
| [`ANDROID_APP_LINKS.md`](ANDROID_APP_LINKS.md) | Digital Asset Links / deep link Play (`assetlinks.json`) |
|
| [`ANDROID_APP_LINKS.md`](ANDROID_APP_LINKS.md) | Digital Asset Links / deep link Play (`assetlinks.json`) |
|
||||||
| [`LIVE_STREAMING.md`](LIVE_STREAMING.md) | MediaMTX, pausa, HLS, ruolo ffmpeg |
|
| [`LIVE_STREAMING.md`](LIVE_STREAMING.md) | MediaMTX, pausa, mute audio, HLS, ruolo ffmpeg |
|
||||||
|
| [`IOS_STREAM_AUDIO_MUTE.md`](IOS_STREAM_AUDIO_MUTE.md) | Mute microfono iOS (allineamento Mac) |
|
||||||
| [`REPLAY_MODULE.md`](REPLAY_MODULE.md) | Garage, retention, YouTube VOD |
|
| [`REPLAY_MODULE.md`](REPLAY_MODULE.md) | Garage, retention, YouTube VOD |
|
||||||
| [`OPS_MONITORING.md`](OPS_MONITORING.md) | ntfy, variabili ops, troubleshooting |
|
| [`OPS_MONITORING.md`](OPS_MONITORING.md) | ntfy, variabili ops, troubleshooting |
|
||||||
| [`TABELLONI_E_OVERLAY.md`](TABELLONI_E_OVERLAY.md) | Tabelloni e overlay (app nativa) |
|
| [`TABELLONI_E_OVERLAY.md`](TABELLONI_E_OVERLAY.md) | Tabelloni e overlay (app nativa) |
|
||||||
@@ -416,7 +418,8 @@ cd infra && cp .env.example .env && docker compose up -d --build
|
|||||||
| 2026-06 | Replay: redirect 302 a `/media/` → Garage (fuori da Puma) |
|
| 2026-06 | Replay: redirect 302 a `/media/` → Garage (fuori da Puma) |
|
||||||
| 2026-06 | HLS live: edge → MediaMTX (fuori da Puma) |
|
| 2026-06 | HLS live: edge → MediaMTX (fuori da Puma) |
|
||||||
| 2026-06 | Ops: check `http_rails` + `http_public` separati, latenza p95 `UpLatencyTracker` |
|
| 2026-06 | Ops: check `http_rails` + `http_public` separati, latenza p95 `UpLatencyTracker` |
|
||||||
| 2026-06 | `RAILS_MAX_THREADS=5`; relay YouTube da MediaMTX (non via Puma) |
|
| 2026-08 | Mute microfono in diretta (`audio_muted`) per evitare claim YouTube sulla musica in palestra |
|
||||||
| 2026-06 | Cleanup path MediaMTX orfani (`Mediamtx::CleanupOrphanPaths`); delete path sempre a fine diretta |
|
| 2026-06 | Cleanup path MediaMTX orfani (`Mediamtx::CleanupOrphanPaths`); delete path sempre a fine diretta |
|
||||||
| 2026-07 | Rimosso overlay server (`OverlayRelay`); tabellone bruciato in app; HLS su path camera (non `*_air`); `YoutubeRelay` = copy video + AAC |
|
| 2026-07 | Rimosso overlay server (`OverlayRelay`); tabellone bruciato in app; HLS su path camera (non `*_air`); `YoutubeRelay` = copy video + AAC |
|
||||||
| 2026-08 | `YoutubeRelay` = remux copy video+audio (niente ricodifica AAC); profilo app/slate AAC 48k mono |
|
| 2026-08 | `YoutubeRelay` = remux copy video+audio (niente ricodifica AAC); profilo app/slate AAC 48k mono |
|
||||||
|
| 2026-08 | Design autoscale: Proxmox prod + Hetzner Cloud warm spare (MediaMTX+ffmpeg), lab locale, secondo dominio DNS, Garage ora / B2 dopo; Auction rimandato (`STREAMING_AUTOSCALE.md`) |
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
Documento operativo per continuare su **Mac** lo sviluppo iOS e rilasciare un’app **allineata ad Android**.
|
Documento operativo per continuare su **Mac** lo sviluppo iOS e rilasciare un’app **allineata ad Android**.
|
||||||
|
|
||||||
**Aggiornato:** 8 agosto 2026
|
**Aggiornato:** 12 agosto 2026
|
||||||
**Riferimento Android (produzione / telefono / Play):** `2.0.10-native` (`versionCode` **31**), API `https://www.matchlivetv.it`
|
**Riferimento Android (produzione / telefono / Play):** `2.0.10-native` (`versionCode` **31**), API `https://www.matchlivetv.it`
|
||||||
**Stato iOS attuale:** marketing `2.0.10`, build `31` — i18n **allineato** (Login, hub, sheet/dialog, wizard, broadcast, account/forgot)
|
**Stato iOS attuale:** marketing `2.0.10`, build `31` — i18n **allineato** (Login, hub, sheet/dialog, wizard, broadcast, account/forgot)
|
||||||
|
|
||||||
|
**Nuovo (branch `feature/stream-audio-mute`):** mute microfono in diretta. Codice iOS già nel branch; verifica su Mac in [`IOS_STREAM_AUDIO_MUTE.md`](IOS_STREAM_AUDIO_MUTE.md).
|
||||||
|
|
||||||
Obiettivo iOS: **stessa copertura lingua** di Android (Login, hub, sheet/dialog, wizard, broadcast, account) + bump versione, **senza** i bug di sessione/crash già risolti su Android.
|
Obiettivo iOS: **stessa copertura lingua** di Android (Login, hub, sheet/dialog, wizard, broadcast, account) + bump versione, **senza** i bug di sessione/crash già risolti su Android.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# iOS — mute audio in diretta (allineamento da Mac)
|
||||||
|
|
||||||
|
Documento operativo per **Cursor su Mac**: verificare e, se serve, ritoccare l’app iOS dopo il ramo `feature/stream-audio-mute`.
|
||||||
|
|
||||||
|
**Aggiornato:** 2026-08-12
|
||||||
|
**Branch:** `feature/stream-audio-mute` (parte da `main`)
|
||||||
|
**Android di riferimento:** mute microfono in overlay + sync regia
|
||||||
|
**API produzione:** `https://www.matchlivetv.it`
|
||||||
|
|
||||||
|
Il codice iOS è **già nel branch** (engine, overlay, API, Cable, L10n). Su Mac va **compilato, testato su device** e, se HaishinKit 2.2.5 rifiuta un parametro, adattato come sotto.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Perché esiste
|
||||||
|
|
||||||
|
In palestra la musica di sottofondo finisce nel microfono del telefono. Il relay YouTube fa `-c:a copy`, quindi YouTube sente quella musica e può reclamare il copyright.
|
||||||
|
|
||||||
|
Il mute **non ferma** lo stream: continua video + overlay + AAC, ma i sample audio sono silenzio.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contratto API / Cable (già in backend)
|
||||||
|
|
||||||
|
| Pezzo | Dettaglio |
|
||||||
|
|-------|-----------|
|
||||||
|
| Campo sessione | `audio_muted` (boolean, default `false`) in JSON sessione e `GET/POST /regia/:token/status.json` |
|
||||||
|
| REST app | `PATCH /api/v1/sessions/:id/audio_mute` body `{ "muted": true \| false }` |
|
||||||
|
| REST regia | `POST /regia/:token/audio_mute` body `{ "muted": true \| false }` (senza body: toggle) |
|
||||||
|
| Cable command | `{ "type": "command", "action": "mute_audio" \| "unmute_audio", "muted": true \| false }` |
|
||||||
|
| Cable event | `{ "type": "stream_event", "event": "audio_muted", "muted": true \| false }` |
|
||||||
|
|
||||||
|
Idempotente: se lo stato è già quello richiesto, il backend non reinvia eventi.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File iOS già modificati
|
||||||
|
|
||||||
|
```
|
||||||
|
native/ios/MatchLiveTv/Streaming/BroadcastModels.swift # BroadcastMetrics.audioMuted
|
||||||
|
native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift # setAudioMuted + AudioMixerSettings.isMuted
|
||||||
|
native/ios/MatchLiveTv/Streaming/LiveBroadcastCoordinator.swift # setAudioMuted
|
||||||
|
native/ios/MatchLiveTv/Domain/Models.swift # StreamSession.audioMuted + withAudioMuted
|
||||||
|
native/ios/MatchLiveTv/Data/API/ApiDtos.swift # audioMuted + AudioMuteRequest
|
||||||
|
native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift # setAudioMute
|
||||||
|
native/ios/MatchLiveTv/Data/Repository/SessionRepository.swift
|
||||||
|
native/ios/MatchLiveTv/Data/Cable/SessionCableService.swift # onAudioMute + AudioMuteCommandLogic
|
||||||
|
native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift
|
||||||
|
native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift
|
||||||
|
native/ios/MatchLiveTv/Core/AppLanguage.swift # IT/EN/FR/DE/ES
|
||||||
|
native/ios/MatchLiveTvTests/LiveBroadcastCoordinatorTests.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Engine HaishinKit 2.2.5 (punto da verificare su Mac)
|
||||||
|
|
||||||
|
Implementazione attesa in `LiveBroadcastEngine`:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
func setAudioMuted(_ muted: Bool) async {
|
||||||
|
audioMuted = muted
|
||||||
|
await applyAudioMute()
|
||||||
|
metrics.audioMuted = muted
|
||||||
|
emitMetrics()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyAudioMute() async {
|
||||||
|
guard pipelineConfigured else { return }
|
||||||
|
var settings = await mixer.audioMixerSettings
|
||||||
|
settings.isMuted = audioMuted
|
||||||
|
var track = settings.tracks[0] ?? AudioMixerTrackSettings(downmix: true, channelMap: [0])
|
||||||
|
track.isMuted = audioMuted
|
||||||
|
settings.tracks[0] = track
|
||||||
|
try? await mixer.setAudioMixerSettings(settings)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `configurePipeline` i settings iniziali passano `isMuted: audioMuted` sia sul mixer sia sulla track 0.
|
||||||
|
|
||||||
|
**Se il build fallisce:**
|
||||||
|
|
||||||
|
1. `AudioMixerSettings` potrebbe non avere `isMuted` nel memberwise usato oggi — in quel caso muta solo `settings.tracks[0].isMuted`.
|
||||||
|
2. `setAudioMixerSettings` è `async throws` nel codice attuale; se la signature è sync, togli `try await`.
|
||||||
|
3. Non staccare il microfono (`attachAudio(nil)`): YouTube/MediaMTX vogliono AAC continuo. Mute = silenzio, non assenza di traccia.
|
||||||
|
|
||||||
|
Dopo `prepareBroadcast` / `resumeBroadcast` la schermata richiama `setAudioMuted(session.audioMuted)` per riallineare lo stato persistito.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI overlay
|
||||||
|
|
||||||
|
Toolbar destra (come Android), sotto pausa:
|
||||||
|
|
||||||
|
| Stato | SF Symbol | Chiave L10n |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| Audio on | `speaker.wave.2.fill` | `broadcast.mute.cd` |
|
||||||
|
| Audio off | `speaker.slash.fill` | `broadcast.unmute.cd` |
|
||||||
|
|
||||||
|
Bottone `highlighted` quando mutato (stesso verde della pausa). Pannello telemetria: riga `broadcast.audio.muted.label` se mutato.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chiavi L10n (già aggiunte, 5 lingue)
|
||||||
|
|
||||||
|
| Android | iOS |
|
||||||
|
|---------|-----|
|
||||||
|
| `broadcast_mute_cd` | `broadcast.mute.cd` |
|
||||||
|
| `broadcast_unmute_cd` | `broadcast.unmute.cd` |
|
||||||
|
| `broadcast_audio_muted_label` | `broadcast.audio.muted.label` |
|
||||||
|
| `broadcast_snackbar_muted` | `broadcast.snackbar.muted` |
|
||||||
|
| `broadcast_snackbar_unmuted` | `broadcast.snackbar.unmuted` |
|
||||||
|
| `broadcast_snackbar_muted_remote` | `broadcast.snackbar.muted.remote` |
|
||||||
|
| `broadcast_snackbar_unmuted_remote` | `broadcast.snackbar.unmuted.remote` |
|
||||||
|
| `broadcast_snackbar_mute_error` | `broadcast.snackbar.mute.error` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test su Mac (acceptance)
|
||||||
|
|
||||||
|
1. `cd native/ios && python3 generate_xcodeproj.py`
|
||||||
|
2. Build simulatore / device.
|
||||||
|
3. Avvia una diretta (anche verso ambiente di staging).
|
||||||
|
4. Overlay: tap mute → icona speaker.slash, snackbar «Audio silenziato», telemetria «Audio off».
|
||||||
|
5. Apri la **regia** sullo stesso match → il pulsante deve dire «Riattiva audio».
|
||||||
|
6. Dalla regia riattiva → il telefono mostra speaker.wave e snackbar «dalla regia».
|
||||||
|
7. Mute, metti in **pausa**, riprendi: l’audio resta mutato (stato persistito).
|
||||||
|
8. YouTube / player HLS: video continua, audio silenzioso (non assente).
|
||||||
|
9. Unit test: `LiveBroadcastCoordinatorTests.testAudioMuteCommandFromCable`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd native/ios
|
||||||
|
python3 generate_xcodeproj.py
|
||||||
|
xcodebuild -project MatchLiveTv.xcodeproj -scheme MatchLiveTv \
|
||||||
|
-destination 'generic/platform=iOS Simulator' \
|
||||||
|
-derivedDataPath build/DerivedData \
|
||||||
|
ONLY_ACTIVE_ARCH=YES ARCHS=arm64 EXCLUDED_ARCHS=x86_64 \
|
||||||
|
build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Criterio “fatto”
|
||||||
|
|
||||||
|
- [ ] Build iOS senza errori HaishinKit
|
||||||
|
- [ ] Mute/unmute locale in overlay
|
||||||
|
- [ ] Sync bidirezionale con la regia via Cable + REST
|
||||||
|
- [ ] Mute sopravvive a pausa/ripresa
|
||||||
|
- [ ] AAC silenzioso in onda (niente drop della traccia audio)
|
||||||
|
- [ ] Stringhe IT/EN/FR/DE/ES visibili in overlay
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user