Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f92ab3a813 |
@@ -55,6 +55,8 @@ class SessionChannel < ApplicationCable::Channel
|
||||
Sessions::Pause.new(session).call
|
||||
when "resume_stream"
|
||||
Sessions::Resume.new(session).call
|
||||
when "set_min_quality"
|
||||
Sessions::SetMinQuality.new(session, preset: data["min_quality_preset"]).call
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# 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
|
||||
@@ -68,6 +68,7 @@ module Api
|
||||
current_bitrate: params[:current_bitrate],
|
||||
target_bitrate: params[:target_bitrate],
|
||||
fps: params[:fps],
|
||||
thermal_state: sanitized_thermal_state,
|
||||
last_seen_at: Time.current
|
||||
)
|
||||
sync_publisher_when_streaming!(params[:fps].to_f)
|
||||
@@ -141,6 +142,13 @@ module Api
|
||||
}
|
||||
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
|
||||
count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present?
|
||||
Youtube::BroadcastService.new(@session.match.team)
|
||||
@@ -160,6 +168,11 @@ module Api
|
||||
.find(params[:id])
|
||||
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
|
||||
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel)
|
||||
end
|
||||
@@ -180,7 +193,6 @@ module Api
|
||||
platform: session.platform,
|
||||
rtmp_ingest_url: session.rtmp_ingest_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,
|
||||
share_url: session.share_url,
|
||||
youtube_watch_url: session.youtube_watch_url,
|
||||
@@ -188,6 +200,7 @@ module Api
|
||||
youtube_ready: session.youtube_ready?,
|
||||
privacy_status: session.privacy_status,
|
||||
quality_preset: session.quality_preset,
|
||||
min_quality_preset: session.min_quality_preset,
|
||||
target_bitrate: session.target_bitrate,
|
||||
started_at: session.started_at,
|
||||
disconnection_count: session.disconnection_count
|
||||
|
||||
@@ -7,7 +7,7 @@ module Public
|
||||
layout "regia"
|
||||
|
||||
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 min_quality]
|
||||
|
||||
before_action :set_session_from_token
|
||||
|
||||
@@ -61,8 +61,20 @@ module Public
|
||||
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
|
||||
|
||||
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
|
||||
@session = Sessions::RegiaAccess.find_session_by_token(params[:token])
|
||||
return if @session
|
||||
@@ -86,7 +98,10 @@ module Public
|
||||
home_name: @session.match.team.name,
|
||||
away_name: @session.match.opponent_name,
|
||||
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
|
||||
|
||||
@@ -54,15 +54,34 @@ module Public
|
||||
copyPrompt: t("regia.js.copy_prompt"),
|
||||
shareRegiaText: t("regia.js.share_regia_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"),
|
||||
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"),
|
||||
pauseLabel: t("regia.pause"),
|
||||
endedBadge: t("regia.status.ended"),
|
||||
pausedBadge: t("regia.status.paused"),
|
||||
liveBadge: t("regia.status.live"),
|
||||
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
|
||||
end
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ class CleanupExpiredSessionsJob
|
||||
.where("updated_at < ?", 6.hours.ago)
|
||||
.find_each do |session|
|
||||
session.fail! if session.may_fail?
|
||||
Mediamtx::Client.for_session(session).delete_path(session)
|
||||
Mediamtx::Client.new.delete_path(session)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# 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 sui worker con YOUTUBE_RELAY_WORKER=1 (coda youtube_relay).
|
||||
# Avvia/riavvia il relay YouTube solo nel container sidekiq (YOUTUBE_RELAY_WORKER=1).
|
||||
class YoutubeRelayEnsureJob < ApplicationJob
|
||||
queue_as Streams::YoutubeRelay::QUEUE
|
||||
queue_as :default
|
||||
|
||||
def perform(session_id)
|
||||
session = StreamSession.find_by(id: session_id)
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
# Ferma il relay solo sull'owner. Se il job gira su un altro host, requeue breve.
|
||||
class YoutubeRelayStopJob < ApplicationJob
|
||||
queue_as Streams::YoutubeRelay::QUEUE
|
||||
queue_as :default
|
||||
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(session_id, attempts = 0)
|
||||
def perform(session_id)
|
||||
session = StreamSession.find_by(id: session_id)
|
||||
return unless session
|
||||
|
||||
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).perform_later(session_id, attempts + 1)
|
||||
Streams::YoutubeRelay.stop_on_worker!(session) if Streams::YoutubeRelay.worker?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,4 +16,17 @@ class DeviceState < ApplicationRecord
|
||||
status: stream_session.status
|
||||
}
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@ module Ops
|
||||
|
||||
KINDS = %w[
|
||||
disk_space recordings_size service_down http_public http_rails rails_latency
|
||||
sidekiq_stale sidekiq_dead log_pattern garage_storage stream_overflow
|
||||
sidekiq_stale sidekiq_dead log_pattern garage_storage
|
||||
].freeze
|
||||
SEVERITIES = %w[critical warning info].freeze
|
||||
STATUSES = %w[open acknowledged resolved].freeze
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# 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,7 +7,6 @@ class StreamSession < ApplicationRecord
|
||||
|
||||
belongs_to :match
|
||||
belongs_to :user
|
||||
belongs_to :stream_node, optional: true
|
||||
has_many :stream_events, dependent: :destroy
|
||||
has_one :score_state, dependent: :destroy
|
||||
has_many :device_states, dependent: :destroy
|
||||
@@ -15,6 +14,7 @@ class StreamSession < ApplicationRecord
|
||||
validates :platform, inclusion: { in: PLATFORMS }
|
||||
validates :status, inclusion: { in: STATUSES }
|
||||
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
|
||||
validates :min_quality_preset, inclusion: { in: Sessions::SelectQuality::MIN_QUALITY_IDS }
|
||||
|
||||
before_validation :normalize_privacy_status
|
||||
before_validation :ensure_publish_token, on: :create
|
||||
@@ -75,7 +75,7 @@ class StreamSession < ApplicationRecord
|
||||
def rtmp_ingest_url
|
||||
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
|
||||
# MediaMTX path = live/match_{uuid} (no ?token= nel path).
|
||||
"#{rtmp_base_url.chomp('/')}/#{mediamtx_path_name}"
|
||||
"#{MatchLiveTv.mediamtx_rtmp_url}/#{mediamtx_path_name}"
|
||||
end
|
||||
|
||||
def mediamtx_path_name
|
||||
@@ -91,29 +91,8 @@ class StreamSession < ApplicationRecord
|
||||
end
|
||||
|
||||
def hls_playback_url
|
||||
"#{hls_base_url.chomp('/')}/#{effective_hls_path_name}/index.m3u8"
|
||||
end
|
||||
|
||||
def rtmp_base_url
|
||||
stream_node&.rtmp_base_url.presence || MatchLiveTv.mediamtx_rtmp_url
|
||||
end
|
||||
|
||||
def hls_base_url
|
||||
stream_node&.hls_base_url.presence || MatchLiveTv.hls_public_url
|
||||
end
|
||||
|
||||
def mediamtx_api_base_url
|
||||
stream_node&.api_base_url.presence || MatchLiveTv.mediamtx_api_url
|
||||
end
|
||||
|
||||
def mediamtx_internal_rtmp_url
|
||||
stream_node&.internal_rtmp_url.presence ||
|
||||
ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935")
|
||||
end
|
||||
|
||||
def mediamtx_internal_hls_url
|
||||
stream_node&.internal_hls_url.presence ||
|
||||
ENV.fetch("MEDIAMTX_HLS_URL", "http://mediamtx:8888")
|
||||
base = MatchLiveTv.hls_public_url.chomp("/")
|
||||
"#{base}/#{effective_hls_path_name}/index.m3u8"
|
||||
end
|
||||
|
||||
def effective_hls_path_name
|
||||
|
||||
@@ -4,12 +4,7 @@ module Mediamtx
|
||||
class Client
|
||||
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)
|
||||
@base_url = base_url
|
||||
@conn = Faraday.new(url: base_url) do |f|
|
||||
f.request :json
|
||||
f.response :json
|
||||
@@ -17,8 +12,6 @@ module Mediamtx
|
||||
end
|
||||
end
|
||||
|
||||
attr_reader :base_url
|
||||
|
||||
def create_path(session)
|
||||
path = session.mediamtx_path_name
|
||||
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe
|
||||
@@ -105,16 +98,6 @@ module Mediamtx
|
||||
[]
|
||||
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
|
||||
Set.new(list_paths.filter_map { |item| item["name"] if item["online"] })
|
||||
end
|
||||
|
||||
@@ -4,37 +4,17 @@ module Mediamtx
|
||||
module_function
|
||||
|
||||
def active?(session)
|
||||
return true if rtmp_publisher?(session)
|
||||
|
||||
active_path?(path_info(session))
|
||||
end
|
||||
|
||||
def path_info(session)
|
||||
Client.for_session(session).list_paths.find { |i| i["name"] == session.mediamtx_path_name }
|
||||
Client.new.list_paths.find { |i| i["name"] == session.mediamtx_path_name }
|
||||
end
|
||||
|
||||
# MediaMTX <=1.19: online + source.type=rtmpConn.
|
||||
# MediaMTX 1.20+: online/source spesso null anche con publisher; usare rtmpconns.
|
||||
def active_path?(info)
|
||||
return false unless info
|
||||
return true if info["online"] == true && rtmp_source?(info.dig("source", "type"))
|
||||
|
||||
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
|
||||
info["online"] == true && info.dig("source", "type") == "rtmpConn"
|
||||
end
|
||||
|
||||
def h264_video?(info)
|
||||
@@ -46,14 +26,12 @@ module Mediamtx
|
||||
end
|
||||
|
||||
def video_publishing?(session)
|
||||
return false unless active?(session)
|
||||
|
||||
info = path_info(session)
|
||||
h264_video?(info)
|
||||
end
|
||||
return false unless active_path?(info)
|
||||
# Slate alwaysAvailable ha H264 ma non è il telefono.
|
||||
return false unless info.dig("source", "type") == "rtmpConn"
|
||||
|
||||
def rtmp_source?(type)
|
||||
type.to_s.match?(/\Artmps?Conn\z/)
|
||||
h264_video?(info)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,7 +12,7 @@ module Mediamtx
|
||||
return @session if @session.terminal?
|
||||
|
||||
path_info = Mediamtx::PublisherOnline.path_info(@session)
|
||||
publisher_online = Mediamtx::PublisherOnline.active?(@session)
|
||||
publisher_online = Mediamtx::PublisherOnline.active_path?(path_info)
|
||||
|
||||
if publisher_online
|
||||
clear_publisher_misses!(@session.id)
|
||||
@@ -86,7 +86,7 @@ module Mediamtx
|
||||
key = format("youtube:slate_disabled:%s", session.id)
|
||||
return unless redis.set(key, "1", nx: true, ex: 48.hours.to_i)
|
||||
|
||||
Client.for_session(session).set_always_available(session, enabled: false)
|
||||
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}")
|
||||
@@ -95,7 +95,7 @@ module Mediamtx
|
||||
def restore_slate_path!(session)
|
||||
return if session.platform == "matchlivetv"
|
||||
|
||||
Client.for_session(session).set_always_available(session, enabled: true)
|
||||
Client.new.set_always_available(session, enabled: true)
|
||||
rescue Client::Error => e
|
||||
Rails.logger.warn("[PublisherSync] enable slate session=#{session.id}: #{e.message}")
|
||||
end
|
||||
@@ -128,7 +128,7 @@ module Mediamtx
|
||||
return
|
||||
end
|
||||
|
||||
Client.for_session(session).set_path_recording(session, enabled: enabled)
|
||||
Client.new.set_path_recording(session, enabled: enabled)
|
||||
redis.set(key, desired, ex: 48.hours.to_i)
|
||||
mark_recording_patch!(session.id)
|
||||
rescue Client::Error => e
|
||||
@@ -183,7 +183,7 @@ module Mediamtx
|
||||
key = format("youtube_relay:sched:%s", session.id)
|
||||
return unless redis.set(key, "1", nx: true, ex: 10)
|
||||
|
||||
YoutubeRelayEnsureJob.set(queue: Streams::YoutubeRelay::QUEUE).perform_later(session.id)
|
||||
YoutubeRelayEnsureJob.perform_later(session.id)
|
||||
end
|
||||
|
||||
def redis
|
||||
|
||||
@@ -44,8 +44,7 @@ module Ops
|
||||
check_sidekiq_heartbeat,
|
||||
check_sidekiq_dead,
|
||||
check_http_rails,
|
||||
check_rails_latency,
|
||||
check_stream_overflow
|
||||
check_rails_latency
|
||||
]
|
||||
findings << check_http_public if public_check_due?
|
||||
findings
|
||||
@@ -162,58 +161,6 @@ module Ops
|
||||
fail_finding("garage_storage", "warning", "garage_storage:head", "Garage storage non raggiungibile", e.message)
|
||||
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
|
||||
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||
last = redis.get(Ops::HealthMonitorJob::HEARTBEAT_KEY).to_i
|
||||
|
||||
@@ -95,7 +95,7 @@ module Recordings
|
||||
def cleanup_mediamtx_path(session)
|
||||
return unless session.status.in?(%w[ended error])
|
||||
|
||||
Mediamtx::Client.for_session(session).delete_path(session)
|
||||
Mediamtx::Client.new.delete_path(session)
|
||||
rescue Mediamtx::Client::Error => e
|
||||
@logger.warn("[Recordings::CleanupLocal] delete_path #{session.id}: #{e.message}")
|
||||
end
|
||||
|
||||
@@ -95,7 +95,7 @@ module Recordings
|
||||
|
||||
def cleanup_mediamtx_path
|
||||
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
||||
Mediamtx::Client.for_session(@session).delete_path(@session)
|
||||
Mediamtx::Client.new.delete_path(@session)
|
||||
rescue Mediamtx::Client::Error => e
|
||||
Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}")
|
||||
end
|
||||
|
||||
@@ -34,19 +34,11 @@ module Sessions
|
||||
end
|
||||
|
||||
StreamSession.transaction do
|
||||
session.stream_node = Streams::NodeRegistry.allocate!
|
||||
session.save!
|
||||
Scoring::Engine.ensure_score_for(session)
|
||||
Mediamtx::Client.for_session(session).create_path(session)
|
||||
log_event(
|
||||
session,
|
||||
"pairing",
|
||||
{
|
||||
created: true,
|
||||
platform: session.platform,
|
||||
stream_node: session.stream_node&.slug
|
||||
}
|
||||
)
|
||||
mtx = Mediamtx::Client.new
|
||||
mtx.create_path(session)
|
||||
log_event(session, "pairing", { created: true, platform: session.platform })
|
||||
end
|
||||
|
||||
if session.platform == "youtube"
|
||||
@@ -54,8 +46,6 @@ module Sessions
|
||||
end
|
||||
|
||||
session
|
||||
rescue Streams::NodeRegistry::NoCapacityError => e
|
||||
raise Teams::EntitlementError.new(e.message, code: "stream_capacity_exhausted")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -9,11 +9,12 @@ module Sessions
|
||||
@session.pause! if @session.may_pause?
|
||||
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
||||
begin
|
||||
Mediamtx::Client.for_session(@session).set_path_recording(@session, enabled: false)
|
||||
mtx = Mediamtx::Client.new
|
||||
mtx.set_path_recording(@session, enabled: false)
|
||||
mtx.set_always_available(@session, enabled: true)
|
||||
rescue Mediamtx::Client::Error => e
|
||||
Rails.logger.warn("[Sessions::Pause] disable recording: #{e.message}")
|
||||
Rails.logger.warn("[Sessions::Pause] MediaMTX: #{e.message}")
|
||||
end
|
||||
Mediamtx::Client.for_session(@session).set_always_available(@session, enabled: true)
|
||||
# Slate su path per HLS in pausa; RTMP telefono si ferma via comando app.
|
||||
log_event("paused")
|
||||
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
|
||||
|
||||
@@ -3,6 +3,8 @@ module Sessions
|
||||
class SelectQuality
|
||||
UPLOAD_HEADROOM = 0.8
|
||||
UPLOAD_HEADROOM_1080P = 1.0
|
||||
AUTO_ID = "auto"
|
||||
AUTO_FLOOR_BITRATE = 800_000
|
||||
|
||||
PRESETS = [
|
||||
{ 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 }
|
||||
].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:)
|
||||
new(upload_mbps: upload_mbps).call
|
||||
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
|
||||
|
||||
def remove_mediamtx_paths!
|
||||
Mediamtx::Client.for_session(@session).delete_path(@session)
|
||||
Mediamtx::Client.new.delete_path(@session)
|
||||
rescue Mediamtx::Client::Error => e
|
||||
Rails.logger.warn("[Sessions::Stop] delete_path #{@session.id}: #{e.message}")
|
||||
end
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
# 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
|
||||
@@ -1,15 +0,0 @@
|
||||
# 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
|
||||
@@ -1,35 +0,0 @@
|
||||
# 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
|
||||
@@ -1,191 +0,0 @@
|
||||
# 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 = {})
|
||||
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)
|
||||
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
|
||||
@@ -1,45 +0,0 @@
|
||||
# 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
|
||||
@@ -1,162 +0,0 @@
|
||||
# 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
|
||||
@@ -1,14 +0,0 @@
|
||||
# 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
|
||||
@@ -1,21 +0,0 @@
|
||||
# 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
|
||||
@@ -1,106 +0,0 @@
|
||||
# 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
|
||||
@@ -1,47 +0,0 @@
|
||||
# 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
|
||||
@@ -1,152 +0,0 @@
|
||||
# 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"])
|
||||
api_base = simulated ? home.api_base_url : "http://#{private_ip}:9997"
|
||||
internal_rtmp = simulated ? home.internal_rtmp_url : "rtmp://#{private_ip}:1935"
|
||||
internal_hls = simulated ? home.internal_hls_url : "http://#{private_ip}:8888"
|
||||
|
||||
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: use_node_hostname ? "rtmp://#{hostname}:1935" : home.rtmp_base_url,
|
||||
hls_base_url: use_node_hostname ? "https://#{hostname}/hls" : home.hls_base_url,
|
||||
api_base_url: api_base,
|
||||
internal_rtmp_url: internal_rtmp,
|
||||
internal_hls_url: internal_hls,
|
||||
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 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
|
||||
@@ -1,66 +0,0 @@
|
||||
# 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,35 +1,29 @@
|
||||
module Streams
|
||||
# Relay verso YouTube: legge RTMP/HLS da MediaMTX e inoltra su RTMPS (-c copy). Nessun overlay.
|
||||
# ffmpeg gira solo sui worker Sidekiq con YOUTUBE_RELAY_WORKER=1 (coda youtube_relay).
|
||||
# ffmpeg gira solo nel container sidekiq (YOUTUBE_RELAY_WORKER=1).
|
||||
class YoutubeRelay
|
||||
class Error < StandardError; end
|
||||
|
||||
REDIS_KEY = "youtube_relay:pid:%s"
|
||||
OWNER_KEY = "youtube_relay:owner:%s"
|
||||
OWNED_SET = "youtube_relay:owned:%s"
|
||||
QUEUE = :youtube_relay
|
||||
|
||||
class << self
|
||||
def worker?
|
||||
ENV["YOUTUBE_RELAY_WORKER"] == "1"
|
||||
end
|
||||
|
||||
def max_concurrent
|
||||
ENV.fetch("RELAY_MAX_CONCURRENT", "4").to_i
|
||||
end
|
||||
|
||||
def start(session)
|
||||
return unless worker?
|
||||
|
||||
start_on_worker!(session)
|
||||
end
|
||||
|
||||
# Non cancella owner/pid qui: solo il worker owner deve killare ffmpeg.
|
||||
def stop(session)
|
||||
if worker? && owner_is_local?(session.id)
|
||||
clear_pid(session.id)
|
||||
redis.del(format(OWNER_KEY, session.id))
|
||||
if worker?
|
||||
stop_on_worker!(session)
|
||||
else
|
||||
YoutubeRelayStopJob.set(queue: QUEUE).perform_later(session.id)
|
||||
YoutubeRelayStopJob.perform_later(session.id)
|
||||
end
|
||||
true
|
||||
end
|
||||
@@ -37,13 +31,11 @@ module Streams
|
||||
def running?(session_id)
|
||||
owner = redis.get(format(OWNER_KEY, 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 process_alive?(pid) if owner.blank? || owner == worker_id
|
||||
|
||||
# Relay su altro host: attivo se lock owner ancora fresco.
|
||||
# Relay avviato in un altro container: consideralo attivo se il lock è recente.
|
||||
redis.ttl(format(OWNER_KEY, session_id)) > 30
|
||||
end
|
||||
|
||||
@@ -55,12 +47,12 @@ module Streams
|
||||
if worker?
|
||||
ensure_on_worker!(session)
|
||||
else
|
||||
YoutubeRelayEnsureJob.set(queue: QUEUE).perform_later(session.id)
|
||||
YoutubeRelayEnsureJob.perform_later(session.id)
|
||||
end
|
||||
end
|
||||
|
||||
def ensure_on_worker!(session)
|
||||
return :not_worker unless worker?
|
||||
return unless worker?
|
||||
return unless session.platform == "youtube"
|
||||
return if session.terminal?
|
||||
return if session.stream_key.blank?
|
||||
@@ -68,64 +60,28 @@ module Streams
|
||||
return unless intake_available?(session)
|
||||
|
||||
pid = pid_for(session.id)
|
||||
if pid.present? && !process_alive?(pid.to_i)
|
||||
clear_local_ownership(session.id)
|
||||
end
|
||||
clear_pid(session.id) if pid.present? && !process_alive?(pid.to_i)
|
||||
|
||||
if running?(session.id)
|
||||
touch_owner!(session.id) if owner_is_local?(session.id)
|
||||
return :already_running
|
||||
end
|
||||
|
||||
if at_capacity?
|
||||
YoutubeRelayEnsureJob.set(wait: 5.seconds, queue: QUEUE).perform_later(session.id)
|
||||
Rails.logger.info("[YoutubeRelay] at capacity worker=#{worker_id} session=#{session.id} requeue")
|
||||
return :at_capacity
|
||||
end
|
||||
return if running?(session.id)
|
||||
|
||||
last_restart = redis.get(restart_debounce_key(session.id)).to_i
|
||||
return :debounced if last_restart.positive? && (Time.now.to_i - last_restart) < 5
|
||||
return if last_restart.positive? && (Time.now.to_i - last_restart) < 5
|
||||
|
||||
start_on_worker!(session)
|
||||
redis.set(restart_debounce_key(session.id), Time.now.to_i, ex: 300)
|
||||
:started
|
||||
rescue Error => e
|
||||
Rails.logger.warn("[YoutubeRelay] ensure_on_worker session=#{session.id}: #{e.message}")
|
||||
:error
|
||||
end
|
||||
|
||||
# @return [Symbol] :stopped, :wrong_host, :noop
|
||||
def stop_on_worker!(session)
|
||||
return :not_worker unless worker?
|
||||
|
||||
owner = redis.get(format(OWNER_KEY, session.id))
|
||||
if owner.present? && owner != worker_id
|
||||
return :wrong_host
|
||||
end
|
||||
|
||||
pid = pid_for(session.id)
|
||||
if pid.blank?
|
||||
clear_local_ownership(session.id)
|
||||
return :noop
|
||||
end
|
||||
return false if pid.blank?
|
||||
|
||||
terminate_pid(pid)
|
||||
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
|
||||
clear_pid(session.id)
|
||||
redis.del(format(OWNER_KEY, session.id))
|
||||
Rails.logger.info("[YoutubeRelay] stopped pid=#{pid} session=#{session.id}")
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
@@ -136,9 +92,9 @@ module Streams
|
||||
return if session.terminal?
|
||||
return unless intake_available?(session)
|
||||
|
||||
return pid_for(session.id).to_i if running?(session.id) && owner_is_local?(session.id)
|
||||
return pid_for(session.id).to_i if running?(session.id)
|
||||
|
||||
stop_on_worker!(session) if pid_for(session.id).present? && owner_is_local?(session.id)
|
||||
stop_on_worker!(session) if pid_for(session.id).present?
|
||||
|
||||
log_path = log_file(session)
|
||||
FileUtils.mkdir_p(File.dirname(log_path))
|
||||
@@ -152,8 +108,8 @@ module Streams
|
||||
)
|
||||
Process.detach(pid)
|
||||
store_pid(session.id, pid)
|
||||
claim_ownership!(session.id)
|
||||
Rails.logger.info("[YoutubeRelay] started pid=#{pid} session=#{session.id} intake=#{intake_source.join(":")} worker=#{worker_id}")
|
||||
redis.set(format(OWNER_KEY, session.id), worker_id, ex: 48.hours.to_i)
|
||||
Rails.logger.info("[YoutubeRelay] started pid=#{pid} session=#{session.id} intake=#{intake_source.join(":")}")
|
||||
schedule_youtube_activate(session)
|
||||
pid
|
||||
rescue Errno::ENOENT => e
|
||||
@@ -190,20 +146,20 @@ module Streams
|
||||
end
|
||||
|
||||
def mediamtx_intake_source(session)
|
||||
base = session.mediamtx_internal_rtmp_url
|
||||
base = ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935")
|
||||
if Mediamtx::PublisherOnline.active?(session)
|
||||
return [:rtmp, "#{base.chomp('/')}/#{session.mediamtx_path_name}"]
|
||||
end
|
||||
|
||||
path = session.mediamtx_path_name
|
||||
hls = session.mediamtx_internal_hls_url.chomp("/")
|
||||
hls = ENV.fetch("MEDIAMTX_HLS_URL", "http://mediamtx:8888").chomp("/")
|
||||
[:hls, "#{hls}/#{path}/index.m3u8"]
|
||||
end
|
||||
|
||||
def intake_available?(session)
|
||||
return true if Mediamtx::PublisherOnline.active?(session)
|
||||
|
||||
info = Mediamtx::Client.for_session(session).list_paths.find { |i| i["name"] == session.mediamtx_path_name }
|
||||
info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == session.mediamtx_path_name }
|
||||
info && (info["ready"] || info["online"] || info["available"])
|
||||
rescue StandardError
|
||||
false
|
||||
@@ -229,22 +185,6 @@ module Streams
|
||||
format("youtube_relay:debounce:%s", session_id)
|
||||
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)
|
||||
redis.set(format(REDIS_KEY, session_id), pid, ex: 48.hours.to_i)
|
||||
end
|
||||
|
||||
@@ -68,13 +68,13 @@ module Webhooks
|
||||
def enable_recording(session)
|
||||
return unless session.match.team.entitlements.recording_enabled_for_mediamtx?
|
||||
|
||||
Mediamtx::Client.for_session(session).set_path_recording(session, enabled: true)
|
||||
Mediamtx::Client.new.set_path_recording(session, enabled: true)
|
||||
rescue Mediamtx::Client::Error => e
|
||||
Rails.logger.warn("[MediamtxHandler] enable recording: #{e.message}")
|
||||
end
|
||||
|
||||
def disable_recording(session)
|
||||
Mediamtx::Client.for_session(session).set_path_recording(session, enabled: false)
|
||||
Mediamtx::Client.new.set_path_recording(session, enabled: false)
|
||||
rescue Mediamtx::Client::Error => e
|
||||
Rails.logger.warn("[MediamtxHandler] disable recording: #{e.message}")
|
||||
end
|
||||
|
||||
@@ -63,7 +63,7 @@ module Youtube
|
||||
return
|
||||
end
|
||||
|
||||
Mediamtx::Client.for_session(session).set_always_available(session, enabled: false)
|
||||
Mediamtx::Client.new.set_always_available(session, enabled: false)
|
||||
session.go_live! if session.may_go_live?
|
||||
session.reconnect! if session.reconnecting? && session.may_reconnect?
|
||||
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
<% 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>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<link rel="stylesheet" href="/admin.css?v=4">
|
||||
<link rel="stylesheet" href="/admin.css?v=2">
|
||||
<% if content_for?(:replay_archive_styles) %>
|
||||
<link rel="stylesheet" href="/marketing.css?v=42">
|
||||
<% end %>
|
||||
@@ -29,7 +29,6 @@
|
||||
<%= 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.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 %>
|
||||
<%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %>
|
||||
<% end %>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<meta name="theme-color" content="#0a0a0e">
|
||||
<title><%= content_for?(:title) ? yield(:title) : t("regia.meta_title_fallback") %></title>
|
||||
<%= render "shared/meta_tags" %>
|
||||
<link rel="stylesheet" href="/regia.css?v=1">
|
||||
<link rel="stylesheet" href="/regia.css?v=11">
|
||||
<%= yield :head %>
|
||||
</head>
|
||||
<body class="regia-body">
|
||||
|
||||
@@ -21,4 +21,4 @@
|
||||
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px">−</button>
|
||||
</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>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
|
||||
data-resume-url="<%= j public_regia_resume_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-hls-url="<%= j @session.hls_playback_url %>"
|
||||
data-home-name="<%= j @team.name %>"
|
||||
@@ -29,40 +31,62 @@
|
||||
data-initial-paused="<%= @session.paused? %>">
|
||||
|
||||
<header class="regia-header">
|
||||
<div class="regia-header__text">
|
||||
<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')) %>">
|
||||
<%= @stream_closed ? t("regia.status.ended") : (@session.paused? ? t("regia.status.paused") : (@on_air ? t("regia.status.live") : t("regia.status.waiting"))) %>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<nav class="regia-toolbar" aria-label="<%= t("regia.links_nav") %>">
|
||||
<% if @live_share_url.present? %>
|
||||
<div class="regia-share-group">
|
||||
<p class="regia-share-label"><%= t("regia.live_share_label") %></p>
|
||||
<div class="regia-share">
|
||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-share-live" style="margin:0">
|
||||
<%= t("regia.share_live") %>
|
||||
<div class="regia-tool-group" role="group" aria-label="<%= t("regia.live_share_label") %>">
|
||||
<span class="regia-tool-group__label"><%= t("regia.live_share_label") %></span>
|
||||
<div class="regia-tool-group__btns">
|
||||
<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-btn regia-btn--outline" id="btn-copy-live" style="margin:0">
|
||||
<%= t("regia.copy_link") %>
|
||||
<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-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") %>
|
||||
<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 type="button" class="regia-btn regia-btn--outline" id="btn-copy-regia" style="margin:0">
|
||||
<%= t("regia.copy_link") %>
|
||||
<button type="button" class="regia-icon-btn" id="btn-copy-regia" aria-label="<%= t("regia.copy_regia") %>" title="<%= t("regia.copy_regia") %>">
|
||||
<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>
|
||||
<% unless @stream_closed %>
|
||||
<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 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 %>
|
||||
</nav>
|
||||
|
||||
<div class="regia-preview">
|
||||
<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 %>
|
||||
@@ -70,14 +94,55 @@
|
||||
<%= @stream_closed ? t("regia.preview_ended") : t("regia.preview_waiting") %>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="regia-scoreboard">
|
||||
<%= render regia_board_partial(@match) %>
|
||||
</section>
|
||||
|
||||
<% unless @stream_closed %>
|
||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-pause"><%= @session.paused? ? t("regia.resume") : t("regia.pause") %></button>
|
||||
<button type="button" class="regia-btn regia-btn--danger" id="btn-stop"><%= t("regia.stop") %></button>
|
||||
<section class="regia-telemetry" id="regia-telemetry" aria-label="<%= t("regia.telemetry.label") %>">
|
||||
<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 %>
|
||||
</div>
|
||||
|
||||
@@ -94,4 +159,4 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/regia.js?v=8"></script>
|
||||
<script src="/regia.js?v=18"></script>
|
||||
|
||||
@@ -62,8 +62,8 @@ Rails.application.configure do
|
||||
# Annotate rendered view with file names.
|
||||
config.action_view.annotate_rendered_view_with_filenames = true
|
||||
|
||||
# Uncomment if you wish to allow Action Cable access from any origin.
|
||||
# config.action_cable.disable_request_forgery_protection = true
|
||||
# Permette Action Cable dall'emulatore Android (10.0.2.2) e da altri host locali.
|
||||
config.action_cable.disable_request_forgery_protection = true
|
||||
|
||||
# Raise error when a before_action's only/except options reference missing actions.
|
||||
config.action_controller.raise_on_missing_callback_actions = true
|
||||
|
||||
@@ -6,7 +6,6 @@ Sidekiq.configure_server do |config|
|
||||
config.on(:startup) do
|
||||
StreamPublisherSyncJob.ensure_chain
|
||||
Ops::HealthMonitorJob.ensure_chain
|
||||
Streams::AutoscalerJob.ensure_chain
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ de:
|
||||
billing: Abrechnung
|
||||
youtube: YouTube
|
||||
sessions: Sitzungen
|
||||
stream_nodes: Stream-Knoten
|
||||
password: Passwort
|
||||
logout: Abmelden
|
||||
flash:
|
||||
@@ -27,11 +26,6 @@ de:
|
||||
ops_acknowledged: Vorfall übernommen
|
||||
ops_resolved: Vorfall gelöst
|
||||
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_revoked: "Kostenloses Abonnement für %{club} widerrufen."
|
||||
session_already_terminated: "Sitzung bereits beendet (%{status})."
|
||||
@@ -117,48 +111,6 @@ de:
|
||||
title: Teams
|
||||
matches_count: "%{count} Spiele"
|
||||
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:
|
||||
kpi:
|
||||
critical: Kritisch offen
|
||||
|
||||
@@ -9,7 +9,6 @@ en:
|
||||
billing: Billing
|
||||
youtube: YouTube
|
||||
sessions: Sessions
|
||||
stream_nodes: Stream nodes
|
||||
password: Password
|
||||
logout: Log out
|
||||
flash:
|
||||
@@ -27,11 +26,6 @@ en:
|
||||
ops_acknowledged: Incident acknowledged
|
||||
ops_resolved: Incident resolved
|
||||
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_revoked: "Complimentary subscription revoked for %{club}."
|
||||
session_already_terminated: "Session already ended (%{status})."
|
||||
@@ -117,48 +111,6 @@ en:
|
||||
title: Teams
|
||||
matches_count: "%{count} matches"
|
||||
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:
|
||||
kpi:
|
||||
critical: Critical open
|
||||
|
||||
@@ -9,7 +9,6 @@ es:
|
||||
billing: Facturación
|
||||
youtube: YouTube
|
||||
sessions: Sesiones
|
||||
stream_nodes: Nodos stream
|
||||
password: Contraseña
|
||||
logout: Salir
|
||||
flash:
|
||||
@@ -27,11 +26,6 @@ es:
|
||||
ops_acknowledged: Incidencia asumida
|
||||
ops_resolved: Incidencia resuelta
|
||||
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_revoked: "Suscripción de cortesía revocada para %{club}."
|
||||
session_already_terminated: "La sesión ya ha finalizado (%{status})."
|
||||
@@ -117,48 +111,6 @@ es:
|
||||
title: Equipos
|
||||
matches_count: "%{count} partidos"
|
||||
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:
|
||||
kpi:
|
||||
critical: Críticas abiertas
|
||||
|
||||
@@ -9,7 +9,6 @@ fr:
|
||||
billing: Facturation
|
||||
youtube: YouTube
|
||||
sessions: Sessions
|
||||
stream_nodes: Nœuds stream
|
||||
password: Mot de passe
|
||||
logout: Déconnexion
|
||||
flash:
|
||||
@@ -27,11 +26,6 @@ fr:
|
||||
ops_acknowledged: Incident pris en charge
|
||||
ops_resolved: Incident résolu
|
||||
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_revoked: "Abonnement offert révoqué pour %{club}."
|
||||
session_already_terminated: "Session déjà terminée (%{status})."
|
||||
@@ -117,48 +111,6 @@ fr:
|
||||
title: Équipes
|
||||
matches_count: "%{count} matchs"
|
||||
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:
|
||||
kpi:
|
||||
critical: Critiques ouverts
|
||||
|
||||
@@ -9,7 +9,6 @@ it:
|
||||
billing: Fatturazione
|
||||
youtube: YouTube
|
||||
sessions: Sessioni
|
||||
stream_nodes: Nodi stream
|
||||
password: Password
|
||||
logout: Esci
|
||||
flash:
|
||||
@@ -27,11 +26,6 @@ it:
|
||||
ops_acknowledged: Incidente preso in carico
|
||||
ops_resolved: Incidente risolto
|
||||
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_revoked: "Abbonamento omaggio revocato per %{club}."
|
||||
session_already_terminated: "Sessione già terminata (%{status})."
|
||||
@@ -117,48 +111,6 @@ it:
|
||||
title: Squadre
|
||||
matches_count: "%{count} partite"
|
||||
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:
|
||||
kpi:
|
||||
critical: Critici aperti
|
||||
|
||||
@@ -432,16 +432,51 @@ de:
|
||||
paused: Pausiert
|
||||
live: Live
|
||||
waiting: Wartet
|
||||
live_share_label: "Live-Link (Zuschauer)"
|
||||
live_share_label: Live
|
||||
share_live: Livestream teilen
|
||||
share_action: Teilen
|
||||
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
|
||||
preview_ended: Livestream beendet
|
||||
preview_waiting: Vorschau wartet auf Signal…
|
||||
preview_hide: Vorschau ausblenden
|
||||
preview_show: Vorschau anzeigen
|
||||
preview_off_hint: Vorschau aus · kein Video-Download
|
||||
pause: Pausieren
|
||||
resume: Livestream fortsetzen
|
||||
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:
|
||||
set_won_title: Satz gewonnen
|
||||
confirm: Satz beenden
|
||||
@@ -486,6 +521,8 @@ de:
|
||||
copy_prompt: "Link kopieren:"
|
||||
share_regia_text: "Öffne diesen Link, um den Live-Spielstand zu verwalten:"
|
||||
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:
|
||||
archive:
|
||||
back_prefix: "←"
|
||||
|
||||
@@ -427,16 +427,51 @@ en:
|
||||
paused: Paused
|
||||
live: Live
|
||||
waiting: Waiting
|
||||
live_share_label: "Live link (viewers)"
|
||||
live_share_label: Live
|
||||
share_live: Share live link
|
||||
share_action: Share
|
||||
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
|
||||
preview_ended: Stream ended
|
||||
preview_waiting: Waiting for signal…
|
||||
preview_hide: Hide preview
|
||||
preview_show: Show preview
|
||||
preview_off_hint: Preview off · no video download
|
||||
pause: Pause stream
|
||||
resume: Resume 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:
|
||||
set_won_title: Set won
|
||||
confirm: Close set
|
||||
@@ -481,6 +516,8 @@ en:
|
||||
copy_prompt: "Copy the link:"
|
||||
share_regia_text: "Open this link to manage the live score:"
|
||||
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:
|
||||
archive:
|
||||
back_prefix: "←"
|
||||
|
||||
@@ -432,16 +432,51 @@ es:
|
||||
paused: En pausa
|
||||
live: En directo
|
||||
waiting: En espera
|
||||
live_share_label: "Enlace del directo (espectadores)"
|
||||
live_share_label: Directo
|
||||
share_live: Compartir directo
|
||||
share_action: Compartir
|
||||
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
|
||||
preview_ended: Directo finalizado
|
||||
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
|
||||
resume: Reanudar 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:
|
||||
set_won_title: Set ganado
|
||||
confirm: Cerrar set
|
||||
@@ -486,6 +521,8 @@ es:
|
||||
copy_prompt: "Copia el enlace:"
|
||||
share_regia_text: "Abre este enlace para gestionar el marcador en directo:"
|
||||
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:
|
||||
archive:
|
||||
back_prefix: "←"
|
||||
|
||||
@@ -432,16 +432,51 @@ fr:
|
||||
paused: En pause
|
||||
live: En direct
|
||||
waiting: En attente
|
||||
live_share_label: "Lien direct (spectateurs)"
|
||||
live_share_label: Direct
|
||||
share_live: Partager le direct
|
||||
share_action: Partager
|
||||
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
|
||||
preview_ended: Direct terminé
|
||||
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
|
||||
resume: Reprendre 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:
|
||||
set_won_title: Set gagné
|
||||
confirm: Terminer le set
|
||||
@@ -486,6 +521,8 @@ fr:
|
||||
copy_prompt: "Copiez le lien :"
|
||||
share_regia_text: "Ouvrez ce lien pour gérer le score du direct :"
|
||||
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:
|
||||
archive:
|
||||
back_prefix: "←"
|
||||
|
||||
@@ -427,16 +427,51 @@ it:
|
||||
paused: In pausa
|
||||
live: In onda
|
||||
waiting: In attesa
|
||||
live_share_label: "Link diretta (spettatori)"
|
||||
live_share_label: Diretta
|
||||
share_live: Condividi diretta
|
||||
share_action: Condividi
|
||||
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
|
||||
preview_ended: Diretta terminata
|
||||
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
|
||||
resume: Riprendi 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:
|
||||
set_won_title: Set vinto
|
||||
confirm: Chiudi set
|
||||
@@ -481,6 +516,8 @@ it:
|
||||
copy_prompt: "Copia il link:"
|
||||
share_regia_text: "Apri questo link per gestire il punteggio della diretta:"
|
||||
share_live_text: "Guarda la diretta su Match Live TV:"
|
||||
min_quality_updated: Qualità minima aggiornata
|
||||
min_quality_error: Errore aggiornamento qualità minima
|
||||
recordings:
|
||||
archive:
|
||||
back_prefix: "←"
|
||||
|
||||
@@ -55,6 +55,7 @@ Rails.application.routes.draw do
|
||||
post :claim_pairing
|
||||
post :regia_link
|
||||
post :network_test
|
||||
patch :min_quality
|
||||
get :youtube_stats
|
||||
end
|
||||
end
|
||||
@@ -110,15 +111,6 @@ Rails.application.routes.draw do
|
||||
post :regia_link
|
||||
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
|
||||
end
|
||||
|
||||
@@ -152,6 +144,7 @@ Rails.application.routes.draw do
|
||||
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/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"
|
||||
get "sitemap.xml", to: "public/sitemap#show", defaults: { format: :xml }
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
:concurrency: 5
|
||||
:queues:
|
||||
- critical
|
||||
- youtube_relay
|
||||
- default
|
||||
- critical
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# 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,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
+3
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_08_09_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
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -138,6 +138,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_09_120000) do
|
||||
t.datetime "last_seen_at"
|
||||
t.datetime "created_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"], name: "index_device_states_on_stream_session_id"
|
||||
end
|
||||
@@ -304,6 +305,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_09_120000) do
|
||||
t.string "regia_token_digest"
|
||||
t.datetime "regia_token_expires_at"
|
||||
t.uuid "stream_node_id"
|
||||
t.string "min_quality_preset", default: "auto", null: false
|
||||
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 ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
|
||||
|
||||
@@ -43,6 +43,3 @@ end
|
||||
|
||||
puts "Seed OK: coach@matchlivetv.test / Password123"
|
||||
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}"
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# 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
|
||||
end
|
||||
+13
-106
@@ -228,8 +228,6 @@ body.admin-body {
|
||||
.badge--live { background: var(--red); color: #fff; }
|
||||
.badge--connecting { background: #ff9800; color: #111; }
|
||||
.badge--paused { background: #555; color: #fff; }
|
||||
.badge--ready { background: #1b5e20; color: #c8e6c9; }
|
||||
.badge--ok { background: #1b5e20; color: #c8e6c9; }
|
||||
|
||||
.team-list {
|
||||
list-style: none;
|
||||
@@ -347,40 +345,28 @@ body.admin-body {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.admin-btn {
|
||||
display: inline-block;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
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--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 {
|
||||
display: inline-block;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.admin-btn--sm { padding: 0.25rem 0.55rem; font-size: 0.75rem; }
|
||||
|
||||
.admin-btn--danger {
|
||||
background: var(--red);
|
||||
color: #fff;
|
||||
@@ -388,85 +374,6 @@ body.admin-body {
|
||||
|
||||
.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); }
|
||||
.empty { color: var(--muted); font-size: 0.9rem; margin: 0; }
|
||||
|
||||
|
||||
+277
-41
@@ -4,6 +4,8 @@
|
||||
--regia-surface: #14141c;
|
||||
--regia-red: #e53935;
|
||||
--regia-yellow: #f9a825;
|
||||
--regia-green: #43a047;
|
||||
--regia-orange: #fb8c00;
|
||||
--regia-text: #f5f5f5;
|
||||
--regia-muted: #9ca3af;
|
||||
--regia-safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
@@ -22,37 +24,166 @@
|
||||
.regia-page {
|
||||
max-width: 480px;
|
||||
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 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.regia-header p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.85rem;
|
||||
margin: 2px 0 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--regia-muted);
|
||||
}
|
||||
|
||||
.regia-badge {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
padding: 4px 10px;
|
||||
margin-top: 0;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.regia-badge--live { background: #2e7d32; color: #fff; }
|
||||
.regia-badge--wait { background: #444; 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 {
|
||||
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:active { transform: scale(0.96); }
|
||||
|
||||
.regia-icon-btn.is-off {
|
||||
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 {
|
||||
margin: 14px 0;
|
||||
margin: 0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
@@ -90,29 +221,29 @@
|
||||
.regia-scoreboard {
|
||||
background: var(--regia-surface);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.regia-sets {
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--regia-muted);
|
||||
margin: 0 0 8px;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.regia-score-line {
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.regia-partials {
|
||||
text-align: center;
|
||||
font-size: 0.78rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--regia-muted);
|
||||
margin: 8px 0 0;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.regia-team-row {
|
||||
@@ -120,7 +251,7 @@
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.regia-team-name {
|
||||
@@ -131,7 +262,7 @@
|
||||
}
|
||||
|
||||
.regia-points {
|
||||
font-size: 2rem;
|
||||
font-size: 1.55rem;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -146,7 +277,7 @@
|
||||
.regia-btn {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 16px 12px;
|
||||
padding: 10px 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
@@ -156,8 +287,8 @@
|
||||
|
||||
.regia-btn:active { transform: scale(0.98); }
|
||||
.regia-btn--point { background: var(--regia-red); color: #fff; }
|
||||
.regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 12px; }
|
||||
.regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 10px; }
|
||||
.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: 8px; padding: 12px; }
|
||||
.regia-btn--outline {
|
||||
background: transparent;
|
||||
border: 1px solid #444;
|
||||
@@ -173,26 +304,6 @@
|
||||
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 {
|
||||
position: fixed;
|
||||
bottom: calc(16px + var(--regia-safe-bottom));
|
||||
@@ -240,7 +351,7 @@
|
||||
text-align: center;
|
||||
color: var(--regia-muted);
|
||||
font-size: 0.72rem;
|
||||
padding-top: 24px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.regia-clock-line {
|
||||
@@ -255,3 +366,128 @@
|
||||
gap: 8px;
|
||||
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);
|
||||
}
|
||||
|
||||
+262
-5
@@ -37,13 +37,32 @@
|
||||
shareLiveText: "Guarda la diretta su Match Live TV:",
|
||||
previewWaiting: "Anteprima in attesa del segnale…",
|
||||
streamEnded: "Diretta terminata",
|
||||
previewHide: "Nascondi anteprima",
|
||||
previewShow: "Mostra anteprima",
|
||||
previewOffHint: "Anteprima spenta · nessun download video",
|
||||
resumeLabel: "Riprendi diretta",
|
||||
pauseLabel: "Metti in pausa",
|
||||
endedBadge: "Terminata",
|
||||
pausedBadge: "In pausa",
|
||||
liveBadge: "In onda",
|
||||
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() {
|
||||
@@ -77,6 +96,7 @@
|
||||
pauseUrl: root.dataset.pauseUrl,
|
||||
resumeUrl: root.dataset.resumeUrl,
|
||||
stopUrl: root.dataset.stopUrl,
|
||||
minQualityUrl: root.dataset.minQualityUrl,
|
||||
cableUrl: root.dataset.cableUrl,
|
||||
hlsUrl: root.dataset.hlsUrl,
|
||||
homeName: root.dataset.homeName,
|
||||
@@ -102,15 +122,28 @@
|
||||
modalCancel: document.getElementById("modal-cancel"),
|
||||
preview: document.getElementById("regia-preview"),
|
||||
previewPlaceholder: document.getElementById("regia-preview-placeholder"),
|
||||
previewWrap: document.getElementById("regia-preview-wrap"),
|
||||
btnTogglePreview: document.getElementById("btn-toggle-preview"),
|
||||
btnPause: document.getElementById("btn-pause"),
|
||||
subtitle: document.getElementById("regia-subtitle")
|
||||
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 hls = null;
|
||||
let previewStarted = false;
|
||||
let previewWanted = true;
|
||||
let wasPausedPreview = false;
|
||||
let streamPaused = root.dataset.initialPaused === "true";
|
||||
const PREVIEW_PREF_KEY = "mltv-regia-preview";
|
||||
|
||||
function hlsUrlFresh() {
|
||||
const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
|
||||
@@ -139,8 +172,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() {
|
||||
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed) return;
|
||||
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed || !previewWanted) return;
|
||||
destroyPreview();
|
||||
previewStarted = false;
|
||||
startPreview();
|
||||
@@ -243,7 +311,10 @@
|
||||
if (!els.btnPause) return;
|
||||
const paused = !!(data.paused || data.status === "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 setBadge(data) {
|
||||
@@ -364,6 +435,11 @@
|
||||
}
|
||||
|
||||
const paused = data.paused || data.status === "paused";
|
||||
if (!previewWanted) {
|
||||
destroyPreview();
|
||||
wasPausedPreview = paused;
|
||||
return;
|
||||
}
|
||||
if (data.on_air) {
|
||||
if (!previewStarted) {
|
||||
startPreview();
|
||||
@@ -382,6 +458,119 @@
|
||||
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() {
|
||||
try {
|
||||
const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } });
|
||||
@@ -389,6 +578,8 @@
|
||||
const data = await res.json();
|
||||
applyScorePayload(data);
|
||||
setBadge(data);
|
||||
syncMinQuality(data);
|
||||
syncTelemetry(data);
|
||||
syncPreviewFromStatus(data);
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -401,7 +592,7 @@
|
||||
}
|
||||
|
||||
function startPreview() {
|
||||
if (cfg.streamClosed || !els.preview || !cfg.hlsUrl || previewStarted) return;
|
||||
if (cfg.streamClosed || !previewWanted || !els.preview || !cfg.hlsUrl || previewStarted) return;
|
||||
|
||||
previewStarted = true;
|
||||
const url = hlsUrlFresh();
|
||||
@@ -454,6 +645,8 @@
|
||||
const wasPaused = streamPaused;
|
||||
applyScorePayload(data);
|
||||
setBadge(data);
|
||||
syncMinQuality(data);
|
||||
syncTelemetry(data);
|
||||
syncPreviewFromStatus(data);
|
||||
toast(wasPaused ? I18N.resumed : I18N.pausedCover);
|
||||
}
|
||||
@@ -473,6 +666,70 @@
|
||||
els.btnPause?.addEventListener("click", () => togglePauseStream().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) {
|
||||
if (!url) {
|
||||
toast(I18N.linkUnavailable);
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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)
|
||||
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).and_return(job_proxy)
|
||||
expect(job_proxy).to receive(:perform_later).with(session.id, 1)
|
||||
|
||||
described_class.new.perform(session.id, 0)
|
||||
end
|
||||
end
|
||||
@@ -1,37 +0,0 @@
|
||||
# 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
|
||||
@@ -1,7 +1,7 @@
|
||||
require "rails_helper"
|
||||
|
||||
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!(:team) { club.teams.create!(name: "T", 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)
|
||||
expect(response).to have_http_status(:ok)
|
||||
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("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).not_to include("Aruba")
|
||||
end
|
||||
@@ -156,4 +167,39 @@ RSpec.describe "Public regia", type: :request do
|
||||
get public_regia_path(token)
|
||||
expect(response).to have_http_status(:ok)
|
||||
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
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
require "rails_helper"
|
||||
|
||||
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(:team) { club.teams.create!(name: "Under 16", sport: "volleyball") }
|
||||
let!(:match) { team.matches.create!(opponent_name: "Avversario") }
|
||||
@@ -20,11 +20,8 @@ RSpec.describe Mediamtx::PublisherSync do
|
||||
before do
|
||||
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
|
||||
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(: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(client).to receive(:set_path_recording)
|
||||
end
|
||||
@@ -39,7 +36,6 @@ RSpec.describe Mediamtx::PublisherSync 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?).and_return(false)
|
||||
path_info["online"] = false
|
||||
|
||||
described_class.new(session).call
|
||||
@@ -57,7 +53,6 @@ RSpec.describe Mediamtx::PublisherSync do
|
||||
it "non disabilita la registrazione su reconnecting con publisher offline" do
|
||||
session.update!(status: "reconnecting")
|
||||
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(false)
|
||||
allow(Mediamtx::PublisherOnline).to receive(:active?).and_return(false)
|
||||
|
||||
described_class.new(session).call
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ RSpec.describe Ops::HealthChecks do
|
||||
%i[
|
||||
check_recordings_size check_postgres check_redis check_mediamtx check_garage
|
||||
check_sidekiq_heartbeat check_sidekiq_dead check_http_rails check_rails_latency
|
||||
check_stream_overflow
|
||||
].each do |method|
|
||||
allow_any_instance_of(described_class).to receive(method).and_return(
|
||||
described_class::Finding.new(
|
||||
@@ -53,7 +52,6 @@ RSpec.describe Ops::HealthChecks do
|
||||
%i[
|
||||
check_recordings_size check_postgres check_redis check_mediamtx check_garage
|
||||
check_sidekiq_heartbeat check_sidekiq_dead check_http_rails check_rails_latency
|
||||
check_stream_overflow
|
||||
].each do |method|
|
||||
allow_any_instance_of(described_class).to receive(method).and_return(
|
||||
described_class::Finding.new(
|
||||
@@ -67,7 +65,7 @@ RSpec.describe Ops::HealthChecks do
|
||||
summary = described_class.new.summary
|
||||
|
||||
expect(summary[:status]).to eq("ok")
|
||||
expect(summary[:checks].size).to eq(11)
|
||||
expect(summary[:checks].size).to eq(10)
|
||||
end
|
||||
|
||||
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")
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -1,227 +0,0 @@
|
||||
# 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
|
||||
@@ -1,82 +0,0 @@
|
||||
# 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"
|
||||
) 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
|
||||
@@ -1,35 +0,0 @@
|
||||
# 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
|
||||
@@ -1,41 +0,0 @@
|
||||
# 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"
|
||||
|
||||
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(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
|
||||
].each { |k| ENV.delete(k) }
|
||||
end
|
||||
end
|
||||
@@ -1,66 +0,0 @@
|
||||
# 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"
|
||||
) 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 "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"
|
||||
) 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
|
||||
@@ -1,133 +0,0 @@
|
||||
# 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
|
||||
@@ -21,46 +21,4 @@ RSpec.describe Streams::YoutubeRelay do
|
||||
expect(cmd).not_to include("-b:a")
|
||||
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)
|
||||
redis.flushdb
|
||||
end
|
||||
|
||||
def session_double
|
||||
instance_double(
|
||||
StreamSession,
|
||||
id: session_id,
|
||||
platform: "youtube",
|
||||
terminal?: false,
|
||||
stream_key: "yt-key",
|
||||
status: "live"
|
||||
)
|
||||
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 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)).and_return(
|
||||
double(perform_later: true)
|
||||
)
|
||||
|
||||
expect(described_class.ensure_on_worker!(session_double)).to eq(:at_capacity)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
Documento di riferimento per l'intero sistema: rete, container Docker, flussi video, replay, monitoraggio ops e rilasci.
|
||||
|
||||
**Ultimo aggiornamento:** 2026-08-09
|
||||
**Produzione:** `eminux@192.168.1.146` → `/opt/matchlivetv` (Proxmox; control plane attuale per autoscale)
|
||||
**Ultimo aggiornamento:** 2026-07-29
|
||||
**Produzione:** `eminux@192.168.1.146` → `/opt/matchlivetv`
|
||||
|
||||
---
|
||||
|
||||
@@ -397,8 +397,7 @@ cd infra && cp .env.example .env && docker compose up -d --build
|
||||
| Documento | Contenuto |
|
||||
|-----------|-----------|
|
||||
| [`infrastructure/SERVER_DEPLOYMENT.md`](infrastructure/SERVER_DEPLOYMENT.md) | Bootstrap server, NPM, cron, backup |
|
||||
| [`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) |
|
||||
| [`infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md`](infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md) | Piano overflow relay YouTube su Hetzner Cloud (picchi) |
|
||||
| [`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 |
|
||||
| [`REPLAY_MODULE.md`](REPLAY_MODULE.md) | Garage, retention, YouTube VOD |
|
||||
@@ -421,4 +420,3 @@ cd infra && cp .env.example .env && docker compose up -d --build
|
||||
| 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-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`) |
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
# Piano: overflow relay YouTube su Hetzner Cloud
|
||||
|
||||
> **Superseded (2026-08-09):** il design corrente è [`STREAMING_AUTOSCALE.md`](STREAMING_AUTOSCALE.md) (Auction + Proxmox, scale di MediaMTX **e** ffmpeg, warm spare, secondo dominio DNS, Garage ora / B2 dopo). Questo file resta come riferimento storico sul solo overflow dei relay.
|
||||
|
||||
**Stato:** piano / design storico — non implementato come doc standalone
|
||||
**Ultimo aggiornamento:** 2026-07-29 (header superseded 2026-08-09)
|
||||
**Stato:** piano / design — non implementato
|
||||
**Ultimo aggiornamento:** 2026-07-29
|
||||
**Contesto:** produzione attuale su host dedicato (es. Proxmox/Hetzner) con MediaMTX + Sidekiq sullo stesso box; obiettivo >10 live YouTube contemporanee senza abbandonare il middle-tier.
|
||||
|
||||
Documenti correlati: [`STREAMING_AUTOSCALE.md`](STREAMING_AUTOSCALE.md), [`LIVE_STREAMING.md`](../LIVE_STREAMING.md), [`ARCHITECTURE.md`](../ARCHITECTURE.md), [`OPS_MONITORING.md`](../OPS_MONITORING.md).
|
||||
Documenti correlati: [`LIVE_STREAMING.md`](../LIVE_STREAMING.md), [`ARCHITECTURE.md`](../ARCHITECTURE.md), [`OPS_MONITORING.md`](../OPS_MONITORING.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
# Autoscale streaming — Proxmox attuale + Hetzner Cloud
|
||||
|
||||
**Stato:** fasi 0–4 implementate sul branch — **solo test lab; nessun deploy produzione**
|
||||
**Branch:** `feature/streaming-autoscale-hetzner`
|
||||
**Ultimo aggiornamento:** 2026-08-09 (Fase 4 hardening + runbook)
|
||||
|
||||
**Sostituisce / estende:** il piano overflow-only [`HETZNER_CLOUD_RELAY_OVERFLOW.md`](HETZNER_CLOUD_RELAY_OVERFLOW.md). Questo documento copre **MediaMTX + ffmpeg**, warm spare, DNS, storage, disco e lab sul Proxmox di produzione.
|
||||
|
||||
Documenti correlati: [`ARCHITECTURE.md`](../ARCHITECTURE.md), [`LIVE_STREAMING.md`](../LIVE_STREAMING.md), [`REPLAY_MODULE.md`](../REPLAY_MODULE.md), [`OPS_MONITORING.md`](../OPS_MONITORING.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Obiettivo
|
||||
|
||||
Scalare molte dirette contemporanee (sito e/o YouTube) senza saturare un unico box:
|
||||
|
||||
- **Control plane (ora):** Proxmox **già in produzione** (`192.168.1.146` / `/opt/matchlivetv`) — sito, API, DB, Redis, Garage, MediaMTX/ffmpeg “home”.
|
||||
- **Data plane elastico:** **Hetzner Cloud** — nodi `MediaMTX + ffmpeg` con warm spare (+1).
|
||||
- **DNS ingest:** secondo dominio su **Hetzner DNS** (API).
|
||||
- **Lab:** stesso Proxmox, con `ProxmoxLabProvider`, prima di affidarsi al Cloud in produzione.
|
||||
- **Futuro (non bloccante):** migrazione control plane su **Hetzner Auction + Proxmox** (vSwitch nativo, hardware dedicato).
|
||||
|
||||
---
|
||||
|
||||
## 2. Decisioni chiuse
|
||||
|
||||
| # | Decisione | Scelta |
|
||||
|---|----------|--------|
|
||||
| D1 | Host primario **ora** | **Proxmox già usato in produzione** (non attendere Auction) |
|
||||
| D1b | Host primario **futuro** | Hetzner Server Auction + Proxmox (quando si migra) |
|
||||
| D2 | Overflow / warm spare | **Hetzner Cloud** (attivato per questo progetto) |
|
||||
| D3 | Unità di scala | Nodo **stream** = MediaMTX + worker `youtube_relay` (ffmpeg remux copy) |
|
||||
| D4 | Warm pool | Almeno **1 spare ready** quando il carico si avvicina al cap |
|
||||
| D5 | DNS ingest | Dominio **`mltv-stream.net`** (registrar Aruba, NS → Hetzner DNS). `matchlivetv.it` resta su Aruba intatto |
|
||||
| D6 | Aruba | Nessuna delega NS; niente Floating IP prenotate |
|
||||
| D7a | Rete **ora** (casa/Proxmox ↔ Cloud) | **WireGuard** (o Tailscale) privato: Redis, DB, API MediaMTX home. Non esporre Redis/Postgres su WAN |
|
||||
| D7b | Rete **futuro** (Auction ↔ Cloud) | vSwitch Robot + Cloud Network (stessa location) |
|
||||
| D8 | Object storage | **Garage resta**. **B2** = migrazione pronta quando misurato |
|
||||
| D9 | Staging | Segmenti su disco VM `stream-*` in live; upload Garage a fine sessione |
|
||||
| D10 | Prodotto | Consigliare **YouTube** per alleggerire storage/egress replay |
|
||||
| D11 | Disco | Separazione volumi + **monitoraggio attivo** obbligatorio (§6) |
|
||||
| D12 | Lab prima del Cloud prod | `ProxmoxLabProvider` + DNS lab sullo stesso Proxmox; poi `HetznerCloudProvider` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Topologia — fase attuale (Proxmox prod + Cloud)
|
||||
|
||||
```text
|
||||
Internet
|
||||
│ HTTPS :443 · RTMP :1935
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Proxmox produzione (attuale) │
|
||||
│ eminux@192.168.1.146 · /opt/matchlivetv │
|
||||
│ │
|
||||
│ edge · rails · sidekiq-core · postgres · redis │
|
||||
│ garage · stream-home (MediaMTX + youtube_relay) │
|
||||
│ autoscaler (Rails/Sidekiq) │
|
||||
│ │
|
||||
│ Lab (opz.): clone VM stream-* via ProxmoxLabProvider │
|
||||
└────────────────────────┬─────────────────────────────────┘
|
||||
│ WireGuard / tunnel privato
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Hetzner Cloud — pool stream nodes │
|
||||
│ stream-01 … stream-N MediaMTX + ffmpeg │
|
||||
│ + 1 spare ready (warm) │
|
||||
└────────────────────────┬─────────────────────────────────┘
|
||||
│
|
||||
Hetzner DNS (secondo dominio)
|
||||
ingest-XX.<dominio> → IP pubblico nodo
|
||||
```
|
||||
|
||||
**Futuro Auction:** stesso schema, tunnel sostituito da vSwitch; control plane migrato sul bare metal Hetzner.
|
||||
|
||||
**Principio:** Rails solo controllo. Live sui MediaMTX del nodo assegnato. Replay su Garage (poi eventualmente B2).
|
||||
|
||||
---
|
||||
|
||||
## 4. DNS (secondo dominio)
|
||||
|
||||
RTMP **non** tollera round-robin su un unico hostname.
|
||||
|
||||
1. Dominio **`mltv-stream.net`** registrato su Aruba; zona DNS su Hetzner Console (progetto `matchlivetv-stream`).
|
||||
2. NS Aruba del dominio → `hydrogen` / `oxygen` / `helium` Hetzner (delegazione OK).
|
||||
3. Autoscaler a VM ready → upsert `A` `ingest-03.mltv-stream.net` → health-check → nodo `ready`.
|
||||
4. API create/start sessione → URL del nodo assegnato:
|
||||
|
||||
```text
|
||||
rtmp://ingest-03.mltv-stream.net:1935/live/match_<uuid>
|
||||
https://ingest-03.mltv-stream.net/hls/... # o via edge matchlivetv.it che proxya il nodo
|
||||
```
|
||||
|
||||
5. Scale-in: drain → delete DNS → destroy VM.
|
||||
|
||||
Hostname pubblici dei nodi vivono sotto `*.mltv-stream.net`. In lab: `LabDnsProvider` (`/etc/hosts`, dnsmasq).
|
||||
|
||||
---
|
||||
|
||||
## 5. Autoscaler e warm spare
|
||||
|
||||
Ogni nodo: `max_publishers` / `max_relays` (es. 4).
|
||||
|
||||
| Regola | Comportamento |
|
||||
|--------|----------------|
|
||||
| Scale-out | `free_slots` sotto soglia soft → crea 1 nodo |
|
||||
| Warm spare | Se `spare_ready < 1` vicino al carico → crea spare |
|
||||
| Scale-in | Idle da X min e non unica spare → destroy |
|
||||
| Floor | `stream-home` sul Proxmox sempre on |
|
||||
|
||||
`CloudProvider`: `HetznerCloudProvider` (prod overflow) + `ProxmoxLabProvider` (test).
|
||||
Code Sidekiq: coda `youtube_relay` isolata; sticky owner Redis.
|
||||
|
||||
---
|
||||
|
||||
## 6. Disco Proxmox — requisito critico
|
||||
|
||||
**Non** mettere OS, DB e video sullo stesso volume che può riempirsi al 100%.
|
||||
|
||||
Vale **già** sul Proxmox di produzione (rinforzare ora), e di nuovo alla migrazione Auction.
|
||||
|
||||
### 6.1 Layout volumi (target)
|
||||
|
||||
| Volume / disco | Contenuto | Note |
|
||||
|----------------|-----------|------|
|
||||
| `os` | Proxmox / sistema host | Quasi statico |
|
||||
| `vm-system` / root stack | rails, edge, redis, … | Snapshot |
|
||||
| `vm-data-db` | Postgres | Separato; backup prioritari |
|
||||
| `vm-data-video` | Garage + staging MediaMTX | **Disco che può riempirsi** |
|
||||
| `backup` | Backup / PBS | **Mai** sullo stesso disco dei video |
|
||||
|
||||
Regole: cap Garage/staging; se staging pieno → **rifiutare nuove sessioni**, non far cadere DB/API.
|
||||
|
||||
### 6.2 Monitoraggio attivo (obbligatorio)
|
||||
|
||||
| Metrica | Warn | Critical |
|
||||
|---------|------|----------|
|
||||
| Uso `%` volume video | ≥ 70% | ≥ 85% |
|
||||
| Spazio libero GB | sotto soglia | < X GB |
|
||||
| Inode liberi | bassi | esaurimento |
|
||||
| Crescita GB/giorno Garage | anomalia | — |
|
||||
| Purge/retention falliti | — | immediato |
|
||||
| Staging per nodo `stream-*` | ≥ 70% | ≥ 85% + blocco live su quel nodo |
|
||||
|
||||
Checklist:
|
||||
|
||||
- [ ] Alert ntfy disco testati
|
||||
- [ ] Vista ops “disco video”
|
||||
- [ ] Test periodico “80% pieno”
|
||||
- [ ] Restore di prova Postgres
|
||||
|
||||
---
|
||||
|
||||
## 7. Storage: Garage ora, B2 dopo
|
||||
|
||||
| Ora | **Garage** |
|
||||
| Dopo | **Backblaze B2** quando metriche/€ lo giustificano |
|
||||
|
||||
Client S3-ready; staging locale invariato. Spinta prodotto YouTube riduce pressione replay.
|
||||
|
||||
---
|
||||
|
||||
## 8. Prodotto: YouTube consigliato
|
||||
|
||||
Suggerire YouTube in app/dashboard quando ha senso → meno storage/egress MatchLiveTV; middle-tier (slate + relay) resta il valore. Live solo-sito restano supportate.
|
||||
|
||||
---
|
||||
|
||||
## 9. Astrazioni codice
|
||||
|
||||
```text
|
||||
CloudProvider
|
||||
create_node / destroy_node / list_nodes / wait_until_running / public_ip
|
||||
→ HetznerCloudProvider | ProxmoxLabProvider
|
||||
|
||||
DnsProvider
|
||||
upsert_a / delete_a
|
||||
→ HetznerDnsProvider | LabDnsProvider
|
||||
|
||||
StreamNodeRegistry
|
||||
register / mark_ready / allocate_for_session / drain / release
|
||||
|
||||
Autoscaler
|
||||
reconcile!(metrics) → ensure spare / scale-in idle
|
||||
```
|
||||
|
||||
Tag VM Cloud: `matchlivetv`, `role=stream-node`, `env=prod|lab`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Lab sul Proxmox attuale
|
||||
|
||||
Prima (o in parallelo) al Cloud “vero”:
|
||||
|
||||
1. Template VM `stream-node` su Proxmox.
|
||||
2. `CLOUD_PROVIDER=proxmox_lab` → clone/start/stop via API Proxmox.
|
||||
3. DNS lab (hosts/dnsmasq).
|
||||
4. Soft cap basso; N sessioni di test; verifica assignment, spare, scale-in, alert disco.
|
||||
5. Poi smoke su Hetzner Cloud (1 CX piccolo) + DNS reale.
|
||||
|
||||
Cosa il lab **non** replica al 100%: tempi boot Hetzner, vSwitch, RTMP 4G multi-nodo (smoke WAN dopo).
|
||||
|
||||
---
|
||||
|
||||
## 11. Fasi di implementazione
|
||||
|
||||
| Fase | Cosa | Esito |
|
||||
|------|------|--------|
|
||||
| **L — Lab Proxmox** | `LocalLab` / `ProxmoxLab`, DNS lab, admin nodi | **implementata sul branch** |
|
||||
| **0 — Multi-nodo ready** | Registry + URL RTMP/HLS in API (`StreamNode`, `Streams::NodeRegistry`) | **implementata sul branch** |
|
||||
| **1 — Hetzner Cloud + DNS** | `HetznerCloudProvider` + `HetznerDnsProvider`, `mltv-stream.net`, cloud-init, WireGuard (§16) | **implementata sul branch** (WG ops manuale) |
|
||||
| **2 — Routing relay** | Coda `youtube_relay`, sticky owner, cap `RELAY_MAX_CONCURRENT` | **implementata sul branch** |
|
||||
| **3 — Autoscaler** | Soglie + warm spare (+1), kill-switch `STREAM_AUTOSCALE_ENABLED` | **implementata sul branch** |
|
||||
| **4 — Hardening** | Drain sicuro, budget, kill-switch Redis/admin, alert overflow, runbook | **implementata sul branch** (solo test lab — **non in prod**) |
|
||||
| **A — Auction (futuro)** | Migrazione control plane + vSwitch al posto di WireGuard | Hardware dedicato Hetzner |
|
||||
| **B2 (opz.)** | Switch object storage | Quando misurato |
|
||||
|
||||
### Fase 0 — dettagli implementati
|
||||
|
||||
- Tabella `stream_nodes` + `stream_sessions.stream_node_id`
|
||||
- `Streams::NodeRegistry.ensure_home_from_env!` / `allocate!` (least-loaded)
|
||||
- `Sessions::Create` assegna il nodo e crea il path MediaMTX sul client del nodo
|
||||
- URL RTMP/HLS/API/internal per sessione derivati dal nodo (fallback ENV se nodo assente)
|
||||
- API JSON espone `stream_node` (slug)
|
||||
- Dominio ingest pubblico futuro: `*.mltv-stream.net`
|
||||
|
||||
### Fase L — dettagli implementati
|
||||
|
||||
- `Streams::CloudProviders` (`local_lab`, `proxmox_lab`, stub `hetzner`)
|
||||
- `Streams::DnsProviders` (`lab` su Redis, stub `hetzner`)
|
||||
- `Streams::NodeProvisioner` (provision / drain / decommission)
|
||||
- Admin **Nodi stream** (`/admin/stream_nodes`): lista, provision lab, drain, delete
|
||||
- Rake: `streams:nodes:ensure_home`, `streams:nodes:provision_lab`, `streams:nodes:dns_lab_dump`
|
||||
- Default sicuro: `STREAM_CLOUD_PROVIDER=local_lab` (nessuna chiamata Proxmox finché non configuri token)
|
||||
|
||||
### Fase 1 — dettagli implementati
|
||||
|
||||
- `Streams::CloudProviders::Hetzner` (create/destroy server, wait running, labels)
|
||||
- `Streams::DnsProviders::Hetzner` (upsert/delete A su zona `mltv-stream.net`)
|
||||
- `NodeProvisioner#provision_cloud!` + bottone admin **Provisiona nodo Hetzner**
|
||||
- Cloud-init: `infra/stream-node/cloud-init.yaml` (`HCLOUD_USER_DATA_FILE`)
|
||||
- Decommission usa il provider del nodo (non solo ENV globale)
|
||||
- WireGuard: vedi §16
|
||||
|
||||
### Fase 2 — dettagli implementati
|
||||
|
||||
- Coda Sidekiq dedicata `youtube_relay` (priorità sopra `default`)
|
||||
- `YoutubeRelayEnsureJob` / `YoutubeRelayStopJob` solo su quella coda
|
||||
- Stop sticky: non cancella owner da Rails; lo stop gira sull’owner o requeue (`:wrong_host`)
|
||||
- Cap per worker: `RELAY_MAX_CONCURRENT` (default 4) + set Redis `youtube_relay:owned:HOSTNAME`
|
||||
- Ensure a capacità piena → requeue 5s invece di avviare un secondo ffmpeg locale
|
||||
|
||||
### Fase 3 — dettagli implementati
|
||||
|
||||
- `Streams::Autoscaler` + job chain `Streams::AutoscalerJob` (come health monitor)
|
||||
- Kill-switch: `STREAM_AUTOSCALE_ENABLED=1` per attivare
|
||||
- Scale-out se `free_slots ≤ STREAM_AUTOSCALE_SOFT_FREE_SLOTS`
|
||||
- Warm spare (+N) se carico/soglia e `spare_ready < WARM_SPARE`
|
||||
- Scale-in overflow idle da `IDLE_MINUTES` (non tocca home; rispetta warm spare)
|
||||
- Cap `STREAM_AUTOSCALE_MAX_NODES`; kind `lab|cloud`
|
||||
- Metriche in admin Nodi stream
|
||||
|
||||
### Fase 4 — hardening (implementata sul branch)
|
||||
|
||||
> **NON rilasciare in produzione** finché lab + smoke Cloud non sono verdi. Su questo branch restano `STREAM_AUTOSCALE_ENABLED=0` e `STREAM_AUTOSCALE_ALLOW_CLOUD=0`.
|
||||
|
||||
- Scale-in **sicuro**: `drain!` → attesa sessioni zero → `decommission!`
|
||||
- Budget soft: `STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR` + `STREAM_AUTOSCALE_NODE_EUR_PER_HOUR` (stima 24/7); scale-out bloccato se sforerebbe
|
||||
- Cloud autoscale solo con `STREAM_AUTOSCALE_ALLOW_CLOUD=1` **e** `HCLOUD_TOKEN`
|
||||
- Kill-switch runtime Redis `streams:autoscaler:kill_switch` (bottoni admin ON/OFF) oltre all’ENV
|
||||
- Health check `stream_overflow` → incidente Ops/ntfy su nodi idle orfani, over-budget, capacità al max
|
||||
- Admin: metriche budget + stato kill-switch
|
||||
|
||||
#### Runbook operativo (lab / pre-prod)
|
||||
|
||||
| Situazione | Azione |
|
||||
|------------|--------|
|
||||
| Dubbio / picco anomalo | Admin → **Kill-switch ON** (o `STREAM_AUTOSCALE_ENABLED=0`) |
|
||||
| Nodo spillato | Drain dal admin; dopo fine live → Destroy |
|
||||
| Budget alert Ops | Abbassa `MAX_NODES` / alza budget solo dopo review costi Hetzner |
|
||||
| Smoke Cloud | Provision **manuale** admin (kind cloud), non autoscaler; verifica WG + RTMP 1935 |
|
||||
| Scale-out lab OK | Solo dopo: considerare `ENABLED=1` + `KIND=lab` su staging/lab Proxmox |
|
||||
| Produzione overflow | Solo dopo checklist §12 punto 6 |
|
||||
|
||||
Ordine di lavoro consigliato sul branch: **0 → L → 1 → 2 → 3 → 4**; **A** quando si decide di lasciare il Proxmox casa.
|
||||
|
||||
---
|
||||
|
||||
## 12. Ordine operativo “ora” (senza Auction)
|
||||
|
||||
1. Rinforzare dischi/alert sul Proxmox prod (§6).
|
||||
2. Secondo dominio + zona Hetzner DNS.
|
||||
3. Progetto Hetzner Cloud + immagine/snapshot `stream-node`.
|
||||
4. Tunnel WireGuard Proxmox ↔ Cloud (Redis/DB/API private).
|
||||
5. Implementare fasi 0 → L → 1… sul branch.
|
||||
6. Cutover overflow in produzione solo dopo lab + smoke Cloud verdi.
|
||||
|
||||
---
|
||||
|
||||
## 13. Criteri di successo
|
||||
|
||||
| Criterio | Misura |
|
||||
|----------|--------|
|
||||
| Lab | Scale-out/in e assignment verificati su Proxmox senza Hetzner |
|
||||
| Picco | N live oltre soft cap home con spare Cloud ready |
|
||||
| Continuità | Drop telefono → slate YT attiva |
|
||||
| Disco | Nessun outage DB/API per disco video; alert prima del critical |
|
||||
| DNS | Aruba intatto; ingest sul secondo dominio |
|
||||
| Futuro | Path chiaro verso Auction senza riscrivere autoscaler |
|
||||
|
||||
---
|
||||
|
||||
## 14. Aperto / da calibrare
|
||||
|
||||
| Voce | Note |
|
||||
|------|------|
|
||||
| Nome secondo dominio | **`mltv-stream.net`** (chiuso) |
|
||||
| Soft cap `stream-home` | es. 4 vs 6 |
|
||||
| Tipo VM Cloud | CPX vs CCX |
|
||||
| `OVERFLOW_MAX` / budget | — |
|
||||
| Spare di notte | 0 vs 1 |
|
||||
| Dettaglio WireGuard | subnet, peer, MTU |
|
||||
| Soglie GB disco | da size reale volume video |
|
||||
|
||||
---
|
||||
|
||||
## 15. Riepilogo esecutivo
|
||||
|
||||
**Ora:** restiamo sul Proxmox di produzione; attiviamo Hetzner Cloud (spare MediaMTX+ffmpeg) + Hetzner DNS (secondo dominio) + WireGuard. Testiamo prima in lab sullo stesso Proxmox. Garage resta; B2 dopo. YouTube consigliato in prodotto. Disco e alert sono vincolo non negoziabile.
|
||||
|
||||
**Dopo:** eventuale Auction Hetzner sostituisce il control plane casa e WireGuard → vSwitch, senza cambiare il modello nodi/autoscaler.
|
||||
|
||||
---
|
||||
|
||||
## 16. WireGuard (Proxmox casa ↔ Hetzner Cloud)
|
||||
|
||||
Obiettivo: Rails/Sidekiq sull’host di produzione raggiungono `api_base_url` / `internal_rtmp_url` dei nodi Cloud (es. `http://10.0.0.9:9997`) **senza** esporre Redis/Postgres/MediaMTX API su Internet.
|
||||
|
||||
### Setup consigliato (una tantum)
|
||||
|
||||
1. Su Hetzner Console: crea **Network** privata (es. `10.0.0.0/16`) nella location `fsn1`, subnet `10.0.1.0/24` per i server Cloud.
|
||||
2. Imposta `HCLOUD_NETWORK_ID=<id>` così i nodi stream entrano nella network al create.
|
||||
3. Su Proxmox (VM o LXC gateway): installa WireGuard; peer verso una VM “gateway” Cloud **sempre on** (CX22 piccola) *oppure* peer site-to-site verso un server Cloud fisso.
|
||||
4. Alternativa più semplice per i primi test: **Tailscale** su Proxmox host + su ogni stream-node (cloud-init) — stesso effetto, meno ops.
|
||||
5. Firewall Hetzner Cloud:
|
||||
- WAN: `1935/tcp` (RTMP), `443/tcp` o `8888` se HLS diretto
|
||||
- Solo rete privata / WG: `9997` (MediaMTX API), niente Postgres/Redis sui nodi stream
|
||||
6. Verifica da Rails: `curl http://<private_ip>:9997/v3/paths/list`
|
||||
|
||||
Finché WireGuard/Tailscale non è pronto, `api_base_url` punta comunque alla private IP (o pubblica se manca private_net): il path MediaMTX da Create fallirà dal control plane se non raggiungibile — provisionare nodi Cloud solo dopo connettività privata OK, oppure smoke con API su IP pubblico temporaneo + firewall allowlist IP casa.
|
||||
|
||||
### ENV produzione (stream autoscale)
|
||||
|
||||
```bash
|
||||
HCLOUD_TOKEN=...
|
||||
HCLOUD_LOCATION=nbg1
|
||||
HCLOUD_SERVER_TYPE=cpx12
|
||||
HCLOUD_IMAGE=debian-12
|
||||
HCLOUD_SSH_KEY=matchlivetv-stream-hetzner
|
||||
HCLOUD_NETWORK_ID= # opzionale
|
||||
HCLOUD_USER_DATA_FILE=/opt/matchlivetv/infra/stream-node/cloud-init.yaml
|
||||
|
||||
STREAM_DNS_ZONE=mltv-stream.net
|
||||
STREAM_DNS_TTL=60
|
||||
STREAM_CLOUD_DNS_SUFFIX=mltv-stream.net
|
||||
STREAM_CLOUD_MAX_PUBLISHERS=4
|
||||
STREAM_NODE_ENV=prod
|
||||
|
||||
# Default lab sicuro; in prod per overflow usa hetzner esplicitamente dal bottone admin
|
||||
STREAM_CLOUD_PROVIDER=local_lab
|
||||
STREAM_DNS_PROVIDER=lab
|
||||
```
|
||||
@@ -1,109 +0,0 @@
|
||||
# Copia come infra/.env sul container di collaudo e genera i secret.
|
||||
# cp .env.collaudo.example .env && openssl rand -hex 32 (ripeti per ogni CHANGE_ME)
|
||||
|
||||
POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD
|
||||
SECRET_KEY_BASE=CHANGE_ME_openssl_rand_hex_64
|
||||
JWT_SECRET=CHANGE_ME_openssl_rand_hex_32
|
||||
MEDIAMTX_WEBHOOK_SECRET=CHANGE_ME_openssl_rand_hex_32
|
||||
|
||||
# RTMP collaudo: porta host 11935 (vedi docker-compose.collaudo.yml).
|
||||
# LAN: rtmp://192.168.1.157:11935
|
||||
# WAN (opzionale): apri SOLO 11935 sul router → .157 — NON toccare :1935 di produzione.
|
||||
MEDIAMTX_RTMP_URL=rtmp://192.168.1.157:11935
|
||||
|
||||
APP_PUBLIC_URL=https://collaudo.matchlivetv.it
|
||||
HLS_PUBLIC_URL=https://collaudo.matchlivetv.it/hls
|
||||
CORS_ORIGINS=https://collaudo.matchlivetv.it
|
||||
ALLOWED_HOSTS=collaudo.matchlivetv.it,192.168.1.157,localhost
|
||||
YOUTUBE_REDIRECT_URI=https://collaudo.matchlivetv.it/api/v1/youtube/callback
|
||||
|
||||
YOUTUBE_CLIENT_ID=
|
||||
YOUTUBE_CLIENT_SECRET=
|
||||
YOUTUBE_PLATFORM_REFRESH_TOKEN=
|
||||
|
||||
# Stripe: preferisci chiavi Test su collaudo
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID=
|
||||
STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID=
|
||||
STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID=
|
||||
STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID=
|
||||
STRIPE_PREMIUM_LIGHT_PRICE_ID=
|
||||
STRIPE_PREMIUM_FULL_PRICE_ID=
|
||||
|
||||
RAILS_LOG_LEVEL=info
|
||||
|
||||
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
||||
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
||||
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
||||
PRIVACY_CONTROLLER_VAT=
|
||||
|
||||
MAILER_FROM=Match Live TV Collaudo <noreply@matchlivetv.it>
|
||||
SMTP_ADDRESS=
|
||||
SMTP_PORT=465
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_AUTH=plain
|
||||
SMTP_SSL=true
|
||||
SMTP_STARTTLS=false
|
||||
PASSWORD_RESET_EXPIRY_HOURS=2
|
||||
|
||||
MATCHLIVETV_VIDEOS_ROOT=/media/videos/matchlivetv
|
||||
|
||||
REPLAY_STORAGE_ENDPOINT=http://garage:3900
|
||||
REPLAY_STORAGE_BUCKET=matchlivetv-replays
|
||||
REPLAY_STORAGE_REGION=garage
|
||||
REPLAY_STORAGE_ACCESS_KEY_ID=
|
||||
REPLAY_STORAGE_SECRET_ACCESS_KEY=
|
||||
REPLAY_STORAGE_FORCE_PATH_STYLE=true
|
||||
REPLAY_MEDIA_PUBLIC_BASE_URL=https://collaudo.matchlivetv.it/media
|
||||
REPLAY_MEDIA_REDIRECT=true
|
||||
|
||||
RAILS_MAX_THREADS=5
|
||||
|
||||
OPS_HTTP_RAILS_URL=http://edge/up
|
||||
OPS_HTTP_PUBLIC_INTERVAL_SECS=900
|
||||
NTFY_PUBLIC_URL=http://192.168.1.157:18090
|
||||
OPS_NTFY_URL=
|
||||
OPS_NTFY_TOKEN=
|
||||
OPS_ALERT_EMAIL=
|
||||
OPS_NOTIFY_SEVERITIES=critical,warning
|
||||
OPS_NOTIFY_COOLDOWN_MINUTES=30
|
||||
OPS_HEALTH_INTERVAL_SECS=180
|
||||
OPS_HEALTH_TOKEN=
|
||||
OPS_DISK_WARN_PERCENT=80
|
||||
OPS_DISK_CRIT_PERCENT=90
|
||||
OPS_RECORDINGS_WARN_GB=20
|
||||
OPS_RECORDINGS_CRIT_GB=50
|
||||
OPS_SIDEKIQ_STALE_SECS=300
|
||||
OPS_LOG_SUBSCRIBER=false
|
||||
|
||||
SENTRY_DSN=
|
||||
|
||||
# Stream autoscale — token Hetzner da compilare; kill-switch cloud off finché WG non è ok
|
||||
HCLOUD_TOKEN=
|
||||
HCLOUD_LOCATION=nbg1
|
||||
HCLOUD_SERVER_TYPE=cpx12
|
||||
HCLOUD_IMAGE=debian-12
|
||||
HCLOUD_SSH_KEY=matchlivetv-stream-hetzner
|
||||
HCLOUD_NETWORK_ID=
|
||||
HCLOUD_USER_DATA_FILE=/opt/matchlivetv/infra/stream-node/cloud-init.yaml
|
||||
STREAM_DNS_ZONE=mltv-stream.net
|
||||
STREAM_DNS_TTL=60
|
||||
STREAM_CLOUD_DNS_SUFFIX=mltv-stream.net
|
||||
STREAM_CLOUD_MAX_PUBLISHERS=4
|
||||
STREAM_NODE_ENV=collaudo
|
||||
STREAM_CLOUD_PROVIDER=local_lab
|
||||
RELAY_MAX_CONCURRENT=4
|
||||
YOUTUBE_RELAY_WORKER=1
|
||||
|
||||
STREAM_AUTOSCALE_ENABLED=0
|
||||
STREAM_AUTOSCALE_KIND=lab
|
||||
STREAM_AUTOSCALE_ALLOW_CLOUD=0
|
||||
STREAM_AUTOSCALE_SOFT_FREE_SLOTS=2
|
||||
STREAM_AUTOSCALE_WARM_SPARE=1
|
||||
STREAM_AUTOSCALE_IDLE_MINUTES=30
|
||||
STREAM_AUTOSCALE_MAX_NODES=5
|
||||
STREAM_AUTOSCALE_NODE_EUR_PER_HOUR=0.015
|
||||
STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR=40
|
||||
STREAM_OVERFLOW_ORPHAN_HOURS=3
|
||||
@@ -106,35 +106,3 @@ OPS_LOG_SUBSCRIBER=false
|
||||
# Sentry (opzionale — error tracking produzione)
|
||||
SENTRY_DSN=
|
||||
SENTRY_TRACES_SAMPLE_RATE=0.1
|
||||
|
||||
# --- Stream autoscale (Hetzner Cloud + DNS mltv-stream.net) ---
|
||||
# Token progetto matchlivetv-stream (Read & Write). Non committare il valore reale.
|
||||
HCLOUD_TOKEN=
|
||||
HCLOUD_LOCATION=nbg1
|
||||
HCLOUD_SERVER_TYPE=cpx12
|
||||
HCLOUD_IMAGE=debian-12
|
||||
HCLOUD_SSH_KEY=matchlivetv-stream-hetzner
|
||||
HCLOUD_NETWORK_ID=
|
||||
HCLOUD_USER_DATA_FILE=/opt/matchlivetv/infra/stream-node/cloud-init.yaml
|
||||
STREAM_DNS_ZONE=mltv-stream.net
|
||||
STREAM_DNS_TTL=60
|
||||
STREAM_CLOUD_DNS_SUFFIX=mltv-stream.net
|
||||
STREAM_CLOUD_MAX_PUBLISHERS=4
|
||||
STREAM_NODE_ENV=prod
|
||||
# Lab locale di default; il bottone admin "Provisiona nodo Hetzner" usa comunque i provider hetzner.
|
||||
STREAM_CLOUD_PROVIDER=local_lab
|
||||
RELAY_MAX_CONCURRENT=4
|
||||
YOUTUBE_RELAY_WORKER=1
|
||||
|
||||
# Autoscaler (kill-switch: 0 finché WireGuard/smoke Cloud non sono OK — NON abilitare in prod senza lab)
|
||||
STREAM_AUTOSCALE_ENABLED=0
|
||||
STREAM_AUTOSCALE_KIND=lab
|
||||
STREAM_AUTOSCALE_ALLOW_CLOUD=0
|
||||
STREAM_AUTOSCALE_SOFT_FREE_SLOTS=2
|
||||
STREAM_AUTOSCALE_WARM_SPARE=1
|
||||
STREAM_AUTOSCALE_IDLE_MINUTES=30
|
||||
STREAM_AUTOSCALE_MAX_NODES=5
|
||||
STREAM_AUTOSCALE_INTERVAL_SECS=60
|
||||
STREAM_AUTOSCALE_NODE_EUR_PER_HOUR=0.015
|
||||
STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR=40
|
||||
STREAM_OVERFLOW_ORPHAN_HOURS=3
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# Collaudo Proxmox (es. 192.168.1.157) — overlay su docker-compose.prod.yml
|
||||
#
|
||||
# Uso:
|
||||
# export COMPOSE_FILE=docker-compose.prod.yml:docker-compose.collaudo.yml
|
||||
# docker compose --env-file .env up -d --build
|
||||
#
|
||||
# Porte host MediaMTX diverse da produzione per non competere sullo stesso IP pubblico:
|
||||
# prod WAN :1935 → 192.168.1.146
|
||||
# collaudo LAN/WAN opzionale :11935 → 192.168.1.157
|
||||
# HTTP resta :3000 (NPM → collaudo.matchlivetv.it); IP LAN diversi = nessun conflitto.
|
||||
|
||||
services:
|
||||
mediamtx:
|
||||
ports: !override
|
||||
- "11935:1935" # RTMP collaudo (NON usare WAN :1935 di produzione)
|
||||
- "18888:8888" # HLS diretto opzionale; in genere basta /hls via edge+NPM
|
||||
|
||||
# Passa tutto .env (HCLOUD_*, STREAM_*, …) ai processi Rails/Sidekiq
|
||||
# Monta cloud-init: HCLOUD_USER_DATA_FILE punta a path host non presente nell'immagine.
|
||||
rails:
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./stream-node:/opt/matchlivetv/infra/stream-node:ro
|
||||
|
||||
sidekiq:
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./stream-node:/opt/matchlivetv/infra/stream-node:ro
|
||||
|
||||
# ntfy collaudo su porta diversa (evita confusione se si punta per sbaglio l'host)
|
||||
ntfy:
|
||||
ports: !override
|
||||
- "18090:80"
|
||||
@@ -136,7 +136,6 @@ services:
|
||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/recordings:/recordings
|
||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/active_storage:/app/storage
|
||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/log:/app/log
|
||||
- ${HCLOUD_USER_DATA_HOST_DIR:-./stream-node}:/opt/matchlivetv/infra/stream-node:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/up"]
|
||||
interval: 15s
|
||||
@@ -232,7 +231,6 @@ services:
|
||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/recordings:/recordings
|
||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/active_storage:/app/storage
|
||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/log:/app/log
|
||||
- ${HCLOUD_USER_DATA_HOST_DIR:-./stream-node}:/opt/matchlivetv/infra/stream-node:ro
|
||||
|
||||
garage:
|
||||
image: dxflrs/garage:v1.0.1
|
||||
|
||||
@@ -59,6 +59,8 @@ services:
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
MEDIAMTX_API_URL: http://mediamtx:9997
|
||||
MEDIAMTX_RTMP_URL: ${MEDIAMTX_RTMP_URL:-rtmp://127.0.0.1:1935}
|
||||
MEDIAMTX_HLS_URL: ${MEDIAMTX_HLS_URL:-http://mediamtx:8888}
|
||||
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:3000/hls}
|
||||
MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret}
|
||||
RAILS_WEBHOOK_URL: http://rails:3000
|
||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev_secret_key_base_change_me_32chars_min}
|
||||
|
||||
@@ -5,14 +5,14 @@ set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
export COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}"
|
||||
export COMPOSE_FILE="docker-compose.prod.yml"
|
||||
export ENV_FILE="${ROOT}/.env"
|
||||
export CREDS_FILE="${ROOT}/garage/prod-credentials.env"
|
||||
|
||||
bash "${ROOT}/scripts/ensure_garage_prod_config.sh"
|
||||
|
||||
echo "Avvio Garage (COMPOSE_FILE=${COMPOSE_FILE})..."
|
||||
docker compose --env-file .env up -d garage
|
||||
echo "Avvio Garage..."
|
||||
docker compose -f docker-compose.prod.yml --env-file .env up -d garage
|
||||
|
||||
videos_root="${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}"
|
||||
capacity_df="${videos_root}"
|
||||
@@ -27,8 +27,8 @@ echo "Capacità nodo Garage: ${GARAGE_NODE_CAPACITY} (disco ~${avail_gb}G, riser
|
||||
bash "${ROOT}/scripts/setup_garage_replays.sh"
|
||||
|
||||
echo "Riavvio Rails e Sidekiq..."
|
||||
docker compose --env-file .env up -d rails sidekiq
|
||||
docker compose -f docker-compose.prod.yml --env-file .env up -d rails sidekiq
|
||||
|
||||
echo "Verifica storage:"
|
||||
docker compose --env-file .env exec -T rails bundle exec rails runner \
|
||||
docker compose -f docker-compose.prod.yml --env-file .env exec -T rails bundle exec rails runner \
|
||||
'puts({local: MatchLiveTv.replay_storage_local?, backend: Recordings::Storage.new.backend_name}.inspect)'
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
#cloud-config
|
||||
# Nodo stream Hetzner: MediaMTX + ffmpeg.
|
||||
# HCLOUD_USER_DATA_FILE=.../infra/stream-node/cloud-init.yaml
|
||||
|
||||
package_update: true
|
||||
packages:
|
||||
- docker.io
|
||||
- ffmpeg
|
||||
- curl
|
||||
- apparmor
|
||||
|
||||
write_files:
|
||||
- path: /opt/stream-node/mediamtx.yml
|
||||
permissions: "0644"
|
||||
content: |
|
||||
logLevel: info
|
||||
# Allinea a infra/mediamtx.yml: API raggiungibile anche da IP non-localhost
|
||||
# (in prod restringere via WireGuard / firewall Hetzner).
|
||||
authInternalUsers:
|
||||
- user: any
|
||||
pass:
|
||||
ips: []
|
||||
permissions:
|
||||
- action: api
|
||||
- action: metrics
|
||||
- action: publish
|
||||
- action: read
|
||||
- action: playback
|
||||
api: yes
|
||||
apiAddress: :9997
|
||||
rtmp: yes
|
||||
rtmpAddress: :1935
|
||||
hls: yes
|
||||
hlsAddress: :8888
|
||||
paths:
|
||||
all_others:
|
||||
- path: /opt/stream-node/bootstrap.sh
|
||||
permissions: "0755"
|
||||
content: |
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
mkdir -p /recordings /slates
|
||||
systemctl enable --now docker
|
||||
# aspetta il socket docker
|
||||
for i in $(seq 1 60); do
|
||||
if docker info >/dev/null 2>&1; then break; fi
|
||||
sleep 2
|
||||
done
|
||||
docker pull bluenviron/mediamtx:latest
|
||||
docker rm -f mediamtx 2>/dev/null || true
|
||||
# Slate offline (alwaysAvailable) — richiesta da Mediamtx::Client#create_path.
|
||||
# MediaMTX 1.20+: AAC del publisher RTMP deve matchare la slate.
|
||||
# Allineato ad app Android (BroadcastConfig: 48 kHz mono) e infra/scripts/generate_slate.sh.
|
||||
mkdir -p /slates
|
||||
ffmpeg -y -f lavfi -i color=c=black:s=1280x720:d=2 -f lavfi -i anullsrc=r=48000:cl=mono \
|
||||
-c:v libx264 -t 2 -pix_fmt yuv420p -c:a aac -ac 1 -ar 48000 -shortest /slates/offline.mp4
|
||||
# Debian cloud image: docker.io senza apparmor_parser → serve unconfined o pkg apparmor
|
||||
docker run -d --name mediamtx --restart unless-stopped --network host \
|
||||
--security-opt apparmor=unconfined \
|
||||
-v /opt/stream-node/mediamtx.yml:/mediamtx.yml:ro \
|
||||
-v /recordings:/recordings \
|
||||
-v /slates:/slates:ro \
|
||||
bluenviron/mediamtx:latest /mediamtx.yml
|
||||
# smoke locale
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsS http://127.0.0.1:9997/v3/paths/list >/dev/null; then
|
||||
echo "mediamtx ready" | tee /opt/stream-node/ready
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "mediamtx NOT ready" | tee /opt/stream-node/ready
|
||||
docker logs mediamtx || true
|
||||
exit 1
|
||||
|
||||
runcmd:
|
||||
- [bash, /opt/stream-node/bootstrap.sh]
|
||||
@@ -24,8 +24,8 @@ android {
|
||||
applicationId = "com.matchlivetv.match_live_tv"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 32
|
||||
versionName = "2.0.11-native"
|
||||
versionCode = 31
|
||||
versionName = "2.0.10-native"
|
||||
|
||||
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
|
||||
?: "https://www.matchlivetv.it"
|
||||
|
||||
+24
-70
@@ -17,16 +17,11 @@ import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* E2E sul simulatore: login → nuova partita → wizard 3 step → schermata diretta.
|
||||
* Usa le stringhe dell'app (locale del device), non testo hardcodato IT/EN.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class E2EWizardFlowTest {
|
||||
private lateinit var device: UiDevice
|
||||
private val pkg = "com.matchlivetv.match_live_tv"
|
||||
private val ctx by lazy { InstrumentationRegistry.getInstrumentation().targetContext }
|
||||
|
||||
private fun s(id: Int): String = ctx.getString(id)
|
||||
private fun su(id: Int): String = s(id).uppercase()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
@@ -37,42 +32,30 @@ class E2EWizardFlowTest {
|
||||
|
||||
@Test
|
||||
fun login_newMatch_wizard_reachesBroadcastScreen() {
|
||||
waitForAnyText(s(R.string.login_email), su(R.string.login_submit), timeoutMs = 45_000)
|
||||
waitForAnyText("Email", "ACCEDI", timeoutMs = 45_000)
|
||||
fillLogin()
|
||||
waitForText(su(R.string.matches_new), timeoutMs = 45_000)
|
||||
tapClickableText(su(R.string.matches_new))
|
||||
waitForText(s(R.string.sheet_quick_option), timeoutMs = 15_000)
|
||||
tapClickableText(s(R.string.sheet_quick_option))
|
||||
waitForText(s(R.string.wizard_step_title_match), timeoutMs = 45_000)
|
||||
waitForText("NUOVA PARTITA", timeoutMs = 45_000)
|
||||
tapClickableText("NUOVA PARTITA")
|
||||
waitForText("Avvia subito", timeoutMs = 15_000)
|
||||
tapClickableText("Avvia subito")
|
||||
waitForText("01 · Partita", timeoutMs = 45_000)
|
||||
scrollDown()
|
||||
tapClickableText(s(R.string.wizard_action_next))
|
||||
waitForText(s(R.string.wizard_step_title_transmission), timeoutMs = 45_000)
|
||||
waitForText(s(R.string.wizard_transmission_platform_title), timeoutMs = 30_000)
|
||||
tapClickableText("AVANTI >")
|
||||
waitForText("02 · Trasmissione", timeoutMs = 45_000)
|
||||
waitForText("Piattaforma", timeoutMs = 30_000)
|
||||
scrollDown()
|
||||
tapClickableText(s(R.string.wizard_action_next))
|
||||
waitForText(s(R.string.wizard_step_title_network), timeoutMs = 45_000)
|
||||
waitForText(s(R.string.wizard_network_test_start_label), timeoutMs = 30_000)
|
||||
tapClickableText(s(R.string.wizard_network_test_start_label))
|
||||
waitForText(s(R.string.wizard_action_start), timeoutMs = 30_000)
|
||||
waitUntilEnabled(s(R.string.wizard_action_start), timeoutMs = 25_000)
|
||||
tapClickableText("AVANTI >")
|
||||
waitForText("03 · Test rete", timeoutMs = 45_000)
|
||||
waitForText("AVVIA TEST RETE", timeoutMs = 30_000)
|
||||
tapClickableText("AVVIA TEST RETE")
|
||||
waitForText("INIZIA >", timeoutMs = 30_000)
|
||||
waitUntilEnabled("INIZIA >", timeoutMs = 25_000)
|
||||
scrollDown()
|
||||
tapClickableText(s(R.string.wizard_action_start))
|
||||
waitForAnyText(
|
||||
s(R.string.broadcast_status_live),
|
||||
s(R.string.broadcast_status_connecting),
|
||||
s(R.string.broadcast_status_reconnecting),
|
||||
timeoutMs = 60_000,
|
||||
)
|
||||
// CLOSE SET è un SideIconButton: in hierarchy compare come content-desc, non sempre come text.
|
||||
assertTrue(
|
||||
"Schermata diretta non pronta (né CLOSE SET né End live)",
|
||||
waitForTextOrDesc(
|
||||
timeoutMs = 30_000,
|
||||
s(R.string.broadcast_close_set_button),
|
||||
s(R.string.score_action_close_set),
|
||||
s(R.string.broadcast_terminate_cd),
|
||||
),
|
||||
)
|
||||
tapClickableText("INIZIA >")
|
||||
val diretta = waitForText("Diretta", timeoutMs = 60_000)
|
||||
assertNotNull(diretta)
|
||||
assertNotNull(waitForText("TERMINA DIRETTA", timeoutMs = 30_000))
|
||||
assertNotNull(waitForText("CHIUDI SET", timeoutMs = 20_000))
|
||||
}
|
||||
|
||||
private fun grantRuntimePermissions() {
|
||||
@@ -86,10 +69,11 @@ class E2EWizardFlowTest {
|
||||
}
|
||||
|
||||
private fun launchApp() {
|
||||
val intent = ctx.packageManager.getLaunchIntentForPackage(pkg)?.apply {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val intent = context.packageManager.getLaunchIntentForPackage(pkg)?.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
} ?: error("Launch intent mancante per $pkg")
|
||||
ctx.startActivity(intent)
|
||||
context.startActivity(intent)
|
||||
device.wait(Until.hasObject(By.pkg(pkg).depth(0)), 15_000)
|
||||
}
|
||||
|
||||
@@ -97,10 +81,8 @@ class E2EWizardFlowTest {
|
||||
val fields = device.wait(Until.findObjects(By.clazz("android.widget.EditText")), 15_000)
|
||||
if (fields.size < 2) error("Campi login non trovati (${fields.size})")
|
||||
pasteIntoField(fields[0], "coach@matchlivetv.test")
|
||||
pasteIntoField(fields[1], "Password123")
|
||||
pasteIntoField(fields[1], "password123")
|
||||
device.pressKeyCode(KeyEvent.KEYCODE_ENTER)
|
||||
// Preferisci il bottone (ultimo match uppercase), non il titolo.
|
||||
tapLastMatchingText(su(R.string.login_submit))
|
||||
device.waitForIdle()
|
||||
}
|
||||
|
||||
@@ -129,34 +111,6 @@ class E2EWizardFlowTest {
|
||||
error("Nessuno dei testi trovato: ${texts.joinToString()}")
|
||||
}
|
||||
|
||||
private fun waitForTextOrDesc(timeoutMs: Long, vararg labels: String): Boolean {
|
||||
val deadline = SystemClock.elapsedRealtime() + timeoutMs
|
||||
while (SystemClock.elapsedRealtime() < deadline) {
|
||||
for (label in labels) {
|
||||
if (device.hasObject(By.text(label)) || device.hasObject(By.desc(label))) return true
|
||||
}
|
||||
SystemClock.sleep(250)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun tapLastMatchingText(text: String) {
|
||||
val nodes = device.findObjects(By.text(text))
|
||||
val target = nodes.lastOrNull { it.isClickable }
|
||||
?: nodes.lastOrNull()?.let { label ->
|
||||
var node: UiObject2? = label
|
||||
repeat(6) {
|
||||
val current = node ?: return@repeat
|
||||
if (current.isClickable) return@let current
|
||||
node = current.parent
|
||||
}
|
||||
label
|
||||
}
|
||||
?: error("Testo non trovato: $text")
|
||||
target.click()
|
||||
device.waitForIdle()
|
||||
}
|
||||
|
||||
private fun tapClickableText(text: String) {
|
||||
device.findObject(By.text(text).clickable(true))?.let { node ->
|
||||
node.click()
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package com.matchlivetv.match_live_tv
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.matchlivetv.match_live_tv.core.AppConfig
|
||||
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import retrofit2.HttpException
|
||||
|
||||
/**
|
||||
* Contratto API qualità minima contro il backend locale (emulatore → 10.0.2.2).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class LocalAdaptiveBitrateApiTest {
|
||||
private lateinit var container: AppContainer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
container = AppContainer(context)
|
||||
assertTrue(
|
||||
"Questo test richiede API locale, trovato ${AppConfig.apiBaseUrl}",
|
||||
AppConfig.apiBaseUrl.contains("10.0.2.2") || AppConfig.apiBaseUrl.contains("localhost"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setMinQuality_persistsAndRejects1080On720Session() = runBlocking {
|
||||
container.authRepository.login(
|
||||
email = "coach@matchlivetv.test",
|
||||
password = "Password123",
|
||||
)
|
||||
val teams = container.matchRepository.fetchTeams()
|
||||
assertTrue(teams.isNotEmpty())
|
||||
val match = container.matchRepository.createQuickMatch(teams.first().id)
|
||||
val created = container.sessionRepository.createSession(match.id)
|
||||
assertEquals("auto", created.minQualityPreset)
|
||||
|
||||
val updated = container.sessionRepository.setMinQuality(created.id, "720p_30_1.5mbps")
|
||||
assertEquals("720p_30_1.5mbps", updated.minQualityPreset)
|
||||
|
||||
val fetched = container.sessionRepository.fetchSession(created.id)
|
||||
assertEquals("720p_30_1.5mbps", fetched.minQualityPreset)
|
||||
|
||||
try {
|
||||
container.sessionRepository.setMinQuality(created.id, "1080p_30_4.5mbps")
|
||||
fail("1080p avrebbe dovuto essere rifiutato su sessione 720p")
|
||||
} catch (e: HttpException) {
|
||||
assertEquals(422, e.code())
|
||||
}
|
||||
|
||||
val forced = container.sessionRepository.setMinQuality(created.id, "720p_30_4mbps")
|
||||
assertEquals("720p_30_4mbps", forced.minQualityPreset)
|
||||
|
||||
val auto = container.sessionRepository.setMinQuality(created.id, "auto")
|
||||
assertEquals("auto", auto.minQualityPreset)
|
||||
|
||||
val started = container.sessionRepository.startSession(created.id)
|
||||
assertFalse(started.rtmpIngestUrl.isNullOrBlank())
|
||||
container.sessionRepository.stopSession(created.id)
|
||||
Unit
|
||||
}
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
package com.matchlivetv.match_live_tv
|
||||
|
||||
import android.content.Intent
|
||||
import android.view.KeyEvent
|
||||
import android.os.SystemClock
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.uiautomator.By
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
import androidx.test.uiautomator.UiObject2
|
||||
import androidx.test.uiautomator.Until
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* E2E locale: login seed → wizard → schermata ripresa → qualità minima.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class LocalAdaptiveBitrateUiTest {
|
||||
private lateinit var device: UiDevice
|
||||
private val pkg = "com.matchlivetv.match_live_tv"
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
|
||||
grantRuntimePermissions()
|
||||
launchApp()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun broadcastScreen_canForceMinimumQuality() {
|
||||
waitForAny(45_000, "Email", "ACCEDI", "LOG IN", "NUOVA PARTITA", "NEW MATCH")
|
||||
if (!hasAny("NUOVA PARTITA", "NEW MATCH")) {
|
||||
fillLogin()
|
||||
if (!waitQuietly(8_000, "NUOVA PARTITA", "NEW MATCH")) {
|
||||
tapClickableLogin()
|
||||
}
|
||||
waitForAny(45_000, "NUOVA PARTITA", "NEW MATCH")
|
||||
}
|
||||
tapAny("NUOVA PARTITA", "NEW MATCH")
|
||||
waitForAny(15_000, "Avvia subito", "Start now")
|
||||
tapAny("Avvia subito", "Start now")
|
||||
waitForAny(45_000, "01 · Partita", "01 · Match")
|
||||
scrollDown()
|
||||
tapAny("AVANTI >", "NEXT >")
|
||||
waitForAny(45_000, "02 · Trasmissione", "02 · Broadcast")
|
||||
waitForAny(30_000, "Piattaforma", "Platform")
|
||||
scrollDown()
|
||||
tapAny("AVANTI >", "NEXT >")
|
||||
waitForAny(45_000, "03 · Test rete", "03 · Network test")
|
||||
waitForAny(30_000, "AVVIA TEST RETE", "START NETWORK TEST")
|
||||
tapAny("AVVIA TEST RETE", "START NETWORK TEST")
|
||||
waitForAny(30_000, "INIZIA >", "START >")
|
||||
waitUntilEnabledAny("INIZIA >", "START >", timeoutMs = 25_000)
|
||||
scrollDown()
|
||||
tapAny("INIZIA >", "START >")
|
||||
|
||||
waitForAnyDesc(
|
||||
60_000,
|
||||
"Qualità minima di trasmissione",
|
||||
"Minimum streaming quality",
|
||||
)
|
||||
tapAnyDesc("Qualità minima di trasmissione", "Minimum streaming quality")
|
||||
waitForAny(10_000, "Qualità minima", "Minimum quality")
|
||||
waitForAny(5_000, "Automatica", "Automatic")
|
||||
tapClickableText("720p · 1.5 Mbps")
|
||||
assertNotNull(
|
||||
waitForAny(
|
||||
20_000,
|
||||
"Qualità minima: 720p · 1.5 Mbps",
|
||||
"Minimum quality: 720p · 1.5 Mbps",
|
||||
"720p · 1.5 Mbps",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun grantRuntimePermissions() {
|
||||
listOf(
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
"android.permission.POST_NOTIFICATIONS",
|
||||
).forEach { permission ->
|
||||
device.executeShellCommand("pm grant $pkg $permission")
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchApp() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val intent = context.packageManager.getLaunchIntentForPackage(pkg)?.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
} ?: error("Launch intent mancante per $pkg")
|
||||
context.startActivity(intent)
|
||||
device.wait(Until.hasObject(By.pkg(pkg).depth(0)), 15_000)
|
||||
}
|
||||
|
||||
private fun fillLogin() {
|
||||
val email = waitForRes("login_email")
|
||||
val password = waitForRes("login_password")
|
||||
typeInto(email, "coach@matchlivetv.test")
|
||||
typeInto(password, "Password123")
|
||||
device.pressKeyCode(KeyEvent.KEYCODE_ENTER)
|
||||
device.waitForIdle()
|
||||
SystemClock.sleep(800)
|
||||
}
|
||||
|
||||
private fun hasAny(vararg texts: String): Boolean =
|
||||
texts.any { device.hasObject(By.text(it)) || device.hasObject(By.desc(it)) }
|
||||
|
||||
private fun waitQuietly(timeoutMs: Long, vararg texts: String): Boolean {
|
||||
val deadline = SystemClock.elapsedRealtime() + timeoutMs
|
||||
while (SystemClock.elapsedRealtime() < deadline) {
|
||||
if (hasAny(*texts)) return true
|
||||
SystemClock.sleep(250)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun waitForRes(tag: String): UiObject2 {
|
||||
val obj = device.wait(Until.findObject(By.res(tag)), 10_000)
|
||||
?: device.wait(Until.findObject(By.res("$pkg:id/$tag")), 2_000)
|
||||
?: error("Nodo non trovato: $tag | visibili: ${visibleTexts()}")
|
||||
return obj
|
||||
}
|
||||
|
||||
private fun typeInto(field: UiObject2, value: String) {
|
||||
field.click()
|
||||
device.waitForIdle()
|
||||
field.text = value
|
||||
device.waitForIdle()
|
||||
SystemClock.sleep(300)
|
||||
}
|
||||
|
||||
private fun tapClickableLogin() {
|
||||
waitForResOrNull("login_submit")?.let { btn ->
|
||||
val bounds = btn.visibleBounds
|
||||
device.click(bounds.centerX(), bounds.centerY())
|
||||
device.waitForIdle()
|
||||
return
|
||||
}
|
||||
val deadline = SystemClock.elapsedRealtime() + 10_000
|
||||
while (SystemClock.elapsedRealtime() < deadline) {
|
||||
val candidates = device.findObjects(By.text("ACCEDI")) +
|
||||
device.findObjects(By.text("LOG IN"))
|
||||
for (node in candidates) {
|
||||
if (clickEnabled(node)) return
|
||||
}
|
||||
SystemClock.sleep(250)
|
||||
}
|
||||
error("Login non cliccabile | visibili: ${visibleTexts()}")
|
||||
}
|
||||
|
||||
private fun waitForResOrNull(tag: String): UiObject2? {
|
||||
return device.wait(Until.findObject(By.res(tag)), 3_000)
|
||||
?: device.wait(Until.findObject(By.res("$pkg:id/$tag")), 1_000)
|
||||
}
|
||||
|
||||
private fun clickEnabled(start: UiObject2): Boolean {
|
||||
var node: UiObject2? = start
|
||||
for (i in 0 until 6) {
|
||||
val current = node ?: break
|
||||
if (current.isClickable && current.isEnabled) {
|
||||
current.click()
|
||||
device.waitForIdle()
|
||||
return true
|
||||
}
|
||||
node = current.parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun visibleTexts(): String {
|
||||
val interesting = listOf("ACCEDI", "LOG IN", "Email", "Password", "NUOVA", "NEW MATCH", "error", "Error", "Server")
|
||||
return device.findObjects(By.pkg(pkg)).mapNotNull { it.text?.takeIf { t -> t.isNotBlank() } }
|
||||
.distinct()
|
||||
.filter { text -> interesting.any { key -> text.contains(key, ignoreCase = true) } || text.length < 40 }
|
||||
.take(30)
|
||||
.joinToString(" | ")
|
||||
}
|
||||
|
||||
private fun waitForAny(timeoutMs: Long, vararg texts: String): UiObject2 {
|
||||
val deadline = SystemClock.elapsedRealtime() + timeoutMs
|
||||
while (SystemClock.elapsedRealtime() < deadline) {
|
||||
for (text in texts) {
|
||||
device.findObject(By.text(text))?.let { return it }
|
||||
device.findObject(By.desc(text))?.let { return it }
|
||||
}
|
||||
SystemClock.sleep(250)
|
||||
}
|
||||
error("Nessuno dei testi trovato: ${texts.joinToString()} | visibili: ${visibleTexts()}")
|
||||
}
|
||||
|
||||
private fun waitForAnyDesc(timeoutMs: Long, vararg descs: String): UiObject2 {
|
||||
val deadline = SystemClock.elapsedRealtime() + timeoutMs
|
||||
while (SystemClock.elapsedRealtime() < deadline) {
|
||||
for (desc in descs) {
|
||||
device.findObject(By.desc(desc))?.let { return it }
|
||||
}
|
||||
SystemClock.sleep(250)
|
||||
}
|
||||
error("Nessuna contentDescription trovata: ${descs.joinToString()}")
|
||||
}
|
||||
|
||||
private fun tapAny(vararg texts: String) {
|
||||
val label = waitForAny(15_000, *texts)
|
||||
tapNode(label)
|
||||
}
|
||||
|
||||
private fun tapClickableText(text: String) {
|
||||
tapAny(text)
|
||||
}
|
||||
|
||||
private fun tapAnyDesc(vararg descs: String) {
|
||||
tapNode(waitForAnyDesc(10_000, *descs))
|
||||
}
|
||||
|
||||
private fun tapNode(label: UiObject2) {
|
||||
if (label.isClickable) {
|
||||
label.click()
|
||||
device.waitForIdle()
|
||||
return
|
||||
}
|
||||
var node: UiObject2? = label
|
||||
for (i in 0 until 6) {
|
||||
val current = node ?: break
|
||||
if (current.isClickable) {
|
||||
current.click()
|
||||
device.waitForIdle()
|
||||
return
|
||||
}
|
||||
node = current.parent
|
||||
}
|
||||
label.click()
|
||||
device.waitForIdle()
|
||||
}
|
||||
|
||||
private fun waitUntilEnabledAny(vararg texts: String, timeoutMs: Long) {
|
||||
val deadline = SystemClock.elapsedRealtime() + timeoutMs
|
||||
while (SystemClock.elapsedRealtime() < deadline) {
|
||||
for (text in texts) {
|
||||
val label = device.findObject(By.text(text)) ?: continue
|
||||
var node: UiObject2? = label
|
||||
for (i in 0 until 6) {
|
||||
val current = node ?: break
|
||||
if (current.isClickable && current.isEnabled) return
|
||||
node = current.parent
|
||||
}
|
||||
}
|
||||
SystemClock.sleep(500)
|
||||
}
|
||||
error("Pulsante non abilitato entro timeout: ${texts.joinToString()}")
|
||||
}
|
||||
|
||||
private fun scrollDown(steps: Int = 2) {
|
||||
val centerX = device.displayWidth / 2
|
||||
val startY = (device.displayHeight * 0.75).toInt()
|
||||
val endY = (device.displayHeight * 0.25).toInt()
|
||||
repeat(steps) {
|
||||
device.swipe(centerX, startY, centerX, endY, 24)
|
||||
device.waitForIdle()
|
||||
SystemClock.sleep(300)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,17 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||
import com.matchlivetv.match_live_tv.core.AppLocale
|
||||
import com.matchlivetv.match_live_tv.core.ProvideAppLocale
|
||||
import com.matchlivetv.match_live_tv.ui.navigation.AppNavHost
|
||||
import com.matchlivetv.match_live_tv.ui.theme.MatchLiveTheme
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val container by lazy { (application as MatchLiveTvApplication).container }
|
||||
@@ -33,9 +39,11 @@ class MainActivity : ComponentActivity() {
|
||||
setContent {
|
||||
ProvideAppLocale {
|
||||
MatchLiveTheme {
|
||||
Box(Modifier.semantics { testTagsAsResourceId = true }) {
|
||||
AppNavHost(container)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +183,7 @@ data class StreamSessionDto(
|
||||
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
|
||||
@Json(name = "target_fps") val targetFps: Int? = null,
|
||||
@Json(name = "quality_preset") val qualityPreset: String? = null,
|
||||
@Json(name = "min_quality_preset") val minQualityPreset: String? = null,
|
||||
@Json(name = "started_at") val startedAt: String? = null,
|
||||
val score: ScoreStateDto? = null,
|
||||
) {
|
||||
@@ -201,6 +202,7 @@ data class StreamSessionDto(
|
||||
targetBitrate = targetBitrate ?: 2_500_000,
|
||||
targetFps = targetFps ?: 30,
|
||||
qualityPreset = qualityPreset ?: "720p_30_2.5mbps",
|
||||
minQualityPreset = minQualityPreset ?: "auto",
|
||||
startedAt = startedAt,
|
||||
score = score?.toDomain(),
|
||||
)
|
||||
@@ -285,6 +287,10 @@ data class CreateSessionRequest(
|
||||
@Json(name = "youtube_channel") val youtubeChannel: String? = null,
|
||||
)
|
||||
|
||||
data class MinQualityRequest(
|
||||
@Json(name = "min_quality_preset") val minQualityPreset: String,
|
||||
)
|
||||
|
||||
data class UpdateMatchBody(
|
||||
@Json(name = "opponent_name") val opponentName: String? = null,
|
||||
val location: String? = null,
|
||||
@@ -392,4 +398,5 @@ data class TelemetryRequest(
|
||||
@Json(name = "current_bitrate") val currentBitrate: Int? = null,
|
||||
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
|
||||
val fps: Int? = null,
|
||||
@Json(name = "thermal_state") val thermalState: String? = null,
|
||||
)
|
||||
|
||||
+6
@@ -117,6 +117,12 @@ interface MatchLiveApi {
|
||||
@PATCH("sessions/{sessionId}/resume")
|
||||
suspend fun resumeSession(@Path("sessionId") sessionId: String): StreamSessionDto
|
||||
|
||||
@PATCH("sessions/{sessionId}/min_quality")
|
||||
suspend fun setMinQuality(
|
||||
@Path("sessionId") sessionId: String,
|
||||
@Body body: MinQualityRequest,
|
||||
): StreamSessionDto
|
||||
|
||||
@POST("sessions/{sessionId}/network_test")
|
||||
suspend fun networkTest(
|
||||
@Path("sessionId") sessionId: String,
|
||||
|
||||
+2
@@ -33,6 +33,7 @@ class SessionCableService(
|
||||
var onPauseStream: (() -> Unit)? = null
|
||||
var onResumeStream: (() -> Unit)? = null
|
||||
var onEndStream: (() -> Unit)? = null
|
||||
var onMinQuality: ((String) -> Unit)? = null
|
||||
|
||||
fun connect(sessionId: String, accessToken: String, deviceRole: String = "camera") {
|
||||
intentionalDisconnect = false
|
||||
@@ -109,6 +110,7 @@ class SessionCableService(
|
||||
"pause_stream" -> onPauseStream?.invoke()
|
||||
"resume_stream" -> onResumeStream?.invoke()
|
||||
"stop_stream" -> onEndStream?.invoke()
|
||||
"set_min_quality" -> (payload["min_quality_preset"] as? String)?.let { onMinQuality?.invoke(it) }
|
||||
}
|
||||
"stream_event" -> when (payload["event"] as? String) {
|
||||
"paused" -> onPauseStream?.invoke()
|
||||
|
||||
+6
@@ -2,6 +2,7 @@ package com.matchlivetv.match_live_tv.data.repository
|
||||
|
||||
import com.matchlivetv.match_live_tv.data.api.CreateSessionRequest
|
||||
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
|
||||
import com.matchlivetv.match_live_tv.data.api.MinQualityRequest
|
||||
import com.matchlivetv.match_live_tv.data.api.NetworkTestRequest
|
||||
import com.matchlivetv.match_live_tv.data.api.NetworkTestResponse
|
||||
import com.matchlivetv.match_live_tv.domain.StreamSession
|
||||
@@ -38,6 +39,9 @@ class SessionRepository(
|
||||
suspend fun resumeSession(sessionId: String): StreamSession =
|
||||
api.resumeSession(sessionId).toDomain()
|
||||
|
||||
suspend fun setMinQuality(sessionId: String, preset: String): StreamSession =
|
||||
api.setMinQuality(sessionId, MinQualityRequest(minQualityPreset = preset)).toDomain()
|
||||
|
||||
suspend fun submitNetworkTest(
|
||||
sessionId: String,
|
||||
downloadMbps: Double,
|
||||
@@ -61,6 +65,7 @@ class SessionRepository(
|
||||
currentBitrate: Int? = null,
|
||||
targetBitrate: Int? = null,
|
||||
fps: Int? = null,
|
||||
thermalState: String? = null,
|
||||
) {
|
||||
api.postTelemetry(
|
||||
sessionId,
|
||||
@@ -72,6 +77,7 @@ class SessionRepository(
|
||||
currentBitrate = currentBitrate,
|
||||
targetBitrate = targetBitrate,
|
||||
fps = fps,
|
||||
thermalState = thermalState,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user