Compare commits

..
Author SHA1 Message Date
eminux d100c8030f Merge branch 'main' into feature/streaming-autoscale-hetzner 2026-08-11 19:45:27 +02:00
eminuxandCursor f1906517a9 Rende leggibile la pagina admin Nodi streaming con KPI e toolbar.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 19:04:12 +02:00
eminuxandCursor 00fde25c50 Evita scale-in del nodo appena creato nello stesso reconcile.
Con IDLE_MINUTES=0 (o race stretta) lo scale-out veniva annullato subito; lo smoke AutoscalerJob Cloud su collaudo ora completa scale-out e scale-in.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 18:42:35 +02:00
eminuxandCursor 4c5af99efa Allinea slate Cloud a AAC 48k mono e preferisce home in allocate.
Senza questo il telefono (mono 48k) veniva rifiutato sui nodi Hetzner e lo warm spare rubava le sessioni a home.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 18:27:13 +02:00
eminuxandCursor 8bf12e7721 Bump versione Android a 2.0.11-native (versionCode 32).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 18:10:08 +02:00
eminuxandCursor 5a0ca8ef1c Corregge cloud-init montato, PublisherOnline multi-nodo e E2E locale-aware.
Senza volume stream-node i nodi Hetzner nascevano senza MediaMTX; sync live usa ora Client.for_session e rtmpconns (MediaMTX 1.20).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 08:48:28 +02:00
eminuxandCursor 714602f3da Aggiunge overlay Compose per collaudo con RTMP su porta 11935.
Isola MediaMTX dal :1935 di produzione sullo stesso IP pubblico e documenta env/helper per il container Proxmox di test.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 20:29:47 +02:00
eminuxandCursor baac3a512b Preserva drain/offline su home e genera slate offline nel cloud-init.
Evita che ensure_home_from_env! annulli il drain a ogni allocate, e prepara /slates/offline.mp4 sul nodo Cloud per create_path MediaMTX.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 12:29:23 +02:00
eminuxandCursor 559284f0b2 Corregge il provisioning Hetzner: Faraday, cloud-init e default cpx12.
Sistemati path API /v1, AppArmor/auth MediaMTX sul nodo e defaults nbg1 così lo smoke Cloud è ripetibile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 12:20:36 +02:00
eminuxandCursor e5fc925bae Completa il hardening autoscaler (Fase 4): budget, kill-switch e runbook.
Drain sicuro in scale-in, alert Ops su overflow e controlli admin senza abilitare il deploy in produzione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 20:00:15 +02:00
eminuxandCursor 318a319608 Aggiunge l'autoscaler streaming con warm spare e kill-switch.
Scala i nodi overflow quando gli slot calano, mantiene una spare a caldo sotto carico e spegne gli idle, disattivabile con STREAM_AUTOSCALE_ENABLED.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 19:56:58 +02:00
eminuxandCursor 0b55d5a8fa Rende sticky e capacizzati i relay YouTube su coda dedicata.
Evita doppi ffmpeg tra host: stop solo sull'owner, ensure con requeue a capacità piena e coda Sidekiq youtube_relay isolata.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 19:54:04 +02:00
eminuxandCursor c3878cdc6d Aggiunge registry nodi stream e provisioning Hetzner/lab per lo scale-out.
Prepara l'architettura multi-nodo (MediaMTX+ffmpeg) con assignment URL per sessione, admin di provision/drain e provider Cloud/DNS astratti verso mltv-stream.net.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 19:52:47 +02:00
131 changed files with 3598 additions and 2825 deletions
-2
View File
@@ -55,8 +55,6 @@ 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
@@ -0,0 +1,67 @@
# frozen_string_literal: true
module Admin
class StreamNodesController < Admin::BaseController
def index
Streams::NodeRegistry.ensure_home_from_env!
@nodes = StreamNode.order(:role, :slug)
@dns_provider = ENV.fetch("STREAM_DNS_PROVIDER", "lab")
@cloud_provider = ENV.fetch("STREAM_CLOUD_PROVIDER", "local_lab")
@lab_hosts = lab_dns_snippet
@hetzner_configured = ENV["HCLOUD_TOKEN"].present?
@autoscale_metrics = Streams::Autoscaler.metrics
end
def create
kind = params[:kind].to_s
node =
if kind == "cloud"
Streams::NodeProvisioner.new.provision_cloud!
else
Streams::NodeProvisioner.new.provision_lab!
end
redirect_to admin_stream_nodes_path,
notice: t("admin.flash.stream_node_created", slug: node.slug)
rescue Streams::NodeProvisioner::Error, Streams::CloudProviders::Error, Streams::DnsProviders::Error,
KeyError => e
redirect_to admin_stream_nodes_path, alert: e.message
end
def drain
node = StreamNode.find(params[:id])
Streams::NodeProvisioner.new.drain!(node)
redirect_to admin_stream_nodes_path, notice: t("admin.flash.stream_node_draining", slug: node.slug)
rescue Streams::NodeProvisioner::Error => e
redirect_to admin_stream_nodes_path, alert: e.message
end
def destroy
node = StreamNode.find(params[:id])
Streams::NodeProvisioner.new.decommission!(node)
redirect_to admin_stream_nodes_path, notice: t("admin.flash.stream_node_destroyed", slug: node.slug)
rescue Streams::NodeProvisioner::BusyError, Streams::NodeProvisioner::Error,
Streams::CloudProviders::Error, Streams::DnsProviders::Error => e
redirect_to admin_stream_nodes_path, alert: e.message
end
def kill_switch
Streams::Autoscaler.engage_kill_switch!
redirect_to admin_stream_nodes_path, notice: t("admin.flash.autoscale_kill_on")
end
def clear_kill_switch
Streams::Autoscaler.clear_kill_switch!
redirect_to admin_stream_nodes_path, notice: t("admin.flash.autoscale_kill_off")
end
private
def lab_dns_snippet
return "" unless @dns_provider == "lab"
Streams::DnsProviders::Lab.new.hosts_file_snippet
rescue Redis::BaseError
""
end
end
end
@@ -68,7 +68,6 @@ 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)
@@ -142,13 +141,6 @@ 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)
@@ -168,11 +160,6 @@ 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
@@ -193,6 +180,7 @@ 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,
@@ -200,7 +188,6 @@ 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 min_quality]
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop]
before_action :set_session_from_token
@@ -61,20 +61,8 @@ 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
@@ -98,10 +86,7 @@ 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,
min_quality_preset: @session.min_quality_preset,
min_quality_options: Sessions::SelectQuality.allowed_min_ids(@session.quality_preset),
telemetry: camera_telemetry_payload(publisher_online)
score: score&.as_cable_payload
}
end
end
+1 -20
View File
@@ -54,34 +54,15 @@ 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"),
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")
subtitlePrefix: t("regia.subtitle_prefix")
}.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.new.delete_path(session)
Mediamtx::Client.for_session(session).delete_path(session)
end
end
end
@@ -0,0 +1,33 @@
# frozen_string_literal: true
module Streams
class AutoscalerJob
include Sidekiq::Job
sidekiq_options retry: 1, queue: "default"
INTERVAL_SECS = ENV.fetch("STREAM_AUTOSCALE_INTERVAL_SECS", "60").to_i
REDIS_CHAIN_KEY = "streams:autoscaler:chain"
def self.ensure_chain
return unless redis
return if redis.get(REDIS_CHAIN_KEY)
redis.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL_SECS * 2)
perform_in(INTERVAL_SECS)
end
def self.redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
rescue Redis::CannotConnectError
nil
end
def perform
Streams::Autoscaler.reconcile!
ensure
self.class.redis&.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL_SECS * 2)
self.class.perform_in(INTERVAL_SECS)
end
end
end
+2 -2
View File
@@ -1,6 +1,6 @@
# Avvia/riavvia il relay YouTube solo nel container sidekiq (YOUTUBE_RELAY_WORKER=1).
# Avvia/riavvia il relay YouTube solo sui worker con YOUTUBE_RELAY_WORKER=1 (coda youtube_relay).
class YoutubeRelayEnsureJob < ApplicationJob
queue_as :default
queue_as Streams::YoutubeRelay::QUEUE
def perform(session_id)
session = StreamSession.find_by(id: session_id)
+15 -3
View File
@@ -1,10 +1,22 @@
# Ferma il relay solo sull'owner. Se il job gira su un altro host, requeue breve.
class YoutubeRelayStopJob < ApplicationJob
queue_as :default
queue_as Streams::YoutubeRelay::QUEUE
def perform(session_id)
discard_on ActiveJob::DeserializationError
def perform(session_id, attempts = 0)
session = StreamSession.find_by(id: session_id)
return unless session
Streams::YoutubeRelay.stop_on_worker!(session) if Streams::YoutubeRelay.worker?
unless Streams::YoutubeRelay.worker?
# Solo i worker relay processano questa coda in modo utile.
return
end
result = Streams::YoutubeRelay.stop_on_worker!(session)
return unless result == :wrong_host
return if attempts >= 30
self.class.set(wait: 2.seconds).perform_later(session_id, attempts + 1)
end
end
-13
View File
@@ -16,17 +16,4 @@ 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
+1 -1
View File
@@ -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
sidekiq_stale sidekiq_dead log_pattern garage_storage stream_overflow
].freeze
SEVERITIES = %w[critical warning info].freeze
STATUSES = %w[open acknowledged resolved].freeze
+38
View File
@@ -0,0 +1,38 @@
# frozen_string_literal: true
# Nodo streaming (MediaMTX [+ relay ffmpeg]). Fase 0: registry + assignment URL.
class StreamNode < ApplicationRecord
ROLES = %w[home cloud lab].freeze
STATUSES = %w[provisioning ready draining offline error].freeze
PROVIDERS = %w[local proxmox_lab hetzner].freeze
has_many :stream_sessions, dependent: :nullify
validates :slug, presence: true, uniqueness: true
validates :hostname, presence: true
validates :role, inclusion: { in: ROLES }
validates :status, inclusion: { in: STATUSES }
validates :provider, inclusion: { in: PROVIDERS }
validates :rtmp_base_url, :hls_base_url, :api_base_url, presence: true
validates :max_publishers, :max_relays, numericality: { greater_than: 0 }
scope :ready, -> { where(status: "ready") }
scope :allocatable, -> { ready }
# Sessioni che occupano uno slot path MediaMTX su questo nodo.
def occupying_sessions
stream_sessions.where(status: %w[idle connecting live reconnecting paused])
end
def active_publishers
occupying_sessions.count
end
def free_slots
[max_publishers - active_publishers, 0].max
end
def allocatable?
status == "ready" && free_slots.positive?
end
end
+25 -4
View File
@@ -7,6 +7,7 @@ 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
@@ -14,7 +15,6 @@ 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).
"#{MatchLiveTv.mediamtx_rtmp_url}/#{mediamtx_path_name}"
"#{rtmp_base_url.chomp('/')}/#{mediamtx_path_name}"
end
def mediamtx_path_name
@@ -91,8 +91,29 @@ class StreamSession < ApplicationRecord
end
def hls_playback_url
base = MatchLiveTv.hls_public_url.chomp("/")
"#{base}/#{effective_hls_path_name}/index.m3u8"
"#{hls_base_url.chomp('/')}/#{effective_hls_path_name}/index.m3u8"
end
def rtmp_base_url
stream_node&.rtmp_base_url.presence || MatchLiveTv.mediamtx_rtmp_url
end
def hls_base_url
stream_node&.hls_base_url.presence || MatchLiveTv.hls_public_url
end
def mediamtx_api_base_url
stream_node&.api_base_url.presence || MatchLiveTv.mediamtx_api_url
end
def mediamtx_internal_rtmp_url
stream_node&.internal_rtmp_url.presence ||
ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935")
end
def mediamtx_internal_hls_url
stream_node&.internal_hls_url.presence ||
ENV.fetch("MEDIAMTX_HLS_URL", "http://mediamtx:8888")
end
def effective_hls_path_name
+17
View File
@@ -4,7 +4,12 @@ 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
@@ -12,6 +17,8 @@ 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
@@ -98,6 +105,16 @@ 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,17 +4,37 @@ module Mediamtx
module_function
def active?(session)
return true if rtmp_publisher?(session)
active_path?(path_info(session))
end
def path_info(session)
Client.new.list_paths.find { |i| i["name"] == session.mediamtx_path_name }
Client.for_session(session).list_paths.find { |i| i["name"] == session.mediamtx_path_name }
end
# 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"))
info["online"] == true && info.dig("source", "type") == "rtmpConn"
false
end
def rtmp_publisher?(session)
path = session.mediamtx_path_name.to_s
return false if path.blank?
Client.for_session(session).list_rtmp_conns.any? do |conn|
conn_path = conn["path"].to_s.sub(%r{\A/}, "")
next false unless conn_path == path
state = conn["state"].to_s
state.empty? || state == "publish" || state == "idle"
end
rescue StandardError
false
end
def h264_video?(info)
@@ -26,12 +46,14 @@ module Mediamtx
end
def video_publishing?(session)
info = path_info(session)
return false unless active_path?(info)
# Slate alwaysAvailable ha H264 ma non è il telefono.
return false unless info.dig("source", "type") == "rtmpConn"
return false unless active?(session)
info = path_info(session)
h264_video?(info)
end
def rtmp_source?(type)
type.to_s.match?(/\Artmps?Conn\z/)
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_path?(path_info)
publisher_online = Mediamtx::PublisherOnline.active?(@session)
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.new.set_always_available(session, enabled: false)
Client.for_session(session).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.new.set_always_available(session, enabled: true)
Client.for_session(session).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.new.set_path_recording(session, enabled: enabled)
Client.for_session(session).set_path_recording(session, enabled: enabled)
redis.set(key, desired, ex: 48.hours.to_i)
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.perform_later(session.id)
YoutubeRelayEnsureJob.set(queue: Streams::YoutubeRelay::QUEUE).perform_later(session.id)
end
def redis
+54 -1
View File
@@ -44,7 +44,8 @@ module Ops
check_sidekiq_heartbeat,
check_sidekiq_dead,
check_http_rails,
check_rails_latency
check_rails_latency,
check_stream_overflow
]
findings << check_http_public if public_check_due?
findings
@@ -161,6 +162,58 @@ 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.new.delete_path(session)
Mediamtx::Client.for_session(session).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.new.delete_path(@session)
Mediamtx::Client.for_session(@session).delete_path(@session)
rescue Mediamtx::Client::Error => e
Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}")
end
+13 -3
View File
@@ -34,11 +34,19 @@ module Sessions
end
StreamSession.transaction do
session.stream_node = Streams::NodeRegistry.allocate!
session.save!
Scoring::Engine.ensure_score_for(session)
mtx = Mediamtx::Client.new
mtx.create_path(session)
log_event(session, "pairing", { created: true, platform: session.platform })
Mediamtx::Client.for_session(session).create_path(session)
log_event(
session,
"pairing",
{
created: true,
platform: session.platform,
stream_node: session.stream_node&.slug
}
)
end
if session.platform == "youtube"
@@ -46,6 +54,8 @@ module Sessions
end
session
rescue Streams::NodeRegistry::NoCapacityError => e
raise Teams::EntitlementError.new(e.message, code: "stream_capacity_exhausted")
end
private
+3 -4
View File
@@ -9,12 +9,11 @@ module Sessions
@session.pause! if @session.may_pause?
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
begin
mtx = Mediamtx::Client.new
mtx.set_path_recording(@session, enabled: false)
mtx.set_always_available(@session, enabled: true)
Mediamtx::Client.for_session(@session).set_path_recording(@session, enabled: false)
rescue Mediamtx::Client::Error => e
Rails.logger.warn("[Sessions::Pause] MediaMTX: #{e.message}")
Rails.logger.warn("[Sessions::Pause] disable recording: #{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,8 +3,6 @@ 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 },
@@ -13,32 +11,6 @@ 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
@@ -1,39 +0,0 @@
# 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
+1 -1
View File
@@ -33,7 +33,7 @@ module Sessions
end
def remove_mediamtx_paths!
Mediamtx::Client.new.delete_path(@session)
Mediamtx::Client.for_session(@session).delete_path(@session)
rescue Mediamtx::Client::Error => e
Rails.logger.warn("[Sessions::Stop] delete_path #{@session.id}: #{e.message}")
end
+241
View File
@@ -0,0 +1,241 @@
# frozen_string_literal: true
module Streams
# Scale-out / warm spare / scale-in dei nodi overflow (lab o Hetzner).
# Kill-switch: STREAM_AUTOSCALE_ENABLED!=1 OPPURE Redis streams:autoscaler:kill_switch=1.
class Autoscaler
Result = Struct.new(:actions, :metrics, :skipped, :error, keyword_init: true)
LOCK_KEY = "streams:autoscaler:lock"
KILL_SWITCH_KEY = "streams:autoscaler:kill_switch"
class << self
def enabled?
return false if kill_switch_engaged?
return false unless ENV["STREAM_AUTOSCALE_ENABLED"] == "1"
true
end
def kill_switch_engaged?
redis_get(KILL_SWITCH_KEY) == "1"
end
def engage_kill_switch!
redis_set(KILL_SWITCH_KEY, "1")
end
def clear_kill_switch!
redis_del(KILL_SWITCH_KEY)
end
def soft_free_slots
ENV.fetch("STREAM_AUTOSCALE_SOFT_FREE_SLOTS", "2").to_i
end
def warm_spare_min
ENV.fetch("STREAM_AUTOSCALE_WARM_SPARE", "1").to_i
end
def idle_minutes
ENV.fetch("STREAM_AUTOSCALE_IDLE_MINUTES", "30").to_i
end
def max_overflow_nodes
ENV.fetch("STREAM_AUTOSCALE_MAX_NODES", "5").to_i
end
def kind
ENV.fetch("STREAM_AUTOSCALE_KIND", "lab") # lab|cloud
end
def allow_cloud?
ENV["STREAM_AUTOSCALE_ALLOW_CLOUD"] == "1" && ENV["HCLOUD_TOKEN"].present?
end
def node_eur_per_hour
ENV.fetch("STREAM_AUTOSCALE_NODE_EUR_PER_HOUR", "0.015").to_f
end
def monthly_budget_eur
ENV.fetch("STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR", "40").to_f
end
def estimated_monthly_eur(overflow_count = nil)
count = overflow_count || metrics[:overflow_nodes]
# Worst case: nodi sempre accesi 24/7
(count * node_eur_per_hour * 24 * 30).round(2)
end
def within_budget?(overflow_count = nil)
estimated_monthly_eur(overflow_count) <= monthly_budget_eur
end
def reconcile!(provisioner: nil)
return Result.new(skipped: true, actions: [], metrics: metrics) unless enabled?
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
unless redis.set(LOCK_KEY, worker_id, nx: true, ex: 55)
return Result.new(skipped: true, actions: [], metrics: metrics, error: "locked")
end
begin
new(provisioner: provisioner).reconcile!
ensure
redis.del(LOCK_KEY)
end
end
def metrics
NodeRegistry.ensure_home_from_env!
nodes = StreamNode.ready.to_a
overflow = StreamNode.where.not(slug: NodeRegistry::HOME_SLUG)
.where(status: %w[ready draining provisioning]).to_a
{
free_slots: nodes.sum(&:free_slots),
ready_nodes: nodes.size,
spare_ready: nodes.count { |n| n.slug != NodeRegistry::HOME_SLUG && n.active_publishers.zero? },
overflow_nodes: overflow.size,
soft_free_slots: soft_free_slots,
warm_spare_min: warm_spare_min,
max_overflow_nodes: max_overflow_nodes,
enabled: enabled?,
env_enabled: ENV["STREAM_AUTOSCALE_ENABLED"] == "1",
kill_switch: kill_switch_engaged?,
kind: kind,
allow_cloud: allow_cloud?,
estimated_monthly_eur: estimated_monthly_eur(overflow.size),
monthly_budget_eur: monthly_budget_eur,
within_budget: within_budget?(overflow.size)
}
end
def worker_id
ENV.fetch("HOSTNAME", "autoscaler")
end
def redis_get(key)
Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")).get(key)
rescue Redis::BaseError
nil
end
def redis_set(key, value)
Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")).set(key, value)
end
def redis_del(key)
Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")).del(key)
end
end
def initialize(provisioner: nil)
@provisioner = provisioner || NodeProvisioner.new
@provisioned_this_round = []
end
def reconcile!
actions = []
m = self.class.metrics
if need_capacity?(m) && can_provision?(m)
provision_overflow!
actions << :scale_out
m = self.class.metrics
elsif need_capacity?(m) && !can_provision?(m)
actions << :blocked_capacity
Rails.logger.warn("[Streams::Autoscaler] capacity needed but blocked metrics=#{m.inspect}")
end
if warm_spare_desired?(m) && m[:spare_ready] < self.class.warm_spare_min && can_provision?(m)
provision_overflow!
actions << :warm_spare
m = self.class.metrics
end
scale_in_candidates.each do |node|
next if keep_as_warm_spare?(node)
next if @provisioned_this_round.include?(node.id)
safe_scale_in!(node)
actions << :"scale_in_#{node.slug}"
m = self.class.metrics
rescue NodeProvisioner::BusyError, NodeProvisioner::Error => e
Rails.logger.warn("[Streams::Autoscaler] scale-in #{node.slug}: #{e.message}")
end
Rails.logger.info("[Streams::Autoscaler] actions=#{actions.inspect} metrics=#{m.inspect}")
Result.new(actions: actions, metrics: m, skipped: false)
end
private
def need_capacity?(m)
m[:free_slots] <= self.class.soft_free_slots
end
def warm_spare_desired?(m)
return false if self.class.warm_spare_min <= 0
need_capacity?(m) || overflow_in_use?
end
def overflow_in_use?
StreamNode.ready.where.not(slug: NodeRegistry::HOME_SLUG).any? { |n| n.active_publishers.positive? }
end
def can_provision?(m)
return false if m[:overflow_nodes] >= self.class.max_overflow_nodes
return false unless self.class.within_budget?(m[:overflow_nodes] + 1)
return false if self.class.kind == "cloud" && !self.class.allow_cloud?
true
end
def provision_overflow!
node =
case self.class.kind
when "cloud"
raise NodeProvisioner::Error, "Cloud autoscale disabilitato (STREAM_AUTOSCALE_ALLOW_CLOUD / HCLOUD_TOKEN)" unless self.class.allow_cloud?
@provisioner.provision_cloud!
else
@provisioner.provision_lab!
end
@provisioned_this_round << node.id if node
node
end
def safe_scale_in!(node)
@provisioner.drain!(node) unless node.status == "draining"
node.reload
raise NodeProvisioner::BusyError, "sessioni ancora attive" if node.occupying_sessions.exists?
raise NodeProvisioner::Error, "idle insufficiente" unless idle_long_enough?(node)
@provisioner.decommission!(node)
end
def scale_in_candidates
StreamNode.where.not(slug: NodeRegistry::HOME_SLUG)
.where(status: %w[ready draining])
.order(:created_at)
.select { |n| n.active_publishers.zero? && idle_long_enough?(n) }
end
def idle_long_enough?(node)
idle_since(node) <= self.class.idle_minutes.minutes.ago
end
def idle_since(node)
last_end = node.stream_sessions.where(status: %w[ended error]).maximum(:ended_at)
last_end || node.created_at
end
def keep_as_warm_spare?(node)
return false unless warm_spare_desired?(self.class.metrics)
spares = StreamNode.ready.where.not(slug: NodeRegistry::HOME_SLUG).select { |n| n.active_publishers.zero? }
spares.size <= self.class.warm_spare_min && spares.map(&:id).include?(node.id)
end
end
end
@@ -0,0 +1,15 @@
# frozen_string_literal: true
module Streams
module CloudProviders
def self.build(name = ENV.fetch("STREAM_CLOUD_PROVIDER", "local_lab"))
case name.to_s
when "local_lab" then LocalLab.new
when "proxmox_lab" then ProxmoxLab.new
when "hetzner" then Hetzner.new
else
raise Error, "STREAM_CLOUD_PROVIDER sconosciuto: #{name}"
end
end
end
end
@@ -0,0 +1,35 @@
# frozen_string_literal: true
module Streams
module CloudProviders
class Error < StandardError; end
# Descrittore restituito da create_node / list.
Instance = Struct.new(
:id, :name, :public_ip, :private_ip, :status, :raw,
keyword_init: true
)
class Base
def create_node(name:, labels: {})
raise NotImplementedError
end
def destroy_node(instance_id)
raise NotImplementedError
end
def list_nodes(labels: {})
raise NotImplementedError
end
def wait_until_running(instance_id, timeout: 120)
raise NotImplementedError
end
def public_ip(instance_id)
raise NotImplementedError
end
end
end
end
@@ -0,0 +1,191 @@
# 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
@@ -0,0 +1,45 @@
# frozen_string_literal: true
module Streams
module CloudProviders
# Lab senza API Proxmox: simula create/destroy e riusa MediaMTX home per i path.
# Utile per testare registry, assignment e admin senza secondi host.
class LocalLab < Base
def create_node(name:, labels: {})
Instance.new(
id: "sim-#{name}",
name: name,
public_ip: labels[:public_ip].presence || "127.0.0.1",
private_ip: labels[:private_ip].presence || "127.0.0.1",
status: "running",
raw: { simulated: true, labels: labels }
)
end
def destroy_node(instance_id)
true
end
def list_nodes(labels: {})
StreamNode.where(provider: "local", role: "lab").map do |node|
Instance.new(
id: node.provider_instance_id,
name: node.slug,
public_ip: node.metadata["public_ip"],
private_ip: node.metadata["private_ip"],
status: node.status == "ready" ? "running" : node.status,
raw: node.metadata
)
end
end
def wait_until_running(instance_id, timeout: 120)
Instance.new(id: instance_id, name: instance_id, status: "running")
end
def public_ip(instance_id)
"127.0.0.1"
end
end
end
end
@@ -0,0 +1,162 @@
# frozen_string_literal: true
require "faraday"
module Streams
module CloudProviders
# Clone/start/stop di VM template su Proxmox VE (API token).
#
# ENV richiesti:
# PROXMOX_API_URL, PROXMOX_TOKEN_ID, PROXMOX_TOKEN_SECRET,
# PROXMOX_NODE, PROXMOX_TEMPLATE_VMID
class ProxmoxLab < Base
def initialize(
api_url: ENV.fetch("PROXMOX_API_URL"),
token_id: ENV.fetch("PROXMOX_TOKEN_ID"),
token_secret: ENV.fetch("PROXMOX_TOKEN_SECRET"),
node: ENV.fetch("PROXMOX_NODE"),
template_vmid: ENV.fetch("PROXMOX_TEMPLATE_VMID"),
verify_ssl: ENV.fetch("PROXMOX_VERIFY_SSL", "false") == "true"
)
@api_url = api_url.to_s.chomp("/")
@token_id = token_id
@token_secret = token_secret
@node = node
@template_vmid = template_vmid.to_i
@verify_ssl = verify_ssl
end
def create_node(name:, labels: {})
newid = next_vmid
post("/nodes/#{@node}/qemu/#{@template_vmid}/clone", {
newid: newid,
name: name,
full: 1,
target: @node
})
post("/nodes/#{@node}/qemu/#{newid}/status/start", {})
wait_until_running(newid.to_s)
ip = public_ip(newid.to_s)
Instance.new(
id: newid.to_s,
name: name,
public_ip: ip,
private_ip: ip,
status: "running",
raw: { node: @node, vmid: newid, labels: labels }
)
end
def destroy_node(instance_id)
vmid = instance_id.to_i
begin
post("/nodes/#{@node}/qemu/#{vmid}/status/stop", { timeout: 30 })
rescue Error
# già spenta
end
sleep 2
delete("/nodes/#{@node}/qemu/#{vmid}", { purge: 1 })
true
end
def list_nodes(labels: {})
items = get("/nodes/#{@node}/qemu")
Array(items).filter_map do |row|
name = row["name"].to_s
next unless name.start_with?("mltv-stream-") || name.start_with?("ingest-")
Instance.new(
id: row["vmid"].to_s,
name: name,
public_ip: nil,
private_ip: nil,
status: row["status"],
raw: row
)
end
end
def wait_until_running(instance_id, timeout: 180)
deadline = Time.now + timeout
loop do
status = get("/nodes/#{@node}/qemu/#{instance_id}/status/current")
return Instance.new(id: instance_id.to_s, status: "running", raw: status) if status["status"] == "running"
raise Error, "Timeout attesa VM #{instance_id}" if Time.now >= deadline
sleep 3
end
end
def public_ip(instance_id)
agent = get("/nodes/#{@node}/qemu/#{instance_id}/agent/network-get-interfaces")
interfaces = agent.is_a?(Hash) ? agent["result"] : nil
Array(interfaces).each do |iface|
Array(iface["ip-addresses"]).each do |addr|
ip = addr["ip-address"].to_s
next if ip.blank? || ip.start_with?("127.") || ip.include?(":")
return ip
end
end
ENV["STREAM_LAB_FALLBACK_IP"].presence || "127.0.0.1"
rescue Error
ENV["STREAM_LAB_FALLBACK_IP"].presence || "127.0.0.1"
end
private
def next_vmid
used = Array(get("/cluster/resources", type: "vm")).map { |r| r["vmid"].to_i }
candidate = ENV.fetch("PROXMOX_VMID_START", "9100").to_i
candidate += 1 while used.include?(candidate)
candidate
end
def conn
@conn ||= Faraday.new(url: "#{@api_url}/api2/json") do |f|
f.request :url_encoded
f.response :json, content_type: /\bjson$/
f.adapter Faraday.default_adapter
f.ssl[:verify] = @verify_ssl
end
end
def auth_headers
{ "Authorization" => "PVEAPIToken=#{@token_id}=#{@token_secret}" }
end
def get(path, params = {})
response = conn.get(path) do |req|
req.headers.update(auth_headers)
req.params.update(params)
end
unwrap!(response)
end
def post(path, body = {})
response = conn.post(path) do |req|
req.headers.update(auth_headers)
req.body = body
end
unwrap!(response)
end
def delete(path, params = {})
response = conn.delete(path) do |req|
req.headers.update(auth_headers)
req.params.update(params)
end
unwrap!(response)
end
def unwrap!(response)
unless response.success?
raise Error, "Proxmox API #{response.status}: #{response.body.inspect}"
end
body = response.body
body.is_a?(Hash) && body.key?("data") ? body["data"] : body
end
end
end
end
@@ -0,0 +1,14 @@
# frozen_string_literal: true
module Streams
module DnsProviders
def self.build(name = ENV.fetch("STREAM_DNS_PROVIDER", "lab"))
case name.to_s
when "lab" then Lab.new
when "hetzner" then Hetzner.new
else
raise Error, "STREAM_DNS_PROVIDER sconosciuto: #{name}"
end
end
end
end
@@ -0,0 +1,21 @@
# frozen_string_literal: true
module Streams
module DnsProviders
class Error < StandardError; end
class Base
def upsert_a(name, ip)
raise NotImplementedError
end
def delete_a(name)
raise NotImplementedError
end
def resolve(name)
raise NotImplementedError
end
end
end
end
@@ -0,0 +1,106 @@
# frozen_string_literal: true
require "faraday"
require "cgi"
module Streams
module DnsProviders
# Hetzner Cloud DNS (Console) — zone mltv-stream.net.
#
# ENV:
# HCLOUD_TOKEN (stesso del Cloud)
# STREAM_DNS_ZONE (default mltv-stream.net)
# STREAM_DNS_TTL (default 60)
class Hetzner < Base
# Trailing slash obbligatorio: path assoluti altrimenti droppano /v1.
API = "https://api.hetzner.cloud/v1/"
def initialize(token: ENV.fetch("HCLOUD_TOKEN"), zone: nil, conn: nil)
@token = token
@zone = zone || ENV.fetch("STREAM_DNS_ZONE", "mltv-stream.net")
@ttl = ENV.fetch("STREAM_DNS_TTL", "60").to_i
@conn = conn
end
def upsert_a(name, ip)
rr_name = relative_name(name)
delete_a(name)
post("zones/#{CGI.escape(@zone)}/rrsets", {
name: rr_name,
type: "A",
ttl: @ttl,
records: [{ value: ip.to_s, comment: "matchlivetv stream-node" }],
labels: { "matchlivetv" => "true", "role" => "stream-node" }
})
true
end
def delete_a(name)
rr_name = relative_name(name)
encoded = CGI.escape(rr_name)
response = conn.delete("zones/#{CGI.escape(@zone)}/rrsets/#{encoded}/A") do |req|
req.headers.update(auth_headers)
end
return true if response.status == 404 || response.status == 204 || response.success?
raise Error, "Hetzner DNS API #{response.status}: #{response.body.inspect}"
end
def resolve(name)
rr_name = relative_name(name)
data = get("zones/#{CGI.escape(@zone)}/rrsets", name: rr_name, type: "A")
rrset = Array(data["rrsets"]).first
Array(rrset&.dig("records")).first&.dig("value")
rescue Error
nil
end
private
def relative_name(name)
host = name.to_s.strip.downcase.delete_suffix(".")
suffix = ".#{@zone}"
return "@" if host == @zone
return host.delete_suffix(suffix) if host.end_with?(suffix)
host
end
def conn
@conn ||= Faraday.new(url: API) do |f|
f.request :json
f.response :json, content_type: /\bjson$/
f.adapter Faraday.default_adapter
end
end
def auth_headers
{ "Authorization" => "Bearer #{@token}" }
end
def get(path, params = {})
response = conn.get(path) do |req|
req.headers.update(auth_headers)
req.params.update(params)
end
unwrap!(response)
end
def post(path, body)
response = conn.post(path) do |req|
req.headers.update(auth_headers)
req.body = body
end
unwrap!(response)
end
def unwrap!(response)
unless response.success?
raise Error, "Hetzner DNS API #{response.status}: #{response.body.inspect}"
end
response.body.is_a?(Hash) ? response.body : {}
end
end
end
end
@@ -0,0 +1,47 @@
# frozen_string_literal: true
module Streams
module DnsProviders
# DNS lab in Redis (e dump hosts). Nessuna chiamata al registrar.
class Lab < Base
REDIS_KEY = "stream_dns:a_records"
def initialize(redis: nil)
@redis = redis
end
def upsert_a(name, ip)
host = normalize(name)
redis.hset(REDIS_KEY, host, ip.to_s)
true
end
def delete_a(name)
redis.hdel(REDIS_KEY, normalize(name))
true
end
def resolve(name)
redis.hget(REDIS_KEY, normalize(name))
end
def all_records
redis.hgetall(REDIS_KEY)
end
def hosts_file_snippet
all_records.sort.map { |host, ip| "#{ip}\t#{host}" }.join("\n")
end
private
def normalize(name)
name.to_s.strip.downcase.delete_suffix(".")
end
def redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
end
end
end
end
@@ -0,0 +1,152 @@
# 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
@@ -0,0 +1,66 @@
# frozen_string_literal: true
module Streams
# Assegna un StreamNode a una nuova sessione.
# Preferisce home finché ha slot; poi least-loaded tra i nodi overflow ready.
# Garantisce il nodo "home" derivato dagli ENV MediaMTX attuali.
class NodeRegistry
class NoCapacityError < StandardError; end
HOME_SLUG = "home"
class << self
def ensure_home_from_env!
StreamNode.find_or_initialize_by(slug: HOME_SLUG).tap do |node|
attrs = {
hostname: home_hostname,
role: "home",
provider: "local",
rtmp_base_url: MatchLiveTv.mediamtx_rtmp_url,
hls_base_url: MatchLiveTv.hls_public_url,
api_base_url: MatchLiveTv.mediamtx_api_url,
internal_rtmp_url: ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935"),
internal_hls_url: ENV.fetch("MEDIAMTX_HLS_URL", "http://mediamtx:8888"),
max_publishers: ENV.fetch("STREAM_NODE_HOME_MAX_PUBLISHERS", "6").to_i,
max_relays: ENV.fetch("STREAM_NODE_HOME_MAX_RELAYS", "6").to_i
}
# Non sovrascrivere drain/offline/error (ops) a ogni allocate!
attrs[:status] = "ready" if node.new_record? || !%w[draining offline error].include?(node.status)
node.assign_attributes(attrs)
node.save!
end
end
def allocate!
ensure_home_from_env!
# Overflow: riempi prima home; i nodi cloud/lab sono solo quando home è pieno
# (altrimenti lo warm spare ruberebbe tutte le sessioni).
home = StreamNode.find_by(slug: HOME_SLUG)
return home if home&.allocatable?
node = StreamNode.ready
.where.not(slug: HOME_SLUG)
.to_a
.select(&:allocatable?)
.min_by { |n| [n.active_publishers, n.slug] }
raise NoCapacityError, "Nessun nodo streaming con slot liberi" if node.nil?
node
end
private
def home_hostname
ENV["STREAM_NODE_HOME_HOSTNAME"].presence ||
begin
uri = URI.parse(MatchLiveTv.mediamtx_rtmp_url.sub(/\Artmps?:\/\//, "http://"))
uri.host.presence
rescue URI::InvalidURIError
nil
end || "home"
end
end
end
end
+83 -23
View File
@@ -1,29 +1,35 @@
module Streams
# Relay verso YouTube: legge RTMP/HLS da MediaMTX e inoltra su RTMPS (-c copy). Nessun overlay.
# ffmpeg gira solo nel container sidekiq (YOUTUBE_RELAY_WORKER=1).
# ffmpeg gira solo sui worker Sidekiq con YOUTUBE_RELAY_WORKER=1 (coda youtube_relay).
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)
clear_pid(session.id)
redis.del(format(OWNER_KEY, session.id))
if worker?
if worker? && owner_is_local?(session.id)
stop_on_worker!(session)
else
YoutubeRelayStopJob.perform_later(session.id)
YoutubeRelayStopJob.set(queue: QUEUE).perform_later(session.id)
end
true
end
@@ -31,11 +37,13 @@ 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 avviato in un altro container: consideralo attivo se il lock è recente.
# Relay su altro host: attivo se lock owner ancora fresco.
redis.ttl(format(OWNER_KEY, session_id)) > 30
end
@@ -47,12 +55,12 @@ module Streams
if worker?
ensure_on_worker!(session)
else
YoutubeRelayEnsureJob.perform_later(session.id)
YoutubeRelayEnsureJob.set(queue: QUEUE).perform_later(session.id)
end
end
def ensure_on_worker!(session)
return unless worker?
return :not_worker unless worker?
return unless session.platform == "youtube"
return if session.terminal?
return if session.stream_key.blank?
@@ -60,28 +68,64 @@ module Streams
return unless intake_available?(session)
pid = pid_for(session.id)
clear_pid(session.id) if pid.present? && !process_alive?(pid.to_i)
if pid.present? && !process_alive?(pid.to_i)
clear_local_ownership(session.id)
end
return if running?(session.id)
if running?(session.id)
touch_owner!(session.id) if owner_is_local?(session.id)
return :already_running
end
if at_capacity?
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
last_restart = redis.get(restart_debounce_key(session.id)).to_i
return if last_restart.positive? && (Time.now.to_i - last_restart) < 5
return :debounced if last_restart.positive? && (Time.now.to_i - last_restart) < 5
start_on_worker!(session)
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)
return false if pid.blank?
if pid.blank?
clear_local_ownership(session.id)
return :noop
end
terminate_pid(pid)
clear_pid(session.id)
redis.del(format(OWNER_KEY, session.id))
Rails.logger.info("[YoutubeRelay] stopped pid=#{pid} session=#{session.id}")
true
clear_local_ownership(session.id)
Rails.logger.info("[YoutubeRelay] stopped pid=#{pid} session=#{session.id} worker=#{worker_id}")
:stopped
end
def local_owned_count
redis.scard(format(OWNED_SET, worker_id)).to_i
end
def at_capacity?
local_owned_count >= max_concurrent
end
def owner_is_local?(session_id)
owner = redis.get(format(OWNER_KEY, session_id))
owner.blank? || owner == worker_id
end
private
@@ -92,9 +136,9 @@ module Streams
return if session.terminal?
return unless intake_available?(session)
return pid_for(session.id).to_i if running?(session.id)
return pid_for(session.id).to_i if running?(session.id) && owner_is_local?(session.id)
stop_on_worker!(session) if pid_for(session.id).present?
stop_on_worker!(session) if pid_for(session.id).present? && owner_is_local?(session.id)
log_path = log_file(session)
FileUtils.mkdir_p(File.dirname(log_path))
@@ -108,8 +152,8 @@ module Streams
)
Process.detach(pid)
store_pid(session.id, pid)
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(":")}")
claim_ownership!(session.id)
Rails.logger.info("[YoutubeRelay] started pid=#{pid} session=#{session.id} intake=#{intake_source.join(":")} worker=#{worker_id}")
schedule_youtube_activate(session)
pid
rescue Errno::ENOENT => e
@@ -146,20 +190,20 @@ module Streams
end
def mediamtx_intake_source(session)
base = ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935")
base = session.mediamtx_internal_rtmp_url
if Mediamtx::PublisherOnline.active?(session)
return [:rtmp, "#{base.chomp('/')}/#{session.mediamtx_path_name}"]
end
path = session.mediamtx_path_name
hls = ENV.fetch("MEDIAMTX_HLS_URL", "http://mediamtx:8888").chomp("/")
hls = session.mediamtx_internal_hls_url.chomp("/")
[:hls, "#{hls}/#{path}/index.m3u8"]
end
def intake_available?(session)
return true if Mediamtx::PublisherOnline.active?(session)
info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == session.mediamtx_path_name }
info = Mediamtx::Client.for_session(session).list_paths.find { |i| i["name"] == session.mediamtx_path_name }
info && (info["ready"] || info["online"] || info["available"])
rescue StandardError
false
@@ -185,6 +229,22 @@ 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.new.set_path_recording(session, enabled: true)
Mediamtx::Client.for_session(session).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.new.set_path_recording(session, enabled: false)
Mediamtx::Client.for_session(session).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.new.set_always_available(session, enabled: false)
Mediamtx::Client.for_session(session).set_always_available(session, enabled: false)
session.go_live! if session.may_go_live?
session.reconnect! if session.reconnecting? && session.may_reconnect?
@@ -0,0 +1,129 @@
<% content_for :body_class, "admin-body" %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.stream_nodes.title") %></h2>
<p class="muted admin-page-sub">
<%= t("admin.stream_nodes.providers", cloud: @cloud_provider, dns: @dns_provider) %>
</p>
</div>
<% m = @autoscale_metrics %>
<section class="kpi-grid">
<div class="kpi-card <%= m[:enabled] ? 'kpi-card--accent' : '' %> <%= 'kpi-card--danger' if m[:kill_switch] %>">
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.autoscaler") %></div>
<div class="kpi-value"><%= m[:kill_switch] ? t("admin.stream_nodes.kpi.kill_switch") : (m[:enabled] ? "ON" : "OFF") %></div>
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.kind", kind: m[:kind]) %></div>
</div>
<div class="kpi-card <%= m[:free_slots] <= m[:soft_free_slots] ? 'kpi-card--danger' : '' %>">
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.free_slots") %></div>
<div class="kpi-value"><%= m[:free_slots] %></div>
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.soft_slots", soft: m[:soft_free_slots]) %></div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.spare") %></div>
<div class="kpi-value"><%= m[:spare_ready] %><span class="kpi-value-unit">/<%= m[:warm_spare_min] %></span></div>
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.warm_spare") %></div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.overflow") %></div>
<div class="kpi-value"><%= m[:overflow_nodes] %><span class="kpi-value-unit">/<%= m[:max_overflow_nodes] %></span></div>
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.overflow_nodes") %></div>
</div>
<div class="kpi-card <%= m[:within_budget] ? '' : 'kpi-card--danger' %>">
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.budget") %></div>
<div class="kpi-value kpi-value--sm"><%= m[:estimated_monthly_eur] %></div>
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.budget_of", budget: m[:monthly_budget_eur], ok: (m[:within_budget] ? "OK" : "OVER")) %></div>
</div>
</section>
<div class="panel" style="margin-bottom:1.5rem">
<div class="admin-panel-head">
<h2><%= t("admin.stream_nodes.actions_title") %></h2>
</div>
<div class="admin-toolbar">
<%= button_to t("admin.stream_nodes.provision_lab"), admin_stream_nodes_path, method: :post, params: { kind: "lab" }, class: "admin-btn admin-btn--secondary" %>
<% if @hetzner_configured %>
<%= button_to t("admin.stream_nodes.provision_cloud"), admin_stream_nodes_path, method: :post, params: { kind: "cloud" }, class: "admin-btn admin-btn--primary",
form: { data: { confirm: t("admin.stream_nodes.provision_cloud_confirm") } } %>
<% else %>
<span class="muted"><%= t("admin.stream_nodes.hetzner_token_missing") %></span>
<% end %>
<% if m[:kill_switch] %>
<%= button_to t("admin.stream_nodes.clear_kill_switch"), clear_kill_switch_admin_stream_nodes_path, method: :delete, class: "admin-btn admin-btn--secondary" %>
<% else %>
<%= button_to t("admin.stream_nodes.engage_kill_switch"), kill_switch_admin_stream_nodes_path, method: :post, class: "admin-btn admin-btn--danger",
form: { data: { confirm: t("admin.stream_nodes.kill_switch_confirm") } } %>
<% end %>
</div>
</div>
<div class="panel">
<h2><%= t("admin.stream_nodes.table_title") %></h2>
<% if @nodes.any? %>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th><%= t("admin.stream_nodes.col.slug") %></th>
<th><%= t("admin.stream_nodes.col.role") %></th>
<th><%= t("admin.stream_nodes.col.status") %></th>
<th><%= t("admin.stream_nodes.col.slots") %></th>
<th><%= t("admin.stream_nodes.col.hostname") %></th>
<th><%= t("admin.stream_nodes.col.provider") %></th>
<th></th>
</tr>
</thead>
<tbody>
<% @nodes.each do |node| %>
<%
badge =
case node.status
when "ready" then "badge--ready"
when "provisioning", "draining" then "badge--connecting"
when "error", "offline" then "badge--paused"
else "badge--paused"
end
%>
<tr>
<td><strong><%= node.slug %></strong></td>
<td class="muted"><%= node.role %></td>
<td><span class="badge <%= badge %>"><%= node.status %></span></td>
<td>
<strong><%= node.active_publishers %></strong>
<span class="muted">/ <%= node.max_publishers %></span>
<div class="muted" style="font-size:0.8rem"><%= t("admin.stream_nodes.free_slots", count: node.free_slots) %></div>
</td>
<td class="admin-mono"><%= node.hostname %></td>
<td class="muted">
<%= node.provider %>
<% if node.provider_instance_id.present? %>
<div class="admin-mono" style="font-size:0.75rem;margin-top:0.2rem"><%= node.provider_instance_id %></div>
<% end %>
</td>
<td class="admin-actions">
<% if node.slug != "home" %>
<% if node.status == "ready" %>
<%= button_to t("admin.stream_nodes.drain"), drain_admin_stream_node_path(node), method: :post, class: "admin-btn admin-btn--sm admin-btn--secondary" %>
<% end %>
<%= button_to t("admin.stream_nodes.destroy"), admin_stream_node_path(node), method: :delete, class: "admin-btn admin-btn--sm admin-btn--danger",
form: { data: { confirm: t("admin.stream_nodes.destroy_confirm", slug: node.slug) } } %>
<% else %>
<span class="muted"></span>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="empty"><%= t("admin.stream_nodes.empty") %></p>
<% end %>
</div>
<% if @lab_hosts.present? %>
<div class="panel" style="margin-top:1.5rem">
<h2><%= t("admin.stream_nodes.hosts_title") %></h2>
<pre class="admin-pre"><%= @lab_hosts %></pre>
</div>
<% end %>
+2 -1
View File
@@ -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=2">
<link rel="stylesheet" href="/admin.css?v=4">
<% if content_for?(:replay_archive_styles) %>
<link rel="stylesheet" href="/marketing.css?v=42">
<% end %>
@@ -29,6 +29,7 @@
<%= link_to t("admin.layout.nav.billing"), admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %>
<%= link_to t("admin.layout.nav.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 %>
+1 -1
View File
@@ -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=11">
<link rel="stylesheet" href="/regia.css?v=1">
<%= 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"><%= t("regia.board.close_set") %></button>
<button type="button" class="regia-btn regia-btn--yellow" data-action="close_set" style="margin-top:12px"><%= t("regia.board.close_set") %></button>
+23 -88
View File
@@ -16,8 +16,6 @@
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 %>"
@@ -31,62 +29,40 @@
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-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>
<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") %>
</button>
<button type="button" class="regia-icon-btn" id="btn-copy-live" aria-label="<%= t("regia.copy_live") %>" title="<%= t("regia.copy_live") %>">
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<button type="button" class="regia-btn regia-btn--outline" id="btn-copy-live" style="margin:0">
<%= t("regia.copy_link") %>
</button>
</div>
</div>
<% end %>
<div class="regia-tool-group" role="group" aria-label="<%= t("regia.regia_share_label") %>">
<span class="regia-tool-group__label"><%= t("regia.regia_share_label") %></span>
<div class="regia-tool-group__btns">
<button type="button" class="regia-icon-btn" id="btn-share-regia" aria-label="<%= t("regia.share_regia") %>" title="<%= t("regia.share_regia") %>">
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
</button>
<button 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">&nbsp;</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>
<section class="regia-preview-wrap" id="regia-preview-wrap">
<div class="regia-preview" id="regia-preview-box">
<div class="regia-share-group">
<p class="regia-share-label"><%= t("regia.regia_share_label") %></p>
<div class="regia-share">
<button type="button" class="regia-btn regia-btn--outline" id="btn-share-regia" style="margin:0">
<%= t("regia.share_regia") %>
</button>
<button type="button" class="regia-btn regia-btn--outline" id="btn-copy-regia" style="margin:0">
<%= t("regia.copy_link") %>
</button>
</div>
</div>
<div class="regia-preview">
<% unless @stream_closed %>
<video id="regia-preview" playsinline muted autoplay></video>
<% end %>
@@ -94,55 +70,14 @@
<%= @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 %>
<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>
<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>
<% end %>
</div>
@@ -159,4 +94,4 @@
</div>
</div>
<script src="/regia.js?v=18"></script>
<script src="/regia.js?v=8"></script>
+2 -2
View File
@@ -62,8 +62,8 @@ Rails.application.configure do
# Annotate rendered view with file names.
config.action_view.annotate_rendered_view_with_filenames = true
# Permette Action Cable dall'emulatore Android (10.0.2.2) e da altri host locali.
config.action_cable.disable_request_forgery_protection = true
# Uncomment if you wish to allow Action Cable access from any origin.
# 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
+1
View File
@@ -6,6 +6,7 @@ Sidekiq.configure_server do |config|
config.on(:startup) do
StreamPublisherSyncJob.ensure_chain
Ops::HealthMonitorJob.ensure_chain
Streams::AutoscalerJob.ensure_chain
end
end
+48
View File
@@ -9,6 +9,7 @@ de:
billing: Abrechnung
youtube: YouTube
sessions: Sitzungen
stream_nodes: Stream-Knoten
password: Passwort
logout: Abmelden
flash:
@@ -26,6 +27,11 @@ 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})."
@@ -111,6 +117,48 @@ 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
+48
View File
@@ -9,6 +9,7 @@ en:
billing: Billing
youtube: YouTube
sessions: Sessions
stream_nodes: Stream nodes
password: Password
logout: Log out
flash:
@@ -26,6 +27,11 @@ 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})."
@@ -111,6 +117,48 @@ 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
+48
View File
@@ -9,6 +9,7 @@ es:
billing: Facturación
youtube: YouTube
sessions: Sesiones
stream_nodes: Nodos stream
password: Contraseña
logout: Salir
flash:
@@ -26,6 +27,11 @@ 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})."
@@ -111,6 +117,48 @@ 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
+48
View File
@@ -9,6 +9,7 @@ fr:
billing: Facturation
youtube: YouTube
sessions: Sessions
stream_nodes: Nœuds stream
password: Mot de passe
logout: Déconnexion
flash:
@@ -26,6 +27,11 @@ 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})."
@@ -111,6 +117,48 @@ 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
+48
View File
@@ -9,6 +9,7 @@ it:
billing: Fatturazione
youtube: YouTube
sessions: Sessioni
stream_nodes: Nodi stream
password: Password
logout: Esci
flash:
@@ -26,6 +27,11 @@ 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})."
@@ -111,6 +117,48 @@ 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
+2 -39
View File
@@ -432,51 +432,16 @@ de:
paused: Pausiert
live: Live
waiting: Wartet
live_share_label: Live
live_share_label: "Live-Link (Zuschauer)"
share_live: Livestream teilen
share_action: Teilen
copy_link: Link kopieren
copy_action: Kopieren
copy_live: Live kopieren
copy_regia: Regie kopieren
links_nav: Schnellaktionen
regia_share_label: Regie
stream_controls_label: Live
regia_share_label: "Regie-Link (Punktestand)"
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
@@ -521,8 +486,6 @@ 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: "←"
+2 -39
View File
@@ -427,51 +427,16 @@ en:
paused: Paused
live: Live
waiting: Waiting
live_share_label: Live
live_share_label: "Live link (viewers)"
share_live: Share live link
share_action: Share
copy_link: Copy link
copy_action: Copy
copy_live: Copy live
copy_regia: Copy room
links_nav: Quick actions
regia_share_label: Control
stream_controls_label: Live
regia_share_label: "Control room link (score)"
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
@@ -516,8 +481,6 @@ 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: "←"
+2 -39
View File
@@ -432,51 +432,16 @@ es:
paused: En pausa
live: En directo
waiting: En espera
live_share_label: Directo
live_share_label: "Enlace del directo (espectadores)"
share_live: Compartir directo
share_action: Compartir
copy_link: Copiar enlace
copy_action: Copiar
copy_live: Copiar directo
copy_regia: Copiar control
links_nav: Acciones rápidas
regia_share_label: Control
stream_controls_label: Live
regia_share_label: "Enlace de control (marcador)"
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
@@ -521,8 +486,6 @@ 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: "←"
+2 -39
View File
@@ -432,51 +432,16 @@ fr:
paused: En pause
live: En direct
waiting: En attente
live_share_label: Direct
live_share_label: "Lien direct (spectateurs)"
share_live: Partager le direct
share_action: Partager
copy_link: Copier le lien
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
regia_share_label: "Lien régie (score)"
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
@@ -521,8 +486,6 @@ 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: "←"
+2 -39
View File
@@ -427,51 +427,16 @@ it:
paused: In pausa
live: In onda
waiting: In attesa
live_share_label: Diretta
live_share_label: "Link diretta (spettatori)"
share_live: Condividi diretta
share_action: Condividi
copy_link: Copia link
copy_action: Copia
copy_live: Copia live
copy_regia: Copia regia
links_nav: Azioni rapide
regia_share_label: Regia
stream_controls_label: Live
regia_share_label: "Link regia (punteggio)"
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
@@ -516,8 +481,6 @@ 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: "←"
+9 -2
View File
@@ -55,7 +55,6 @@ Rails.application.routes.draw do
post :claim_pairing
post :regia_link
post :network_test
patch :min_quality
get :youtube_stats
end
end
@@ -111,6 +110,15 @@ 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
@@ -144,7 +152,6 @@ 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 }
+2 -1
View File
@@ -1,4 +1,5 @@
:concurrency: 5
:queues:
- default
- critical
- youtube_relay
- default
@@ -0,0 +1,30 @@
# frozen_string_literal: true
class CreateStreamNodes < ActiveRecord::Migration[7.2]
def change
create_table :stream_nodes, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
t.string :slug, null: false
t.string :hostname, null: false
t.string :role, null: false, default: "home"
t.string :status, null: false, default: "ready"
t.string :rtmp_base_url, null: false
t.string :hls_base_url, null: false
t.string :api_base_url, null: false
t.string :internal_rtmp_url
t.string :internal_hls_url
t.integer :max_publishers, null: false, default: 6
t.integer :max_relays, null: false, default: 6
t.string :provider, null: false, default: "local"
t.string :provider_instance_id
t.datetime :last_health_at
t.jsonb :metadata, null: false, default: {}
t.timestamps
end
add_index :stream_nodes, :slug, unique: true
add_index :stream_nodes, :status
add_index :stream_nodes, :role
add_reference :stream_sessions, :stream_node, type: :uuid, foreign_key: true, null: true, index: true
end
end
@@ -1,7 +0,0 @@
# 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
@@ -1,5 +0,0 @@
class AddThermalStateToDeviceStates < ActiveRecord::Migration[7.2]
def change
add_column :device_states, :thermal_state, :string
end
end
+1 -3
View File
@@ -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_13_184100) do
ActiveRecord::Schema[7.2].define(version: 2026_08_09_120000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -138,7 +138,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) 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
@@ -305,7 +304,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) 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
+3
View File
@@ -43,3 +43,6 @@ 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}"
+32
View File
@@ -0,0 +1,32 @@
# 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
+103 -10
View File
@@ -228,6 +228,8 @@ 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;
@@ -345,28 +347,40 @@ body.admin-body {
font-size: 0.9rem;
}
.admin-btn--secondary {
background: #333;
color: #eee;
}
.admin-btn--secondary:hover {
background: #444;
}
.admin-btn {
display: inline-block;
padding: 0.45rem 0.9rem;
border: none;
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--danger {
background: var(--red);
color: #fff;
@@ -374,6 +388,85 @@ 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; }
+41 -277
View File
@@ -4,8 +4,6 @@
--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);
@@ -24,166 +22,37 @@
.regia-page {
max-width: 480px;
margin: 0 auto;
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;
padding: 12px 16px calc(24px + var(--regia-safe-bottom));
}
.regia-header h1 {
margin: 0;
font-size: 1rem;
font-size: 1.1rem;
font-weight: 700;
line-height: 1.25;
}
.regia-header p {
margin: 2px 0 0;
font-size: 0.78rem;
margin: 4px 0 0;
font-size: 0.85rem;
color: var(--regia-muted);
}
.regia-badge {
display: inline-block;
margin-top: 0;
padding: 3px 8px;
margin-top: 8px;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.65rem;
font-size: 0.7rem;
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: 0;
margin: 14px 0;
border-radius: 12px;
overflow: hidden;
background: #000;
@@ -221,29 +90,29 @@
.regia-scoreboard {
background: var(--regia-surface);
border-radius: 12px;
padding: 10px 12px;
margin-bottom: 8px;
padding: 16px;
margin-bottom: 12px;
}
.regia-sets {
text-align: center;
font-size: 0.78rem;
font-size: 0.82rem;
color: var(--regia-muted);
margin: 0 0 4px;
margin: 0 0 8px;
}
.regia-score-line {
text-align: center;
font-size: 1.2rem;
font-size: 1.5rem;
font-weight: 800;
margin: 0;
}
.regia-partials {
text-align: center;
font-size: 0.75rem;
font-size: 0.78rem;
color: var(--regia-muted);
margin: 4px 0 0;
margin: 8px 0 0;
}
.regia-team-row {
@@ -251,7 +120,7 @@
grid-template-columns: 1fr auto 1fr;
gap: 8px;
align-items: center;
margin-top: 8px;
margin-top: 16px;
}
.regia-team-name {
@@ -262,7 +131,7 @@
}
.regia-points {
font-size: 1.55rem;
font-size: 2rem;
font-weight: 800;
text-align: center;
}
@@ -277,7 +146,7 @@
.regia-btn {
border: none;
border-radius: 10px;
padding: 10px 8px;
padding: 16px 12px;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
@@ -287,8 +156,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: 8px; }
.regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 8px; padding: 12px; }
.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--outline {
background: transparent;
border: 1px solid #444;
@@ -304,6 +173,26 @@
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));
@@ -351,7 +240,7 @@
text-align: center;
color: var(--regia-muted);
font-size: 0.72rem;
padding-top: 8px;
padding-top: 24px;
}
.regia-clock-line {
@@ -366,128 +255,3 @@
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);
}
+5 -262
View File
@@ -37,32 +37,13 @@
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 ·",
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"
subtitlePrefix: "Regia ·"
};
function loadI18n() {
@@ -96,7 +77,6 @@
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,
@@ -122,28 +102,15 @@
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"),
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")
subtitle: document.getElementById("regia-subtitle")
};
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("?") ? "&" : "?";
@@ -172,43 +139,8 @@
}
}
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 || !previewWanted) return;
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed) return;
destroyPreview();
previewStarted = false;
startPreview();
@@ -311,10 +243,7 @@
if (!els.btnPause) return;
const paused = !!(data.paused || data.status === "paused");
streamPaused = paused;
const label = paused ? I18N.resumeLabel : I18N.pauseLabel;
els.btnPause.classList.toggle("is-paused", paused);
els.btnPause.setAttribute("aria-label", label);
els.btnPause.title = label;
els.btnPause.textContent = paused ? I18N.resumeLabel : I18N.pauseLabel;
}
function setBadge(data) {
@@ -435,11 +364,6 @@
}
const paused = data.paused || data.status === "paused";
if (!previewWanted) {
destroyPreview();
wasPausedPreview = paused;
return;
}
if (data.on_air) {
if (!previewStarted) {
startPreview();
@@ -458,119 +382,6 @@
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" } });
@@ -578,8 +389,6 @@
const data = await res.json();
applyScorePayload(data);
setBadge(data);
syncMinQuality(data);
syncTelemetry(data);
syncPreviewFromStatus(data);
} catch (_) {}
}
@@ -592,7 +401,7 @@
}
function startPreview() {
if (cfg.streamClosed || !previewWanted || !els.preview || !cfg.hlsUrl || previewStarted) return;
if (cfg.streamClosed || !els.preview || !cfg.hlsUrl || previewStarted) return;
previewStarted = true;
const url = hlsUrlFresh();
@@ -645,8 +454,6 @@
const wasPaused = streamPaused;
applyScorePayload(data);
setBadge(data);
syncMinQuality(data);
syncTelemetry(data);
syncPreviewFromStatus(data);
toast(wasPaused ? I18N.resumed : I18N.pausedCover);
}
@@ -666,70 +473,6 @@
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);
@@ -0,0 +1,18 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe YoutubeRelayStopJob, type: :job do
it "requeues when stop runs on the wrong host" do
session = instance_double(StreamSession, id: SecureRandom.uuid)
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
@@ -0,0 +1,37 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe StreamSession do
it "builds ingest/hls URLs from the assigned stream node" do
node = StreamNode.create!(
slug: "ingest-01",
hostname: "ingest-01.mltv-stream.net",
role: "cloud",
status: "ready",
provider: "hetzner",
rtmp_base_url: "rtmp://ingest-01.mltv-stream.net:1935",
hls_base_url: "https://ingest-01.mltv-stream.net/hls",
api_base_url: "http://10.0.0.2:9997",
internal_rtmp_url: "rtmp://10.0.0.2:1935",
max_publishers: 4,
max_relays: 4
)
user = User.create!(email: "s@example.com", name: "S", password: "Password123", role: "coach")
club = Club.create!(name: "Club", sport: "volleyball")
team = club.teams.create!(name: "Team", sport: "volleyball", slug: "team-url")
match = team.matches.create!(opponent_name: "Opp", scheduled_at: 1.hour.from_now)
session = StreamSession.create!(
match: match, user: user, platform: "matchlivetv", status: "idle", stream_node: node
)
expect(session.rtmp_ingest_url).to eq(
"rtmp://ingest-01.mltv-stream.net:1935/live/match_#{session.id}"
)
expect(session.hls_playback_url).to eq(
"https://ingest-01.mltv-stream.net/hls/live/match_#{session.id}/index.m3u8"
)
expect(session.mediamtx_api_base_url).to eq("http://10.0.0.2:9997")
expect(session.mediamtx_internal_rtmp_url).to eq("rtmp://10.0.0.2:1935")
end
end
+1 -47
View File
@@ -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,18 +12,7 @@ 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
@@ -167,39 +156,4 @@ 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,8 +20,11 @@ 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
@@ -36,6 +39,7 @@ 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
@@ -53,6 +57,7 @@ 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,6 +25,7 @@ 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(
@@ -52,6 +53,7 @@ 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(
@@ -65,7 +67,7 @@ RSpec.describe Ops::HealthChecks do
summary = described_class.new.summary
expect(summary[:status]).to eq("ok")
expect(summary[:checks].size).to eq(10)
expect(summary[:checks].size).to eq(11)
end
it "degraded quando la latenza p95 supera la soglia warning" do
@@ -11,11 +11,4 @@ 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
@@ -1,41 +0,0 @@
require "rails_helper"
RSpec.describe Sessions::SetMinQuality do
let!(:user) { User.create!(email: "abr@test.it", name: "U", password: "Password123", role: "coach") }
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
let!(:session) do
StreamSession.create!(
match: match,
user: user,
platform: "matchlivetv",
status: "live",
quality_preset: "720p_30_2.5mbps"
)
end
it "salva il pavimento e notifica i client" do
expect(SessionChannel).to receive(:broadcast_message).with(
session,
hash_including(action: "set_min_quality", min_quality_preset: "720p_30_1.5mbps")
)
described_class.new(session, preset: "720p_30_1.5mbps").call
expect(session.reload.min_quality_preset).to eq("720p_30_1.5mbps")
expect(session.stream_events.where(event_type: "quality_changed")).to exist
end
it "rifiuta 1080p se la sessione è 720p" do
expect {
described_class.new(session, preset: "1080p_30_4.5mbps").call
}.to raise_error(ArgumentError)
end
it "accetta auto come qualità minima" do
session.update!(min_quality_preset: "720p_30_1.5mbps")
described_class.new(session, preset: "auto").call
expect(session.reload.min_quality_preset).to eq("auto")
end
end
@@ -0,0 +1,227 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Streams::Autoscaler do
def with_env(vars)
previous = vars.keys.index_with { |k| ENV[k] }
vars.each { |k, v| ENV[k] = v }
yield
ensure
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
end
let(:redis) { Redis.new(url: ENV.fetch("REDIS_URL", "redis://redis:6379/0")) }
before do
redis.del(Streams::Autoscaler::LOCK_KEY)
redis.del(Streams::Autoscaler::KILL_SWITCH_KEY)
redis.del(Streams::DnsProviders::Lab::REDIS_KEY)
StreamSession.where.not(stream_node_id: nil).update_all(stream_node_id: nil, status: "ended", ended_at: Time.current)
StreamNode.where.not(slug: Streams::NodeRegistry::HOME_SLUG).delete_all
end
it "is a no-op when disabled" do
with_env("STREAM_AUTOSCALE_ENABLED" => "0") do
result = described_class.reconcile!
expect(result.skipped).to eq(true)
expect(result.actions).to eq([])
end
end
it "scales out and creates a warm spare when free slots are low" do
with_env(
"STREAM_AUTOSCALE_ENABLED" => "1",
"STREAM_AUTOSCALE_SOFT_FREE_SLOTS" => "2",
"STREAM_AUTOSCALE_WARM_SPARE" => "1",
"STREAM_AUTOSCALE_KIND" => "lab",
"STREAM_AUTOSCALE_MAX_NODES" => "5",
"STREAM_CLOUD_PROVIDER" => "local_lab",
"STREAM_DNS_PROVIDER" => "lab",
"STREAM_NODE_HOME_MAX_PUBLISHERS" => "2",
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
"MEDIAMTX_RTMP_URL" => "rtmp://home.example:1935",
"HLS_PUBLIC_URL" => "https://home.example/hls"
) do
home = Streams::NodeRegistry.ensure_home_from_env!
user = User.create!(email: "as@example.com", name: "A", password: "Password123", role: "coach")
club = Club.create!(name: "AS", sport: "volleyball")
team = club.teams.create!(name: "T", sport: "volleyball", slug: "as-t")
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
# Fill home (2/2) → free_slots=0
2.times do
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home)
end
provisioner = instance_double(Streams::NodeProvisioner)
created = []
allow(provisioner).to receive(:provision_lab!) do
node = StreamNode.create!(
slug: "ingest-lab-#{created.size + 1}",
hostname: "h#{created.size}.lab",
role: "lab",
status: "ready",
provider: "local",
rtmp_base_url: "rtmp://h:1935",
hls_base_url: "https://h/hls",
api_base_url: "http://h:9997",
max_publishers: 2,
max_relays: 2
)
created << node
node
end
result = described_class.reconcile!(provisioner: provisioner)
expect(result.actions).to include(:scale_out)
expect(created.size).to eq(1)
expect(result.metrics[:free_slots]).to be >= 2
# Riempi il nodo overflow → spare_ready=0 ma carico presente → warm spare
StreamSession.create!(
match: match, user: user, platform: "matchlivetv", status: "live", stream_node: created.first
)
StreamSession.create!(
match: match, user: user, platform: "matchlivetv", status: "live", stream_node: created.first
)
result2 = described_class.reconcile!(provisioner: provisioner)
expect(result2.actions).to include(:warm_spare).or include(:scale_out)
expect(created.size).to be >= 2
end
end
it "does not scale in a node provisioned in the same reconcile round" do
with_env(
"STREAM_AUTOSCALE_ENABLED" => "1",
"STREAM_AUTOSCALE_SOFT_FREE_SLOTS" => "2",
"STREAM_AUTOSCALE_WARM_SPARE" => "0",
"STREAM_AUTOSCALE_IDLE_MINUTES" => "0",
"STREAM_AUTOSCALE_KIND" => "lab",
"STREAM_AUTOSCALE_MAX_NODES" => "5",
"STREAM_CLOUD_PROVIDER" => "local_lab",
"STREAM_DNS_PROVIDER" => "lab",
"STREAM_NODE_HOME_MAX_PUBLISHERS" => "2",
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
"MEDIAMTX_RTMP_URL" => "rtmp://home.example:1935",
"HLS_PUBLIC_URL" => "https://home.example/hls"
) do
home = Streams::NodeRegistry.ensure_home_from_env!
user = User.create!(email: "same@example.com", name: "S", password: "Password123", role: "coach")
club = Club.create!(name: "Same", sport: "volleyball")
team = club.teams.create!(name: "T", sport: "volleyball", slug: "same-t")
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
2.times do
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home)
end
provisioner = instance_double(Streams::NodeProvisioner)
created = nil
allow(provisioner).to receive(:provision_lab!) do
created = StreamNode.create!(
slug: "ingest-lab-new",
hostname: "new.lab",
role: "lab",
status: "ready",
provider: "local",
rtmp_base_url: "rtmp://h:1935",
hls_base_url: "https://h/hls",
api_base_url: "http://h:9997",
max_publishers: 4,
max_relays: 2
)
end
expect(provisioner).not_to receive(:decommission!)
result = described_class.reconcile!(provisioner: provisioner)
expect(result.actions).to include(:scale_out)
expect(result.actions.map(&:to_s)).not_to include("scale_in_ingest-lab-new")
expect(StreamNode.find_by(slug: "ingest-lab-new")).to be_present
end
end
it "scales in an idle overflow node when warm spare is not required" do
with_env(
"STREAM_AUTOSCALE_ENABLED" => "1",
"STREAM_AUTOSCALE_SOFT_FREE_SLOTS" => "0",
"STREAM_AUTOSCALE_WARM_SPARE" => "0",
"STREAM_AUTOSCALE_IDLE_MINUTES" => "0",
"STREAM_NODE_HOME_MAX_PUBLISHERS" => "6",
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
"MEDIAMTX_RTMP_URL" => "rtmp://home.example:1935",
"HLS_PUBLIC_URL" => "https://home.example/hls"
) do
Streams::NodeRegistry.ensure_home_from_env!
idle = StreamNode.create!(
slug: "ingest-lab-99",
hostname: "idle.lab",
role: "lab",
status: "ready",
provider: "local",
provider_instance_id: "sim-x",
rtmp_base_url: "rtmp://h:1935",
hls_base_url: "https://h/hls",
api_base_url: "http://h:9997",
max_publishers: 2,
max_relays: 2,
created_at: 2.hours.ago
)
provisioner = instance_double(Streams::NodeProvisioner)
allow(provisioner).to receive(:drain!)
expect(provisioner).to receive(:decommission!).with(idle)
result = described_class.reconcile!(provisioner: provisioner)
expect(result.actions.map(&:to_s)).to include("scale_in_ingest-lab-99")
end
end
it "skips when Redis kill-switch is engaged even if ENV is enabled" do
redis.del(described_class::KILL_SWITCH_KEY)
with_env("STREAM_AUTOSCALE_ENABLED" => "1") do
described_class.engage_kill_switch!
expect(described_class.enabled?).to eq(false)
result = described_class.reconcile!
expect(result.skipped).to eq(true)
ensure
described_class.clear_kill_switch!
end
end
it "blocks scale-out when next node would exceed monthly budget" do
with_env(
"STREAM_AUTOSCALE_ENABLED" => "1",
"STREAM_AUTOSCALE_SOFT_FREE_SLOTS" => "2",
"STREAM_AUTOSCALE_WARM_SPARE" => "0",
"STREAM_AUTOSCALE_KIND" => "lab",
"STREAM_AUTOSCALE_MAX_NODES" => "5",
"STREAM_AUTOSCALE_NODE_EUR_PER_HOUR" => "1",
"STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR" => "10",
"STREAM_CLOUD_PROVIDER" => "local_lab",
"STREAM_DNS_PROVIDER" => "lab",
"STREAM_NODE_HOME_MAX_PUBLISHERS" => "1",
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
"MEDIAMTX_RTMP_URL" => "rtmp://home.example:1935",
"HLS_PUBLIC_URL" => "https://home.example/hls"
) do
StreamSession.where.not(stream_node_id: nil).update_all(stream_node_id: nil, status: "ended", ended_at: Time.current)
StreamNode.where.not(slug: Streams::NodeRegistry::HOME_SLUG).delete_all
home = Streams::NodeRegistry.ensure_home_from_env!
home.update!(max_publishers: 1)
user = User.create!(email: "budget@example.com", name: "B", password: "Password123", role: "coach")
club = Club.create!(name: "Budget", sport: "volleyball")
team = club.teams.create!(name: "T", sport: "volleyball", slug: "budget-t")
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home)
provisioner = instance_double(Streams::NodeProvisioner)
expect(provisioner).not_to receive(:provision_lab!)
result = described_class.reconcile!(provisioner: provisioner)
expect(result.actions).to include(:blocked_capacity)
# 0 overflow nodes → stima 0€ ≤ budget; il blocco è sul nodo successivo (+1)
expect(described_class.within_budget?(0)).to eq(true)
expect(described_class.within_budget?(1)).to eq(false)
end
end
end
@@ -0,0 +1,82 @@
# 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
@@ -0,0 +1,35 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Streams::DnsProviders::Hetzner do
let(:stubs) { Faraday::Adapter::Test::Stubs.new }
let(:conn) do
Faraday.new(url: Streams::DnsProviders::Hetzner::API) do |f|
f.request :json
f.response :json
f.adapter :test, stubs
end
end
let(:provider) { described_class.new(token: "test-token", zone: "mltv-stream.net", conn: conn) }
after { stubs.verify_stubbed_calls }
it "upserts an A record (delete then create)" do
stubs.delete("https://api.hetzner.cloud/v1/zones/mltv-stream.net/rrsets/ingest-01/A") { [404, {}, ""] }
stubs.post("https://api.hetzner.cloud/v1/zones/mltv-stream.net/rrsets") do |env|
body = JSON.parse(env.body)
expect(body["name"]).to eq("ingest-01")
expect(body["type"]).to eq("A")
expect(body["records"].first["value"]).to eq("49.13.1.2")
[201, { "Content-Type" => "application/json" }, { "rrset" => { "name" => "ingest-01" } }]
end
expect(provider.upsert_a("ingest-01.mltv-stream.net", "49.13.1.2")).to eq(true)
end
it "deletes an A record" do
stubs.delete("https://api.hetzner.cloud/v1/zones/mltv-stream.net/rrsets/ingest-01/A") { [204, {}, ""] }
expect(provider.delete_a("ingest-01.mltv-stream.net")).to eq(true)
end
end
@@ -0,0 +1,41 @@
# 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
@@ -0,0 +1,66 @@
# 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
@@ -0,0 +1,133 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Streams::NodeRegistry do
def with_env(vars)
previous = vars.keys.index_with { |k| ENV[k] }
vars.each { |k, v| ENV[k] = v }
yield
ensure
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
end
let(:home_env) do
{
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
"MEDIAMTX_RTMP_URL" => "rtmp://ingest-home.example:1935",
"HLS_PUBLIC_URL" => "https://ingest-home.example/hls",
"MEDIAMTX_INTERNAL_RTMP_URL" => "rtmp://mtx-home:1935",
"MEDIAMTX_HLS_URL" => "http://mtx-home:8888",
"STREAM_NODE_HOME_MAX_PUBLISHERS" => "2"
}
end
before do
StreamSession.where.not(stream_node_id: nil).update_all(stream_node_id: nil, status: "ended", ended_at: Time.current)
StreamNode.where.not(slug: Streams::NodeRegistry::HOME_SLUG).delete_all
StreamNode.find_by(slug: Streams::NodeRegistry::HOME_SLUG)&.update!(max_publishers: 2, status: "ready")
end
it "creates home node from ENV" do
with_env(home_env) do
node = described_class.ensure_home_from_env!
expect(node.slug).to eq("home")
expect(node.rtmp_base_url).to eq("rtmp://ingest-home.example:1935")
expect(node.api_base_url).to eq("http://mtx-home:9997")
expect(node.max_publishers).to eq(2)
expect(node.status).to eq("ready")
end
end
it "does not reset home drain/offline/error status on ensure" do
with_env(home_env) do
home = described_class.ensure_home_from_env!
home.update!(status: "draining")
expect(described_class.ensure_home_from_env!.status).to eq("draining")
expect { described_class.allocate! }.to raise_error(Streams::NodeRegistry::NoCapacityError)
end
end
it "prefers home while it still has free slots even if cloud is idle" do
with_env(home_env) do
home = described_class.ensure_home_from_env!
StreamNode.create!(
slug: "ingest-01",
hostname: "ingest-01.mltv-stream.net",
role: "cloud",
status: "ready",
provider: "hetzner",
rtmp_base_url: "rtmp://ingest-01.mltv-stream.net:1935",
hls_base_url: "https://ingest-01.mltv-stream.net/hls",
api_base_url: "http://10.0.0.2:9997",
max_publishers: 2,
max_relays: 2
)
expect(described_class.allocate!).to eq(home)
end
end
it "allocates the least loaded cloud node when home is full" do
with_env(home_env) do
home = described_class.ensure_home_from_env!
cloud = StreamNode.create!(
slug: "ingest-01",
hostname: "ingest-01.mltv-stream.net",
role: "cloud",
status: "ready",
provider: "hetzner",
rtmp_base_url: "rtmp://ingest-01.mltv-stream.net:1935",
hls_base_url: "https://ingest-01.mltv-stream.net/hls",
api_base_url: "http://10.0.0.2:9997",
max_publishers: 2,
max_relays: 2
)
cloud_busy = StreamNode.create!(
slug: "ingest-02",
hostname: "ingest-02.mltv-stream.net",
role: "cloud",
status: "ready",
provider: "hetzner",
rtmp_base_url: "rtmp://ingest-02.mltv-stream.net:1935",
hls_base_url: "https://ingest-02.mltv-stream.net/hls",
api_base_url: "http://10.0.0.3:9997",
max_publishers: 2,
max_relays: 2
)
user = User.create!(email: "n@example.com", name: "N", password: "Password123", role: "coach")
club = Club.create!(name: "C", sport: "volleyball")
team = club.teams.create!(name: "T", sport: "volleyball", slug: "t-node")
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
match2 = team.matches.create!(opponent_name: "Y", scheduled_at: 2.hours.from_now)
# Riempie home (max 2)
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home)
StreamSession.create!(match: match2, user: user, platform: "matchlivetv", status: "live", stream_node: home)
# Un publisher già su ingest-02 → least-loaded = ingest-01
StreamSession.create!(
match: team.matches.create!(opponent_name: "Z", scheduled_at: 3.hours.from_now),
user: user, platform: "matchlivetv", status: "live", stream_node: cloud_busy
)
expect(described_class.allocate!).to eq(cloud)
end
end
it "raises when no capacity remains" do
with_env(home_env.merge("STREAM_NODE_HOME_MAX_PUBLISHERS" => "1")) do
home = described_class.ensure_home_from_env!
user = User.create!(email: "full@example.com", name: "F", password: "Password123", role: "coach")
club = Club.create!(name: "Full", sport: "volleyball")
team = club.teams.create!(name: "T", sport: "volleyball", slug: "t-full")
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
StreamSession.create!(
match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home
)
expect { described_class.allocate! }.to raise_error(Streams::NodeRegistry::NoCapacityError)
end
end
end
@@ -21,4 +21,46 @@ 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
+5 -3
View File
@@ -2,8 +2,8 @@
Documento di riferimento per l'intero sistema: rete, container Docker, flussi video, replay, monitoraggio ops e rilasci.
**Ultimo aggiornamento:** 2026-07-29
**Produzione:** `eminux@192.168.1.146``/opt/matchlivetv`
**Ultimo aggiornamento:** 2026-08-09
**Produzione:** `eminux@192.168.1.146``/opt/matchlivetv` (Proxmox; control plane attuale per autoscale)
---
@@ -397,7 +397,8 @@ 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/HETZNER_CLOUD_RELAY_OVERFLOW.md`](infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md) | Piano overflow relay YouTube su Hetzner Cloud (picchi) |
| [`infrastructure/STREAMING_AUTOSCALE.md`](infrastructure/STREAMING_AUTOSCALE.md) | **Design attuale:** Proxmox prod + Hetzner Cloud warm spare (MediaMTX+ffmpeg), lab, DNS, disco; Auction in futuro |
| [`infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md`](infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md) | Piano storico overflow-only relay YouTube (superseded dal doc sopra) |
| [`ANDROID_APP_LINKS.md`](ANDROID_APP_LINKS.md) | Digital Asset Links / deep link Play (`assetlinks.json`) |
| [`LIVE_STREAMING.md`](LIVE_STREAMING.md) | MediaMTX, pausa, HLS, ruolo ffmpeg |
| [`REPLAY_MODULE.md`](REPLAY_MODULE.md) | Garage, retention, YouTube VOD |
@@ -420,3 +421,4 @@ 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,10 +1,12 @@
# Piano: overflow relay YouTube su Hetzner Cloud
**Stato:** piano / design — non implementato
**Ultimo aggiornamento:** 2026-07-29
> **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)
**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: [`LIVE_STREAMING.md`](../LIVE_STREAMING.md), [`ARCHITECTURE.md`](../ARCHITECTURE.md), [`OPS_MONITORING.md`](../OPS_MONITORING.md).
Documenti correlati: [`STREAMING_AUTOSCALE.md`](STREAMING_AUTOSCALE.md), [`LIVE_STREAMING.md`](../LIVE_STREAMING.md), [`ARCHITECTURE.md`](../ARCHITECTURE.md), [`OPS_MONITORING.md`](../OPS_MONITORING.md).
---
+372
View File
@@ -0,0 +1,372 @@
# Autoscale streaming — Proxmox attuale + Hetzner Cloud
**Stato:** fasi 04 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 | &lt; 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 sullowner 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 allENV
- 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 sullhost 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
```
+109
View File
@@ -0,0 +1,109 @@
# 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
+32
View File
@@ -106,3 +106,35 @@ 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
+35
View File
@@ -0,0 +1,35 @@
# 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"
+2
View File
@@ -136,6 +136,7 @@ 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
@@ -231,6 +232,7 @@ 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
-2
View File
@@ -59,8 +59,6 @@ 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 -5
View File
@@ -5,14 +5,14 @@ set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
export COMPOSE_FILE="docker-compose.prod.yml"
export COMPOSE_FILE="${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..."
docker compose -f docker-compose.prod.yml --env-file .env up -d garage
echo "Avvio Garage (COMPOSE_FILE=${COMPOSE_FILE})..."
docker compose --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 -f docker-compose.prod.yml --env-file .env up -d rails sidekiq
docker compose --env-file .env up -d rails sidekiq
echo "Verifica storage:"
docker compose -f docker-compose.prod.yml --env-file .env exec -T rails bundle exec rails runner \
docker compose --env-file .env exec -T rails bundle exec rails runner \
'puts({local: MatchLiveTv.replay_storage_local?, backend: Recordings::Storage.new.backend_name}.inspect)'
+77
View File
@@ -0,0 +1,77 @@
#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]
+2 -2
View File
@@ -24,8 +24,8 @@ android {
applicationId = "com.matchlivetv.match_live_tv"
minSdk = 24
targetSdk = 36
versionCode = 31
versionName = "2.0.10-native"
versionCode = 32
versionName = "2.0.11-native"
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
?: "https://www.matchlivetv.it"
@@ -17,11 +17,16 @@ 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() {
@@ -32,30 +37,42 @@ class E2EWizardFlowTest {
@Test
fun login_newMatch_wizard_reachesBroadcastScreen() {
waitForAnyText("Email", "ACCEDI", timeoutMs = 45_000)
waitForAnyText(s(R.string.login_email), su(R.string.login_submit), timeoutMs = 45_000)
fillLogin()
waitForText("NUOVA PARTITA", timeoutMs = 45_000)
tapClickableText("NUOVA PARTITA")
waitForText("Avvia subito", timeoutMs = 15_000)
tapClickableText("Avvia subito")
waitForText("01 · Partita", timeoutMs = 45_000)
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)
scrollDown()
tapClickableText("AVANTI >")
waitForText("02 · Trasmissione", timeoutMs = 45_000)
waitForText("Piattaforma", timeoutMs = 30_000)
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)
scrollDown()
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)
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)
scrollDown()
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))
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),
),
)
}
private fun grantRuntimePermissions() {
@@ -69,11 +86,10 @@ class E2EWizardFlowTest {
}
private fun launchApp() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val intent = context.packageManager.getLaunchIntentForPackage(pkg)?.apply {
val intent = ctx.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)
ctx.startActivity(intent)
device.wait(Until.hasObject(By.pkg(pkg).depth(0)), 15_000)
}
@@ -81,8 +97,10 @@ 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()
}
@@ -111,6 +129,34 @@ 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()
@@ -1,71 +0,0 @@
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
}
}
@@ -1,265 +0,0 @@
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,17 +6,11 @@ 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 }
@@ -39,11 +33,9 @@ class MainActivity : ComponentActivity() {
setContent {
ProvideAppLocale {
MatchLiveTheme {
Box(Modifier.semantics { testTagsAsResourceId = true }) {
AppNavHost(container)
}
}
}
}
}
}
@@ -183,7 +183,6 @@ 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,
) {
@@ -202,7 +201,6 @@ 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(),
)
@@ -287,10 +285,6 @@ 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,
@@ -398,5 +392,4 @@ 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,
)
@@ -117,12 +117,6 @@ 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,
@@ -33,7 +33,6 @@ 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
@@ -110,7 +109,6 @@ 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()
@@ -2,7 +2,6 @@ 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
@@ -39,9 +38,6 @@ 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,
@@ -65,7 +61,6 @@ class SessionRepository(
currentBitrate: Int? = null,
targetBitrate: Int? = null,
fps: Int? = null,
thermalState: String? = null,
) {
api.postTelemetry(
sessionId,
@@ -77,7 +72,6 @@ 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