Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46244fe066 | ||
|
|
3ed70b6999 | ||
|
|
b05b1acd7a | ||
|
|
c777853c97 | ||
|
|
9225318e1a | ||
|
|
061b7fad8b | ||
|
|
be7957c078 | ||
|
|
91667e1bfd | ||
|
|
f92ab3a813 | ||
|
|
1fecccaafd | ||
|
|
d100c8030f | ||
|
|
bc2257efd2 | ||
|
|
f1906517a9 | ||
|
|
00fde25c50 | ||
|
|
4c5af99efa | ||
|
|
8bf12e7721 | ||
|
|
5a0ca8ef1c | ||
|
|
714602f3da | ||
|
|
baac3a512b | ||
|
|
559284f0b2 | ||
|
|
e5fc925bae | ||
|
|
318a319608 | ||
|
|
0b55d5a8fa | ||
|
|
c3878cdc6d | ||
|
|
4af4fa68ac | ||
|
|
45bdda7c95 | ||
|
|
b926531447 | ||
|
|
da6fc1d523 | ||
|
|
1d1cbf9f3f | ||
|
|
dd9901519a | ||
|
|
53449c5d3c | ||
|
|
db908e3109 | ||
|
|
5857f60d79 |
@@ -55,6 +55,12 @@ class SessionChannel < ApplicationCable::Channel
|
||||
Sessions::Pause.new(session).call
|
||||
when "resume_stream"
|
||||
Sessions::Resume.new(session).call
|
||||
when "mute_audio"
|
||||
Sessions::SetAudioMute.new(session, muted: true).call
|
||||
when "unmute_audio"
|
||||
Sessions::SetAudioMute.new(session, muted: false).call
|
||||
when "set_min_quality"
|
||||
Sessions::SetMinQuality.new(session, preset: data["min_quality_preset"]).call
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -9,13 +9,23 @@ module Admin
|
||||
return render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if params[:password].blank? || params[:password].length < 8
|
||||
flash.now[:alert] = t("admin.flash.password_too_short")
|
||||
if params[:password] != params[:password_confirmation]
|
||||
flash.now[:alert] = t("admin.flash.password_mismatch")
|
||||
return render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if params[:password] != params[:password_confirmation]
|
||||
flash.now[:alert] = t("admin.flash.password_mismatch")
|
||||
if (code = PasswordComplexity.violation(params[:password]))
|
||||
key = case code
|
||||
when :blank, :too_short then :password_too_short
|
||||
when :too_long then :password_too_long
|
||||
else :password_too_weak
|
||||
end
|
||||
flash.now[:alert] = t("admin.flash.#{key}")
|
||||
return render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if PasswordComplexity.same_as_current?(current_admin_account, params[:password])
|
||||
flash.now[:alert] = t("admin.flash.password_same_as_current")
|
||||
return render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class StreamNodesController < Admin::BaseController
|
||||
def index
|
||||
Streams::NodeRegistry.ensure_home_from_env!
|
||||
@nodes = StreamNode.order(:role, :slug)
|
||||
@dns_provider = ENV.fetch("STREAM_DNS_PROVIDER", "lab")
|
||||
@cloud_provider = ENV.fetch("STREAM_CLOUD_PROVIDER", "local_lab")
|
||||
@lab_hosts = lab_dns_snippet
|
||||
@hetzner_configured = ENV["HCLOUD_TOKEN"].present?
|
||||
@autoscale_metrics = Streams::Autoscaler.metrics
|
||||
end
|
||||
|
||||
def create
|
||||
kind = params[:kind].to_s
|
||||
node =
|
||||
if kind == "cloud"
|
||||
Streams::NodeProvisioner.new.provision_cloud!
|
||||
else
|
||||
Streams::NodeProvisioner.new.provision_lab!
|
||||
end
|
||||
redirect_to admin_stream_nodes_path,
|
||||
notice: t("admin.flash.stream_node_created", slug: node.slug)
|
||||
rescue Streams::NodeProvisioner::Error, Streams::CloudProviders::Error, Streams::DnsProviders::Error,
|
||||
KeyError => e
|
||||
redirect_to admin_stream_nodes_path, alert: e.message
|
||||
end
|
||||
|
||||
def drain
|
||||
node = StreamNode.find(params[:id])
|
||||
Streams::NodeProvisioner.new.drain!(node)
|
||||
redirect_to admin_stream_nodes_path, notice: t("admin.flash.stream_node_draining", slug: node.slug)
|
||||
rescue Streams::NodeProvisioner::Error => e
|
||||
redirect_to admin_stream_nodes_path, alert: e.message
|
||||
end
|
||||
|
||||
def destroy
|
||||
node = StreamNode.find(params[:id])
|
||||
Streams::NodeProvisioner.new.decommission!(node)
|
||||
redirect_to admin_stream_nodes_path, notice: t("admin.flash.stream_node_destroyed", slug: node.slug)
|
||||
rescue Streams::NodeProvisioner::BusyError, Streams::NodeProvisioner::Error,
|
||||
Streams::CloudProviders::Error, Streams::DnsProviders::Error => e
|
||||
redirect_to admin_stream_nodes_path, alert: e.message
|
||||
end
|
||||
|
||||
def kill_switch
|
||||
Streams::Autoscaler.engage_kill_switch!
|
||||
redirect_to admin_stream_nodes_path, notice: t("admin.flash.autoscale_kill_on")
|
||||
end
|
||||
|
||||
def clear_kill_switch
|
||||
Streams::Autoscaler.clear_kill_switch!
|
||||
redirect_to admin_stream_nodes_path, notice: t("admin.flash.autoscale_kill_off")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def lab_dns_snippet
|
||||
return "" unless @dns_provider == "lab"
|
||||
|
||||
Streams::DnsProviders::Lab.new.hosts_file_snippet
|
||||
rescue Redis::BaseError
|
||||
""
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -8,7 +8,7 @@ module Api
|
||||
def update
|
||||
name = params[:name].to_s.strip
|
||||
if name.blank?
|
||||
return render json: { error: "Name is required" }, status: :unprocessable_entity
|
||||
return render json: { error: I18n.t("flash.accounts.name_required") }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if current_user.update(name: name)
|
||||
@@ -30,7 +30,7 @@ module Api
|
||||
return render json: { error: password_error_message(result.error) }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
render json: { message: "Password updated" }
|
||||
render json: { message: I18n.t("flash.accounts.password_updated") }
|
||||
end
|
||||
|
||||
private
|
||||
@@ -46,10 +46,13 @@ module Api
|
||||
|
||||
def password_error_message(code)
|
||||
case code
|
||||
when :current_incorrect then "Current password is incorrect"
|
||||
when :too_short then "Password must be at least 8 characters"
|
||||
when :mismatch then "Passwords do not match"
|
||||
else "Unable to update password"
|
||||
when :current_incorrect then I18n.t("flash.accounts.password_current_incorrect")
|
||||
when :too_short then I18n.t("flash.accounts.password_too_short")
|
||||
when :too_long then I18n.t("flash.accounts.password_too_long")
|
||||
when :too_weak then I18n.t("flash.accounts.password_too_weak")
|
||||
when :same_as_current then I18n.t("flash.accounts.password_same_as_current")
|
||||
when :mismatch then I18n.t("flash.accounts.password_mismatch")
|
||||
else I18n.t("flash.accounts.password_update_failed")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,18 +22,18 @@ module Api
|
||||
if user&.authenticate(params[:password])
|
||||
render json: token_response(user), status: :ok
|
||||
else
|
||||
render json: { error: "Invalid credentials" }, status: :unauthorized
|
||||
render json: { error: I18n.t("flash.sessions.invalid_credentials") }, status: :unauthorized
|
||||
end
|
||||
end
|
||||
|
||||
def logout
|
||||
render json: { message: "Logged out" }
|
||||
render json: { message: I18n.t("flash.sessions.logged_out") }
|
||||
end
|
||||
|
||||
def refresh
|
||||
payload = JsonWebToken.decode(params[:refresh_token] || bearer_token)
|
||||
user = User.find_by(id: payload&.dig(:user_id))
|
||||
return render json: { error: "Invalid token" }, status: :unauthorized unless user
|
||||
return render json: { error: I18n.t("flash.sessions.invalid_token") }, status: :unauthorized unless user
|
||||
|
||||
render json: token_response(user)
|
||||
end
|
||||
@@ -45,7 +45,7 @@ module Api
|
||||
def forgot_password
|
||||
Users::RequestPasswordReset.call(email: params[:email])
|
||||
render json: {
|
||||
message: "If the email is registered, you will receive a password reset link shortly."
|
||||
message: I18n.t("flash.password_resets.email_sent")
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -34,6 +34,12 @@ module Api
|
||||
render json: session_json(@session)
|
||||
end
|
||||
|
||||
def audio_mute
|
||||
muted = params.key?(:muted) ? params[:muted] : true
|
||||
Sessions::SetAudioMute.new(@session, muted: muted).call
|
||||
render json: session_json(@session)
|
||||
end
|
||||
|
||||
def score
|
||||
Scoring::SyncState.new(session: @session, payload: score_sync_params).call
|
||||
render json: session_json(@session, detail: true)
|
||||
@@ -68,6 +74,7 @@ module Api
|
||||
current_bitrate: params[:current_bitrate],
|
||||
target_bitrate: params[:target_bitrate],
|
||||
fps: params[:fps],
|
||||
thermal_state: sanitized_thermal_state,
|
||||
last_seen_at: Time.current
|
||||
)
|
||||
sync_publisher_when_streaming!(params[:fps].to_f)
|
||||
@@ -141,6 +148,13 @@ module Api
|
||||
}
|
||||
end
|
||||
|
||||
def min_quality
|
||||
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
|
||||
render json: session_json(@session)
|
||||
rescue ArgumentError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def youtube_stats
|
||||
count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present?
|
||||
Youtube::BroadcastService.new(@session.match.team)
|
||||
@@ -160,6 +174,11 @@ module Api
|
||||
.find(params[:id])
|
||||
end
|
||||
|
||||
def sanitized_thermal_state
|
||||
value = params[:thermal_state].to_s.downcase.presence
|
||||
value if value.in?(%w[nominal fair serious critical])
|
||||
end
|
||||
|
||||
def session_params
|
||||
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel)
|
||||
end
|
||||
@@ -180,6 +199,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,
|
||||
@@ -187,9 +207,11 @@ module Api
|
||||
youtube_ready: session.youtube_ready?,
|
||||
privacy_status: session.privacy_status,
|
||||
quality_preset: session.quality_preset,
|
||||
min_quality_preset: session.min_quality_preset,
|
||||
target_bitrate: session.target_bitrate,
|
||||
started_at: session.started_at,
|
||||
disconnection_count: session.disconnection_count
|
||||
disconnection_count: session.disconnection_count,
|
||||
audio_muted: session.audio_muted
|
||||
}
|
||||
if detail
|
||||
data[:score] = session.score_state&.as_cable_payload
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
class ApplicationController < ActionController::API
|
||||
include ActionController::HttpAuthentication::Token::ControllerMethods
|
||||
|
||||
before_action :set_api_locale
|
||||
before_action :authenticate_request!
|
||||
|
||||
attr_reader :current_user
|
||||
|
||||
private
|
||||
|
||||
def set_api_locale
|
||||
explicit = LocaleResolver.normalize(request.headers["X-Locale"])
|
||||
I18n.locale = explicit ||
|
||||
LocaleResolver.from_accept_language(request.headers["Accept-Language"]) ||
|
||||
I18n.default_locale
|
||||
end
|
||||
|
||||
def authenticate_request!
|
||||
token = bearer_token
|
||||
payload = JsonWebToken.decode(token)
|
||||
@current_user = User.find_by(id: payload[:user_id]) if payload
|
||||
render json: { error: "Unauthorized" }, status: :unauthorized unless @current_user
|
||||
render json: { error: I18n.t("flash.sessions.unauthorized") }, status: :unauthorized unless @current_user
|
||||
end
|
||||
|
||||
def bearer_token
|
||||
|
||||
@@ -29,8 +29,8 @@ module Public
|
||||
)
|
||||
|
||||
unless result.ok?
|
||||
flash.now[:alert] = t("flash.accounts.password_#{result.error}")
|
||||
return render :show, status: :unprocessable_entity
|
||||
redirect_to public_account_path, alert: t("flash.accounts.password_#{result.error}")
|
||||
return
|
||||
end
|
||||
|
||||
redirect_to public_account_path, notice: t("flash.accounts.password_updated")
|
||||
|
||||
@@ -11,17 +11,8 @@ module Public
|
||||
load_club_billing_context_for_pricing
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_club_billing_context_for_pricing
|
||||
return unless logged_in?
|
||||
|
||||
@club = current_user.primary_club
|
||||
return unless @club
|
||||
|
||||
@subscription = @club.subscription
|
||||
@team = @club.teams.order(:name).first
|
||||
@entitlements = @team&.entitlements
|
||||
def support
|
||||
@app_store_review_chrome = true
|
||||
end
|
||||
|
||||
def privacy
|
||||
@@ -38,5 +29,18 @@ module Public
|
||||
|
||||
def pallavolo
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_club_billing_context_for_pricing
|
||||
return unless logged_in?
|
||||
|
||||
@club = current_user.primary_club
|
||||
return unless @club
|
||||
|
||||
@subscription = @club.subscription
|
||||
@team = @club.teams.order(:name).first
|
||||
@entitlements = @team&.entitlements
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,7 +13,7 @@ module Public
|
||||
def edit
|
||||
@user = User.find_by_password_reset_token(params[:token])
|
||||
if @user.nil? || @user.password_reset_expired?
|
||||
redirect_to new_public_password_reset_path,
|
||||
redirect_to public_password_forgot_path,
|
||||
alert: t("flash.password_resets.invalid_or_expired_link")
|
||||
return
|
||||
end
|
||||
@@ -23,19 +23,25 @@ module Public
|
||||
def update
|
||||
@user = User.find_by_password_reset_token(params[:token])
|
||||
if @user.nil? || @user.password_reset_expired?
|
||||
redirect_to new_public_password_reset_path,
|
||||
redirect_to public_password_forgot_path,
|
||||
alert: t("flash.password_resets.invalid_or_expired_link")
|
||||
return
|
||||
end
|
||||
|
||||
if params[:password].blank? || params[:password].length < 8
|
||||
flash.now[:alert] = t("flash.password_resets.password_min_length")
|
||||
if params[:password] != params[:password_confirmation]
|
||||
flash.now[:alert] = t("flash.password_resets.password_mismatch")
|
||||
@token = params[:token]
|
||||
return render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if params[:password] != params[:password_confirmation]
|
||||
flash.now[:alert] = t("flash.password_resets.password_mismatch")
|
||||
if (code = PasswordComplexity.violation(params[:password]))
|
||||
flash.now[:alert] = t("flash.password_resets.password_#{code == :blank ? :too_short : code}")
|
||||
@token = params[:token]
|
||||
return render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if PasswordComplexity.same_as_current?(@user, params[:password])
|
||||
flash.now[:alert] = t("flash.password_resets.password_same_as_current")
|
||||
@token = params[:token]
|
||||
return render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
@@ -7,7 +7,7 @@ module Public
|
||||
layout "regia"
|
||||
|
||||
protect_from_forgery with: :null_session
|
||||
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop]
|
||||
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop audio_mute min_quality]
|
||||
|
||||
before_action :set_session_from_token
|
||||
|
||||
@@ -61,8 +61,26 @@ module Public
|
||||
render json: status_payload
|
||||
end
|
||||
|
||||
def audio_mute
|
||||
muted = params.key?(:muted) ? params[:muted] : !@session.audio_muted
|
||||
Sessions::SetAudioMute.new(@session, muted: muted).call
|
||||
render json: status_payload
|
||||
end
|
||||
|
||||
def min_quality
|
||||
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
|
||||
render json: status_payload
|
||||
rescue ArgumentError => e
|
||||
render json: status_payload.merge(error: e.message), status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def camera_telemetry_payload(publisher_online)
|
||||
cam = @session.device_states.find_by(device_role: "camera")
|
||||
(cam&.as_regia_payload || {}).merge(publisher_online: publisher_online)
|
||||
end
|
||||
|
||||
def set_session_from_token
|
||||
@session = Sessions::RegiaAccess.find_session_by_token(params[:token])
|
||||
return if @session
|
||||
@@ -78,6 +96,7 @@ module Public
|
||||
{
|
||||
status: @session.status,
|
||||
paused: @session.paused?,
|
||||
audio_muted: @session.audio_muted,
|
||||
stream_closed: closed,
|
||||
live: !closed && (@session.live? || @session.paused? || publisher_online),
|
||||
on_air: playable || (!closed && @session.status.in?(%w[connecting live reconnecting paused])),
|
||||
@@ -86,7 +105,10 @@ module Public
|
||||
home_name: @session.match.team.name,
|
||||
away_name: @session.match.opponent_name,
|
||||
points_target: Scoring::Rules.points_target_for_match(@session.match, current_set: score&.current_set || 1),
|
||||
score: score&.as_cable_payload
|
||||
score: score&.as_cable_payload,
|
||||
min_quality_preset: @session.min_quality_preset,
|
||||
min_quality_options: Sessions::SelectQuality.allowed_min_ids(@session.quality_preset),
|
||||
telemetry: camera_telemetry_payload(publisher_online)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,6 +11,7 @@ module Public
|
||||
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
|
||||
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
|
||||
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
|
||||
{ loc: "#{base}/support", changefreq: "yearly", priority: "0.3" },
|
||||
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
|
||||
{ loc: "#{base}/termini", changefreq: "yearly", priority: "0.3" }
|
||||
]
|
||||
|
||||
@@ -57,6 +57,10 @@ 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
|
||||
|
||||
@@ -44,9 +44,12 @@ module Public
|
||||
partialsPrefix: t("regia.board.partials_prefix"),
|
||||
resumed: t("regia.js.resumed"),
|
||||
pausedCover: t("regia.js.paused_cover"),
|
||||
muted: t("regia.js.muted"),
|
||||
unmuted: t("regia.js.unmuted"),
|
||||
closed: t("regia.js.closed"),
|
||||
resumeError: t("regia.js.resume_error"),
|
||||
pauseError: t("regia.js.pause_error"),
|
||||
muteError: t("regia.js.mute_error"),
|
||||
closeError: t("regia.js.close_error"),
|
||||
closeConfirm: t("regia.js.close_confirm"),
|
||||
linkUnavailable: t("regia.js.link_unavailable"),
|
||||
@@ -54,15 +57,36 @@ module Public
|
||||
copyPrompt: t("regia.js.copy_prompt"),
|
||||
shareRegiaText: t("regia.js.share_regia_text"),
|
||||
shareLiveText: t("regia.js.share_live_text"),
|
||||
minQualityUpdated: t("regia.js.min_quality_updated"),
|
||||
minQualityError: t("regia.js.min_quality_error"),
|
||||
minQualityAuto: t("regia.min_quality.auto"),
|
||||
previewWaiting: t("regia.preview_waiting"),
|
||||
streamEnded: t("regia.preview_ended"),
|
||||
previewHide: t("regia.preview_hide"),
|
||||
previewShow: t("regia.preview_show"),
|
||||
previewOffHint: t("regia.preview_off_hint"),
|
||||
resumeLabel: t("regia.resume"),
|
||||
pauseLabel: t("regia.pause"),
|
||||
muteLabel: t("regia.mute"),
|
||||
unmuteLabel: t("regia.unmute"),
|
||||
endedBadge: t("regia.status.ended"),
|
||||
pausedBadge: t("regia.status.paused"),
|
||||
liveBadge: t("regia.status.live"),
|
||||
waitingBadge: t("regia.status.waiting"),
|
||||
subtitlePrefix: t("regia.subtitle_prefix")
|
||||
subtitlePrefix: t("regia.subtitle_prefix"),
|
||||
telemetryWaiting: t("regia.telemetry.waiting"),
|
||||
telemetryStale: t("regia.telemetry.stale"),
|
||||
telemetryJustNow: t("regia.telemetry.just_now"),
|
||||
telemetrySecondsAgo: t("regia.telemetry.seconds_ago"),
|
||||
telemetryPublisherOn: t("regia.telemetry.publisher_on"),
|
||||
telemetryPublisherOff: t("regia.telemetry.publisher_off"),
|
||||
telemetryThermalNominal: t("regia.telemetry.thermal_nominal"),
|
||||
telemetryThermalFair: t("regia.telemetry.thermal_fair"),
|
||||
telemetryThermalSerious: t("regia.telemetry.thermal_serious"),
|
||||
telemetryThermalCritical: t("regia.telemetry.thermal_critical"),
|
||||
telemetryBitrateMax: t("regia.telemetry.bitrate_max"),
|
||||
telemetryBitrateTitle: t("regia.telemetry.bitrate_title"),
|
||||
telemetryBitrateTap: t("regia.telemetry.bitrate_tap")
|
||||
}.to_json
|
||||
end
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ class CleanupExpiredSessionsJob
|
||||
.where("updated_at < ?", 6.hours.ago)
|
||||
.find_each do |session|
|
||||
session.fail! if session.may_fail?
|
||||
Mediamtx::Client.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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,4 +1,6 @@
|
||||
class AdminAccount < ApplicationRecord
|
||||
include PasswordComplexity
|
||||
|
||||
has_secure_password
|
||||
|
||||
validates :username, presence: true, uniqueness: true
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Criteri "di mercato" (stile Cognito/Auth0 bilanciato):
|
||||
# - minimo 8 caratteri (max 72 per bcrypt)
|
||||
# - almeno 3 classi su 4: minuscole, maiuscole, numeri, simboli
|
||||
module PasswordComplexity
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
MIN_LENGTH = 8
|
||||
MAX_LENGTH = 72
|
||||
REQUIRED_CLASSES = 3
|
||||
|
||||
CLASS_CHECKS = {
|
||||
lowercase: /[a-z]/,
|
||||
uppercase: /[A-Z]/,
|
||||
digit: /\d/,
|
||||
symbol: /[^A-Za-z0-9]/
|
||||
}.freeze
|
||||
|
||||
class << self
|
||||
def violation(password)
|
||||
value = password.to_s
|
||||
return :blank if value.blank?
|
||||
return :too_short if value.length < MIN_LENGTH
|
||||
return :too_long if value.bytesize > MAX_LENGTH
|
||||
return :too_weak unless strong_enough?(value)
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def strong_enough?(password)
|
||||
matched = CLASS_CHECKS.count { |_, pattern| password.match?(pattern) }
|
||||
matched >= REQUIRED_CLASSES
|
||||
end
|
||||
|
||||
# Confronta con il digest già salvato (prima di assegnare la nuova password).
|
||||
def same_as_current?(record, password)
|
||||
return false if password.blank? || !record.respond_to?(:authenticate)
|
||||
|
||||
record.authenticate(password).present?
|
||||
end
|
||||
|
||||
def requirement_summary
|
||||
I18n.t("password_policy.hint")
|
||||
end
|
||||
end
|
||||
|
||||
included do
|
||||
validate :password_meets_complexity_policy, if: -> { password.present? }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def password_meets_complexity_policy
|
||||
case PasswordComplexity.violation(password)
|
||||
when :too_short
|
||||
errors.add(:password, :too_short, count: MIN_LENGTH)
|
||||
when :too_long
|
||||
errors.add(:password, :too_long, count: MAX_LENGTH)
|
||||
when :too_weak
|
||||
errors.add(:password, :complexity)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -16,4 +16,17 @@ class DeviceState < ApplicationRecord
|
||||
status: stream_session.status
|
||||
}
|
||||
end
|
||||
|
||||
def as_regia_payload
|
||||
{
|
||||
battery: battery_level,
|
||||
network: network_type,
|
||||
signal: signal_strength,
|
||||
bitrate: current_bitrate,
|
||||
target_bitrate: target_bitrate,
|
||||
fps: fps,
|
||||
thermal_state: thermal_state,
|
||||
last_seen_at: last_seen_at&.iso8601
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,7 +4,7 @@ module Ops
|
||||
|
||||
KINDS = %w[
|
||||
disk_space recordings_size service_down http_public http_rails rails_latency
|
||||
sidekiq_stale sidekiq_dead log_pattern garage_storage
|
||||
sidekiq_stale sidekiq_dead log_pattern garage_storage stream_overflow
|
||||
].freeze
|
||||
SEVERITIES = %w[critical warning info].freeze
|
||||
STATUSES = %w[open acknowledged resolved].freeze
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class StreamEvent < ApplicationRecord
|
||||
EVENT_TYPES = %w[
|
||||
connected disconnected reconnected quality_changed error paused resumed
|
||||
started ended network_test pairing youtube_ready
|
||||
started ended network_test pairing youtube_ready audio_muted audio_unmuted
|
||||
].freeze
|
||||
|
||||
belongs_to :stream_session
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Nodo streaming (MediaMTX [+ relay ffmpeg]). Fase 0: registry + assignment URL.
|
||||
class StreamNode < ApplicationRecord
|
||||
ROLES = %w[home cloud lab].freeze
|
||||
STATUSES = %w[provisioning ready draining offline error].freeze
|
||||
PROVIDERS = %w[local proxmox_lab hetzner].freeze
|
||||
|
||||
has_many :stream_sessions, dependent: :nullify
|
||||
|
||||
validates :slug, presence: true, uniqueness: true
|
||||
validates :hostname, presence: true
|
||||
validates :role, inclusion: { in: ROLES }
|
||||
validates :status, inclusion: { in: STATUSES }
|
||||
validates :provider, inclusion: { in: PROVIDERS }
|
||||
validates :rtmp_base_url, :hls_base_url, :api_base_url, presence: true
|
||||
validates :max_publishers, :max_relays, numericality: { greater_than: 0 }
|
||||
|
||||
scope :ready, -> { where(status: "ready") }
|
||||
scope :allocatable, -> { ready }
|
||||
|
||||
# Sessioni che occupano uno slot path MediaMTX su questo nodo.
|
||||
def occupying_sessions
|
||||
stream_sessions.where(status: %w[idle connecting live reconnecting paused])
|
||||
end
|
||||
|
||||
def active_publishers
|
||||
occupying_sessions.count
|
||||
end
|
||||
|
||||
def free_slots
|
||||
[max_publishers - active_publishers, 0].max
|
||||
end
|
||||
|
||||
def allocatable?
|
||||
status == "ready" && free_slots.positive?
|
||||
end
|
||||
end
|
||||
@@ -7,6 +7,7 @@ class StreamSession < ApplicationRecord
|
||||
|
||||
belongs_to :match
|
||||
belongs_to :user
|
||||
belongs_to :stream_node, optional: true
|
||||
has_many :stream_events, dependent: :destroy
|
||||
has_one :score_state, dependent: :destroy
|
||||
has_many :device_states, dependent: :destroy
|
||||
@@ -14,6 +15,7 @@ class StreamSession < ApplicationRecord
|
||||
validates :platform, inclusion: { in: PLATFORMS }
|
||||
validates :status, inclusion: { in: STATUSES }
|
||||
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
|
||||
validates :min_quality_preset, inclusion: { in: Sessions::SelectQuality::MIN_QUALITY_IDS }
|
||||
|
||||
before_validation :normalize_privacy_status
|
||||
before_validation :ensure_publish_token, on: :create
|
||||
@@ -74,7 +76,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 +92,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
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class User < ApplicationRecord
|
||||
include PasswordComplexity
|
||||
|
||||
ROLES = %w[admin coach parent volunteer].freeze
|
||||
|
||||
has_secure_password
|
||||
|
||||
@@ -5,9 +5,13 @@ 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],
|
||||
20_000 => %w[premium_full yearly]
|
||||
2490 => %w[premium_full monthly],
|
||||
20_000 => %w[premium_full yearly],
|
||||
19_900 => %w[premium_full yearly]
|
||||
}.freeze
|
||||
|
||||
class << self
|
||||
|
||||
@@ -23,10 +23,11 @@ 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: plan.name, price: price)
|
||||
I18n.t("billing.messages.activate_button", plan: name, price: price)
|
||||
else
|
||||
I18n.t("billing.messages.switch_button", plan: plan.name, price: price)
|
||||
I18n.t("billing.messages.switch_button", plan: name, price: price)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -4,9 +4,16 @@ module Billing
|
||||
INTERVALS = %w[monthly yearly].freeze
|
||||
DEFAULT_INTERVAL = "yearly"
|
||||
|
||||
PRICING = {
|
||||
"premium_light" => { "yearly" => "€40/anno", "monthly" => "€5/mese" },
|
||||
"premium_full" => { "yearly" => "€200/anno", "monthly" => "€20/mese" }
|
||||
# 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 }
|
||||
}
|
||||
}.freeze
|
||||
|
||||
class << self
|
||||
@@ -30,7 +37,23 @@ module Billing
|
||||
end
|
||||
|
||||
def label(plan_slug:, interval:)
|
||||
PRICING.dig(plan_slug.to_s, normalize_interval(interval)) || normalize_interval(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")
|
||||
end
|
||||
|
||||
def available_intervals(plan_slug:)
|
||||
@@ -52,8 +75,33 @@ 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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,11 +9,12 @@ 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)
|
||||
mtx = Mediamtx::Client.for_session(@session)
|
||||
mtx.set_path_recording(@session, enabled: false)
|
||||
mtx.set_always_available(@session, enabled: true)
|
||||
rescue Mediamtx::Client::Error => e
|
||||
Rails.logger.warn("[Sessions::Pause] disable recording: #{e.message}")
|
||||
Rails.logger.warn("[Sessions::Pause] MediaMTX: #{e.message}")
|
||||
end
|
||||
Mediamtx::Client.new.set_always_available(@session, enabled: true)
|
||||
# Slate su path per HLS in pausa; RTMP telefono si ferma via comando app.
|
||||
log_event("paused")
|
||||
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
|
||||
|
||||
@@ -3,6 +3,8 @@ module Sessions
|
||||
class SelectQuality
|
||||
UPLOAD_HEADROOM = 0.8
|
||||
UPLOAD_HEADROOM_1080P = 1.0
|
||||
AUTO_ID = "auto"
|
||||
AUTO_FLOOR_BITRATE = 800_000
|
||||
|
||||
PRESETS = [
|
||||
{ id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 },
|
||||
@@ -11,6 +13,32 @@ module Sessions
|
||||
{ id: "720p_30_1.5mbps", target_bitrate: 1_500_000, target_fps: 30 }
|
||||
].freeze
|
||||
|
||||
MIN_QUALITY_IDS = ([AUTO_ID] + PRESETS.map { |preset| preset[:id] }).freeze
|
||||
|
||||
def self.floor_bitrate(preset_id)
|
||||
return AUTO_FLOOR_BITRATE if preset_id.blank? || preset_id == AUTO_ID
|
||||
|
||||
PRESETS.find { |preset| preset[:id] == preset_id }&.fetch(:target_bitrate) || AUTO_FLOOR_BITRATE
|
||||
end
|
||||
|
||||
def self.allowed_min_ids(session_preset)
|
||||
ids = [AUTO_ID] + PRESETS.reject { |preset| preset[:id].start_with?("1080p") }.reverse.map { |preset| preset[:id] }
|
||||
ids << "1080p_30_4.5mbps" if session_preset.to_s.start_with?("1080p")
|
||||
ids
|
||||
end
|
||||
|
||||
def self.display_name(preset_id)
|
||||
return AUTO_ID if preset_id.blank? || preset_id == AUTO_ID
|
||||
|
||||
preset = PRESETS.find { |item| item[:id] == preset_id }
|
||||
return preset_id unless preset
|
||||
|
||||
resolution = preset[:id].start_with?("1080p") ? "1080p" : "720p"
|
||||
mbps = preset[:target_bitrate] / 1_000_000.0
|
||||
mbps_text = (mbps == mbps.to_i) ? mbps.to_i.to_s : format("%.1f", mbps)
|
||||
"#{resolution} · #{mbps_text} Mbps"
|
||||
end
|
||||
|
||||
def self.call(upload_mbps:)
|
||||
new(upload_mbps: upload_mbps).call
|
||||
end
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
module Sessions
|
||||
# Silenzia o riattiva il microfono della camera in onda.
|
||||
# Lo stream continua a inviare AAC silenzioso (YouTube/MediaMTX richiedono la traccia audio).
|
||||
class SetAudioMute
|
||||
def initialize(session, muted:)
|
||||
@session = session
|
||||
@muted = ActiveModel::Type::Boolean.new.cast(muted)
|
||||
end
|
||||
|
||||
def call
|
||||
return @session if @session.audio_muted == @muted
|
||||
|
||||
@session.update!(audio_muted: @muted)
|
||||
action = @muted ? "mute_audio" : "unmute_audio"
|
||||
event_type = @muted ? "audio_muted" : "audio_unmuted"
|
||||
log_event(event_type)
|
||||
SessionChannel.broadcast_message(@session, { type: "command", action: action, muted: @muted })
|
||||
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "audio_muted", muted: @muted })
|
||||
@session
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def log_event(type)
|
||||
@session.stream_events.create!(event_type: type, occurred_at: Time.current, metadata: { muted: @muted })
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Sessions
|
||||
class SetMinQuality
|
||||
def initialize(session, preset:)
|
||||
@session = session
|
||||
@preset = preset.to_s.strip
|
||||
end
|
||||
|
||||
def call
|
||||
id = normalize
|
||||
unless SelectQuality.allowed_min_ids(@session.quality_preset).include?(id)
|
||||
raise ArgumentError, "Qualità minima non valida"
|
||||
end
|
||||
|
||||
return @session if @session.min_quality_preset == id
|
||||
|
||||
@session.update!(min_quality_preset: id)
|
||||
@session.stream_events.create!(
|
||||
event_type: "quality_changed",
|
||||
occurred_at: Time.current,
|
||||
metadata: { "min_quality_preset" => id }
|
||||
)
|
||||
SessionChannel.broadcast_message(
|
||||
@session,
|
||||
{ type: "command", action: "set_min_quality", min_quality_preset: id }
|
||||
)
|
||||
@session
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize
|
||||
return SelectQuality::AUTO_ID if @preset.blank? || @preset == SelectQuality::AUTO_ID
|
||||
|
||||
@preset
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -33,7 +33,7 @@ module Sessions
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -23,15 +23,23 @@ module Users
|
||||
return Result.new(ok?: false, error: :current_incorrect)
|
||||
end
|
||||
|
||||
if @password.blank? || @password.length < 8
|
||||
return Result.new(ok?: false, error: :too_short)
|
||||
end
|
||||
|
||||
if @password != @password_confirmation
|
||||
return Result.new(ok?: false, error: :mismatch)
|
||||
end
|
||||
|
||||
@user.update!(password: @password)
|
||||
if (code = PasswordComplexity.violation(@password))
|
||||
return Result.new(ok?: false, error: code == :blank ? :too_short : code)
|
||||
end
|
||||
|
||||
if PasswordComplexity.same_as_current?(@user, @password)
|
||||
return Result.new(ok?: false, error: :same_as_current)
|
||||
end
|
||||
|
||||
unless @user.update(password: @password)
|
||||
complexity_error = @user.errors.details[:password]&.any? { |d| d[:error] == :complexity }
|
||||
return Result.new(ok?: false, error: complexity_error ? :too_weak : :too_short)
|
||||
end
|
||||
|
||||
@user.clear_password_reset!
|
||||
Result.new(ok?: true)
|
||||
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 %>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -8,17 +8,17 @@
|
||||
<%= 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=44">
|
||||
<link rel="stylesheet" href="/marketing.css?v=63">
|
||||
</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" %>
|
||||
<%= render "shared/marketing_nav" %>
|
||||
<%= render(@app_store_review_chrome ? "shared/marketing_nav_app_store" : "shared/marketing_nav") %>
|
||||
<main>
|
||||
<% if flash[:notice] %><div class="wrap"><div class="flash notice"><%= flash[:notice] %></div></div><% end %>
|
||||
<% if flash[:alert] %><div class="wrap"><div class="flash alert"><%= flash[:alert] %></div></div><% end %>
|
||||
<%= yield %>
|
||||
</main>
|
||||
<%= render "shared/marketing_footer" %>
|
||||
<%= render(@app_store_review_chrome ? "shared/marketing_footer_app_store" : "shared/marketing_footer") %>
|
||||
<script src="/branding-form.js?v=1" defer></script>
|
||||
<script src="/roster-form.js?v=1" defer></script>
|
||||
<script src="/password-toggle.js?v=2" defer></script>
|
||||
|
||||
@@ -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=44">
|
||||
<link rel="stylesheet" href="/marketing.css?v=63">
|
||||
<link rel="stylesheet" href="/live.css?v=26">
|
||||
<%= yield :head %>
|
||||
</head>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<meta name="theme-color" content="#0a0a0e">
|
||||
<title><%= content_for?(:title) ? yield(:title) : t("regia.meta_title_fallback") %></title>
|
||||
<%= render "shared/meta_tags" %>
|
||||
<link rel="stylesheet" href="/regia.css?v=1">
|
||||
<link rel="stylesheet" href="/regia.css?v=11">
|
||||
<%= yield :head %>
|
||||
</head>
|
||||
<body class="regia-body">
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
|
||||
<div class="card">
|
||||
<h2 style="font-size:1.1rem;margin-top:0"><%= t("auth.account.password_heading") %></h2>
|
||||
<p class="muted" style="font-size:0.9rem"><%= t("password_policy.hint") %></p>
|
||||
<%= form_with url: public_account_password_path, method: :patch, local: true do %>
|
||||
<%= render "shared/input_toggle",
|
||||
name: :current_password,
|
||||
|
||||
@@ -2,25 +2,27 @@
|
||||
<% content_for :meta_description, t("auth.invitation.meta_description") %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<div class="card" style="max-width:480px">
|
||||
<h1><%= raw t("auth.invitation.title_html", team_name: @invitation.team.name) %></h1>
|
||||
<p><%= raw t("auth.invitation.role_notice_html", email: @invitation.email) %></p>
|
||||
<p class="muted" style="font-size:0.9rem;margin-bottom:16px">
|
||||
<%= raw t("auth.invitation.instructions_html", email: @invitation.email) %>
|
||||
</p>
|
||||
<% if logged_in? %>
|
||||
<%= button_to t("auth.invitation.accept"), public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %>
|
||||
<% else %>
|
||||
<p><%= raw t(
|
||||
"auth.invitation.login_or_signup_html",
|
||||
login_link: link_to(t("auth.invitation.login_link"), public_login_path),
|
||||
signup_link: link_to(t("auth.invitation.signup_link"), public_signup_path),
|
||||
email: @invitation.email
|
||||
) %></p>
|
||||
<%= button_to t("auth.invitation.accept_if_logged_in"), public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %>
|
||||
<% end %>
|
||||
<p style="margin-top:16px;font-size:0.88rem;color:#888">
|
||||
<%= t("auth.invitation.mobile_app_label") %>
|
||||
<a href="matchlivetv://join/<%= @token %>"><%= t("auth.invitation.open_in_app") %></a>
|
||||
</p>
|
||||
</div>
|
||||
<section class="auth-page">
|
||||
<div class="card">
|
||||
<h1><%= raw t("auth.invitation.title_html", team_name: @invitation.team.name) %></h1>
|
||||
<p><%= raw t("auth.invitation.role_notice_html", email: @invitation.email) %></p>
|
||||
<p class="muted" style="font-size:0.9rem;margin-bottom:16px">
|
||||
<%= raw t("auth.invitation.instructions_html", email: @invitation.email) %>
|
||||
</p>
|
||||
<% if logged_in? %>
|
||||
<%= button_to t("auth.invitation.accept"), public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %>
|
||||
<% else %>
|
||||
<p><%= raw t(
|
||||
"auth.invitation.login_or_signup_html",
|
||||
login_link: link_to(t("auth.invitation.login_link"), public_login_path),
|
||||
signup_link: link_to(t("auth.invitation.signup_link"), public_signup_path),
|
||||
email: @invitation.email
|
||||
) %></p>
|
||||
<%= button_to t("auth.invitation.accept_if_logged_in"), public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %>
|
||||
<% end %>
|
||||
<p style="margin-top:16px;font-size:0.88rem;color:#888">
|
||||
<%= t("auth.invitation.mobile_app_label") %>
|
||||
<a href="matchlivetv://join/<%= @token %>"><%= t("auth.invitation.open_in_app") %></a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
|
||||
<h3><%= t("legal.cookies.s4_1_title") %></h3>
|
||||
<p><%= t("legal.cookies.s4_1_intro") %></p>
|
||||
<table class="legal-table">
|
||||
<div class="table-scroll">
|
||||
<table class="legal-table">
|
||||
<thead>
|
||||
<tr><th><%= t("legal.cookies.table_col_name") %></th><th><%= t("legal.cookies.table_col_purpose") %></th><th><%= t("legal.cookies.table_col_duration") %></th><th><%= t("legal.cookies.table_col_provider") %></th></tr>
|
||||
</thead>
|
||||
@@ -64,7 +65,8 @@
|
||||
<td><%= t("legal.cookies.s4_1_row2_provider") %></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3><%= t("legal.cookies.s4_2_title") %></h3>
|
||||
<p>
|
||||
@@ -75,7 +77,8 @@
|
||||
<% else %>
|
||||
<p class="muted"><%= t("legal.cookies.s4_2_inactive") %></p>
|
||||
<% end %>
|
||||
<table class="legal-table">
|
||||
<div class="table-scroll">
|
||||
<table class="legal-table">
|
||||
<thead>
|
||||
<tr><th><%= t("legal.cookies.table2_col_name") %></th><th><%= t("legal.cookies.table_col_purpose") %></th><th><%= t("legal.cookies.table_col_duration") %></th><th><%= t("legal.cookies.table_col_provider") %></th></tr>
|
||||
</thead>
|
||||
@@ -99,7 +102,8 @@
|
||||
<td><%= t("legal.cookies.s4_2_row3_provider") %></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
<p>
|
||||
<%= raw t(
|
||||
"legal.cookies.s4_2_p2_html",
|
||||
|
||||
@@ -2,63 +2,283 @@
|
||||
<% content_for :meta_description, t("pages.features.meta_description") %>
|
||||
<% content_for :canonical_url, seo_absolute_url(public_features_path) %>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<section class="section">
|
||||
<h2><%= t("pages.features.staff_section_title") %></h2>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<h3><%= t("pages.features.staff_invite_title") %></h3>
|
||||
<p><%= t("pages.features.staff_invite_body") %></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="feature-card">
|
||||
<h3><%= t("pages.features.staff_access_title") %></h3>
|
||||
<p><%= t("pages.features.staff_access_body") %></p>
|
||||
<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 class="feature-card">
|
||||
<h3><%= t("pages.features.staff_plans_title") %></h3>
|
||||
<p><%= t("pages.features.staff_plans_body") %></p>
|
||||
</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">
|
||||
<h2><%= t("pages.features.live_section_title") %></h2>
|
||||
<div class="features-grid">
|
||||
<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">
|
||||
<div class="feature-card">
|
||||
<h3><%= t("pages.features.live_phone_title") %></h3>
|
||||
<p><%= t("pages.features.live_phone_body") %></p>
|
||||
<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>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3><%= t("pages.features.live_score_title") %></h3>
|
||||
<p><%= t("pages.features.live_score_body") %></p>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p style="text-align:center;margin:40px 0">
|
||||
<%= link_to t("pages.features.cta_signup"), public_signup_path, class: "btn btn-primary" %>
|
||||
</p>
|
||||
</div>
|
||||
<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" %>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"url" => seo_absolute_url(root_path),
|
||||
"applicationCategory" => "SportsApplication",
|
||||
"operatingSystem" => "Web, Android, iOS",
|
||||
"description" => t("home.schema_description")
|
||||
"description" => t("home.schema_description"),
|
||||
"sameAs" => [MatchLiveTv.app_store_url, MatchLiveTv.play_store_url]
|
||||
}.to_json) %>
|
||||
</script>
|
||||
<% end %>
|
||||
@@ -26,6 +27,10 @@
|
||||
<%= 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,20 +22,63 @@
|
||||
|
||||
<%= render "shared/plan_cards" %>
|
||||
|
||||
<table class="compare-table">
|
||||
<thead>
|
||||
<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_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><%= raw t("pages.pricing.table_price_light_html") %></td><td><%= raw t("pages.pricing.table_price_full_html") %></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-scroll compare-table-wrap">
|
||||
<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>
|
||||
</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_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>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:32px;text-align:center">
|
||||
<h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3>
|
||||
|
||||
@@ -77,7 +77,8 @@
|
||||
|
||||
<section>
|
||||
<h2><%= t("legal.privacy.s5_title") %></h2>
|
||||
<table class="legal-table">
|
||||
<div class="table-scroll">
|
||||
<table class="legal-table">
|
||||
<thead>
|
||||
<tr><th><%= t("legal.privacy.s5_col_purpose") %></th><th><%= t("legal.privacy.s5_col_basis") %></th></tr>
|
||||
</thead>
|
||||
@@ -107,7 +108,8 @@
|
||||
<td><%= t("legal.privacy.s5_row6_basis") %></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<% content_for :title, t("legal.support.title") %>
|
||||
<% content_for :meta_description, t("legal.support.meta_description") %>
|
||||
<% content_for :canonical_url, seo_absolute_url(public_support_path) %>
|
||||
|
||||
<div class="wrap legal-doc">
|
||||
<h1><%= t("legal.support.h1") %></h1>
|
||||
<p><%= t("legal.support.intro") %></p>
|
||||
|
||||
<p>
|
||||
<%= t("legal.support.email_label") %>
|
||||
<a href="mailto:<%= MatchLiveTv.support_email %>"><%= MatchLiveTv.support_email %></a>
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2><%= t("legal.support.access_title") %></h2>
|
||||
<p><%= t("legal.support.access_body") %></p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2><%= t("legal.support.live_title") %></h2>
|
||||
<p><%= t("legal.support.live_body") %></p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2><%= t("legal.support.team_title") %></h2>
|
||||
<p><%= t("legal.support.team_body") %></p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2><%= t("legal.support.privacy_title") %></h2>
|
||||
<p>
|
||||
<%= raw t(
|
||||
"legal.support.privacy_body_html",
|
||||
privacy_link: link_to(t("legal.support.privacy_link_text"), public_privacy_path)
|
||||
) %>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
@@ -4,6 +4,7 @@
|
||||
<section class="auth-page">
|
||||
<h1><%= t("auth.password_reset.title") %></h1>
|
||||
<div class="card">
|
||||
<p class="muted" style="font-size:0.9rem"><%= t("password_policy.hint") %></p>
|
||||
<%= form_with url: public_password_reset_path, method: :patch, local: true do %>
|
||||
<%= hidden_field_tag :token, @token %>
|
||||
<%= render "shared/input_toggle",
|
||||
|
||||
@@ -21,4 +21,4 @@
|
||||
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px">−</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="regia-btn regia-btn--yellow" data-action="close_set" style="margin-top:12px"><%= t("regia.board.close_set") %></button>
|
||||
<button type="button" class="regia-btn regia-btn--yellow" data-action="close_set"><%= t("regia.board.close_set") %></button>
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
data-score-url="<%= j public_regia_score_path(params[:token]) %>"
|
||||
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
|
||||
data-resume-url="<%= j public_regia_resume_path(params[:token]) %>"
|
||||
data-audio-mute-url="<%= j public_regia_audio_mute_path(params[:token]) %>"
|
||||
data-stop-url="<%= j public_regia_stop_path(params[:token]) %>"
|
||||
data-min-quality-url="<%= j public_regia_min_quality_path(params[:token]) %>"
|
||||
data-min-quality="<%= j @session.min_quality_preset %>"
|
||||
data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>"
|
||||
data-hls-url="<%= j @session.hls_playback_url %>"
|
||||
data-home-name="<%= j @team.name %>"
|
||||
@@ -26,58 +29,126 @@
|
||||
data-live-share-title="<%= j @live_share_title %>"
|
||||
data-points-target="<%= @match.effective_board_type.in?(%w[volley racket]) ? Scoring::Rules.from_match(@match).points_target(@score.current_set) : 0 %>"
|
||||
data-stream-closed="<%= @stream_closed %>"
|
||||
data-initial-paused="<%= @session.paused? %>">
|
||||
data-initial-paused="<%= @session.paused? %>"
|
||||
data-initial-audio-muted="<%= @session.audio_muted %>">
|
||||
|
||||
<header class="regia-header">
|
||||
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
|
||||
<p id="regia-subtitle"><%= t("regia.subtitle_prefix") %> <%= regia_header_subtitle(@match, @score) %></p>
|
||||
<div class="regia-header__text">
|
||||
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
|
||||
<p id="regia-subtitle"><%= t("regia.subtitle_prefix") %> <%= regia_header_subtitle(@match, @score) %></p>
|
||||
</div>
|
||||
<span id="regia-badge" class="regia-badge <%= @stream_closed ? 'regia-badge--ended' : (@session.paused? ? 'regia-badge--wait' : (@on_air ? 'regia-badge--live' : 'regia-badge--wait')) %>">
|
||||
<%= @stream_closed ? t("regia.status.ended") : (@session.paused? ? t("regia.status.paused") : (@on_air ? t("regia.status.live") : t("regia.status.waiting"))) %>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<% if @live_share_url.present? %>
|
||||
<div class="regia-share-group">
|
||||
<p class="regia-share-label"><%= t("regia.live_share_label") %></p>
|
||||
<div class="regia-share">
|
||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-share-live" style="margin:0">
|
||||
<%= t("regia.share_live") %>
|
||||
<nav class="regia-toolbar" aria-label="<%= t("regia.links_nav") %>">
|
||||
<% if @live_share_url.present? %>
|
||||
<div class="regia-tool-group" role="group" aria-label="<%= t("regia.live_share_label") %>">
|
||||
<span class="regia-tool-group__label"><%= t("regia.live_share_label") %></span>
|
||||
<div class="regia-tool-group__btns">
|
||||
<button type="button" class="regia-icon-btn" id="btn-share-live" aria-label="<%= t("regia.share_live") %>" title="<%= t("regia.share_live") %>">
|
||||
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
||||
</button>
|
||||
<button type="button" class="regia-icon-btn" id="btn-copy-live" aria-label="<%= t("regia.copy_live") %>" title="<%= t("regia.copy_live") %>">
|
||||
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="regia-tool-group" role="group" aria-label="<%= t("regia.regia_share_label") %>">
|
||||
<span class="regia-tool-group__label"><%= t("regia.regia_share_label") %></span>
|
||||
<div class="regia-tool-group__btns">
|
||||
<button type="button" class="regia-icon-btn" id="btn-share-regia" aria-label="<%= t("regia.share_regia") %>" title="<%= t("regia.share_regia") %>">
|
||||
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
||||
</button>
|
||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-copy-live" style="margin:0">
|
||||
<%= t("regia.copy_link") %>
|
||||
<button type="button" class="regia-icon-btn" id="btn-copy-regia" aria-label="<%= t("regia.copy_regia") %>" title="<%= t("regia.copy_regia") %>">
|
||||
<svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="regia-share-group">
|
||||
<p class="regia-share-label"><%= t("regia.regia_share_label") %></p>
|
||||
<div class="regia-share">
|
||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-share-regia" style="margin:0">
|
||||
<%= t("regia.share_regia") %>
|
||||
</button>
|
||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-copy-regia" style="margin:0">
|
||||
<%= t("regia.copy_link") %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="regia-preview">
|
||||
<% unless @stream_closed %>
|
||||
<video id="regia-preview" playsinline muted autoplay></video>
|
||||
<div class="regia-tool-group regia-tool-group--stream" role="group" aria-label="<%= t("regia.stream_controls_label") %>">
|
||||
<span class="regia-tool-group__label" aria-hidden="true"> </span>
|
||||
<div class="regia-tool-group__btns">
|
||||
<button type="button" class="regia-icon-btn" id="btn-toggle-preview" aria-label="<%= t("regia.preview_hide") %>" title="<%= t("regia.preview_hide") %>" aria-pressed="true">
|
||||
<svg class="icon-fill icon-eye" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>
|
||||
<svg class="icon-fill icon-eye-off" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78 3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>
|
||||
</button>
|
||||
<button type="button" class="regia-icon-btn<%= " is-paused" if @session.paused? %>" id="btn-pause" aria-label="<%= @session.paused? ? t("regia.resume") : t("regia.pause") %>" title="<%= @session.paused? ? t("regia.resume") : t("regia.pause") %>">
|
||||
<svg class="icon-fill icon-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||
<svg class="icon-fill icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
<button type="button" class="regia-icon-btn<%= " is-muted" if @session.audio_muted %>" id="btn-audio-mute" aria-label="<%= @session.audio_muted ? t("regia.unmute") : t("regia.mute") %>" title="<%= @session.audio_muted ? t("regia.unmute") : t("regia.mute") %>">
|
||||
<svg class="icon-fill icon-mic" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5-3c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>
|
||||
<svg class="icon-fill icon-mic-off" viewBox="0 0 24 24" aria-hidden="true"><path d="M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.02.17c0-.06.02-.11.02-.17V5c0-1.66-1.34-3-3-3S9 3.34 9 5v.18l5.98 5.99zM4.27 3L3 4.27l6.01 6.01V11c0 1.66 1.33 3 2.99 3 .22 0 .44-.03.65-.08l1.66 1.66c-.71.33-1.5.52-2.31.52-2.76 0-5.3-2.1-5.3-5.1H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c.91-.13 1.77-.45 2.54-.9L19.73 21 21 19.73 4.27 3z"/></svg>
|
||||
</button>
|
||||
<button type="button" class="regia-icon-btn regia-icon-btn--danger" id="btn-stop" aria-label="<%= t("regia.stop") %>" title="<%= t("regia.stop") %>">
|
||||
<svg class="icon-fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6h12v12H6z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<div id="regia-preview-placeholder" class="regia-preview__placeholder">
|
||||
<%= @stream_closed ? t("regia.preview_ended") : t("regia.preview_waiting") %>
|
||||
</nav>
|
||||
|
||||
<section class="regia-preview-wrap" id="regia-preview-wrap">
|
||||
<div class="regia-preview" id="regia-preview-box">
|
||||
<% unless @stream_closed %>
|
||||
<video id="regia-preview" playsinline muted autoplay></video>
|
||||
<% end %>
|
||||
<div id="regia-preview-placeholder" class="regia-preview__placeholder">
|
||||
<%= @stream_closed ? t("regia.preview_ended") : t("regia.preview_waiting") %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="regia-scoreboard">
|
||||
<%= render regia_board_partial(@match) %>
|
||||
</section>
|
||||
|
||||
<% unless @stream_closed %>
|
||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-pause"><%= @session.paused? ? t("regia.resume") : t("regia.pause") %></button>
|
||||
<button type="button" class="regia-btn regia-btn--danger" id="btn-stop"><%= t("regia.stop") %></button>
|
||||
<section class="regia-telemetry" id="regia-telemetry" aria-label="<%= t("regia.telemetry.label") %>">
|
||||
<div class="regia-telemetry__row">
|
||||
<span class="regia-chip" id="tel-battery" title="<%= t("regia.telemetry.battery") %>">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 4h-1V3h-6v1H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H8V6h8v14z"/></svg>
|
||||
<span class="regia-chip__label"><%= t("regia.telemetry.battery") %></span>
|
||||
<span class="regia-chip__value">—</span>
|
||||
</span>
|
||||
<span class="regia-chip" id="tel-network" title="<%= t("regia.telemetry.network") %>">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z"/></svg>
|
||||
<span class="regia-chip__label"><%= t("regia.telemetry.network") %></span>
|
||||
<span class="regia-chip__value">—</span>
|
||||
</span>
|
||||
<button type="button" class="regia-chip regia-chip--action" id="tel-bitrate" aria-expanded="false" aria-controls="regia-quality" title="<%= t("regia.telemetry.bitrate_tap") %>">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 13h2v-2H3v2zm4 4h2v-6H7v6zm4 4h2V7h-2v14zm4-8h2V9h-2v4zm4-4h2V5h-2v4z"/></svg>
|
||||
<span class="regia-chip__label"><%= t("regia.telemetry.bitrate") %></span>
|
||||
<span class="regia-chip__value">—</span>
|
||||
<span class="regia-chip__hint" hidden></span>
|
||||
</button>
|
||||
<span class="regia-chip" id="tel-fps" title="<%= t("regia.telemetry.fps") %>">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
|
||||
<span class="regia-chip__label"><%= t("regia.telemetry.fps") %></span>
|
||||
<span class="regia-chip__value">—</span>
|
||||
</span>
|
||||
<span class="regia-chip" id="tel-thermal" title="<%= t("regia.telemetry.thermal") %>">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 13V5c0-1.66-1.34-3-3-3S9 3.34 9 5v8c-1.21.91-2 2.37-2 4 0 2.76 2.24 5 5 5s5-2.24 5-5c0-1.63-.79-3.09-2-4zm-3 8c-1.66 0-3-1.34-3-3 0-1.19.7-2.23 1.71-2.72L11 14.73V5c0-.55.45-1 1-1s1 .45 1 1v9.73l.29.18C14.3 15.77 15 16.81 15 18c0 1.66-1.34 3-3 3z"/></svg>
|
||||
<span class="regia-chip__label"><%= t("regia.telemetry.thermal") %></span>
|
||||
<span class="regia-chip__value">—</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="regia-quality" id="regia-quality" hidden>
|
||||
<p class="regia-quality__hint"><%= t("regia.min_quality.recommend") %></p>
|
||||
<label for="min-quality"><%= t("regia.min_quality.label") %></label>
|
||||
<select id="min-quality" title="<%= t("regia.min_quality.hint") %>">
|
||||
<% Sessions::SelectQuality.allowed_min_ids(@session.quality_preset).each do |id| %>
|
||||
<option value="<%= id %>" <%= "selected" if @session.min_quality_preset == id %>>
|
||||
<%= id == Sessions::SelectQuality::AUTO_ID ? t("regia.min_quality.auto") : Sessions::SelectQuality.display_name(id) %>
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
<p class="regia-telemetry__meta" id="tel-meta"><%= t("regia.telemetry.waiting") %></p>
|
||||
</section>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -94,4 +165,4 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/regia.js?v=8"></script>
|
||||
<script src="/regia.js?v=19"></script>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
required: true,
|
||||
minlength: 8,
|
||||
autocomplete: "new-password" %>
|
||||
<p class="muted" style="font-size:0.9rem;margin-top:-0.5rem"><%= t("password_policy.hint") %></p>
|
||||
<%= render "shared/input_toggle",
|
||||
name: "user[password_confirmation]",
|
||||
id: "user_password_confirmation",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
<h3 style="margin-top:28px;font-size:1.1rem"><%= t("billing.documents.table_heading") %></h3>
|
||||
<% if payments.any? %>
|
||||
<div class="table-scroll">
|
||||
<table class="data billing-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -60,6 +61,7 @@
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<p style="color:#888"><%= t("billing.documents.no_payments") %></p>
|
||||
<% end %>
|
||||
|
||||
@@ -58,10 +58,18 @@
|
||||
}
|
||||
|
||||
toggle.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(menu.hidden);
|
||||
});
|
||||
|
||||
// Keep parent mobile nav open while interacting with the switcher UI.
|
||||
root.addEventListener("click", function (e) {
|
||||
if (e.target.closest(".lang-switcher__toggle")) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", function (e) {
|
||||
if (!root.contains(e.target)) setOpen(false);
|
||||
});
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
<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 %> ·
|
||||
<%= link_to t("common.pricing"), public_prezzi_path %> ·
|
||||
<%= link_to t("footer.live"), public_live_index_path %> ·
|
||||
<%= link_to t("common.faq"), public_faq_path %> ·
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<%# Footer minimale per App Store Review: solo link legali/supporto, nessun CTA commerciale. %>
|
||||
<footer class="site-footer">
|
||||
<div class="wrap">
|
||||
<div>
|
||||
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
||||
</div>
|
||||
<div>
|
||||
<%= link_to t("common.support"), public_support_path %> ·
|
||||
<%= link_to t("common.privacy"), public_privacy_path %> ·
|
||||
<%= link_to t("common.cookies"), public_cookies_path %> ·
|
||||
<%= link_to t("common.terms"), public_termini_path %>
|
||||
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
|
||||
</div>
|
||||
<div class="site-footer__legal">
|
||||
<p><%= t("footer.copyright") %></p>
|
||||
<p><%= t("footer.responsibility") %></p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -19,6 +19,15 @@
|
||||
|
||||
<nav id="site-nav" class="nav" aria-label="<%= t('nav.main_menu') %>" aria-hidden="true">
|
||||
<div class="nav-panel">
|
||||
<div class="nav-mobile-head">
|
||||
<%= link_to root_path, class: "nav-mobile-brand", aria: { label: "Match Live TV" }, title: "Match Live TV" do %>
|
||||
<img class="nav-mobile-brand-logo" src="/logo.png?v=3" alt="" width="40" height="40" decoding="async">
|
||||
<span class="brand">Match <span>Live TV</span></span>
|
||||
<% end %>
|
||||
<div class="nav-lang">
|
||||
<%= render "shared/language_switcher" %>
|
||||
</div>
|
||||
</div>
|
||||
<%= link_to t("nav.home"), root_path, class: (request.path == "/" ? "nav-active" : nil) %>
|
||||
<%= link_to t("nav.features"), public_features_path, class: (request.path == "/funzionalita" ? "nav-active" : nil) %>
|
||||
<%= link_to t("nav.pricing"), public_prezzi_path, class: (request.path == "/prezzi" ? "nav-active" : nil) %>
|
||||
@@ -40,9 +49,6 @@
|
||||
<%= link_to t("nav.login"), public_login_path, class: "nav-link-item" %>
|
||||
<%= link_to t("nav.signup"), public_signup_path, class: "btn btn-primary nav-btn" %>
|
||||
<% end %>
|
||||
<div class="nav-lang">
|
||||
<%= render "shared/language_switcher" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -78,8 +84,16 @@
|
||||
|
||||
if (backdrop) backdrop.addEventListener("click", closeMenu);
|
||||
|
||||
function shouldCloseNavOnControl(el) {
|
||||
// Language toggle must keep the mobile menu open; only a locale choice may close it.
|
||||
if (!el.closest("[data-lang-switcher]")) return true;
|
||||
return el.classList.contains("lang-switcher__option");
|
||||
}
|
||||
|
||||
nav.querySelectorAll("a, button").forEach(function (el) {
|
||||
el.addEventListener("click", closeMenu);
|
||||
el.addEventListener("click", function () {
|
||||
if (shouldCloseNavOnControl(el)) closeMenu();
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("resize", function () {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<%# Chrome minimale per App Store Review: branding, lingua, Privacy e Supporto — senza CTA commerciali. %>
|
||||
<div class="site-chrome">
|
||||
<div class="site-masthead">
|
||||
<div class="wrap mast-inner">
|
||||
<%= link_to public_support_path, class: "mast-brand", aria: { label: "Match Live TV" }, title: "Match Live TV" do %>
|
||||
<img class="mast-brand-logo" src="/logo.png?v=3" alt="Match Live TV" width="40" height="40" decoding="async">
|
||||
<span class="brand" aria-hidden="true">Match <span>Live TV</span></span>
|
||||
<% end %>
|
||||
|
||||
<div class="mast-tools">
|
||||
<button type="button" class="nav-toggle" aria-label="<%= t('nav.open_menu') %>" aria-expanded="false" aria-controls="site-nav">
|
||||
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav id="site-nav" class="nav" aria-label="<%= t('nav.main_menu') %>" aria-hidden="true">
|
||||
<div class="nav-panel">
|
||||
<div class="nav-mobile-head">
|
||||
<%= link_to public_support_path, class: "nav-mobile-brand", aria: { label: "Match Live TV" }, title: "Match Live TV" do %>
|
||||
<img class="nav-mobile-brand-logo" src="/logo.png?v=3" alt="" width="40" height="40" decoding="async">
|
||||
<span class="brand">Match <span>Live TV</span></span>
|
||||
<% end %>
|
||||
<div class="nav-lang">
|
||||
<%= render "shared/language_switcher" %>
|
||||
</div>
|
||||
</div>
|
||||
<%= link_to t("common.support"), public_support_path, class: "nav-active" %>
|
||||
<%= link_to t("common.privacy"), public_privacy_path, class: (request.path == "/privacy" ? "nav-active" : nil) %>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="nav-backdrop" id="nav-backdrop" aria-hidden="true"></div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var chrome = document.querySelector(".site-chrome");
|
||||
var toggle = document.querySelector(".nav-toggle");
|
||||
var backdrop = document.getElementById("nav-backdrop");
|
||||
var nav = document.getElementById("site-nav");
|
||||
if (!chrome || !toggle || !nav) return;
|
||||
var openLabel = <%= raw t("nav.open_menu").to_json %>;
|
||||
var closeLabel = <%= raw t("nav.close_menu").to_json %>;
|
||||
|
||||
function setOpen(open) {
|
||||
chrome.classList.toggle("nav-open", open);
|
||||
document.body.classList.toggle("nav-menu-open", open);
|
||||
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
toggle.setAttribute("aria-label", open ? closeLabel : openLabel);
|
||||
nav.setAttribute("aria-hidden", open ? "false" : "true");
|
||||
if (backdrop) backdrop.setAttribute("aria-hidden", open ? "false" : "true");
|
||||
}
|
||||
|
||||
function closeMenu() { setOpen(false); }
|
||||
|
||||
toggle.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
document.body.classList.contains("nav-menu-open") ? closeMenu() : setOpen(true);
|
||||
});
|
||||
|
||||
if (backdrop) backdrop.addEventListener("click", closeMenu);
|
||||
|
||||
function shouldCloseNavOnControl(el) {
|
||||
if (!el.closest("[data-lang-switcher]")) return true;
|
||||
return el.classList.contains("lang-switcher__option");
|
||||
}
|
||||
|
||||
nav.querySelectorAll("a, button").forEach(function (el) {
|
||||
el.addEventListener("click", function () {
|
||||
if (shouldCloseNavOnControl(el)) closeMenu();
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("resize", function () {
|
||||
if (window.matchMedia("(min-width: 900px)").matches) closeMenu();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Escape") closeMenu();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -15,23 +15,33 @@
|
||||
current_interval: current_interval,
|
||||
subscription: subscription
|
||||
) : nil %>
|
||||
<div class="plan-card">
|
||||
<% 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 %>
|
||||
<%= 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 %>
|
||||
<% 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>
|
||||
<% 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>
|
||||
<% end %>
|
||||
<ul>
|
||||
<li><%= raw t("pages.plans.staff_html", count: plan.max_staff_transmission || t("pages.plans.staff_unlimited")) %></li>
|
||||
<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.matches_html", count: plan.concurrent_streams_limit || t("pages.plans.matches_unlimited")) %></li>
|
||||
<li><%= raw t(
|
||||
"pages.plans.replay_html",
|
||||
@@ -48,6 +58,9 @@
|
||||
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 %>
|
||||
@@ -78,8 +91,12 @@
|
||||
<%= link_to t("pages.plans.start_free"), public_signup_path, class: "btn btn-secondary" %>
|
||||
<% else %>
|
||||
<div class="plan-interval-actions">
|
||||
<% 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)),
|
||||
<% %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)
|
||||
),
|
||||
public_signup_path(plan: plan.slug, interval: interval),
|
||||
class: plan_interval_button_class(interval) %>
|
||||
<% end %>
|
||||
@@ -88,4 +105,3 @@
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<% 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>
|
||||
@@ -62,8 +62,8 @@ Rails.application.configure do
|
||||
# Annotate rendered view with file names.
|
||||
config.action_view.annotate_rendered_view_with_filenames = true
|
||||
|
||||
# Uncomment if you wish to allow Action Cable access from any origin.
|
||||
# config.action_cable.disable_request_forgery_protection = true
|
||||
# Permette Action Cable dall'emulatore Android (10.0.2.2) e da altri host locali.
|
||||
config.action_cable.disable_request_forgery_protection = true
|
||||
|
||||
# Raise error when a before_action's only/except options reference missing actions.
|
||||
config.action_controller.raise_on_missing_callback_actions = true
|
||||
|
||||
@@ -81,6 +81,12 @@ module MatchLiveTv
|
||||
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlivetv.it")
|
||||
end
|
||||
|
||||
# Email di supporto (App Store Support URL e contatti assistenza).
|
||||
# Preferisce SUPPORT_CONTACT_EMAIL; default = contatto commerciale già usato nel sito.
|
||||
def support_email
|
||||
ENV["SUPPORT_CONTACT_EMAIL"].presence || "info@matchlivetv.it"
|
||||
end
|
||||
|
||||
def privacy_controller_address
|
||||
ENV.fetch("PRIVACY_CONTROLLER_ADDRESS", "Via Guido De Ruggiero, 89 - 20142 - Milano (MI)")
|
||||
end
|
||||
@@ -174,5 +180,13 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ de:
|
||||
billing: Abrechnung
|
||||
youtube: YouTube
|
||||
sessions: Sitzungen
|
||||
stream_nodes: Stream-Knoten
|
||||
password: Passwort
|
||||
logout: Abmelden
|
||||
flash:
|
||||
@@ -18,11 +19,19 @@ de:
|
||||
logout_success: Abgemeldet
|
||||
password_current_incorrect: Das aktuelle Passwort ist falsch
|
||||
password_too_short: Das neue Passwort muss mindestens 8 Zeichen lang sein
|
||||
password_too_long: Das neue Passwort darf höchstens 72 Zeichen lang sein
|
||||
password_too_weak: Das neue Passwort muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten
|
||||
password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden
|
||||
password_mismatch: Die Passwörter stimmen nicht überein
|
||||
password_updated: Passwort aktualisiert
|
||||
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})."
|
||||
@@ -53,7 +62,7 @@ de:
|
||||
edit:
|
||||
title: Passwort ändern
|
||||
current_password_label: Aktuelles Passwort
|
||||
new_password_label: "Neues Passwort (mind. 8 Zeichen)"
|
||||
new_password_label: "Neues Passwort (mind. 8, mindestens 3 Zeichenarten)"
|
||||
confirm_password_label: Neues Passwort bestätigen
|
||||
submit: Passwort speichern
|
||||
cancel: Abbrechen
|
||||
@@ -108,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
|
||||
|
||||
@@ -9,6 +9,7 @@ en:
|
||||
billing: Billing
|
||||
youtube: YouTube
|
||||
sessions: Sessions
|
||||
stream_nodes: Stream nodes
|
||||
password: Password
|
||||
logout: Log out
|
||||
flash:
|
||||
@@ -18,11 +19,19 @@ en:
|
||||
logout_success: Signed out
|
||||
password_current_incorrect: Current password is incorrect
|
||||
password_too_short: The new password must be at least 8 characters long
|
||||
password_too_long: New password cannot exceed 72 characters
|
||||
password_too_weak: "New password must include at least 3 of: lowercase, uppercase, numbers and symbols"
|
||||
password_same_as_current: New password must be different from the current password
|
||||
password_mismatch: Passwords do not match
|
||||
password_updated: Password updated
|
||||
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})."
|
||||
@@ -53,7 +62,7 @@ en:
|
||||
edit:
|
||||
title: Change password
|
||||
current_password_label: Current password
|
||||
new_password_label: "New password (min. 8 characters)"
|
||||
new_password_label: "New password (min. 8, at least 3 character types)"
|
||||
confirm_password_label: Confirm new password
|
||||
submit: Save password
|
||||
cancel: Cancel
|
||||
@@ -108,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
|
||||
|
||||
@@ -9,6 +9,7 @@ es:
|
||||
billing: Facturación
|
||||
youtube: YouTube
|
||||
sessions: Sesiones
|
||||
stream_nodes: Nodos stream
|
||||
password: Contraseña
|
||||
logout: Salir
|
||||
flash:
|
||||
@@ -18,11 +19,19 @@ es:
|
||||
logout_success: Sesión cerrada
|
||||
password_current_incorrect: La contraseña actual no es correcta
|
||||
password_too_short: La nueva contraseña debe tener al menos 8 caracteres
|
||||
password_too_long: La nueva contraseña no puede superar los 72 caracteres
|
||||
password_too_weak: "La nueva contraseña debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
||||
password_same_as_current: La nueva contraseña debe ser distinta de la actual
|
||||
password_mismatch: Las contraseñas no coinciden
|
||||
password_updated: Contraseña actualizada
|
||||
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})."
|
||||
@@ -53,7 +62,7 @@ es:
|
||||
edit:
|
||||
title: Cambiar contraseña
|
||||
current_password_label: Contraseña actual
|
||||
new_password_label: "Nueva contraseña (mín. 8 caracteres)"
|
||||
new_password_label: "Nueva contraseña (mín. 8, al menos 3 tipos de caracteres)"
|
||||
confirm_password_label: Confirma la nueva contraseña
|
||||
submit: Guardar contraseña
|
||||
cancel: Cancelar
|
||||
@@ -108,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
|
||||
|
||||
@@ -9,6 +9,7 @@ fr:
|
||||
billing: Facturation
|
||||
youtube: YouTube
|
||||
sessions: Sessions
|
||||
stream_nodes: Nœuds stream
|
||||
password: Mot de passe
|
||||
logout: Déconnexion
|
||||
flash:
|
||||
@@ -18,11 +19,19 @@ fr:
|
||||
logout_success: Déconnecté
|
||||
password_current_incorrect: Le mot de passe actuel est incorrect
|
||||
password_too_short: Le nouveau mot de passe doit contenir au moins 8 caractères
|
||||
password_too_long: Le nouveau mot de passe ne peut pas dépasser 72 caractères
|
||||
password_too_weak: "Le nouveau mot de passe doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
||||
password_same_as_current: "Le nouveau mot de passe doit être différent de l'actuel"
|
||||
password_mismatch: Les mots de passe ne correspondent pas
|
||||
password_updated: Mot de passe mis à jour
|
||||
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})."
|
||||
@@ -53,7 +62,7 @@ fr:
|
||||
edit:
|
||||
title: Changer le mot de passe
|
||||
current_password_label: Mot de passe actuel
|
||||
new_password_label: "Nouveau mot de passe (min. 8 caractères)"
|
||||
new_password_label: "Nouveau mot de passe (min. 8, au moins 3 types de caractères)"
|
||||
confirm_password_label: Confirmer le nouveau mot de passe
|
||||
submit: Enregistrer le mot de passe
|
||||
cancel: Annuler
|
||||
@@ -108,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
|
||||
|
||||
@@ -9,6 +9,7 @@ it:
|
||||
billing: Fatturazione
|
||||
youtube: YouTube
|
||||
sessions: Sessioni
|
||||
stream_nodes: Nodi stream
|
||||
password: Password
|
||||
logout: Esci
|
||||
flash:
|
||||
@@ -18,11 +19,19 @@ it:
|
||||
logout_success: Disconnesso
|
||||
password_current_incorrect: Password attuale non corretta
|
||||
password_too_short: La nuova password deve avere almeno 8 caratteri
|
||||
password_too_long: La nuova password non può superare i 72 caratteri
|
||||
password_too_weak: "La nuova password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
|
||||
password_same_as_current: La nuova password deve essere diversa da quella attuale
|
||||
password_mismatch: Le password non coincidono
|
||||
password_updated: Password aggiornata
|
||||
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})."
|
||||
@@ -53,7 +62,7 @@ it:
|
||||
edit:
|
||||
title: Cambia password
|
||||
current_password_label: Password attuale
|
||||
new_password_label: "Nuova password (min. 8 caratteri)"
|
||||
new_password_label: "Nuova password (min. 8, almeno 3 tipi di caratteri)"
|
||||
confirm_password_label: Conferma nuova password
|
||||
submit: Salva password
|
||||
cancel: Annulla
|
||||
@@ -108,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
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
de:
|
||||
password_policy:
|
||||
hint: "Mindestens 8 Zeichen, mit mindestens 3 aus: Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen."
|
||||
activerecord:
|
||||
attributes:
|
||||
user:
|
||||
password: Passwort
|
||||
admin_account:
|
||||
password: Passwort
|
||||
errors:
|
||||
models:
|
||||
user:
|
||||
attributes:
|
||||
password:
|
||||
too_short: "ist zu kurz (mindestens %{count} Zeichen)"
|
||||
too_long: "ist zu lang (höchstens %{count} Zeichen)"
|
||||
complexity: "muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten"
|
||||
admin_account:
|
||||
attributes:
|
||||
password:
|
||||
too_short: "ist zu kurz (mindestens %{count} Zeichen)"
|
||||
too_long: "ist zu lang (höchstens %{count} Zeichen)"
|
||||
complexity: "muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten"
|
||||
club:
|
||||
back_to_club: "← Verein"
|
||||
sport_label: Hauptsportart
|
||||
@@ -60,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 Übertragungsverantwortlicher pro Team, 1 Livestream"
|
||||
plan_free: "Free — 1 Streaming-Konto pro Team, 1 Livestream"
|
||||
plan_light: Premium Light
|
||||
plan_full: Premium Full
|
||||
interval_label: Premium-Abrechnung
|
||||
interval_yearly: "Jährlich — 40 €/Jahr (Light) oder 200 €/Jahr (Full)"
|
||||
interval_monthly: "Monatlich — 5 €/Monat (Light) oder 20 €/Monat (Full)"
|
||||
interval_yearly: "Jährlich — 59 €/Jahr (Light) oder 199 €/Jahr (Full)"
|
||||
interval_monthly: "Monatlich — 7,90 €/Monat (Light) oder 24,90 €/Monat (Full)"
|
||||
submit: Verein erstellen
|
||||
billing:
|
||||
title: "Abonnement — %{name}"
|
||||
@@ -401,6 +423,9 @@ 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}"
|
||||
@@ -410,16 +435,53 @@ de:
|
||||
paused: Pausiert
|
||||
live: Live
|
||||
waiting: Wartet
|
||||
live_share_label: "Live-Link (Zuschauer)"
|
||||
live_share_label: Live
|
||||
share_live: Livestream teilen
|
||||
share_action: Teilen
|
||||
copy_link: Link kopieren
|
||||
regia_share_label: "Regie-Link (Punktestand)"
|
||||
copy_action: Kopieren
|
||||
copy_live: Live kopieren
|
||||
copy_regia: Regie kopieren
|
||||
links_nav: Schnellaktionen
|
||||
regia_share_label: Regie
|
||||
stream_controls_label: Live
|
||||
share_regia: Regie-Link teilen
|
||||
preview_ended: Livestream beendet
|
||||
preview_waiting: Vorschau wartet auf Signal…
|
||||
preview_hide: Vorschau ausblenden
|
||||
preview_show: Vorschau anzeigen
|
||||
preview_off_hint: Vorschau aus · kein Video-Download
|
||||
pause: Pausieren
|
||||
resume: Livestream fortsetzen
|
||||
mute: Audio stummschalten
|
||||
unmute: Audio einschalten
|
||||
stop: Livestream beenden
|
||||
min_quality:
|
||||
label: Minimale Übertragungsqualität
|
||||
summary: Qualität
|
||||
hint: Der Encoder fällt nicht unter dieses Niveau. Automatisch passt die Bitrate an, um Aussetzer zu reduzieren.
|
||||
recommend: "Automatisch wird empfohlen: passt sich an Netz und Telefon an, ohne das Maximum zu erzwingen."
|
||||
auto: Automatisch
|
||||
telemetry:
|
||||
label: Telefon
|
||||
waiting: Warte auf das Telefon…
|
||||
stale: Telefon antwortet nicht
|
||||
just_now: jetzt
|
||||
seconds_ago: "vor %{n}s"
|
||||
publisher_on: RTMP ok
|
||||
publisher_off: RTMP fehlt
|
||||
battery: Akku
|
||||
network: Netz
|
||||
bitrate: Bitrate
|
||||
bitrate_max: "max %{n}"
|
||||
bitrate_title: "%{current} Mbps aktuell · max. %{target} Mbps"
|
||||
bitrate_tap: Tippen, um die Mindestqualität festzulegen
|
||||
fps: FPS
|
||||
thermal: Temp.
|
||||
thermal_nominal: OK
|
||||
thermal_fair: Warm
|
||||
thermal_serious: Heiß
|
||||
thermal_critical: Kritisch
|
||||
modal:
|
||||
set_won_title: Satz gewonnen
|
||||
confirm: Satz beenden
|
||||
@@ -454,9 +516,12 @@ de:
|
||||
unknown_period: "—"
|
||||
resumed: Übertragung fortgesetzt
|
||||
paused_cover: "Übertragung pausiert — Deckbild auf Sendung"
|
||||
muted: "Audio stumm — YouTube empfängt das Mikrofon nicht"
|
||||
unmuted: "Mikrofon-Audio wieder aktiv"
|
||||
closed: Übertragung beendet
|
||||
resume_error: Fehler beim Fortsetzen der Übertragung
|
||||
pause_error: Fehler beim Pausieren der Übertragung
|
||||
mute_error: Fehler beim Stummschalten
|
||||
close_error: Fehler beim Beenden
|
||||
close_confirm: Übertragung endgültig beenden?
|
||||
link_unavailable: Link nicht verfügbar
|
||||
@@ -464,6 +529,8 @@ de:
|
||||
copy_prompt: "Link kopieren:"
|
||||
share_regia_text: "Öffne diesen Link, um den Live-Spielstand zu verwalten:"
|
||||
share_live_text: "Sieh dir die Übertragung auf Match Live TV an:"
|
||||
min_quality_updated: Mindestqualität aktualisiert
|
||||
min_quality_error: Fehler beim Aktualisieren der Mindestqualität
|
||||
recordings:
|
||||
archive:
|
||||
back_prefix: "←"
|
||||
@@ -600,10 +667,16 @@ de:
|
||||
welcome_back: Willkommen zurück!
|
||||
invalid_credentials: E-Mail oder Passwort ungültig
|
||||
logged_out: Abgemeldet
|
||||
unauthorized: Nicht autorisiert
|
||||
invalid_token: Ungültiges Token
|
||||
password_resets:
|
||||
email_sent: Wenn die E-Mail registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen des Passworts.
|
||||
invalid_or_expired_link: Link ungültig oder abgelaufen. Fordere ein neues Zurücksetzen des Passworts an.
|
||||
password_min_length: Das Passwort muss mindestens 8 Zeichen lang sein
|
||||
password_too_short: Das Passwort muss mindestens 8 Zeichen lang sein
|
||||
password_too_long: Das Passwort darf höchstens 72 Zeichen lang sein
|
||||
password_too_weak: Das Passwort muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten
|
||||
password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden
|
||||
password_mismatch: Die Passwörter stimmen nicht überein
|
||||
password_updated: Passwort aktualisiert. Du kannst dich jetzt anmelden.
|
||||
accounts:
|
||||
@@ -611,8 +684,12 @@ de:
|
||||
profile_updated: Profil aktualisiert.
|
||||
password_current_incorrect: Das aktuelle Passwort ist falsch
|
||||
password_too_short: Das Passwort muss mindestens 8 Zeichen haben
|
||||
password_too_long: Das Passwort darf höchstens 72 Zeichen lang sein
|
||||
password_too_weak: Das Passwort muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten
|
||||
password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden
|
||||
password_mismatch: Die Passwörter stimmen nicht überein
|
||||
password_updated: Passwort aktualisiert.
|
||||
password_update_failed: Passwort konnte nicht aktualisiert werden
|
||||
replay:
|
||||
download_unavailable: Download nicht verfügbar
|
||||
not_available: Replay nicht verfügbar
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
en:
|
||||
password_policy:
|
||||
hint: "At least 8 characters, including 3 of: lowercase, uppercase, numbers and symbols."
|
||||
activerecord:
|
||||
errors:
|
||||
models:
|
||||
user:
|
||||
attributes:
|
||||
password:
|
||||
too_short: "is too short (minimum is %{count} characters)"
|
||||
too_long: "is too long (maximum is %{count} characters)"
|
||||
complexity: "must include at least 3 of: lowercase letters, uppercase letters, numbers and symbols"
|
||||
admin_account:
|
||||
attributes:
|
||||
password:
|
||||
too_short: "is too short (minimum is %{count} characters)"
|
||||
too_long: "is too long (maximum is %{count} characters)"
|
||||
complexity: "must include at least 3 of: lowercase letters, uppercase letters, numbers and symbols"
|
||||
club:
|
||||
back_to_club: "← Club"
|
||||
sport_label: Main sport
|
||||
@@ -60,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 broadcast manager per team, 1 live stream"
|
||||
plan_free: "Free — 1 transmitter account per team, 1 live stream"
|
||||
plan_light: Premium Light
|
||||
plan_full: Premium Full
|
||||
interval_label: Premium billing
|
||||
interval_yearly: "Yearly — €40/year (Light) or €200/year (Full)"
|
||||
interval_monthly: "Monthly — €5/month (Light) or €20/month (Full)"
|
||||
interval_yearly: "Yearly — €59/year (Light) or €199/year (Full)"
|
||||
interval_monthly: "Monthly — €7.90/month (Light) or €24.90/month (Full)"
|
||||
submit: Create club
|
||||
billing:
|
||||
title: "Subscription — %{name}"
|
||||
@@ -401,6 +418,9 @@ 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}"
|
||||
@@ -410,16 +430,53 @@ en:
|
||||
paused: Paused
|
||||
live: Live
|
||||
waiting: Waiting
|
||||
live_share_label: "Live link (viewers)"
|
||||
live_share_label: Live
|
||||
share_live: Share live link
|
||||
share_action: Share
|
||||
copy_link: Copy link
|
||||
regia_share_label: "Control room link (score)"
|
||||
copy_action: Copy
|
||||
copy_live: Copy live
|
||||
copy_regia: Copy room
|
||||
links_nav: Quick actions
|
||||
regia_share_label: Control
|
||||
stream_controls_label: Live
|
||||
share_regia: Share control room link
|
||||
preview_ended: Stream ended
|
||||
preview_waiting: Waiting for signal…
|
||||
preview_hide: Hide preview
|
||||
preview_show: Show preview
|
||||
preview_off_hint: Preview off · no video download
|
||||
pause: Pause stream
|
||||
resume: Resume stream
|
||||
mute: Mute audio
|
||||
unmute: Unmute audio
|
||||
stop: End stream
|
||||
min_quality:
|
||||
label: Minimum streaming quality
|
||||
summary: Quality
|
||||
hint: The encoder will not go below this level. Automatic adapts bitrate to reduce stalls.
|
||||
recommend: "Automatic is recommended: it adapts to the network and the phone, without forcing maximum quality."
|
||||
auto: Automatic
|
||||
telemetry:
|
||||
label: Phone
|
||||
waiting: Waiting for the phone…
|
||||
stale: Phone not responding
|
||||
just_now: now
|
||||
seconds_ago: "%{n}s ago"
|
||||
publisher_on: RTMP ok
|
||||
publisher_off: RTMP down
|
||||
battery: Battery
|
||||
network: Network
|
||||
bitrate: Bitrate
|
||||
bitrate_max: "max %{n}"
|
||||
bitrate_title: "%{current} Mbps current · max %{target} Mbps"
|
||||
bitrate_tap: Tap to set the minimum quality
|
||||
fps: FPS
|
||||
thermal: Temp.
|
||||
thermal_nominal: OK
|
||||
thermal_fair: Warm
|
||||
thermal_serious: Hot
|
||||
thermal_critical: Critical
|
||||
modal:
|
||||
set_won_title: Set won
|
||||
confirm: Close set
|
||||
@@ -454,9 +511,12 @@ en:
|
||||
unknown_period: "—"
|
||||
resumed: Stream resumed
|
||||
paused_cover: "Stream paused — cover on air"
|
||||
muted: "Audio muted — YouTube is not receiving the microphone"
|
||||
unmuted: "Microphone audio restored"
|
||||
closed: Stream closed
|
||||
resume_error: Error resuming the stream
|
||||
pause_error: Error pausing the stream
|
||||
mute_error: Error muting audio
|
||||
close_error: Error closing
|
||||
close_confirm: Permanently close the live stream?
|
||||
link_unavailable: Link not available
|
||||
@@ -464,6 +524,8 @@ en:
|
||||
copy_prompt: "Copy the link:"
|
||||
share_regia_text: "Open this link to manage the live score:"
|
||||
share_live_text: "Watch the live stream on Match Live TV:"
|
||||
min_quality_updated: Minimum quality updated
|
||||
min_quality_error: Error updating minimum quality
|
||||
recordings:
|
||||
archive:
|
||||
back_prefix: "←"
|
||||
@@ -600,10 +662,16 @@ en:
|
||||
welcome_back: Welcome back!
|
||||
invalid_credentials: Invalid email or password
|
||||
logged_out: Logged out
|
||||
unauthorized: Unauthorized
|
||||
invalid_token: Invalid token
|
||||
password_resets:
|
||||
email_sent: If the email is registered, you'll receive a password reset link shortly.
|
||||
invalid_or_expired_link: Invalid or expired link. Request a new password reset.
|
||||
password_min_length: Password must be at least 8 characters
|
||||
password_too_short: Password must be at least 8 characters
|
||||
password_too_long: Password cannot exceed 72 characters
|
||||
password_too_weak: "Password must include at least 3 of: lowercase, uppercase, numbers and symbols"
|
||||
password_same_as_current: New password must be different from the current password
|
||||
password_mismatch: Passwords don't match
|
||||
password_updated: Password updated. You can now log in.
|
||||
accounts:
|
||||
@@ -611,8 +679,12 @@ en:
|
||||
profile_updated: Profile updated.
|
||||
password_current_incorrect: Current password is incorrect
|
||||
password_too_short: Password must be at least 8 characters
|
||||
password_too_long: Password cannot exceed 72 characters
|
||||
password_too_weak: "Password must include at least 3 of: lowercase, uppercase, numbers and symbols"
|
||||
password_same_as_current: New password must be different from the current password
|
||||
password_mismatch: Passwords do not match
|
||||
password_updated: Password updated.
|
||||
password_update_failed: Unable to update password
|
||||
replay:
|
||||
download_unavailable: Download not available
|
||||
not_available: Replay not available
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
es:
|
||||
password_policy:
|
||||
hint: "Mínimo 8 caracteres, con al menos 3 entre: minúsculas, mayúsculas, números y símbolos."
|
||||
activerecord:
|
||||
attributes:
|
||||
user:
|
||||
password: Contraseña
|
||||
admin_account:
|
||||
password: Contraseña
|
||||
errors:
|
||||
models:
|
||||
user:
|
||||
attributes:
|
||||
password:
|
||||
too_short: "es demasiado corta (mínimo %{count} caracteres)"
|
||||
too_long: "es demasiado larga (máximo %{count} caracteres)"
|
||||
complexity: "debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
||||
admin_account:
|
||||
attributes:
|
||||
password:
|
||||
too_short: "es demasiado corta (mínimo %{count} caracteres)"
|
||||
too_long: "es demasiado larga (máximo %{count} caracteres)"
|
||||
complexity: "debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
||||
club:
|
||||
back_to_club: "← Club"
|
||||
sport_label: Deporte principal
|
||||
@@ -60,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 responsable de transmisión por equipo, 1 directo"
|
||||
plan_free: "Free — 1 cuenta de emisión por equipo, 1 directo"
|
||||
plan_light: Premium Light
|
||||
plan_full: Premium Full
|
||||
interval_label: Facturación premium
|
||||
interval_yearly: "Anual — 40 €/año (Light) o 200 €/año (Full)"
|
||||
interval_monthly: "Mensual — 5 €/mes (Light) o 20 €/mes (Full)"
|
||||
interval_yearly: "Anual — 59 €/año (Light) o 199 €/año (Full)"
|
||||
interval_monthly: "Mensual — 7,90 €/mes (Light) o 24,90 €/mes (Full)"
|
||||
submit: Crear club
|
||||
billing:
|
||||
title: "Suscripción — %{name}"
|
||||
@@ -401,6 +423,9 @@ 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}"
|
||||
@@ -410,16 +435,53 @@ es:
|
||||
paused: En pausa
|
||||
live: En directo
|
||||
waiting: En espera
|
||||
live_share_label: "Enlace del directo (espectadores)"
|
||||
live_share_label: Directo
|
||||
share_live: Compartir directo
|
||||
share_action: Compartir
|
||||
copy_link: Copiar enlace
|
||||
regia_share_label: "Enlace de control (marcador)"
|
||||
copy_action: Copiar
|
||||
copy_live: Copiar directo
|
||||
copy_regia: Copiar control
|
||||
links_nav: Acciones rápidas
|
||||
regia_share_label: Control
|
||||
stream_controls_label: Live
|
||||
share_regia: Compartir enlace de control
|
||||
preview_ended: Directo finalizado
|
||||
preview_waiting: Vista previa a la espera de señal…
|
||||
preview_hide: Ocultar vista previa
|
||||
preview_show: Mostrar vista previa
|
||||
preview_off_hint: Vista previa apagada · sin descarga de vídeo
|
||||
pause: Pausar directo
|
||||
resume: Reanudar directo
|
||||
mute: Silenciar audio
|
||||
unmute: Reactivar audio
|
||||
stop: Finalizar directo
|
||||
min_quality:
|
||||
label: Calidad mínima de transmisión
|
||||
summary: Calidad
|
||||
hint: El encoder no baja de este nivel. Automática adapta el bitrate para reducir los cortes.
|
||||
recommend: "Se recomienda Automática: se adapta a la red y al teléfono, sin forzar el máximo."
|
||||
auto: Automática
|
||||
telemetry:
|
||||
label: Teléfono
|
||||
waiting: Esperando al teléfono…
|
||||
stale: El teléfono no responde
|
||||
just_now: ahora
|
||||
seconds_ago: "hace %{n}s"
|
||||
publisher_on: RTMP ok
|
||||
publisher_off: RTMP ausente
|
||||
battery: Batería
|
||||
network: Red
|
||||
bitrate: Bitrate
|
||||
bitrate_max: "máx %{n}"
|
||||
bitrate_title: "%{current} Mbps actuales · máximo %{target} Mbps"
|
||||
bitrate_tap: Toca para definir la calidad mínima
|
||||
fps: FPS
|
||||
thermal: Temp.
|
||||
thermal_nominal: OK
|
||||
thermal_fair: Caliente
|
||||
thermal_serious: Muy caliente
|
||||
thermal_critical: Crítica
|
||||
modal:
|
||||
set_won_title: Set ganado
|
||||
confirm: Cerrar set
|
||||
@@ -454,9 +516,12 @@ es:
|
||||
unknown_period: "—"
|
||||
resumed: Retransmisión reanudada
|
||||
paused_cover: "Retransmisión en pausa — cortinilla al aire"
|
||||
muted: "Audio silenciado — YouTube no recibe el micrófono"
|
||||
unmuted: "Audio del micrófono reactivado"
|
||||
closed: Retransmisión finalizada
|
||||
resume_error: Error al reanudar la retransmisión
|
||||
pause_error: Error al pausar la retransmisión
|
||||
mute_error: Error al silenciar el audio
|
||||
close_error: Error al cerrar
|
||||
close_confirm: "¿Cerrar definitivamente la retransmisión?"
|
||||
link_unavailable: Enlace no disponible
|
||||
@@ -464,6 +529,8 @@ es:
|
||||
copy_prompt: "Copia el enlace:"
|
||||
share_regia_text: "Abre este enlace para gestionar el marcador en directo:"
|
||||
share_live_text: "Mira la retransmisión en Match Live TV:"
|
||||
min_quality_updated: Calidad mínima actualizada
|
||||
min_quality_error: Error al actualizar la calidad mínima
|
||||
recordings:
|
||||
archive:
|
||||
back_prefix: "←"
|
||||
@@ -600,10 +667,16 @@ es:
|
||||
welcome_back: "¡Bienvenido de nuevo!"
|
||||
invalid_credentials: Correo o contraseña no válidos
|
||||
logged_out: Sesión cerrada
|
||||
unauthorized: No autorizado
|
||||
invalid_token: Token no válido
|
||||
password_resets:
|
||||
email_sent: Si el correo está registrado, recibirás en breve un enlace para restablecer la contraseña.
|
||||
invalid_or_expired_link: Enlace no válido o caducado. Solicita un nuevo restablecimiento de contraseña.
|
||||
password_min_length: La contraseña debe tener al menos 8 caracteres
|
||||
password_too_short: La contraseña debe tener al menos 8 caracteres
|
||||
password_too_long: La contraseña no puede superar los 72 caracteres
|
||||
password_too_weak: "La contraseña debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
||||
password_same_as_current: La nueva contraseña debe ser distinta de la actual
|
||||
password_mismatch: Las contraseñas no coinciden
|
||||
password_updated: Contraseña actualizada. Ya puedes iniciar sesión.
|
||||
accounts:
|
||||
@@ -611,8 +684,12 @@ es:
|
||||
profile_updated: Perfil actualizado.
|
||||
password_current_incorrect: La contraseña actual no es correcta
|
||||
password_too_short: La contraseña debe tener al menos 8 caracteres
|
||||
password_too_long: La contraseña no puede superar los 72 caracteres
|
||||
password_too_weak: "La contraseña debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
||||
password_same_as_current: La nueva contraseña debe ser distinta de la actual
|
||||
password_mismatch: Las contraseñas no coinciden
|
||||
password_updated: Contraseña actualizada.
|
||||
password_update_failed: No se pudo actualizar la contraseña
|
||||
replay:
|
||||
download_unavailable: Descarga no disponible
|
||||
not_available: Repetición no disponible
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
fr:
|
||||
password_policy:
|
||||
hint: "Au moins 8 caractères, avec au moins 3 parmi : minuscules, majuscules, chiffres et symboles."
|
||||
activerecord:
|
||||
attributes:
|
||||
user:
|
||||
password: Mot de passe
|
||||
admin_account:
|
||||
password: Mot de passe
|
||||
errors:
|
||||
models:
|
||||
user:
|
||||
attributes:
|
||||
password:
|
||||
too_short: "est trop court (minimum %{count} caractères)"
|
||||
too_long: "est trop long (maximum %{count} caractères)"
|
||||
complexity: "doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
||||
admin_account:
|
||||
attributes:
|
||||
password:
|
||||
too_short: "est trop court (minimum %{count} caractères)"
|
||||
too_long: "est trop long (maximum %{count} caractères)"
|
||||
complexity: "doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
||||
club:
|
||||
back_to_club: "← Club"
|
||||
sport_label: Sport principal
|
||||
@@ -60,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 responsable diffusion par équipe, 1 direct"
|
||||
plan_free: "Gratuit — 1 compte diffuseur par équipe, 1 direct"
|
||||
plan_light: Premium Light
|
||||
plan_full: Premium Full
|
||||
interval_label: Facturation premium
|
||||
interval_yearly: "Annuel — 40 €/an (Light) ou 200 €/an (Full)"
|
||||
interval_monthly: "Mensuel — 5 €/mois (Light) ou 20 €/mois (Full)"
|
||||
interval_yearly: "Annuel — 59 €/an (Light) ou 199 €/an (Full)"
|
||||
interval_monthly: "Mensuel — 7,90 €/mois (Light) ou 24,90 €/mois (Full)"
|
||||
submit: Créer le club
|
||||
billing:
|
||||
title: "Abonnement — %{name}"
|
||||
@@ -401,6 +423,9 @@ 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}"
|
||||
@@ -410,16 +435,53 @@ fr:
|
||||
paused: En pause
|
||||
live: En direct
|
||||
waiting: En attente
|
||||
live_share_label: "Lien direct (spectateurs)"
|
||||
live_share_label: Direct
|
||||
share_live: Partager le direct
|
||||
share_action: Partager
|
||||
copy_link: Copier le lien
|
||||
regia_share_label: "Lien régie (score)"
|
||||
copy_action: Copier
|
||||
copy_live: Copier live
|
||||
copy_regia: Copier régie
|
||||
links_nav: Actions rapides
|
||||
regia_share_label: Régie
|
||||
stream_controls_label: Live
|
||||
share_regia: Partager le lien régie
|
||||
preview_ended: Direct terminé
|
||||
preview_waiting: Aperçu en attente du signal…
|
||||
preview_hide: Masquer l'aperçu
|
||||
preview_show: Afficher l'aperçu
|
||||
preview_off_hint: Aperçu désactivé · aucun téléchargement vidéo
|
||||
pause: Mettre en pause
|
||||
resume: Reprendre le direct
|
||||
mute: Couper l'audio
|
||||
unmute: Réactiver l'audio
|
||||
stop: Terminer le direct
|
||||
min_quality:
|
||||
label: Qualité minimale de transmission
|
||||
summary: Qualité
|
||||
hint: L'encodeur ne descend pas en dessous de ce niveau. Automatique adapte le débit pour limiter les coupures.
|
||||
recommend: "Automatique est conseillé : s'adapte au réseau et au téléphone, sans forcer le maximum."
|
||||
auto: Automatique
|
||||
telemetry:
|
||||
label: Téléphone
|
||||
waiting: En attente du téléphone…
|
||||
stale: Téléphone sans réponse
|
||||
just_now: maintenant
|
||||
seconds_ago: "il y a %{n}s"
|
||||
publisher_on: RTMP ok
|
||||
publisher_off: RTMP absent
|
||||
battery: Batterie
|
||||
network: Réseau
|
||||
bitrate: Débit
|
||||
bitrate_max: "max %{n}"
|
||||
bitrate_title: "%{current} Mbps actuels · max %{target} Mbps"
|
||||
bitrate_tap: Touchez pour définir la qualité minimale
|
||||
fps: FPS
|
||||
thermal: Temp.
|
||||
thermal_nominal: OK
|
||||
thermal_fair: Chaude
|
||||
thermal_serious: Très chaude
|
||||
thermal_critical: Critique
|
||||
modal:
|
||||
set_won_title: Set gagné
|
||||
confirm: Terminer le set
|
||||
@@ -454,9 +516,12 @@ fr:
|
||||
unknown_period: "—"
|
||||
resumed: Direct repris
|
||||
paused_cover: "Direct en pause — habillage à l'antenne"
|
||||
muted: "Audio coupé — YouTube ne reçoit pas le micro"
|
||||
unmuted: "Audio du micro rétabli"
|
||||
closed: Direct terminé
|
||||
resume_error: Erreur lors de la reprise du direct
|
||||
pause_error: Erreur lors de la mise en pause du direct
|
||||
mute_error: Erreur lors de la coupure audio
|
||||
close_error: Erreur de clôture
|
||||
close_confirm: Clôturer définitivement le direct ?
|
||||
link_unavailable: Lien non disponible
|
||||
@@ -464,6 +529,8 @@ fr:
|
||||
copy_prompt: "Copiez le lien :"
|
||||
share_regia_text: "Ouvrez ce lien pour gérer le score du direct :"
|
||||
share_live_text: "Regardez le direct sur Match Live TV :"
|
||||
min_quality_updated: Qualité minimale mise à jour
|
||||
min_quality_error: Erreur de mise à jour de la qualité minimale
|
||||
recordings:
|
||||
archive:
|
||||
back_prefix: "←"
|
||||
@@ -600,10 +667,16 @@ fr:
|
||||
welcome_back: Bon retour !
|
||||
invalid_credentials: E-mail ou mot de passe invalide
|
||||
logged_out: Déconnecté
|
||||
unauthorized: Non autorisé
|
||||
invalid_token: Jeton invalide
|
||||
password_resets:
|
||||
email_sent: Si l'e-mail est enregistré, tu recevras bientôt un lien pour réinitialiser le mot de passe.
|
||||
invalid_or_expired_link: Lien invalide ou expiré. Demande une nouvelle réinitialisation du mot de passe.
|
||||
password_min_length: Le mot de passe doit comporter au moins 8 caractères
|
||||
password_too_short: Le mot de passe doit comporter au moins 8 caractères
|
||||
password_too_long: Le mot de passe ne peut pas dépasser 72 caractères
|
||||
password_too_weak: "Le mot de passe doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
||||
password_same_as_current: "Le nouveau mot de passe doit être différent de l'actuel"
|
||||
password_mismatch: Les mots de passe ne correspondent pas
|
||||
password_updated: Mot de passe mis à jour. Tu peux maintenant te connecter.
|
||||
accounts:
|
||||
@@ -611,8 +684,12 @@ fr:
|
||||
profile_updated: Profil mis à jour.
|
||||
password_current_incorrect: Le mot de passe actuel est incorrect
|
||||
password_too_short: Le mot de passe doit contenir au moins 8 caractères
|
||||
password_too_long: Le mot de passe ne peut pas dépasser 72 caractères
|
||||
password_too_weak: "Le mot de passe doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
||||
password_same_as_current: "Le nouveau mot de passe doit être différent de l'actuel"
|
||||
password_mismatch: Les mots de passe ne correspondent pas
|
||||
password_updated: Mot de passe mis à jour.
|
||||
password_update_failed: Impossible de mettre à jour le mot de passe
|
||||
replay:
|
||||
download_unavailable: Téléchargement non disponible
|
||||
not_available: Replay non disponible
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
it:
|
||||
password_policy:
|
||||
hint: "Minimo 8 caratteri, con almeno 3 tra: minuscole, maiuscole, numeri e simboli."
|
||||
activerecord:
|
||||
errors:
|
||||
models:
|
||||
user:
|
||||
attributes:
|
||||
password:
|
||||
too_short: "è troppo corta (minimo %{count} caratteri)"
|
||||
too_long: "è troppo lunga (massimo %{count} caratteri)"
|
||||
complexity: "deve includere almeno 3 tra: lettere minuscole, maiuscole, numeri e simboli"
|
||||
admin_account:
|
||||
attributes:
|
||||
password:
|
||||
too_short: "è troppo corta (minimo %{count} caratteri)"
|
||||
too_long: "è troppo lunga (massimo %{count} caratteri)"
|
||||
complexity: "deve includere almeno 3 tra: lettere minuscole, maiuscole, numeri e simboli"
|
||||
club:
|
||||
back_to_club: "← Società"
|
||||
sport_label: Sport principale
|
||||
@@ -60,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 responsabile trasmissione per squadra, 1 live"
|
||||
plan_free: "Free — 1 account trasmettitore per squadra, 1 live"
|
||||
plan_light: Premium Light
|
||||
plan_full: Premium Full
|
||||
interval_label: Fatturazione premium
|
||||
interval_yearly: "Annuale — €40/anno (Light) o €200/anno (Full)"
|
||||
interval_monthly: "Mensile — €5/mese (Light) o €20/mese (Full)"
|
||||
interval_yearly: "Annuale — €59/anno (Light) o €199/anno (Full)"
|
||||
interval_monthly: "Mensile — €7,90/mese (Light) o €24,90/mese (Full)"
|
||||
submit: Crea società
|
||||
billing:
|
||||
title: "Abbonamento — %{name}"
|
||||
@@ -401,6 +418,9 @@ 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}"
|
||||
@@ -410,16 +430,53 @@ it:
|
||||
paused: In pausa
|
||||
live: In onda
|
||||
waiting: In attesa
|
||||
live_share_label: "Link diretta (spettatori)"
|
||||
live_share_label: Diretta
|
||||
share_live: Condividi diretta
|
||||
share_action: Condividi
|
||||
copy_link: Copia link
|
||||
regia_share_label: "Link regia (punteggio)"
|
||||
copy_action: Copia
|
||||
copy_live: Copia live
|
||||
copy_regia: Copia regia
|
||||
links_nav: Azioni rapide
|
||||
regia_share_label: Regia
|
||||
stream_controls_label: Live
|
||||
share_regia: Condividi link regia
|
||||
preview_ended: Diretta terminata
|
||||
preview_waiting: Anteprima in attesa del segnale…
|
||||
preview_hide: Nascondi anteprima
|
||||
preview_show: Mostra anteprima
|
||||
preview_off_hint: Anteprima spenta · nessun download video
|
||||
pause: Metti in pausa
|
||||
resume: Riprendi diretta
|
||||
mute: Silenzia audio
|
||||
unmute: Riattiva audio
|
||||
stop: Chiudi diretta
|
||||
min_quality:
|
||||
label: Qualità minima di trasmissione
|
||||
summary: Qualità
|
||||
hint: L'encoder non scende sotto questo livello. Automatica adatta il bitrate per ridurre i salti.
|
||||
recommend: "Consigliata Automatica: si adatta alla rete e al telefono, senza forzare il massimo."
|
||||
auto: Automatica
|
||||
telemetry:
|
||||
label: Telefono
|
||||
waiting: In attesa del telefono…
|
||||
stale: Telefono non risponde
|
||||
just_now: ora
|
||||
seconds_ago: "%{n}s fa"
|
||||
publisher_on: RTMP ok
|
||||
publisher_off: RTMP assente
|
||||
battery: Batteria
|
||||
network: Rete
|
||||
bitrate: Bitrate
|
||||
bitrate_max: "max %{n}"
|
||||
bitrate_title: "%{current} Mbps attuali · massimo %{target} Mbps"
|
||||
bitrate_tap: Tocca per impostare la qualità minima
|
||||
fps: FPS
|
||||
thermal: Temp.
|
||||
thermal_nominal: OK
|
||||
thermal_fair: Calda
|
||||
thermal_serious: Molto calda
|
||||
thermal_critical: Critica
|
||||
modal:
|
||||
set_won_title: Set vinto
|
||||
confirm: Chiudi set
|
||||
@@ -454,9 +511,12 @@ it:
|
||||
unknown_period: "—"
|
||||
resumed: Diretta ripresa
|
||||
paused_cover: "Diretta in pausa — copertina in onda"
|
||||
muted: "Audio silenziato — YouTube non riceve il microfono"
|
||||
unmuted: "Audio microfono riattivato"
|
||||
closed: Diretta chiusa
|
||||
resume_error: Errore ripresa diretta
|
||||
pause_error: Errore pausa diretta
|
||||
mute_error: Errore silenziamento audio
|
||||
close_error: Errore chiusura
|
||||
close_confirm: Chiudere definitivamente la diretta?
|
||||
link_unavailable: Link non disponibile
|
||||
@@ -464,6 +524,8 @@ it:
|
||||
copy_prompt: "Copia il link:"
|
||||
share_regia_text: "Apri questo link per gestire il punteggio della diretta:"
|
||||
share_live_text: "Guarda la diretta su Match Live TV:"
|
||||
min_quality_updated: Qualità minima aggiornata
|
||||
min_quality_error: Errore aggiornamento qualità minima
|
||||
recordings:
|
||||
archive:
|
||||
back_prefix: "←"
|
||||
@@ -600,10 +662,19 @@ it:
|
||||
welcome_back: Bentornato!
|
||||
invalid_credentials: Email o password non validi
|
||||
logged_out: Disconnesso
|
||||
unauthorized: Non autorizzato
|
||||
invalid_token: Token non valido
|
||||
password_resets:
|
||||
email_sent: Se l'email è registrata, riceverai a breve un link per reimpostare la password.
|
||||
invalid_or_expired_link: Link non valido o scaduto. Richiedi un nuovo reset password.
|
||||
password_min_length: La password deve avere almeno 8 caratteri
|
||||
password_too_short: La password deve avere almeno 8 caratteri
|
||||
password_too_long: La password non può superare i 72 caratteri
|
||||
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
|
||||
password_same_as_current: La nuova password deve essere diversa da quella attuale
|
||||
password_too_short: La password deve avere almeno 8 caratteri
|
||||
password_too_long: La password non può superare i 72 caratteri
|
||||
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
|
||||
password_mismatch: Le password non coincidono
|
||||
password_updated: Password aggiornata. Ora puoi accedere.
|
||||
accounts:
|
||||
@@ -611,8 +682,14 @@ it:
|
||||
profile_updated: Profilo aggiornato.
|
||||
password_current_incorrect: La password attuale non è corretta
|
||||
password_too_short: La password deve avere almeno 8 caratteri
|
||||
password_too_long: La password non può superare i 72 caratteri
|
||||
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
|
||||
password_same_as_current: La nuova password deve essere diversa da quella attuale
|
||||
password_too_long: La password non può superare i 72 caratteri
|
||||
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
|
||||
password_mismatch: Le password non coincidono
|
||||
password_updated: Password aggiornata.
|
||||
password_update_failed: Impossibile aggiornare la password
|
||||
replay:
|
||||
download_unavailable: Download non disponibile
|
||||
not_available: Replay non disponibile
|
||||
|
||||
@@ -195,3 +195,18 @@ de:
|
||||
s6_body_html: "Um die von der DSGVO vorgesehenen Rechte auszuüben (Zugang, Löschung, Widerspruch, Widerruf der Einwilligung), schreiben Sie an %{email_link}. Details finden Sie im %{privacy_doc_link}."
|
||||
s6_privacy_doc_link_text: Datenschutzdokument
|
||||
manage_button: Cookie-Einstellungen verwalten
|
||||
support:
|
||||
title: Match Live TV Support
|
||||
meta_description: "Match Live TV Hilfe: Zugangsprobleme, Livestreams, Teamverwaltung und Support-Kontaktdaten."
|
||||
h1: Match Live TV Support
|
||||
intro: "Brauchen Sie Hilfe mit Match Live TV? Bei Problemen mit dem Zugang, der Einrichtung von Livestreams, der Spielverwaltung oder der Nutzung der App können Sie unseren Support kontaktieren."
|
||||
email_label: "Support-E-Mail:"
|
||||
access_title: Zugangsprobleme
|
||||
access_body: "Wenn Sie sich nicht anmelden können, prüfen Sie die Zugangsdaten, die Sie von Ihrem Sportverein erhalten haben. Wenn das Problem weiterhin besteht, kontaktieren Sie den Support."
|
||||
live_title: Probleme während eines Livestreams
|
||||
live_body: "Prüfen Sie Ihre Internetverbindung und versuchen Sie erneut, die Übertragung in der App zu starten. Wenn das Problem weiterhin besteht, kontaktieren Sie den Support und geben Sie Gerät, App-Version und eine kurze Beschreibung des Problems an."
|
||||
team_title: Teamverwaltung
|
||||
team_body: "Konten und Übertragungsberechtigungen werden vom Sportverein verwaltet."
|
||||
privacy_title: Datenschutz
|
||||
privacy_body_html: "Informationen zur Verarbeitung personenbezogener Daten finden Sie auf der Seite %{privacy_link}."
|
||||
privacy_link_text: Datenschutz
|
||||
|
||||
@@ -195,3 +195,18 @@ en:
|
||||
s6_body_html: "To exercise the rights provided by the GDPR (access, erasure, objection, withdrawal of consent) write to %{email_link}. Details are in the %{privacy_doc_link}."
|
||||
s6_privacy_doc_link_text: privacy document
|
||||
manage_button: Manage cookie preferences
|
||||
support:
|
||||
title: Match Live TV Support
|
||||
meta_description: "Match Live TV help: access issues, live streaming, team management and support contact details."
|
||||
h1: Match Live TV Support
|
||||
intro: "Need help with Match Live TV? For access issues, live stream setup, match management or app usage, you can contact our support team."
|
||||
email_label: "Support email:"
|
||||
access_title: Access issues
|
||||
access_body: "If you cannot sign in, check the credentials provided by your sports club. If the problem persists, contact support."
|
||||
live_title: Issues during a live stream
|
||||
live_body: "Check your Internet connection and try starting the broadcast from the app again. If the problem persists, contact support and include your device, app version and a short description of the issue."
|
||||
team_title: Team management
|
||||
team_body: "Accounts and broadcasting permissions are managed by the sports club."
|
||||
privacy_title: Privacy
|
||||
privacy_body_html: "For information on personal data processing, see the %{privacy_link} page."
|
||||
privacy_link_text: Privacy
|
||||
|
||||
@@ -195,3 +195,18 @@ es:
|
||||
s6_body_html: "Para ejercer los derechos previstos por el RGPD (acceso, supresión, oposición, revocación del consentimiento) escribe a %{email_link}. Más detalles en el %{privacy_doc_link}."
|
||||
s6_privacy_doc_link_text: documento de privacidad
|
||||
manage_button: Gestionar preferencias de cookies
|
||||
support:
|
||||
title: Soporte Match Live TV
|
||||
meta_description: "Ayuda de Match Live TV: problemas de acceso, directos, gestión del equipo y datos de contacto del soporte."
|
||||
h1: Soporte Match Live TV
|
||||
intro: "¿Necesitas ayuda con Match Live TV? Para problemas de acceso, configuración de directos, gestión de partidos o uso de la app puedes contactar con nuestro soporte."
|
||||
email_label: "Correo de soporte:"
|
||||
access_title: Problemas de acceso
|
||||
access_body: "Si no puedes acceder, verifica las credenciales recibidas de tu club deportivo. Si el problema continúa, contacta con el soporte."
|
||||
live_title: Problemas durante un directo
|
||||
live_body: "Verifica la conexión a Internet e intenta de nuevo iniciar la transmisión desde la app. Si el problema continúa, contacta con el soporte indicando el dispositivo, la versión de la app y una breve descripción del problema."
|
||||
team_title: Gestión del equipo
|
||||
team_body: "Las cuentas y los permisos de transmisión los gestiona el club deportivo."
|
||||
privacy_title: Privacidad
|
||||
privacy_body_html: "Para información sobre el tratamiento de datos personales, consulta la página %{privacy_link}."
|
||||
privacy_link_text: Privacidad
|
||||
|
||||
@@ -195,3 +195,18 @@ fr:
|
||||
s6_body_html: "Pour exercer les droits prévus par le RGPD (accès, effacement, opposition, retrait du consentement), écrivez à %{email_link}. Détails dans le %{privacy_doc_link}."
|
||||
s6_privacy_doc_link_text: document de confidentialité
|
||||
manage_button: Gérer les préférences de cookies
|
||||
support:
|
||||
title: Support Match Live TV
|
||||
meta_description: "Assistance Match Live TV : problèmes d'accès, directs, gestion d'équipe et coordonnées du support."
|
||||
h1: Support Match Live TV
|
||||
intro: "Besoin d'aide avec Match Live TV ? Pour les problèmes d'accès, la configuration des directs, la gestion des matchs ou l'utilisation de l'application, vous pouvez contacter notre support."
|
||||
email_label: "E-mail du support :"
|
||||
access_title: Problèmes d'accès
|
||||
access_body: "Si vous ne parvenez pas à vous connecter, vérifiez les identifiants fournis par votre club sportif. Si le problème persiste, contactez le support."
|
||||
live_title: Problèmes pendant un direct
|
||||
live_body: "Vérifiez votre connexion Internet et réessayez de démarrer la diffusion depuis l'application. Si le problème persiste, contactez le support en indiquant l'appareil, la version de l'application et une brève description du problème."
|
||||
team_title: Gestion de l'équipe
|
||||
team_body: "Les comptes et les autorisations de diffusion sont gérés par le club sportif."
|
||||
privacy_title: Confidentialité
|
||||
privacy_body_html: "Pour les informations sur le traitement des données personnelles, consultez la page %{privacy_link}."
|
||||
privacy_link_text: Confidentialité
|
||||
|
||||
@@ -195,3 +195,18 @@ it:
|
||||
s6_body_html: "Per esercitare i diritti previsti dal GDPR (accesso, cancellazione, opposizione, revoca consenso) scrivi a %{email_link}. Dettagli nel %{privacy_doc_link}."
|
||||
s6_privacy_doc_link_text: documento privacy
|
||||
manage_button: Gestisci preferenze cookie
|
||||
support:
|
||||
title: Supporto Match Live TV
|
||||
meta_description: "Assistenza Match Live TV: problemi di accesso, dirette live, gestione squadra e contatti del supporto."
|
||||
h1: Supporto Match Live TV
|
||||
intro: "Hai bisogno di assistenza con Match Live TV? Per problemi di accesso, configurazione delle dirette, gestione delle partite o utilizzo dell’app puoi contattare il nostro supporto."
|
||||
email_label: "Email di supporto:"
|
||||
access_title: Problemi di accesso
|
||||
access_body: "Se non riesci ad accedere, verifica le credenziali ricevute dalla tua società sportiva. Se il problema persiste, contatta il supporto."
|
||||
live_title: Problemi durante una diretta
|
||||
live_body: "Verifica la connessione Internet e riprova ad avviare la trasmissione dall’app. Se il problema persiste, contatta il supporto indicando dispositivo, versione dell’app e una breve descrizione del problema."
|
||||
team_title: Gestione della squadra
|
||||
team_body: "Gli account e le autorizzazioni per la trasmissione sono gestiti dalla società sportiva."
|
||||
privacy_title: Privacy
|
||||
privacy_body_html: "Per informazioni sul trattamento dei dati personali consulta la pagina %{privacy_link}."
|
||||
privacy_link_text: Privacy
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user