Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baac3a512b | ||
|
|
559284f0b2 | ||
|
|
e5fc925bae | ||
|
|
318a319608 | ||
|
|
0b55d5a8fa | ||
|
|
c3878cdc6d |
@@ -55,8 +55,6 @@ class SessionChannel < ApplicationCable::Channel
|
|||||||
Sessions::Pause.new(session).call
|
Sessions::Pause.new(session).call
|
||||||
when "resume_stream"
|
when "resume_stream"
|
||||||
Sessions::Resume.new(session).call
|
Sessions::Resume.new(session).call
|
||||||
when "set_min_quality"
|
|
||||||
Sessions::SetMinQuality.new(session, preset: data["min_quality_preset"]).call
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Admin
|
||||||
|
class StreamNodesController < Admin::BaseController
|
||||||
|
def index
|
||||||
|
Streams::NodeRegistry.ensure_home_from_env!
|
||||||
|
@nodes = StreamNode.order(:role, :slug)
|
||||||
|
@dns_provider = ENV.fetch("STREAM_DNS_PROVIDER", "lab")
|
||||||
|
@cloud_provider = ENV.fetch("STREAM_CLOUD_PROVIDER", "local_lab")
|
||||||
|
@lab_hosts = lab_dns_snippet
|
||||||
|
@hetzner_configured = ENV["HCLOUD_TOKEN"].present?
|
||||||
|
@autoscale_metrics = Streams::Autoscaler.metrics
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
kind = params[:kind].to_s
|
||||||
|
node =
|
||||||
|
if kind == "cloud"
|
||||||
|
Streams::NodeProvisioner.new.provision_cloud!
|
||||||
|
else
|
||||||
|
Streams::NodeProvisioner.new.provision_lab!
|
||||||
|
end
|
||||||
|
redirect_to admin_stream_nodes_path,
|
||||||
|
notice: t("admin.flash.stream_node_created", slug: node.slug)
|
||||||
|
rescue Streams::NodeProvisioner::Error, Streams::CloudProviders::Error, Streams::DnsProviders::Error,
|
||||||
|
KeyError => e
|
||||||
|
redirect_to admin_stream_nodes_path, alert: e.message
|
||||||
|
end
|
||||||
|
|
||||||
|
def drain
|
||||||
|
node = StreamNode.find(params[:id])
|
||||||
|
Streams::NodeProvisioner.new.drain!(node)
|
||||||
|
redirect_to admin_stream_nodes_path, notice: t("admin.flash.stream_node_draining", slug: node.slug)
|
||||||
|
rescue Streams::NodeProvisioner::Error => e
|
||||||
|
redirect_to admin_stream_nodes_path, alert: e.message
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
node = StreamNode.find(params[:id])
|
||||||
|
Streams::NodeProvisioner.new.decommission!(node)
|
||||||
|
redirect_to admin_stream_nodes_path, notice: t("admin.flash.stream_node_destroyed", slug: node.slug)
|
||||||
|
rescue Streams::NodeProvisioner::BusyError, Streams::NodeProvisioner::Error,
|
||||||
|
Streams::CloudProviders::Error, Streams::DnsProviders::Error => e
|
||||||
|
redirect_to admin_stream_nodes_path, alert: e.message
|
||||||
|
end
|
||||||
|
|
||||||
|
def kill_switch
|
||||||
|
Streams::Autoscaler.engage_kill_switch!
|
||||||
|
redirect_to admin_stream_nodes_path, notice: t("admin.flash.autoscale_kill_on")
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_kill_switch
|
||||||
|
Streams::Autoscaler.clear_kill_switch!
|
||||||
|
redirect_to admin_stream_nodes_path, notice: t("admin.flash.autoscale_kill_off")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def lab_dns_snippet
|
||||||
|
return "" unless @dns_provider == "lab"
|
||||||
|
|
||||||
|
Streams::DnsProviders::Lab.new.hosts_file_snippet
|
||||||
|
rescue Redis::BaseError
|
||||||
|
""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -68,7 +68,6 @@ module Api
|
|||||||
current_bitrate: params[:current_bitrate],
|
current_bitrate: params[:current_bitrate],
|
||||||
target_bitrate: params[:target_bitrate],
|
target_bitrate: params[:target_bitrate],
|
||||||
fps: params[:fps],
|
fps: params[:fps],
|
||||||
thermal_state: sanitized_thermal_state,
|
|
||||||
last_seen_at: Time.current
|
last_seen_at: Time.current
|
||||||
)
|
)
|
||||||
sync_publisher_when_streaming!(params[:fps].to_f)
|
sync_publisher_when_streaming!(params[:fps].to_f)
|
||||||
@@ -142,13 +141,6 @@ module Api
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def min_quality
|
|
||||||
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
|
|
||||||
render json: session_json(@session)
|
|
||||||
rescue ArgumentError => e
|
|
||||||
render json: { error: e.message }, status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
|
|
||||||
def youtube_stats
|
def youtube_stats
|
||||||
count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present?
|
count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present?
|
||||||
Youtube::BroadcastService.new(@session.match.team)
|
Youtube::BroadcastService.new(@session.match.team)
|
||||||
@@ -168,11 +160,6 @@ module Api
|
|||||||
.find(params[:id])
|
.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def sanitized_thermal_state
|
|
||||||
value = params[:thermal_state].to_s.downcase.presence
|
|
||||||
value if value.in?(%w[nominal fair serious critical])
|
|
||||||
end
|
|
||||||
|
|
||||||
def session_params
|
def session_params
|
||||||
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel)
|
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel)
|
||||||
end
|
end
|
||||||
@@ -193,6 +180,7 @@ module Api
|
|||||||
platform: session.platform,
|
platform: session.platform,
|
||||||
rtmp_ingest_url: session.rtmp_ingest_url,
|
rtmp_ingest_url: session.rtmp_ingest_url,
|
||||||
hls_playback_url: session.hls_playback_url,
|
hls_playback_url: session.hls_playback_url,
|
||||||
|
stream_node: session.stream_node&.slug,
|
||||||
watch_page_url: session.matchlivetv_platform? ? session.watch_page_url : nil,
|
watch_page_url: session.matchlivetv_platform? ? session.watch_page_url : nil,
|
||||||
share_url: session.share_url,
|
share_url: session.share_url,
|
||||||
youtube_watch_url: session.youtube_watch_url,
|
youtube_watch_url: session.youtube_watch_url,
|
||||||
@@ -200,7 +188,6 @@ module Api
|
|||||||
youtube_ready: session.youtube_ready?,
|
youtube_ready: session.youtube_ready?,
|
||||||
privacy_status: session.privacy_status,
|
privacy_status: session.privacy_status,
|
||||||
quality_preset: session.quality_preset,
|
quality_preset: session.quality_preset,
|
||||||
min_quality_preset: session.min_quality_preset,
|
|
||||||
target_bitrate: session.target_bitrate,
|
target_bitrate: session.target_bitrate,
|
||||||
started_at: session.started_at,
|
started_at: session.started_at,
|
||||||
disconnection_count: session.disconnection_count
|
disconnection_count: session.disconnection_count
|
||||||
|
|||||||
@@ -11,8 +11,17 @@ module Public
|
|||||||
load_club_billing_context_for_pricing
|
load_club_billing_context_for_pricing
|
||||||
end
|
end
|
||||||
|
|
||||||
def support
|
private
|
||||||
@app_store_review_chrome = true
|
|
||||||
|
def load_club_billing_context_for_pricing
|
||||||
|
return unless logged_in?
|
||||||
|
|
||||||
|
@club = current_user.primary_club
|
||||||
|
return unless @club
|
||||||
|
|
||||||
|
@subscription = @club.subscription
|
||||||
|
@team = @club.teams.order(:name).first
|
||||||
|
@entitlements = @team&.entitlements
|
||||||
end
|
end
|
||||||
|
|
||||||
def privacy
|
def privacy
|
||||||
@@ -29,18 +38,5 @@ module Public
|
|||||||
|
|
||||||
def pallavolo
|
def pallavolo
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def load_club_billing_context_for_pricing
|
|
||||||
return unless logged_in?
|
|
||||||
|
|
||||||
@club = current_user.primary_club
|
|
||||||
return unless @club
|
|
||||||
|
|
||||||
@subscription = @club.subscription
|
|
||||||
@team = @club.teams.order(:name).first
|
|
||||||
@entitlements = @team&.entitlements
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ module Public
|
|||||||
layout "regia"
|
layout "regia"
|
||||||
|
|
||||||
protect_from_forgery with: :null_session
|
protect_from_forgery with: :null_session
|
||||||
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop min_quality]
|
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop]
|
||||||
|
|
||||||
before_action :set_session_from_token
|
before_action :set_session_from_token
|
||||||
|
|
||||||
@@ -61,20 +61,8 @@ module Public
|
|||||||
render json: status_payload
|
render json: status_payload
|
||||||
end
|
end
|
||||||
|
|
||||||
def min_quality
|
|
||||||
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
|
|
||||||
render json: status_payload
|
|
||||||
rescue ArgumentError => e
|
|
||||||
render json: status_payload.merge(error: e.message), status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def camera_telemetry_payload(publisher_online)
|
|
||||||
cam = @session.device_states.find_by(device_role: "camera")
|
|
||||||
(cam&.as_regia_payload || {}).merge(publisher_online: publisher_online)
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_session_from_token
|
def set_session_from_token
|
||||||
@session = Sessions::RegiaAccess.find_session_by_token(params[:token])
|
@session = Sessions::RegiaAccess.find_session_by_token(params[:token])
|
||||||
return if @session
|
return if @session
|
||||||
@@ -98,10 +86,7 @@ module Public
|
|||||||
home_name: @session.match.team.name,
|
home_name: @session.match.team.name,
|
||||||
away_name: @session.match.opponent_name,
|
away_name: @session.match.opponent_name,
|
||||||
points_target: Scoring::Rules.points_target_for_match(@session.match, current_set: score&.current_set || 1),
|
points_target: Scoring::Rules.points_target_for_match(@session.match, current_set: score&.current_set || 1),
|
||||||
score: score&.as_cable_payload,
|
score: score&.as_cable_payload
|
||||||
min_quality_preset: @session.min_quality_preset,
|
|
||||||
min_quality_options: Sessions::SelectQuality.allowed_min_ids(@session.quality_preset),
|
|
||||||
telemetry: camera_telemetry_payload(publisher_online)
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ module Public
|
|||||||
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
|
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
|
||||||
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
|
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
|
||||||
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
|
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
|
||||||
{ loc: "#{base}/support", changefreq: "yearly", priority: "0.3" },
|
|
||||||
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
|
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
|
||||||
{ loc: "#{base}/termini", changefreq: "yearly", priority: "0.3" }
|
{ loc: "#{base}/termini", changefreq: "yearly", priority: "0.3" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -54,34 +54,15 @@ module Public
|
|||||||
copyPrompt: t("regia.js.copy_prompt"),
|
copyPrompt: t("regia.js.copy_prompt"),
|
||||||
shareRegiaText: t("regia.js.share_regia_text"),
|
shareRegiaText: t("regia.js.share_regia_text"),
|
||||||
shareLiveText: t("regia.js.share_live_text"),
|
shareLiveText: t("regia.js.share_live_text"),
|
||||||
minQualityUpdated: t("regia.js.min_quality_updated"),
|
|
||||||
minQualityError: t("regia.js.min_quality_error"),
|
|
||||||
minQualityAuto: t("regia.min_quality.auto"),
|
|
||||||
previewWaiting: t("regia.preview_waiting"),
|
previewWaiting: t("regia.preview_waiting"),
|
||||||
streamEnded: t("regia.preview_ended"),
|
streamEnded: t("regia.preview_ended"),
|
||||||
previewHide: t("regia.preview_hide"),
|
|
||||||
previewShow: t("regia.preview_show"),
|
|
||||||
previewOffHint: t("regia.preview_off_hint"),
|
|
||||||
resumeLabel: t("regia.resume"),
|
resumeLabel: t("regia.resume"),
|
||||||
pauseLabel: t("regia.pause"),
|
pauseLabel: t("regia.pause"),
|
||||||
endedBadge: t("regia.status.ended"),
|
endedBadge: t("regia.status.ended"),
|
||||||
pausedBadge: t("regia.status.paused"),
|
pausedBadge: t("regia.status.paused"),
|
||||||
liveBadge: t("regia.status.live"),
|
liveBadge: t("regia.status.live"),
|
||||||
waitingBadge: t("regia.status.waiting"),
|
waitingBadge: t("regia.status.waiting"),
|
||||||
subtitlePrefix: t("regia.subtitle_prefix"),
|
subtitlePrefix: t("regia.subtitle_prefix")
|
||||||
telemetryWaiting: t("regia.telemetry.waiting"),
|
|
||||||
telemetryStale: t("regia.telemetry.stale"),
|
|
||||||
telemetryJustNow: t("regia.telemetry.just_now"),
|
|
||||||
telemetrySecondsAgo: t("regia.telemetry.seconds_ago"),
|
|
||||||
telemetryPublisherOn: t("regia.telemetry.publisher_on"),
|
|
||||||
telemetryPublisherOff: t("regia.telemetry.publisher_off"),
|
|
||||||
telemetryThermalNominal: t("regia.telemetry.thermal_nominal"),
|
|
||||||
telemetryThermalFair: t("regia.telemetry.thermal_fair"),
|
|
||||||
telemetryThermalSerious: t("regia.telemetry.thermal_serious"),
|
|
||||||
telemetryThermalCritical: t("regia.telemetry.thermal_critical"),
|
|
||||||
telemetryBitrateMax: t("regia.telemetry.bitrate_max"),
|
|
||||||
telemetryBitrateTitle: t("regia.telemetry.bitrate_title"),
|
|
||||||
telemetryBitrateTap: t("regia.telemetry.bitrate_tap")
|
|
||||||
}.to_json
|
}.to_json
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ class CleanupExpiredSessionsJob
|
|||||||
.where("updated_at < ?", 6.hours.ago)
|
.where("updated_at < ?", 6.hours.ago)
|
||||||
.find_each do |session|
|
.find_each do |session|
|
||||||
session.fail! if session.may_fail?
|
session.fail! if session.may_fail?
|
||||||
Mediamtx::Client.new.delete_path(session)
|
Mediamtx::Client.for_session(session).delete_path(session)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Streams
|
||||||
|
class AutoscalerJob
|
||||||
|
include Sidekiq::Job
|
||||||
|
|
||||||
|
sidekiq_options retry: 1, queue: "default"
|
||||||
|
|
||||||
|
INTERVAL_SECS = ENV.fetch("STREAM_AUTOSCALE_INTERVAL_SECS", "60").to_i
|
||||||
|
REDIS_CHAIN_KEY = "streams:autoscaler:chain"
|
||||||
|
|
||||||
|
def self.ensure_chain
|
||||||
|
return unless redis
|
||||||
|
return if redis.get(REDIS_CHAIN_KEY)
|
||||||
|
|
||||||
|
redis.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL_SECS * 2)
|
||||||
|
perform_in(INTERVAL_SECS)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.redis
|
||||||
|
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||||
|
rescue Redis::CannotConnectError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform
|
||||||
|
Streams::Autoscaler.reconcile!
|
||||||
|
ensure
|
||||||
|
self.class.redis&.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL_SECS * 2)
|
||||||
|
self.class.perform_in(INTERVAL_SECS)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Avvia/riavvia il relay YouTube solo nel container sidekiq (YOUTUBE_RELAY_WORKER=1).
|
# Avvia/riavvia il relay YouTube solo sui worker con YOUTUBE_RELAY_WORKER=1 (coda youtube_relay).
|
||||||
class YoutubeRelayEnsureJob < ApplicationJob
|
class YoutubeRelayEnsureJob < ApplicationJob
|
||||||
queue_as :default
|
queue_as Streams::YoutubeRelay::QUEUE
|
||||||
|
|
||||||
def perform(session_id)
|
def perform(session_id)
|
||||||
session = StreamSession.find_by(id: session_id)
|
session = StreamSession.find_by(id: session_id)
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
|
# Ferma il relay solo sull'owner. Se il job gira su un altro host, requeue breve.
|
||||||
class YoutubeRelayStopJob < ApplicationJob
|
class YoutubeRelayStopJob < ApplicationJob
|
||||||
queue_as :default
|
queue_as Streams::YoutubeRelay::QUEUE
|
||||||
|
|
||||||
def perform(session_id)
|
discard_on ActiveJob::DeserializationError
|
||||||
|
|
||||||
|
def perform(session_id, attempts = 0)
|
||||||
session = StreamSession.find_by(id: session_id)
|
session = StreamSession.find_by(id: session_id)
|
||||||
return unless session
|
return unless session
|
||||||
|
|
||||||
Streams::YoutubeRelay.stop_on_worker!(session) if Streams::YoutubeRelay.worker?
|
unless Streams::YoutubeRelay.worker?
|
||||||
|
# Solo i worker relay processano questa coda in modo utile.
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
result = Streams::YoutubeRelay.stop_on_worker!(session)
|
||||||
|
return unless result == :wrong_host
|
||||||
|
return if attempts >= 30
|
||||||
|
|
||||||
|
self.class.set(wait: 2.seconds).perform_later(session_id, attempts + 1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -16,17 +16,4 @@ class DeviceState < ApplicationRecord
|
|||||||
status: stream_session.status
|
status: stream_session.status
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def as_regia_payload
|
|
||||||
{
|
|
||||||
battery: battery_level,
|
|
||||||
network: network_type,
|
|
||||||
signal: signal_strength,
|
|
||||||
bitrate: current_bitrate,
|
|
||||||
target_bitrate: target_bitrate,
|
|
||||||
fps: fps,
|
|
||||||
thermal_state: thermal_state,
|
|
||||||
last_seen_at: last_seen_at&.iso8601
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ module Ops
|
|||||||
|
|
||||||
KINDS = %w[
|
KINDS = %w[
|
||||||
disk_space recordings_size service_down http_public http_rails rails_latency
|
disk_space recordings_size service_down http_public http_rails rails_latency
|
||||||
sidekiq_stale sidekiq_dead log_pattern garage_storage
|
sidekiq_stale sidekiq_dead log_pattern garage_storage stream_overflow
|
||||||
].freeze
|
].freeze
|
||||||
SEVERITIES = %w[critical warning info].freeze
|
SEVERITIES = %w[critical warning info].freeze
|
||||||
STATUSES = %w[open acknowledged resolved].freeze
|
STATUSES = %w[open acknowledged resolved].freeze
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Nodo streaming (MediaMTX [+ relay ffmpeg]). Fase 0: registry + assignment URL.
|
||||||
|
class StreamNode < ApplicationRecord
|
||||||
|
ROLES = %w[home cloud lab].freeze
|
||||||
|
STATUSES = %w[provisioning ready draining offline error].freeze
|
||||||
|
PROVIDERS = %w[local proxmox_lab hetzner].freeze
|
||||||
|
|
||||||
|
has_many :stream_sessions, dependent: :nullify
|
||||||
|
|
||||||
|
validates :slug, presence: true, uniqueness: true
|
||||||
|
validates :hostname, presence: true
|
||||||
|
validates :role, inclusion: { in: ROLES }
|
||||||
|
validates :status, inclusion: { in: STATUSES }
|
||||||
|
validates :provider, inclusion: { in: PROVIDERS }
|
||||||
|
validates :rtmp_base_url, :hls_base_url, :api_base_url, presence: true
|
||||||
|
validates :max_publishers, :max_relays, numericality: { greater_than: 0 }
|
||||||
|
|
||||||
|
scope :ready, -> { where(status: "ready") }
|
||||||
|
scope :allocatable, -> { ready }
|
||||||
|
|
||||||
|
# Sessioni che occupano uno slot path MediaMTX su questo nodo.
|
||||||
|
def occupying_sessions
|
||||||
|
stream_sessions.where(status: %w[idle connecting live reconnecting paused])
|
||||||
|
end
|
||||||
|
|
||||||
|
def active_publishers
|
||||||
|
occupying_sessions.count
|
||||||
|
end
|
||||||
|
|
||||||
|
def free_slots
|
||||||
|
[max_publishers - active_publishers, 0].max
|
||||||
|
end
|
||||||
|
|
||||||
|
def allocatable?
|
||||||
|
status == "ready" && free_slots.positive?
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -7,6 +7,7 @@ class StreamSession < ApplicationRecord
|
|||||||
|
|
||||||
belongs_to :match
|
belongs_to :match
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
belongs_to :stream_node, optional: true
|
||||||
has_many :stream_events, dependent: :destroy
|
has_many :stream_events, dependent: :destroy
|
||||||
has_one :score_state, dependent: :destroy
|
has_one :score_state, dependent: :destroy
|
||||||
has_many :device_states, dependent: :destroy
|
has_many :device_states, dependent: :destroy
|
||||||
@@ -14,7 +15,6 @@ class StreamSession < ApplicationRecord
|
|||||||
validates :platform, inclusion: { in: PLATFORMS }
|
validates :platform, inclusion: { in: PLATFORMS }
|
||||||
validates :status, inclusion: { in: STATUSES }
|
validates :status, inclusion: { in: STATUSES }
|
||||||
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
|
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
|
||||||
validates :min_quality_preset, inclusion: { in: Sessions::SelectQuality::MIN_QUALITY_IDS }
|
|
||||||
|
|
||||||
before_validation :normalize_privacy_status
|
before_validation :normalize_privacy_status
|
||||||
before_validation :ensure_publish_token, on: :create
|
before_validation :ensure_publish_token, on: :create
|
||||||
@@ -75,7 +75,7 @@ class StreamSession < ApplicationRecord
|
|||||||
def rtmp_ingest_url
|
def rtmp_ingest_url
|
||||||
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
|
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
|
||||||
# MediaMTX path = live/match_{uuid} (no ?token= nel path).
|
# MediaMTX path = live/match_{uuid} (no ?token= nel path).
|
||||||
"#{MatchLiveTv.mediamtx_rtmp_url}/#{mediamtx_path_name}"
|
"#{rtmp_base_url.chomp('/')}/#{mediamtx_path_name}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def mediamtx_path_name
|
def mediamtx_path_name
|
||||||
@@ -91,8 +91,29 @@ class StreamSession < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def hls_playback_url
|
def hls_playback_url
|
||||||
base = MatchLiveTv.hls_public_url.chomp("/")
|
"#{hls_base_url.chomp('/')}/#{effective_hls_path_name}/index.m3u8"
|
||||||
"#{base}/#{effective_hls_path_name}/index.m3u8"
|
end
|
||||||
|
|
||||||
|
def rtmp_base_url
|
||||||
|
stream_node&.rtmp_base_url.presence || MatchLiveTv.mediamtx_rtmp_url
|
||||||
|
end
|
||||||
|
|
||||||
|
def hls_base_url
|
||||||
|
stream_node&.hls_base_url.presence || MatchLiveTv.hls_public_url
|
||||||
|
end
|
||||||
|
|
||||||
|
def mediamtx_api_base_url
|
||||||
|
stream_node&.api_base_url.presence || MatchLiveTv.mediamtx_api_url
|
||||||
|
end
|
||||||
|
|
||||||
|
def mediamtx_internal_rtmp_url
|
||||||
|
stream_node&.internal_rtmp_url.presence ||
|
||||||
|
ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935")
|
||||||
|
end
|
||||||
|
|
||||||
|
def mediamtx_internal_hls_url
|
||||||
|
stream_node&.internal_hls_url.presence ||
|
||||||
|
ENV.fetch("MEDIAMTX_HLS_URL", "http://mediamtx:8888")
|
||||||
end
|
end
|
||||||
|
|
||||||
def effective_hls_path_name
|
def effective_hls_path_name
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ module Mediamtx
|
|||||||
class Client
|
class Client
|
||||||
class Error < StandardError; end
|
class Error < StandardError; end
|
||||||
|
|
||||||
|
def self.for_session(session)
|
||||||
|
new(base_url: session.mediamtx_api_base_url)
|
||||||
|
end
|
||||||
|
|
||||||
def initialize(base_url: MatchLiveTv.mediamtx_api_url)
|
def initialize(base_url: MatchLiveTv.mediamtx_api_url)
|
||||||
|
@base_url = base_url
|
||||||
@conn = Faraday.new(url: base_url) do |f|
|
@conn = Faraday.new(url: base_url) do |f|
|
||||||
f.request :json
|
f.request :json
|
||||||
f.response :json
|
f.response :json
|
||||||
@@ -12,6 +17,8 @@ module Mediamtx
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
attr_reader :base_url
|
||||||
|
|
||||||
def create_path(session)
|
def create_path(session)
|
||||||
path = session.mediamtx_path_name
|
path = session.mediamtx_path_name
|
||||||
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe
|
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ module Mediamtx
|
|||||||
key = format("youtube_relay:sched:%s", session.id)
|
key = format("youtube_relay:sched:%s", session.id)
|
||||||
return unless redis.set(key, "1", nx: true, ex: 10)
|
return unless redis.set(key, "1", nx: true, ex: 10)
|
||||||
|
|
||||||
YoutubeRelayEnsureJob.perform_later(session.id)
|
YoutubeRelayEnsureJob.set(queue: Streams::YoutubeRelay::QUEUE).perform_later(session.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def redis
|
def redis
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ module Ops
|
|||||||
check_sidekiq_heartbeat,
|
check_sidekiq_heartbeat,
|
||||||
check_sidekiq_dead,
|
check_sidekiq_dead,
|
||||||
check_http_rails,
|
check_http_rails,
|
||||||
check_rails_latency
|
check_rails_latency,
|
||||||
|
check_stream_overflow
|
||||||
]
|
]
|
||||||
findings << check_http_public if public_check_due?
|
findings << check_http_public if public_check_due?
|
||||||
findings
|
findings
|
||||||
@@ -161,6 +162,58 @@ module Ops
|
|||||||
fail_finding("garage_storage", "warning", "garage_storage:head", "Garage storage non raggiungibile", e.message)
|
fail_finding("garage_storage", "warning", "garage_storage:head", "Garage storage non raggiungibile", e.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def check_stream_overflow
|
||||||
|
return ok_finding("stream_overflow:skip", "Nodi stream non migrati") unless ActiveRecord::Base.connection.data_source_exists?("stream_nodes")
|
||||||
|
|
||||||
|
orphan_hours = ENV.fetch("STREAM_OVERFLOW_ORPHAN_HOURS", "3").to_i
|
||||||
|
orphans = StreamNode.where.not(slug: "home").where(status: %w[ready draining]).select do |n|
|
||||||
|
n.active_publishers.zero? && n.created_at < orphan_hours.hours.ago
|
||||||
|
end
|
||||||
|
metrics = Streams::Autoscaler.metrics
|
||||||
|
over_budget = !metrics[:within_budget]
|
||||||
|
at_max = metrics[:overflow_nodes] >= metrics[:max_overflow_nodes] && metrics[:free_slots] <= metrics[:soft_free_slots]
|
||||||
|
|
||||||
|
if orphans.any?
|
||||||
|
return Finding.new(
|
||||||
|
kind: "stream_overflow",
|
||||||
|
severity: "warning",
|
||||||
|
healthy: false,
|
||||||
|
title: "Nodi stream overflow idle",
|
||||||
|
message: "#{orphans.size} nodo/i idle da >#{orphan_hours}h: #{orphans.map(&:slug).join(', ')}",
|
||||||
|
metadata: { "slugs" => orphans.map(&:slug) },
|
||||||
|
fingerprint: "stream_overflow:orphan_idle"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if over_budget
|
||||||
|
return Finding.new(
|
||||||
|
kind: "stream_overflow",
|
||||||
|
severity: "warning",
|
||||||
|
healthy: false,
|
||||||
|
title: "Budget overflow streaming",
|
||||||
|
message: "Stima €#{metrics[:estimated_monthly_eur]}/mese > budget €#{metrics[:monthly_budget_eur]}",
|
||||||
|
metadata: metrics.transform_keys(&:to_s),
|
||||||
|
fingerprint: "stream_overflow:budget"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if at_max
|
||||||
|
return Finding.new(
|
||||||
|
kind: "stream_overflow",
|
||||||
|
severity: "warning",
|
||||||
|
healthy: false,
|
||||||
|
title: "Capacità stream al massimo",
|
||||||
|
message: "overflow=#{metrics[:overflow_nodes]}/#{metrics[:max_overflow_nodes]} free_slots=#{metrics[:free_slots]}",
|
||||||
|
metadata: metrics.transform_keys(&:to_s),
|
||||||
|
fingerprint: "stream_overflow:at_max"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
ok_finding("stream_overflow:ok", "Overflow streaming OK")
|
||||||
|
rescue StandardError => e
|
||||||
|
fail_finding("stream_overflow", "warning", "stream_overflow:error", "Check overflow fallito", e.message)
|
||||||
|
end
|
||||||
|
|
||||||
def check_sidekiq_heartbeat
|
def check_sidekiq_heartbeat
|
||||||
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||||
last = redis.get(Ops::HealthMonitorJob::HEARTBEAT_KEY).to_i
|
last = redis.get(Ops::HealthMonitorJob::HEARTBEAT_KEY).to_i
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ module Recordings
|
|||||||
def cleanup_mediamtx_path(session)
|
def cleanup_mediamtx_path(session)
|
||||||
return unless session.status.in?(%w[ended error])
|
return unless session.status.in?(%w[ended error])
|
||||||
|
|
||||||
Mediamtx::Client.new.delete_path(session)
|
Mediamtx::Client.for_session(session).delete_path(session)
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
@logger.warn("[Recordings::CleanupLocal] delete_path #{session.id}: #{e.message}")
|
@logger.warn("[Recordings::CleanupLocal] delete_path #{session.id}: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ module Recordings
|
|||||||
|
|
||||||
def cleanup_mediamtx_path
|
def cleanup_mediamtx_path
|
||||||
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
||||||
Mediamtx::Client.new.delete_path(@session)
|
Mediamtx::Client.for_session(@session).delete_path(@session)
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}")
|
Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -34,11 +34,19 @@ module Sessions
|
|||||||
end
|
end
|
||||||
|
|
||||||
StreamSession.transaction do
|
StreamSession.transaction do
|
||||||
|
session.stream_node = Streams::NodeRegistry.allocate!
|
||||||
session.save!
|
session.save!
|
||||||
Scoring::Engine.ensure_score_for(session)
|
Scoring::Engine.ensure_score_for(session)
|
||||||
mtx = Mediamtx::Client.new
|
Mediamtx::Client.for_session(session).create_path(session)
|
||||||
mtx.create_path(session)
|
log_event(
|
||||||
log_event(session, "pairing", { created: true, platform: session.platform })
|
session,
|
||||||
|
"pairing",
|
||||||
|
{
|
||||||
|
created: true,
|
||||||
|
platform: session.platform,
|
||||||
|
stream_node: session.stream_node&.slug
|
||||||
|
}
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
if session.platform == "youtube"
|
if session.platform == "youtube"
|
||||||
@@ -46,6 +54,8 @@ module Sessions
|
|||||||
end
|
end
|
||||||
|
|
||||||
session
|
session
|
||||||
|
rescue Streams::NodeRegistry::NoCapacityError => e
|
||||||
|
raise Teams::EntitlementError.new(e.message, code: "stream_capacity_exhausted")
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -9,12 +9,11 @@ module Sessions
|
|||||||
@session.pause! if @session.may_pause?
|
@session.pause! if @session.may_pause?
|
||||||
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
||||||
begin
|
begin
|
||||||
mtx = Mediamtx::Client.new
|
Mediamtx::Client.for_session(@session).set_path_recording(@session, enabled: false)
|
||||||
mtx.set_path_recording(@session, enabled: false)
|
|
||||||
mtx.set_always_available(@session, enabled: true)
|
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
Rails.logger.warn("[Sessions::Pause] MediaMTX: #{e.message}")
|
Rails.logger.warn("[Sessions::Pause] disable recording: #{e.message}")
|
||||||
end
|
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.
|
# Slate su path per HLS in pausa; RTMP telefono si ferma via comando app.
|
||||||
log_event("paused")
|
log_event("paused")
|
||||||
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
|
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ module Sessions
|
|||||||
class SelectQuality
|
class SelectQuality
|
||||||
UPLOAD_HEADROOM = 0.8
|
UPLOAD_HEADROOM = 0.8
|
||||||
UPLOAD_HEADROOM_1080P = 1.0
|
UPLOAD_HEADROOM_1080P = 1.0
|
||||||
AUTO_ID = "auto"
|
|
||||||
AUTO_FLOOR_BITRATE = 800_000
|
|
||||||
|
|
||||||
PRESETS = [
|
PRESETS = [
|
||||||
{ id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 },
|
{ id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 },
|
||||||
@@ -13,32 +11,6 @@ module Sessions
|
|||||||
{ id: "720p_30_1.5mbps", target_bitrate: 1_500_000, target_fps: 30 }
|
{ id: "720p_30_1.5mbps", target_bitrate: 1_500_000, target_fps: 30 }
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
MIN_QUALITY_IDS = ([AUTO_ID] + PRESETS.map { |preset| preset[:id] }).freeze
|
|
||||||
|
|
||||||
def self.floor_bitrate(preset_id)
|
|
||||||
return AUTO_FLOOR_BITRATE if preset_id.blank? || preset_id == AUTO_ID
|
|
||||||
|
|
||||||
PRESETS.find { |preset| preset[:id] == preset_id }&.fetch(:target_bitrate) || AUTO_FLOOR_BITRATE
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.allowed_min_ids(session_preset)
|
|
||||||
ids = [AUTO_ID] + PRESETS.reject { |preset| preset[:id].start_with?("1080p") }.reverse.map { |preset| preset[:id] }
|
|
||||||
ids << "1080p_30_4.5mbps" if session_preset.to_s.start_with?("1080p")
|
|
||||||
ids
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.display_name(preset_id)
|
|
||||||
return AUTO_ID if preset_id.blank? || preset_id == AUTO_ID
|
|
||||||
|
|
||||||
preset = PRESETS.find { |item| item[:id] == preset_id }
|
|
||||||
return preset_id unless preset
|
|
||||||
|
|
||||||
resolution = preset[:id].start_with?("1080p") ? "1080p" : "720p"
|
|
||||||
mbps = preset[:target_bitrate] / 1_000_000.0
|
|
||||||
mbps_text = (mbps == mbps.to_i) ? mbps.to_i.to_s : format("%.1f", mbps)
|
|
||||||
"#{resolution} · #{mbps_text} Mbps"
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.call(upload_mbps:)
|
def self.call(upload_mbps:)
|
||||||
new(upload_mbps: upload_mbps).call
|
new(upload_mbps: upload_mbps).call
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -33,7 +33,7 @@ module Sessions
|
|||||||
end
|
end
|
||||||
|
|
||||||
def remove_mediamtx_paths!
|
def remove_mediamtx_paths!
|
||||||
Mediamtx::Client.new.delete_path(@session)
|
Mediamtx::Client.for_session(@session).delete_path(@session)
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
Rails.logger.warn("[Sessions::Stop] delete_path #{@session.id}: #{e.message}")
|
Rails.logger.warn("[Sessions::Stop] delete_path #{@session.id}: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# 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
|
||||||
|
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)
|
||||||
|
|
||||||
|
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!
|
||||||
|
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
|
||||||
|
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,184 @@
|
|||||||
|
# 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 if user_data.present?
|
||||||
|
|
||||||
|
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
|
||||||
|
return File.read(path) if path && File.file?(path)
|
||||||
|
|
||||||
|
ENV["HCLOUD_USER_DATA"].presence
|
||||||
|
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,59 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Streams
|
||||||
|
# Assegna un StreamNode a una nuova sessione (least-loaded tra i 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!
|
||||||
|
|
||||||
|
node = StreamNode.ready
|
||||||
|
.to_a
|
||||||
|
.select(&:allocatable?)
|
||||||
|
.min_by { |n| [n.active_publishers, n.role == "home" ? 1 : 0, n.slug] }
|
||||||
|
|
||||||
|
raise NoCapacityError, "Nessun nodo streaming con slot liberi" if node.nil?
|
||||||
|
|
||||||
|
node
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def home_hostname
|
||||||
|
ENV["STREAM_NODE_HOME_HOSTNAME"].presence ||
|
||||||
|
begin
|
||||||
|
uri = URI.parse(MatchLiveTv.mediamtx_rtmp_url.sub(/\Artmps?:\/\//, "http://"))
|
||||||
|
uri.host.presence
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
nil
|
||||||
|
end || "home"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,29 +1,35 @@
|
|||||||
module Streams
|
module Streams
|
||||||
# Relay verso YouTube: legge RTMP/HLS da MediaMTX e inoltra su RTMPS (-c copy). Nessun overlay.
|
# 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 YoutubeRelay
|
||||||
class Error < StandardError; end
|
class Error < StandardError; end
|
||||||
|
|
||||||
REDIS_KEY = "youtube_relay:pid:%s"
|
REDIS_KEY = "youtube_relay:pid:%s"
|
||||||
OWNER_KEY = "youtube_relay:owner:%s"
|
OWNER_KEY = "youtube_relay:owner:%s"
|
||||||
|
OWNED_SET = "youtube_relay:owned:%s"
|
||||||
|
QUEUE = :youtube_relay
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def worker?
|
def worker?
|
||||||
ENV["YOUTUBE_RELAY_WORKER"] == "1"
|
ENV["YOUTUBE_RELAY_WORKER"] == "1"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def max_concurrent
|
||||||
|
ENV.fetch("RELAY_MAX_CONCURRENT", "4").to_i
|
||||||
|
end
|
||||||
|
|
||||||
def start(session)
|
def start(session)
|
||||||
return unless worker?
|
return unless worker?
|
||||||
|
|
||||||
start_on_worker!(session)
|
start_on_worker!(session)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Non cancella owner/pid qui: solo il worker owner deve killare ffmpeg.
|
||||||
def stop(session)
|
def stop(session)
|
||||||
clear_pid(session.id)
|
if worker? && owner_is_local?(session.id)
|
||||||
redis.del(format(OWNER_KEY, session.id))
|
|
||||||
if worker?
|
|
||||||
stop_on_worker!(session)
|
stop_on_worker!(session)
|
||||||
else
|
else
|
||||||
YoutubeRelayStopJob.perform_later(session.id)
|
YoutubeRelayStopJob.set(queue: QUEUE).perform_later(session.id)
|
||||||
end
|
end
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
@@ -31,11 +37,13 @@ module Streams
|
|||||||
def running?(session_id)
|
def running?(session_id)
|
||||||
owner = redis.get(format(OWNER_KEY, session_id))
|
owner = redis.get(format(OWNER_KEY, session_id))
|
||||||
pid = pid_for(session_id)
|
pid = pid_for(session_id)
|
||||||
|
return false if pid.blank? && owner.blank?
|
||||||
|
return false if pid.blank? && owner.present? && owner != worker_id && redis.ttl(format(OWNER_KEY, session_id)) <= 30
|
||||||
return false if pid.blank?
|
return false if pid.blank?
|
||||||
|
|
||||||
return process_alive?(pid) if owner.blank? || owner == worker_id
|
return process_alive?(pid) if owner.blank? || owner == worker_id
|
||||||
|
|
||||||
# Relay avviato in un altro container: consideralo attivo se il lock è recente.
|
# Relay su altro host: attivo se lock owner ancora fresco.
|
||||||
redis.ttl(format(OWNER_KEY, session_id)) > 30
|
redis.ttl(format(OWNER_KEY, session_id)) > 30
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -47,12 +55,12 @@ module Streams
|
|||||||
if worker?
|
if worker?
|
||||||
ensure_on_worker!(session)
|
ensure_on_worker!(session)
|
||||||
else
|
else
|
||||||
YoutubeRelayEnsureJob.perform_later(session.id)
|
YoutubeRelayEnsureJob.set(queue: QUEUE).perform_later(session.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def ensure_on_worker!(session)
|
def ensure_on_worker!(session)
|
||||||
return unless worker?
|
return :not_worker unless worker?
|
||||||
return unless session.platform == "youtube"
|
return unless session.platform == "youtube"
|
||||||
return if session.terminal?
|
return if session.terminal?
|
||||||
return if session.stream_key.blank?
|
return if session.stream_key.blank?
|
||||||
@@ -60,28 +68,64 @@ module Streams
|
|||||||
return unless intake_available?(session)
|
return unless intake_available?(session)
|
||||||
|
|
||||||
pid = pid_for(session.id)
|
pid = pid_for(session.id)
|
||||||
clear_pid(session.id) if pid.present? && !process_alive?(pid.to_i)
|
if pid.present? && !process_alive?(pid.to_i)
|
||||||
|
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
|
last_restart = redis.get(restart_debounce_key(session.id)).to_i
|
||||||
return if last_restart.positive? && (Time.now.to_i - last_restart) < 5
|
return :debounced if last_restart.positive? && (Time.now.to_i - last_restart) < 5
|
||||||
|
|
||||||
start_on_worker!(session)
|
start_on_worker!(session)
|
||||||
redis.set(restart_debounce_key(session.id), Time.now.to_i, ex: 300)
|
redis.set(restart_debounce_key(session.id), Time.now.to_i, ex: 300)
|
||||||
|
:started
|
||||||
rescue Error => e
|
rescue Error => e
|
||||||
Rails.logger.warn("[YoutubeRelay] ensure_on_worker session=#{session.id}: #{e.message}")
|
Rails.logger.warn("[YoutubeRelay] ensure_on_worker session=#{session.id}: #{e.message}")
|
||||||
|
:error
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Symbol] :stopped, :wrong_host, :noop
|
||||||
def stop_on_worker!(session)
|
def stop_on_worker!(session)
|
||||||
|
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)
|
pid = pid_for(session.id)
|
||||||
return false if pid.blank?
|
if pid.blank?
|
||||||
|
clear_local_ownership(session.id)
|
||||||
|
return :noop
|
||||||
|
end
|
||||||
|
|
||||||
terminate_pid(pid)
|
terminate_pid(pid)
|
||||||
clear_pid(session.id)
|
clear_local_ownership(session.id)
|
||||||
redis.del(format(OWNER_KEY, session.id))
|
Rails.logger.info("[YoutubeRelay] stopped pid=#{pid} session=#{session.id} worker=#{worker_id}")
|
||||||
Rails.logger.info("[YoutubeRelay] stopped pid=#{pid} session=#{session.id}")
|
:stopped
|
||||||
true
|
end
|
||||||
|
|
||||||
|
def local_owned_count
|
||||||
|
redis.scard(format(OWNED_SET, worker_id)).to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def at_capacity?
|
||||||
|
local_owned_count >= max_concurrent
|
||||||
|
end
|
||||||
|
|
||||||
|
def owner_is_local?(session_id)
|
||||||
|
owner = redis.get(format(OWNER_KEY, session_id))
|
||||||
|
owner.blank? || owner == worker_id
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -92,9 +136,9 @@ module Streams
|
|||||||
return if session.terminal?
|
return if session.terminal?
|
||||||
return unless intake_available?(session)
|
return unless intake_available?(session)
|
||||||
|
|
||||||
return pid_for(session.id).to_i if running?(session.id)
|
return pid_for(session.id).to_i if running?(session.id) && owner_is_local?(session.id)
|
||||||
|
|
||||||
stop_on_worker!(session) if pid_for(session.id).present?
|
stop_on_worker!(session) if pid_for(session.id).present? && owner_is_local?(session.id)
|
||||||
|
|
||||||
log_path = log_file(session)
|
log_path = log_file(session)
|
||||||
FileUtils.mkdir_p(File.dirname(log_path))
|
FileUtils.mkdir_p(File.dirname(log_path))
|
||||||
@@ -108,8 +152,8 @@ module Streams
|
|||||||
)
|
)
|
||||||
Process.detach(pid)
|
Process.detach(pid)
|
||||||
store_pid(session.id, pid)
|
store_pid(session.id, pid)
|
||||||
redis.set(format(OWNER_KEY, session.id), worker_id, ex: 48.hours.to_i)
|
claim_ownership!(session.id)
|
||||||
Rails.logger.info("[YoutubeRelay] started pid=#{pid} session=#{session.id} intake=#{intake_source.join(":")}")
|
Rails.logger.info("[YoutubeRelay] started pid=#{pid} session=#{session.id} intake=#{intake_source.join(":")} worker=#{worker_id}")
|
||||||
schedule_youtube_activate(session)
|
schedule_youtube_activate(session)
|
||||||
pid
|
pid
|
||||||
rescue Errno::ENOENT => e
|
rescue Errno::ENOENT => e
|
||||||
@@ -146,20 +190,20 @@ module Streams
|
|||||||
end
|
end
|
||||||
|
|
||||||
def mediamtx_intake_source(session)
|
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)
|
if Mediamtx::PublisherOnline.active?(session)
|
||||||
return [:rtmp, "#{base.chomp('/')}/#{session.mediamtx_path_name}"]
|
return [:rtmp, "#{base.chomp('/')}/#{session.mediamtx_path_name}"]
|
||||||
end
|
end
|
||||||
|
|
||||||
path = session.mediamtx_path_name
|
path = session.mediamtx_path_name
|
||||||
hls = ENV.fetch("MEDIAMTX_HLS_URL", "http://mediamtx:8888").chomp("/")
|
hls = session.mediamtx_internal_hls_url.chomp("/")
|
||||||
[:hls, "#{hls}/#{path}/index.m3u8"]
|
[:hls, "#{hls}/#{path}/index.m3u8"]
|
||||||
end
|
end
|
||||||
|
|
||||||
def intake_available?(session)
|
def intake_available?(session)
|
||||||
return true if Mediamtx::PublisherOnline.active?(session)
|
return true if Mediamtx::PublisherOnline.active?(session)
|
||||||
|
|
||||||
info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == session.mediamtx_path_name }
|
info = Mediamtx::Client.for_session(session).list_paths.find { |i| i["name"] == session.mediamtx_path_name }
|
||||||
info && (info["ready"] || info["online"] || info["available"])
|
info && (info["ready"] || info["online"] || info["available"])
|
||||||
rescue StandardError
|
rescue StandardError
|
||||||
false
|
false
|
||||||
@@ -185,6 +229,22 @@ module Streams
|
|||||||
format("youtube_relay:debounce:%s", session_id)
|
format("youtube_relay:debounce:%s", session_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def claim_ownership!(session_id)
|
||||||
|
redis.set(format(OWNER_KEY, session_id), worker_id, ex: 48.hours.to_i)
|
||||||
|
redis.sadd(format(OWNED_SET, worker_id), session_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def touch_owner!(session_id)
|
||||||
|
redis.expire(format(OWNER_KEY, session_id), 48.hours.to_i)
|
||||||
|
redis.expire(format(REDIS_KEY, session_id), 48.hours.to_i)
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_local_ownership(session_id)
|
||||||
|
redis.del(format(REDIS_KEY, session_id))
|
||||||
|
redis.del(format(OWNER_KEY, session_id))
|
||||||
|
redis.srem(format(OWNED_SET, worker_id), session_id)
|
||||||
|
end
|
||||||
|
|
||||||
def store_pid(session_id, pid)
|
def store_pid(session_id, pid)
|
||||||
redis.set(format(REDIS_KEY, session_id), pid, ex: 48.hours.to_i)
|
redis.set(format(REDIS_KEY, session_id), pid, ex: 48.hours.to_i)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -68,13 +68,13 @@ module Webhooks
|
|||||||
def enable_recording(session)
|
def enable_recording(session)
|
||||||
return unless session.match.team.entitlements.recording_enabled_for_mediamtx?
|
return unless session.match.team.entitlements.recording_enabled_for_mediamtx?
|
||||||
|
|
||||||
Mediamtx::Client.new.set_path_recording(session, enabled: true)
|
Mediamtx::Client.for_session(session).set_path_recording(session, enabled: true)
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
Rails.logger.warn("[MediamtxHandler] enable recording: #{e.message}")
|
Rails.logger.warn("[MediamtxHandler] enable recording: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|
||||||
def disable_recording(session)
|
def disable_recording(session)
|
||||||
Mediamtx::Client.new.set_path_recording(session, enabled: false)
|
Mediamtx::Client.for_session(session).set_path_recording(session, enabled: false)
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
Rails.logger.warn("[MediamtxHandler] disable recording: #{e.message}")
|
Rails.logger.warn("[MediamtxHandler] disable recording: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ module Youtube
|
|||||||
return
|
return
|
||||||
end
|
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.go_live! if session.may_go_live?
|
||||||
session.reconnect! if session.reconnecting? && session.may_reconnect?
|
session.reconnect! if session.reconnecting? && session.may_reconnect?
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<% content_for :body_class, "admin-body" %>
|
||||||
|
<h2><%= t("admin.stream_nodes.title") %></h2>
|
||||||
|
<p class="admin-muted">
|
||||||
|
<%= t("admin.stream_nodes.providers", cloud: @cloud_provider, dns: @dns_provider) %>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="admin-muted">
|
||||||
|
<%= t(
|
||||||
|
"admin.stream_nodes.autoscale",
|
||||||
|
enabled: (@autoscale_metrics[:enabled] ? "ON" : "OFF"),
|
||||||
|
free: @autoscale_metrics[:free_slots],
|
||||||
|
soft: @autoscale_metrics[:soft_free_slots],
|
||||||
|
spare: @autoscale_metrics[:spare_ready],
|
||||||
|
warm: @autoscale_metrics[:warm_spare_min],
|
||||||
|
overflow: @autoscale_metrics[:overflow_nodes],
|
||||||
|
max: @autoscale_metrics[:max_overflow_nodes],
|
||||||
|
kind: @autoscale_metrics[:kind]
|
||||||
|
) %>
|
||||||
|
· <%= t(
|
||||||
|
"admin.stream_nodes.autoscale_budget",
|
||||||
|
eur: @autoscale_metrics[:estimated_monthly_eur],
|
||||||
|
budget: @autoscale_metrics[:monthly_budget_eur],
|
||||||
|
ok: (@autoscale_metrics[:within_budget] ? "OK" : "OVER")
|
||||||
|
) %>
|
||||||
|
<% if @autoscale_metrics[:kill_switch] %>
|
||||||
|
· <strong><%= t("admin.stream_nodes.kill_switch_active") %></strong>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<%= button_to t("admin.stream_nodes.provision_lab"), admin_stream_nodes_path, method: :post, params: { kind: "lab" }, class: "admin-btn" %>
|
||||||
|
<% if @hetzner_configured %>
|
||||||
|
<%= button_to t("admin.stream_nodes.provision_cloud"), admin_stream_nodes_path, method: :post, params: { kind: "cloud" }, class: "admin-btn",
|
||||||
|
form: { data: { confirm: t("admin.stream_nodes.provision_cloud_confirm") } } %>
|
||||||
|
<% else %>
|
||||||
|
<span class="admin-muted"><%= t("admin.stream_nodes.hetzner_token_missing") %></span>
|
||||||
|
<% end %>
|
||||||
|
<% if @autoscale_metrics[: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 %>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<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| %>
|
||||||
|
<tr>
|
||||||
|
<td><code><%= node.slug %></code></td>
|
||||||
|
<td><%= node.role %></td>
|
||||||
|
<td><%= node.status %></td>
|
||||||
|
<td><%= node.active_publishers %> / <%= node.max_publishers %> (free <%= node.free_slots %>)</td>
|
||||||
|
<td><code><%= node.hostname %></code></td>
|
||||||
|
<td><%= node.provider %><% if node.provider_instance_id.present? %> · <%= node.provider_instance_id %><% 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-secondary" %>
|
||||||
|
<% end %>
|
||||||
|
<%= button_to t("admin.stream_nodes.destroy"), admin_stream_node_path(node), method: :delete, class: "admin-btn admin-btn-danger", form: { data: { confirm: t("admin.stream_nodes.destroy_confirm", slug: node.slug) } } %>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<% if @lab_hosts.present? %>
|
||||||
|
<h3><%= t("admin.stream_nodes.hosts_title") %></h3>
|
||||||
|
<pre class="admin-pre"><%= @lab_hosts %></pre>
|
||||||
|
<% end %>
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
<%= link_to t("admin.layout.nav.billing"), admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %>
|
<%= link_to t("admin.layout.nav.billing"), admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %>
|
||||||
<%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
|
<%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
|
||||||
<%= link_to t("admin.layout.nav.sessions"), admin_sessions_path, class: ("active" if controller_name == "sessions") %>
|
<%= link_to t("admin.layout.nav.sessions"), admin_sessions_path, class: ("active" if controller_name == "sessions") %>
|
||||||
|
<%= link_to t("admin.layout.nav.stream_nodes"), admin_stream_nodes_path, class: ("active" if controller_name == "stream_nodes") %>
|
||||||
<%= link_to t("admin.layout.nav.password"), edit_admin_password_path %>
|
<%= link_to t("admin.layout.nav.password"), edit_admin_password_path %>
|
||||||
<%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %>
|
<%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -12,13 +12,13 @@
|
|||||||
</head>
|
</head>
|
||||||
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
||||||
<%= render "shared/cookie_banner" %>
|
<%= render "shared/cookie_banner" %>
|
||||||
<%= render(@app_store_review_chrome ? "shared/marketing_nav_app_store" : "shared/marketing_nav") %>
|
<%= render "shared/marketing_nav" %>
|
||||||
<main>
|
<main>
|
||||||
<% if flash[:notice] %><div class="wrap"><div class="flash notice"><%= flash[:notice] %></div></div><% end %>
|
<% if flash[:notice] %><div class="wrap"><div class="flash notice"><%= flash[:notice] %></div></div><% end %>
|
||||||
<% if flash[:alert] %><div class="wrap"><div class="flash alert"><%= flash[:alert] %></div></div><% end %>
|
<% if flash[:alert] %><div class="wrap"><div class="flash alert"><%= flash[:alert] %></div></div><% end %>
|
||||||
<%= yield %>
|
<%= yield %>
|
||||||
</main>
|
</main>
|
||||||
<%= render(@app_store_review_chrome ? "shared/marketing_footer_app_store" : "shared/marketing_footer") %>
|
<%= render "shared/marketing_footer" %>
|
||||||
<script src="/branding-form.js?v=1" defer></script>
|
<script src="/branding-form.js?v=1" defer></script>
|
||||||
<script src="/roster-form.js?v=1" defer></script>
|
<script src="/roster-form.js?v=1" defer></script>
|
||||||
<script src="/password-toggle.js?v=2" defer></script>
|
<script src="/password-toggle.js?v=2" defer></script>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<meta name="theme-color" content="#0a0a0e">
|
<meta name="theme-color" content="#0a0a0e">
|
||||||
<title><%= content_for?(:title) ? yield(:title) : t("regia.meta_title_fallback") %></title>
|
<title><%= content_for?(:title) ? yield(:title) : t("regia.meta_title_fallback") %></title>
|
||||||
<%= render "shared/meta_tags" %>
|
<%= render "shared/meta_tags" %>
|
||||||
<link rel="stylesheet" href="/regia.css?v=11">
|
<link rel="stylesheet" href="/regia.css?v=1">
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
<body class="regia-body">
|
<body class="regia-body">
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
<% content_for :title, t("legal.support.title") %>
|
|
||||||
<% content_for :meta_description, t("legal.support.meta_description") %>
|
|
||||||
<% content_for :canonical_url, seo_absolute_url(public_support_path) %>
|
|
||||||
|
|
||||||
<div class="wrap legal-doc">
|
|
||||||
<h1><%= t("legal.support.h1") %></h1>
|
|
||||||
<p><%= t("legal.support.intro") %></p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<%= t("legal.support.email_label") %>
|
|
||||||
<a href="mailto:<%= MatchLiveTv.support_email %>"><%= MatchLiveTv.support_email %></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2><%= t("legal.support.access_title") %></h2>
|
|
||||||
<p><%= t("legal.support.access_body") %></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2><%= t("legal.support.live_title") %></h2>
|
|
||||||
<p><%= t("legal.support.live_body") %></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2><%= t("legal.support.team_title") %></h2>
|
|
||||||
<p><%= t("legal.support.team_body") %></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2><%= t("legal.support.privacy_title") %></h2>
|
|
||||||
<p>
|
|
||||||
<%= raw t(
|
|
||||||
"legal.support.privacy_body_html",
|
|
||||||
privacy_link: link_to(t("legal.support.privacy_link_text"), public_privacy_path)
|
|
||||||
) %>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
@@ -21,4 +21,4 @@
|
|||||||
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px">−</button>
|
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px">−</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="regia-btn regia-btn--yellow" data-action="close_set"><%= 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>
|
||||||
|
|||||||
@@ -16,8 +16,6 @@
|
|||||||
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
|
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
|
||||||
data-resume-url="<%= j public_regia_resume_path(params[:token]) %>"
|
data-resume-url="<%= j public_regia_resume_path(params[:token]) %>"
|
||||||
data-stop-url="<%= j public_regia_stop_path(params[:token]) %>"
|
data-stop-url="<%= j public_regia_stop_path(params[:token]) %>"
|
||||||
data-min-quality-url="<%= j public_regia_min_quality_path(params[:token]) %>"
|
|
||||||
data-min-quality="<%= j @session.min_quality_preset %>"
|
|
||||||
data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>"
|
data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>"
|
||||||
data-hls-url="<%= j @session.hls_playback_url %>"
|
data-hls-url="<%= j @session.hls_playback_url %>"
|
||||||
data-home-name="<%= j @team.name %>"
|
data-home-name="<%= j @team.name %>"
|
||||||
@@ -31,62 +29,40 @@
|
|||||||
data-initial-paused="<%= @session.paused? %>">
|
data-initial-paused="<%= @session.paused? %>">
|
||||||
|
|
||||||
<header class="regia-header">
|
<header class="regia-header">
|
||||||
<div class="regia-header__text">
|
|
||||||
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
|
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
|
||||||
<p id="regia-subtitle"><%= t("regia.subtitle_prefix") %> <%= regia_header_subtitle(@match, @score) %></p>
|
<p id="regia-subtitle"><%= t("regia.subtitle_prefix") %> <%= regia_header_subtitle(@match, @score) %></p>
|
||||||
</div>
|
|
||||||
<span id="regia-badge" class="regia-badge <%= @stream_closed ? 'regia-badge--ended' : (@session.paused? ? 'regia-badge--wait' : (@on_air ? 'regia-badge--live' : 'regia-badge--wait')) %>">
|
<span id="regia-badge" class="regia-badge <%= @stream_closed ? 'regia-badge--ended' : (@session.paused? ? 'regia-badge--wait' : (@on_air ? 'regia-badge--live' : 'regia-badge--wait')) %>">
|
||||||
<%= @stream_closed ? t("regia.status.ended") : (@session.paused? ? t("regia.status.paused") : (@on_air ? t("regia.status.live") : t("regia.status.waiting"))) %>
|
<%= @stream_closed ? t("regia.status.ended") : (@session.paused? ? t("regia.status.paused") : (@on_air ? t("regia.status.live") : t("regia.status.waiting"))) %>
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="regia-toolbar" aria-label="<%= t("regia.links_nav") %>">
|
|
||||||
<% if @live_share_url.present? %>
|
<% if @live_share_url.present? %>
|
||||||
<div class="regia-tool-group" role="group" aria-label="<%= t("regia.live_share_label") %>">
|
<div class="regia-share-group">
|
||||||
<span class="regia-tool-group__label"><%= t("regia.live_share_label") %></span>
|
<p class="regia-share-label"><%= t("regia.live_share_label") %></p>
|
||||||
<div class="regia-tool-group__btns">
|
<div class="regia-share">
|
||||||
<button type="button" class="regia-icon-btn" id="btn-share-live" aria-label="<%= t("regia.share_live") %>" title="<%= t("regia.share_live") %>">
|
<button type="button" class="regia-btn regia-btn--outline" id="btn-share-live" style="margin:0">
|
||||||
<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>
|
<%= t("regia.share_live") %>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="regia-icon-btn" id="btn-copy-live" aria-label="<%= t("regia.copy_live") %>" title="<%= t("regia.copy_live") %>">
|
<button type="button" class="regia-btn regia-btn--outline" id="btn-copy-live" style="margin:0">
|
||||||
<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>
|
<%= t("regia.copy_link") %>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% 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"> </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-share-group">
|
||||||
<div class="regia-preview" id="regia-preview-box">
|
<p class="regia-share-label"><%= t("regia.regia_share_label") %></p>
|
||||||
|
<div class="regia-share">
|
||||||
|
<button type="button" class="regia-btn regia-btn--outline" id="btn-share-regia" style="margin:0">
|
||||||
|
<%= t("regia.share_regia") %>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="regia-btn regia-btn--outline" id="btn-copy-regia" style="margin:0">
|
||||||
|
<%= t("regia.copy_link") %>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="regia-preview">
|
||||||
<% unless @stream_closed %>
|
<% unless @stream_closed %>
|
||||||
<video id="regia-preview" playsinline muted autoplay></video>
|
<video id="regia-preview" playsinline muted autoplay></video>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -94,55 +70,14 @@
|
|||||||
<%= @stream_closed ? t("regia.preview_ended") : t("regia.preview_waiting") %>
|
<%= @stream_closed ? t("regia.preview_ended") : t("regia.preview_waiting") %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="regia-scoreboard">
|
<section class="regia-scoreboard">
|
||||||
<%= render regia_board_partial(@match) %>
|
<%= render regia_board_partial(@match) %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<% unless @stream_closed %>
|
<% unless @stream_closed %>
|
||||||
<section class="regia-telemetry" id="regia-telemetry" aria-label="<%= t("regia.telemetry.label") %>">
|
<button type="button" class="regia-btn regia-btn--outline" id="btn-pause"><%= @session.paused? ? t("regia.resume") : t("regia.pause") %></button>
|
||||||
<div class="regia-telemetry__row">
|
<button type="button" class="regia-btn regia-btn--danger" id="btn-stop"><%= t("regia.stop") %></button>
|
||||||
<span class="regia-chip" id="tel-battery" title="<%= t("regia.telemetry.battery") %>">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 4h-1V3h-6v1H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H8V6h8v14z"/></svg>
|
|
||||||
<span class="regia-chip__label"><%= t("regia.telemetry.battery") %></span>
|
|
||||||
<span class="regia-chip__value">—</span>
|
|
||||||
</span>
|
|
||||||
<span class="regia-chip" id="tel-network" title="<%= t("regia.telemetry.network") %>">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z"/></svg>
|
|
||||||
<span class="regia-chip__label"><%= t("regia.telemetry.network") %></span>
|
|
||||||
<span class="regia-chip__value">—</span>
|
|
||||||
</span>
|
|
||||||
<button type="button" class="regia-chip regia-chip--action" id="tel-bitrate" aria-expanded="false" aria-controls="regia-quality" title="<%= t("regia.telemetry.bitrate_tap") %>">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 13h2v-2H3v2zm4 4h2v-6H7v6zm4 4h2V7h-2v14zm4-8h2V9h-2v4zm4-4h2V5h-2v4z"/></svg>
|
|
||||||
<span class="regia-chip__label"><%= t("regia.telemetry.bitrate") %></span>
|
|
||||||
<span class="regia-chip__value">—</span>
|
|
||||||
<span class="regia-chip__hint" hidden></span>
|
|
||||||
</button>
|
|
||||||
<span class="regia-chip" id="tel-fps" title="<%= t("regia.telemetry.fps") %>">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
|
|
||||||
<span class="regia-chip__label"><%= t("regia.telemetry.fps") %></span>
|
|
||||||
<span class="regia-chip__value">—</span>
|
|
||||||
</span>
|
|
||||||
<span class="regia-chip" id="tel-thermal" title="<%= t("regia.telemetry.thermal") %>">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 13V5c0-1.66-1.34-3-3-3S9 3.34 9 5v8c-1.21.91-2 2.37-2 4 0 2.76 2.24 5 5 5s5-2.24 5-5c0-1.63-.79-3.09-2-4zm-3 8c-1.66 0-3-1.34-3-3 0-1.19.7-2.23 1.71-2.72L11 14.73V5c0-.55.45-1 1-1s1 .45 1 1v9.73l.29.18C14.3 15.77 15 16.81 15 18c0 1.66-1.34 3-3 3z"/></svg>
|
|
||||||
<span class="regia-chip__label"><%= t("regia.telemetry.thermal") %></span>
|
|
||||||
<span class="regia-chip__value">—</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="regia-quality" id="regia-quality" hidden>
|
|
||||||
<p class="regia-quality__hint"><%= t("regia.min_quality.recommend") %></p>
|
|
||||||
<label for="min-quality"><%= t("regia.min_quality.label") %></label>
|
|
||||||
<select id="min-quality" title="<%= t("regia.min_quality.hint") %>">
|
|
||||||
<% Sessions::SelectQuality.allowed_min_ids(@session.quality_preset).each do |id| %>
|
|
||||||
<option value="<%= id %>" <%= "selected" if @session.min_quality_preset == id %>>
|
|
||||||
<%= id == Sessions::SelectQuality::AUTO_ID ? t("regia.min_quality.auto") : Sessions::SelectQuality.display_name(id) %>
|
|
||||||
</option>
|
|
||||||
<% end %>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<p class="regia-telemetry__meta" id="tel-meta"><%= t("regia.telemetry.waiting") %></p>
|
|
||||||
</section>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -159,4 +94,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/regia.js?v=18"></script>
|
<script src="/regia.js?v=8"></script>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<%= link_to t("common.support"), public_support_path %> ·
|
|
||||||
<%= link_to t("common.pricing"), public_prezzi_path %> ·
|
<%= link_to t("common.pricing"), public_prezzi_path %> ·
|
||||||
<%= link_to t("footer.live"), public_live_index_path %> ·
|
<%= link_to t("footer.live"), public_live_index_path %> ·
|
||||||
<%= link_to t("common.faq"), public_faq_path %> ·
|
<%= link_to t("common.faq"), public_faq_path %> ·
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
<%# Footer minimale per App Store Review: solo link legali/supporto, nessun CTA commerciale. %>
|
|
||||||
<footer class="site-footer">
|
|
||||||
<div class="wrap">
|
|
||||||
<div>
|
|
||||||
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<%= link_to t("common.support"), public_support_path %> ·
|
|
||||||
<%= link_to t("common.privacy"), public_privacy_path %> ·
|
|
||||||
<%= link_to t("common.cookies"), public_cookies_path %> ·
|
|
||||||
<%= link_to t("common.terms"), public_termini_path %>
|
|
||||||
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
|
|
||||||
</div>
|
|
||||||
<div class="site-footer__legal">
|
|
||||||
<p><%= t("footer.copyright") %></p>
|
|
||||||
<p><%= t("footer.responsibility") %></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
<%# Chrome minimale per App Store Review: branding, lingua, Privacy e Supporto — senza CTA commerciali. %>
|
|
||||||
<div class="site-chrome">
|
|
||||||
<div class="site-masthead">
|
|
||||||
<div class="wrap mast-inner">
|
|
||||||
<%= link_to public_support_path, class: "mast-brand", aria: { label: "Match Live TV" }, title: "Match Live TV" do %>
|
|
||||||
<img class="mast-brand-logo" src="/logo.png?v=3" alt="Match Live TV" width="40" height="40" decoding="async">
|
|
||||||
<span class="brand" aria-hidden="true">Match <span>Live TV</span></span>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="mast-tools">
|
|
||||||
<button type="button" class="nav-toggle" aria-label="<%= t('nav.open_menu') %>" aria-expanded="false" aria-controls="site-nav">
|
|
||||||
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
|
||||||
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
|
||||||
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav id="site-nav" class="nav" aria-label="<%= t('nav.main_menu') %>" aria-hidden="true">
|
|
||||||
<div class="nav-panel">
|
|
||||||
<div class="nav-mobile-head">
|
|
||||||
<%= link_to public_support_path, class: "nav-mobile-brand", aria: { label: "Match Live TV" }, title: "Match Live TV" do %>
|
|
||||||
<img class="nav-mobile-brand-logo" src="/logo.png?v=3" alt="" width="40" height="40" decoding="async">
|
|
||||||
<span class="brand">Match <span>Live TV</span></span>
|
|
||||||
<% end %>
|
|
||||||
<div class="nav-lang">
|
|
||||||
<%= render "shared/language_switcher" %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<%= link_to t("common.support"), public_support_path, class: "nav-active" %>
|
|
||||||
<%= link_to t("common.privacy"), public_privacy_path, class: (request.path == "/privacy" ? "nav-active" : nil) %>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="nav-backdrop" id="nav-backdrop" aria-hidden="true"></div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
var chrome = document.querySelector(".site-chrome");
|
|
||||||
var toggle = document.querySelector(".nav-toggle");
|
|
||||||
var backdrop = document.getElementById("nav-backdrop");
|
|
||||||
var nav = document.getElementById("site-nav");
|
|
||||||
if (!chrome || !toggle || !nav) return;
|
|
||||||
var openLabel = <%= raw t("nav.open_menu").to_json %>;
|
|
||||||
var closeLabel = <%= raw t("nav.close_menu").to_json %>;
|
|
||||||
|
|
||||||
function setOpen(open) {
|
|
||||||
chrome.classList.toggle("nav-open", open);
|
|
||||||
document.body.classList.toggle("nav-menu-open", open);
|
|
||||||
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
|
||||||
toggle.setAttribute("aria-label", open ? closeLabel : openLabel);
|
|
||||||
nav.setAttribute("aria-hidden", open ? "false" : "true");
|
|
||||||
if (backdrop) backdrop.setAttribute("aria-hidden", open ? "false" : "true");
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeMenu() { setOpen(false); }
|
|
||||||
|
|
||||||
toggle.addEventListener("click", function (e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
document.body.classList.contains("nav-menu-open") ? closeMenu() : setOpen(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (backdrop) backdrop.addEventListener("click", closeMenu);
|
|
||||||
|
|
||||||
function shouldCloseNavOnControl(el) {
|
|
||||||
if (!el.closest("[data-lang-switcher]")) return true;
|
|
||||||
return el.classList.contains("lang-switcher__option");
|
|
||||||
}
|
|
||||||
|
|
||||||
nav.querySelectorAll("a, button").forEach(function (el) {
|
|
||||||
el.addEventListener("click", function () {
|
|
||||||
if (shouldCloseNavOnControl(el)) closeMenu();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("resize", function () {
|
|
||||||
if (window.matchMedia("(min-width: 900px)").matches) closeMenu();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("keydown", function (e) {
|
|
||||||
if (e.key === "Escape") closeMenu();
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
@@ -62,8 +62,8 @@ Rails.application.configure do
|
|||||||
# Annotate rendered view with file names.
|
# Annotate rendered view with file names.
|
||||||
config.action_view.annotate_rendered_view_with_filenames = true
|
config.action_view.annotate_rendered_view_with_filenames = true
|
||||||
|
|
||||||
# Permette Action Cable dall'emulatore Android (10.0.2.2) e da altri host locali.
|
# Uncomment if you wish to allow Action Cable access from any origin.
|
||||||
config.action_cable.disable_request_forgery_protection = true
|
# config.action_cable.disable_request_forgery_protection = true
|
||||||
|
|
||||||
# Raise error when a before_action's only/except options reference missing actions.
|
# Raise error when a before_action's only/except options reference missing actions.
|
||||||
config.action_controller.raise_on_missing_callback_actions = true
|
config.action_controller.raise_on_missing_callback_actions = true
|
||||||
|
|||||||
@@ -81,12 +81,6 @@ module MatchLiveTv
|
|||||||
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlivetv.it")
|
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlivetv.it")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Email di supporto (App Store Support URL e contatti assistenza).
|
|
||||||
# Preferisce SUPPORT_CONTACT_EMAIL; default = contatto commerciale già usato nel sito.
|
|
||||||
def support_email
|
|
||||||
ENV["SUPPORT_CONTACT_EMAIL"].presence || "info@matchlivetv.it"
|
|
||||||
end
|
|
||||||
|
|
||||||
def privacy_controller_address
|
def privacy_controller_address
|
||||||
ENV.fetch("PRIVACY_CONTROLLER_ADDRESS", "Via Guido De Ruggiero, 89 - 20142 - Milano (MI)")
|
ENV.fetch("PRIVACY_CONTROLLER_ADDRESS", "Via Guido De Ruggiero, 89 - 20142 - Milano (MI)")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Sidekiq.configure_server do |config|
|
|||||||
config.on(:startup) do
|
config.on(:startup) do
|
||||||
StreamPublisherSyncJob.ensure_chain
|
StreamPublisherSyncJob.ensure_chain
|
||||||
Ops::HealthMonitorJob.ensure_chain
|
Ops::HealthMonitorJob.ensure_chain
|
||||||
|
Streams::AutoscalerJob.ensure_chain
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ de:
|
|||||||
billing: Abrechnung
|
billing: Abrechnung
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sitzungen
|
sessions: Sitzungen
|
||||||
|
stream_nodes: Stream-Knoten
|
||||||
password: Passwort
|
password: Passwort
|
||||||
logout: Abmelden
|
logout: Abmelden
|
||||||
flash:
|
flash:
|
||||||
@@ -26,6 +27,11 @@ de:
|
|||||||
ops_acknowledged: Vorfall übernommen
|
ops_acknowledged: Vorfall übernommen
|
||||||
ops_resolved: Vorfall gelöst
|
ops_resolved: Vorfall gelöst
|
||||||
ops_muted: Benachrichtigungen für 24 Stunden stummgeschaltet
|
ops_muted: Benachrichtigungen für 24 Stunden stummgeschaltet
|
||||||
|
stream_node_created: "Lab-Knoten %{slug} bereitgestellt."
|
||||||
|
stream_node_destroyed: "Knoten %{slug} entfernt."
|
||||||
|
stream_node_draining: "Knoten %{slug} im Drain-Modus."
|
||||||
|
autoscale_kill_on: "Autoscaler-Kill-Switch aktiv. Kein automatisches Scale-out."
|
||||||
|
autoscale_kill_off: "Autoscaler-Kill-Switch aus (STREAM_AUTOSCALE_ENABLED=1 weiterhin nötig)."
|
||||||
comped_granted: "Kostenloses Abonnement %{plan} für %{club} aktiviert."
|
comped_granted: "Kostenloses Abonnement %{plan} für %{club} aktiviert."
|
||||||
comped_revoked: "Kostenloses Abonnement für %{club} widerrufen."
|
comped_revoked: "Kostenloses Abonnement für %{club} widerrufen."
|
||||||
session_already_terminated: "Sitzung bereits beendet (%{status})."
|
session_already_terminated: "Sitzung bereits beendet (%{status})."
|
||||||
@@ -111,6 +117,27 @@ de:
|
|||||||
title: Teams
|
title: Teams
|
||||||
matches_count: "%{count} Spiele"
|
matches_count: "%{count} Spiele"
|
||||||
view_all: "Vereine ansehen (%{count} Teams)"
|
view_all: "Vereine ansehen (%{count} Teams)"
|
||||||
|
stream_nodes:
|
||||||
|
title: Stream-Knoten
|
||||||
|
providers: "Cloud-Provider: %{cloud} · DNS-Provider: %{dns}"
|
||||||
|
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-Knoten bereitstellen
|
||||||
|
drain: Drain
|
||||||
|
destroy: Löschen
|
||||||
|
destroy_confirm: "Knoten %{slug} löschen?"
|
||||||
|
hosts_title: Lab-DNS (/etc/hosts)
|
||||||
|
col:
|
||||||
|
slug: Slug
|
||||||
|
role: Rolle
|
||||||
|
status: Status
|
||||||
|
slots: Slots
|
||||||
|
hostname: Hostname
|
||||||
|
provider: Provider
|
||||||
ops:
|
ops:
|
||||||
kpi:
|
kpi:
|
||||||
critical: Kritisch offen
|
critical: Kritisch offen
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ en:
|
|||||||
billing: Billing
|
billing: Billing
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sessions
|
sessions: Sessions
|
||||||
|
stream_nodes: Stream nodes
|
||||||
password: Password
|
password: Password
|
||||||
logout: Log out
|
logout: Log out
|
||||||
flash:
|
flash:
|
||||||
@@ -26,6 +27,11 @@ en:
|
|||||||
ops_acknowledged: Incident acknowledged
|
ops_acknowledged: Incident acknowledged
|
||||||
ops_resolved: Incident resolved
|
ops_resolved: Incident resolved
|
||||||
ops_muted: Notifications muted for 24 hours
|
ops_muted: Notifications muted for 24 hours
|
||||||
|
stream_node_created: "Lab node %{slug} provisioned."
|
||||||
|
stream_node_destroyed: "Node %{slug} removed."
|
||||||
|
stream_node_draining: "Node %{slug} is draining (no new sessions)."
|
||||||
|
autoscale_kill_on: "Autoscaler kill-switch engaged. No automatic scale-out."
|
||||||
|
autoscale_kill_off: "Autoscaler kill-switch cleared (still needs STREAM_AUTOSCALE_ENABLED=1)."
|
||||||
comped_granted: "%{plan} complimentary subscription activated for %{club}."
|
comped_granted: "%{plan} complimentary subscription activated for %{club}."
|
||||||
comped_revoked: "Complimentary subscription revoked for %{club}."
|
comped_revoked: "Complimentary subscription revoked for %{club}."
|
||||||
session_already_terminated: "Session already ended (%{status})."
|
session_already_terminated: "Session already ended (%{status})."
|
||||||
@@ -111,6 +117,30 @@ en:
|
|||||||
title: Teams
|
title: Teams
|
||||||
matches_count: "%{count} matches"
|
matches_count: "%{count} matches"
|
||||||
view_all: "View clubs (%{count} teams)"
|
view_all: "View clubs (%{count} teams)"
|
||||||
|
stream_nodes:
|
||||||
|
title: Streaming nodes
|
||||||
|
providers: "Cloud provider: %{cloud} · DNS provider: %{dns}"
|
||||||
|
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 (block autoscaler)"
|
||||||
|
clear_kill_switch: "Kill-switch OFF"
|
||||||
|
kill_switch_confirm: "Immediately block the autoscaler? Existing nodes stay up."
|
||||||
|
provision_lab: Provision lab node
|
||||||
|
provision_cloud: Provision Hetzner node
|
||||||
|
provision_cloud_confirm: "Create a Hetzner Cloud server + DNS record on mltv-stream.net? Billing applies until destroyed."
|
||||||
|
hetzner_token_missing: "Set HCLOUD_TOKEN to enable Cloud provisioning."
|
||||||
|
drain: Drain
|
||||||
|
destroy: Delete
|
||||||
|
destroy_confirm: "Delete node %{slug}?"
|
||||||
|
hosts_title: Lab DNS records (/etc/hosts snippet)
|
||||||
|
col:
|
||||||
|
slug: Slug
|
||||||
|
role: Role
|
||||||
|
status: Status
|
||||||
|
slots: Slots
|
||||||
|
hostname: Hostname
|
||||||
|
provider: Provider
|
||||||
ops:
|
ops:
|
||||||
kpi:
|
kpi:
|
||||||
critical: Critical open
|
critical: Critical open
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ es:
|
|||||||
billing: Facturación
|
billing: Facturación
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sesiones
|
sessions: Sesiones
|
||||||
|
stream_nodes: Nodos stream
|
||||||
password: Contraseña
|
password: Contraseña
|
||||||
logout: Salir
|
logout: Salir
|
||||||
flash:
|
flash:
|
||||||
@@ -26,6 +27,11 @@ es:
|
|||||||
ops_acknowledged: Incidencia asumida
|
ops_acknowledged: Incidencia asumida
|
||||||
ops_resolved: Incidencia resuelta
|
ops_resolved: Incidencia resuelta
|
||||||
ops_muted: Notificaciones silenciadas durante 24 horas
|
ops_muted: Notificaciones silenciadas durante 24 horas
|
||||||
|
stream_node_created: "Nodo lab %{slug} provisionado."
|
||||||
|
stream_node_destroyed: "Nodo %{slug} eliminado."
|
||||||
|
stream_node_draining: "Nodo %{slug} en drain."
|
||||||
|
autoscale_kill_on: "Kill-switch del autoscaler activado. Sin scale-out automático."
|
||||||
|
autoscale_kill_off: "Kill-switch del autoscaler desactivado (hace falta STREAM_AUTOSCALE_ENABLED=1)."
|
||||||
comped_granted: "Suscripción de cortesía %{plan} activada para %{club}."
|
comped_granted: "Suscripción de cortesía %{plan} activada para %{club}."
|
||||||
comped_revoked: "Suscripción de cortesía revocada para %{club}."
|
comped_revoked: "Suscripción de cortesía revocada para %{club}."
|
||||||
session_already_terminated: "La sesión ya ha finalizado (%{status})."
|
session_already_terminated: "La sesión ya ha finalizado (%{status})."
|
||||||
@@ -111,6 +117,27 @@ es:
|
|||||||
title: Equipos
|
title: Equipos
|
||||||
matches_count: "%{count} partidos"
|
matches_count: "%{count} partidos"
|
||||||
view_all: "Ver clubes (%{count} equipos)"
|
view_all: "Ver clubes (%{count} equipos)"
|
||||||
|
stream_nodes:
|
||||||
|
title: Nodos streaming
|
||||||
|
providers: "Cloud provider: %{cloud} · DNS provider: %{dns}"
|
||||||
|
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 nodo lab
|
||||||
|
drain: Drain
|
||||||
|
destroy: Eliminar
|
||||||
|
destroy_confirm: "¿Eliminar el nodo %{slug}?"
|
||||||
|
hosts_title: DNS lab (/etc/hosts)
|
||||||
|
col:
|
||||||
|
slug: Slug
|
||||||
|
role: Rol
|
||||||
|
status: Estado
|
||||||
|
slots: Slots
|
||||||
|
hostname: Hostname
|
||||||
|
provider: Provider
|
||||||
ops:
|
ops:
|
||||||
kpi:
|
kpi:
|
||||||
critical: Críticas abiertas
|
critical: Críticas abiertas
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ fr:
|
|||||||
billing: Facturation
|
billing: Facturation
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sessions
|
sessions: Sessions
|
||||||
|
stream_nodes: Nœuds stream
|
||||||
password: Mot de passe
|
password: Mot de passe
|
||||||
logout: Déconnexion
|
logout: Déconnexion
|
||||||
flash:
|
flash:
|
||||||
@@ -26,6 +27,11 @@ fr:
|
|||||||
ops_acknowledged: Incident pris en charge
|
ops_acknowledged: Incident pris en charge
|
||||||
ops_resolved: Incident résolu
|
ops_resolved: Incident résolu
|
||||||
ops_muted: Notifications suspendues pendant 24 heures
|
ops_muted: Notifications suspendues pendant 24 heures
|
||||||
|
stream_node_created: "Nœud lab %{slug} provisionné."
|
||||||
|
stream_node_destroyed: "Nœud %{slug} supprimé."
|
||||||
|
stream_node_draining: "Nœud %{slug} en drain."
|
||||||
|
autoscale_kill_on: "Kill-switch autoscaler activé. Pas de scale-out automatique."
|
||||||
|
autoscale_kill_off: "Kill-switch autoscaler désactivé (nécessite aussi STREAM_AUTOSCALE_ENABLED=1)."
|
||||||
comped_granted: "Abonnement offert %{plan} activé pour %{club}."
|
comped_granted: "Abonnement offert %{plan} activé pour %{club}."
|
||||||
comped_revoked: "Abonnement offert révoqué pour %{club}."
|
comped_revoked: "Abonnement offert révoqué pour %{club}."
|
||||||
session_already_terminated: "Session déjà terminée (%{status})."
|
session_already_terminated: "Session déjà terminée (%{status})."
|
||||||
@@ -111,6 +117,27 @@ fr:
|
|||||||
title: Équipes
|
title: Équipes
|
||||||
matches_count: "%{count} matchs"
|
matches_count: "%{count} matchs"
|
||||||
view_all: "Voir les clubs (%{count} équipes)"
|
view_all: "Voir les clubs (%{count} équipes)"
|
||||||
|
stream_nodes:
|
||||||
|
title: Nœuds streaming
|
||||||
|
providers: "Cloud provider: %{cloud} · DNS provider: %{dns}"
|
||||||
|
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 un nœud lab
|
||||||
|
drain: Drain
|
||||||
|
destroy: Supprimer
|
||||||
|
destroy_confirm: "Supprimer le nœud %{slug} ?"
|
||||||
|
hosts_title: DNS lab (/etc/hosts)
|
||||||
|
col:
|
||||||
|
slug: Slug
|
||||||
|
role: Rôle
|
||||||
|
status: Statut
|
||||||
|
slots: Slots
|
||||||
|
hostname: Hostname
|
||||||
|
provider: Provider
|
||||||
ops:
|
ops:
|
||||||
kpi:
|
kpi:
|
||||||
critical: Critiques ouverts
|
critical: Critiques ouverts
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ it:
|
|||||||
billing: Fatturazione
|
billing: Fatturazione
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sessioni
|
sessions: Sessioni
|
||||||
|
stream_nodes: Nodi stream
|
||||||
password: Password
|
password: Password
|
||||||
logout: Esci
|
logout: Esci
|
||||||
flash:
|
flash:
|
||||||
@@ -26,6 +27,11 @@ it:
|
|||||||
ops_acknowledged: Incidente preso in carico
|
ops_acknowledged: Incidente preso in carico
|
||||||
ops_resolved: Incidente risolto
|
ops_resolved: Incidente risolto
|
||||||
ops_muted: Notifiche sospese per 24 ore
|
ops_muted: Notifiche sospese per 24 ore
|
||||||
|
stream_node_created: "Nodo lab %{slug} provisionato."
|
||||||
|
stream_node_destroyed: "Nodo %{slug} rimosso."
|
||||||
|
stream_node_draining: "Nodo %{slug} in drain (niente nuove sessioni)."
|
||||||
|
autoscale_kill_on: "Kill-switch autoscaler attivato. Nessun scale-out automatico."
|
||||||
|
autoscale_kill_off: "Kill-switch autoscaler disattivato (serve comunque STREAM_AUTOSCALE_ENABLED=1)."
|
||||||
comped_granted: "Abbonamento omaggio %{plan} attivato per %{club}."
|
comped_granted: "Abbonamento omaggio %{plan} attivato per %{club}."
|
||||||
comped_revoked: "Abbonamento omaggio revocato per %{club}."
|
comped_revoked: "Abbonamento omaggio revocato per %{club}."
|
||||||
session_already_terminated: "Sessione già terminata (%{status})."
|
session_already_terminated: "Sessione già terminata (%{status})."
|
||||||
@@ -111,6 +117,30 @@ it:
|
|||||||
title: Squadre
|
title: Squadre
|
||||||
matches_count: "%{count} partite"
|
matches_count: "%{count} partite"
|
||||||
view_all: "Vedi società (%{count} squadre)"
|
view_all: "Vedi società (%{count} squadre)"
|
||||||
|
stream_nodes:
|
||||||
|
title: Nodi streaming
|
||||||
|
providers: "Cloud provider: %{cloud} · DNS provider: %{dns}"
|
||||||
|
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 (blocca autoscaler)"
|
||||||
|
clear_kill_switch: "Kill-switch OFF"
|
||||||
|
kill_switch_confirm: "Bloccare immediatamente l'autoscaler? I nodi esistenti restano accesi."
|
||||||
|
provision_lab: Provisiona nodo lab
|
||||||
|
provision_cloud: Provisiona nodo Hetzner
|
||||||
|
provision_cloud_confirm: "Creare un server Hetzner Cloud + record DNS su mltv-stream.net? Verrà addebitato fino allo spegnimento."
|
||||||
|
hetzner_token_missing: "Imposta HCLOUD_TOKEN per abilitare il provisioning Cloud."
|
||||||
|
drain: Drain
|
||||||
|
destroy: Elimina
|
||||||
|
destroy_confirm: "Eliminare il nodo %{slug}?"
|
||||||
|
hosts_title: Record DNS lab (snippet /etc/hosts)
|
||||||
|
col:
|
||||||
|
slug: Slug
|
||||||
|
role: Ruolo
|
||||||
|
status: Stato
|
||||||
|
slots: Slot
|
||||||
|
hostname: Hostname
|
||||||
|
provider: Provider
|
||||||
ops:
|
ops:
|
||||||
kpi:
|
kpi:
|
||||||
critical: Critici aperti
|
critical: Critici aperti
|
||||||
|
|||||||
@@ -432,51 +432,16 @@ de:
|
|||||||
paused: Pausiert
|
paused: Pausiert
|
||||||
live: Live
|
live: Live
|
||||||
waiting: Wartet
|
waiting: Wartet
|
||||||
live_share_label: Live
|
live_share_label: "Live-Link (Zuschauer)"
|
||||||
share_live: Livestream teilen
|
share_live: Livestream teilen
|
||||||
share_action: Teilen
|
|
||||||
copy_link: Link kopieren
|
copy_link: Link kopieren
|
||||||
copy_action: Kopieren
|
regia_share_label: "Regie-Link (Punktestand)"
|
||||||
copy_live: Live kopieren
|
|
||||||
copy_regia: Regie kopieren
|
|
||||||
links_nav: Schnellaktionen
|
|
||||||
regia_share_label: Regie
|
|
||||||
stream_controls_label: Live
|
|
||||||
share_regia: Regie-Link teilen
|
share_regia: Regie-Link teilen
|
||||||
preview_ended: Livestream beendet
|
preview_ended: Livestream beendet
|
||||||
preview_waiting: Vorschau wartet auf Signal…
|
preview_waiting: Vorschau wartet auf Signal…
|
||||||
preview_hide: Vorschau ausblenden
|
|
||||||
preview_show: Vorschau anzeigen
|
|
||||||
preview_off_hint: Vorschau aus · kein Video-Download
|
|
||||||
pause: Pausieren
|
pause: Pausieren
|
||||||
resume: Livestream fortsetzen
|
resume: Livestream fortsetzen
|
||||||
stop: Livestream beenden
|
stop: Livestream beenden
|
||||||
min_quality:
|
|
||||||
label: Minimale Übertragungsqualität
|
|
||||||
summary: Qualität
|
|
||||||
hint: Der Encoder fällt nicht unter dieses Niveau. Automatisch passt die Bitrate an, um Aussetzer zu reduzieren.
|
|
||||||
recommend: "Automatisch wird empfohlen: passt sich an Netz und Telefon an, ohne das Maximum zu erzwingen."
|
|
||||||
auto: Automatisch
|
|
||||||
telemetry:
|
|
||||||
label: Telefon
|
|
||||||
waiting: Warte auf das Telefon…
|
|
||||||
stale: Telefon antwortet nicht
|
|
||||||
just_now: jetzt
|
|
||||||
seconds_ago: "vor %{n}s"
|
|
||||||
publisher_on: RTMP ok
|
|
||||||
publisher_off: RTMP fehlt
|
|
||||||
battery: Akku
|
|
||||||
network: Netz
|
|
||||||
bitrate: Bitrate
|
|
||||||
bitrate_max: "max %{n}"
|
|
||||||
bitrate_title: "%{current} Mbps aktuell · max. %{target} Mbps"
|
|
||||||
bitrate_tap: Tippen, um die Mindestqualität festzulegen
|
|
||||||
fps: FPS
|
|
||||||
thermal: Temp.
|
|
||||||
thermal_nominal: OK
|
|
||||||
thermal_fair: Warm
|
|
||||||
thermal_serious: Heiß
|
|
||||||
thermal_critical: Kritisch
|
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Satz gewonnen
|
set_won_title: Satz gewonnen
|
||||||
confirm: Satz beenden
|
confirm: Satz beenden
|
||||||
@@ -521,8 +486,6 @@ de:
|
|||||||
copy_prompt: "Link kopieren:"
|
copy_prompt: "Link kopieren:"
|
||||||
share_regia_text: "Öffne diesen Link, um den Live-Spielstand zu verwalten:"
|
share_regia_text: "Öffne diesen Link, um den Live-Spielstand zu verwalten:"
|
||||||
share_live_text: "Sieh dir die Übertragung auf Match Live TV an:"
|
share_live_text: "Sieh dir die Übertragung auf Match Live TV an:"
|
||||||
min_quality_updated: Mindestqualität aktualisiert
|
|
||||||
min_quality_error: Fehler beim Aktualisieren der Mindestqualität
|
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -427,51 +427,16 @@ en:
|
|||||||
paused: Paused
|
paused: Paused
|
||||||
live: Live
|
live: Live
|
||||||
waiting: Waiting
|
waiting: Waiting
|
||||||
live_share_label: Live
|
live_share_label: "Live link (viewers)"
|
||||||
share_live: Share live link
|
share_live: Share live link
|
||||||
share_action: Share
|
|
||||||
copy_link: Copy link
|
copy_link: Copy link
|
||||||
copy_action: Copy
|
regia_share_label: "Control room link (score)"
|
||||||
copy_live: Copy live
|
|
||||||
copy_regia: Copy room
|
|
||||||
links_nav: Quick actions
|
|
||||||
regia_share_label: Control
|
|
||||||
stream_controls_label: Live
|
|
||||||
share_regia: Share control room link
|
share_regia: Share control room link
|
||||||
preview_ended: Stream ended
|
preview_ended: Stream ended
|
||||||
preview_waiting: Waiting for signal…
|
preview_waiting: Waiting for signal…
|
||||||
preview_hide: Hide preview
|
|
||||||
preview_show: Show preview
|
|
||||||
preview_off_hint: Preview off · no video download
|
|
||||||
pause: Pause stream
|
pause: Pause stream
|
||||||
resume: Resume stream
|
resume: Resume stream
|
||||||
stop: End stream
|
stop: End stream
|
||||||
min_quality:
|
|
||||||
label: Minimum streaming quality
|
|
||||||
summary: Quality
|
|
||||||
hint: The encoder will not go below this level. Automatic adapts bitrate to reduce stalls.
|
|
||||||
recommend: "Automatic is recommended: it adapts to the network and the phone, without forcing maximum quality."
|
|
||||||
auto: Automatic
|
|
||||||
telemetry:
|
|
||||||
label: Phone
|
|
||||||
waiting: Waiting for the phone…
|
|
||||||
stale: Phone not responding
|
|
||||||
just_now: now
|
|
||||||
seconds_ago: "%{n}s ago"
|
|
||||||
publisher_on: RTMP ok
|
|
||||||
publisher_off: RTMP down
|
|
||||||
battery: Battery
|
|
||||||
network: Network
|
|
||||||
bitrate: Bitrate
|
|
||||||
bitrate_max: "max %{n}"
|
|
||||||
bitrate_title: "%{current} Mbps current · max %{target} Mbps"
|
|
||||||
bitrate_tap: Tap to set the minimum quality
|
|
||||||
fps: FPS
|
|
||||||
thermal: Temp.
|
|
||||||
thermal_nominal: OK
|
|
||||||
thermal_fair: Warm
|
|
||||||
thermal_serious: Hot
|
|
||||||
thermal_critical: Critical
|
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set won
|
set_won_title: Set won
|
||||||
confirm: Close set
|
confirm: Close set
|
||||||
@@ -516,8 +481,6 @@ en:
|
|||||||
copy_prompt: "Copy the link:"
|
copy_prompt: "Copy the link:"
|
||||||
share_regia_text: "Open this link to manage the live score:"
|
share_regia_text: "Open this link to manage the live score:"
|
||||||
share_live_text: "Watch the live stream on Match Live TV:"
|
share_live_text: "Watch the live stream on Match Live TV:"
|
||||||
min_quality_updated: Minimum quality updated
|
|
||||||
min_quality_error: Error updating minimum quality
|
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -432,51 +432,16 @@ es:
|
|||||||
paused: En pausa
|
paused: En pausa
|
||||||
live: En directo
|
live: En directo
|
||||||
waiting: En espera
|
waiting: En espera
|
||||||
live_share_label: Directo
|
live_share_label: "Enlace del directo (espectadores)"
|
||||||
share_live: Compartir directo
|
share_live: Compartir directo
|
||||||
share_action: Compartir
|
|
||||||
copy_link: Copiar enlace
|
copy_link: Copiar enlace
|
||||||
copy_action: Copiar
|
regia_share_label: "Enlace de control (marcador)"
|
||||||
copy_live: Copiar directo
|
|
||||||
copy_regia: Copiar control
|
|
||||||
links_nav: Acciones rápidas
|
|
||||||
regia_share_label: Control
|
|
||||||
stream_controls_label: Live
|
|
||||||
share_regia: Compartir enlace de control
|
share_regia: Compartir enlace de control
|
||||||
preview_ended: Directo finalizado
|
preview_ended: Directo finalizado
|
||||||
preview_waiting: Vista previa a la espera de señal…
|
preview_waiting: Vista previa a la espera de señal…
|
||||||
preview_hide: Ocultar vista previa
|
|
||||||
preview_show: Mostrar vista previa
|
|
||||||
preview_off_hint: Vista previa apagada · sin descarga de vídeo
|
|
||||||
pause: Pausar directo
|
pause: Pausar directo
|
||||||
resume: Reanudar directo
|
resume: Reanudar directo
|
||||||
stop: Finalizar directo
|
stop: Finalizar directo
|
||||||
min_quality:
|
|
||||||
label: Calidad mínima de transmisión
|
|
||||||
summary: Calidad
|
|
||||||
hint: El encoder no baja de este nivel. Automática adapta el bitrate para reducir los cortes.
|
|
||||||
recommend: "Se recomienda Automática: se adapta a la red y al teléfono, sin forzar el máximo."
|
|
||||||
auto: Automática
|
|
||||||
telemetry:
|
|
||||||
label: Teléfono
|
|
||||||
waiting: Esperando al teléfono…
|
|
||||||
stale: El teléfono no responde
|
|
||||||
just_now: ahora
|
|
||||||
seconds_ago: "hace %{n}s"
|
|
||||||
publisher_on: RTMP ok
|
|
||||||
publisher_off: RTMP ausente
|
|
||||||
battery: Batería
|
|
||||||
network: Red
|
|
||||||
bitrate: Bitrate
|
|
||||||
bitrate_max: "máx %{n}"
|
|
||||||
bitrate_title: "%{current} Mbps actuales · máximo %{target} Mbps"
|
|
||||||
bitrate_tap: Toca para definir la calidad mínima
|
|
||||||
fps: FPS
|
|
||||||
thermal: Temp.
|
|
||||||
thermal_nominal: OK
|
|
||||||
thermal_fair: Caliente
|
|
||||||
thermal_serious: Muy caliente
|
|
||||||
thermal_critical: Crítica
|
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set ganado
|
set_won_title: Set ganado
|
||||||
confirm: Cerrar set
|
confirm: Cerrar set
|
||||||
@@ -521,8 +486,6 @@ es:
|
|||||||
copy_prompt: "Copia el enlace:"
|
copy_prompt: "Copia el enlace:"
|
||||||
share_regia_text: "Abre este enlace para gestionar el marcador en directo:"
|
share_regia_text: "Abre este enlace para gestionar el marcador en directo:"
|
||||||
share_live_text: "Mira la retransmisión en Match Live TV:"
|
share_live_text: "Mira la retransmisión en Match Live TV:"
|
||||||
min_quality_updated: Calidad mínima actualizada
|
|
||||||
min_quality_error: Error al actualizar la calidad mínima
|
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -432,51 +432,16 @@ fr:
|
|||||||
paused: En pause
|
paused: En pause
|
||||||
live: En direct
|
live: En direct
|
||||||
waiting: En attente
|
waiting: En attente
|
||||||
live_share_label: Direct
|
live_share_label: "Lien direct (spectateurs)"
|
||||||
share_live: Partager le direct
|
share_live: Partager le direct
|
||||||
share_action: Partager
|
|
||||||
copy_link: Copier le lien
|
copy_link: Copier le lien
|
||||||
copy_action: Copier
|
regia_share_label: "Lien régie (score)"
|
||||||
copy_live: Copier live
|
|
||||||
copy_regia: Copier régie
|
|
||||||
links_nav: Actions rapides
|
|
||||||
regia_share_label: Régie
|
|
||||||
stream_controls_label: Live
|
|
||||||
share_regia: Partager le lien régie
|
share_regia: Partager le lien régie
|
||||||
preview_ended: Direct terminé
|
preview_ended: Direct terminé
|
||||||
preview_waiting: Aperçu en attente du signal…
|
preview_waiting: Aperçu en attente du signal…
|
||||||
preview_hide: Masquer l'aperçu
|
|
||||||
preview_show: Afficher l'aperçu
|
|
||||||
preview_off_hint: Aperçu désactivé · aucun téléchargement vidéo
|
|
||||||
pause: Mettre en pause
|
pause: Mettre en pause
|
||||||
resume: Reprendre le direct
|
resume: Reprendre le direct
|
||||||
stop: Terminer le direct
|
stop: Terminer le direct
|
||||||
min_quality:
|
|
||||||
label: Qualité minimale de transmission
|
|
||||||
summary: Qualité
|
|
||||||
hint: L'encodeur ne descend pas en dessous de ce niveau. Automatique adapte le débit pour limiter les coupures.
|
|
||||||
recommend: "Automatique est conseillé : s'adapte au réseau et au téléphone, sans forcer le maximum."
|
|
||||||
auto: Automatique
|
|
||||||
telemetry:
|
|
||||||
label: Téléphone
|
|
||||||
waiting: En attente du téléphone…
|
|
||||||
stale: Téléphone sans réponse
|
|
||||||
just_now: maintenant
|
|
||||||
seconds_ago: "il y a %{n}s"
|
|
||||||
publisher_on: RTMP ok
|
|
||||||
publisher_off: RTMP absent
|
|
||||||
battery: Batterie
|
|
||||||
network: Réseau
|
|
||||||
bitrate: Débit
|
|
||||||
bitrate_max: "max %{n}"
|
|
||||||
bitrate_title: "%{current} Mbps actuels · max %{target} Mbps"
|
|
||||||
bitrate_tap: Touchez pour définir la qualité minimale
|
|
||||||
fps: FPS
|
|
||||||
thermal: Temp.
|
|
||||||
thermal_nominal: OK
|
|
||||||
thermal_fair: Chaude
|
|
||||||
thermal_serious: Très chaude
|
|
||||||
thermal_critical: Critique
|
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set gagné
|
set_won_title: Set gagné
|
||||||
confirm: Terminer le set
|
confirm: Terminer le set
|
||||||
@@ -521,8 +486,6 @@ fr:
|
|||||||
copy_prompt: "Copiez le lien :"
|
copy_prompt: "Copiez le lien :"
|
||||||
share_regia_text: "Ouvrez ce lien pour gérer le score du direct :"
|
share_regia_text: "Ouvrez ce lien pour gérer le score du direct :"
|
||||||
share_live_text: "Regardez le direct sur Match Live TV :"
|
share_live_text: "Regardez le direct sur Match Live TV :"
|
||||||
min_quality_updated: Qualité minimale mise à jour
|
|
||||||
min_quality_error: Erreur de mise à jour de la qualité minimale
|
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -427,51 +427,16 @@ it:
|
|||||||
paused: In pausa
|
paused: In pausa
|
||||||
live: In onda
|
live: In onda
|
||||||
waiting: In attesa
|
waiting: In attesa
|
||||||
live_share_label: Diretta
|
live_share_label: "Link diretta (spettatori)"
|
||||||
share_live: Condividi diretta
|
share_live: Condividi diretta
|
||||||
share_action: Condividi
|
|
||||||
copy_link: Copia link
|
copy_link: Copia link
|
||||||
copy_action: Copia
|
regia_share_label: "Link regia (punteggio)"
|
||||||
copy_live: Copia live
|
|
||||||
copy_regia: Copia regia
|
|
||||||
links_nav: Azioni rapide
|
|
||||||
regia_share_label: Regia
|
|
||||||
stream_controls_label: Live
|
|
||||||
share_regia: Condividi link regia
|
share_regia: Condividi link regia
|
||||||
preview_ended: Diretta terminata
|
preview_ended: Diretta terminata
|
||||||
preview_waiting: Anteprima in attesa del segnale…
|
preview_waiting: Anteprima in attesa del segnale…
|
||||||
preview_hide: Nascondi anteprima
|
|
||||||
preview_show: Mostra anteprima
|
|
||||||
preview_off_hint: Anteprima spenta · nessun download video
|
|
||||||
pause: Metti in pausa
|
pause: Metti in pausa
|
||||||
resume: Riprendi diretta
|
resume: Riprendi diretta
|
||||||
stop: Chiudi diretta
|
stop: Chiudi diretta
|
||||||
min_quality:
|
|
||||||
label: Qualità minima di trasmissione
|
|
||||||
summary: Qualità
|
|
||||||
hint: L'encoder non scende sotto questo livello. Automatica adatta il bitrate per ridurre i salti.
|
|
||||||
recommend: "Consigliata Automatica: si adatta alla rete e al telefono, senza forzare il massimo."
|
|
||||||
auto: Automatica
|
|
||||||
telemetry:
|
|
||||||
label: Telefono
|
|
||||||
waiting: In attesa del telefono…
|
|
||||||
stale: Telefono non risponde
|
|
||||||
just_now: ora
|
|
||||||
seconds_ago: "%{n}s fa"
|
|
||||||
publisher_on: RTMP ok
|
|
||||||
publisher_off: RTMP assente
|
|
||||||
battery: Batteria
|
|
||||||
network: Rete
|
|
||||||
bitrate: Bitrate
|
|
||||||
bitrate_max: "max %{n}"
|
|
||||||
bitrate_title: "%{current} Mbps attuali · massimo %{target} Mbps"
|
|
||||||
bitrate_tap: Tocca per impostare la qualità minima
|
|
||||||
fps: FPS
|
|
||||||
thermal: Temp.
|
|
||||||
thermal_nominal: OK
|
|
||||||
thermal_fair: Calda
|
|
||||||
thermal_serious: Molto calda
|
|
||||||
thermal_critical: Critica
|
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set vinto
|
set_won_title: Set vinto
|
||||||
confirm: Chiudi set
|
confirm: Chiudi set
|
||||||
@@ -516,8 +481,6 @@ it:
|
|||||||
copy_prompt: "Copia il link:"
|
copy_prompt: "Copia il link:"
|
||||||
share_regia_text: "Apri questo link per gestire il punteggio della diretta:"
|
share_regia_text: "Apri questo link per gestire il punteggio della diretta:"
|
||||||
share_live_text: "Guarda la diretta su Match Live TV:"
|
share_live_text: "Guarda la diretta su Match Live TV:"
|
||||||
min_quality_updated: Qualità minima aggiornata
|
|
||||||
min_quality_error: Errore aggiornamento qualità minima
|
|
||||||
recordings:
|
recordings:
|
||||||
archive:
|
archive:
|
||||||
back_prefix: "←"
|
back_prefix: "←"
|
||||||
|
|||||||
@@ -195,18 +195,3 @@ de:
|
|||||||
s6_body_html: "Um die von der DSGVO vorgesehenen Rechte auszuüben (Zugang, Löschung, Widerspruch, Widerruf der Einwilligung), schreiben Sie an %{email_link}. Details finden Sie im %{privacy_doc_link}."
|
s6_body_html: "Um die von der DSGVO vorgesehenen Rechte auszuüben (Zugang, Löschung, Widerspruch, Widerruf der Einwilligung), schreiben Sie an %{email_link}. Details finden Sie im %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: Datenschutzdokument
|
s6_privacy_doc_link_text: Datenschutzdokument
|
||||||
manage_button: Cookie-Einstellungen verwalten
|
manage_button: Cookie-Einstellungen verwalten
|
||||||
support:
|
|
||||||
title: Match Live TV Support
|
|
||||||
meta_description: "Match Live TV Hilfe: Zugangsprobleme, Livestreams, Teamverwaltung und Support-Kontaktdaten."
|
|
||||||
h1: Match Live TV Support
|
|
||||||
intro: "Brauchen Sie Hilfe mit Match Live TV? Bei Problemen mit dem Zugang, der Einrichtung von Livestreams, der Spielverwaltung oder der Nutzung der App können Sie unseren Support kontaktieren."
|
|
||||||
email_label: "Support-E-Mail:"
|
|
||||||
access_title: Zugangsprobleme
|
|
||||||
access_body: "Wenn Sie sich nicht anmelden können, prüfen Sie die Zugangsdaten, die Sie von Ihrem Sportverein erhalten haben. Wenn das Problem weiterhin besteht, kontaktieren Sie den Support."
|
|
||||||
live_title: Probleme während eines Livestreams
|
|
||||||
live_body: "Prüfen Sie Ihre Internetverbindung und versuchen Sie erneut, die Übertragung in der App zu starten. Wenn das Problem weiterhin besteht, kontaktieren Sie den Support und geben Sie Gerät, App-Version und eine kurze Beschreibung des Problems an."
|
|
||||||
team_title: Teamverwaltung
|
|
||||||
team_body: "Konten und Übertragungsberechtigungen werden vom Sportverein verwaltet."
|
|
||||||
privacy_title: Datenschutz
|
|
||||||
privacy_body_html: "Informationen zur Verarbeitung personenbezogener Daten finden Sie auf der Seite %{privacy_link}."
|
|
||||||
privacy_link_text: Datenschutz
|
|
||||||
|
|||||||
@@ -195,18 +195,3 @@ en:
|
|||||||
s6_body_html: "To exercise the rights provided by the GDPR (access, erasure, objection, withdrawal of consent) write to %{email_link}. Details are in the %{privacy_doc_link}."
|
s6_body_html: "To exercise the rights provided by the GDPR (access, erasure, objection, withdrawal of consent) write to %{email_link}. Details are in the %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: privacy document
|
s6_privacy_doc_link_text: privacy document
|
||||||
manage_button: Manage cookie preferences
|
manage_button: Manage cookie preferences
|
||||||
support:
|
|
||||||
title: Match Live TV Support
|
|
||||||
meta_description: "Match Live TV help: access issues, live streaming, team management and support contact details."
|
|
||||||
h1: Match Live TV Support
|
|
||||||
intro: "Need help with Match Live TV? For access issues, live stream setup, match management or app usage, you can contact our support team."
|
|
||||||
email_label: "Support email:"
|
|
||||||
access_title: Access issues
|
|
||||||
access_body: "If you cannot sign in, check the credentials provided by your sports club. If the problem persists, contact support."
|
|
||||||
live_title: Issues during a live stream
|
|
||||||
live_body: "Check your Internet connection and try starting the broadcast from the app again. If the problem persists, contact support and include your device, app version and a short description of the issue."
|
|
||||||
team_title: Team management
|
|
||||||
team_body: "Accounts and broadcasting permissions are managed by the sports club."
|
|
||||||
privacy_title: Privacy
|
|
||||||
privacy_body_html: "For information on personal data processing, see the %{privacy_link} page."
|
|
||||||
privacy_link_text: Privacy
|
|
||||||
|
|||||||
@@ -195,18 +195,3 @@ es:
|
|||||||
s6_body_html: "Para ejercer los derechos previstos por el RGPD (acceso, supresión, oposición, revocación del consentimiento) escribe a %{email_link}. Más detalles en el %{privacy_doc_link}."
|
s6_body_html: "Para ejercer los derechos previstos por el RGPD (acceso, supresión, oposición, revocación del consentimiento) escribe a %{email_link}. Más detalles en el %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: documento de privacidad
|
s6_privacy_doc_link_text: documento de privacidad
|
||||||
manage_button: Gestionar preferencias de cookies
|
manage_button: Gestionar preferencias de cookies
|
||||||
support:
|
|
||||||
title: Soporte Match Live TV
|
|
||||||
meta_description: "Ayuda de Match Live TV: problemas de acceso, directos, gestión del equipo y datos de contacto del soporte."
|
|
||||||
h1: Soporte Match Live TV
|
|
||||||
intro: "¿Necesitas ayuda con Match Live TV? Para problemas de acceso, configuración de directos, gestión de partidos o uso de la app puedes contactar con nuestro soporte."
|
|
||||||
email_label: "Correo de soporte:"
|
|
||||||
access_title: Problemas de acceso
|
|
||||||
access_body: "Si no puedes acceder, verifica las credenciales recibidas de tu club deportivo. Si el problema continúa, contacta con el soporte."
|
|
||||||
live_title: Problemas durante un directo
|
|
||||||
live_body: "Verifica la conexión a Internet e intenta de nuevo iniciar la transmisión desde la app. Si el problema continúa, contacta con el soporte indicando el dispositivo, la versión de la app y una breve descripción del problema."
|
|
||||||
team_title: Gestión del equipo
|
|
||||||
team_body: "Las cuentas y los permisos de transmisión los gestiona el club deportivo."
|
|
||||||
privacy_title: Privacidad
|
|
||||||
privacy_body_html: "Para información sobre el tratamiento de datos personales, consulta la página %{privacy_link}."
|
|
||||||
privacy_link_text: Privacidad
|
|
||||||
|
|||||||
@@ -195,18 +195,3 @@ fr:
|
|||||||
s6_body_html: "Pour exercer les droits prévus par le RGPD (accès, effacement, opposition, retrait du consentement), écrivez à %{email_link}. Détails dans le %{privacy_doc_link}."
|
s6_body_html: "Pour exercer les droits prévus par le RGPD (accès, effacement, opposition, retrait du consentement), écrivez à %{email_link}. Détails dans le %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: document de confidentialité
|
s6_privacy_doc_link_text: document de confidentialité
|
||||||
manage_button: Gérer les préférences de cookies
|
manage_button: Gérer les préférences de cookies
|
||||||
support:
|
|
||||||
title: Support Match Live TV
|
|
||||||
meta_description: "Assistance Match Live TV : problèmes d'accès, directs, gestion d'équipe et coordonnées du support."
|
|
||||||
h1: Support Match Live TV
|
|
||||||
intro: "Besoin d'aide avec Match Live TV ? Pour les problèmes d'accès, la configuration des directs, la gestion des matchs ou l'utilisation de l'application, vous pouvez contacter notre support."
|
|
||||||
email_label: "E-mail du support :"
|
|
||||||
access_title: Problèmes d'accès
|
|
||||||
access_body: "Si vous ne parvenez pas à vous connecter, vérifiez les identifiants fournis par votre club sportif. Si le problème persiste, contactez le support."
|
|
||||||
live_title: Problèmes pendant un direct
|
|
||||||
live_body: "Vérifiez votre connexion Internet et réessayez de démarrer la diffusion depuis l'application. Si le problème persiste, contactez le support en indiquant l'appareil, la version de l'application et une brève description du problème."
|
|
||||||
team_title: Gestion de l'équipe
|
|
||||||
team_body: "Les comptes et les autorisations de diffusion sont gérés par le club sportif."
|
|
||||||
privacy_title: Confidentialité
|
|
||||||
privacy_body_html: "Pour les informations sur le traitement des données personnelles, consultez la page %{privacy_link}."
|
|
||||||
privacy_link_text: Confidentialité
|
|
||||||
|
|||||||
@@ -195,18 +195,3 @@ it:
|
|||||||
s6_body_html: "Per esercitare i diritti previsti dal GDPR (accesso, cancellazione, opposizione, revoca consenso) scrivi a %{email_link}. Dettagli nel %{privacy_doc_link}."
|
s6_body_html: "Per esercitare i diritti previsti dal GDPR (accesso, cancellazione, opposizione, revoca consenso) scrivi a %{email_link}. Dettagli nel %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: documento privacy
|
s6_privacy_doc_link_text: documento privacy
|
||||||
manage_button: Gestisci preferenze cookie
|
manage_button: Gestisci preferenze cookie
|
||||||
support:
|
|
||||||
title: Supporto Match Live TV
|
|
||||||
meta_description: "Assistenza Match Live TV: problemi di accesso, dirette live, gestione squadra e contatti del supporto."
|
|
||||||
h1: Supporto Match Live TV
|
|
||||||
intro: "Hai bisogno di assistenza con Match Live TV? Per problemi di accesso, configurazione delle dirette, gestione delle partite o utilizzo dell’app puoi contattare il nostro supporto."
|
|
||||||
email_label: "Email di supporto:"
|
|
||||||
access_title: Problemi di accesso
|
|
||||||
access_body: "Se non riesci ad accedere, verifica le credenziali ricevute dalla tua società sportiva. Se il problema persiste, contatta il supporto."
|
|
||||||
live_title: Problemi durante una diretta
|
|
||||||
live_body: "Verifica la connessione Internet e riprova ad avviare la trasmissione dall’app. Se il problema persiste, contatta il supporto indicando dispositivo, versione dell’app e una breve descrizione del problema."
|
|
||||||
team_title: Gestione della squadra
|
|
||||||
team_body: "Gli account e le autorizzazioni per la trasmissione sono gestiti dalla società sportiva."
|
|
||||||
privacy_title: Privacy
|
|
||||||
privacy_body_html: "Per informazioni sul trattamento dei dati personali consulta la pagina %{privacy_link}."
|
|
||||||
privacy_link_text: Privacy
|
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ de:
|
|||||||
save_password: Passwort aktualisieren
|
save_password: Passwort aktualisieren
|
||||||
common:
|
common:
|
||||||
privacy: Datenschutz
|
privacy: Datenschutz
|
||||||
support: Support
|
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: AGB
|
terms: AGB
|
||||||
pricing: Preise
|
pricing: Preise
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ en:
|
|||||||
save_password: Update password
|
save_password: Update password
|
||||||
common:
|
common:
|
||||||
privacy: Privacy
|
privacy: Privacy
|
||||||
support: Support
|
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: Terms
|
terms: Terms
|
||||||
pricing: Pricing
|
pricing: Pricing
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ es:
|
|||||||
save_password: Actualizar contraseña
|
save_password: Actualizar contraseña
|
||||||
common:
|
common:
|
||||||
privacy: Privacidad
|
privacy: Privacidad
|
||||||
support: Soporte
|
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: Términos
|
terms: Términos
|
||||||
pricing: Precios
|
pricing: Precios
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ fr:
|
|||||||
save_password: Mettre à jour le mot de passe
|
save_password: Mettre à jour le mot de passe
|
||||||
common:
|
common:
|
||||||
privacy: Confidentialité
|
privacy: Confidentialité
|
||||||
support: Support
|
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: Conditions
|
terms: Conditions
|
||||||
pricing: Tarifs
|
pricing: Tarifs
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ it:
|
|||||||
save_password: Aggiorna password
|
save_password: Aggiorna password
|
||||||
common:
|
common:
|
||||||
privacy: Privacy
|
privacy: Privacy
|
||||||
support: Supporto
|
|
||||||
cookies: Cookie
|
cookies: Cookie
|
||||||
terms: Termini
|
terms: Termini
|
||||||
pricing: Prezzi
|
pricing: Prezzi
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ Rails.application.routes.draw do
|
|||||||
post :claim_pairing
|
post :claim_pairing
|
||||||
post :regia_link
|
post :regia_link
|
||||||
post :network_test
|
post :network_test
|
||||||
patch :min_quality
|
|
||||||
get :youtube_stats
|
get :youtube_stats
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -111,6 +110,15 @@ Rails.application.routes.draw do
|
|||||||
post :regia_link
|
post :regia_link
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
resources :stream_nodes, only: %i[index create destroy] do
|
||||||
|
member do
|
||||||
|
post :drain
|
||||||
|
end
|
||||||
|
collection do
|
||||||
|
post :kill_switch
|
||||||
|
delete :clear_kill_switch
|
||||||
|
end
|
||||||
|
end
|
||||||
get "youtube/platform", to: "youtube#platform", as: :youtube_platform
|
get "youtube/platform", to: "youtube#platform", as: :youtube_platform
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -144,7 +152,6 @@ Rails.application.routes.draw do
|
|||||||
post "regia/:token/pause", to: "public/regia#pause", as: :public_regia_pause
|
post "regia/:token/pause", to: "public/regia#pause", as: :public_regia_pause
|
||||||
post "regia/:token/resume", to: "public/regia#resume", as: :public_regia_resume
|
post "regia/:token/resume", to: "public/regia#resume", as: :public_regia_resume
|
||||||
post "regia/:token/stop", to: "public/regia#stop", as: :public_regia_stop
|
post "regia/:token/stop", to: "public/regia#stop", as: :public_regia_stop
|
||||||
patch "regia/:token/min_quality", to: "public/regia#min_quality", as: :public_regia_min_quality
|
|
||||||
|
|
||||||
root to: "public/pages#home"
|
root to: "public/pages#home"
|
||||||
get "sitemap.xml", to: "public/sitemap#show", defaults: { format: :xml }
|
get "sitemap.xml", to: "public/sitemap#show", defaults: { format: :xml }
|
||||||
@@ -156,7 +163,6 @@ Rails.application.routes.draw do
|
|||||||
get "prezzi", to: "pages#pricing", as: :prezzi
|
get "prezzi", to: "pages#pricing", as: :prezzi
|
||||||
get "pricing", to: redirect("/prezzi")
|
get "pricing", to: redirect("/prezzi")
|
||||||
get "privacy", to: "pages#privacy", as: :privacy
|
get "privacy", to: "pages#privacy", as: :privacy
|
||||||
get "support", to: "pages#support", as: :support
|
|
||||||
get "cookie", to: "pages#cookies", as: :cookies
|
get "cookie", to: "pages#cookies", as: :cookies
|
||||||
get "cookies", to: redirect("/cookie")
|
get "cookies", to: redirect("/cookie")
|
||||||
get "termini", to: "pages#terms", as: :termini
|
get "termini", to: "pages#terms", as: :termini
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
:concurrency: 5
|
:concurrency: 5
|
||||||
:queues:
|
:queues:
|
||||||
- default
|
|
||||||
- critical
|
- 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
|
|
||||||
Generated
+1
-3
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2026_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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
@@ -138,7 +138,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
|
|||||||
t.datetime "last_seen_at"
|
t.datetime "last_seen_at"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.string "thermal_state"
|
|
||||||
t.index ["stream_session_id", "device_role"], name: "index_device_states_on_stream_session_id_and_device_role", unique: true
|
t.index ["stream_session_id", "device_role"], name: "index_device_states_on_stream_session_id_and_device_role", unique: true
|
||||||
t.index ["stream_session_id"], name: "index_device_states_on_stream_session_id"
|
t.index ["stream_session_id"], name: "index_device_states_on_stream_session_id"
|
||||||
end
|
end
|
||||||
@@ -305,7 +304,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
|
|||||||
t.string "regia_token_digest"
|
t.string "regia_token_digest"
|
||||||
t.datetime "regia_token_expires_at"
|
t.datetime "regia_token_expires_at"
|
||||||
t.uuid "stream_node_id"
|
t.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 ["match_id"], name: "index_stream_sessions_on_match_id"
|
||||||
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
|
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
|
||||||
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
|
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
|
||||||
|
|||||||
@@ -43,3 +43,6 @@ end
|
|||||||
|
|
||||||
puts "Seed OK: coach@matchlivetv.test / Password123"
|
puts "Seed OK: coach@matchlivetv.test / Password123"
|
||||||
puts "Club: #{club.name}, Team: #{team.name}, Match: #{match.opponent_name}"
|
puts "Club: #{club.name}, Team: #{team.name}, Match: #{match.opponent_name}"
|
||||||
|
|
||||||
|
home = Streams::NodeRegistry.ensure_home_from_env!
|
||||||
|
puts "Stream node home: #{home.slug} rtmp=#{home.rtmp_base_url}"
|
||||||
|
|||||||
@@ -0,0 +1,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
|
||||||
+41
-277
@@ -4,8 +4,6 @@
|
|||||||
--regia-surface: #14141c;
|
--regia-surface: #14141c;
|
||||||
--regia-red: #e53935;
|
--regia-red: #e53935;
|
||||||
--regia-yellow: #f9a825;
|
--regia-yellow: #f9a825;
|
||||||
--regia-green: #43a047;
|
|
||||||
--regia-orange: #fb8c00;
|
|
||||||
--regia-text: #f5f5f5;
|
--regia-text: #f5f5f5;
|
||||||
--regia-muted: #9ca3af;
|
--regia-muted: #9ca3af;
|
||||||
--regia-safe-bottom: env(safe-area-inset-bottom, 0px);
|
--regia-safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
@@ -24,166 +22,37 @@
|
|||||||
.regia-page {
|
.regia-page {
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 8px 12px calc(12px + var(--regia-safe-bottom));
|
padding: 12px 16px calc(24px + var(--regia-safe-bottom));
|
||||||
}
|
|
||||||
|
|
||||||
.regia-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-header__text {
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-header h1 {
|
.regia-header h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.25;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-header p {
|
.regia-header p {
|
||||||
margin: 2px 0 0;
|
margin: 4px 0 0;
|
||||||
font-size: 0.78rem;
|
font-size: 0.85rem;
|
||||||
color: var(--regia-muted);
|
color: var(--regia-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-badge {
|
.regia-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 0;
|
margin-top: 8px;
|
||||||
padding: 3px 8px;
|
padding: 4px 10px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 0.65rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-badge--live { background: #2e7d32; color: #fff; }
|
.regia-badge--live { background: #2e7d32; color: #fff; }
|
||||||
.regia-badge--wait { background: #444; color: #ddd; }
|
.regia-badge--wait { background: #444; color: #ddd; }
|
||||||
.regia-badge--ended { background: #374151; color: #ddd; }
|
.regia-badge--ended { background: #374151; color: #ddd; }
|
||||||
|
|
||||||
.regia-toolbar {
|
|
||||||
margin: 8px 0 6px;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-tool-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 3px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-tool-group__label {
|
|
||||||
font-size: 0.62rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--regia-muted);
|
|
||||||
line-height: 1;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-tool-group__btns {
|
|
||||||
display: flex;
|
|
||||||
gap: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-tool-group--stream {
|
|
||||||
margin-left: auto;
|
|
||||||
padding-left: 8px;
|
|
||||||
border-left: 1px solid #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-icon-btn {
|
|
||||||
flex: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid #333;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #16161f;
|
|
||||||
color: var(--regia-text);
|
|
||||||
cursor: pointer;
|
|
||||||
touch-action: manipulation;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-icon-btn svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-icon-btn svg.icon-fill {
|
|
||||||
fill: currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-icon-btn svg.icon-outline {
|
|
||||||
fill: none;
|
|
||||||
stroke: currentColor;
|
|
||||||
stroke-width: 2;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-icon-btn .icon-eye-off,
|
|
||||||
.regia-icon-btn .icon-play {
|
|
||||||
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 {
|
.regia-preview {
|
||||||
margin: 0;
|
margin: 14px 0;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #000;
|
background: #000;
|
||||||
@@ -221,29 +90,29 @@
|
|||||||
.regia-scoreboard {
|
.regia-scoreboard {
|
||||||
background: var(--regia-surface);
|
background: var(--regia-surface);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 10px 12px;
|
padding: 16px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-sets {
|
.regia-sets {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.78rem;
|
font-size: 0.82rem;
|
||||||
color: var(--regia-muted);
|
color: var(--regia-muted);
|
||||||
margin: 0 0 4px;
|
margin: 0 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-score-line {
|
.regia-score-line {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 1.2rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-partials {
|
.regia-partials {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.75rem;
|
font-size: 0.78rem;
|
||||||
color: var(--regia-muted);
|
color: var(--regia-muted);
|
||||||
margin: 4px 0 0;
|
margin: 8px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-team-row {
|
.regia-team-row {
|
||||||
@@ -251,7 +120,7 @@
|
|||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 8px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-team-name {
|
.regia-team-name {
|
||||||
@@ -262,7 +131,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.regia-points {
|
.regia-points {
|
||||||
font-size: 1.55rem;
|
font-size: 2rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -277,7 +146,7 @@
|
|||||||
.regia-btn {
|
.regia-btn {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 10px 8px;
|
padding: 16px 12px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -287,8 +156,8 @@
|
|||||||
|
|
||||||
.regia-btn:active { transform: scale(0.98); }
|
.regia-btn:active { transform: scale(0.98); }
|
||||||
.regia-btn--point { background: var(--regia-red); color: #fff; }
|
.regia-btn--point { background: var(--regia-red); color: #fff; }
|
||||||
.regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 8px; }
|
.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: 8px; padding: 12px; }
|
.regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 10px; }
|
||||||
.regia-btn--outline {
|
.regia-btn--outline {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid #444;
|
border: 1px solid #444;
|
||||||
@@ -304,6 +173,26 @@
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.regia-share-group {
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-share-label {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--regia-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-share {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regia-share .regia-btn { flex: 1; font-size: 0.9rem; padding: 12px; }
|
||||||
|
|
||||||
.regia-toast {
|
.regia-toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: calc(16px + var(--regia-safe-bottom));
|
bottom: calc(16px + var(--regia-safe-bottom));
|
||||||
@@ -351,7 +240,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--regia-muted);
|
color: var(--regia-muted);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
padding-top: 8px;
|
padding-top: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-clock-line {
|
.regia-clock-line {
|
||||||
@@ -366,128 +255,3 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regia-quality {
|
|
||||||
margin: 6px 0 0;
|
|
||||||
padding: 8px 10px 10px;
|
|
||||||
background: var(--regia-surface);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-quality[hidden] { display: none !important; }
|
|
||||||
|
|
||||||
.regia-quality__hint {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
font-size: 0.68rem;
|
|
||||||
line-height: 1.35;
|
|
||||||
color: var(--regia-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-quality label {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.68rem;
|
|
||||||
color: var(--regia-muted);
|
|
||||||
margin: 0 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-quality select {
|
|
||||||
width: 100%;
|
|
||||||
background: #0a0a0e;
|
|
||||||
color: var(--regia-text);
|
|
||||||
border: 1px solid #2a2a35;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-telemetry {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-telemetry__row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-chip {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 0;
|
|
||||||
text-align: center;
|
|
||||||
padding: 6px 2px 5px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--regia-surface);
|
|
||||||
color: var(--regia-text);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-chip--action {
|
|
||||||
appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
font: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-chip--action:focus-visible {
|
|
||||||
outline: 2px solid var(--regia-yellow);
|
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-chip--action.is-open {
|
|
||||||
border-color: #3a3a48;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-chip svg {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
fill: currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-chip__label {
|
|
||||||
font-size: 0.55rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--regia-muted);
|
|
||||||
line-height: 1;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-chip__value {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1.15;
|
|
||||||
max-width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-chip__hint {
|
|
||||||
font-size: 0.55rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--regia-muted);
|
|
||||||
line-height: 1;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-chip__hint[hidden] { display: none; }
|
|
||||||
|
|
||||||
.regia-chip.is-ok { color: var(--regia-green); }
|
|
||||||
.regia-chip.is-warn { color: var(--regia-orange); }
|
|
||||||
.regia-chip.is-bad { color: var(--regia-red); }
|
|
||||||
|
|
||||||
.regia-telemetry__meta {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
font-size: 0.65rem;
|
|
||||||
color: var(--regia-muted);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.regia-telemetry.is-stale .regia-telemetry__meta {
|
|
||||||
color: var(--regia-yellow);
|
|
||||||
}
|
|
||||||
|
|||||||
+5
-262
@@ -37,32 +37,13 @@
|
|||||||
shareLiveText: "Guarda la diretta su Match Live TV:",
|
shareLiveText: "Guarda la diretta su Match Live TV:",
|
||||||
previewWaiting: "Anteprima in attesa del segnale…",
|
previewWaiting: "Anteprima in attesa del segnale…",
|
||||||
streamEnded: "Diretta terminata",
|
streamEnded: "Diretta terminata",
|
||||||
previewHide: "Nascondi anteprima",
|
|
||||||
previewShow: "Mostra anteprima",
|
|
||||||
previewOffHint: "Anteprima spenta · nessun download video",
|
|
||||||
resumeLabel: "Riprendi diretta",
|
resumeLabel: "Riprendi diretta",
|
||||||
pauseLabel: "Metti in pausa",
|
pauseLabel: "Metti in pausa",
|
||||||
endedBadge: "Terminata",
|
endedBadge: "Terminata",
|
||||||
pausedBadge: "In pausa",
|
pausedBadge: "In pausa",
|
||||||
liveBadge: "In onda",
|
liveBadge: "In onda",
|
||||||
waitingBadge: "In attesa",
|
waitingBadge: "In attesa",
|
||||||
subtitlePrefix: "Regia ·",
|
subtitlePrefix: "Regia ·"
|
||||||
minQualityUpdated: "Qualità minima aggiornata",
|
|
||||||
minQualityError: "Errore aggiornamento qualità minima",
|
|
||||||
minQualityAuto: "Automatica",
|
|
||||||
telemetryWaiting: "In attesa del telefono…",
|
|
||||||
telemetryStale: "Telefono non risponde",
|
|
||||||
telemetryJustNow: "ora",
|
|
||||||
telemetrySecondsAgo: "%{n}s fa",
|
|
||||||
telemetryPublisherOn: "RTMP ok",
|
|
||||||
telemetryPublisherOff: "RTMP assente",
|
|
||||||
telemetryThermalNominal: "OK",
|
|
||||||
telemetryThermalFair: "Calda",
|
|
||||||
telemetryThermalSerious: "Calda",
|
|
||||||
telemetryThermalCritical: "Critica",
|
|
||||||
telemetryBitrateMax: "max %{n}",
|
|
||||||
telemetryBitrateTitle: "%{current} Mbps attuali · massimo %{target} Mbps",
|
|
||||||
telemetryBitrateTap: "Tocca per impostare la qualità minima"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function loadI18n() {
|
function loadI18n() {
|
||||||
@@ -96,7 +77,6 @@
|
|||||||
pauseUrl: root.dataset.pauseUrl,
|
pauseUrl: root.dataset.pauseUrl,
|
||||||
resumeUrl: root.dataset.resumeUrl,
|
resumeUrl: root.dataset.resumeUrl,
|
||||||
stopUrl: root.dataset.stopUrl,
|
stopUrl: root.dataset.stopUrl,
|
||||||
minQualityUrl: root.dataset.minQualityUrl,
|
|
||||||
cableUrl: root.dataset.cableUrl,
|
cableUrl: root.dataset.cableUrl,
|
||||||
hlsUrl: root.dataset.hlsUrl,
|
hlsUrl: root.dataset.hlsUrl,
|
||||||
homeName: root.dataset.homeName,
|
homeName: root.dataset.homeName,
|
||||||
@@ -122,28 +102,15 @@
|
|||||||
modalCancel: document.getElementById("modal-cancel"),
|
modalCancel: document.getElementById("modal-cancel"),
|
||||||
preview: document.getElementById("regia-preview"),
|
preview: document.getElementById("regia-preview"),
|
||||||
previewPlaceholder: document.getElementById("regia-preview-placeholder"),
|
previewPlaceholder: document.getElementById("regia-preview-placeholder"),
|
||||||
previewWrap: document.getElementById("regia-preview-wrap"),
|
|
||||||
btnTogglePreview: document.getElementById("btn-toggle-preview"),
|
|
||||||
btnPause: document.getElementById("btn-pause"),
|
btnPause: document.getElementById("btn-pause"),
|
||||||
minQuality: document.getElementById("min-quality"),
|
subtitle: document.getElementById("regia-subtitle")
|
||||||
subtitle: document.getElementById("regia-subtitle"),
|
|
||||||
telemetry: document.getElementById("regia-telemetry"),
|
|
||||||
telBattery: document.getElementById("tel-battery"),
|
|
||||||
telNetwork: document.getElementById("tel-network"),
|
|
||||||
telBitrate: document.getElementById("tel-bitrate"),
|
|
||||||
telFps: document.getElementById("tel-fps"),
|
|
||||||
telThermal: document.getElementById("tel-thermal"),
|
|
||||||
telMeta: document.getElementById("tel-meta"),
|
|
||||||
qualityPanel: document.getElementById("regia-quality")
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let pendingAction = null;
|
let pendingAction = null;
|
||||||
let hls = null;
|
let hls = null;
|
||||||
let previewStarted = false;
|
let previewStarted = false;
|
||||||
let previewWanted = true;
|
|
||||||
let wasPausedPreview = false;
|
let wasPausedPreview = false;
|
||||||
let streamPaused = root.dataset.initialPaused === "true";
|
let streamPaused = root.dataset.initialPaused === "true";
|
||||||
const PREVIEW_PREF_KEY = "mltv-regia-preview";
|
|
||||||
|
|
||||||
function hlsUrlFresh() {
|
function hlsUrlFresh() {
|
||||||
const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
|
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() {
|
function refreshPreviewForCover() {
|
||||||
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed || !previewWanted) return;
|
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed) return;
|
||||||
destroyPreview();
|
destroyPreview();
|
||||||
previewStarted = false;
|
previewStarted = false;
|
||||||
startPreview();
|
startPreview();
|
||||||
@@ -311,10 +243,7 @@
|
|||||||
if (!els.btnPause) return;
|
if (!els.btnPause) return;
|
||||||
const paused = !!(data.paused || data.status === "paused");
|
const paused = !!(data.paused || data.status === "paused");
|
||||||
streamPaused = paused;
|
streamPaused = paused;
|
||||||
const label = paused ? I18N.resumeLabel : I18N.pauseLabel;
|
els.btnPause.textContent = paused ? I18N.resumeLabel : I18N.pauseLabel;
|
||||||
els.btnPause.classList.toggle("is-paused", paused);
|
|
||||||
els.btnPause.setAttribute("aria-label", label);
|
|
||||||
els.btnPause.title = label;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBadge(data) {
|
function setBadge(data) {
|
||||||
@@ -435,11 +364,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const paused = data.paused || data.status === "paused";
|
const paused = data.paused || data.status === "paused";
|
||||||
if (!previewWanted) {
|
|
||||||
destroyPreview();
|
|
||||||
wasPausedPreview = paused;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data.on_air) {
|
if (data.on_air) {
|
||||||
if (!previewStarted) {
|
if (!previewStarted) {
|
||||||
startPreview();
|
startPreview();
|
||||||
@@ -458,119 +382,6 @@
|
|||||||
wasPausedPreview = paused;
|
wasPausedPreview = paused;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mbpsNumber(bps) {
|
|
||||||
const n = Number(bps);
|
|
||||||
if (!n || n <= 0) return null;
|
|
||||||
const mbps = n >= 10_000 ? n / 1e6 : n / 1e3;
|
|
||||||
return mbps >= 10 ? mbps.toFixed(0) : mbps.toFixed(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAge(iso) {
|
|
||||||
if (!iso) return null;
|
|
||||||
const then = Date.parse(iso);
|
|
||||||
if (!then) return null;
|
|
||||||
const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
|
|
||||||
if (secs < 8) return I18N.telemetryJustNow;
|
|
||||||
return fillTemplate(I18N.telemetrySecondsAgo, { n: secs });
|
|
||||||
}
|
|
||||||
|
|
||||||
function setChip(el, text, level, hint) {
|
|
||||||
if (!el) return;
|
|
||||||
const valueEl = el.querySelector(".regia-chip__value") || el;
|
|
||||||
valueEl.textContent = text;
|
|
||||||
el.classList.toggle("is-ok", level === "ok");
|
|
||||||
el.classList.toggle("is-warn", level === "warn");
|
|
||||||
el.classList.toggle("is-bad", level === "bad");
|
|
||||||
const hintEl = el.querySelector(".regia-chip__hint");
|
|
||||||
if (hintEl) {
|
|
||||||
hintEl.textContent = hint || "";
|
|
||||||
hintEl.hidden = !hint;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatThermal(state) {
|
|
||||||
const key = String(state || "").toLowerCase();
|
|
||||||
if (key === "nominal") return { text: I18N.telemetryThermalNominal, level: "ok" };
|
|
||||||
if (key === "fair") return { text: I18N.telemetryThermalFair, level: "warn" };
|
|
||||||
if (key === "serious") return { text: I18N.telemetryThermalSerious, level: "bad" };
|
|
||||||
if (key === "critical") return { text: I18N.telemetryThermalCritical, level: "bad" };
|
|
||||||
return { text: "—", level: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
function bitrateLevel(current, target, paused) {
|
|
||||||
if (paused || !current || current <= 0) return null;
|
|
||||||
if (target && target > 0) {
|
|
||||||
const ratio = current / target;
|
|
||||||
if (ratio >= 0.7) return "ok";
|
|
||||||
if (ratio >= 0.4) return "warn";
|
|
||||||
return "bad";
|
|
||||||
}
|
|
||||||
const mbps = current >= 10_000 ? current / 1e6 : current / 1e3;
|
|
||||||
if (mbps >= 2.5) return "ok";
|
|
||||||
if (mbps >= 1.5) return "warn";
|
|
||||||
return "bad";
|
|
||||||
}
|
|
||||||
|
|
||||||
function bitrateTitle(currentMbps, targetMbps) {
|
|
||||||
const tap = I18N.telemetryBitrateTap;
|
|
||||||
if (currentMbps && targetMbps) {
|
|
||||||
return `${fillTemplate(I18N.telemetryBitrateTitle, { current: currentMbps, target: targetMbps })} · ${tap}`;
|
|
||||||
}
|
|
||||||
if (currentMbps) return `${currentMbps} Mbps · ${tap}`;
|
|
||||||
return tap;
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncTelemetry(data) {
|
|
||||||
if (!els.telemetry) return;
|
|
||||||
const tel = data.telemetry || {};
|
|
||||||
const battery = tel.battery;
|
|
||||||
const fps = tel.fps;
|
|
||||||
const bitrate = tel.bitrate;
|
|
||||||
const target = tel.target_bitrate || tel.targetBitrate;
|
|
||||||
const lastSeen = tel.last_seen_at || tel.lastSeenAt;
|
|
||||||
const ageSecs = lastSeen ? Math.max(0, Math.round((Date.now() - Date.parse(lastSeen)) / 1000)) : null;
|
|
||||||
const stale = ageSecs == null || ageSecs > 25;
|
|
||||||
const publishing = !!tel.publisher_online || !!tel.publisherOnline;
|
|
||||||
|
|
||||||
let battLevel = null;
|
|
||||||
let battText = "—";
|
|
||||||
if (battery != null && battery !== "") {
|
|
||||||
battText = `${battery}%`;
|
|
||||||
if (battery <= 15) battLevel = "bad";
|
|
||||||
else if (battery <= 25) battLevel = "warn";
|
|
||||||
}
|
|
||||||
setChip(els.telBattery, battText, battLevel);
|
|
||||||
setChip(els.telNetwork, tel.network || "—");
|
|
||||||
|
|
||||||
const currentMbps = mbpsNumber(bitrate);
|
|
||||||
const targetMbps = mbpsNumber(target);
|
|
||||||
const rateText = currentMbps || "—";
|
|
||||||
const rateHint = currentMbps && targetMbps
|
|
||||||
? fillTemplate(I18N.telemetryBitrateMax, { n: targetMbps })
|
|
||||||
: null;
|
|
||||||
if (els.telBitrate) els.telBitrate.title = bitrateTitle(currentMbps, targetMbps);
|
|
||||||
setChip(els.telBitrate, rateText, bitrateLevel(Number(bitrate), Number(target), data.paused), rateHint);
|
|
||||||
|
|
||||||
let fpsLevel = null;
|
|
||||||
if (fps != null && fps !== "" && Number(fps) > 0 && Number(fps) < 20) fpsLevel = "warn";
|
|
||||||
setChip(els.telFps, fps && Number(fps) > 0 ? String(fps) : "—", fpsLevel);
|
|
||||||
|
|
||||||
const thermal = formatThermal(tel.thermal_state || tel.thermalState);
|
|
||||||
setChip(els.telThermal, thermal.text, thermal.level);
|
|
||||||
|
|
||||||
els.telemetry.classList.toggle("is-stale", stale && !data.paused && !data.stream_closed);
|
|
||||||
if (!els.telMeta) return;
|
|
||||||
if (stale && !lastSeen) {
|
|
||||||
els.telMeta.textContent = I18N.telemetryWaiting;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const parts = [publishing ? I18N.telemetryPublisherOn : I18N.telemetryPublisherOff];
|
|
||||||
const age = formatAge(lastSeen);
|
|
||||||
if (age) parts.push(age);
|
|
||||||
if (stale && !data.paused && !data.stream_closed) parts.push(I18N.telemetryStale);
|
|
||||||
els.telMeta.textContent = parts.join(" · ");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollStatus() {
|
async function pollStatus() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } });
|
const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } });
|
||||||
@@ -578,8 +389,6 @@
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
applyScorePayload(data);
|
applyScorePayload(data);
|
||||||
setBadge(data);
|
setBadge(data);
|
||||||
syncMinQuality(data);
|
|
||||||
syncTelemetry(data);
|
|
||||||
syncPreviewFromStatus(data);
|
syncPreviewFromStatus(data);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
@@ -592,7 +401,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startPreview() {
|
function startPreview() {
|
||||||
if (cfg.streamClosed || !previewWanted || !els.preview || !cfg.hlsUrl || previewStarted) return;
|
if (cfg.streamClosed || !els.preview || !cfg.hlsUrl || previewStarted) return;
|
||||||
|
|
||||||
previewStarted = true;
|
previewStarted = true;
|
||||||
const url = hlsUrlFresh();
|
const url = hlsUrlFresh();
|
||||||
@@ -645,8 +454,6 @@
|
|||||||
const wasPaused = streamPaused;
|
const wasPaused = streamPaused;
|
||||||
applyScorePayload(data);
|
applyScorePayload(data);
|
||||||
setBadge(data);
|
setBadge(data);
|
||||||
syncMinQuality(data);
|
|
||||||
syncTelemetry(data);
|
|
||||||
syncPreviewFromStatus(data);
|
syncPreviewFromStatus(data);
|
||||||
toast(wasPaused ? I18N.resumed : I18N.pausedCover);
|
toast(wasPaused ? I18N.resumed : I18N.pausedCover);
|
||||||
}
|
}
|
||||||
@@ -666,70 +473,6 @@
|
|||||||
els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message)));
|
els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message)));
|
||||||
document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message)));
|
document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message)));
|
||||||
|
|
||||||
let minQualityBusy = false;
|
|
||||||
|
|
||||||
function setQualityPanelOpen(open) {
|
|
||||||
if (!els.qualityPanel || !els.telBitrate) return;
|
|
||||||
els.qualityPanel.hidden = !open;
|
|
||||||
els.telBitrate.setAttribute("aria-expanded", open ? "true" : "false");
|
|
||||||
els.telBitrate.classList.toggle("is-open", open);
|
|
||||||
if (open && els.minQuality) {
|
|
||||||
window.requestAnimationFrame(() => els.minQuality.focus());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncMinQuality(data) {
|
|
||||||
if (!els.minQuality || minQualityBusy) return;
|
|
||||||
const value = data.min_quality_preset || "auto";
|
|
||||||
if (els.minQuality.value !== value) els.minQuality.value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setMinQuality(preset) {
|
|
||||||
if (!cfg.minQualityUrl) return;
|
|
||||||
minQualityBusy = true;
|
|
||||||
try {
|
|
||||||
const res = await fetch(cfg.minQualityUrl, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
||||||
body: JSON.stringify({ min_quality_preset: preset })
|
|
||||||
});
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
if (!res.ok) throw new Error(data.error || I18N.minQualityError);
|
|
||||||
minQualityBusy = false;
|
|
||||||
syncMinQuality(data);
|
|
||||||
toast(I18N.minQualityUpdated);
|
|
||||||
} catch (e) {
|
|
||||||
toast(e.message || I18N.minQualityError);
|
|
||||||
} finally {
|
|
||||||
minQualityBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
els.minQuality?.addEventListener("change", () => {
|
|
||||||
setMinQuality(els.minQuality.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
els.telBitrate?.addEventListener("click", (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const opening = !!(els.qualityPanel && els.qualityPanel.hidden);
|
|
||||||
setQualityPanelOpen(opening);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("click", (e) => {
|
|
||||||
if (!els.qualityPanel || els.qualityPanel.hidden) return;
|
|
||||||
if (els.qualityPanel.contains(e.target) || els.telBitrate?.contains(e.target)) return;
|
|
||||||
setQualityPanelOpen(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("keydown", (e) => {
|
|
||||||
if (e.key === "Escape") setQualityPanelOpen(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
els.btnTogglePreview?.addEventListener("click", () => setPreviewWanted(!previewWanted));
|
|
||||||
previewWanted = readPreviewPref();
|
|
||||||
syncPreviewToggleUi();
|
|
||||||
if (!previewWanted) destroyPreview();
|
|
||||||
|
|
||||||
async function shareLink(url, title, text) {
|
async function shareLink(url, title, text) {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
toast(I18N.linkUnavailable);
|
toast(I18N.linkUnavailable);
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe YoutubeRelayStopJob, type: :job do
|
||||||
|
it "requeues when stop runs on the wrong host" do
|
||||||
|
session = instance_double(StreamSession, id: SecureRandom.uuid)
|
||||||
|
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,7 +1,7 @@
|
|||||||
require "rails_helper"
|
require "rails_helper"
|
||||||
|
|
||||||
RSpec.describe "Public regia", type: :request do
|
RSpec.describe "Public regia", type: :request do
|
||||||
let!(:user) { User.create!(email: "regia@test.it", name: "U", password: "Password123", role: "coach") }
|
let!(:user) { User.create!(email: "regia@test.it", name: "U", password: "password123", role: "coach") }
|
||||||
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
||||||
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
|
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
|
||||||
@@ -12,18 +12,7 @@ RSpec.describe "Public regia", type: :request do
|
|||||||
get public_regia_path(token)
|
get public_regia_path(token)
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(response.body).to include("Condividi diretta")
|
expect(response.body).to include("Condividi diretta")
|
||||||
expect(response.body).to include("Copia live")
|
|
||||||
expect(response.body).to include("Condividi link regia")
|
expect(response.body).to include("Condividi link regia")
|
||||||
expect(response.body).to include("Copia regia")
|
|
||||||
expect(response.body).to include(">Diretta<")
|
|
||||||
expect(response.body).to include(">Regia<")
|
|
||||||
expect(response.body).to include("regia-tool-group")
|
|
||||||
expect(response.body).to include("Nascondi anteprima")
|
|
||||||
expect(response.body).to include('id="btn-pause"')
|
|
||||||
expect(response.body).to include('id="btn-stop"')
|
|
||||||
expect(response.body).to include("Qualità minima di trasmissione")
|
|
||||||
expect(response.body).to include('id="tel-bitrate"')
|
|
||||||
expect(response.body).to include('aria-controls="regia-quality"')
|
|
||||||
expect(response.body).to include("/live/#{session.id}")
|
expect(response.body).to include("/live/#{session.id}")
|
||||||
expect(response.body).not_to include("Aruba")
|
expect(response.body).not_to include("Aruba")
|
||||||
end
|
end
|
||||||
@@ -167,39 +156,4 @@ RSpec.describe "Public regia", type: :request do
|
|||||||
get public_regia_path(token)
|
get public_regia_path(token)
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "aggiorna la qualità minima di trasmissione" do
|
|
||||||
token = Sessions::RegiaAccess.new(session).issue_token!
|
|
||||||
patch public_regia_min_quality_path(token), params: { min_quality_preset: "720p_30_1.5mbps" }
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(session.reload.min_quality_preset).to eq("720p_30_1.5mbps")
|
|
||||||
json = JSON.parse(response.body)
|
|
||||||
expect(json["min_quality_preset"]).to eq("720p_30_1.5mbps")
|
|
||||||
end
|
|
||||||
|
|
||||||
it "espone la telemetria della camera nello status" do
|
|
||||||
session.device_states.create!(
|
|
||||||
device_role: "camera",
|
|
||||||
battery_level: 81,
|
|
||||||
network_type: "WiFi",
|
|
||||||
current_bitrate: 2_400_000,
|
|
||||||
target_bitrate: 4_500_000,
|
|
||||||
fps: 30,
|
|
||||||
thermal_state: "nominal",
|
|
||||||
last_seen_at: Time.current
|
|
||||||
)
|
|
||||||
token = Sessions::RegiaAccess.new(session).issue_token!
|
|
||||||
get public_regia_path(token)
|
|
||||||
expect(response.body).to include('id="regia-telemetry"')
|
|
||||||
|
|
||||||
get public_regia_status_path(token)
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
tel = response.parsed_body["telemetry"]
|
|
||||||
expect(tel["battery"]).to eq(81)
|
|
||||||
expect(tel["network"]).to eq("WiFi")
|
|
||||||
expect(tel["fps"]).to eq(30)
|
|
||||||
expect(tel["bitrate"]).to eq(2_400_000)
|
|
||||||
expect(tel["thermal_state"]).to eq("nominal")
|
|
||||||
expect(tel).to have_key("publisher_online")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
require "rails_helper"
|
|
||||||
|
|
||||||
RSpec.describe "Public support page", type: :request do
|
|
||||||
it "è pubblica, indicizzabile e mostra l'email di supporto configurata" do
|
|
||||||
get public_support_path
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(response.body).to include(MatchLiveTv.support_email)
|
|
||||||
expect(response.body).to include("mailto:#{MatchLiveTv.support_email}")
|
|
||||||
expect(response.body).to include(I18n.t("legal.support.h1", locale: :it))
|
|
||||||
expect(response.body).to include('rel="canonical"')
|
|
||||||
expect(response.body).to include(public_support_path)
|
|
||||||
expect(response.body).to include(public_privacy_path)
|
|
||||||
end
|
|
||||||
|
|
||||||
it "non espone CTA o link commerciali (App Store Review)" do
|
|
||||||
get public_support_path
|
|
||||||
body = response.body
|
|
||||||
|
|
||||||
expect(body).not_to include(public_prezzi_path)
|
|
||||||
expect(body).not_to include(public_signup_path)
|
|
||||||
expect(body).not_to include('href="/prezzi"')
|
|
||||||
expect(body).not_to include('href="/signup"')
|
|
||||||
expect(body).not_to include(I18n.t("nav.signup", locale: :it))
|
|
||||||
expect(body).not_to include(I18n.t("nav.pricing", locale: :it))
|
|
||||||
expect(body).not_to include(I18n.t("common.pricing", locale: :it))
|
|
||||||
expect(body).not_to match(/\bPremium Light\b/i)
|
|
||||||
expect(body).not_to match(/\bPremium Full\b/i)
|
|
||||||
expect(body).not_to match(/\bpiano Free\b/i)
|
|
||||||
expect(body).not_to match(/\bAbbonati\b/i)
|
|
||||||
expect(body).not_to match(/\bAcquista\b/i)
|
|
||||||
end
|
|
||||||
|
|
||||||
{
|
|
||||||
"it" => "Supporto Match Live TV",
|
|
||||||
"en" => "Match Live TV Support",
|
|
||||||
"fr" => "Support Match Live TV",
|
|
||||||
"de" => "Match Live TV Support",
|
|
||||||
"es" => "Soporte Match Live TV"
|
|
||||||
}.each do |locale, heading|
|
|
||||||
it "renderizza correttamente in #{locale} senza translation missing" do
|
|
||||||
cookies[:mltv_locale] = locale
|
|
||||||
get public_support_path
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(response.body).to include(heading)
|
|
||||||
expect(response.body).not_to include("translation missing")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "include /support nella sitemap" do
|
|
||||||
get "/sitemap.xml"
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(response.body).to include("#{MatchLiveTv.app_public_url.chomp('/')}/support")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -25,6 +25,7 @@ RSpec.describe Ops::HealthChecks do
|
|||||||
%i[
|
%i[
|
||||||
check_recordings_size check_postgres check_redis check_mediamtx check_garage
|
check_recordings_size check_postgres check_redis check_mediamtx check_garage
|
||||||
check_sidekiq_heartbeat check_sidekiq_dead check_http_rails check_rails_latency
|
check_sidekiq_heartbeat check_sidekiq_dead check_http_rails check_rails_latency
|
||||||
|
check_stream_overflow
|
||||||
].each do |method|
|
].each do |method|
|
||||||
allow_any_instance_of(described_class).to receive(method).and_return(
|
allow_any_instance_of(described_class).to receive(method).and_return(
|
||||||
described_class::Finding.new(
|
described_class::Finding.new(
|
||||||
@@ -52,6 +53,7 @@ RSpec.describe Ops::HealthChecks do
|
|||||||
%i[
|
%i[
|
||||||
check_recordings_size check_postgres check_redis check_mediamtx check_garage
|
check_recordings_size check_postgres check_redis check_mediamtx check_garage
|
||||||
check_sidekiq_heartbeat check_sidekiq_dead check_http_rails check_rails_latency
|
check_sidekiq_heartbeat check_sidekiq_dead check_http_rails check_rails_latency
|
||||||
|
check_stream_overflow
|
||||||
].each do |method|
|
].each do |method|
|
||||||
allow_any_instance_of(described_class).to receive(method).and_return(
|
allow_any_instance_of(described_class).to receive(method).and_return(
|
||||||
described_class::Finding.new(
|
described_class::Finding.new(
|
||||||
@@ -65,7 +67,7 @@ RSpec.describe Ops::HealthChecks do
|
|||||||
summary = described_class.new.summary
|
summary = described_class.new.summary
|
||||||
|
|
||||||
expect(summary[:status]).to eq("ok")
|
expect(summary[:status]).to eq("ok")
|
||||||
expect(summary[:checks].size).to eq(10)
|
expect(summary[:checks].size).to eq(11)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "degraded quando la latenza p95 supera la soglia warning" do
|
it "degraded quando la latenza p95 supera la soglia warning" do
|
||||||
|
|||||||
@@ -11,11 +11,4 @@ RSpec.describe Sessions::SelectQuality do
|
|||||||
|
|
||||||
expect(preset[:id]).to eq("1080p_30_4.5mbps")
|
expect(preset[:id]).to eq("1080p_30_4.5mbps")
|
||||||
end
|
end
|
||||||
|
|
||||||
it "offre 1080p come qualità minima solo se la sessione è 1080p" do
|
|
||||||
expect(described_class.allowed_min_ids("720p_30_2.5mbps")).to eq(
|
|
||||||
%w[auto 720p_30_1.5mbps 720p_30_2.5mbps 720p_30_4mbps]
|
|
||||||
)
|
|
||||||
expect(described_class.allowed_min_ids("1080p_30_4.5mbps")).to include("1080p_30_4.5mbps")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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,178 @@
|
|||||||
|
# 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 "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,95 @@
|
|||||||
|
# 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 "allocates the least loaded ready node" 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
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
StreamSession.create!(
|
||||||
|
match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
expect(cmd).not_to include("-b:a")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "multi-host sticky stop/capacity" do
|
||||||
|
let(:redis) { Redis.new(url: ENV.fetch("REDIS_URL", "redis://redis:6379/0")) }
|
||||||
|
let(:session_id) { SecureRandom.uuid }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(described_class).to receive(:worker?).and_return(true)
|
||||||
|
allow(described_class).to receive(:worker_id).and_return("worker-a")
|
||||||
|
allow(described_class).to receive(:max_concurrent).and_return(1)
|
||||||
|
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
|
end
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
Documento di riferimento per l'intero sistema: rete, container Docker, flussi video, replay, monitoraggio ops e rilasci.
|
Documento di riferimento per l'intero sistema: rete, container Docker, flussi video, replay, monitoraggio ops e rilasci.
|
||||||
|
|
||||||
**Ultimo aggiornamento:** 2026-07-29
|
**Ultimo aggiornamento:** 2026-08-09
|
||||||
**Produzione:** `eminux@192.168.1.146` → `/opt/matchlivetv`
|
**Produzione:** `eminux@192.168.1.146` → `/opt/matchlivetv` (Proxmox; control plane attuale per autoscale)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -397,7 +397,8 @@ cd infra && cp .env.example .env && docker compose up -d --build
|
|||||||
| Documento | Contenuto |
|
| Documento | Contenuto |
|
||||||
|-----------|-----------|
|
|-----------|-----------|
|
||||||
| [`infrastructure/SERVER_DEPLOYMENT.md`](infrastructure/SERVER_DEPLOYMENT.md) | Bootstrap server, NPM, cron, backup |
|
| [`infrastructure/SERVER_DEPLOYMENT.md`](infrastructure/SERVER_DEPLOYMENT.md) | Bootstrap server, NPM, cron, backup |
|
||||||
| [`infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md`](infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md) | Piano overflow relay YouTube su Hetzner Cloud (picchi) |
|
| [`infrastructure/STREAMING_AUTOSCALE.md`](infrastructure/STREAMING_AUTOSCALE.md) | **Design attuale:** Proxmox prod + Hetzner Cloud warm spare (MediaMTX+ffmpeg), lab, DNS, disco; Auction in futuro |
|
||||||
|
| [`infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md`](infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md) | Piano storico overflow-only relay YouTube (superseded dal doc sopra) |
|
||||||
| [`ANDROID_APP_LINKS.md`](ANDROID_APP_LINKS.md) | Digital Asset Links / deep link Play (`assetlinks.json`) |
|
| [`ANDROID_APP_LINKS.md`](ANDROID_APP_LINKS.md) | Digital Asset Links / deep link Play (`assetlinks.json`) |
|
||||||
| [`LIVE_STREAMING.md`](LIVE_STREAMING.md) | MediaMTX, pausa, HLS, ruolo ffmpeg |
|
| [`LIVE_STREAMING.md`](LIVE_STREAMING.md) | MediaMTX, pausa, HLS, ruolo ffmpeg |
|
||||||
| [`REPLAY_MODULE.md`](REPLAY_MODULE.md) | Garage, retention, YouTube VOD |
|
| [`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-06 | Cleanup path MediaMTX orfani (`Mediamtx::CleanupOrphanPaths`); delete path sempre a fine diretta |
|
||||||
| 2026-07 | Rimosso overlay server (`OverlayRelay`); tabellone bruciato in app; HLS su path camera (non `*_air`); `YoutubeRelay` = copy video + AAC |
|
| 2026-07 | Rimosso overlay server (`OverlayRelay`); tabellone bruciato in app; HLS su path camera (non `*_air`); `YoutubeRelay` = copy video + AAC |
|
||||||
| 2026-08 | `YoutubeRelay` = remux copy video+audio (niente ricodifica AAC); profilo app/slate AAC 48k mono |
|
| 2026-08 | `YoutubeRelay` = remux copy video+audio (niente ricodifica AAC); profilo app/slate AAC 48k mono |
|
||||||
|
| 2026-08 | Design autoscale: Proxmox prod + Hetzner Cloud warm spare (MediaMTX+ffmpeg), lab locale, secondo dominio DNS, Garage ora / B2 dopo; Auction rimandato (`STREAMING_AUTOSCALE.md`) |
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
# Piano: overflow relay YouTube su Hetzner Cloud
|
# Piano: overflow relay YouTube su Hetzner Cloud
|
||||||
|
|
||||||
**Stato:** piano / design — non implementato
|
> **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.
|
||||||
**Ultimo aggiornamento:** 2026-07-29
|
|
||||||
|
**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.
|
**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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
# Autoscale streaming — Proxmox attuale + Hetzner Cloud
|
||||||
|
|
||||||
|
**Stato:** fasi 0–4 implementate sul branch — **solo test lab; nessun deploy produzione**
|
||||||
|
**Branch:** `feature/streaming-autoscale-hetzner`
|
||||||
|
**Ultimo aggiornamento:** 2026-08-09 (Fase 4 hardening + runbook)
|
||||||
|
|
||||||
|
**Sostituisce / estende:** il piano overflow-only [`HETZNER_CLOUD_RELAY_OVERFLOW.md`](HETZNER_CLOUD_RELAY_OVERFLOW.md). Questo documento copre **MediaMTX + ffmpeg**, warm spare, DNS, storage, disco e lab sul Proxmox di produzione.
|
||||||
|
|
||||||
|
Documenti correlati: [`ARCHITECTURE.md`](../ARCHITECTURE.md), [`LIVE_STREAMING.md`](../LIVE_STREAMING.md), [`REPLAY_MODULE.md`](../REPLAY_MODULE.md), [`OPS_MONITORING.md`](../OPS_MONITORING.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Obiettivo
|
||||||
|
|
||||||
|
Scalare molte dirette contemporanee (sito e/o YouTube) senza saturare un unico box:
|
||||||
|
|
||||||
|
- **Control plane (ora):** Proxmox **già in produzione** (`192.168.1.146` / `/opt/matchlivetv`) — sito, API, DB, Redis, Garage, MediaMTX/ffmpeg “home”.
|
||||||
|
- **Data plane elastico:** **Hetzner Cloud** — nodi `MediaMTX + ffmpeg` con warm spare (+1).
|
||||||
|
- **DNS ingest:** secondo dominio su **Hetzner DNS** (API).
|
||||||
|
- **Lab:** stesso Proxmox, con `ProxmoxLabProvider`, prima di affidarsi al Cloud in produzione.
|
||||||
|
- **Futuro (non bloccante):** migrazione control plane su **Hetzner Auction + Proxmox** (vSwitch nativo, hardware dedicato).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Decisioni chiuse
|
||||||
|
|
||||||
|
| # | Decisione | Scelta |
|
||||||
|
|---|----------|--------|
|
||||||
|
| D1 | Host primario **ora** | **Proxmox già usato in produzione** (non attendere Auction) |
|
||||||
|
| D1b | Host primario **futuro** | Hetzner Server Auction + Proxmox (quando si migra) |
|
||||||
|
| D2 | Overflow / warm spare | **Hetzner Cloud** (attivato per questo progetto) |
|
||||||
|
| D3 | Unità di scala | Nodo **stream** = MediaMTX + worker `youtube_relay` (ffmpeg remux copy) |
|
||||||
|
| D4 | Warm pool | Almeno **1 spare ready** quando il carico si avvicina al cap |
|
||||||
|
| D5 | DNS ingest | Dominio **`mltv-stream.net`** (registrar Aruba, NS → Hetzner DNS). `matchlivetv.it` resta su Aruba intatto |
|
||||||
|
| D6 | Aruba | Nessuna delega NS; niente Floating IP prenotate |
|
||||||
|
| D7a | Rete **ora** (casa/Proxmox ↔ Cloud) | **WireGuard** (o Tailscale) privato: Redis, DB, API MediaMTX home. Non esporre Redis/Postgres su WAN |
|
||||||
|
| D7b | Rete **futuro** (Auction ↔ Cloud) | vSwitch Robot + Cloud Network (stessa location) |
|
||||||
|
| D8 | Object storage | **Garage resta**. **B2** = migrazione pronta quando misurato |
|
||||||
|
| D9 | Staging | Segmenti su disco VM `stream-*` in live; upload Garage a fine sessione |
|
||||||
|
| D10 | Prodotto | Consigliare **YouTube** per alleggerire storage/egress replay |
|
||||||
|
| D11 | Disco | Separazione volumi + **monitoraggio attivo** obbligatorio (§6) |
|
||||||
|
| D12 | Lab prima del Cloud prod | `ProxmoxLabProvider` + DNS lab sullo stesso Proxmox; poi `HetznerCloudProvider` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Topologia — fase attuale (Proxmox prod + Cloud)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Internet
|
||||||
|
│ HTTPS :443 · RTMP :1935
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ Proxmox produzione (attuale) │
|
||||||
|
│ eminux@192.168.1.146 · /opt/matchlivetv │
|
||||||
|
│ │
|
||||||
|
│ edge · rails · sidekiq-core · postgres · redis │
|
||||||
|
│ garage · stream-home (MediaMTX + youtube_relay) │
|
||||||
|
│ autoscaler (Rails/Sidekiq) │
|
||||||
|
│ │
|
||||||
|
│ Lab (opz.): clone VM stream-* via ProxmoxLabProvider │
|
||||||
|
└────────────────────────┬─────────────────────────────────┘
|
||||||
|
│ WireGuard / tunnel privato
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ Hetzner Cloud — pool stream nodes │
|
||||||
|
│ stream-01 … stream-N MediaMTX + ffmpeg │
|
||||||
|
│ + 1 spare ready (warm) │
|
||||||
|
└────────────────────────┬─────────────────────────────────┘
|
||||||
|
│
|
||||||
|
Hetzner DNS (secondo dominio)
|
||||||
|
ingest-XX.<dominio> → IP pubblico nodo
|
||||||
|
```
|
||||||
|
|
||||||
|
**Futuro Auction:** stesso schema, tunnel sostituito da vSwitch; control plane migrato sul bare metal Hetzner.
|
||||||
|
|
||||||
|
**Principio:** Rails solo controllo. Live sui MediaMTX del nodo assegnato. Replay su Garage (poi eventualmente B2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. DNS (secondo dominio)
|
||||||
|
|
||||||
|
RTMP **non** tollera round-robin su un unico hostname.
|
||||||
|
|
||||||
|
1. Dominio **`mltv-stream.net`** registrato su Aruba; zona DNS su Hetzner Console (progetto `matchlivetv-stream`).
|
||||||
|
2. NS Aruba del dominio → `hydrogen` / `oxygen` / `helium` Hetzner (delegazione OK).
|
||||||
|
3. Autoscaler a VM ready → upsert `A` `ingest-03.mltv-stream.net` → health-check → nodo `ready`.
|
||||||
|
4. API create/start sessione → URL del nodo assegnato:
|
||||||
|
|
||||||
|
```text
|
||||||
|
rtmp://ingest-03.mltv-stream.net:1935/live/match_<uuid>
|
||||||
|
https://ingest-03.mltv-stream.net/hls/... # o via edge matchlivetv.it che proxya il nodo
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Scale-in: drain → delete DNS → destroy VM.
|
||||||
|
|
||||||
|
Hostname pubblici dei nodi vivono sotto `*.mltv-stream.net`. In lab: `LabDnsProvider` (`/etc/hosts`, dnsmasq).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Autoscaler e warm spare
|
||||||
|
|
||||||
|
Ogni nodo: `max_publishers` / `max_relays` (es. 4).
|
||||||
|
|
||||||
|
| Regola | Comportamento |
|
||||||
|
|--------|----------------|
|
||||||
|
| Scale-out | `free_slots` sotto soglia soft → crea 1 nodo |
|
||||||
|
| Warm spare | Se `spare_ready < 1` vicino al carico → crea spare |
|
||||||
|
| Scale-in | Idle da X min e non unica spare → destroy |
|
||||||
|
| Floor | `stream-home` sul Proxmox sempre on |
|
||||||
|
|
||||||
|
`CloudProvider`: `HetznerCloudProvider` (prod overflow) + `ProxmoxLabProvider` (test).
|
||||||
|
Code Sidekiq: coda `youtube_relay` isolata; sticky owner Redis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Disco Proxmox — requisito critico
|
||||||
|
|
||||||
|
**Non** mettere OS, DB e video sullo stesso volume che può riempirsi al 100%.
|
||||||
|
|
||||||
|
Vale **già** sul Proxmox di produzione (rinforzare ora), e di nuovo alla migrazione Auction.
|
||||||
|
|
||||||
|
### 6.1 Layout volumi (target)
|
||||||
|
|
||||||
|
| Volume / disco | Contenuto | Note |
|
||||||
|
|----------------|-----------|------|
|
||||||
|
| `os` | Proxmox / sistema host | Quasi statico |
|
||||||
|
| `vm-system` / root stack | rails, edge, redis, … | Snapshot |
|
||||||
|
| `vm-data-db` | Postgres | Separato; backup prioritari |
|
||||||
|
| `vm-data-video` | Garage + staging MediaMTX | **Disco che può riempirsi** |
|
||||||
|
| `backup` | Backup / PBS | **Mai** sullo stesso disco dei video |
|
||||||
|
|
||||||
|
Regole: cap Garage/staging; se staging pieno → **rifiutare nuove sessioni**, non far cadere DB/API.
|
||||||
|
|
||||||
|
### 6.2 Monitoraggio attivo (obbligatorio)
|
||||||
|
|
||||||
|
| Metrica | Warn | Critical |
|
||||||
|
|---------|------|----------|
|
||||||
|
| Uso `%` volume video | ≥ 70% | ≥ 85% |
|
||||||
|
| Spazio libero GB | sotto soglia | < X GB |
|
||||||
|
| Inode liberi | bassi | esaurimento |
|
||||||
|
| Crescita GB/giorno Garage | anomalia | — |
|
||||||
|
| Purge/retention falliti | — | immediato |
|
||||||
|
| Staging per nodo `stream-*` | ≥ 70% | ≥ 85% + blocco live su quel nodo |
|
||||||
|
|
||||||
|
Checklist:
|
||||||
|
|
||||||
|
- [ ] Alert ntfy disco testati
|
||||||
|
- [ ] Vista ops “disco video”
|
||||||
|
- [ ] Test periodico “80% pieno”
|
||||||
|
- [ ] Restore di prova Postgres
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Storage: Garage ora, B2 dopo
|
||||||
|
|
||||||
|
| Ora | **Garage** |
|
||||||
|
| Dopo | **Backblaze B2** quando metriche/€ lo giustificano |
|
||||||
|
|
||||||
|
Client S3-ready; staging locale invariato. Spinta prodotto YouTube riduce pressione replay.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Prodotto: YouTube consigliato
|
||||||
|
|
||||||
|
Suggerire YouTube in app/dashboard quando ha senso → meno storage/egress MatchLiveTV; middle-tier (slate + relay) resta il valore. Live solo-sito restano supportate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Astrazioni codice
|
||||||
|
|
||||||
|
```text
|
||||||
|
CloudProvider
|
||||||
|
create_node / destroy_node / list_nodes / wait_until_running / public_ip
|
||||||
|
→ HetznerCloudProvider | ProxmoxLabProvider
|
||||||
|
|
||||||
|
DnsProvider
|
||||||
|
upsert_a / delete_a
|
||||||
|
→ HetznerDnsProvider | LabDnsProvider
|
||||||
|
|
||||||
|
StreamNodeRegistry
|
||||||
|
register / mark_ready / allocate_for_session / drain / release
|
||||||
|
|
||||||
|
Autoscaler
|
||||||
|
reconcile!(metrics) → ensure spare / scale-in idle
|
||||||
|
```
|
||||||
|
|
||||||
|
Tag VM Cloud: `matchlivetv`, `role=stream-node`, `env=prod|lab`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Lab sul Proxmox attuale
|
||||||
|
|
||||||
|
Prima (o in parallelo) al Cloud “vero”:
|
||||||
|
|
||||||
|
1. Template VM `stream-node` su Proxmox.
|
||||||
|
2. `CLOUD_PROVIDER=proxmox_lab` → clone/start/stop via API Proxmox.
|
||||||
|
3. DNS lab (hosts/dnsmasq).
|
||||||
|
4. Soft cap basso; N sessioni di test; verifica assignment, spare, scale-in, alert disco.
|
||||||
|
5. Poi smoke su Hetzner Cloud (1 CX piccolo) + DNS reale.
|
||||||
|
|
||||||
|
Cosa il lab **non** replica al 100%: tempi boot Hetzner, vSwitch, RTMP 4G multi-nodo (smoke WAN dopo).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Fasi di implementazione
|
||||||
|
|
||||||
|
| Fase | Cosa | Esito |
|
||||||
|
|------|------|--------|
|
||||||
|
| **L — Lab Proxmox** | `LocalLab` / `ProxmoxLab`, DNS lab, admin nodi | **implementata sul branch** |
|
||||||
|
| **0 — Multi-nodo ready** | Registry + URL RTMP/HLS in API (`StreamNode`, `Streams::NodeRegistry`) | **implementata sul branch** |
|
||||||
|
| **1 — Hetzner Cloud + DNS** | `HetznerCloudProvider` + `HetznerDnsProvider`, `mltv-stream.net`, cloud-init, WireGuard (§16) | **implementata sul branch** (WG ops manuale) |
|
||||||
|
| **2 — Routing relay** | Coda `youtube_relay`, sticky owner, cap `RELAY_MAX_CONCURRENT` | **implementata sul branch** |
|
||||||
|
| **3 — Autoscaler** | Soglie + warm spare (+1), kill-switch `STREAM_AUTOSCALE_ENABLED` | **implementata sul branch** |
|
||||||
|
| **4 — Hardening** | Drain sicuro, budget, kill-switch Redis/admin, alert overflow, runbook | **implementata sul branch** (solo test lab — **non in prod**) |
|
||||||
|
| **A — Auction (futuro)** | Migrazione control plane + vSwitch al posto di WireGuard | Hardware dedicato Hetzner |
|
||||||
|
| **B2 (opz.)** | Switch object storage | Quando misurato |
|
||||||
|
|
||||||
|
### Fase 0 — dettagli implementati
|
||||||
|
|
||||||
|
- Tabella `stream_nodes` + `stream_sessions.stream_node_id`
|
||||||
|
- `Streams::NodeRegistry.ensure_home_from_env!` / `allocate!` (least-loaded)
|
||||||
|
- `Sessions::Create` assegna il nodo e crea il path MediaMTX sul client del nodo
|
||||||
|
- URL RTMP/HLS/API/internal per sessione derivati dal nodo (fallback ENV se nodo assente)
|
||||||
|
- API JSON espone `stream_node` (slug)
|
||||||
|
- Dominio ingest pubblico futuro: `*.mltv-stream.net`
|
||||||
|
|
||||||
|
### Fase L — dettagli implementati
|
||||||
|
|
||||||
|
- `Streams::CloudProviders` (`local_lab`, `proxmox_lab`, stub `hetzner`)
|
||||||
|
- `Streams::DnsProviders` (`lab` su Redis, stub `hetzner`)
|
||||||
|
- `Streams::NodeProvisioner` (provision / drain / decommission)
|
||||||
|
- Admin **Nodi stream** (`/admin/stream_nodes`): lista, provision lab, drain, delete
|
||||||
|
- Rake: `streams:nodes:ensure_home`, `streams:nodes:provision_lab`, `streams:nodes:dns_lab_dump`
|
||||||
|
- Default sicuro: `STREAM_CLOUD_PROVIDER=local_lab` (nessuna chiamata Proxmox finché non configuri token)
|
||||||
|
|
||||||
|
### Fase 1 — dettagli implementati
|
||||||
|
|
||||||
|
- `Streams::CloudProviders::Hetzner` (create/destroy server, wait running, labels)
|
||||||
|
- `Streams::DnsProviders::Hetzner` (upsert/delete A su zona `mltv-stream.net`)
|
||||||
|
- `NodeProvisioner#provision_cloud!` + bottone admin **Provisiona nodo Hetzner**
|
||||||
|
- Cloud-init: `infra/stream-node/cloud-init.yaml` (`HCLOUD_USER_DATA_FILE`)
|
||||||
|
- Decommission usa il provider del nodo (non solo ENV globale)
|
||||||
|
- WireGuard: vedi §16
|
||||||
|
|
||||||
|
### Fase 2 — dettagli implementati
|
||||||
|
|
||||||
|
- Coda Sidekiq dedicata `youtube_relay` (priorità sopra `default`)
|
||||||
|
- `YoutubeRelayEnsureJob` / `YoutubeRelayStopJob` solo su quella coda
|
||||||
|
- Stop sticky: non cancella owner da Rails; lo stop gira sull’owner o requeue (`:wrong_host`)
|
||||||
|
- Cap per worker: `RELAY_MAX_CONCURRENT` (default 4) + set Redis `youtube_relay:owned:HOSTNAME`
|
||||||
|
- Ensure a capacità piena → requeue 5s invece di avviare un secondo ffmpeg locale
|
||||||
|
|
||||||
|
### Fase 3 — dettagli implementati
|
||||||
|
|
||||||
|
- `Streams::Autoscaler` + job chain `Streams::AutoscalerJob` (come health monitor)
|
||||||
|
- Kill-switch: `STREAM_AUTOSCALE_ENABLED=1` per attivare
|
||||||
|
- Scale-out se `free_slots ≤ STREAM_AUTOSCALE_SOFT_FREE_SLOTS`
|
||||||
|
- Warm spare (+N) se carico/soglia e `spare_ready < WARM_SPARE`
|
||||||
|
- Scale-in overflow idle da `IDLE_MINUTES` (non tocca home; rispetta warm spare)
|
||||||
|
- Cap `STREAM_AUTOSCALE_MAX_NODES`; kind `lab|cloud`
|
||||||
|
- Metriche in admin Nodi stream
|
||||||
|
|
||||||
|
### Fase 4 — hardening (implementata sul branch)
|
||||||
|
|
||||||
|
> **NON rilasciare in produzione** finché lab + smoke Cloud non sono verdi. Su questo branch restano `STREAM_AUTOSCALE_ENABLED=0` e `STREAM_AUTOSCALE_ALLOW_CLOUD=0`.
|
||||||
|
|
||||||
|
- Scale-in **sicuro**: `drain!` → attesa sessioni zero → `decommission!`
|
||||||
|
- Budget soft: `STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR` + `STREAM_AUTOSCALE_NODE_EUR_PER_HOUR` (stima 24/7); scale-out bloccato se sforerebbe
|
||||||
|
- Cloud autoscale solo con `STREAM_AUTOSCALE_ALLOW_CLOUD=1` **e** `HCLOUD_TOKEN`
|
||||||
|
- Kill-switch runtime Redis `streams:autoscaler:kill_switch` (bottoni admin ON/OFF) oltre all’ENV
|
||||||
|
- Health check `stream_overflow` → incidente Ops/ntfy su nodi idle orfani, over-budget, capacità al max
|
||||||
|
- Admin: metriche budget + stato kill-switch
|
||||||
|
|
||||||
|
#### Runbook operativo (lab / pre-prod)
|
||||||
|
|
||||||
|
| Situazione | Azione |
|
||||||
|
|------------|--------|
|
||||||
|
| Dubbio / picco anomalo | Admin → **Kill-switch ON** (o `STREAM_AUTOSCALE_ENABLED=0`) |
|
||||||
|
| Nodo spillato | Drain dal admin; dopo fine live → Destroy |
|
||||||
|
| Budget alert Ops | Abbassa `MAX_NODES` / alza budget solo dopo review costi Hetzner |
|
||||||
|
| Smoke Cloud | Provision **manuale** admin (kind cloud), non autoscaler; verifica WG + RTMP 1935 |
|
||||||
|
| Scale-out lab OK | Solo dopo: considerare `ENABLED=1` + `KIND=lab` su staging/lab Proxmox |
|
||||||
|
| Produzione overflow | Solo dopo checklist §12 punto 6 |
|
||||||
|
|
||||||
|
Ordine di lavoro consigliato sul branch: **0 → L → 1 → 2 → 3 → 4**; **A** quando si decide di lasciare il Proxmox casa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Ordine operativo “ora” (senza Auction)
|
||||||
|
|
||||||
|
1. Rinforzare dischi/alert sul Proxmox prod (§6).
|
||||||
|
2. Secondo dominio + zona Hetzner DNS.
|
||||||
|
3. Progetto Hetzner Cloud + immagine/snapshot `stream-node`.
|
||||||
|
4. Tunnel WireGuard Proxmox ↔ Cloud (Redis/DB/API private).
|
||||||
|
5. Implementare fasi 0 → L → 1… sul branch.
|
||||||
|
6. Cutover overflow in produzione solo dopo lab + smoke Cloud verdi.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Criteri di successo
|
||||||
|
|
||||||
|
| Criterio | Misura |
|
||||||
|
|----------|--------|
|
||||||
|
| Lab | Scale-out/in e assignment verificati su Proxmox senza Hetzner |
|
||||||
|
| Picco | N live oltre soft cap home con spare Cloud ready |
|
||||||
|
| Continuità | Drop telefono → slate YT attiva |
|
||||||
|
| Disco | Nessun outage DB/API per disco video; alert prima del critical |
|
||||||
|
| DNS | Aruba intatto; ingest sul secondo dominio |
|
||||||
|
| Futuro | Path chiaro verso Auction senza riscrivere autoscaler |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Aperto / da calibrare
|
||||||
|
|
||||||
|
| Voce | Note |
|
||||||
|
|------|------|
|
||||||
|
| Nome secondo dominio | **`mltv-stream.net`** (chiuso) |
|
||||||
|
| Soft cap `stream-home` | es. 4 vs 6 |
|
||||||
|
| Tipo VM Cloud | CPX vs CCX |
|
||||||
|
| `OVERFLOW_MAX` / budget | — |
|
||||||
|
| Spare di notte | 0 vs 1 |
|
||||||
|
| Dettaglio WireGuard | subnet, peer, MTU |
|
||||||
|
| Soglie GB disco | da size reale volume video |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Riepilogo esecutivo
|
||||||
|
|
||||||
|
**Ora:** restiamo sul Proxmox di produzione; attiviamo Hetzner Cloud (spare MediaMTX+ffmpeg) + Hetzner DNS (secondo dominio) + WireGuard. Testiamo prima in lab sullo stesso Proxmox. Garage resta; B2 dopo. YouTube consigliato in prodotto. Disco e alert sono vincolo non negoziabile.
|
||||||
|
|
||||||
|
**Dopo:** eventuale Auction Hetzner sostituisce il control plane casa e WireGuard → vSwitch, senza cambiare il modello nodi/autoscaler.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. WireGuard (Proxmox casa ↔ Hetzner Cloud)
|
||||||
|
|
||||||
|
Obiettivo: Rails/Sidekiq sull’host di produzione raggiungono `api_base_url` / `internal_rtmp_url` dei nodi Cloud (es. `http://10.0.0.9:9997`) **senza** esporre Redis/Postgres/MediaMTX API su Internet.
|
||||||
|
|
||||||
|
### Setup consigliato (una tantum)
|
||||||
|
|
||||||
|
1. Su Hetzner Console: crea **Network** privata (es. `10.0.0.0/16`) nella location `fsn1`, subnet `10.0.1.0/24` per i server Cloud.
|
||||||
|
2. Imposta `HCLOUD_NETWORK_ID=<id>` così i nodi stream entrano nella network al create.
|
||||||
|
3. Su Proxmox (VM o LXC gateway): installa WireGuard; peer verso una VM “gateway” Cloud **sempre on** (CX22 piccola) *oppure* peer site-to-site verso un server Cloud fisso.
|
||||||
|
4. Alternativa più semplice per i primi test: **Tailscale** su Proxmox host + su ogni stream-node (cloud-init) — stesso effetto, meno ops.
|
||||||
|
5. Firewall Hetzner Cloud:
|
||||||
|
- WAN: `1935/tcp` (RTMP), `443/tcp` o `8888` se HLS diretto
|
||||||
|
- Solo rete privata / WG: `9997` (MediaMTX API), niente Postgres/Redis sui nodi stream
|
||||||
|
6. Verifica da Rails: `curl http://<private_ip>:9997/v3/paths/list`
|
||||||
|
|
||||||
|
Finché WireGuard/Tailscale non è pronto, `api_base_url` punta comunque alla private IP (o pubblica se manca private_net): il path MediaMTX da Create fallirà dal control plane se non raggiungibile — provisionare nodi Cloud solo dopo connettività privata OK, oppure smoke con API su IP pubblico temporaneo + firewall allowlist IP casa.
|
||||||
|
|
||||||
|
### ENV produzione (stream autoscale)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HCLOUD_TOKEN=...
|
||||||
|
HCLOUD_LOCATION=nbg1
|
||||||
|
HCLOUD_SERVER_TYPE=cpx12
|
||||||
|
HCLOUD_IMAGE=debian-12
|
||||||
|
HCLOUD_SSH_KEY=matchlivetv-stream-hetzner
|
||||||
|
HCLOUD_NETWORK_ID= # opzionale
|
||||||
|
HCLOUD_USER_DATA_FILE=/opt/matchlivetv/infra/stream-node/cloud-init.yaml
|
||||||
|
|
||||||
|
STREAM_DNS_ZONE=mltv-stream.net
|
||||||
|
STREAM_DNS_TTL=60
|
||||||
|
STREAM_CLOUD_DNS_SUFFIX=mltv-stream.net
|
||||||
|
STREAM_CLOUD_MAX_PUBLISHERS=4
|
||||||
|
STREAM_NODE_ENV=prod
|
||||||
|
|
||||||
|
# Default lab sicuro; in prod per overflow usa hetzner esplicitamente dal bottone admin
|
||||||
|
STREAM_CLOUD_PROVIDER=local_lab
|
||||||
|
STREAM_DNS_PROVIDER=lab
|
||||||
|
```
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user