Compare commits

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 19:52:47 +02:00
113 changed files with 3748 additions and 2226 deletions
@@ -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
@@ -180,6 +180,7 @@ module Api
platform: session.platform,
rtmp_ingest_url: session.rtmp_ingest_url,
hls_playback_url: session.hls_playback_url,
stream_node: session.stream_node&.slug,
watch_page_url: session.matchlivetv_platform? ? session.watch_page_url : nil,
share_url: session.share_url,
youtube_watch_url: session.youtube_watch_url,
@@ -57,10 +57,6 @@ module Public
Billing::Stripe::PlanChangeMessages.button_label(plan: plan, interval: interval, kind: btn_kind)
end
def plan_cta_name(plan)
I18n.t("pages.plans.cta_name.#{plan.slug}", default: plan.name)
end
def billing_plan_change_info_lines(subscription: nil)
Billing::Stripe::PlanChangeMessages.billing_info_lines(subscription: subscription)
end
@@ -6,7 +6,7 @@ class CleanupExpiredSessionsJob
.where("updated_at < ?", 6.hours.ago)
.find_each do |session|
session.fail! if session.may_fail?
Mediamtx::Client.new.delete_path(session)
Mediamtx::Client.for_session(session).delete_path(session)
end
end
end
@@ -0,0 +1,33 @@
# frozen_string_literal: true
module Streams
class AutoscalerJob
include Sidekiq::Job
sidekiq_options retry: 1, queue: "default"
INTERVAL_SECS = ENV.fetch("STREAM_AUTOSCALE_INTERVAL_SECS", "60").to_i
REDIS_CHAIN_KEY = "streams:autoscaler:chain"
def self.ensure_chain
return unless redis
return if redis.get(REDIS_CHAIN_KEY)
redis.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL_SECS * 2)
perform_in(INTERVAL_SECS)
end
def self.redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
rescue Redis::CannotConnectError
nil
end
def perform
Streams::Autoscaler.reconcile!
ensure
self.class.redis&.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL_SECS * 2)
self.class.perform_in(INTERVAL_SECS)
end
end
end
+2 -2
View File
@@ -1,6 +1,6 @@
# Avvia/riavvia il relay YouTube solo nel container sidekiq (YOUTUBE_RELAY_WORKER=1).
# Avvia/riavvia il relay YouTube solo sui worker con YOUTUBE_RELAY_WORKER=1 (coda youtube_relay).
class YoutubeRelayEnsureJob < ApplicationJob
queue_as :default
queue_as Streams::YoutubeRelay::QUEUE
def perform(session_id)
session = StreamSession.find_by(id: session_id)
+15 -3
View File
@@ -1,10 +1,22 @@
# Ferma il relay solo sull'owner. Se il job gira su un altro host, requeue breve.
class YoutubeRelayStopJob < ApplicationJob
queue_as :default
queue_as Streams::YoutubeRelay::QUEUE
def perform(session_id)
discard_on ActiveJob::DeserializationError
def perform(session_id, attempts = 0)
session = StreamSession.find_by(id: session_id)
return unless session
Streams::YoutubeRelay.stop_on_worker!(session) if Streams::YoutubeRelay.worker?
unless Streams::YoutubeRelay.worker?
# Solo i worker relay processano questa coda in modo utile.
return
end
result = Streams::YoutubeRelay.stop_on_worker!(session)
return unless result == :wrong_host
return if attempts >= 30
self.class.set(wait: 2.seconds).perform_later(session_id, attempts + 1)
end
end
+1 -1
View File
@@ -4,7 +4,7 @@ module Ops
KINDS = %w[
disk_space recordings_size service_down http_public http_rails rails_latency
sidekiq_stale sidekiq_dead log_pattern garage_storage
sidekiq_stale sidekiq_dead log_pattern garage_storage stream_overflow
].freeze
SEVERITIES = %w[critical warning info].freeze
STATUSES = %w[open acknowledged resolved].freeze
+38
View File
@@ -0,0 +1,38 @@
# frozen_string_literal: true
# Nodo streaming (MediaMTX [+ relay ffmpeg]). Fase 0: registry + assignment URL.
class StreamNode < ApplicationRecord
ROLES = %w[home cloud lab].freeze
STATUSES = %w[provisioning ready draining offline error].freeze
PROVIDERS = %w[local proxmox_lab hetzner].freeze
has_many :stream_sessions, dependent: :nullify
validates :slug, presence: true, uniqueness: true
validates :hostname, presence: true
validates :role, inclusion: { in: ROLES }
validates :status, inclusion: { in: STATUSES }
validates :provider, inclusion: { in: PROVIDERS }
validates :rtmp_base_url, :hls_base_url, :api_base_url, presence: true
validates :max_publishers, :max_relays, numericality: { greater_than: 0 }
scope :ready, -> { where(status: "ready") }
scope :allocatable, -> { ready }
# Sessioni che occupano uno slot path MediaMTX su questo nodo.
def occupying_sessions
stream_sessions.where(status: %w[idle connecting live reconnecting paused])
end
def active_publishers
occupying_sessions.count
end
def free_slots
[max_publishers - active_publishers, 0].max
end
def allocatable?
status == "ready" && free_slots.positive?
end
end
+25 -3
View File
@@ -7,6 +7,7 @@ class StreamSession < ApplicationRecord
belongs_to :match
belongs_to :user
belongs_to :stream_node, optional: true
has_many :stream_events, dependent: :destroy
has_one :score_state, dependent: :destroy
has_many :device_states, dependent: :destroy
@@ -74,7 +75,7 @@ class StreamSession < ApplicationRecord
def rtmp_ingest_url
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
# MediaMTX path = live/match_{uuid} (no ?token= nel path).
"#{MatchLiveTv.mediamtx_rtmp_url}/#{mediamtx_path_name}"
"#{rtmp_base_url.chomp('/')}/#{mediamtx_path_name}"
end
def mediamtx_path_name
@@ -90,8 +91,29 @@ class StreamSession < ApplicationRecord
end
def hls_playback_url
base = MatchLiveTv.hls_public_url.chomp("/")
"#{base}/#{effective_hls_path_name}/index.m3u8"
"#{hls_base_url.chomp('/')}/#{effective_hls_path_name}/index.m3u8"
end
def rtmp_base_url
stream_node&.rtmp_base_url.presence || MatchLiveTv.mediamtx_rtmp_url
end
def hls_base_url
stream_node&.hls_base_url.presence || MatchLiveTv.hls_public_url
end
def mediamtx_api_base_url
stream_node&.api_base_url.presence || MatchLiveTv.mediamtx_api_url
end
def mediamtx_internal_rtmp_url
stream_node&.internal_rtmp_url.presence ||
ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935")
end
def mediamtx_internal_hls_url
stream_node&.internal_hls_url.presence ||
ENV.fetch("MEDIAMTX_HLS_URL", "http://mediamtx:8888")
end
def effective_hls_path_name
@@ -5,13 +5,9 @@ module Billing
AMOUNT_HINTS = {
500 => %w[premium_light monthly],
790 => %w[premium_light monthly],
4000 => %w[premium_light yearly],
5900 => %w[premium_light yearly],
2000 => %w[premium_full monthly],
2490 => %w[premium_full monthly],
20_000 => %w[premium_full yearly],
19_900 => %w[premium_full yearly]
20_000 => %w[premium_full yearly]
}.freeze
class << self
@@ -23,11 +23,10 @@ module Billing
def button_label(plan:, interval:, kind:)
price = PriceCatalog.label(plan_slug: plan.slug, interval: interval)
name = I18n.t("pages.plans.cta_name.#{plan.slug}", default: plan.name)
if kind == :checkout
I18n.t("billing.messages.activate_button", plan: name, price: price)
I18n.t("billing.messages.activate_button", plan: plan.name, price: price)
else
I18n.t("billing.messages.switch_button", plan: name, price: price)
I18n.t("billing.messages.switch_button", plan: plan.name, price: price)
end
end
@@ -4,16 +4,9 @@ module Billing
INTERVALS = %w[monthly yearly].freeze
DEFAULT_INTERVAL = "yearly"
# Importi in centesimi (EUR). Il yearly è il prezzo lancio addebitato.
AMOUNTS = {
"premium_light" => {
"yearly" => { charge: 5900, list: 7900, equivalent_monthly: 492 },
"monthly" => { charge: 790 }
},
"premium_full" => {
"yearly" => { charge: 19_900, list: 24_900, equivalent_monthly: 1658 },
"monthly" => { charge: 2490 }
}
PRICING = {
"premium_light" => { "yearly" => "€40/anno", "monthly" => "€5/mese" },
"premium_full" => { "yearly" => "€200/anno", "monthly" => "€20/mese" }
}.freeze
class << self
@@ -37,23 +30,7 @@ module Billing
end
def label(plan_slug:, interval:)
interval = normalize_interval(interval)
format_interval_price(charge_cents(plan_slug, interval), interval)
end
def list_label(plan_slug:, interval: "yearly")
interval = normalize_interval(interval)
cents = list_cents(plan_slug, interval)
return nil if cents.blank?
format_interval_price(cents, interval)
end
def equivalent_monthly_label(plan_slug:)
cents = AMOUNTS.dig(plan_slug.to_s, "yearly", :equivalent_monthly)
return nil if cents.blank?
format_interval_price(cents, "monthly")
PRICING.dig(plan_slug.to_s, normalize_interval(interval)) || normalize_interval(interval)
end
def available_intervals(plan_slug:)
@@ -75,33 +52,8 @@ module Billing
nil
end
def format_eur(cents)
cents = cents.to_i
whole, frac = cents.divmod(100)
if frac.zero?
"#{whole}"
else
sep = I18n.locale.to_s == "en" ? "." : ","
"#{whole}#{sep}#{frac.to_s.rjust(2, '0')}"
end
end
private
def charge_cents(plan_slug, interval)
AMOUNTS.dig(plan_slug.to_s, interval, :charge)
end
def list_cents(plan_slug, interval)
AMOUNTS.dig(plan_slug.to_s, interval, :list)
end
def format_interval_price(cents, interval)
return nil if cents.blank?
I18n.t("billing.prices.#{interval}", amount: format_eur(cents))
end
def price_id_for(plan_slug, interval)
case [plan_slug, interval]
when %w[premium_light monthly]
+17
View File
@@ -4,7 +4,12 @@ module Mediamtx
class Client
class Error < StandardError; end
def self.for_session(session)
new(base_url: session.mediamtx_api_base_url)
end
def initialize(base_url: MatchLiveTv.mediamtx_api_url)
@base_url = base_url
@conn = Faraday.new(url: base_url) do |f|
f.request :json
f.response :json
@@ -12,6 +17,8 @@ module Mediamtx
end
end
attr_reader :base_url
def create_path(session)
path = session.mediamtx_path_name
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe
@@ -98,6 +105,16 @@ module Mediamtx
[]
end
def list_rtmp_conns
response = @conn.get("/v3/rtmpconns/list")
return [] unless response.success?
body = response.body
body.is_a?(Hash) ? (body["items"] || []) : []
rescue Error, Faraday::Error
[]
end
def online_path_names
Set.new(list_paths.filter_map { |item| item["name"] if item["online"] })
end
@@ -4,17 +4,37 @@ module Mediamtx
module_function
def active?(session)
return true if rtmp_publisher?(session)
active_path?(path_info(session))
end
def path_info(session)
Client.new.list_paths.find { |i| i["name"] == session.mediamtx_path_name }
Client.for_session(session).list_paths.find { |i| i["name"] == session.mediamtx_path_name }
end
# MediaMTX <=1.19: online + source.type=rtmpConn.
# MediaMTX 1.20+: online/source spesso null anche con publisher; usare rtmpconns.
def active_path?(info)
return false unless info
return true if info["online"] == true && rtmp_source?(info.dig("source", "type"))
info["online"] == true && info.dig("source", "type") == "rtmpConn"
false
end
def rtmp_publisher?(session)
path = session.mediamtx_path_name.to_s
return false if path.blank?
Client.for_session(session).list_rtmp_conns.any? do |conn|
conn_path = conn["path"].to_s.sub(%r{\A/}, "")
next false unless conn_path == path
state = conn["state"].to_s
state.empty? || state == "publish" || state == "idle"
end
rescue StandardError
false
end
def h264_video?(info)
@@ -26,12 +46,14 @@ module Mediamtx
end
def video_publishing?(session)
info = path_info(session)
return false unless active_path?(info)
# Slate alwaysAvailable ha H264 ma non è il telefono.
return false unless info.dig("source", "type") == "rtmpConn"
return false unless active?(session)
info = path_info(session)
h264_video?(info)
end
def rtmp_source?(type)
type.to_s.match?(/\Artmps?Conn\z/)
end
end
end
@@ -12,7 +12,7 @@ module Mediamtx
return @session if @session.terminal?
path_info = Mediamtx::PublisherOnline.path_info(@session)
publisher_online = Mediamtx::PublisherOnline.active_path?(path_info)
publisher_online = Mediamtx::PublisherOnline.active?(@session)
if publisher_online
clear_publisher_misses!(@session.id)
@@ -86,7 +86,7 @@ module Mediamtx
key = format("youtube:slate_disabled:%s", session.id)
return unless redis.set(key, "1", nx: true, ex: 48.hours.to_i)
Client.new.set_always_available(session, enabled: false)
Client.for_session(session).set_always_available(session, enabled: false)
rescue Client::Error => e
redis.del(format("youtube:slate_disabled:%s", session.id))
Rails.logger.warn("[PublisherSync] disable slate session=#{session.id}: #{e.message}")
@@ -95,7 +95,7 @@ module Mediamtx
def restore_slate_path!(session)
return if session.platform == "matchlivetv"
Client.new.set_always_available(session, enabled: true)
Client.for_session(session).set_always_available(session, enabled: true)
rescue Client::Error => e
Rails.logger.warn("[PublisherSync] enable slate session=#{session.id}: #{e.message}")
end
@@ -128,7 +128,7 @@ module Mediamtx
return
end
Client.new.set_path_recording(session, enabled: enabled)
Client.for_session(session).set_path_recording(session, enabled: enabled)
redis.set(key, desired, ex: 48.hours.to_i)
mark_recording_patch!(session.id)
rescue Client::Error => e
@@ -183,7 +183,7 @@ module Mediamtx
key = format("youtube_relay:sched:%s", session.id)
return unless redis.set(key, "1", nx: true, ex: 10)
YoutubeRelayEnsureJob.perform_later(session.id)
YoutubeRelayEnsureJob.set(queue: Streams::YoutubeRelay::QUEUE).perform_later(session.id)
end
def redis
+54 -1
View File
@@ -44,7 +44,8 @@ module Ops
check_sidekiq_heartbeat,
check_sidekiq_dead,
check_http_rails,
check_rails_latency
check_rails_latency,
check_stream_overflow
]
findings << check_http_public if public_check_due?
findings
@@ -161,6 +162,58 @@ module Ops
fail_finding("garage_storage", "warning", "garage_storage:head", "Garage storage non raggiungibile", e.message)
end
def check_stream_overflow
return ok_finding("stream_overflow:skip", "Nodi stream non migrati") unless ActiveRecord::Base.connection.data_source_exists?("stream_nodes")
orphan_hours = ENV.fetch("STREAM_OVERFLOW_ORPHAN_HOURS", "3").to_i
orphans = StreamNode.where.not(slug: "home").where(status: %w[ready draining]).select do |n|
n.active_publishers.zero? && n.created_at < orphan_hours.hours.ago
end
metrics = Streams::Autoscaler.metrics
over_budget = !metrics[:within_budget]
at_max = metrics[:overflow_nodes] >= metrics[:max_overflow_nodes] && metrics[:free_slots] <= metrics[:soft_free_slots]
if orphans.any?
return Finding.new(
kind: "stream_overflow",
severity: "warning",
healthy: false,
title: "Nodi stream overflow idle",
message: "#{orphans.size} nodo/i idle da >#{orphan_hours}h: #{orphans.map(&:slug).join(', ')}",
metadata: { "slugs" => orphans.map(&:slug) },
fingerprint: "stream_overflow:orphan_idle"
)
end
if over_budget
return Finding.new(
kind: "stream_overflow",
severity: "warning",
healthy: false,
title: "Budget overflow streaming",
message: "Stima €#{metrics[:estimated_monthly_eur]}/mese > budget €#{metrics[:monthly_budget_eur]}",
metadata: metrics.transform_keys(&:to_s),
fingerprint: "stream_overflow:budget"
)
end
if at_max
return Finding.new(
kind: "stream_overflow",
severity: "warning",
healthy: false,
title: "Capacità stream al massimo",
message: "overflow=#{metrics[:overflow_nodes]}/#{metrics[:max_overflow_nodes]} free_slots=#{metrics[:free_slots]}",
metadata: metrics.transform_keys(&:to_s),
fingerprint: "stream_overflow:at_max"
)
end
ok_finding("stream_overflow:ok", "Overflow streaming OK")
rescue StandardError => e
fail_finding("stream_overflow", "warning", "stream_overflow:error", "Check overflow fallito", e.message)
end
def check_sidekiq_heartbeat
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
last = redis.get(Ops::HealthMonitorJob::HEARTBEAT_KEY).to_i
@@ -95,7 +95,7 @@ module Recordings
def cleanup_mediamtx_path(session)
return unless session.status.in?(%w[ended error])
Mediamtx::Client.new.delete_path(session)
Mediamtx::Client.for_session(session).delete_path(session)
rescue Mediamtx::Client::Error => e
@logger.warn("[Recordings::CleanupLocal] delete_path #{session.id}: #{e.message}")
end
@@ -95,7 +95,7 @@ module Recordings
def cleanup_mediamtx_path
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
Mediamtx::Client.new.delete_path(@session)
Mediamtx::Client.for_session(@session).delete_path(@session)
rescue Mediamtx::Client::Error => e
Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}")
end
+13 -3
View File
@@ -34,11 +34,19 @@ module Sessions
end
StreamSession.transaction do
session.stream_node = Streams::NodeRegistry.allocate!
session.save!
Scoring::Engine.ensure_score_for(session)
mtx = Mediamtx::Client.new
mtx.create_path(session)
log_event(session, "pairing", { created: true, platform: session.platform })
Mediamtx::Client.for_session(session).create_path(session)
log_event(
session,
"pairing",
{
created: true,
platform: session.platform,
stream_node: session.stream_node&.slug
}
)
end
if session.platform == "youtube"
@@ -46,6 +54,8 @@ module Sessions
end
session
rescue Streams::NodeRegistry::NoCapacityError => e
raise Teams::EntitlementError.new(e.message, code: "stream_capacity_exhausted")
end
private
+2 -2
View File
@@ -9,11 +9,11 @@ module Sessions
@session.pause! if @session.may_pause?
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
begin
Mediamtx::Client.new.set_path_recording(@session, enabled: false)
Mediamtx::Client.for_session(@session).set_path_recording(@session, enabled: false)
rescue Mediamtx::Client::Error => e
Rails.logger.warn("[Sessions::Pause] disable recording: #{e.message}")
end
Mediamtx::Client.new.set_always_available(@session, enabled: true)
Mediamtx::Client.for_session(@session).set_always_available(@session, enabled: true)
# Slate su path per HLS in pausa; RTMP telefono si ferma via comando app.
log_event("paused")
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
+1 -1
View File
@@ -33,7 +33,7 @@ module Sessions
end
def remove_mediamtx_paths!
Mediamtx::Client.new.delete_path(@session)
Mediamtx::Client.for_session(@session).delete_path(@session)
rescue Mediamtx::Client::Error => e
Rails.logger.warn("[Sessions::Stop] delete_path #{@session.id}: #{e.message}")
end
+241
View File
@@ -0,0 +1,241 @@
# frozen_string_literal: true
module Streams
# Scale-out / warm spare / scale-in dei nodi overflow (lab o Hetzner).
# Kill-switch: STREAM_AUTOSCALE_ENABLED!=1 OPPURE Redis streams:autoscaler:kill_switch=1.
class Autoscaler
Result = Struct.new(:actions, :metrics, :skipped, :error, keyword_init: true)
LOCK_KEY = "streams:autoscaler:lock"
KILL_SWITCH_KEY = "streams:autoscaler:kill_switch"
class << self
def enabled?
return false if kill_switch_engaged?
return false unless ENV["STREAM_AUTOSCALE_ENABLED"] == "1"
true
end
def kill_switch_engaged?
redis_get(KILL_SWITCH_KEY) == "1"
end
def engage_kill_switch!
redis_set(KILL_SWITCH_KEY, "1")
end
def clear_kill_switch!
redis_del(KILL_SWITCH_KEY)
end
def soft_free_slots
ENV.fetch("STREAM_AUTOSCALE_SOFT_FREE_SLOTS", "2").to_i
end
def warm_spare_min
ENV.fetch("STREAM_AUTOSCALE_WARM_SPARE", "1").to_i
end
def idle_minutes
ENV.fetch("STREAM_AUTOSCALE_IDLE_MINUTES", "30").to_i
end
def max_overflow_nodes
ENV.fetch("STREAM_AUTOSCALE_MAX_NODES", "5").to_i
end
def kind
ENV.fetch("STREAM_AUTOSCALE_KIND", "lab") # lab|cloud
end
def allow_cloud?
ENV["STREAM_AUTOSCALE_ALLOW_CLOUD"] == "1" && ENV["HCLOUD_TOKEN"].present?
end
def node_eur_per_hour
ENV.fetch("STREAM_AUTOSCALE_NODE_EUR_PER_HOUR", "0.015").to_f
end
def monthly_budget_eur
ENV.fetch("STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR", "40").to_f
end
def estimated_monthly_eur(overflow_count = nil)
count = overflow_count || metrics[:overflow_nodes]
# Worst case: nodi sempre accesi 24/7
(count * node_eur_per_hour * 24 * 30).round(2)
end
def within_budget?(overflow_count = nil)
estimated_monthly_eur(overflow_count) <= monthly_budget_eur
end
def reconcile!(provisioner: nil)
return Result.new(skipped: true, actions: [], metrics: metrics) unless enabled?
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
unless redis.set(LOCK_KEY, worker_id, nx: true, ex: 55)
return Result.new(skipped: true, actions: [], metrics: metrics, error: "locked")
end
begin
new(provisioner: provisioner).reconcile!
ensure
redis.del(LOCK_KEY)
end
end
def metrics
NodeRegistry.ensure_home_from_env!
nodes = StreamNode.ready.to_a
overflow = StreamNode.where.not(slug: NodeRegistry::HOME_SLUG)
.where(status: %w[ready draining provisioning]).to_a
{
free_slots: nodes.sum(&:free_slots),
ready_nodes: nodes.size,
spare_ready: nodes.count { |n| n.slug != NodeRegistry::HOME_SLUG && n.active_publishers.zero? },
overflow_nodes: overflow.size,
soft_free_slots: soft_free_slots,
warm_spare_min: warm_spare_min,
max_overflow_nodes: max_overflow_nodes,
enabled: enabled?,
env_enabled: ENV["STREAM_AUTOSCALE_ENABLED"] == "1",
kill_switch: kill_switch_engaged?,
kind: kind,
allow_cloud: allow_cloud?,
estimated_monthly_eur: estimated_monthly_eur(overflow.size),
monthly_budget_eur: monthly_budget_eur,
within_budget: within_budget?(overflow.size)
}
end
def worker_id
ENV.fetch("HOSTNAME", "autoscaler")
end
def redis_get(key)
Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")).get(key)
rescue Redis::BaseError
nil
end
def redis_set(key, value)
Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")).set(key, value)
end
def redis_del(key)
Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")).del(key)
end
end
def initialize(provisioner: nil)
@provisioner = provisioner || NodeProvisioner.new
@provisioned_this_round = []
end
def reconcile!
actions = []
m = self.class.metrics
if need_capacity?(m) && can_provision?(m)
provision_overflow!
actions << :scale_out
m = self.class.metrics
elsif need_capacity?(m) && !can_provision?(m)
actions << :blocked_capacity
Rails.logger.warn("[Streams::Autoscaler] capacity needed but blocked metrics=#{m.inspect}")
end
if warm_spare_desired?(m) && m[:spare_ready] < self.class.warm_spare_min && can_provision?(m)
provision_overflow!
actions << :warm_spare
m = self.class.metrics
end
scale_in_candidates.each do |node|
next if keep_as_warm_spare?(node)
next if @provisioned_this_round.include?(node.id)
safe_scale_in!(node)
actions << :"scale_in_#{node.slug}"
m = self.class.metrics
rescue NodeProvisioner::BusyError, NodeProvisioner::Error => e
Rails.logger.warn("[Streams::Autoscaler] scale-in #{node.slug}: #{e.message}")
end
Rails.logger.info("[Streams::Autoscaler] actions=#{actions.inspect} metrics=#{m.inspect}")
Result.new(actions: actions, metrics: m, skipped: false)
end
private
def need_capacity?(m)
m[:free_slots] <= self.class.soft_free_slots
end
def warm_spare_desired?(m)
return false if self.class.warm_spare_min <= 0
need_capacity?(m) || overflow_in_use?
end
def overflow_in_use?
StreamNode.ready.where.not(slug: NodeRegistry::HOME_SLUG).any? { |n| n.active_publishers.positive? }
end
def can_provision?(m)
return false if m[:overflow_nodes] >= self.class.max_overflow_nodes
return false unless self.class.within_budget?(m[:overflow_nodes] + 1)
return false if self.class.kind == "cloud" && !self.class.allow_cloud?
true
end
def provision_overflow!
node =
case self.class.kind
when "cloud"
raise NodeProvisioner::Error, "Cloud autoscale disabilitato (STREAM_AUTOSCALE_ALLOW_CLOUD / HCLOUD_TOKEN)" unless self.class.allow_cloud?
@provisioner.provision_cloud!
else
@provisioner.provision_lab!
end
@provisioned_this_round << node.id if node
node
end
def safe_scale_in!(node)
@provisioner.drain!(node) unless node.status == "draining"
node.reload
raise NodeProvisioner::BusyError, "sessioni ancora attive" if node.occupying_sessions.exists?
raise NodeProvisioner::Error, "idle insufficiente" unless idle_long_enough?(node)
@provisioner.decommission!(node)
end
def scale_in_candidates
StreamNode.where.not(slug: NodeRegistry::HOME_SLUG)
.where(status: %w[ready draining])
.order(:created_at)
.select { |n| n.active_publishers.zero? && idle_long_enough?(n) }
end
def idle_long_enough?(node)
idle_since(node) <= self.class.idle_minutes.minutes.ago
end
def idle_since(node)
last_end = node.stream_sessions.where(status: %w[ended error]).maximum(:ended_at)
last_end || node.created_at
end
def keep_as_warm_spare?(node)
return false unless warm_spare_desired?(self.class.metrics)
spares = StreamNode.ready.where.not(slug: NodeRegistry::HOME_SLUG).select { |n| n.active_publishers.zero? }
spares.size <= self.class.warm_spare_min && spares.map(&:id).include?(node.id)
end
end
end
@@ -0,0 +1,15 @@
# frozen_string_literal: true
module Streams
module CloudProviders
def self.build(name = ENV.fetch("STREAM_CLOUD_PROVIDER", "local_lab"))
case name.to_s
when "local_lab" then LocalLab.new
when "proxmox_lab" then ProxmoxLab.new
when "hetzner" then Hetzner.new
else
raise Error, "STREAM_CLOUD_PROVIDER sconosciuto: #{name}"
end
end
end
end
@@ -0,0 +1,35 @@
# frozen_string_literal: true
module Streams
module CloudProviders
class Error < StandardError; end
# Descrittore restituito da create_node / list.
Instance = Struct.new(
:id, :name, :public_ip, :private_ip, :status, :raw,
keyword_init: true
)
class Base
def create_node(name:, labels: {})
raise NotImplementedError
end
def destroy_node(instance_id)
raise NotImplementedError
end
def list_nodes(labels: {})
raise NotImplementedError
end
def wait_until_running(instance_id, timeout: 120)
raise NotImplementedError
end
def public_ip(instance_id)
raise NotImplementedError
end
end
end
end
@@ -0,0 +1,191 @@
# frozen_string_literal: true
require "faraday"
module Streams
module CloudProviders
# Hetzner Cloud — create/destroy server per nodi stream.
#
# ENV:
# HCLOUD_TOKEN (obbligatorio)
# HCLOUD_LOCATION (default fsn1)
# HCLOUD_SERVER_TYPE (default cpx21)
# HCLOUD_IMAGE (default debian-12)
# HCLOUD_SSH_KEY (nome chiave, default matchlivetv-stream)
# HCLOUD_NETWORK_ID (opzionale, private network / WireGuard prep)
# HCLOUD_USER_DATA_FILE (opzionale, cloud-init path)
class Hetzner < Base
# Trailing slash obbligatorio: path assoluti tipo "/servers" altrimenti droppano /v1.
API = "https://api.hetzner.cloud/v1/"
def initialize(token: ENV.fetch("HCLOUD_TOKEN"), conn: nil)
@token = token
@conn = conn
end
def create_node(name:, labels: {})
body = {
name: name,
server_type: ENV.fetch("HCLOUD_SERVER_TYPE", "cpx12"),
image: ENV.fetch("HCLOUD_IMAGE", "debian-12"),
location: ENV.fetch("HCLOUD_LOCATION", "nbg1"),
start_after_create: true,
labels: default_labels.merge(stringify_labels(labels)),
ssh_keys: [ENV.fetch("HCLOUD_SSH_KEY", "matchlivetv-stream-hetzner")],
public_net: {
enable_ipv4: true,
enable_ipv6: false
}
}
network_id = ENV["HCLOUD_NETWORK_ID"].presence
body[:networks] = [network_id.to_i] if network_id
user_data = cloud_init_user_data
body[:user_data] = user_data
data = post("servers", body)
server = data["server"] || {}
action = data["action"]
wait_action!(action) if action
instance = wait_until_running(server["id"].to_s)
instance.name = name
instance
end
def destroy_node(instance_id)
delete("servers/#{instance_id}")
true
end
def list_nodes(labels: {})
params = {}
label_selector = labels.map { |k, v| "#{k}=#{v}" }.join(",")
params[:label_selector] = label_selector if label_selector.present?
params[:label_selector] ||= "matchlivetv=true,role=stream-node"
data = get("servers", params)
Array(data["servers"]).map { |s| instance_from_server(s) }
end
def wait_until_running(instance_id, timeout: 180)
deadline = Time.now + timeout
loop do
data = get("servers/#{instance_id}")
server = data["server"]
status = server["status"]
if status == "running"
return instance_from_server(server)
end
raise Error, "Timeout attesa server Hetzner #{instance_id} (status=#{status})" if Time.now >= deadline
sleep 3
end
end
def public_ip(instance_id)
wait_until_running(instance_id).public_ip
end
private
def default_labels
{
"matchlivetv" => "true",
"role" => "stream-node",
"env" => ENV.fetch("STREAM_NODE_ENV", "prod")
}
end
def stringify_labels(labels)
labels.to_h.transform_keys(&:to_s).transform_values(&:to_s)
end
def instance_from_server(server)
public_ip = server.dig("public_net", "ipv4", "ip")
private_ip = Array(server["private_net"]).first&.dig("ip")
Instance.new(
id: server["id"].to_s,
name: server["name"],
public_ip: public_ip,
private_ip: private_ip.presence || public_ip,
status: server["status"],
raw: server
)
end
def cloud_init_user_data
path = ENV["HCLOUD_USER_DATA_FILE"].presence
if path.present?
raise Error, "HCLOUD_USER_DATA_FILE non leggibile nel container: #{path}" unless File.file?(path)
return File.read(path)
end
inline = ENV["HCLOUD_USER_DATA"].presence
raise Error, "Manca cloud-init: imposta HCLOUD_USER_DATA_FILE (montato) o HCLOUD_USER_DATA" if inline.blank?
inline
end
def conn
@conn ||= Faraday.new(url: API) do |f|
f.request :json
f.response :json, content_type: /\bjson$/
f.adapter Faraday.default_adapter
end
end
def auth_headers
{ "Authorization" => "Bearer #{@token}" }
end
def get(path, params = {})
response = conn.get(path) do |req|
req.headers.update(auth_headers)
req.params.update(params)
end
unwrap!(response)
end
def post(path, body)
response = conn.post(path) do |req|
req.headers.update(auth_headers)
req.body = body
end
unwrap!(response)
end
def delete(path)
response = conn.delete(path) do |req|
req.headers.update(auth_headers)
end
return {} if response.status == 204
unwrap!(response)
end
def unwrap!(response)
unless response.success?
raise Error, "Hetzner Cloud API #{response.status}: #{response.body.inspect}"
end
response.body.is_a?(Hash) ? response.body : {}
end
def wait_action!(action, timeout: 120)
return unless action.is_a?(Hash) && action["id"]
deadline = Time.now + timeout
id = action["id"]
loop do
data = get("actions/#{id}")
status = data.dig("action", "status")
return if status == "success"
raise Error, "Hetzner action #{id} failed: #{data.inspect}" if status == "error"
raise Error, "Timeout action Hetzner #{id}" if Time.now >= deadline
sleep 2
end
end
end
end
end
@@ -0,0 +1,45 @@
# frozen_string_literal: true
module Streams
module CloudProviders
# Lab senza API Proxmox: simula create/destroy e riusa MediaMTX home per i path.
# Utile per testare registry, assignment e admin senza secondi host.
class LocalLab < Base
def create_node(name:, labels: {})
Instance.new(
id: "sim-#{name}",
name: name,
public_ip: labels[:public_ip].presence || "127.0.0.1",
private_ip: labels[:private_ip].presence || "127.0.0.1",
status: "running",
raw: { simulated: true, labels: labels }
)
end
def destroy_node(instance_id)
true
end
def list_nodes(labels: {})
StreamNode.where(provider: "local", role: "lab").map do |node|
Instance.new(
id: node.provider_instance_id,
name: node.slug,
public_ip: node.metadata["public_ip"],
private_ip: node.metadata["private_ip"],
status: node.status == "ready" ? "running" : node.status,
raw: node.metadata
)
end
end
def wait_until_running(instance_id, timeout: 120)
Instance.new(id: instance_id, name: instance_id, status: "running")
end
def public_ip(instance_id)
"127.0.0.1"
end
end
end
end
@@ -0,0 +1,162 @@
# frozen_string_literal: true
require "faraday"
module Streams
module CloudProviders
# Clone/start/stop di VM template su Proxmox VE (API token).
#
# ENV richiesti:
# PROXMOX_API_URL, PROXMOX_TOKEN_ID, PROXMOX_TOKEN_SECRET,
# PROXMOX_NODE, PROXMOX_TEMPLATE_VMID
class ProxmoxLab < Base
def initialize(
api_url: ENV.fetch("PROXMOX_API_URL"),
token_id: ENV.fetch("PROXMOX_TOKEN_ID"),
token_secret: ENV.fetch("PROXMOX_TOKEN_SECRET"),
node: ENV.fetch("PROXMOX_NODE"),
template_vmid: ENV.fetch("PROXMOX_TEMPLATE_VMID"),
verify_ssl: ENV.fetch("PROXMOX_VERIFY_SSL", "false") == "true"
)
@api_url = api_url.to_s.chomp("/")
@token_id = token_id
@token_secret = token_secret
@node = node
@template_vmid = template_vmid.to_i
@verify_ssl = verify_ssl
end
def create_node(name:, labels: {})
newid = next_vmid
post("/nodes/#{@node}/qemu/#{@template_vmid}/clone", {
newid: newid,
name: name,
full: 1,
target: @node
})
post("/nodes/#{@node}/qemu/#{newid}/status/start", {})
wait_until_running(newid.to_s)
ip = public_ip(newid.to_s)
Instance.new(
id: newid.to_s,
name: name,
public_ip: ip,
private_ip: ip,
status: "running",
raw: { node: @node, vmid: newid, labels: labels }
)
end
def destroy_node(instance_id)
vmid = instance_id.to_i
begin
post("/nodes/#{@node}/qemu/#{vmid}/status/stop", { timeout: 30 })
rescue Error
# già spenta
end
sleep 2
delete("/nodes/#{@node}/qemu/#{vmid}", { purge: 1 })
true
end
def list_nodes(labels: {})
items = get("/nodes/#{@node}/qemu")
Array(items).filter_map do |row|
name = row["name"].to_s
next unless name.start_with?("mltv-stream-") || name.start_with?("ingest-")
Instance.new(
id: row["vmid"].to_s,
name: name,
public_ip: nil,
private_ip: nil,
status: row["status"],
raw: row
)
end
end
def wait_until_running(instance_id, timeout: 180)
deadline = Time.now + timeout
loop do
status = get("/nodes/#{@node}/qemu/#{instance_id}/status/current")
return Instance.new(id: instance_id.to_s, status: "running", raw: status) if status["status"] == "running"
raise Error, "Timeout attesa VM #{instance_id}" if Time.now >= deadline
sleep 3
end
end
def public_ip(instance_id)
agent = get("/nodes/#{@node}/qemu/#{instance_id}/agent/network-get-interfaces")
interfaces = agent.is_a?(Hash) ? agent["result"] : nil
Array(interfaces).each do |iface|
Array(iface["ip-addresses"]).each do |addr|
ip = addr["ip-address"].to_s
next if ip.blank? || ip.start_with?("127.") || ip.include?(":")
return ip
end
end
ENV["STREAM_LAB_FALLBACK_IP"].presence || "127.0.0.1"
rescue Error
ENV["STREAM_LAB_FALLBACK_IP"].presence || "127.0.0.1"
end
private
def next_vmid
used = Array(get("/cluster/resources", type: "vm")).map { |r| r["vmid"].to_i }
candidate = ENV.fetch("PROXMOX_VMID_START", "9100").to_i
candidate += 1 while used.include?(candidate)
candidate
end
def conn
@conn ||= Faraday.new(url: "#{@api_url}/api2/json") do |f|
f.request :url_encoded
f.response :json, content_type: /\bjson$/
f.adapter Faraday.default_adapter
f.ssl[:verify] = @verify_ssl
end
end
def auth_headers
{ "Authorization" => "PVEAPIToken=#{@token_id}=#{@token_secret}" }
end
def get(path, params = {})
response = conn.get(path) do |req|
req.headers.update(auth_headers)
req.params.update(params)
end
unwrap!(response)
end
def post(path, body = {})
response = conn.post(path) do |req|
req.headers.update(auth_headers)
req.body = body
end
unwrap!(response)
end
def delete(path, params = {})
response = conn.delete(path) do |req|
req.headers.update(auth_headers)
req.params.update(params)
end
unwrap!(response)
end
def unwrap!(response)
unless response.success?
raise Error, "Proxmox API #{response.status}: #{response.body.inspect}"
end
body = response.body
body.is_a?(Hash) && body.key?("data") ? body["data"] : body
end
end
end
end
@@ -0,0 +1,14 @@
# frozen_string_literal: true
module Streams
module DnsProviders
def self.build(name = ENV.fetch("STREAM_DNS_PROVIDER", "lab"))
case name.to_s
when "lab" then Lab.new
when "hetzner" then Hetzner.new
else
raise Error, "STREAM_DNS_PROVIDER sconosciuto: #{name}"
end
end
end
end
@@ -0,0 +1,21 @@
# frozen_string_literal: true
module Streams
module DnsProviders
class Error < StandardError; end
class Base
def upsert_a(name, ip)
raise NotImplementedError
end
def delete_a(name)
raise NotImplementedError
end
def resolve(name)
raise NotImplementedError
end
end
end
end
@@ -0,0 +1,106 @@
# frozen_string_literal: true
require "faraday"
require "cgi"
module Streams
module DnsProviders
# Hetzner Cloud DNS (Console) — zone mltv-stream.net.
#
# ENV:
# HCLOUD_TOKEN (stesso del Cloud)
# STREAM_DNS_ZONE (default mltv-stream.net)
# STREAM_DNS_TTL (default 60)
class Hetzner < Base
# Trailing slash obbligatorio: path assoluti altrimenti droppano /v1.
API = "https://api.hetzner.cloud/v1/"
def initialize(token: ENV.fetch("HCLOUD_TOKEN"), zone: nil, conn: nil)
@token = token
@zone = zone || ENV.fetch("STREAM_DNS_ZONE", "mltv-stream.net")
@ttl = ENV.fetch("STREAM_DNS_TTL", "60").to_i
@conn = conn
end
def upsert_a(name, ip)
rr_name = relative_name(name)
delete_a(name)
post("zones/#{CGI.escape(@zone)}/rrsets", {
name: rr_name,
type: "A",
ttl: @ttl,
records: [{ value: ip.to_s, comment: "matchlivetv stream-node" }],
labels: { "matchlivetv" => "true", "role" => "stream-node" }
})
true
end
def delete_a(name)
rr_name = relative_name(name)
encoded = CGI.escape(rr_name)
response = conn.delete("zones/#{CGI.escape(@zone)}/rrsets/#{encoded}/A") do |req|
req.headers.update(auth_headers)
end
return true if response.status == 404 || response.status == 204 || response.success?
raise Error, "Hetzner DNS API #{response.status}: #{response.body.inspect}"
end
def resolve(name)
rr_name = relative_name(name)
data = get("zones/#{CGI.escape(@zone)}/rrsets", name: rr_name, type: "A")
rrset = Array(data["rrsets"]).first
Array(rrset&.dig("records")).first&.dig("value")
rescue Error
nil
end
private
def relative_name(name)
host = name.to_s.strip.downcase.delete_suffix(".")
suffix = ".#{@zone}"
return "@" if host == @zone
return host.delete_suffix(suffix) if host.end_with?(suffix)
host
end
def conn
@conn ||= Faraday.new(url: API) do |f|
f.request :json
f.response :json, content_type: /\bjson$/
f.adapter Faraday.default_adapter
end
end
def auth_headers
{ "Authorization" => "Bearer #{@token}" }
end
def get(path, params = {})
response = conn.get(path) do |req|
req.headers.update(auth_headers)
req.params.update(params)
end
unwrap!(response)
end
def post(path, body)
response = conn.post(path) do |req|
req.headers.update(auth_headers)
req.body = body
end
unwrap!(response)
end
def unwrap!(response)
unless response.success?
raise Error, "Hetzner DNS API #{response.status}: #{response.body.inspect}"
end
response.body.is_a?(Hash) ? response.body : {}
end
end
end
end
@@ -0,0 +1,47 @@
# frozen_string_literal: true
module Streams
module DnsProviders
# DNS lab in Redis (e dump hosts). Nessuna chiamata al registrar.
class Lab < Base
REDIS_KEY = "stream_dns:a_records"
def initialize(redis: nil)
@redis = redis
end
def upsert_a(name, ip)
host = normalize(name)
redis.hset(REDIS_KEY, host, ip.to_s)
true
end
def delete_a(name)
redis.hdel(REDIS_KEY, normalize(name))
true
end
def resolve(name)
redis.hget(REDIS_KEY, normalize(name))
end
def all_records
redis.hgetall(REDIS_KEY)
end
def hosts_file_snippet
all_records.sort.map { |host, ip| "#{ip}\t#{host}" }.join("\n")
end
private
def normalize(name)
name.to_s.strip.downcase.delete_suffix(".")
end
def redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
end
end
end
end
@@ -0,0 +1,152 @@
# frozen_string_literal: true
module Streams
# Provisiona / decommissiona nodi stream (lab o cloud) e aggiorna DNS + registry.
class NodeProvisioner
class Error < StandardError; end
class BusyError < Error; end
LAB_DNS_SUFFIX = -> { ENV.fetch("STREAM_LAB_DNS_SUFFIX", "lab.mltv-stream.net") }
CLOUD_DNS_SUFFIX = -> { ENV.fetch("STREAM_CLOUD_DNS_SUFFIX", ENV.fetch("STREAM_DNS_ZONE", "mltv-stream.net")) }
def initialize(cloud: nil, dns: nil)
@cloud = cloud
@dns = dns
end
def provision_lab!(prefix: "ingest-lab")
provision!(
prefix: prefix,
role: "lab",
dns_suffix: LAB_DNS_SUFFIX.call,
cloud: cloud_provider(ENV.fetch("STREAM_CLOUD_PROVIDER", "local_lab")),
dns: dns_provider(ENV.fetch("STREAM_DNS_PROVIDER", "lab")),
max: ENV.fetch("STREAM_LAB_MAX_PUBLISHERS", "2").to_i,
use_node_hostname: ENV["STREAM_LAB_USE_NODE_HOSTNAME"] == "1"
)
end
def provision_cloud!(prefix: "ingest")
provision!(
prefix: prefix,
role: "cloud",
dns_suffix: CLOUD_DNS_SUFFIX.call,
cloud: cloud_provider("hetzner"),
dns: dns_provider("hetzner"),
max: ENV.fetch("STREAM_CLOUD_MAX_PUBLISHERS", "4").to_i,
use_node_hostname: true
)
end
def decommission!(node)
raise Error, "Non si può decommissionare il nodo home" if node.slug == Streams::NodeRegistry::HOME_SLUG
if node.occupying_sessions.exists?
raise BusyError, "Nodo #{node.slug} ha ancora sessioni attive"
end
node.update!(status: "draining")
cloud = cloud_for_node(node)
dns = dns_for_node(node)
cloud.destroy_node(node.provider_instance_id) if node.provider_instance_id.present?
dns.delete_a(node.hostname) if node.hostname.present?
node.destroy!
true
end
def drain!(node)
raise Error, "Non si può mettere in drain il nodo home" if node.slug == Streams::NodeRegistry::HOME_SLUG
node.update!(status: "draining")
node
end
private
def provision!(prefix:, role:, dns_suffix:, cloud:, dns:, max:, use_node_hostname:)
Streams::NodeRegistry.ensure_home_from_env!
home = StreamNode.find_by!(slug: Streams::NodeRegistry::HOME_SLUG)
slug = next_slug(prefix)
hostname = "#{slug}.#{dns_suffix}"
instance = cloud.create_node(
name: "mltv-stream-#{slug}",
labels: { role: "stream-node", env: role == "cloud" ? "prod" : "lab" }
)
ip = instance.public_ip.presence || "127.0.0.1"
private_ip = instance.private_ip.presence || ip
dns.upsert_a(hostname, ip)
simulated = instance.raw.is_a?(Hash) && (instance.raw[:simulated] || instance.raw["simulated"])
api_base = simulated ? home.api_base_url : "http://#{private_ip}:9997"
internal_rtmp = simulated ? home.internal_rtmp_url : "rtmp://#{private_ip}:1935"
internal_hls = simulated ? home.internal_hls_url : "http://#{private_ip}:8888"
StreamNode.create!(
slug: slug,
hostname: hostname,
role: role,
status: "ready",
provider: provider_name_for(cloud, role: role),
provider_instance_id: instance.id,
rtmp_base_url: use_node_hostname ? "rtmp://#{hostname}:1935" : home.rtmp_base_url,
hls_base_url: use_node_hostname ? "https://#{hostname}/hls" : home.hls_base_url,
api_base_url: api_base,
internal_rtmp_url: internal_rtmp,
internal_hls_url: internal_hls,
max_publishers: max,
max_relays: max,
last_health_at: Time.current,
metadata: {
"public_ip" => ip,
"private_ip" => private_ip,
"simulated" => simulated,
"cloud_raw" => instance.raw
}
)
end
def next_slug(prefix)
used = StreamNode.where("slug LIKE ?", "#{prefix}-%").pluck(:slug)
n = 1
loop do
candidate = format("%s-%02d", prefix, n)
return candidate unless used.include?(candidate)
n += 1
end
end
def cloud_provider(name)
@cloud || Streams::CloudProviders.build(name)
end
def dns_provider(name)
@dns || Streams::DnsProviders.build(name)
end
def provider_name_for(cloud, role: nil)
return "hetzner" if role.to_s == "cloud"
case cloud
when Streams::CloudProviders::ProxmoxLab then "proxmox_lab"
when Streams::CloudProviders::Hetzner then "hetzner"
else "local"
end
end
def cloud_for_node(node)
case node.provider
when "hetzner" then Streams::CloudProviders::Hetzner.new
when "proxmox_lab" then Streams::CloudProviders::ProxmoxLab.new
else Streams::CloudProviders::LocalLab.new
end
end
def dns_for_node(node)
case node.provider
when "hetzner" then Streams::DnsProviders::Hetzner.new
else Streams::DnsProviders::Lab.new
end
end
end
end
@@ -0,0 +1,66 @@
# frozen_string_literal: true
module Streams
# Assegna un StreamNode a una nuova sessione.
# Preferisce home finché ha slot; poi least-loaded tra i nodi overflow ready.
# Garantisce il nodo "home" derivato dagli ENV MediaMTX attuali.
class NodeRegistry
class NoCapacityError < StandardError; end
HOME_SLUG = "home"
class << self
def ensure_home_from_env!
StreamNode.find_or_initialize_by(slug: HOME_SLUG).tap do |node|
attrs = {
hostname: home_hostname,
role: "home",
provider: "local",
rtmp_base_url: MatchLiveTv.mediamtx_rtmp_url,
hls_base_url: MatchLiveTv.hls_public_url,
api_base_url: MatchLiveTv.mediamtx_api_url,
internal_rtmp_url: ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935"),
internal_hls_url: ENV.fetch("MEDIAMTX_HLS_URL", "http://mediamtx:8888"),
max_publishers: ENV.fetch("STREAM_NODE_HOME_MAX_PUBLISHERS", "6").to_i,
max_relays: ENV.fetch("STREAM_NODE_HOME_MAX_RELAYS", "6").to_i
}
# Non sovrascrivere drain/offline/error (ops) a ogni allocate!
attrs[:status] = "ready" if node.new_record? || !%w[draining offline error].include?(node.status)
node.assign_attributes(attrs)
node.save!
end
end
def allocate!
ensure_home_from_env!
# Overflow: riempi prima home; i nodi cloud/lab sono solo quando home è pieno
# (altrimenti lo warm spare ruberebbe tutte le sessioni).
home = StreamNode.find_by(slug: HOME_SLUG)
return home if home&.allocatable?
node = StreamNode.ready
.where.not(slug: HOME_SLUG)
.to_a
.select(&:allocatable?)
.min_by { |n| [n.active_publishers, n.slug] }
raise NoCapacityError, "Nessun nodo streaming con slot liberi" if node.nil?
node
end
private
def home_hostname
ENV["STREAM_NODE_HOME_HOSTNAME"].presence ||
begin
uri = URI.parse(MatchLiveTv.mediamtx_rtmp_url.sub(/\Artmps?:\/\//, "http://"))
uri.host.presence
rescue URI::InvalidURIError
nil
end || "home"
end
end
end
end
+83 -23
View File
@@ -1,29 +1,35 @@
module Streams
# Relay verso YouTube: legge RTMP/HLS da MediaMTX e inoltra su RTMPS (-c copy). Nessun overlay.
# ffmpeg gira solo nel container sidekiq (YOUTUBE_RELAY_WORKER=1).
# ffmpeg gira solo sui worker Sidekiq con YOUTUBE_RELAY_WORKER=1 (coda youtube_relay).
class YoutubeRelay
class Error < StandardError; end
REDIS_KEY = "youtube_relay:pid:%s"
OWNER_KEY = "youtube_relay:owner:%s"
OWNED_SET = "youtube_relay:owned:%s"
QUEUE = :youtube_relay
class << self
def worker?
ENV["YOUTUBE_RELAY_WORKER"] == "1"
end
def max_concurrent
ENV.fetch("RELAY_MAX_CONCURRENT", "4").to_i
end
def start(session)
return unless worker?
start_on_worker!(session)
end
# Non cancella owner/pid qui: solo il worker owner deve killare ffmpeg.
def stop(session)
clear_pid(session.id)
redis.del(format(OWNER_KEY, session.id))
if worker?
if worker? && owner_is_local?(session.id)
stop_on_worker!(session)
else
YoutubeRelayStopJob.perform_later(session.id)
YoutubeRelayStopJob.set(queue: QUEUE).perform_later(session.id)
end
true
end
@@ -31,11 +37,13 @@ module Streams
def running?(session_id)
owner = redis.get(format(OWNER_KEY, session_id))
pid = pid_for(session_id)
return false if pid.blank? && owner.blank?
return false if pid.blank? && owner.present? && owner != worker_id && redis.ttl(format(OWNER_KEY, session_id)) <= 30
return false if pid.blank?
return process_alive?(pid) if owner.blank? || owner == worker_id
# Relay avviato in un altro container: consideralo attivo se il lock è recente.
# Relay su altro host: attivo se lock owner ancora fresco.
redis.ttl(format(OWNER_KEY, session_id)) > 30
end
@@ -47,12 +55,12 @@ module Streams
if worker?
ensure_on_worker!(session)
else
YoutubeRelayEnsureJob.perform_later(session.id)
YoutubeRelayEnsureJob.set(queue: QUEUE).perform_later(session.id)
end
end
def ensure_on_worker!(session)
return unless worker?
return :not_worker unless worker?
return unless session.platform == "youtube"
return if session.terminal?
return if session.stream_key.blank?
@@ -60,28 +68,64 @@ module Streams
return unless intake_available?(session)
pid = pid_for(session.id)
clear_pid(session.id) if pid.present? && !process_alive?(pid.to_i)
if pid.present? && !process_alive?(pid.to_i)
clear_local_ownership(session.id)
end
return if running?(session.id)
if running?(session.id)
touch_owner!(session.id) if owner_is_local?(session.id)
return :already_running
end
if at_capacity?
YoutubeRelayEnsureJob.set(wait: 5.seconds, queue: QUEUE).perform_later(session.id)
Rails.logger.info("[YoutubeRelay] at capacity worker=#{worker_id} session=#{session.id} requeue")
return :at_capacity
end
last_restart = redis.get(restart_debounce_key(session.id)).to_i
return if last_restart.positive? && (Time.now.to_i - last_restart) < 5
return :debounced if last_restart.positive? && (Time.now.to_i - last_restart) < 5
start_on_worker!(session)
redis.set(restart_debounce_key(session.id), Time.now.to_i, ex: 300)
:started
rescue Error => e
Rails.logger.warn("[YoutubeRelay] ensure_on_worker session=#{session.id}: #{e.message}")
:error
end
# @return [Symbol] :stopped, :wrong_host, :noop
def stop_on_worker!(session)
return :not_worker unless worker?
owner = redis.get(format(OWNER_KEY, session.id))
if owner.present? && owner != worker_id
return :wrong_host
end
pid = pid_for(session.id)
return false if pid.blank?
if pid.blank?
clear_local_ownership(session.id)
return :noop
end
terminate_pid(pid)
clear_pid(session.id)
redis.del(format(OWNER_KEY, session.id))
Rails.logger.info("[YoutubeRelay] stopped pid=#{pid} session=#{session.id}")
true
clear_local_ownership(session.id)
Rails.logger.info("[YoutubeRelay] stopped pid=#{pid} session=#{session.id} worker=#{worker_id}")
:stopped
end
def local_owned_count
redis.scard(format(OWNED_SET, worker_id)).to_i
end
def at_capacity?
local_owned_count >= max_concurrent
end
def owner_is_local?(session_id)
owner = redis.get(format(OWNER_KEY, session_id))
owner.blank? || owner == worker_id
end
private
@@ -92,9 +136,9 @@ module Streams
return if session.terminal?
return unless intake_available?(session)
return pid_for(session.id).to_i if running?(session.id)
return pid_for(session.id).to_i if running?(session.id) && owner_is_local?(session.id)
stop_on_worker!(session) if pid_for(session.id).present?
stop_on_worker!(session) if pid_for(session.id).present? && owner_is_local?(session.id)
log_path = log_file(session)
FileUtils.mkdir_p(File.dirname(log_path))
@@ -108,8 +152,8 @@ module Streams
)
Process.detach(pid)
store_pid(session.id, pid)
redis.set(format(OWNER_KEY, session.id), worker_id, ex: 48.hours.to_i)
Rails.logger.info("[YoutubeRelay] started pid=#{pid} session=#{session.id} intake=#{intake_source.join(":")}")
claim_ownership!(session.id)
Rails.logger.info("[YoutubeRelay] started pid=#{pid} session=#{session.id} intake=#{intake_source.join(":")} worker=#{worker_id}")
schedule_youtube_activate(session)
pid
rescue Errno::ENOENT => e
@@ -146,20 +190,20 @@ module Streams
end
def mediamtx_intake_source(session)
base = ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935")
base = session.mediamtx_internal_rtmp_url
if Mediamtx::PublisherOnline.active?(session)
return [:rtmp, "#{base.chomp('/')}/#{session.mediamtx_path_name}"]
end
path = session.mediamtx_path_name
hls = ENV.fetch("MEDIAMTX_HLS_URL", "http://mediamtx:8888").chomp("/")
hls = session.mediamtx_internal_hls_url.chomp("/")
[:hls, "#{hls}/#{path}/index.m3u8"]
end
def intake_available?(session)
return true if Mediamtx::PublisherOnline.active?(session)
info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == session.mediamtx_path_name }
info = Mediamtx::Client.for_session(session).list_paths.find { |i| i["name"] == session.mediamtx_path_name }
info && (info["ready"] || info["online"] || info["available"])
rescue StandardError
false
@@ -185,6 +229,22 @@ module Streams
format("youtube_relay:debounce:%s", session_id)
end
def claim_ownership!(session_id)
redis.set(format(OWNER_KEY, session_id), worker_id, ex: 48.hours.to_i)
redis.sadd(format(OWNED_SET, worker_id), session_id)
end
def touch_owner!(session_id)
redis.expire(format(OWNER_KEY, session_id), 48.hours.to_i)
redis.expire(format(REDIS_KEY, session_id), 48.hours.to_i)
end
def clear_local_ownership(session_id)
redis.del(format(REDIS_KEY, session_id))
redis.del(format(OWNER_KEY, session_id))
redis.srem(format(OWNED_SET, worker_id), session_id)
end
def store_pid(session_id, pid)
redis.set(format(REDIS_KEY, session_id), pid, ex: 48.hours.to_i)
end
@@ -68,13 +68,13 @@ module Webhooks
def enable_recording(session)
return unless session.match.team.entitlements.recording_enabled_for_mediamtx?
Mediamtx::Client.new.set_path_recording(session, enabled: true)
Mediamtx::Client.for_session(session).set_path_recording(session, enabled: true)
rescue Mediamtx::Client::Error => e
Rails.logger.warn("[MediamtxHandler] enable recording: #{e.message}")
end
def disable_recording(session)
Mediamtx::Client.new.set_path_recording(session, enabled: false)
Mediamtx::Client.for_session(session).set_path_recording(session, enabled: false)
rescue Mediamtx::Client::Error => e
Rails.logger.warn("[MediamtxHandler] disable recording: #{e.message}")
end
@@ -63,7 +63,7 @@ module Youtube
return
end
Mediamtx::Client.new.set_always_available(session, enabled: false)
Mediamtx::Client.for_session(session).set_always_available(session, enabled: false)
session.go_live! if session.may_go_live?
session.reconnect! if session.reconnecting? && session.may_reconnect?
@@ -0,0 +1,129 @@
<% content_for :body_class, "admin-body" %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.stream_nodes.title") %></h2>
<p class="muted admin-page-sub">
<%= t("admin.stream_nodes.providers", cloud: @cloud_provider, dns: @dns_provider) %>
</p>
</div>
<% m = @autoscale_metrics %>
<section class="kpi-grid">
<div class="kpi-card <%= m[:enabled] ? 'kpi-card--accent' : '' %> <%= 'kpi-card--danger' if m[:kill_switch] %>">
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.autoscaler") %></div>
<div class="kpi-value"><%= m[:kill_switch] ? t("admin.stream_nodes.kpi.kill_switch") : (m[:enabled] ? "ON" : "OFF") %></div>
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.kind", kind: m[:kind]) %></div>
</div>
<div class="kpi-card <%= m[:free_slots] <= m[:soft_free_slots] ? 'kpi-card--danger' : '' %>">
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.free_slots") %></div>
<div class="kpi-value"><%= m[:free_slots] %></div>
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.soft_slots", soft: m[:soft_free_slots]) %></div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.spare") %></div>
<div class="kpi-value"><%= m[:spare_ready] %><span class="kpi-value-unit">/<%= m[:warm_spare_min] %></span></div>
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.warm_spare") %></div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.overflow") %></div>
<div class="kpi-value"><%= m[:overflow_nodes] %><span class="kpi-value-unit">/<%= m[:max_overflow_nodes] %></span></div>
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.overflow_nodes") %></div>
</div>
<div class="kpi-card <%= m[:within_budget] ? '' : 'kpi-card--danger' %>">
<div class="kpi-label"><%= t("admin.stream_nodes.kpi.budget") %></div>
<div class="kpi-value kpi-value--sm"><%= m[:estimated_monthly_eur] %></div>
<div class="kpi-sub"><%= t("admin.stream_nodes.kpi.budget_of", budget: m[:monthly_budget_eur], ok: (m[:within_budget] ? "OK" : "OVER")) %></div>
</div>
</section>
<div class="panel" style="margin-bottom:1.5rem">
<div class="admin-panel-head">
<h2><%= t("admin.stream_nodes.actions_title") %></h2>
</div>
<div class="admin-toolbar">
<%= button_to t("admin.stream_nodes.provision_lab"), admin_stream_nodes_path, method: :post, params: { kind: "lab" }, class: "admin-btn admin-btn--secondary" %>
<% if @hetzner_configured %>
<%= button_to t("admin.stream_nodes.provision_cloud"), admin_stream_nodes_path, method: :post, params: { kind: "cloud" }, class: "admin-btn admin-btn--primary",
form: { data: { confirm: t("admin.stream_nodes.provision_cloud_confirm") } } %>
<% else %>
<span class="muted"><%= t("admin.stream_nodes.hetzner_token_missing") %></span>
<% end %>
<% if m[:kill_switch] %>
<%= button_to t("admin.stream_nodes.clear_kill_switch"), clear_kill_switch_admin_stream_nodes_path, method: :delete, class: "admin-btn admin-btn--secondary" %>
<% else %>
<%= button_to t("admin.stream_nodes.engage_kill_switch"), kill_switch_admin_stream_nodes_path, method: :post, class: "admin-btn admin-btn--danger",
form: { data: { confirm: t("admin.stream_nodes.kill_switch_confirm") } } %>
<% end %>
</div>
</div>
<div class="panel">
<h2><%= t("admin.stream_nodes.table_title") %></h2>
<% if @nodes.any? %>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th><%= t("admin.stream_nodes.col.slug") %></th>
<th><%= t("admin.stream_nodes.col.role") %></th>
<th><%= t("admin.stream_nodes.col.status") %></th>
<th><%= t("admin.stream_nodes.col.slots") %></th>
<th><%= t("admin.stream_nodes.col.hostname") %></th>
<th><%= t("admin.stream_nodes.col.provider") %></th>
<th></th>
</tr>
</thead>
<tbody>
<% @nodes.each do |node| %>
<%
badge =
case node.status
when "ready" then "badge--ready"
when "provisioning", "draining" then "badge--connecting"
when "error", "offline" then "badge--paused"
else "badge--paused"
end
%>
<tr>
<td><strong><%= node.slug %></strong></td>
<td class="muted"><%= node.role %></td>
<td><span class="badge <%= badge %>"><%= node.status %></span></td>
<td>
<strong><%= node.active_publishers %></strong>
<span class="muted">/ <%= node.max_publishers %></span>
<div class="muted" style="font-size:0.8rem"><%= t("admin.stream_nodes.free_slots", count: node.free_slots) %></div>
</td>
<td class="admin-mono"><%= node.hostname %></td>
<td class="muted">
<%= node.provider %>
<% if node.provider_instance_id.present? %>
<div class="admin-mono" style="font-size:0.75rem;margin-top:0.2rem"><%= node.provider_instance_id %></div>
<% end %>
</td>
<td class="admin-actions">
<% if node.slug != "home" %>
<% if node.status == "ready" %>
<%= button_to t("admin.stream_nodes.drain"), drain_admin_stream_node_path(node), method: :post, class: "admin-btn admin-btn--sm admin-btn--secondary" %>
<% end %>
<%= button_to t("admin.stream_nodes.destroy"), admin_stream_node_path(node), method: :delete, class: "admin-btn admin-btn--sm admin-btn--danger",
form: { data: { confirm: t("admin.stream_nodes.destroy_confirm", slug: node.slug) } } %>
<% else %>
<span class="muted"></span>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="empty"><%= t("admin.stream_nodes.empty") %></p>
<% end %>
</div>
<% if @lab_hosts.present? %>
<div class="panel" style="margin-top:1.5rem">
<h2><%= t("admin.stream_nodes.hosts_title") %></h2>
<pre class="admin-pre"><%= @lab_hosts %></pre>
</div>
<% end %>
+2 -1
View File
@@ -4,7 +4,7 @@
<title><%= t("admin.layout.title") %></title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="/admin.css?v=2">
<link rel="stylesheet" href="/admin.css?v=4">
<% if content_for?(:replay_archive_styles) %>
<link rel="stylesheet" href="/marketing.css?v=42">
<% end %>
@@ -29,6 +29,7 @@
<%= link_to t("admin.layout.nav.billing"), admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %>
<%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
<%= link_to t("admin.layout.nav.sessions"), admin_sessions_path, class: ("active" if controller_name == "sessions") %>
<%= link_to t("admin.layout.nav.stream_nodes"), admin_stream_nodes_path, class: ("active" if controller_name == "stream_nodes") %>
<%= link_to t("admin.layout.nav.password"), edit_admin_password_path %>
<%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %>
<% end %>
+1 -1
View File
@@ -8,7 +8,7 @@
<%= render "shared/meta_tags" %>
<%= yield :head %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=63">
<link rel="stylesheet" href="/marketing.css?v=50">
</head>
<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" %>
@@ -6,7 +6,7 @@
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
<%= render "shared/meta_tags" %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=63">
<link rel="stylesheet" href="/marketing.css?v=50">
<link rel="stylesheet" href="/live.css?v=26">
<%= yield :head %>
</head>
+47 -267
View File
@@ -2,283 +2,63 @@
<% content_for :meta_description, t("pages.features.meta_description") %>
<% content_for :canonical_url, seo_absolute_url(public_features_path) %>
<article class="features-page">
<header class="features-hero">
<div class="wrap features-hero__grid">
<div class="features-hero__copy">
<p class="features-hero__eyebrow">Match Live TV</p>
<h1><%= t("pages.features.hero_title") %></h1>
<p class="features-hero__lead"><%= t("pages.features.hero_lead") %></p>
<div class="wrap">
<h1 style="margin-top:24px"><%= t("pages.features.title") %></h1>
<p style="color:#aaa;max-width:640px"><%= t("pages.features.lead") %></p>
<ol class="features-flow" aria-label="<%= t("pages.features.flow_aria") %>">
<li class="features-flow__item">
<span class="features-flow__icon" aria-hidden="true"><i class="fa-solid fa-mobile-screen"></i></span>
<span class="features-flow__label"><%= t("pages.features.flow_phone") %></span>
</li>
<li class="features-flow__arrow" aria-hidden="true"><i class="fa-solid fa-arrow-right"></i></li>
<li class="features-flow__item">
<span class="features-flow__icon features-flow__icon--blue" aria-hidden="true"><i class="fa-solid fa-user"></i></span>
<span class="features-flow__label"><%= t("pages.features.flow_account") %></span>
</li>
<li class="features-flow__arrow" aria-hidden="true"><i class="fa-solid fa-arrow-right"></i></li>
<li class="features-flow__item features-flow__item--live">
<span class="features-flow__icon features-flow__icon--live" aria-hidden="true">
<span class="features-flow__dot"></span>
</span>
<span class="features-flow__label"><%= t("pages.features.flow_live") %></span>
</li>
</ol>
</div>
<div class="features-hero__visual">
<%= image_tag "/hero-devices.png",
alt: t("pages.features.hero_alt"),
class: "features-hero__img",
loading: "eager",
fetchpriority: "high" %>
</div>
</div>
</header>
<section class="section wrap features-how" aria-labelledby="features-how-title">
<h2 id="features-how-title"><%= t("pages.features.how_title") %></h2>
<ol class="features-how__steps">
<li class="features-how__step">
<span class="features-how__num" aria-hidden="true">1</span>
<h3><%= t("pages.features.how_1_title") %></h3>
<p><%= t("pages.features.how_1_body") %></p>
</li>
<li class="features-how__step">
<span class="features-how__num" aria-hidden="true">2</span>
<h3><%= t("pages.features.how_2_title") %></h3>
<p><%= t("pages.features.how_2_body") %></p>
</li>
<li class="features-how__step">
<span class="features-how__num" aria-hidden="true">3</span>
<h3><%= t("pages.features.how_3_title") %></h3>
<p><%= t("pages.features.how_3_body") %></p>
</li>
<li class="features-how__step">
<span class="features-how__num" aria-hidden="true">4</span>
<h3><%= t("pages.features.how_4_title") %></h3>
<p><%= t("pages.features.how_4_body") %></p>
</li>
<li class="features-how__step">
<span class="features-how__num" aria-hidden="true">5</span>
<h3><%= t("pages.features.how_5_title") %></h3>
<p><%= t("pages.features.how_5_body") %></p>
</li>
</ol>
</section>
<section class="section wrap features-accounts" aria-labelledby="features-accounts-title">
<div class="features-panel">
<h2 id="features-accounts-title"><%= t("pages.features.accounts_title") %></h2>
<p class="features-panel__lead"><%= t("pages.features.accounts_lead") %></p>
<div class="features-accounts__grid">
<div class="features-accounts__card">
<span class="feature-card__icon" aria-hidden="true"><i class="fa-solid fa-user-plus"></i></span>
<h3><%= t("pages.features.accounts_invite_title") %></h3>
<p><%= t("pages.features.accounts_invite_body") %></p>
</div>
<div class="features-accounts__card">
<span class="feature-card__icon feature-card__icon--blue" aria-hidden="true"><i class="fa-solid fa-mobile-screen"></i></span>
<h3><%= t("pages.features.accounts_transmit_title") %></h3>
<p><%= t("pages.features.accounts_transmit_body") %></p>
</div>
<div class="features-accounts__card">
<span class="feature-card__icon feature-card__icon--gold" aria-hidden="true"><i class="fa-solid fa-user-shield"></i></span>
<h3><%= t("pages.features.accounts_revoke_title") %></h3>
<p><%= t("pages.features.accounts_revoke_body") %></p>
</div>
</div>
<p class="features-highlight"><%= t("pages.features.accounts_highlight") %></p>
</div>
</section>
<section class="section wrap features-score" aria-labelledby="features-score-title">
<div class="features-split">
<div class="features-split__copy">
<h2 id="features-score-title"><%= t("pages.features.score_title") %></h2>
<p class="features-panel__lead features-panel__lead--left"><%= t("pages.features.score_lead") %></p>
<ul class="features-checklist">
<li><%= t("pages.features.score_item_app") %></li>
<li><%= t("pages.features.score_item_signup") %></li>
<li><%= t("pages.features.score_item_password") %></li>
<li><%= t("pages.features.score_item_link") %></li>
</ul>
</div>
<aside class="features-score__card" aria-label="<%= t("pages.features.score_card_aria") %>">
<div class="features-score__visual" aria-hidden="true">
<div class="features-score__phone">
<div class="features-score__phone-top">
<span class="features-score__live"><span class="features-score__live-dot"></span> REGIA</span>
<span class="features-score__set">3° set</span>
</div>
<div class="features-score__board">
<div class="features-score__team">
<span class="features-score__team-name">Casa</span>
<span class="features-score__points">21</span>
</div>
<span class="features-score__sep"></span>
<div class="features-score__team features-score__team--away">
<span class="features-score__team-name">Ospiti</span>
<span class="features-score__points">18</span>
</div>
</div>
<div class="features-score__actions">
<span class="features-score__btn">+1</span>
<span class="features-score__btn features-score__btn--muted">+1</span>
</div>
</div>
<div class="features-score__linkchip">
<i class="fa-solid fa-link" aria-hidden="true"></i>
<span>matchlivetv.it/regia/…</span>
</div>
</div>
<div class="features-score__body">
<span class="feature-card__badge feature-card__badge--link"><%= t("pages.features.access_link") %></span>
<p class="features-score__card-title"><%= t("pages.features.score_card_title") %></p>
<p><%= t("pages.features.score_card_body") %></p>
</div>
</aside>
</div>
</section>
<section class="section wrap features-distribute" aria-labelledby="features-distribute-title">
<h2 id="features-distribute-title"><%= t("pages.features.distribute_title") %></h2>
<p class="features-panel__lead"><%= t("pages.features.distribute_lead") %></p>
<ol class="features-pipeline" aria-label="<%= t("pages.features.distribute_aria") %>">
<li class="features-pipeline__node">
<span class="features-pipeline__icon" aria-hidden="true"><i class="fa-solid fa-mobile-screen"></i></span>
<span class="features-pipeline__label"><%= t("pages.features.distribute_phone") %></span>
</li>
<li class="features-pipeline__join" aria-hidden="true"></li>
<li class="features-pipeline__node features-pipeline__node--hub">
<span class="features-pipeline__icon" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span>
<span class="features-pipeline__label"><%= t("pages.features.distribute_mltv") %></span>
</li>
<li class="features-pipeline__join" aria-hidden="true"></li>
<li class="features-pipeline__targets">
<div class="features-pipeline__node">
<span class="features-pipeline__icon features-pipeline__icon--blue" aria-hidden="true"><i class="fa-solid fa-users"></i></span>
<span class="features-pipeline__label"><%= t("pages.features.distribute_viewers") %></span>
</div>
<div class="features-pipeline__node">
<span class="features-pipeline__icon" aria-hidden="true"><i class="fa-brands fa-youtube"></i></span>
<span class="features-pipeline__label"><%= t("pages.features.distribute_youtube") %></span>
</div>
</li>
</ol>
</section>
<section class="section wrap features-youtube" aria-labelledby="features-youtube-title">
<div class="features-panel features-panel--accent features-youtube__panel">
<div class="features-youtube__copy">
<span class="feature-card__badge feature-card__badge--invite"><%= t("pages.features.youtube_badge") %></span>
<h2 id="features-youtube-title"><%= t("pages.features.youtube_title") %></h2>
<p class="features-panel__lead features-panel__lead--left"><%= t("pages.features.youtube_body") %></p>
</div>
<div class="features-youtube__visual" aria-hidden="true">
<div class="features-yt-mock">
<div class="features-yt-mock__player">
<span class="features-yt-mock__live"><span class="features-yt-mock__dot"></span> LIVE</span>
<span class="features-yt-mock__play"><i class="fa-brands fa-youtube"></i></span>
</div>
<div class="features-yt-mock__meta">
<span class="features-yt-mock__avatar"><i class="fa-solid fa-shield-halved"></i></span>
<div class="features-yt-mock__text">
<strong><%= t("pages.features.youtube_mock_channel") %></strong>
<span><%= t("pages.features.youtube_mock_subs") %></span>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="section wrap features-replay" aria-labelledby="features-replay-title">
<div class="features-split features-split--reverse">
<div class="features-split__copy">
<h2 id="features-replay-title"><%= t("pages.features.replay_title") %></h2>
<p class="features-panel__lead features-panel__lead--left"><%= t("pages.features.replay_lead") %></p>
<ul class="features-checklist">
<li><%= t("pages.features.replay_item_server") %></li>
<li><%= t("pages.features.replay_item_plan") %></li>
<li><%= t("pages.features.replay_item_download") %></li>
</ul>
</div>
<aside class="features-replay__card" aria-label="<%= t("pages.features.replay_card_title") %>">
<div class="features-replay__visual" aria-hidden="true">
<div class="features-archive-mock">
<div class="features-archive-mock__head">
<i class="fa-solid fa-cloud" aria-hidden="true"></i>
<span><%= t("pages.features.replay_card_title") %></span>
</div>
<ul class="features-archive-mock__list">
<li>
<span class="features-archive-mock__thumb features-archive-mock__thumb--a"></span>
<span class="features-archive-mock__info">
<strong><%= t("pages.features.replay_mock_1_title") %></strong>
<em><%= t("pages.features.replay_mock_1_meta") %></em>
</span>
<i class="fa-solid fa-download" aria-hidden="true"></i>
</li>
<li>
<span class="features-archive-mock__thumb features-archive-mock__thumb--b"></span>
<span class="features-archive-mock__info">
<strong><%= t("pages.features.replay_mock_2_title") %></strong>
<em><%= t("pages.features.replay_mock_2_meta") %></em>
</span>
<i class="fa-solid fa-download" aria-hidden="true"></i>
</li>
<li>
<span class="features-archive-mock__thumb features-archive-mock__thumb--c"></span>
<span class="features-archive-mock__info">
<strong><%= t("pages.features.replay_mock_3_title") %></strong>
<em><%= t("pages.features.replay_mock_3_meta") %></em>
</span>
<i class="fa-solid fa-download" aria-hidden="true"></i>
</li>
</ul>
</div>
</div>
<div class="features-replay__body">
<p class="features-score__card-title"><%= t("pages.features.replay_card_title") %></p>
<p><%= t("pages.features.replay_card_body") %></p>
</div>
</aside>
</div>
</section>
<section class="section wrap features-more" aria-labelledby="features-more-title">
<h2 id="features-more-title"><%= t("pages.features.more_title") %></h2>
<div class="features-grid features-grid--compact">
<section class="section">
<h2><%= t("pages.features.staff_section_title") %></h2>
<div class="features-grid">
<div class="feature-card">
<span class="feature-card__icon" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span>
<h3><%= t("pages.features.more_stable_title") %></h3>
<p><%= t("pages.features.more_stable_body") %></p>
<h3><%= t("pages.features.staff_invite_title") %></h3>
<p><%= t("pages.features.staff_invite_body") %></p>
</div>
<div class="feature-card">
<span class="feature-card__icon feature-card__icon--blue" aria-hidden="true"><i class="fa-solid fa-share-nodes"></i></span>
<h3><%= t("pages.features.more_link_title") %></h3>
<p><%= t("pages.features.more_link_body") %></p>
<h3><%= t("pages.features.staff_access_title") %></h3>
<p><%= t("pages.features.staff_access_body") %></p>
</div>
<div class="feature-card">
<span class="feature-card__icon" aria-hidden="true"><i class="fa-brands fa-youtube"></i></span>
<h3><%= t("pages.features.more_yt_mltv_title") %></h3>
<p><%= t("pages.features.more_yt_mltv_body") %></p>
<h3><%= t("pages.features.staff_plans_title") %></h3>
<p><%= t("pages.features.staff_plans_body") %></p>
</div>
</div>
</section>
<section class="section wrap features-cta" aria-labelledby="features-cta-title">
<div class="features-cta__box">
<h2 id="features-cta-title"><%= t("pages.features.cta_title") %></h2>
<p><%= t("pages.features.cta_body") %></p>
<div class="features-cta__actions">
<%= link_to t("pages.features.cta_primary"), public_signup_path, class: "btn btn-primary" %>
<%= link_to t("pages.features.cta_secondary"), public_prezzi_path, class: "btn btn-outline" %>
<section class="section">
<h2><%= t("pages.features.live_section_title") %></h2>
<div class="features-grid">
<div class="feature-card">
<h3><%= t("pages.features.live_phone_title") %></h3>
<p><%= t("pages.features.live_phone_body") %></p>
</div>
<div class="feature-card">
<h3><%= t("pages.features.live_score_title") %></h3>
<p><%= t("pages.features.live_score_body") %></p>
</div>
<div class="feature-card">
<h3><%= t("pages.features.live_link_title") %></h3>
<p><%= t("pages.features.live_link_body") %></p>
</div>
<div class="feature-card">
<h3><%= t("pages.features.live_archive_title") %></h3>
<p><%= t("pages.features.live_archive_body") %></p>
</div>
<div class="feature-card">
<h3><%= t("pages.features.live_youtube_mltv_title") %></h3>
<p><%= t("pages.features.live_youtube_mltv_body") %></p>
</div>
<div class="feature-card">
<h3><%= t("pages.features.live_youtube_club_title") %></h3>
<p><%= t("pages.features.live_youtube_club_body") %></p>
</div>
<div class="feature-card">
<h3><%= t("pages.features.live_website_title") %></h3>
<p><%= t("pages.features.live_website_body") %></p>
</div>
</div>
</section>
</article>
<p style="text-align:center;margin:40px 0">
<%= link_to t("pages.features.cta_signup"), public_signup_path, class: "btn btn-primary" %>
</p>
</div>
+1 -6
View File
@@ -10,8 +10,7 @@
"url" => seo_absolute_url(root_path),
"applicationCategory" => "SportsApplication",
"operatingSystem" => "Web, Android, iOS",
"description" => t("home.schema_description"),
"sameAs" => [MatchLiveTv.app_store_url, MatchLiveTv.play_store_url]
"description" => t("home.schema_description")
}.to_json) %>
</script>
<% end %>
@@ -27,10 +26,6 @@
<%= link_to t("home.cta_signup"), public_signup_path, class: "btn btn-primary" %>
<%= link_to t("home.cta_live"), public_live_index_path, class: "btn btn-secondary" %>
</div>
<div class="hero-stores">
<p class="hero-stores-label"><%= t("home.stores_label") %></p>
<%= render "shared/store_badges", variant: "hero" %>
</div>
<ul class="hero-features" aria-label="<%= t('home.features_aria') %>">
<li class="hero-feature-item">
<span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span>
@@ -22,60 +22,19 @@
<%= render "shared/plan_cards" %>
<div class="table-scroll compare-table-wrap">
<div class="table-scroll">
<table class="compare-table">
<colgroup>
<col class="compare-table__feature">
<col class="compare-table__plan">
<col class="compare-table__plan">
<col class="compare-table__plan compare-table__plan--featured">
</colgroup>
<thead>
<tr>
<th></th>
<th>Free</th>
<th>Premium Light</th>
<th>Premium Full</th>
</tr>
<tr><th></th><th>Free</th><th>Premium Light</th><th>Premium Full</th></tr>
</thead>
<tbody>
<tr>
<td><%= t("pages.pricing.table_staff") %></td>
<td>1</td>
<td>5</td>
<td><%= t("pages.pricing.table_unlimited") %></td>
</tr>
<tr><td><%= t("pages.pricing.table_staff") %></td><td>1</td><td>5</td><td><%= t("pages.pricing.table_unlimited") %></td></tr>
<tr><td><%= t("pages.pricing.table_matches") %></td><td>1</td><td>3</td><td>10</td></tr>
<tr><td><%= t("pages.pricing.table_live_mltv") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td></tr>
<tr><td><%= t("pages.pricing.table_youtube") %></td><td><%= t("pages.pricing.table_no") %></td><td>Match Live TV</td><td><%= t("pages.pricing.table_youtube_club") %></td></tr>
<tr><td><%= t("pages.pricing.table_replay") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.plans.replay_days", count: 30) %></td><td><%= t("pages.plans.replay_days", count: 90) %></td></tr>
<tr><td><%= t("pages.pricing.table_download") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td></tr>
<tr>
<td><%= t("pages.pricing.table_price") %></td>
<td><%= t("pages.pricing.table_price_free") %></td>
<td>
<div class="compare-price">
<strong><%= Billing::Stripe::PriceCatalog.label(plan_slug: "premium_light", interval: "yearly") %></strong>
<span class="compare-price__launch"><%= t("pages.plans.launch_price") %></span>
<small class="compare-price__note"><%= t(
"pages.pricing.table_price_note",
list: Billing::Stripe::PriceCatalog.list_label(plan_slug: "premium_light", interval: "yearly"),
monthly: Billing::Stripe::PriceCatalog.label(plan_slug: "premium_light", interval: "monthly")
) %></small>
</div>
</td>
<td>
<div class="compare-price">
<strong><%= Billing::Stripe::PriceCatalog.label(plan_slug: "premium_full", interval: "yearly") %></strong>
<span class="compare-price__launch"><%= t("pages.plans.launch_price") %></span>
<small class="compare-price__note"><%= t(
"pages.pricing.table_price_note",
list: Billing::Stripe::PriceCatalog.list_label(plan_slug: "premium_full", interval: "yearly"),
monthly: Billing::Stripe::PriceCatalog.label(plan_slug: "premium_full", interval: "monthly")
) %></small>
</div>
</td>
</tr>
<tr><td><%= t("pages.pricing.table_price") %></td><td><%= t("pages.pricing.table_price_free") %></td><td><%= raw t("pages.pricing.table_price_light_html") %></td><td><%= raw t("pages.pricing.table_price_full_html") %></td></tr>
</tbody>
</table>
</div>
@@ -2,7 +2,6 @@
<div class="wrap">
<div>
<strong style="color:#fff">Match Live TV</strong><%= t("footer.tagline") %>
<%= render "shared/store_badges", variant: "footer" %>
</div>
<div>
<%= link_to t("common.support"), public_support_path %> ·
+14 -30
View File
@@ -15,33 +15,23 @@
current_interval: current_interval,
subscription: subscription
) : nil %>
<% featured = plan.slug == "premium_full" %>
<div class="plan-card<%= " plan-card--featured" if featured %>">
<% if featured %>
<p class="plan-card__recommend"><%= t("pages.plans.recommended_badge") %></p>
<% end %>
<div class="plan-card">
<%= render "shared/plan_title", plan: plan %>
<% if plan.slug == "free" %>
<div class="plan-price">€0</div>
<% elsif plan.slug == "premium_light" %>
<% light_yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %>
<% light_monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %>
<div class="plan-price" style="color:#e53935"><%= light_yearly %></div>
<p class="plan-price-alt"><%= raw t("pages.plans.price_alt_html", price: light_monthly) %></p>
<% else %>
<% yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %>
<% monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %>
<% list = Billing::Stripe::PriceCatalog.list_label(plan_slug: plan.slug, interval: "yearly") %>
<% equiv = Billing::Stripe::PriceCatalog.equivalent_monthly_label(plan_slug: plan.slug) %>
<div class="plan-price-block">
<% if list.present? %>
<div class="plan-price-list"><s><%= list %></s></div>
<% end %>
<div class="plan-price<%= " plan-price--featured" if featured %>"><%= yearly %></div>
<span class="plan-price-badge"><%= t("pages.plans.launch_price") %></span>
<% if equiv.present? %>
<p class="plan-price-equiv"><%= t("pages.plans.price_equiv", price: equiv) %></p>
<% end %>
</div>
<p class="plan-price-alt"><%= raw t("pages.plans.price_alt_html", price: monthly) %></p>
<% full_yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %>
<% full_monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %>
<div class="plan-price"><%= full_yearly %></div>
<p class="plan-price-alt"><%= raw t("pages.plans.price_alt_html", price: full_monthly) %></p>
<% end %>
<ul>
<li><%= raw(plan.max_staff_transmission ? t("pages.plans.staff_html", count: plan.max_staff_transmission) : t("pages.plans.staff_unlimited_html")) %></li>
<li><%= raw t("pages.plans.staff_html", count: plan.max_staff_transmission || t("pages.plans.staff_unlimited")) %></li>
<li><%= raw t("pages.plans.matches_html", count: plan.concurrent_streams_limit || t("pages.plans.matches_unlimited")) %></li>
<li><%= raw t(
"pages.plans.replay_html",
@@ -58,9 +48,6 @@
end
) %></li>
</ul>
<% if plan.slug == "premium_full" %>
<p class="plan-staff-note"><%= t("pages.plans.staff_note_full") %></p>
<% end %>
<% if billing_mode %>
<% action_kind = action[:kind] %>
<% if action_kind == :current %>
@@ -91,12 +78,8 @@
<%= link_to t("pages.plans.start_free"), public_signup_path, class: "btn btn-secondary" %>
<% else %>
<div class="plan-interval-actions">
<% %w[yearly monthly].each do |interval| %>
<%= link_to t(
"billing.messages.activate_button",
plan: plan_cta_name(plan),
price: Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: interval)
),
<% plan_intervals_for_display(Billing::Stripe::PriceCatalog.available_intervals(plan_slug: plan.slug)).each do |interval| %>
<%= link_to t("pages.plans.register_with_price", price: Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: interval)),
public_signup_path(plan: plan.slug, interval: interval),
class: plan_interval_button_class(interval) %>
<% end %>
@@ -105,3 +88,4 @@
</div>
<% end %>
</div>
@@ -1,31 +0,0 @@
<% variant = local_assigns.fetch(:variant, "hero") %>
<div class="store-badges store-badges--<%= variant %>">
<%= link_to MatchLiveTv.app_store_url,
class: "store-badge",
target: "_blank",
rel: "noopener noreferrer",
aria: { label: t("home.store_app_store_aria") } do %>
<%= image_tag "/images/store/apple.svg",
alt: "",
class: "store-badge__logo store-badge__logo--apple",
aria: { hidden: true } %>
<span>
<span class="store-badge__kicker"><%= t("home.store_app_store_kicker") %></span>
<span class="store-badge__name"><%= t("home.store_app_store_name") %></span>
</span>
<% end %>
<%= link_to MatchLiveTv.play_store_url,
class: "store-badge",
target: "_blank",
rel: "noopener noreferrer",
aria: { label: t("home.store_play_aria") } do %>
<%= image_tag "/images/store/google-play.svg",
alt: "",
class: "store-badge__logo store-badge__logo--play",
aria: { hidden: true } %>
<span>
<span class="store-badge__kicker"><%= t("home.store_play_kicker") %></span>
<span class="store-badge__name"><%= t("home.store_play_name") %></span>
</span>
<% end %>
</div>
@@ -180,13 +180,5 @@ module MatchLiveTv
def google_analytics_configured?
google_analytics_measurement_id.present?
end
def app_store_url
"https://apps.apple.com/us/app/matchlivetv/id6799510705"
end
def play_store_url
"https://play.google.com/store/apps/details?id=com.matchlivetv.match_live_tv"
end
end
end
+1
View File
@@ -6,6 +6,7 @@ Sidekiq.configure_server do |config|
config.on(:startup) do
StreamPublisherSyncJob.ensure_chain
Ops::HealthMonitorJob.ensure_chain
Streams::AutoscalerJob.ensure_chain
end
end
+48
View File
@@ -9,6 +9,7 @@ de:
billing: Abrechnung
youtube: YouTube
sessions: Sitzungen
stream_nodes: Stream-Knoten
password: Passwort
logout: Abmelden
flash:
@@ -26,6 +27,11 @@ de:
ops_acknowledged: Vorfall übernommen
ops_resolved: Vorfall gelöst
ops_muted: Benachrichtigungen für 24 Stunden stummgeschaltet
stream_node_created: "Lab-Knoten %{slug} bereitgestellt."
stream_node_destroyed: "Knoten %{slug} entfernt."
stream_node_draining: "Knoten %{slug} im Drain-Modus."
autoscale_kill_on: "Autoscaler-Kill-Switch aktiv. Kein automatisches Scale-out."
autoscale_kill_off: "Autoscaler-Kill-Switch aus (STREAM_AUTOSCALE_ENABLED=1 weiterhin nötig)."
comped_granted: "Kostenloses Abonnement %{plan} für %{club} aktiviert."
comped_revoked: "Kostenloses Abonnement für %{club} widerrufen."
session_already_terminated: "Sitzung bereits beendet (%{status})."
@@ -111,6 +117,48 @@ de:
title: Teams
matches_count: "%{count} Spiele"
view_all: "Vereine ansehen (%{count} Teams)"
stream_nodes:
title: Stream-Knoten
providers: "Cloud: %{cloud} · DNS: %{dns}"
actions_title: Aktionen
table_title: Registrierte Knoten
empty: Keine Knoten registriert.
free_slots:
one: "%{count} frei"
other: "%{count} frei"
kpi:
autoscaler: Autoscaler
kill_switch: KILL
kind: "Modus %{kind}"
free_slots: Freie Slots
soft_slots: "Soft-Schwelle ≤ %{soft}"
spare: Spare
warm_spare: Warm-Spares bereit
overflow: Overflow
overflow_nodes: Cloud/Lab-Knoten
budget: Geschätztes Budget
budget_of: "von €%{budget}/Monat (%{ok})"
autoscale: "Autoscaler %{enabled} · free_slots=%{free} (soft≤%{soft}) · spare=%{spare}/%{warm} · overflow=%{overflow}/%{max} · kind=%{kind}"
autoscale_budget: "Budget €%{eur}/€%{budget} (%{ok})"
kill_switch_active: "KILL-SWITCH AKTIV"
engage_kill_switch: "Kill-switch ON"
clear_kill_switch: "Kill-switch OFF"
kill_switch_confirm: "Autoscaler sofort blockieren?"
provision_lab: Lab bereitstellen
provision_cloud: Hetzner bereitstellen
provision_cloud_confirm: "Hetzner Cloud Server + DNS auf mltv-stream.net erstellen?"
hetzner_token_missing: "HCLOUD_TOKEN setzen, um Cloud-Provisioning zu aktivieren."
drain: Drain
destroy: Löschen
destroy_confirm: "Knoten %{slug} löschen?"
hosts_title: Lab-DNS (/etc/hosts)
col:
slug: Slug
role: Rolle
status: Status
slots: Slots
hostname: Hostname
provider: Provider
ops:
kpi:
critical: Kritisch offen
+48
View File
@@ -9,6 +9,7 @@ en:
billing: Billing
youtube: YouTube
sessions: Sessions
stream_nodes: Stream nodes
password: Password
logout: Log out
flash:
@@ -26,6 +27,11 @@ en:
ops_acknowledged: Incident acknowledged
ops_resolved: Incident resolved
ops_muted: Notifications muted for 24 hours
stream_node_created: "Lab node %{slug} provisioned."
stream_node_destroyed: "Node %{slug} removed."
stream_node_draining: "Node %{slug} is draining (no new sessions)."
autoscale_kill_on: "Autoscaler kill-switch engaged. No automatic scale-out."
autoscale_kill_off: "Autoscaler kill-switch cleared (still needs STREAM_AUTOSCALE_ENABLED=1)."
comped_granted: "%{plan} complimentary subscription activated for %{club}."
comped_revoked: "Complimentary subscription revoked for %{club}."
session_already_terminated: "Session already ended (%{status})."
@@ -111,6 +117,48 @@ en:
title: Teams
matches_count: "%{count} matches"
view_all: "View clubs (%{count} teams)"
stream_nodes:
title: Streaming nodes
providers: "Cloud: %{cloud} · DNS: %{dns}"
actions_title: Actions
table_title: Registered nodes
empty: No nodes registered.
free_slots:
one: "%{count} free"
other: "%{count} free"
kpi:
autoscaler: Autoscaler
kill_switch: KILL
kind: "%{kind} mode"
free_slots: Free slots
soft_slots: "soft threshold ≤ %{soft}"
spare: Spare
warm_spare: warm spares ready
overflow: Overflow
overflow_nodes: cloud/lab nodes
budget: Estimated budget
budget_of: "of €%{budget}/mo (%{ok})"
autoscale: "Autoscaler %{enabled} · free_slots=%{free} (soft≤%{soft}) · spare=%{spare}/%{warm} · overflow=%{overflow}/%{max} · kind=%{kind}"
autoscale_budget: "budget €%{eur}/€%{budget} (%{ok})"
kill_switch_active: "KILL-SWITCH ACTIVE"
engage_kill_switch: "Kill-switch ON"
clear_kill_switch: "Kill-switch OFF"
kill_switch_confirm: "Immediately block the autoscaler? Existing nodes stay up."
provision_lab: Provision lab
provision_cloud: Provision Hetzner
provision_cloud_confirm: "Create a Hetzner Cloud server + DNS record on mltv-stream.net? Billing applies until destroyed."
hetzner_token_missing: "Set HCLOUD_TOKEN to enable Cloud provisioning."
drain: Drain
destroy: Delete
destroy_confirm: "Delete node %{slug}?"
hosts_title: Lab DNS records (/etc/hosts snippet)
col:
slug: Slug
role: Role
status: Status
slots: Slots
hostname: Hostname
provider: Provider
ops:
kpi:
critical: Critical open
+48
View File
@@ -9,6 +9,7 @@ es:
billing: Facturación
youtube: YouTube
sessions: Sesiones
stream_nodes: Nodos stream
password: Contraseña
logout: Salir
flash:
@@ -26,6 +27,11 @@ es:
ops_acknowledged: Incidencia asumida
ops_resolved: Incidencia resuelta
ops_muted: Notificaciones silenciadas durante 24 horas
stream_node_created: "Nodo lab %{slug} provisionado."
stream_node_destroyed: "Nodo %{slug} eliminado."
stream_node_draining: "Nodo %{slug} en drain."
autoscale_kill_on: "Kill-switch del autoscaler activado. Sin scale-out automático."
autoscale_kill_off: "Kill-switch del autoscaler desactivado (hace falta STREAM_AUTOSCALE_ENABLED=1)."
comped_granted: "Suscripción de cortesía %{plan} activada para %{club}."
comped_revoked: "Suscripción de cortesía revocada para %{club}."
session_already_terminated: "La sesión ya ha finalizado (%{status})."
@@ -111,6 +117,48 @@ es:
title: Equipos
matches_count: "%{count} partidos"
view_all: "Ver clubes (%{count} equipos)"
stream_nodes:
title: Nodos streaming
providers: "Cloud: %{cloud} · DNS: %{dns}"
actions_title: Acciones
table_title: Nodos registrados
empty: No hay nodos registrados.
free_slots:
one: "%{count} libre"
other: "%{count} libres"
kpi:
autoscaler: Autoscaler
kill_switch: KILL
kind: "modo %{kind}"
free_slots: Slots libres
soft_slots: "umbral soft ≤ %{soft}"
spare: Spare
warm_spare: warm spares listos
overflow: Overflow
overflow_nodes: nodos cloud/lab
budget: Presupuesto estimado
budget_of: "de €%{budget}/mes (%{ok})"
autoscale: "Autoscaler %{enabled} · free_slots=%{free} (soft≤%{soft}) · spare=%{spare}/%{warm} · overflow=%{overflow}/%{max} · kind=%{kind}"
autoscale_budget: "presupuesto €%{eur}/€%{budget} (%{ok})"
kill_switch_active: "KILL-SWITCH ACTIVO"
engage_kill_switch: "Kill-switch ON"
clear_kill_switch: "Kill-switch OFF"
kill_switch_confirm: "¿Bloquear el autoscaler inmediatamente?"
provision_lab: Provisionar lab
provision_cloud: Provisionar Hetzner
provision_cloud_confirm: "¿Crear un servidor Hetzner Cloud + DNS en mltv-stream.net?"
hetzner_token_missing: "Configura HCLOUD_TOKEN para habilitar el provisioning Cloud."
drain: Drain
destroy: Eliminar
destroy_confirm: "¿Eliminar el nodo %{slug}?"
hosts_title: DNS lab (/etc/hosts)
col:
slug: Slug
role: Rol
status: Estado
slots: Slots
hostname: Hostname
provider: Provider
ops:
kpi:
critical: Críticas abiertas
+48
View File
@@ -9,6 +9,7 @@ fr:
billing: Facturation
youtube: YouTube
sessions: Sessions
stream_nodes: Nœuds stream
password: Mot de passe
logout: Déconnexion
flash:
@@ -26,6 +27,11 @@ fr:
ops_acknowledged: Incident pris en charge
ops_resolved: Incident résolu
ops_muted: Notifications suspendues pendant 24 heures
stream_node_created: "Nœud lab %{slug} provisionné."
stream_node_destroyed: "Nœud %{slug} supprimé."
stream_node_draining: "Nœud %{slug} en drain."
autoscale_kill_on: "Kill-switch autoscaler activé. Pas de scale-out automatique."
autoscale_kill_off: "Kill-switch autoscaler désactivé (nécessite aussi STREAM_AUTOSCALE_ENABLED=1)."
comped_granted: "Abonnement offert %{plan} activé pour %{club}."
comped_revoked: "Abonnement offert révoqué pour %{club}."
session_already_terminated: "Session déjà terminée (%{status})."
@@ -111,6 +117,48 @@ fr:
title: Équipes
matches_count: "%{count} matchs"
view_all: "Voir les clubs (%{count} équipes)"
stream_nodes:
title: Nœuds streaming
providers: "Cloud: %{cloud} · DNS: %{dns}"
actions_title: Actions
table_title: Nœuds enregistrés
empty: Aucun nœud enregistré.
free_slots:
one: "%{count} libre"
other: "%{count} libres"
kpi:
autoscaler: Autoscaler
kill_switch: KILL
kind: "mode %{kind}"
free_slots: Slots libres
soft_slots: "seuil soft ≤ %{soft}"
spare: Spare
warm_spare: warm spares prêts
overflow: Overflow
overflow_nodes: nœuds cloud/lab
budget: Budget estimé
budget_of: "sur €%{budget}/mois (%{ok})"
autoscale: "Autoscaler %{enabled} · free_slots=%{free} (soft≤%{soft}) · spare=%{spare}/%{warm} · overflow=%{overflow}/%{max} · kind=%{kind}"
autoscale_budget: "budget €%{eur}/€%{budget} (%{ok})"
kill_switch_active: "KILL-SWITCH ACTIF"
engage_kill_switch: "Kill-switch ON"
clear_kill_switch: "Kill-switch OFF"
kill_switch_confirm: "Bloquer immédiatement l'autoscaler ?"
provision_lab: Provisionner lab
provision_cloud: Provisionner Hetzner
provision_cloud_confirm: "Créer un serveur Hetzner Cloud + DNS sur mltv-stream.net ?"
hetzner_token_missing: "Définir HCLOUD_TOKEN pour activer le provisioning Cloud."
drain: Drain
destroy: Supprimer
destroy_confirm: "Supprimer le nœud %{slug} ?"
hosts_title: DNS lab (/etc/hosts)
col:
slug: Slug
role: Rôle
status: Statut
slots: Slots
hostname: Hostname
provider: Provider
ops:
kpi:
critical: Critiques ouverts
+48
View File
@@ -9,6 +9,7 @@ it:
billing: Fatturazione
youtube: YouTube
sessions: Sessioni
stream_nodes: Nodi stream
password: Password
logout: Esci
flash:
@@ -26,6 +27,11 @@ it:
ops_acknowledged: Incidente preso in carico
ops_resolved: Incidente risolto
ops_muted: Notifiche sospese per 24 ore
stream_node_created: "Nodo lab %{slug} provisionato."
stream_node_destroyed: "Nodo %{slug} rimosso."
stream_node_draining: "Nodo %{slug} in drain (niente nuove sessioni)."
autoscale_kill_on: "Kill-switch autoscaler attivato. Nessun scale-out automatico."
autoscale_kill_off: "Kill-switch autoscaler disattivato (serve comunque STREAM_AUTOSCALE_ENABLED=1)."
comped_granted: "Abbonamento omaggio %{plan} attivato per %{club}."
comped_revoked: "Abbonamento omaggio revocato per %{club}."
session_already_terminated: "Sessione già terminata (%{status})."
@@ -111,6 +117,48 @@ it:
title: Squadre
matches_count: "%{count} partite"
view_all: "Vedi società (%{count} squadre)"
stream_nodes:
title: Nodi streaming
providers: "Cloud: %{cloud} · DNS: %{dns}"
actions_title: Azioni
table_title: Nodi registrati
empty: Nessun nodo registrato.
free_slots:
one: "%{count} libero"
other: "%{count} liberi"
kpi:
autoscaler: Autoscaler
kill_switch: KILL
kind: "modalità %{kind}"
free_slots: Slot liberi
soft_slots: "soglia soft ≤ %{soft}"
spare: Spare
warm_spare: warm spare pronti
overflow: Overflow
overflow_nodes: nodi cloud/lab
budget: Budget stimato
budget_of: "su €%{budget}/mese (%{ok})"
autoscale: "Autoscaler %{enabled} · free_slots=%{free} (soft≤%{soft}) · spare=%{spare}/%{warm} · overflow=%{overflow}/%{max} · kind=%{kind}"
autoscale_budget: "budget €%{eur}/€%{budget} (%{ok})"
kill_switch_active: "KILL-SWITCH ATTIVO"
engage_kill_switch: "Kill-switch ON"
clear_kill_switch: "Kill-switch OFF"
kill_switch_confirm: "Bloccare immediatamente l'autoscaler? I nodi esistenti restano accesi."
provision_lab: Provisiona lab
provision_cloud: Provisiona Hetzner
provision_cloud_confirm: "Creare un server Hetzner Cloud + record DNS su mltv-stream.net? Verrà addebitato fino allo spegnimento."
hetzner_token_missing: "Imposta HCLOUD_TOKEN per abilitare il provisioning Cloud."
drain: Drain
destroy: Elimina
destroy_confirm: "Eliminare il nodo %{slug}?"
hosts_title: Record DNS lab (snippet /etc/hosts)
col:
slug: Slug
role: Ruolo
status: Stato
slots: Slot
hostname: Hostname
provider: Provider
ops:
kpi:
critical: Critici aperti
+3 -6
View File
@@ -82,12 +82,12 @@ de:
first_team_name_label: Teamname
first_team_name_placeholder: "z. B. Under 15"
plan_label: "Starttarif (für den gesamten Verein)"
plan_free: "Free — 1 Streaming-Konto pro Team, 1 Livestream"
plan_free: "Free — 1 Übertragungsverantwortlicher pro Team, 1 Livestream"
plan_light: Premium Light
plan_full: Premium Full
interval_label: Premium-Abrechnung
interval_yearly: "Jährlich — 59 €/Jahr (Light) oder 199 €/Jahr (Full)"
interval_monthly: "Monatlich — 7,90 €/Monat (Light) oder 24,90 €/Monat (Full)"
interval_yearly: "Jährlich — 40 €/Jahr (Light) oder 200 €/Jahr (Full)"
interval_monthly: "Monatlich — 5 €/Monat (Light) oder 20 €/Monat (Full)"
submit: Verein erstellen
billing:
title: "Abonnement — %{name}"
@@ -423,9 +423,6 @@ de:
upgrade_info_line: "Upgrade (höherer Tarif oder Wechsel zur jährlichen Abrechnung): Die Differenz wird sofort von der gespeicherten Stripe-Karte abgebucht."
downgrade_info_line: "Downgrade zwischen kostenpflichtigen Tarifen (z. B. Full → Light): Der neue Tarif gilt ab der nächsten Verlängerung; bis dahin bleiben die Funktionen des aktuellen Tarifs ohne zusätzliche Kosten erhalten."
already_requested_line: "Du hast %{plan} bereits ab dem %{when} angefordert: Bis dahin bleibt %{current_plan} aktiv."
prices:
yearly: "%{amount}/Jahr"
monthly: "%{amount}/Monat"
regia:
meta_title_fallback: "Regie — Match Live TV"
title: "Regie — %{home} vs %{away}"
+3 -6
View File
@@ -77,12 +77,12 @@ en:
first_team_name_label: Team name
first_team_name_placeholder: "e.g. Under 15"
plan_label: "Starting plan (for the whole club)"
plan_free: "Free — 1 transmitter account per team, 1 live stream"
plan_free: "Free — 1 broadcast manager per team, 1 live stream"
plan_light: Premium Light
plan_full: Premium Full
interval_label: Premium billing
interval_yearly: "Yearly — €59/year (Light) or €199/year (Full)"
interval_monthly: "Monthly — €7.90/month (Light) or €24.90/month (Full)"
interval_yearly: "Yearly — €40/year (Light) or €200/year (Full)"
interval_monthly: "Monthly — €5/month (Light) or €20/month (Full)"
submit: Create club
billing:
title: "Subscription — %{name}"
@@ -418,9 +418,6 @@ en:
upgrade_info_line: "Upgrade (higher plan or switch to annual billing): the difference is charged immediately to the saved Stripe card."
downgrade_info_line: "Downgrade between paid plans (e.g. Full → Light): the new plan takes effect from the next renewal; until then the current plan's features remain, with no additional charge now."
already_requested_line: "You already requested %{plan} from %{when}: until then %{current_plan} stays active."
prices:
yearly: "%{amount}/year"
monthly: "%{amount}/month"
regia:
meta_title_fallback: "Control room — Match Live TV"
title: "Control room — %{home} vs %{away}"
+3 -6
View File
@@ -82,12 +82,12 @@ es:
first_team_name_label: Nombre del equipo
first_team_name_placeholder: "ej. Sub-15"
plan_label: "Plan inicial (para todo el club)"
plan_free: "Free — 1 cuenta de emisión por equipo, 1 directo"
plan_free: "Free — 1 responsable de transmisión por equipo, 1 directo"
plan_light: Premium Light
plan_full: Premium Full
interval_label: Facturación premium
interval_yearly: "Anual — 59 €/año (Light) o 199 €/año (Full)"
interval_monthly: "Mensual — 7,90 €/mes (Light) o 24,90 €/mes (Full)"
interval_yearly: "Anual — 40 €/año (Light) o 200 €/año (Full)"
interval_monthly: "Mensual — 5 €/mes (Light) o 20 €/mes (Full)"
submit: Crear club
billing:
title: "Suscripción — %{name}"
@@ -423,9 +423,6 @@ es:
upgrade_info_line: "Mejora (plan superior o cambio a facturación anual): la diferencia se cobra de inmediato en la tarjeta guardada en Stripe."
downgrade_info_line: "Downgrade entre planes de pago (p. ej. Full → Light): el nuevo plan se activa desde la próxima renovación; hasta entonces se mantienen las funciones del plan actual, sin cargo adicional ahora."
already_requested_line: "Ya has solicitado %{plan} desde el %{when}: hasta entonces sigue activo %{current_plan}."
prices:
yearly: "%{amount}/año"
monthly: "%{amount}/mes"
regia:
meta_title_fallback: "Control — Match Live TV"
title: "Control — %{home} vs %{away}"
+3 -6
View File
@@ -82,12 +82,12 @@ fr:
first_team_name_label: Nom de l'équipe
first_team_name_placeholder: "ex. Under 15"
plan_label: "Forfait de départ (pour tout le club)"
plan_free: "Gratuit — 1 compte diffuseur par équipe, 1 direct"
plan_free: "Gratuit — 1 responsable diffusion par équipe, 1 direct"
plan_light: Premium Light
plan_full: Premium Full
interval_label: Facturation premium
interval_yearly: "Annuel — 59 €/an (Light) ou 199 €/an (Full)"
interval_monthly: "Mensuel — 7,90 €/mois (Light) ou 24,90 €/mois (Full)"
interval_yearly: "Annuel — 40 €/an (Light) ou 200 €/an (Full)"
interval_monthly: "Mensuel — 5 €/mois (Light) ou 20 €/mois (Full)"
submit: Créer le club
billing:
title: "Abonnement — %{name}"
@@ -423,9 +423,6 @@ fr:
upgrade_info_line: "Upgrade (forfait supérieur ou passage à la facturation annuelle) : la différence est débitée immédiatement sur la carte Stripe enregistrée."
downgrade_info_line: "Downgrade entre forfaits payants (ex. Full → Light) : le nouveau forfait est actif dès le prochain renouvellement ; jusque-là, les fonctionnalités du forfait actuel restent actives, sans débit supplémentaire pour l'instant."
already_requested_line: "Vous avez déjà demandé %{plan} à partir du %{when} : jusque-là, %{current_plan} reste actif."
prices:
yearly: "%{amount}/an"
monthly: "%{amount}/mois"
regia:
meta_title_fallback: "Régie — Match Live TV"
title: "Régie — %{home} vs %{away}"
+3 -6
View File
@@ -77,12 +77,12 @@ it:
first_team_name_label: Nome squadra
first_team_name_placeholder: "es. Under 15"
plan_label: "Piano iniziale (per tutta la società)"
plan_free: "Free — 1 account trasmettitore per squadra, 1 live"
plan_free: "Free — 1 responsabile trasmissione per squadra, 1 live"
plan_light: Premium Light
plan_full: Premium Full
interval_label: Fatturazione premium
interval_yearly: "Annuale — €59/anno (Light) o €199/anno (Full)"
interval_monthly: "Mensile — €7,90/mese (Light) o €24,90/mese (Full)"
interval_yearly: "Annuale — €40/anno (Light) o €200/anno (Full)"
interval_monthly: "Mensile — €5/mese (Light) o €20/mese (Full)"
submit: Crea società
billing:
title: "Abbonamento — %{name}"
@@ -418,9 +418,6 @@ it:
upgrade_info_line: "Upgrade (piano superiore o passaggio alla fatturazione annuale): la differenza viene addebitata subito sulla carta salvata in Stripe."
downgrade_info_line: "Downgrade tra piani a pagamento (es. Full → Light): il nuovo piano è attivo dal prossimo rinnovo; fino ad allora restano le funzioni del piano attuale, senza addebito aggiuntivo ora."
already_requested_line: "Hai già richiesto %{plan} dal %{when}: fino ad allora resta attivo %{current_plan}."
prices:
yearly: "%{amount}/anno"
monthly: "%{amount}/mese"
regia:
meta_title_fallback: "Regia — Match Live TV"
title: "Regia — %{home} vs %{away}"
+31 -86
View File
@@ -2,80 +2,32 @@ de:
pages:
features:
meta_title: "Streaming-Funktionen für Jugendspiele — Match Live TV"
meta_description: "Übertragen Sie Vereinsspiele vom Smartphone mit persönlichen Konten, Spielstand per Link, Live auf Match Live TV und YouTube sowie Replay und Archiv. Ohne komplizierte Technik."
hero_title: "Die Livestreams Ihres Vereins. Ohne geteilte Passwörter, ohne komplizierte Technik."
hero_lead: "Laden Sie Trainer, Verantwortliche und Eltern ein, mit dem eigenen Konto zu streamen. Ein Smartphone reicht: Match Live TV übernimmt Zugänge, Spielstand, Live, Replay und Veröffentlichung."
hero_alt: Smartphone auf Stativ für den Livestream und zweites Handy für den Spielstand in der Halle
flow_aria: Ablauf der Übertragung
flow_phone: Ein Smartphone
flow_account: Ein persönliches Konto
flow_live: Sie sind live
how_title: Von der Halle zu den Zuschauern in wenigen Minuten
how_1_title: Spiel anlegen
how_1_body: Der Verein bereitet das Spiel mit Team, Gegner, Datum und Uhrzeit vor.
how_2_title: App öffnen
how_2_body: Die beauftragte Person meldet sich in der Match-Live-TV-App mit dem eigenen persönlichen Konto an.
how_3_title: Livestream starten
how_3_body: Ein Smartphone reicht. Keine spezielle Ausrüstung und keine komplizierte Einrichtung.
how_4_title: Spielstand führen
how_4_body: Eine andere Person kann Ergebnis und Spielstand über einen einfachen Link aktualisieren ohne App und ohne Konto.
how_5_title: Replay
how_5_body: Nach dem Livestream kann das Spiel gespeichert und als Replay verfügbar gemacht werden je nach Plan.
accounts_title: Jeder mit dem eigenen Konto. Das Vereins-Passwort bleibt beim Verein.
accounts_lead: Username und Passwort des Hauptkontos müssen nicht mehr geteilt werden. Verantwortliche, Trainer und beauftragte Eltern erhalten einen persönlichen, widerrufbaren Zugang.
accounts_invite_title: Einladen
accounts_invite_body: Der Verein lädt Trainer, Verantwortliche oder Eltern ein, die die Übertragung übernehmen.
accounts_transmit_title: Übertragen
accounts_transmit_body: Jeder meldet sich in der App mit den eigenen Zugangsdaten an.
accounts_revoke_title: Widerrufen
accounts_revoke_body: Wenn jemand nicht mehr mit Team oder Verein zusammenarbeitet, kann der Zugang widerrufen werden, ohne das Passwort der anderen zu ändern.
accounts_highlight: Keine geteilten Passwörter. Jede beauftragte Person hat den eigenen persönlichen Zugang.
score_title: Wer filmt, filmt. Wer den Spielstand führt, führt den Spielstand.
score_lead: Wer überträgt, kann sich auf den Bildausschnitt konzentrieren. Eine andere Person auch von der Tribüne öffnet einen einfachen Link und aktualisiert den Spielstand in Echtzeit.
score_item_app: Keine App-Installation
score_item_signup: Keine Registrierung
score_item_password: Kein Passwort
score_item_link: Einfach den geteilten Link öffnen
score_card_aria: Zugang zur Spielstand-Regie
score_card_title: Regie per Link
score_card_body: Wer Sätze und Punkte aktualisiert, öffnet einen geteilten Link. Kein Konto, keine Einladung.
access_link: Zugang per Link
distribute_title: Ein Spiel, mehrere Wege es zu verfolgen
distribute_lead: Vom Smartphone in der Halle erreicht der Livestream die Zuschauer auf Match Live TV und je nach Plan auch YouTube.
distribute_aria: Wohin der Livestream geht
distribute_phone: Smartphone
distribute_mltv: Match Live TV
distribute_viewers: Zuschauer
distribute_youtube: YouTube
youtube_badge: Premium Full
youtube_title: Der Livestream bleibt bei Ihrem Verein
youtube_body: Mit Premium Full können Sie auch auf den offiziellen YouTube-Kanal des Vereins streamen. So behält der Verein Publikum, Abonnenten, Aufrufe, Spielarchiv und Sponsorensichtbarkeit.
youtube_mock_channel: Offizieller Vereinskanal
youtube_mock_subs: Abonnenten und Aufrufe des Vereins
replay_title: Das Spiel endet. Die Erinnerung bleibt.
replay_lead: Bei Plänen mit Replay bleiben Spiele auf den Match-Live-TV-Servern gespeichert und können später angesehen werden. Die Aufbewahrungsdauer hängt vom Plan ab.
replay_item_server: Speicherung auf Match-Live-TV-Servern
replay_item_plan: Archivdauer je nach Plan
replay_item_download: Video bei Bedarf aufs Handy herunterladen
replay_card_title: Spielarchiv
replay_card_body: Spiele nach dem Schlusspfiff noch einmal ansehen und bei Bedarf herunterladen.
replay_mock_1_title: Rossi vs Neri
replay_mock_1_meta: "12/05 · 01:25:34"
replay_mock_2_title: Rossi vs Blu
replay_mock_2_meta: "05/05 · 01:18:22"
replay_mock_3_title: Rossi vs Bianchi
replay_mock_3_meta: "28/04 · 01:07:15"
more_title: Auch das gehört zum Produkt
more_stable_title: Livestream vom Smartphone
more_stable_body: Handy aufstellen und Übertragung starten. Auch bei schwachem 4G bleibt der Livestream so stabil wie möglich ohne Kabel oder Mischpult.
more_link_title: Link für Familien und Fans
more_link_body: "Einen Link teilen: Eltern, Großeltern und Freunde schauen im Browser von Handy oder Computer ohne App."
more_yt_mltv_title: Auch auf YouTube Match Live TV
more_yt_mltv_body: Der Livestream kann zusätzlich zum Link auf der Website auch auf dem YouTube-Kanal von Match Live TV sichtbar sein.
cta_title: Bereit für das nächste Spiel?
cta_body: Probieren Sie Match Live TV kostenlos. Keine spezielle Ausrüstung nötig.
cta_primary: Kostenlos starten
cta_secondary: Pläne vergleichen
meta_description: "App für Livestreams vom Handy: Spiel filmen, Spielstand aktualisieren, Link mit Eltern und Großeltern teilen. Spielarchiv und Pläne für Jugend-Sportvereine."
title: Funktionen
lead: "Alles, was nötig ist, damit auch die, die nicht in der Halle sein können, das Spiel verfolgen: einfach für Trainer und Helfer, praktisch für Eltern, Großeltern und Verwandte."
staff_section_title: Für Verein und Team
staff_invite_title: Einladungen mit eigener E-Mail
staff_invite_body: "Der Vorsitzende oder Inhaber lädt ein, wer filmt und wer den Spielstand aktualisiert: jeder meldet sich mit seiner eigenen E-Mail an, ohne Passwörter zu teilen."
staff_access_title: Zugriffsverwaltung
staff_access_body: Sie können einem Helfer den Zugriff entziehen oder eine noch offene Einladung vom Team-Dashboard aus stornieren.
staff_plans_title: Maßgeschneiderte Pläne
staff_plans_body: "Mit Free, Premium Light oder Premium Full erhöhen Sie, wie viele Personen live helfen können und wie viele Spiele parallel laufen können."
live_section_title: Auf dem Platz und live
live_phone_title: Vom Handy filmen
live_phone_body: "Handy aufstellen und den Livestream starten: auch bei schwachem 4G bleibt die Übertragung so stabil wie möglich, ohne Kabelsalat oder Mischpult."
live_score_title: Spielstand vom zweiten Handy
live_score_body: "Ein weiterer Helfer aktualisiert Sätze und Punkte von einem zweiten Handy aus: einfach den Code scannen, der auf dem Bildschirm des Filmenden erscheint."
live_link_title: Link für Familien und Fans
live_link_body: "Teilen Sie einen Link: Eltern, Großeltern und Freunde schauen im Browser von Handy oder Computer, ohne App-Installation."
live_archive_title: Spielarchiv
live_archive_body: "Mit Premium Light oder Full bleiben die Spiele auf dem Server gespeichert (30 oder 90 Tage) und Sie können sie bei Bedarf aufs Handy herunterladen."
live_youtube_mltv_title: Auch auf YouTube (Match Live TV)
live_youtube_mltv_body: "Mit Premium Light ist der Livestream zusätzlich zum Link auf der Website auch auf dem YouTube-Kanal von Match Live TV zu sehen."
live_youtube_club_title: YouTube-Kanal des Vereins
live_youtube_club_body: "Mit Premium Full können Sie denselben Livestream auch auf den offiziellen YouTube-Kanal des Vereins senden."
live_website_title: Auf der Website des Vereins
live_website_body: "Integration zum Einbetten des Players auf der Vereinswebsite — demnächst mit Premium Full."
cta_signup: Team registrieren
pricing:
meta_title: "Preise für Jugendspiel-Streaming — Match Live TV"
meta_description: "Free-, Premium-Light- und Premium-Full-Pläne für Livestreams und Spielarchiv. Jahresabo für Vereine: mehr Team-Mitglieder, mehr parallele Spiele, Replay und YouTube."
@@ -83,8 +35,8 @@ de:
club_info_html: "Verein: <strong>%{name}</strong> · %{billing_link}"
manage_subscription: Abo verwalten
team_title: Pläne für Ihr Team
team_lead: "Jahresabo für Vereine: die Streaming-Konten starten den Livestream aus der App; der Spielstand wird über einen teilbaren Link verwaltet."
table_staff: Streaming-Konten
team_lead: "Jahresabo für Vereine: die Streaming-Verantwortlichen starten den Livestream aus der App; der Spielstand wird über einen teilbaren Link verwaltet."
table_staff: Streaming-Verantwortliche / Jahr
table_matches: Gleichzeitige Spiele
table_live_mltv: Live auf Match Live TV
table_youtube: YouTube
@@ -92,7 +44,8 @@ de:
table_download: Handy-Download
table_price: Preis
table_price_free: "€0"
table_price_note: "Listenpreis %{list} — %{monthly}"
table_price_light_html: "€40/Jahr oder €5/Monat"
table_price_full_html: "€200/Jahr oder €20/Monat"
table_yes: Ja
table_no: Nein
table_unlimited: Unbegrenzt
@@ -102,16 +55,8 @@ de:
contact_button: Kontaktieren Sie uns
plans:
price_alt_html: "oder <strong>%{price}</strong>"
price_equiv: "entspricht %{price}"
launch_price: Einführungspreis
recommended_badge: Empfohlen für Vereine
cta_name:
premium_light: Light
premium_full: Full
staff_html: "Streaming-Konten: maximal <strong>%{count}</strong> pro Abonnement"
staff_unlimited_html: "Streaming-Konten: <strong>unbegrenzt</strong> pro Abonnement"
staff_html: "Streaming-Verantwortliche: <strong>%{count}</strong> / Jahr pro Team"
staff_unlimited: unbegrenzt
staff_note_full: "Keine geteilten Passwörter: jede Person nutzt den eigenen Zugang, jederzeit widerrufbar."
matches_html: "Spiele: <strong>%{count}</strong> gleichzeitig (ganzer Verein)"
matches_unlimited: unbegrenzt
replay_html: "Replay: %{value}"
+31 -86
View File
@@ -2,80 +2,32 @@ en:
pages:
features:
meta_title: "Youth match streaming features — Match Live TV"
meta_description: "Stream your club's matches from a smartphone with personal accounts, score via link, live on Match Live TV and YouTube, plus replay and archive. No complicated equipment."
hero_title: "Your club's live streams. No shared passwords, no complicated gear."
hero_lead: "Invite coaches, managers and parents to stream with their own account. All you need is a smartphone: Match Live TV handles access, scoring, the live stream, replay and publishing."
hero_alt: Smartphone on a tripod for the live stream and a second phone for the gym scoreboard
flow_aria: Streaming flow
flow_phone: One smartphone
flow_account: One personal account
flow_live: You're live
how_title: From the gym to viewers in a few minutes
how_1_title: Create the match
how_1_body: The club sets up the match with team, opponent, date and time.
how_2_title: Open the app
how_2_body: The assigned person signs into the Match Live TV app with their own personal account.
how_3_title: Start the stream
how_3_body: A smartphone is enough. No dedicated equipment or complicated setup.
how_4_title: Manage the score
how_4_body: Someone else can update the result and score through a simple link, without installing an app or creating an account.
how_5_title: Replay
how_5_body: After the stream ends, the match can be kept and made available as a replay, depending on the plan.
accounts_title: Everyone with their own account. The club password stays with the club.
accounts_lead: You no longer need to share the main club username and password. Managers, coaches and assigned parents get a personal, revocable login.
accounts_invite_title: Invite
accounts_invite_body: The club invites coaches, managers or parents assigned to stream.
accounts_transmit_title: Stream
accounts_transmit_body: Each person signs into the app with their own credentials.
accounts_revoke_title: Revoke
accounts_revoke_body: When someone no longer works with the team or club, their access can be revoked without changing everyone else's password.
accounts_highlight: No shared passwords. Every assigned person has their own personal access.
score_title: Who films, films. Who keeps score, keeps score.
score_lead: The person streaming can focus on the framing. Someone else, even from the stands, can open a simple link and update the score in real time.
score_item_app: No app to install
score_item_signup: No sign-up
score_item_password: No password
score_item_link: Just open the shared link
score_card_aria: Score control access
score_card_title: Score control via a link
score_card_body: Whoever updates sets and points opens a shared link. No account and no invite.
access_link: Open with a link
distribute_title: One match, more ways to follow it
distribute_lead: From the smartphone in the gym, the stream reaches viewers on Match Live TV and, when included in the plan, also on YouTube.
distribute_aria: Where the stream goes
distribute_phone: Smartphone
distribute_mltv: Match Live TV
distribute_viewers: Viewers
distribute_youtube: YouTube
youtube_badge: Premium Full
youtube_title: The stream stays with your club
youtube_body: With Premium Full you can also stream to the club's official YouTube channel. The club keeps its audience, subscribers, views, match archive and sponsor visibility.
youtube_mock_channel: Official club channel
youtube_mock_subs: Club subscribers and views
replay_title: The match ends. The memory stays.
replay_lead: On plans that include replay, matches are stored on Match Live TV servers and can be watched later. How long they are kept depends on the plan.
replay_item_server: Stored on Match Live TV servers
replay_item_plan: Archive length based on the plan
replay_item_download: Download the video to your phone when you need it
replay_card_title: Match archive
replay_card_body: Rewatch matches after the final whistle and download them when you need them.
replay_mock_1_title: Rossi vs Neri
replay_mock_1_meta: "12/05 · 01:25:34"
replay_mock_2_title: Rossi vs Blu
replay_mock_2_meta: "05/05 · 01:18:22"
replay_mock_3_title: Rossi vs Bianchi
replay_mock_3_meta: "28/04 · 01:07:15"
more_title: Also part of the product
more_stable_title: Stream from your smartphone
more_stable_body: Prop up the phone and start streaming. Even with weak 4G the broadcast stays as stable as possible, with no cables or mixers.
more_link_title: Link for families and fans
more_link_body: "Share a link: parents, grandparents and friends watch from their phone or computer browser, no app to install."
more_yt_mltv_title: Also on Match Live TV YouTube
more_yt_mltv_body: You can also make the stream visible on the Match Live TV YouTube channel, in addition to the link on the site.
cta_title: Ready for the next match?
cta_body: Try Match Live TV for free. No dedicated equipment needed.
cta_primary: Start for free
cta_secondary: Compare plans
meta_description: "App for live streaming from your phone: film the match, update the score, share the link with parents and grandparents. Match archive and plans for youth sports clubs."
title: Features
lead: "Everything you need so people who can't be at the gym can follow the match: simple for coaches and volunteers, convenient for parents, grandparents and relatives."
staff_section_title: For the club and staff
staff_invite_title: Invites with their own email
staff_invite_body: "The president or owner invites whoever films and whoever updates the score: each person signs in with their own email, no shared passwords."
staff_access_title: Access management
staff_access_body: You can remove a volunteer's access or cancel a pending invite from the team dashboard.
staff_plans_title: Plans to fit your needs
staff_plans_body: With Free, Premium Light or Premium Full you increase how many people can help stream live and how many matches can run in parallel.
live_section_title: On the court and live
live_phone_title: Film from your phone
live_phone_body: "Prop up the phone and start the stream: even with weak 4G the broadcast stays as stable as possible, with no cables or mixers to worry about."
live_score_title: Score from a second phone
live_score_body: "Another volunteer updates sets and points from a second phone: just scan the code shown on the streamer's screen."
live_link_title: Link for families and fans
live_link_body: "Share a link: parents, grandparents and friends watch from their phone or computer browser, no app to install."
live_archive_title: Match archive
live_archive_body: "With Premium Light or Full, matches stay saved on the server (30 or 90 days) and you can download them to your phone whenever you need."
live_youtube_mltv_title: Also on YouTube (Match Live TV)
live_youtube_mltv_body: With Premium Light the stream is also visible on the Match Live TV YouTube channel, in addition to the link on the site.
live_youtube_club_title: Your club's YouTube channel
live_youtube_club_body: With Premium Full you can send the same stream to your club's official YouTube channel.
live_website_title: On the club's website
live_website_body: Integration to embed the player on the club's website — coming with Premium Full.
cta_signup: Register your team
pricing:
meta_title: "Youth match streaming pricing — Match Live TV"
meta_description: "Free, Premium Light and Premium Full plans for live streaming and match archive. Annual subscription for clubs: more staff, more concurrent matches, replay and YouTube."
@@ -83,8 +35,8 @@ en:
club_info_html: "Club: <strong>%{name}</strong> · %{billing_link}"
manage_subscription: Manage subscription
team_title: Plans for your team
team_lead: "Annual subscription for clubs: transmitter accounts start the broadcast from the app; the score is managed with a shareable link."
table_staff: Transmitter accounts
team_lead: "Annual subscription for clubs: streaming staff start the broadcast from the app; the score is managed with a shareable link."
table_staff: Streaming staff / year
table_matches: Concurrent matches
table_live_mltv: Live on Match Live TV
table_youtube: YouTube
@@ -92,7 +44,8 @@ en:
table_download: Phone download
table_price: Price
table_price_free: "€0"
table_price_note: "list %{list} — %{monthly}"
table_price_light_html: "€40/year or €5/month"
table_price_full_html: "€200/year or €20/month"
table_yes: "Yes"
table_no: "No"
table_unlimited: Unlimited
@@ -102,16 +55,8 @@ en:
contact_button: Contact us
plans:
price_alt_html: "or <strong>%{price}</strong>"
price_equiv: "equivalent to %{price}"
launch_price: Launch price
recommended_badge: Recommended for clubs
cta_name:
premium_light: Light
premium_full: Full
staff_html: "Transmitter accounts: max <strong>%{count}</strong> per subscription"
staff_unlimited_html: "Transmitter accounts: <strong>unlimited</strong> per subscription"
staff_html: "Streaming staff: <strong>%{count}</strong> / year per team"
staff_unlimited: unlimited
staff_note_full: "No shared passwords: each person uses their own login, which can be revoked at any time."
matches_html: "Matches: <strong>%{count}</strong> concurrent (whole club)"
matches_unlimited: unlimited
replay_html: "Replay: %{value}"
+32 -87
View File
@@ -2,80 +2,32 @@ es:
pages:
features:
meta_title: "Funciones de streaming de partidos juveniles — Match Live TV"
meta_description: "Emite los partidos del club desde el móvil con cuentas personales, marcador por enlace, directo en Match Live TV y YouTube, más repetición y archivo. Sin equipo complicado."
hero_title: "Los directos de tu club. Sin contraseñas compartidas, sin equipo complicado."
hero_lead: "Invita a entrenadores, directivos y padres a emitir con su propia cuenta. Basta un smartphone: Match Live TV gestiona accesos, marcador, directo, repetición y publicación."
hero_alt: Smartphone en trípode para el directo y segundo móvil para el marcador en el pabellón
flow_aria: Flujo de la emisión
flow_phone: Un smartphone
flow_account: Una cuenta personal
flow_live: Estás en directo
how_title: Del pabellón a los espectadores en pocos minutos
how_1_title: Crea el partido
how_1_body: El club prepara el partido indicando equipo, rival, fecha y hora.
how_2_title: Abre la app
how_2_body: La persona encargada accede a la app Match Live TV con su propia cuenta personal.
how_3_title: Empieza el directo
how_3_body: Basta el smartphone. Sin equipo dedicado ni configuración complicada.
how_4_title: Gestiona el marcador
how_4_body: Otra persona puede actualizar resultado y marcador con un enlace sencillo, sin instalar apps ni crear una cuenta.
how_5_title: Repetición
how_5_body: Al terminar el directo, el partido puede conservarse y quedar disponible como repetición, según el plan elegido.
accounts_title: Cada uno con su propia cuenta. La contraseña del club se queda en el club.
accounts_lead: Ya no hace falta compartir el usuario y la contraseña de la cuenta principal. Directivos, entrenadores y padres encargados reciben un acceso personal y revocable.
accounts_invite_title: Invita
accounts_invite_body: El club invita a entrenadores, directivos o padres encargados de la emisión.
accounts_transmit_title: Emite
accounts_transmit_body: Cada uno accede a la app con sus propias credenciales.
accounts_revoke_title: Revoca
accounts_revoke_body: Cuando una persona deja de colaborar con el equipo o el club, su acceso puede revocarse sin cambiar la contraseña a los demás.
accounts_highlight: Sin contraseñas compartidas. Cada encargado tiene su propio acceso personal.
score_title: Quien graba, graba. Quien lleva el marcador, lleva el marcador.
score_lead: Quien emite puede centrarse en el encuadre. Otra persona, incluso desde la grada, puede abrir un enlace sencillo y actualizar el marcador en tiempo real.
score_item_app: Sin app que instalar
score_item_signup: Sin registro
score_item_password: Sin contraseña
score_item_link: Basta abrir el enlace compartido
score_card_aria: Acceso a la regia del marcador
score_card_title: Regia con un enlace
score_card_body: Quien actualiza sets y puntos abre un enlace compartido. Sin cuenta y sin invitación.
access_link: Acceso con enlace
distribute_title: Un partido, más formas de seguirlo
distribute_lead: Desde el smartphone en el pabellón, el directo llega a los espectadores en Match Live TV y, según el plan, también a YouTube.
distribute_aria: Dónde llega el directo
distribute_phone: Smartphone
distribute_mltv: Match Live TV
distribute_viewers: Espectadores
distribute_youtube: YouTube
youtube_badge: Premium Full
youtube_title: El directo se queda en tu club
youtube_body: Con Premium Full también puedes emitir en el canal oficial de YouTube del club. Así el club conserva su público, suscriptores, visualizaciones, archivo de partidos y visibilidad de patrocinadores.
youtube_mock_channel: Canal oficial del club
youtube_mock_subs: Suscriptores y visualizaciones del club
replay_title: El partido termina. El recuerdo permanece.
replay_lead: En los planes con repetición, los partidos se guardan en los servidores de Match Live TV y pueden verse después. La duración del archivo depende del plan.
replay_item_server: Conservación en servidores Match Live TV
replay_item_plan: Duración del archivo según el plan
replay_item_download: Descarga del vídeo al móvil cuando haga falta
replay_card_title: Archivo de partidos
replay_card_body: Vuelve a ver los partidos tras el pitido final y descárgalos cuando los necesites.
replay_mock_1_title: Rossi vs Neri
replay_mock_1_meta: "12/05 · 01:25:34"
replay_mock_2_title: Rossi vs Blu
replay_mock_2_meta: "05/05 · 01:18:22"
replay_mock_3_title: Rossi vs Bianchi
replay_mock_3_meta: "28/04 · 01:07:15"
more_title: También forma parte del producto
more_stable_title: Directo desde el smartphone
more_stable_body: Apoyas el móvil e inicias la emisión. Incluso con 4G débil, el directo se mantiene lo más estable posible, sin cables ni mezcladores.
more_link_title: Enlace para familias y aficionados
more_link_body: "Comparte un enlace: padres, abuelos y amigos miran desde el navegador del móvil o del ordenador, sin instalar apps."
more_yt_mltv_title: También en YouTube Match Live TV
more_yt_mltv_body: También puedes hacer visible el directo en el canal de YouTube de Match Live TV, además del enlace en el sitio.
cta_title: ¿Listos para el próximo partido?
cta_body: Prueba Match Live TV gratis. No hace falta equipo dedicado.
cta_primary: Empieza gratis
cta_secondary: Compara los planes
meta_description: "App para el directo desde el móvil: graba el partido, actualiza el marcador, comparte el enlace con padres y abuelos. Archivo de partidos y planes para clubes deportivos juveniles."
title: Funciones
lead: "Todo lo necesario para que quienes no pueden estar en el pabellón sigan el partido: sencillo para entrenadores y voluntarios, cómodo para padres, abuelos y familiares."
staff_section_title: Para el club y el staff
staff_invite_title: Invitaciones con su propio email
staff_invite_body: "El presidente o propietario invita a quien graba y a quien actualiza el marcador: cada uno accede con su propio email, sin compartir contraseñas."
staff_access_title: Gestión de accesos
staff_access_body: Puedes retirar el acceso a un voluntario o cancelar una invitación pendiente desde el panel del equipo.
staff_plans_title: Planes a medida
staff_plans_body: Con Free, Premium Light o Premium Full aumentas cuántas personas pueden ayudar en directo y cuántos partidos pueden ir en paralelo.
live_section_title: En la cancha y en directo
live_phone_title: Graba desde el móvil
live_phone_body: "Apoyas el móvil e inicias el directo: incluso con 4G débil, la transmisión se mantiene lo más estable posible, sin líos de cables ni mezcladores."
live_score_title: Marcador desde un segundo móvil
live_score_body: "Otro voluntario actualiza sets y puntos desde un segundo móvil: solo hay que escanear el código que aparece en la pantalla de quien graba."
live_link_title: Enlace para familias y aficionados
live_link_body: "Comparte un enlace: padres, abuelos y amigos miran desde el navegador del móvil o del ordenador, sin instalar apps."
live_archive_title: Archivo de partidos
live_archive_body: "Con Premium Light o Full los partidos quedan guardados en el servidor (30 o 90 días) y puedes descargarlos al móvil cuando quieras."
live_youtube_mltv_title: También en YouTube (Match Live TV)
live_youtube_mltv_body: "Con Premium Light el directo también se ve en el canal de YouTube de Match Live TV, además del enlace en el sitio."
live_youtube_club_title: Canal de YouTube del club
live_youtube_club_body: "Con Premium Full puedes enviar el mismo directo también al canal oficial de YouTube del club."
live_website_title: En el sitio web del club
live_website_body: "Integración para incrustar el reproductor en el sitio web del club — próximamente con Premium Full."
cta_signup: Registra tu equipo
pricing:
meta_title: "Precios de streaming de partidos juveniles — Match Live TV"
meta_description: "Planes Free, Premium Light y Premium Full para directos y archivo de partidos. Suscripción anual para clubes: más staff, más partidos en paralelo, repetición y YouTube."
@@ -83,8 +35,8 @@ es:
club_info_html: "Club: <strong>%{name}</strong> · %{billing_link}"
manage_subscription: Gestionar suscripción
team_title: Planes para tu equipo
team_lead: "Suscripción anual para clubes: las cuentas de emisión inician el directo desde la app; el marcador se gestiona con un enlace para compartir."
table_staff: Cuentas de emisión
team_lead: "Suscripción anual para clubes: los responsables de emisión inician el directo desde la app; el marcador se gestiona con un enlace para compartir."
table_staff: Responsables de emisión / año
table_matches: Partidos simultáneos
table_live_mltv: Directo en Match Live TV
table_youtube: YouTube
@@ -92,7 +44,8 @@ es:
table_download: Descarga al móvil
table_price: Precio
table_price_free: "€0"
table_price_note: "tarifa %{list} — %{monthly}"
table_price_light_html: "€40/año o €5/mes"
table_price_full_html: "€200/año o €20/mes"
table_yes: "Sí"
table_no: "No"
table_unlimited: Ilimitado
@@ -102,16 +55,8 @@ es:
contact_button: Contáctanos
plans:
price_alt_html: "o <strong>%{price}</strong>"
price_equiv: "equivale a %{price}"
launch_price: Precio de lanzamiento
recommended_badge: Recomendado para clubes
cta_name:
premium_light: Light
premium_full: Full
staff_html: "Cuentas de emisión: máximo <strong>%{count}</strong> por suscripción"
staff_unlimited_html: "Cuentas de emisión: <strong>ilimitadas</strong> por suscripción"
staff_unlimited: ilimitadas
staff_note_full: "Sin contraseñas compartidas: cada persona usa su propio acceso, revocable en cualquier momento."
staff_html: "Responsables de emisión: <strong>%{count}</strong> / año por equipo"
staff_unlimited: ilimitado
matches_html: "Partidos: <strong>%{count}</strong> simultáneos (todo el club)"
matches_unlimited: ilimitados
replay_html: "Repetición: %{value}"
+32 -87
View File
@@ -2,80 +2,32 @@ fr:
pages:
features:
meta_title: "Fonctionnalités de streaming des matchs jeunes — Match Live TV"
meta_description: "Diffusez les matchs du club depuis un smartphone avec des comptes personnels, le score via un lien, le direct sur Match Live TV et YouTube, plus le replay et l'archive. Sans matériel compliqué."
hero_title: "Les directs de votre club. Sans mots de passe partagés, sans matériel compliqué."
hero_lead: "Invitez entraîneurs, dirigeants et parents à diffuser avec leur propre compte. Un smartphone suffit : Match Live TV gère les accès, le score, le direct, le replay et la publication."
hero_alt: Smartphone sur trépied pour le direct et second téléphone pour le score en salle
flow_aria: Déroulement de la diffusion
flow_phone: Un smartphone
flow_account: Un compte personnel
flow_live: Vous êtes en direct
how_title: De la salle aux spectateurs en quelques minutes
how_1_title: Créez le match
how_1_body: Le club prépare le match en indiquant l'équipe, l'adversaire, la date et l'heure.
how_2_title: Ouvrez l'application
how_2_body: La personne désignée se connecte à l'application Match Live TV avec son propre compte personnel.
how_3_title: Lancez le direct
how_3_body: Un smartphone suffit. Pas de matériel dédié ni de configuration compliquée.
how_4_title: Gérez le score
how_4_body: Une autre personne peut mettre à jour le résultat et le score via un simple lien, sans installer d'application ni créer de compte.
how_5_title: Replay
how_5_body: À la fin du direct, le match peut être conservé et rendu disponible en replay, selon l'offre choisie.
accounts_title: Chacun avec son propre compte. Le mot de passe du club reste au club.
accounts_lead: Plus besoin de partager l'identifiant et le mot de passe du compte principal. Dirigeants, entraîneurs et parents désignés reçoivent un accès personnel et révocable.
accounts_invite_title: Invitez
accounts_invite_body: Le club invite les entraîneurs, dirigeants ou parents chargés de la diffusion.
accounts_transmit_title: Diffusez
accounts_transmit_body: Chacun se connecte à l'application avec ses propres identifiants.
accounts_revoke_title: Révoquez
accounts_revoke_body: Quand une personne ne collabore plus avec l'équipe ou le club, son accès peut être révoqué sans changer le mot de passe des autres.
accounts_highlight: Pas de mots de passe partagés. Chaque personne désignée a son propre accès personnel.
score_title: Qui filme, filme. Qui tient le score, tient le score.
score_lead: La personne qui diffuse peut se concentrer sur le cadrage. Une autre, même depuis les tribunes, peut ouvrir un simple lien et mettre à jour le score en temps réel.
score_item_app: Aucune application à installer
score_item_signup: Aucune inscription
score_item_password: Aucun mot de passe
score_item_link: Il suffit d'ouvrir le lien partagé
score_card_aria: Accès à la régie du score
score_card_title: Régie via un lien
score_card_body: Celui qui met à jour sets et points ouvre un lien partagé. Aucun compte, aucune invitation.
access_link: Accès par lien
distribute_title: Un match, plusieurs façons de le suivre
distribute_lead: Depuis le smartphone en salle, le direct arrive aux spectateurs sur Match Live TV et, selon l'offre, aussi sur YouTube.
distribute_aria: Où va le direct
distribute_phone: Smartphone
distribute_mltv: Match Live TV
distribute_viewers: Spectateurs
distribute_youtube: YouTube
youtube_badge: Premium Full
youtube_title: Le direct reste celui de votre club
youtube_body: Avec Premium Full, vous pouvez aussi diffuser sur la chaîne YouTube officielle du club. Le club conserve ainsi son public, ses abonnés, ses vues, l'archive des matchs et la visibilité des sponsors.
youtube_mock_channel: Chaîne officielle du club
youtube_mock_subs: Abonnés et vues du club
replay_title: Le match se termine. Le souvenir reste.
replay_lead: Sur les offres qui incluent le replay, les matchs restent enregistrés sur les serveurs Match Live TV et peuvent être revus ensuite. La durée de conservation dépend de l'offre.
replay_item_server: Conservation sur les serveurs Match Live TV
replay_item_plan: Durée d'archive selon l'offre
replay_item_download: Téléchargement de la vidéo sur le téléphone si besoin
replay_card_title: Archive des matchs
replay_card_body: Revoyez les matchs après le coup de sifflet final et téléchargez-les quand vous en avez besoin.
replay_mock_1_title: Rossi vs Neri
replay_mock_1_meta: "12/05 · 01:25:34"
replay_mock_2_title: Rossi vs Blu
replay_mock_2_meta: "05/05 · 01:18:22"
replay_mock_3_title: Rossi vs Bianchi
replay_mock_3_meta: "28/04 · 01:07:15"
more_title: Cela fait aussi partie du produit
more_stable_title: Direct depuis le smartphone
more_stable_body: Posez le téléphone et lancez la diffusion. Même avec un 4G faible, le direct reste aussi stable que possible, sans câbles ni mixeur.
more_link_title: Lien pour familles et supporters
more_link_body: "Partagez un lien : parents, grands-parents et amis regardent depuis le navigateur du téléphone ou de l'ordinateur, sans installer d'application."
more_yt_mltv_title: Aussi sur YouTube Match Live TV
more_yt_mltv_body: Vous pouvez aussi rendre le direct visible sur la chaîne YouTube Match Live TV, en plus du lien sur le site.
cta_title: Prêts pour le prochain match ?
cta_body: Essayez Match Live TV gratuitement. Aucun matériel dédié nécessaire.
cta_primary: Commencer gratuitement
cta_secondary: Comparer les offres
meta_description: "Application pour le direct depuis le téléphone : filmez le match, mettez à jour le score, partagez le lien avec parents et grands-parents. Archive des matchs et offres pour les clubs sportifs jeunes."
title: Fonctionnalités
lead: "Tout ce qu'il faut pour faire suivre les matchs à ceux qui ne peuvent pas être en salle : simple pour les coachs et bénévoles, pratique pour parents, grands-parents et proches."
staff_section_title: Pour le club et le staff
staff_invite_title: Invitations avec leur propre email
staff_invite_body: "Le président ou le propriétaire invite qui filme et qui met à jour le score : chacun se connecte avec son propre email, sans partager de mot de passe."
staff_access_title: Gestion des accès
staff_access_body: Vous pouvez retirer l'accès à un bénévole ou annuler une invitation encore en attente depuis le tableau de bord de l'équipe.
staff_plans_title: Des offres sur mesure
staff_plans_body: "Avec Free, Premium Light ou Premium Full, augmentez le nombre de personnes qui peuvent aider en direct et le nombre de matchs en parallèle."
live_section_title: Sur le terrain et en direct
live_phone_title: Filmez depuis votre téléphone
live_phone_body: "Posez le téléphone et lancez le direct : même avec un 4G faible, la diffusion reste aussi stable que possible, sans câbles ni mixeur."
live_score_title: Score depuis un second téléphone
live_score_body: "Un autre bénévole met à jour les sets et les points depuis un second téléphone : il suffit de scanner le code affiché sur l'écran de celui qui filme."
live_link_title: Lien pour les familles et supporters
live_link_body: "Partagez un lien : parents, grands-parents et amis regardent depuis le navigateur du téléphone ou de l'ordinateur, sans installer d'application."
live_archive_title: Archive des matchs
live_archive_body: "Avec Premium Light ou Full, les matchs restent enregistrés sur le serveur (30 ou 90 jours) et vous pouvez les télécharger sur le téléphone quand vous voulez."
live_youtube_mltv_title: Aussi sur YouTube (Match Live TV)
live_youtube_mltv_body: "Avec Premium Light, le direct est aussi visible sur la chaîne YouTube Match Live TV, en plus du lien sur le site."
live_youtube_club_title: Chaîne YouTube du club
live_youtube_club_body: "Avec Premium Full, vous pouvez diffuser le même direct sur la chaîne YouTube officielle du club."
live_website_title: Sur le site du club
live_website_body: "Intégration pour intégrer le lecteur sur le site du club — bientôt disponible avec Premium Full."
cta_signup: Inscrire l'équipe
pricing:
meta_title: "Tarifs streaming des matchs jeunes — Match Live TV"
meta_description: "Offres Free, Premium Light et Premium Full pour le direct et l'archive des matchs. Abonnement annuel pour les clubs : plus de staff, plus de matchs en parallèle, replay et YouTube."
@@ -83,8 +35,8 @@ fr:
club_info_html: "Club : <strong>%{name}</strong> · %{billing_link}"
manage_subscription: Gérer l'abonnement
team_title: Offres pour votre équipe
team_lead: "Abonnement annuel pour les clubs : les comptes diffuseurs démarrent le direct depuis l'application ; le score se gère avec un lien à partager."
table_staff: Comptes diffuseurs
team_lead: "Abonnement annuel pour les clubs : les responsables de diffusion démarrent le direct depuis l'application ; le score se gère avec un lien à partager."
table_staff: Responsables de diffusion / an
table_matches: Matchs simultanés
table_live_mltv: Direct sur Match Live TV
table_youtube: YouTube
@@ -92,7 +44,8 @@ fr:
table_download: Téléchargement téléphone
table_price: Prix
table_price_free: "€0"
table_price_note: "tarif %{list} — %{monthly}"
table_price_light_html: "€40/an ou €5/mois"
table_price_full_html: "€200/an ou €20/mois"
table_yes: Oui
table_no: Non
table_unlimited: Illimité
@@ -102,16 +55,8 @@ fr:
contact_button: Contactez-nous
plans:
price_alt_html: "ou <strong>%{price}</strong>"
price_equiv: "soit %{price}"
launch_price: Prix de lancement
recommended_badge: Conseillé pour les clubs
cta_name:
premium_light: Light
premium_full: Full
staff_html: "Comptes diffuseurs : maximum <strong>%{count}</strong> par abonnement"
staff_unlimited_html: "Comptes diffuseurs : <strong>illimités</strong> par abonnement"
staff_unlimited: illimités
staff_note_full: "Pas de mot de passe partagé : chaque personne utilise son propre accès, révocable à tout moment."
staff_html: "Responsables de diffusion : <strong>%{count}</strong> / an par équipe"
staff_unlimited: illimité
matches_html: "Matchs : <strong>%{count}</strong> simultanés (tout le club)"
matches_unlimited: illimités
replay_html: "Replay : %{value}"
+32 -87
View File
@@ -2,80 +2,32 @@ it:
pages:
features:
meta_title: "Funzionalità streaming partite giovanili — Match Live TV"
meta_description: "Trasmetti le partite della società dallo smartphone con account personali, punteggio via link, diretta su Match Live TV e YouTube, replay e archivio. Senza attrezzatura complicata."
hero_title: "Le dirette della tua società. Senza password condivise, senza attrezzatura complicata."
hero_lead: "Invita allenatori, dirigenti e genitori a trasmettere con il proprio account. Basta uno smartphone: Match Live TV gestisce accessi, punteggio, diretta, replay e pubblicazione."
hero_alt: Smartphone su treppiede per la diretta e secondo telefono per il punteggio in palestra
flow_aria: Flusso della trasmissione
flow_phone: Uno smartphone
flow_account: Un account personale
flow_live: Sei in diretta
how_title: Dalla palestra agli spettatori in pochi minuti
how_1_title: Crea la partita
how_1_body: La società prepara la partita indicando squadra, avversario, data e ora.
how_2_title: Apri l'app
how_2_body: L'incaricato accede dall'app Match Live TV con il proprio account personale.
how_3_title: Inizia la diretta
how_3_body: Basta lo smartphone. Nessuna attrezzatura dedicata o configurazione complicata.
how_4_title: Gestisci il punteggio
how_4_body: Un'altra persona può aggiornare risultato e punteggio tramite un semplice link, senza installare app e senza creare un account.
how_5_title: Replay
how_5_body: Al termine della diretta la partita può essere conservata e resa disponibile come replay, in base al piano scelto.
accounts_title: Ognuno con il proprio account. La password della società resta alla società.
accounts_lead: Non serve più condividere username e password dell'account principale. Dirigenti, allenatori e genitori incaricati ricevono un accesso personale e revocabile.
accounts_invite_title: Invita
accounts_invite_body: La società invita allenatori, dirigenti o genitori incaricati della trasmissione.
accounts_transmit_title: Trasmetti
accounts_transmit_body: Ognuno accede all'app con le proprie credenziali personali.
accounts_revoke_title: Revoca
accounts_revoke_body: Quando una persona non collabora più con la squadra o con la società, il suo accesso può essere revocato senza cambiare la password agli altri.
accounts_highlight: Niente password condivise. Ogni incaricato ha il proprio accesso personale.
score_title: Chi filma, filma. Chi tiene il punteggio, tiene il punteggio.
score_lead: La persona che trasmette può concentrarsi sull'inquadratura. Un'altra persona, anche dagli spalti, può aprire un semplice link e aggiornare il punteggio in tempo reale.
score_item_app: Nessuna app da installare
score_item_signup: Nessuna registrazione
score_item_password: Nessuna password
score_item_link: Basta aprire il link condiviso
score_card_aria: Accesso alla regia
score_card_title: Regia con un link
score_card_body: Chi aggiorna set e punti apre un link condiviso. Nessun account e nessun invito.
access_link: Accesso con link
distribute_title: Una partita, più modi di seguirla
distribute_lead: Dallo smartphone in palestra la diretta arriva agli spettatori su Match Live TV e, quando previsto dal piano, anche su YouTube.
distribute_aria: Dove arriva la diretta
distribute_phone: Smartphone
distribute_mltv: Match Live TV
distribute_viewers: Spettatori
distribute_youtube: YouTube
youtube_badge: Premium Full
youtube_title: La diretta resta della tua società
youtube_body: Con Premium Full puoi trasmettere anche sul canale YouTube ufficiale del club. La società mantiene così il proprio pubblico, gli iscritti, le visualizzazioni, l'archivio delle partite e la visibilità degli sponsor.
youtube_mock_channel: Canale ufficiale società
youtube_mock_subs: Iscritti e visualizzazioni del club
replay_title: La partita finisce. Il ricordo resta.
replay_lead: Nei piani che prevedono il replay, le gare restano salvate sui server Match Live TV e possono essere riviste in seguito. La durata della conservazione dipende dal piano.
replay_item_server: Conservazione sui server Match Live TV
replay_item_plan: Durata dell'archivio in base al piano
replay_item_download: Download del video sul telefono quando serve
replay_card_title: Archivio partite
replay_card_body: Rivedi le gare dopo il fischio finale e scaricale quando ti servono.
replay_mock_1_title: Rossi vs Neri
replay_mock_1_meta: "12/05 · 01:25:34"
replay_mock_2_title: Rossi vs Blu
replay_mock_2_meta: "05/05 · 01:18:22"
replay_mock_3_title: Rossi vs Bianchi
replay_mock_3_meta: "28/04 · 01:07:15"
more_title: Anche questo fa parte del prodotto
more_stable_title: Diretta dallo smartphone
more_stable_body: Appoggi il telefono e avvii la trasmissione. Anche con il 4G debole la diretta resta stabile il più possibile, senza cavi o mixer.
more_link_title: Link per famiglie e tifosi
more_link_body: "Condividi un link: genitori, nonni e amici guardano dal browser del telefono o del computer, senza installare app."
more_yt_mltv_title: Anche su YouTube Match Live TV
more_yt_mltv_body: Puoi rendere la diretta visibile anche sul canale YouTube Match Live TV, oltre al link sul sito.
cta_title: Pronti per la prossima partita?
cta_body: Prova Match Live TV gratuitamente. Non serve attrezzatura dedicata.
cta_primary: Inizia gratis
cta_secondary: Confronta i piani
meta_description: "App per diretta live da telefono: filma la partita, aggiorna il punteggio, condividi il link con genitori e nonni. Archivio gare e piani per società sportive giovanili."
title: Funzionalità
lead: "Tutto ciò che serve per far seguire le partite a chi non può essere in palestra: semplice per coach e volontari, comodo per genitori, nonni e parenti."
staff_section_title: Per la società e lo staff
staff_invite_title: Inviti con la propria email
staff_invite_body: "Il presidente o l'owner invita chi filma e chi aggiorna il punteggio: ognuno accede con la sua email, senza condividere password."
staff_access_title: Gestione accessi
staff_access_body: Puoi togliere l'accesso a un volontario o annullare un invito ancora in sospeso dalla dashboard della squadra.
staff_plans_title: Piani su misura
staff_plans_body: Con Free, Premium Light o Premium Full aumenti quante persone possono aiutare in diretta e quante partite possono andare in parallelo.
live_section_title: In campo e in diretta
live_phone_title: Filma dal telefono
live_phone_body: "Appoggi il telefono e avvii la diretta: anche con il 4G debole la trasmissione resta stabile il più possibile, senza impazzire con cavi o mixer."
live_score_title: Punteggio da un secondo telefono
live_score_body: "Un altro volontario aggiorna set e punti da un secondo cellulare: basta inquadrare il codice che compare sullo schermo di chi filma."
live_link_title: Link per famiglie e tifosi
live_link_body: "Condividi un link: genitori, nonni e amici guardano dal browser del telefono o del computer, senza installare app."
live_archive_title: Archivio partite
live_archive_body: "Con Premium Light o Full le gare restano salvate sul server (30 o 90 giorni) e puoi scaricarle sul telefono quando serve."
live_youtube_mltv_title: Anche su YouTube (Match Live TV)
live_youtube_mltv_body: Con Premium Light la diretta è visibile anche sul canale YouTube Match Live TV, oltre al link sul sito.
live_youtube_club_title: Canale YouTube della società
live_youtube_club_body: Con Premium Full puoi mandare la stessa diretta anche sul canale YouTube ufficiale del club.
live_website_title: Sul sito della società
live_website_body: Integrazione per incorporare il player sul sito del club — in arrivo con Premium Full.
cta_signup: Registra la squadra
pricing:
meta_title: "Prezzi streaming partite giovanili — Match Live TV"
meta_description: "Piani Free, Premium Light e Premium Full per dirette live e archivio partite. Abbonamento annuale per società: più staff, più partite in parallelo, replay e YouTube."
@@ -83,8 +35,8 @@ it:
club_info_html: "Società: <strong>%{name}</strong> · %{billing_link}"
manage_subscription: Gestisci abbonamento
team_title: Piani per la tua squadra
team_lead: "Abbonamento annuale per società: gli account trasmettitori avviano la diretta dall'app; il punteggio si gestisce con un link condivisibile."
table_staff: Account trasmettitori
team_lead: "Abbonamento annuale per società: i responsabili trasmissione avviano la diretta dall'app; il punteggio si gestisce con un link condivisibile."
table_staff: Responsabili trasmissione / anno
table_matches: Partite in contemporanea
table_live_mltv: Live su Match Live TV
table_youtube: YouTube
@@ -92,7 +44,8 @@ it:
table_download: Download telefono
table_price: Prezzo
table_price_free: "€0"
table_price_note: "listino %{list} — %{monthly}"
table_price_light_html: "€40/anno o €5/mese"
table_price_full_html: "€200/anno o €20/mese"
table_yes: "Sì"
table_no: "No"
table_unlimited: Illimitato
@@ -102,16 +55,8 @@ it:
contact_button: Contattaci
plans:
price_alt_html: "oppure <strong>%{price}</strong>"
price_equiv: "equivale a %{price}"
launch_price: Prezzo lancio
recommended_badge: Consigliato per le società
cta_name:
premium_light: Light
premium_full: Full
staff_html: "Account trasmettitori: massimo <strong>%{count}</strong> per abbonamento"
staff_unlimited_html: "Account trasmettitori: <strong>illimitati</strong> per abbonamento"
staff_unlimited: illimitati
staff_note_full: "Niente password condivise: ogni incaricato utilizza il proprio accesso personale, revocabile in qualsiasi momento."
staff_html: "Responsabili trasmissione: <strong>%{count}</strong> / anno per squadra"
staff_unlimited: illimitato
matches_html: "Partite: <strong>%{count}</strong> in contemporanea (tutta la società)"
matches_unlimited: illimitate
replay_html: "Replay: %{value}"
-7
View File
@@ -43,13 +43,6 @@ de:
tagline: "Streamen Sie live vom Smartphone. Großeltern, entfernte Verwandte, Freunde — schauen im Browser, ohne Installation. Und wenn Sie es verpasst haben: es ist im Archiv."
cta_signup: Team registrieren
cta_live: Live-Spiele ansehen
stores_label: Live gehen vom Smartphone
store_app_store_kicker: Laden im
store_app_store_name: App Store
store_app_store_aria: Match Live TV im App Store laden
store_play_kicker: Jetzt bei
store_play_name: Google Play
store_play_aria: Match Live TV bei Google Play laden
features_aria: Wichtige Vorteile
feature_stable: Stabile Streams
feature_archive: Sicheres Archiv
-7
View File
@@ -43,13 +43,6 @@ en:
tagline: Stream live from your smartphone. Grandparents, distant relatives, friends — they watch in the browser, with nothing to install. And if you missed it, it's in the archive.
cta_signup: Register your team
cta_live: Watch live matches
stores_label: To go live from your smartphone
store_app_store_kicker: Download on the
store_app_store_name: App Store
store_app_store_aria: Download Match Live TV on the App Store
store_play_kicker: Get it on
store_play_name: Google Play
store_play_aria: Get Match Live TV on Google Play
features_aria: Key benefits
feature_stable: Reliable streams
feature_archive: Secure archive
-7
View File
@@ -43,13 +43,6 @@ es:
tagline: Emite en directo desde el smartphone. Abuelos, familiares lejanos, amigos — miran en el navegador, sin instalar nada. Y si te lo perdiste, está en el archivo.
cta_signup: Registra tu equipo
cta_live: Ver directos
stores_label: Para emitir en directo desde el smartphone
store_app_store_kicker: Consíguelo en
store_app_store_name: App Store
store_app_store_aria: Descarga Match Live TV en App Store
store_play_kicker: Disponible en
store_play_name: Google Play
store_play_aria: Descarga Match Live TV en Google Play
features_aria: Ventajas principales
feature_stable: Directos estables
feature_archive: Archivo seguro
-7
View File
@@ -43,13 +43,6 @@ fr:
tagline: Diffusez en direct depuis votre smartphone. Les grands-parents, la famille éloignée, les amis — regardent dans le navigateur, sans rien installer. Et si vous l'avez manqué, c'est dans l'archive.
cta_signup: Inscrire votre équipe
cta_live: Voir les directs
stores_label: Pour passer en direct depuis le smartphone
store_app_store_kicker: Télécharger dans
store_app_store_name: App Store
store_app_store_aria: Télécharger Match Live TV dans l'App Store
store_play_kicker: Disponible sur
store_play_name: Google Play
store_play_aria: Télécharger Match Live TV sur Google Play
features_aria: Avantages principaux
feature_stable: Directs stables
feature_archive: Archive sécurisée
-7
View File
@@ -43,13 +43,6 @@ it:
tagline: Trasmetti in diretta dallo smartphone. I nonni, i parenti lontani, gli amici — guardano dal browser, senza installare nulla. E se te la sei persa, la ritrovi nell'archivio.
cta_signup: Registra la tua squadra
cta_live: Guarda le dirette
stores_label: Per andare in diretta dallo smartphone
store_app_store_kicker: Scarica su
store_app_store_name: App Store
store_app_store_aria: Scarica Match Live TV su App Store
store_play_kicker: Disponibile su
store_play_name: Google Play
store_play_aria: Scarica Match Live TV su Google Play
features_aria: Vantaggi principali
feature_stable: Dirette stabili
feature_archive: Archivio sicuro
+9
View File
@@ -110,6 +110,15 @@ Rails.application.routes.draw do
post :regia_link
end
end
resources :stream_nodes, only: %i[index create destroy] do
member do
post :drain
end
collection do
post :kill_switch
delete :clear_kill_switch
end
end
get "youtube/platform", to: "youtube#platform", as: :youtube_platform
end
+2 -1
View File
@@ -1,4 +1,5 @@
:concurrency: 5
:queues:
- default
- critical
- youtube_relay
- default
@@ -0,0 +1,30 @@
# frozen_string_literal: true
class CreateStreamNodes < ActiveRecord::Migration[7.2]
def change
create_table :stream_nodes, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
t.string :slug, null: false
t.string :hostname, null: false
t.string :role, null: false, default: "home"
t.string :status, null: false, default: "ready"
t.string :rtmp_base_url, null: false
t.string :hls_base_url, null: false
t.string :api_base_url, null: false
t.string :internal_rtmp_url
t.string :internal_hls_url
t.integer :max_publishers, null: false, default: 6
t.integer :max_relays, null: false, default: 6
t.string :provider, null: false, default: "local"
t.string :provider_instance_id
t.datetime :last_health_at
t.jsonb :metadata, null: false, default: {}
t.timestamps
end
add_index :stream_nodes, :slug, unique: true
add_index :stream_nodes, :status
add_index :stream_nodes, :role
add_reference :stream_sessions, :stream_node, type: :uuid, foreign_key: true, null: true, index: true
end
end
+27 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
ActiveRecord::Schema[7.2].define(version: 2026_08_09_120000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -255,6 +255,29 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
t.index ["stream_session_id"], name: "index_stream_events_on_stream_session_id"
end
create_table "stream_nodes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "slug", null: false
t.string "hostname", null: false
t.string "role", default: "home", null: false
t.string "status", default: "ready", null: false
t.string "rtmp_base_url", null: false
t.string "hls_base_url", null: false
t.string "api_base_url", null: false
t.string "internal_rtmp_url"
t.string "internal_hls_url"
t.integer "max_publishers", default: 6, null: false
t.integer "max_relays", default: 6, null: false
t.string "provider", default: "local", null: false
t.string "provider_instance_id"
t.datetime "last_health_at"
t.jsonb "metadata", default: {}, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["role"], name: "index_stream_nodes_on_role"
t.index ["slug"], name: "index_stream_nodes_on_slug", unique: true
t.index ["status"], name: "index_stream_nodes_on_status"
end
create_table "stream_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "match_id", null: false
t.uuid "user_id", null: false
@@ -280,10 +303,12 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
t.datetime "updated_at", null: false
t.string "regia_token_digest"
t.datetime "regia_token_expires_at"
t.uuid "stream_node_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 ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
t.index ["status"], name: "index_stream_sessions_on_status"
t.index ["stream_node_id"], name: "index_stream_sessions_on_stream_node_id"
t.index ["user_id"], name: "index_stream_sessions_on_user_id"
end
@@ -411,6 +436,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
add_foreign_key "score_states", "stream_sessions"
add_foreign_key "stream_events", "stream_sessions"
add_foreign_key "stream_sessions", "matches"
add_foreign_key "stream_sessions", "stream_nodes"
add_foreign_key "stream_sessions", "users"
add_foreign_key "subscriptions", "admin_accounts", column: "admin_comped_by_id"
add_foreign_key "subscriptions", "clubs"
+3
View File
@@ -43,3 +43,6 @@ end
puts "Seed OK: coach@matchlivetv.test / Password123"
puts "Club: #{club.name}, Team: #{team.name}, Match: #{match.opponent_name}"
home = Streams::NodeRegistry.ensure_home_from_env!
puts "Stream node home: #{home.slug} rtmp=#{home.rtmp_base_url}"
+32
View File
@@ -0,0 +1,32 @@
# frozen_string_literal: true
namespace :streams do
namespace :nodes do
desc "Assicura il nodo home dagli ENV MediaMTX"
task ensure_home: :environment do
node = Streams::NodeRegistry.ensure_home_from_env!
puts "home ready slug=#{node.slug} rtmp=#{node.rtmp_base_url} max=#{node.max_publishers}"
end
desc "Provisiona un nodo lab (STREAM_CLOUD_PROVIDER=local_lab|proxmox_lab)"
task provision_lab: :environment do
node = Streams::NodeProvisioner.new.provision_lab!
puts "lab node ready slug=#{node.slug} host=#{node.hostname} id=#{node.provider_instance_id}"
if ENV.fetch("STREAM_DNS_PROVIDER", "lab") == "lab"
puts "DNS lab snippet:"
puts Streams::DnsProviders::Lab.new.hosts_file_snippet
end
end
desc "Provisiona un nodo Hetzner Cloud + DNS mltv-stream.net (richiede HCLOUD_TOKEN)"
task provision_cloud: :environment do
node = Streams::NodeProvisioner.new.provision_cloud!
puts "cloud node ready slug=#{node.slug} host=#{node.hostname} id=#{node.provider_instance_id} ip=#{node.metadata['public_ip']}"
end
desc "Dump record DNS lab (Redis)"
task dns_lab_dump: :environment do
puts Streams::DnsProviders::Lab.new.hosts_file_snippet
end
end
end
+103 -10
View File
@@ -228,6 +228,8 @@ body.admin-body {
.badge--live { background: var(--red); color: #fff; }
.badge--connecting { background: #ff9800; color: #111; }
.badge--paused { background: #555; color: #fff; }
.badge--ready { background: #1b5e20; color: #c8e6c9; }
.badge--ok { background: #1b5e20; color: #c8e6c9; }
.team-list {
list-style: none;
@@ -345,28 +347,40 @@ body.admin-body {
font-size: 0.9rem;
}
.admin-btn--secondary {
background: #333;
color: #eee;
}
.admin-btn--secondary:hover {
background: #444;
}
.admin-btn {
display: inline-block;
padding: 0.45rem 0.9rem;
border: none;
border: 1px solid transparent;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
background: #2a2a36;
color: #eee;
line-height: 1.2;
}
.admin-btn:hover { filter: brightness(1.08); }
.admin-btn--sm { padding: 0.25rem 0.55rem; font-size: 0.75rem; }
.admin-btn--primary {
background: var(--red) !important;
color: #fff !important;
border-color: var(--red);
}
.admin-btn--secondary {
background: #333;
color: #eee;
border-color: #444;
}
.admin-btn--secondary:hover {
background: #444;
}
.admin-btn--danger {
background: var(--red);
color: #fff;
@@ -374,6 +388,85 @@ body.admin-body {
.admin-btn--danger:hover { filter: brightness(1.1); }
.admin-btn--outline {
background: transparent;
border-color: #555;
color: #eee;
}
.admin-page-head {
margin-bottom: 1.25rem;
}
.admin-page-title {
margin: 0 0 0.35rem;
font-size: 1.35rem;
font-weight: 800;
}
.admin-page-sub {
margin: 0;
font-size: 0.9rem;
}
.admin-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.admin-panel-head h2 {
margin: 0;
}
.admin-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
align-items: center;
}
.admin-toolbar form {
display: inline;
margin: 0;
}
.admin-table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.admin-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem;
word-break: break-all;
}
.admin-pre {
margin: 0;
padding: 0.85rem 1rem;
background: #0a0a0e;
border: 1px solid var(--card-border);
border-radius: 8px;
overflow-x: auto;
font-size: 0.8rem;
line-height: 1.45;
color: #cfcfd8;
}
.kpi-value-unit {
font-size: 1rem;
font-weight: 600;
color: var(--muted);
margin-left: 0.15rem;
}
.kpi-value--sm {
font-size: 1.35rem;
}
.muted { color: var(--muted); }
.empty { color: var(--muted); font-size: 0.9rem; margin: 0; }
-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-hidden="true">
<path fill="#ffffff" d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
</svg>

Before

Width:  |  Height:  |  Size: 569 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 283" role="img" aria-hidden="true">
<path fill="#EA4335" d="M119.55 134.92 1.06 259.06c2.7 9.56 9.66 17.33 18.86 21.06 9.2 3.73 19.61 2.99 28.2-1.99l133.33-75.93-61.9-67.28z"/>
<path fill="#FBBC04" d="M239.37 113.81 181.71 80.79l-64.9 56.95 65.16 64.28 57.22-32.67c10.33-5.41 16.81-16.11 16.81-27.77s-6.48-22.36-16.81-27.77z"/>
<path fill="#4285F4" d="M1.06 23.49C.34 26.13-.01 28.87 0 31.61v219.33c.01 2.74.36 5.47 1.06 8.12L123.61 138.1 1.06 23.49z"/>
<path fill="#34A853" d="m120.44 141.27 61.28-60.48L48.56 4.5C43.55 1.57 37.86.02 32.05 0 17.64-.03 4.98 9.53 1.06 23.4l119.38 117.87z"/>
</svg>

Before

Width:  |  Height:  |  Size: 663 B

File diff suppressed because it is too large Load Diff
@@ -32,7 +32,7 @@ RSpec.describe Public::BillingHelper, type: :helper do
current_interval: "yearly"
)
expect(action[:kind]).to eq(:current)
expect(action[:label]).to include("59/anno")
expect(action[:label]).to include("40/anno")
end
it "propone cambio intervallo sullo stesso piano" do
@@ -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
@@ -72,7 +72,7 @@ RSpec.describe "Public club billing", type: :request do
get public_club_billing_path(club)
expect(response.body).to include("Completa dati di fatturazione")
expect(response.body).not_to include("Attiva Light — €59/anno")
expect(response.body).not_to include("Attiva Premium Light — €40/anno")
end
it "dopo profilo completo reindirizza al checkout" do
@@ -111,8 +111,8 @@ RSpec.describe "Public club billing", type: :request do
login!
get public_club_billing_path(club)
expect(response.body).to include("Attiva Light — €59/anno")
expect(response.body).to include("Attiva Light — €7,90/mese")
expect(response.body).to include("Premium Light — €40/anno")
expect(response.body).to include("Premium Light — €5/mese")
allow(Billing::Stripe::CheckoutSession).to receive(:new).and_return(
instance_double(Billing::Stripe::CheckoutSession, url: "https://checkout.stripe.com/test")
@@ -1,26 +0,0 @@
require "rails_helper"
RSpec.describe "Public features page", type: :request do
it "racconta il prodotto con sezioni narrative e CTA" do
get public_features_path
expect(response).to have_http_status(:ok)
expect(response.body).to include("Le dirette della tua società")
expect(response.body).to include("Senza password condivise")
expect(response.body).to include("Uno smartphone")
expect(response.body).to include("Un account personale")
expect(response.body).to include("Sei in diretta")
expect(response.body).to include("Dalla palestra agli spettatori")
expect(response.body).to include("Ognuno con il proprio account")
expect(response.body).to include("Niente password condivise")
expect(response.body).to include("Chi filma, filma")
expect(response.body).to include("Accesso con link")
expect(response.body).to include("La diretta resta della tua società")
expect(response.body).to include("La partita finisce. Il ricordo resta.")
expect(response.body).to include("Inizia gratis")
expect(response.body).to include("Confronta i piani")
expect(response.body).to include(public_signup_path)
expect(response.body).to include(public_prezzi_path)
expect(response.body).not_to include("translation missing")
end
end
@@ -1,41 +0,0 @@
require "rails_helper"
RSpec.describe "Public home page", type: :request do
it "mostra i link agli store nell'hero e nel footer" do
get root_path
expect(response).to have_http_status(:ok)
expect(response.body).to include(MatchLiveTv.app_store_url)
expect(response.body).to include(MatchLiveTv.play_store_url)
expect(response.body).to include(I18n.t("home.stores_label", locale: :it))
expect(response.body).to include(I18n.t("home.store_app_store_name", locale: :it))
expect(response.body).to include(I18n.t("home.store_play_name", locale: :it))
expect(response.body.scan(MatchLiveTv.app_store_url).size).to eq(3)
expect(response.body.scan(MatchLiveTv.play_store_url).size).to eq(3)
end
{
"it" => "Per andare in diretta dallo smartphone",
"en" => "To go live from your smartphone",
"fr" => "Pour passer en direct depuis le smartphone",
"de" => "Live gehen vom Smartphone",
"es" => "Para emitir en directo desde el smartphone"
}.each do |locale, label|
it "traduce i badge store in #{locale} senza translation missing" do
cookies[:mltv_locale] = locale
get root_path
expect(response).to have_http_status(:ok)
expect(response.body).to include(label)
expect(response.body).not_to include("translation missing")
end
end
it "non mostra i badge store nel chrome App Store Review" do
get public_support_path
expect(response).to have_http_status(:ok)
expect(response.body).not_to include(MatchLiveTv.app_store_url)
expect(response.body).not_to include(MatchLiveTv.play_store_url)
end
end
@@ -1,32 +0,0 @@
require "rails_helper"
RSpec.describe "Public pricing page", type: :request do
before { load Rails.root.join("db/seeds/plans.rb") }
it "mostra prezzi lancio, listino barrato e Full in evidenza" do
get public_prezzi_path
expect(response).to have_http_status(:ok)
expect(response.body).to include("€59/anno")
expect(response.body).to include("€79/anno")
expect(response.body).to include("€199/anno")
expect(response.body).to include("€249/anno")
expect(response.body).to include("€7,90/mese")
expect(response.body).to include("€24,90/mese")
expect(response.body).to include("equivale a €4,92/mese")
expect(response.body).to include("equivale a €16,58/mese")
expect(response.body).to include("Prezzo lancio")
expect(response.body).to include("Consigliato per le società")
expect(response.body).to include("Attiva Light — €59/anno")
expect(response.body).to include("Attiva Light — €7,90/mese")
expect(response.body).to include("Attiva Full — €199/anno")
expect(response.body).to include("Attiva Full — €24,90/mese")
expect(response.body).to include("Account trasmettitori: massimo")
expect(response.body).to include("per abbonamento")
expect(response.body).to include("Account trasmettitori")
expect(response.body).to include("Niente password condivise")
expect(response.body).to include("plan-card--featured")
expect(response.body).not_to include("a vita")
expect(response.body).not_to include("Più popolare")
end
end
@@ -10,7 +10,7 @@ RSpec.describe Billing::PaymentDescription do
lines: double(data: [])
)
expect(described_class.for_stripe_invoice(invoice)).to eq("Premium Light — €7,90/mese")
expect(described_class.for_stripe_invoice(invoice)).to eq("Premium Light — €5/mese")
end
it "ignora descrizioni di test nel pagamento salvato" do
@@ -23,6 +23,6 @@ RSpec.describe Billing::PaymentDescription do
club: Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff")
)
expect(payment.display_description).to eq("Premium Light — €59/anno")
expect(payment.display_description).to eq("Premium Light — €40/anno")
end
end
@@ -23,15 +23,6 @@ RSpec.describe Billing::Stripe::PriceCatalog do
expect { described_class.normalize_interval("weekly") }.to raise_error(ArgumentError)
end
it "etichetta il prezzo lancio annuale e il mensile" do
expect(described_class.label(plan_slug: "premium_light", interval: "yearly")).to eq("€59/anno")
expect(described_class.label(plan_slug: "premium_light", interval: "monthly")).to eq("€7,90/mese")
expect(described_class.list_label(plan_slug: "premium_light", interval: "yearly")).to eq("€79/anno")
expect(described_class.equivalent_monthly_label(plan_slug: "premium_light")).to eq("€4,92/mese")
expect(described_class.label(plan_slug: "premium_full", interval: "yearly")).to eq("€199/anno")
expect(described_class.label(plan_slug: "premium_full", interval: "monthly")).to eq("€24,90/mese")
end
it "elenca intervalli configurati" do
expect(described_class.available_intervals(plan_slug: "premium_light")).to eq(%w[monthly yearly])
end
@@ -38,6 +38,6 @@ RSpec.describe Billing::Stripe::RecordPayment do
expect(payment.club).to eq(club)
expect(payment.amount_cents).to eq(4000)
expect(payment.stripe_invoice_id).to eq("in_test_1")
expect(payment.description).to eq("Premium Light — €59/anno")
expect(payment.description).to eq("Premium Light — €40/anno")
end
end
@@ -1,7 +1,7 @@
require "rails_helper"
RSpec.describe Mediamtx::PublisherSync do
let(:user) { User.create!(email: "sync@test.com", name: "Sync", password: "password123", role: "coach") }
let(:user) { User.create!(email: "sync@test.com", name: "Sync", password: "Password123", role: "coach") }
let(:club) { Club.create!(name: "Sync Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let(:team) { club.teams.create!(name: "Under 16", sport: "volleyball") }
let!(:match) { team.matches.create!(opponent_name: "Avversario") }
@@ -20,8 +20,11 @@ RSpec.describe Mediamtx::PublisherSync do
before do
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
allow(Mediamtx::Client).to receive(:new).and_return(client)
allow(Mediamtx::Client).to receive(:for_session).and_return(client)
allow(Mediamtx::PublisherOnline).to receive(:path_info).and_return(path_info)
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(true)
allow(Mediamtx::PublisherOnline).to receive(:rtmp_publisher?).and_return(false)
allow(Mediamtx::PublisherOnline).to receive(:active?).and_return(true)
allow_any_instance_of(described_class).to receive(:redis).and_return(redis)
allow(client).to receive(:set_path_recording)
end
@@ -36,6 +39,7 @@ RSpec.describe Mediamtx::PublisherSync do
it "non abilita la registrazione in connecting senza publisher online" do
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(false)
allow(Mediamtx::PublisherOnline).to receive(:active?).and_return(false)
path_info["online"] = false
described_class.new(session).call
@@ -53,6 +57,7 @@ RSpec.describe Mediamtx::PublisherSync do
it "non disabilita la registrazione su reconnecting con publisher offline" do
session.update!(status: "reconnecting")
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(false)
allow(Mediamtx::PublisherOnline).to receive(:active?).and_return(false)
described_class.new(session).call
@@ -25,6 +25,7 @@ RSpec.describe Ops::HealthChecks do
%i[
check_recordings_size check_postgres check_redis check_mediamtx check_garage
check_sidekiq_heartbeat check_sidekiq_dead check_http_rails check_rails_latency
check_stream_overflow
].each do |method|
allow_any_instance_of(described_class).to receive(method).and_return(
described_class::Finding.new(
@@ -52,6 +53,7 @@ RSpec.describe Ops::HealthChecks do
%i[
check_recordings_size check_postgres check_redis check_mediamtx check_garage
check_sidekiq_heartbeat check_sidekiq_dead check_http_rails check_rails_latency
check_stream_overflow
].each do |method|
allow_any_instance_of(described_class).to receive(method).and_return(
described_class::Finding.new(
@@ -65,7 +67,7 @@ RSpec.describe Ops::HealthChecks do
summary = described_class.new.summary
expect(summary[:status]).to eq("ok")
expect(summary[:checks].size).to eq(10)
expect(summary[:checks].size).to eq(11)
end
it "degraded quando la latenza p95 supera la soglia warning" do
@@ -0,0 +1,227 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Streams::Autoscaler do
def with_env(vars)
previous = vars.keys.index_with { |k| ENV[k] }
vars.each { |k, v| ENV[k] = v }
yield
ensure
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
end
let(:redis) { Redis.new(url: ENV.fetch("REDIS_URL", "redis://redis:6379/0")) }
before do
redis.del(Streams::Autoscaler::LOCK_KEY)
redis.del(Streams::Autoscaler::KILL_SWITCH_KEY)
redis.del(Streams::DnsProviders::Lab::REDIS_KEY)
StreamSession.where.not(stream_node_id: nil).update_all(stream_node_id: nil, status: "ended", ended_at: Time.current)
StreamNode.where.not(slug: Streams::NodeRegistry::HOME_SLUG).delete_all
end
it "is a no-op when disabled" do
with_env("STREAM_AUTOSCALE_ENABLED" => "0") do
result = described_class.reconcile!
expect(result.skipped).to eq(true)
expect(result.actions).to eq([])
end
end
it "scales out and creates a warm spare when free slots are low" do
with_env(
"STREAM_AUTOSCALE_ENABLED" => "1",
"STREAM_AUTOSCALE_SOFT_FREE_SLOTS" => "2",
"STREAM_AUTOSCALE_WARM_SPARE" => "1",
"STREAM_AUTOSCALE_KIND" => "lab",
"STREAM_AUTOSCALE_MAX_NODES" => "5",
"STREAM_CLOUD_PROVIDER" => "local_lab",
"STREAM_DNS_PROVIDER" => "lab",
"STREAM_NODE_HOME_MAX_PUBLISHERS" => "2",
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
"MEDIAMTX_RTMP_URL" => "rtmp://home.example:1935",
"HLS_PUBLIC_URL" => "https://home.example/hls"
) do
home = Streams::NodeRegistry.ensure_home_from_env!
user = User.create!(email: "as@example.com", name: "A", password: "Password123", role: "coach")
club = Club.create!(name: "AS", sport: "volleyball")
team = club.teams.create!(name: "T", sport: "volleyball", slug: "as-t")
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
# Fill home (2/2) → free_slots=0
2.times do
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home)
end
provisioner = instance_double(Streams::NodeProvisioner)
created = []
allow(provisioner).to receive(:provision_lab!) do
node = StreamNode.create!(
slug: "ingest-lab-#{created.size + 1}",
hostname: "h#{created.size}.lab",
role: "lab",
status: "ready",
provider: "local",
rtmp_base_url: "rtmp://h:1935",
hls_base_url: "https://h/hls",
api_base_url: "http://h:9997",
max_publishers: 2,
max_relays: 2
)
created << node
node
end
result = described_class.reconcile!(provisioner: provisioner)
expect(result.actions).to include(:scale_out)
expect(created.size).to eq(1)
expect(result.metrics[:free_slots]).to be >= 2
# Riempi il nodo overflow → spare_ready=0 ma carico presente → warm spare
StreamSession.create!(
match: match, user: user, platform: "matchlivetv", status: "live", stream_node: created.first
)
StreamSession.create!(
match: match, user: user, platform: "matchlivetv", status: "live", stream_node: created.first
)
result2 = described_class.reconcile!(provisioner: provisioner)
expect(result2.actions).to include(:warm_spare).or include(:scale_out)
expect(created.size).to be >= 2
end
end
it "does not scale in a node provisioned in the same reconcile round" do
with_env(
"STREAM_AUTOSCALE_ENABLED" => "1",
"STREAM_AUTOSCALE_SOFT_FREE_SLOTS" => "2",
"STREAM_AUTOSCALE_WARM_SPARE" => "0",
"STREAM_AUTOSCALE_IDLE_MINUTES" => "0",
"STREAM_AUTOSCALE_KIND" => "lab",
"STREAM_AUTOSCALE_MAX_NODES" => "5",
"STREAM_CLOUD_PROVIDER" => "local_lab",
"STREAM_DNS_PROVIDER" => "lab",
"STREAM_NODE_HOME_MAX_PUBLISHERS" => "2",
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
"MEDIAMTX_RTMP_URL" => "rtmp://home.example:1935",
"HLS_PUBLIC_URL" => "https://home.example/hls"
) do
home = Streams::NodeRegistry.ensure_home_from_env!
user = User.create!(email: "same@example.com", name: "S", password: "Password123", role: "coach")
club = Club.create!(name: "Same", sport: "volleyball")
team = club.teams.create!(name: "T", sport: "volleyball", slug: "same-t")
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
2.times do
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home)
end
provisioner = instance_double(Streams::NodeProvisioner)
created = nil
allow(provisioner).to receive(:provision_lab!) do
created = StreamNode.create!(
slug: "ingest-lab-new",
hostname: "new.lab",
role: "lab",
status: "ready",
provider: "local",
rtmp_base_url: "rtmp://h:1935",
hls_base_url: "https://h/hls",
api_base_url: "http://h:9997",
max_publishers: 4,
max_relays: 2
)
end
expect(provisioner).not_to receive(:decommission!)
result = described_class.reconcile!(provisioner: provisioner)
expect(result.actions).to include(:scale_out)
expect(result.actions.map(&:to_s)).not_to include("scale_in_ingest-lab-new")
expect(StreamNode.find_by(slug: "ingest-lab-new")).to be_present
end
end
it "scales in an idle overflow node when warm spare is not required" do
with_env(
"STREAM_AUTOSCALE_ENABLED" => "1",
"STREAM_AUTOSCALE_SOFT_FREE_SLOTS" => "0",
"STREAM_AUTOSCALE_WARM_SPARE" => "0",
"STREAM_AUTOSCALE_IDLE_MINUTES" => "0",
"STREAM_NODE_HOME_MAX_PUBLISHERS" => "6",
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
"MEDIAMTX_RTMP_URL" => "rtmp://home.example:1935",
"HLS_PUBLIC_URL" => "https://home.example/hls"
) do
Streams::NodeRegistry.ensure_home_from_env!
idle = StreamNode.create!(
slug: "ingest-lab-99",
hostname: "idle.lab",
role: "lab",
status: "ready",
provider: "local",
provider_instance_id: "sim-x",
rtmp_base_url: "rtmp://h:1935",
hls_base_url: "https://h/hls",
api_base_url: "http://h:9997",
max_publishers: 2,
max_relays: 2,
created_at: 2.hours.ago
)
provisioner = instance_double(Streams::NodeProvisioner)
allow(provisioner).to receive(:drain!)
expect(provisioner).to receive(:decommission!).with(idle)
result = described_class.reconcile!(provisioner: provisioner)
expect(result.actions.map(&:to_s)).to include("scale_in_ingest-lab-99")
end
end
it "skips when Redis kill-switch is engaged even if ENV is enabled" do
redis.del(described_class::KILL_SWITCH_KEY)
with_env("STREAM_AUTOSCALE_ENABLED" => "1") do
described_class.engage_kill_switch!
expect(described_class.enabled?).to eq(false)
result = described_class.reconcile!
expect(result.skipped).to eq(true)
ensure
described_class.clear_kill_switch!
end
end
it "blocks scale-out when next node would exceed monthly budget" do
with_env(
"STREAM_AUTOSCALE_ENABLED" => "1",
"STREAM_AUTOSCALE_SOFT_FREE_SLOTS" => "2",
"STREAM_AUTOSCALE_WARM_SPARE" => "0",
"STREAM_AUTOSCALE_KIND" => "lab",
"STREAM_AUTOSCALE_MAX_NODES" => "5",
"STREAM_AUTOSCALE_NODE_EUR_PER_HOUR" => "1",
"STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR" => "10",
"STREAM_CLOUD_PROVIDER" => "local_lab",
"STREAM_DNS_PROVIDER" => "lab",
"STREAM_NODE_HOME_MAX_PUBLISHERS" => "1",
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
"MEDIAMTX_RTMP_URL" => "rtmp://home.example:1935",
"HLS_PUBLIC_URL" => "https://home.example/hls"
) do
StreamSession.where.not(stream_node_id: nil).update_all(stream_node_id: nil, status: "ended", ended_at: Time.current)
StreamNode.where.not(slug: Streams::NodeRegistry::HOME_SLUG).delete_all
home = Streams::NodeRegistry.ensure_home_from_env!
home.update!(max_publishers: 1)
user = User.create!(email: "budget@example.com", name: "B", password: "Password123", role: "coach")
club = Club.create!(name: "Budget", sport: "volleyball")
team = club.teams.create!(name: "T", sport: "volleyball", slug: "budget-t")
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home)
provisioner = instance_double(Streams::NodeProvisioner)
expect(provisioner).not_to receive(:provision_lab!)
result = described_class.reconcile!(provisioner: provisioner)
expect(result.actions).to include(:blocked_capacity)
# 0 overflow nodes → stima 0€ ≤ budget; il blocco è sul nodo successivo (+1)
expect(described_class.within_budget?(0)).to eq(true)
expect(described_class.within_budget?(1)).to eq(false)
end
end
end
@@ -0,0 +1,82 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Streams::CloudProviders::Hetzner do
let(:stubs) { Faraday::Adapter::Test::Stubs.new }
let(:conn) do
Faraday.new(url: Streams::CloudProviders::Hetzner::API) do |f|
f.request :json
f.response :json
f.adapter :test, stubs
end
end
let(:provider) { described_class.new(token: "test-token", conn: conn) }
def with_env(vars)
previous = vars.keys.index_with { |k| ENV[k] }
vars.each { |k, v| ENV[k] = v }
yield
ensure
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
end
after { stubs.verify_stubbed_calls }
it "creates a server and waits until running" do
with_env(
"HCLOUD_LOCATION" => "nbg1",
"HCLOUD_SERVER_TYPE" => "cpx12",
"HCLOUD_IMAGE" => "debian-12",
"HCLOUD_SSH_KEY" => "matchlivetv-stream-hetzner"
) do
stubs.post("https://api.hetzner.cloud/v1/servers") do |env|
body = JSON.parse(env.body)
expect(body["name"]).to eq("mltv-stream-ingest-01")
expect(body["location"]).to eq("nbg1")
[
201,
{ "Content-Type" => "application/json" },
{
"server" => {
"id" => 42,
"name" => "mltv-stream-ingest-01",
"status" => "initializing",
"public_net" => { "ipv4" => { "ip" => "49.13.1.2" } },
"private_net" => []
},
"action" => { "id" => 7, "status" => "running" }
}
]
end
stubs.get("https://api.hetzner.cloud/v1/actions/7") do
[200, { "Content-Type" => "application/json" }, { "action" => { "id" => 7, "status" => "success" } }]
end
stubs.get("https://api.hetzner.cloud/v1/servers/42") do
[
200,
{ "Content-Type" => "application/json" },
{
"server" => {
"id" => 42,
"name" => "mltv-stream-ingest-01",
"status" => "running",
"public_net" => { "ipv4" => { "ip" => "49.13.1.2" } },
"private_net" => []
}
}
]
end
instance = provider.create_node(name: "mltv-stream-ingest-01", labels: { role: "stream-node" })
expect(instance.id).to eq("42")
expect(instance.public_ip).to eq("49.13.1.2")
expect(instance.status).to eq("running")
end
end
it "destroys a server" do
stubs.delete("https://api.hetzner.cloud/v1/servers/42") { [204, {}, ""] }
expect(provider.destroy_node("42")).to eq(true)
end
end
@@ -0,0 +1,35 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Streams::DnsProviders::Hetzner do
let(:stubs) { Faraday::Adapter::Test::Stubs.new }
let(:conn) do
Faraday.new(url: Streams::DnsProviders::Hetzner::API) do |f|
f.request :json
f.response :json
f.adapter :test, stubs
end
end
let(:provider) { described_class.new(token: "test-token", zone: "mltv-stream.net", conn: conn) }
after { stubs.verify_stubbed_calls }
it "upserts an A record (delete then create)" do
stubs.delete("https://api.hetzner.cloud/v1/zones/mltv-stream.net/rrsets/ingest-01/A") { [404, {}, ""] }
stubs.post("https://api.hetzner.cloud/v1/zones/mltv-stream.net/rrsets") do |env|
body = JSON.parse(env.body)
expect(body["name"]).to eq("ingest-01")
expect(body["type"]).to eq("A")
expect(body["records"].first["value"]).to eq("49.13.1.2")
[201, { "Content-Type" => "application/json" }, { "rrset" => { "name" => "ingest-01" } }]
end
expect(provider.upsert_a("ingest-01.mltv-stream.net", "49.13.1.2")).to eq(true)
end
it "deletes an A record" do
stubs.delete("https://api.hetzner.cloud/v1/zones/mltv-stream.net/rrsets/ingest-01/A") { [204, {}, ""] }
expect(provider.delete_a("ingest-01.mltv-stream.net")).to eq(true)
end
end
@@ -0,0 +1,41 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe "Streams::NodeProvisioner cloud" do
it "registers a cloud node using injected providers" do
cloud = instance_double(
Streams::CloudProviders::Hetzner,
create_node: Streams::CloudProviders::Instance.new(
id: "99",
name: "mltv-stream-ingest-01",
public_ip: "49.13.9.9",
private_ip: "10.0.0.9",
status: "running",
raw: {}
)
)
dns = instance_double(Streams::DnsProviders::Hetzner)
allow(dns).to receive(:upsert_a)
ENV["MEDIAMTX_API_URL"] = "http://mtx-home:9997"
ENV["MEDIAMTX_RTMP_URL"] = "rtmp://home.example:1935"
ENV["HLS_PUBLIC_URL"] = "https://home.example/hls"
ENV["STREAM_CLOUD_DNS_SUFFIX"] = "mltv-stream.net"
ENV["STREAM_CLOUD_MAX_PUBLISHERS"] = "4"
node = Streams::NodeProvisioner.new(cloud: cloud, dns: dns).provision_cloud!
expect(node.slug).to eq("ingest-01")
expect(node.role).to eq("cloud")
expect(node.provider).to eq("hetzner")
expect(node.hostname).to eq("ingest-01.mltv-stream.net")
expect(node.rtmp_base_url).to eq("rtmp://ingest-01.mltv-stream.net:1935")
expect(node.api_base_url).to eq("http://10.0.0.9:9997")
expect(dns).to have_received(:upsert_a).with("ingest-01.mltv-stream.net", "49.13.9.9")
ensure
%w[
MEDIAMTX_API_URL MEDIAMTX_RTMP_URL HLS_PUBLIC_URL
STREAM_CLOUD_DNS_SUFFIX STREAM_CLOUD_MAX_PUBLISHERS
].each { |k| ENV.delete(k) }
end
end
@@ -0,0 +1,66 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Streams::NodeProvisioner do
def with_env(vars)
previous = vars.keys.index_with { |k| ENV[k] }
vars.each { |k, v| ENV[k] = v }
yield
ensure
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
end
let(:redis) { Redis.new(url: ENV.fetch("REDIS_URL", "redis://redis:6379/0")) }
before do
redis.del(Streams::DnsProviders::Lab::REDIS_KEY)
StreamSession.where.not(stream_node_id: nil).update_all(stream_node_id: nil, status: "ended", ended_at: Time.current)
StreamNode.where.not(slug: Streams::NodeRegistry::HOME_SLUG).delete_all
end
it "provisions and decommissions a simulated lab node" do
with_env(
"STREAM_CLOUD_PROVIDER" => "local_lab",
"STREAM_DNS_PROVIDER" => "lab",
"STREAM_LAB_DNS_SUFFIX" => "lab.mltv-stream.net",
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
"MEDIAMTX_RTMP_URL" => "rtmp://ingest-home.example:1935",
"HLS_PUBLIC_URL" => "https://ingest-home.example/hls",
"STREAM_LAB_MAX_PUBLISHERS" => "2"
) do
provisioner = described_class.new
node = provisioner.provision_lab!
expect(node.slug).to eq("ingest-lab-01")
expect(node.role).to eq("lab")
expect(node.status).to eq("ready")
expect(node.hostname).to eq("ingest-lab-01.lab.mltv-stream.net")
expect(node.api_base_url).to eq("http://mtx-home:9997")
expect(Streams::DnsProviders::Lab.new.resolve(node.hostname)).to eq("127.0.0.1")
provisioner.decommission!(node)
expect(StreamNode.find_by(slug: "ingest-lab-01")).to be_nil
expect(Streams::DnsProviders::Lab.new.resolve("ingest-lab-01.lab.mltv-stream.net")).to be_nil
end
end
it "refuses to decommission a busy node" do
with_env(
"STREAM_CLOUD_PROVIDER" => "local_lab",
"STREAM_DNS_PROVIDER" => "lab",
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
"MEDIAMTX_RTMP_URL" => "rtmp://ingest-home.example:1935",
"HLS_PUBLIC_URL" => "https://ingest-home.example/hls"
) do
node = described_class.new.provision_lab!
user = User.create!(email: "lab@example.com", name: "L", password: "Password123", role: "coach")
club = Club.create!(name: "LabClub", sport: "volleyball")
team = club.teams.create!(name: "T", sport: "volleyball", slug: "lab-t")
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live", stream_node: node)
expect { described_class.new.decommission!(node) }.to raise_error(Streams::NodeProvisioner::BusyError)
end
end
end
@@ -0,0 +1,133 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Streams::NodeRegistry do
def with_env(vars)
previous = vars.keys.index_with { |k| ENV[k] }
vars.each { |k, v| ENV[k] = v }
yield
ensure
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
end
let(:home_env) do
{
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
"MEDIAMTX_RTMP_URL" => "rtmp://ingest-home.example:1935",
"HLS_PUBLIC_URL" => "https://ingest-home.example/hls",
"MEDIAMTX_INTERNAL_RTMP_URL" => "rtmp://mtx-home:1935",
"MEDIAMTX_HLS_URL" => "http://mtx-home:8888",
"STREAM_NODE_HOME_MAX_PUBLISHERS" => "2"
}
end
before do
StreamSession.where.not(stream_node_id: nil).update_all(stream_node_id: nil, status: "ended", ended_at: Time.current)
StreamNode.where.not(slug: Streams::NodeRegistry::HOME_SLUG).delete_all
StreamNode.find_by(slug: Streams::NodeRegistry::HOME_SLUG)&.update!(max_publishers: 2, status: "ready")
end
it "creates home node from ENV" do
with_env(home_env) do
node = described_class.ensure_home_from_env!
expect(node.slug).to eq("home")
expect(node.rtmp_base_url).to eq("rtmp://ingest-home.example:1935")
expect(node.api_base_url).to eq("http://mtx-home:9997")
expect(node.max_publishers).to eq(2)
expect(node.status).to eq("ready")
end
end
it "does not reset home drain/offline/error status on ensure" do
with_env(home_env) do
home = described_class.ensure_home_from_env!
home.update!(status: "draining")
expect(described_class.ensure_home_from_env!.status).to eq("draining")
expect { described_class.allocate! }.to raise_error(Streams::NodeRegistry::NoCapacityError)
end
end
it "prefers home while it still has free slots even if cloud is idle" do
with_env(home_env) do
home = described_class.ensure_home_from_env!
StreamNode.create!(
slug: "ingest-01",
hostname: "ingest-01.mltv-stream.net",
role: "cloud",
status: "ready",
provider: "hetzner",
rtmp_base_url: "rtmp://ingest-01.mltv-stream.net:1935",
hls_base_url: "https://ingest-01.mltv-stream.net/hls",
api_base_url: "http://10.0.0.2:9997",
max_publishers: 2,
max_relays: 2
)
expect(described_class.allocate!).to eq(home)
end
end
it "allocates the least loaded cloud node when home is full" do
with_env(home_env) do
home = described_class.ensure_home_from_env!
cloud = StreamNode.create!(
slug: "ingest-01",
hostname: "ingest-01.mltv-stream.net",
role: "cloud",
status: "ready",
provider: "hetzner",
rtmp_base_url: "rtmp://ingest-01.mltv-stream.net:1935",
hls_base_url: "https://ingest-01.mltv-stream.net/hls",
api_base_url: "http://10.0.0.2:9997",
max_publishers: 2,
max_relays: 2
)
cloud_busy = StreamNode.create!(
slug: "ingest-02",
hostname: "ingest-02.mltv-stream.net",
role: "cloud",
status: "ready",
provider: "hetzner",
rtmp_base_url: "rtmp://ingest-02.mltv-stream.net:1935",
hls_base_url: "https://ingest-02.mltv-stream.net/hls",
api_base_url: "http://10.0.0.3:9997",
max_publishers: 2,
max_relays: 2
)
user = User.create!(email: "n@example.com", name: "N", password: "Password123", role: "coach")
club = Club.create!(name: "C", sport: "volleyball")
team = club.teams.create!(name: "T", sport: "volleyball", slug: "t-node")
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
match2 = team.matches.create!(opponent_name: "Y", scheduled_at: 2.hours.from_now)
# Riempie home (max 2)
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home)
StreamSession.create!(match: match2, user: user, platform: "matchlivetv", status: "live", stream_node: home)
# Un publisher già su ingest-02 → least-loaded = ingest-01
StreamSession.create!(
match: team.matches.create!(opponent_name: "Z", scheduled_at: 3.hours.from_now),
user: user, platform: "matchlivetv", status: "live", stream_node: cloud_busy
)
expect(described_class.allocate!).to eq(cloud)
end
end
it "raises when no capacity remains" do
with_env(home_env.merge("STREAM_NODE_HOME_MAX_PUBLISHERS" => "1")) do
home = described_class.ensure_home_from_env!
user = User.create!(email: "full@example.com", name: "F", password: "Password123", role: "coach")
club = Club.create!(name: "Full", sport: "volleyball")
team = club.teams.create!(name: "T", sport: "volleyball", slug: "t-full")
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
StreamSession.create!(
match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home
)
expect { described_class.allocate! }.to raise_error(Streams::NodeRegistry::NoCapacityError)
end
end
end
@@ -21,4 +21,46 @@ RSpec.describe Streams::YoutubeRelay do
expect(cmd).not_to include("-b:a")
end
end
describe "multi-host sticky stop/capacity" do
let(:redis) { Redis.new(url: ENV.fetch("REDIS_URL", "redis://redis:6379/0")) }
let(:session_id) { SecureRandom.uuid }
before do
allow(described_class).to receive(:worker?).and_return(true)
allow(described_class).to receive(:worker_id).and_return("worker-a")
allow(described_class).to receive(:max_concurrent).and_return(1)
redis.flushdb
end
def session_double
instance_double(
StreamSession,
id: session_id,
platform: "youtube",
terminal?: false,
stream_key: "yt-key",
status: "live"
)
end
it "does not stop ffmpeg owned by another worker" do
redis.set(format(Streams::YoutubeRelay::OWNER_KEY, session_id), "worker-b", ex: 3600)
redis.set(format(Streams::YoutubeRelay::REDIS_KEY, session_id), "12345", ex: 3600)
expect(described_class.stop_on_worker!(session_double)).to eq(:wrong_host)
expect(redis.get(format(Streams::YoutubeRelay::OWNER_KEY, session_id))).to eq("worker-b")
end
it "requeues ensure when at capacity" do
other_id = SecureRandom.uuid
redis.sadd(format(Streams::YoutubeRelay::OWNED_SET, "worker-a"), other_id)
allow(described_class).to receive(:intake_available?).and_return(true)
expect(YoutubeRelayEnsureJob).to receive(:set).with(hash_including(wait: 5.seconds, queue: :youtube_relay)).and_return(
double(perform_later: true)
)
expect(described_class.ensure_on_worker!(session_double)).to eq(:at_capacity)
end
end
end
+5 -3
View File
@@ -2,8 +2,8 @@
Documento di riferimento per l'intero sistema: rete, container Docker, flussi video, replay, monitoraggio ops e rilasci.
**Ultimo aggiornamento:** 2026-07-29
**Produzione:** `eminux@192.168.1.146``/opt/matchlivetv`
**Ultimo aggiornamento:** 2026-08-09
**Produzione:** `eminux@192.168.1.146``/opt/matchlivetv` (Proxmox; control plane attuale per autoscale)
---
@@ -397,7 +397,8 @@ cd infra && cp .env.example .env && docker compose up -d --build
| Documento | Contenuto |
|-----------|-----------|
| [`infrastructure/SERVER_DEPLOYMENT.md`](infrastructure/SERVER_DEPLOYMENT.md) | Bootstrap server, NPM, cron, backup |
| [`infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md`](infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md) | Piano overflow relay YouTube su Hetzner Cloud (picchi) |
| [`infrastructure/STREAMING_AUTOSCALE.md`](infrastructure/STREAMING_AUTOSCALE.md) | **Design attuale:** Proxmox prod + Hetzner Cloud warm spare (MediaMTX+ffmpeg), lab, DNS, disco; Auction in futuro |
| [`infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md`](infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md) | Piano storico overflow-only relay YouTube (superseded dal doc sopra) |
| [`ANDROID_APP_LINKS.md`](ANDROID_APP_LINKS.md) | Digital Asset Links / deep link Play (`assetlinks.json`) |
| [`LIVE_STREAMING.md`](LIVE_STREAMING.md) | MediaMTX, pausa, HLS, ruolo ffmpeg |
| [`REPLAY_MODULE.md`](REPLAY_MODULE.md) | Garage, retention, YouTube VOD |
@@ -420,3 +421,4 @@ cd infra && cp .env.example .env && docker compose up -d --build
| 2026-06 | Cleanup path MediaMTX orfani (`Mediamtx::CleanupOrphanPaths`); delete path sempre a fine diretta |
| 2026-07 | Rimosso overlay server (`OverlayRelay`); tabellone bruciato in app; HLS su path camera (non `*_air`); `YoutubeRelay` = copy video + AAC |
| 2026-08 | `YoutubeRelay` = remux copy video+audio (niente ricodifica AAC); profilo app/slate AAC 48k mono |
| 2026-08 | Design autoscale: Proxmox prod + Hetzner Cloud warm spare (MediaMTX+ffmpeg), lab locale, secondo dominio DNS, Garage ora / B2 dopo; Auction rimandato (`STREAMING_AUTOSCALE.md`) |
+2 -3
View File
@@ -4,14 +4,13 @@
| | Free | Premium Light | Premium Full |
|---|------|---------------|----------------|
| Account trasmettitori | 1 | 5 | Illimitati |
| Responsabili trasmissione / anno | 1 | 5 | Illimitato |
| Partite in contemporanea | 1 | 3 | 10 |
| Live Match Live TV | Sì | Sì | Sì |
| YouTube | No | Match Live TV | Canale società |
| Replay server | No | 30 giorni | 90 giorni |
| Download telefono | No | Sì | Sì |
| Prezzo lancio | €0 | €59/anno o €7,90/mese | €199/anno o €24,90/mese |
| Listino | — | €79/anno | €249/anno |
| Prezzo | €0 | €40/anno o €5/mese | €200/anno o €20/mese |
| Embed sito società | No | No | In arrivo |
Il punteggio in diretta si gestisce tramite **link regia** condivisibile (WhatsApp, email, SMS…): non serve un account per chi fa il tabellone.

Some files were not shown because too many files have changed in this diff Show More