Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d100c8030f | ||
|
|
bc2257efd2 | ||
|
|
f1906517a9 | ||
|
|
00fde25c50 | ||
|
|
4c5af99efa | ||
|
|
8bf12e7721 | ||
|
|
5a0ca8ef1c | ||
|
|
714602f3da | ||
|
|
baac3a512b | ||
|
|
559284f0b2 | ||
|
|
e5fc925bae | ||
|
|
318a319608 | ||
|
|
0b55d5a8fa | ||
|
|
c3878cdc6d | ||
|
|
4af4fa68ac | ||
|
|
45bdda7c95 | ||
|
|
b926531447 | ||
|
|
da6fc1d523 | ||
|
|
1d1cbf9f3f | ||
|
|
dd9901519a | ||
|
|
53449c5d3c | ||
|
|
db908e3109 | ||
|
|
5857f60d79 | ||
|
|
83a804a709 | ||
|
|
b8694a6b7b | ||
|
|
5daada81b0 | ||
|
|
d85bab30d1 | ||
|
|
7b3256c8a0 | ||
|
|
9bc89fceba | ||
|
|
d5ec266437 | ||
|
|
a448ad59ee | ||
|
|
74a156cedc | ||
|
|
97d787f758 |
@@ -9,13 +9,23 @@ module Admin
|
|||||||
return render :edit, status: :unprocessable_entity
|
return render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
if params[:password].blank? || params[:password].length < 8
|
if params[:password] != params[:password_confirmation]
|
||||||
flash.now[:alert] = t("admin.flash.password_too_short")
|
flash.now[:alert] = t("admin.flash.password_mismatch")
|
||||||
return render :edit, status: :unprocessable_entity
|
return render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
if params[:password] != params[:password_confirmation]
|
if (code = PasswordComplexity.violation(params[:password]))
|
||||||
flash.now[:alert] = t("admin.flash.password_mismatch")
|
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
|
return render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Admin
|
||||||
|
class StreamNodesController < Admin::BaseController
|
||||||
|
def index
|
||||||
|
Streams::NodeRegistry.ensure_home_from_env!
|
||||||
|
@nodes = StreamNode.order(:role, :slug)
|
||||||
|
@dns_provider = ENV.fetch("STREAM_DNS_PROVIDER", "lab")
|
||||||
|
@cloud_provider = ENV.fetch("STREAM_CLOUD_PROVIDER", "local_lab")
|
||||||
|
@lab_hosts = lab_dns_snippet
|
||||||
|
@hetzner_configured = ENV["HCLOUD_TOKEN"].present?
|
||||||
|
@autoscale_metrics = Streams::Autoscaler.metrics
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
kind = params[:kind].to_s
|
||||||
|
node =
|
||||||
|
if kind == "cloud"
|
||||||
|
Streams::NodeProvisioner.new.provision_cloud!
|
||||||
|
else
|
||||||
|
Streams::NodeProvisioner.new.provision_lab!
|
||||||
|
end
|
||||||
|
redirect_to admin_stream_nodes_path,
|
||||||
|
notice: t("admin.flash.stream_node_created", slug: node.slug)
|
||||||
|
rescue Streams::NodeProvisioner::Error, Streams::CloudProviders::Error, Streams::DnsProviders::Error,
|
||||||
|
KeyError => e
|
||||||
|
redirect_to admin_stream_nodes_path, alert: e.message
|
||||||
|
end
|
||||||
|
|
||||||
|
def drain
|
||||||
|
node = StreamNode.find(params[:id])
|
||||||
|
Streams::NodeProvisioner.new.drain!(node)
|
||||||
|
redirect_to admin_stream_nodes_path, notice: t("admin.flash.stream_node_draining", slug: node.slug)
|
||||||
|
rescue Streams::NodeProvisioner::Error => e
|
||||||
|
redirect_to admin_stream_nodes_path, alert: e.message
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
node = StreamNode.find(params[:id])
|
||||||
|
Streams::NodeProvisioner.new.decommission!(node)
|
||||||
|
redirect_to admin_stream_nodes_path, notice: t("admin.flash.stream_node_destroyed", slug: node.slug)
|
||||||
|
rescue Streams::NodeProvisioner::BusyError, Streams::NodeProvisioner::Error,
|
||||||
|
Streams::CloudProviders::Error, Streams::DnsProviders::Error => e
|
||||||
|
redirect_to admin_stream_nodes_path, alert: e.message
|
||||||
|
end
|
||||||
|
|
||||||
|
def kill_switch
|
||||||
|
Streams::Autoscaler.engage_kill_switch!
|
||||||
|
redirect_to admin_stream_nodes_path, notice: t("admin.flash.autoscale_kill_on")
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_kill_switch
|
||||||
|
Streams::Autoscaler.clear_kill_switch!
|
||||||
|
redirect_to admin_stream_nodes_path, notice: t("admin.flash.autoscale_kill_off")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def lab_dns_snippet
|
||||||
|
return "" unless @dns_provider == "lab"
|
||||||
|
|
||||||
|
Streams::DnsProviders::Lab.new.hosts_file_snippet
|
||||||
|
rescue Redis::BaseError
|
||||||
|
""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class AccountsController < ApplicationController
|
||||||
|
def show
|
||||||
|
render json: user_json(current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
name = params[:name].to_s.strip
|
||||||
|
if name.blank?
|
||||||
|
return render json: { error: I18n.t("flash.accounts.name_required") }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
if current_user.update(name: name)
|
||||||
|
render json: user_json(current_user)
|
||||||
|
else
|
||||||
|
render json: { error: current_user.errors.full_messages.join(", ") }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def password
|
||||||
|
result = Users::ChangePassword.call(
|
||||||
|
user: current_user,
|
||||||
|
current_password: params[:current_password],
|
||||||
|
password: params[:password],
|
||||||
|
password_confirmation: params[:password_confirmation]
|
||||||
|
)
|
||||||
|
|
||||||
|
unless result.ok?
|
||||||
|
return render json: { error: password_error_message(result.error) }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: { message: I18n.t("flash.accounts.password_updated") }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def user_json(user)
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def password_error_message(code)
|
||||||
|
case code
|
||||||
|
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
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
module Api
|
module Api
|
||||||
module V1
|
module V1
|
||||||
class AuthController < ApplicationController
|
class AuthController < ApplicationController
|
||||||
skip_before_action :authenticate_request!, only: %i[login refresh register]
|
skip_before_action :authenticate_request!, only: %i[login refresh register forgot_password]
|
||||||
|
|
||||||
def register
|
def register
|
||||||
user = User.new(
|
user = User.new(
|
||||||
@@ -22,18 +22,18 @@ module Api
|
|||||||
if user&.authenticate(params[:password])
|
if user&.authenticate(params[:password])
|
||||||
render json: token_response(user), status: :ok
|
render json: token_response(user), status: :ok
|
||||||
else
|
else
|
||||||
render json: { error: "Invalid credentials" }, status: :unauthorized
|
render json: { error: I18n.t("flash.sessions.invalid_credentials") }, status: :unauthorized
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def logout
|
def logout
|
||||||
render json: { message: "Logged out" }
|
render json: { message: I18n.t("flash.sessions.logged_out") }
|
||||||
end
|
end
|
||||||
|
|
||||||
def refresh
|
def refresh
|
||||||
payload = JsonWebToken.decode(params[:refresh_token] || bearer_token)
|
payload = JsonWebToken.decode(params[:refresh_token] || bearer_token)
|
||||||
user = User.find_by(id: payload&.dig(:user_id))
|
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)
|
render json: token_response(user)
|
||||||
end
|
end
|
||||||
@@ -42,6 +42,13 @@ module Api
|
|||||||
render json: user_json(current_user)
|
render json: user_json(current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def forgot_password
|
||||||
|
Users::RequestPasswordReset.call(email: params[:email])
|
||||||
|
render json: {
|
||||||
|
message: I18n.t("flash.password_resets.email_sent")
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def token_response(user)
|
def token_response(user)
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ module Api
|
|||||||
platform: session.platform,
|
platform: session.platform,
|
||||||
rtmp_ingest_url: session.rtmp_ingest_url,
|
rtmp_ingest_url: session.rtmp_ingest_url,
|
||||||
hls_playback_url: session.hls_playback_url,
|
hls_playback_url: session.hls_playback_url,
|
||||||
|
stream_node: session.stream_node&.slug,
|
||||||
watch_page_url: session.matchlivetv_platform? ? session.watch_page_url : nil,
|
watch_page_url: session.matchlivetv_platform? ? session.watch_page_url : nil,
|
||||||
share_url: session.share_url,
|
share_url: session.share_url,
|
||||||
youtube_watch_url: session.youtube_watch_url,
|
youtube_watch_url: session.youtube_watch_url,
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
class ApplicationController < ActionController::API
|
class ApplicationController < ActionController::API
|
||||||
include ActionController::HttpAuthentication::Token::ControllerMethods
|
include ActionController::HttpAuthentication::Token::ControllerMethods
|
||||||
|
|
||||||
|
before_action :set_api_locale
|
||||||
before_action :authenticate_request!
|
before_action :authenticate_request!
|
||||||
|
|
||||||
attr_reader :current_user
|
attr_reader :current_user
|
||||||
|
|
||||||
private
|
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!
|
def authenticate_request!
|
||||||
token = bearer_token
|
token = bearer_token
|
||||||
payload = JsonWebToken.decode(token)
|
payload = JsonWebToken.decode(token)
|
||||||
@current_user = User.find_by(id: payload[:user_id]) if payload
|
@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
|
end
|
||||||
|
|
||||||
def bearer_token
|
def bearer_token
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
module Public
|
||||||
|
class AccountsController < WebBaseController
|
||||||
|
before_action :require_login!
|
||||||
|
|
||||||
|
def show
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
name = params[:name].to_s.strip
|
||||||
|
if name.blank?
|
||||||
|
flash.now[:alert] = t("flash.accounts.name_required")
|
||||||
|
return render :show, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
if current_user.update(name: name)
|
||||||
|
redirect_to public_account_path, notice: t("flash.accounts.profile_updated")
|
||||||
|
else
|
||||||
|
flash.now[:alert] = current_user.errors.full_messages.to_sentence
|
||||||
|
render :show, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_password
|
||||||
|
result = Users::ChangePassword.call(
|
||||||
|
user: current_user,
|
||||||
|
current_password: params[:current_password],
|
||||||
|
password: params[:password],
|
||||||
|
password_confirmation: params[:password_confirmation]
|
||||||
|
)
|
||||||
|
|
||||||
|
unless result.ok?
|
||||||
|
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")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -11,17 +11,8 @@ module Public
|
|||||||
load_club_billing_context_for_pricing
|
load_club_billing_context_for_pricing
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
def support
|
||||||
|
@app_store_review_chrome = true
|
||||||
def load_club_billing_context_for_pricing
|
|
||||||
return unless logged_in?
|
|
||||||
|
|
||||||
@club = current_user.primary_club
|
|
||||||
return unless @club
|
|
||||||
|
|
||||||
@subscription = @club.subscription
|
|
||||||
@team = @club.teams.order(:name).first
|
|
||||||
@entitlements = @team&.entitlements
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def privacy
|
def privacy
|
||||||
@@ -38,5 +29,18 @@ module Public
|
|||||||
|
|
||||||
def pallavolo
|
def pallavolo
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def load_club_billing_context_for_pricing
|
||||||
|
return unless logged_in?
|
||||||
|
|
||||||
|
@club = current_user.primary_club
|
||||||
|
return unless @club
|
||||||
|
|
||||||
|
@subscription = @club.subscription
|
||||||
|
@team = @club.teams.order(:name).first
|
||||||
|
@entitlements = @team&.entitlements
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ module Public
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
user = User.find_by(email: params[:email]&.downcase&.strip)
|
Users::RequestPasswordReset.call(email: params[:email])
|
||||||
if user
|
|
||||||
token = user.generate_password_reset!
|
|
||||||
UserMailer.password_reset(user, token).deliver_now
|
|
||||||
end
|
|
||||||
|
|
||||||
redirect_to public_login_path,
|
redirect_to public_login_path,
|
||||||
notice: t("flash.password_resets.email_sent")
|
notice: t("flash.password_resets.email_sent")
|
||||||
@@ -17,7 +13,7 @@ module Public
|
|||||||
def edit
|
def edit
|
||||||
@user = User.find_by_password_reset_token(params[:token])
|
@user = User.find_by_password_reset_token(params[:token])
|
||||||
if @user.nil? || @user.password_reset_expired?
|
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")
|
alert: t("flash.password_resets.invalid_or_expired_link")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -27,19 +23,25 @@ module Public
|
|||||||
def update
|
def update
|
||||||
@user = User.find_by_password_reset_token(params[:token])
|
@user = User.find_by_password_reset_token(params[:token])
|
||||||
if @user.nil? || @user.password_reset_expired?
|
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")
|
alert: t("flash.password_resets.invalid_or_expired_link")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if params[:password].blank? || params[:password].length < 8
|
if params[:password] != params[:password_confirmation]
|
||||||
flash.now[:alert] = t("flash.password_resets.password_min_length")
|
flash.now[:alert] = t("flash.password_resets.password_mismatch")
|
||||||
@token = params[:token]
|
@token = params[:token]
|
||||||
return render :edit, status: :unprocessable_entity
|
return render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
if params[:password] != params[:password_confirmation]
|
if (code = PasswordComplexity.violation(params[:password]))
|
||||||
flash.now[:alert] = t("flash.password_resets.password_mismatch")
|
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]
|
@token = params[:token]
|
||||||
return render :edit, status: :unprocessable_entity
|
return render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ module Public
|
|||||||
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
|
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
|
||||||
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
|
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
|
||||||
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
|
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
|
||||||
|
{ loc: "#{base}/support", changefreq: "yearly", priority: "0.3" },
|
||||||
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
|
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
|
||||||
{ loc: "#{base}/termini", changefreq: "yearly", priority: "0.3" }
|
{ loc: "#{base}/termini", changefreq: "yearly", priority: "0.3" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ class CleanupExpiredSessionsJob
|
|||||||
.where("updated_at < ?", 6.hours.ago)
|
.where("updated_at < ?", 6.hours.ago)
|
||||||
.find_each do |session|
|
.find_each do |session|
|
||||||
session.fail! if session.may_fail?
|
session.fail! if session.may_fail?
|
||||||
Mediamtx::Client.new.delete_path(session)
|
Mediamtx::Client.for_session(session).delete_path(session)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Streams
|
||||||
|
class AutoscalerJob
|
||||||
|
include Sidekiq::Job
|
||||||
|
|
||||||
|
sidekiq_options retry: 1, queue: "default"
|
||||||
|
|
||||||
|
INTERVAL_SECS = ENV.fetch("STREAM_AUTOSCALE_INTERVAL_SECS", "60").to_i
|
||||||
|
REDIS_CHAIN_KEY = "streams:autoscaler:chain"
|
||||||
|
|
||||||
|
def self.ensure_chain
|
||||||
|
return unless redis
|
||||||
|
return if redis.get(REDIS_CHAIN_KEY)
|
||||||
|
|
||||||
|
redis.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL_SECS * 2)
|
||||||
|
perform_in(INTERVAL_SECS)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.redis
|
||||||
|
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||||
|
rescue Redis::CannotConnectError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform
|
||||||
|
Streams::Autoscaler.reconcile!
|
||||||
|
ensure
|
||||||
|
self.class.redis&.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL_SECS * 2)
|
||||||
|
self.class.perform_in(INTERVAL_SECS)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Avvia/riavvia il relay YouTube solo nel container sidekiq (YOUTUBE_RELAY_WORKER=1).
|
# Avvia/riavvia il relay YouTube solo sui worker con YOUTUBE_RELAY_WORKER=1 (coda youtube_relay).
|
||||||
class YoutubeRelayEnsureJob < ApplicationJob
|
class YoutubeRelayEnsureJob < ApplicationJob
|
||||||
queue_as :default
|
queue_as Streams::YoutubeRelay::QUEUE
|
||||||
|
|
||||||
def perform(session_id)
|
def perform(session_id)
|
||||||
session = StreamSession.find_by(id: session_id)
|
session = StreamSession.find_by(id: session_id)
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
|
# Ferma il relay solo sull'owner. Se il job gira su un altro host, requeue breve.
|
||||||
class YoutubeRelayStopJob < ApplicationJob
|
class YoutubeRelayStopJob < ApplicationJob
|
||||||
queue_as :default
|
queue_as Streams::YoutubeRelay::QUEUE
|
||||||
|
|
||||||
def perform(session_id)
|
discard_on ActiveJob::DeserializationError
|
||||||
|
|
||||||
|
def perform(session_id, attempts = 0)
|
||||||
session = StreamSession.find_by(id: session_id)
|
session = StreamSession.find_by(id: session_id)
|
||||||
return unless session
|
return unless session
|
||||||
|
|
||||||
Streams::YoutubeRelay.stop_on_worker!(session) if Streams::YoutubeRelay.worker?
|
unless Streams::YoutubeRelay.worker?
|
||||||
|
# Solo i worker relay processano questa coda in modo utile.
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
result = Streams::YoutubeRelay.stop_on_worker!(session)
|
||||||
|
return unless result == :wrong_host
|
||||||
|
return if attempts >= 30
|
||||||
|
|
||||||
|
self.class.set(wait: 2.seconds).perform_later(session_id, attempts + 1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class AdminAccount < ApplicationRecord
|
class AdminAccount < ApplicationRecord
|
||||||
|
include PasswordComplexity
|
||||||
|
|
||||||
has_secure_password
|
has_secure_password
|
||||||
|
|
||||||
validates :username, presence: true, uniqueness: true
|
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
|
||||||
@@ -4,7 +4,7 @@ module Ops
|
|||||||
|
|
||||||
KINDS = %w[
|
KINDS = %w[
|
||||||
disk_space recordings_size service_down http_public http_rails rails_latency
|
disk_space recordings_size service_down http_public http_rails rails_latency
|
||||||
sidekiq_stale sidekiq_dead log_pattern garage_storage
|
sidekiq_stale sidekiq_dead log_pattern garage_storage stream_overflow
|
||||||
].freeze
|
].freeze
|
||||||
SEVERITIES = %w[critical warning info].freeze
|
SEVERITIES = %w[critical warning info].freeze
|
||||||
STATUSES = %w[open acknowledged resolved].freeze
|
STATUSES = %w[open acknowledged resolved].freeze
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Nodo streaming (MediaMTX [+ relay ffmpeg]). Fase 0: registry + assignment URL.
|
||||||
|
class StreamNode < ApplicationRecord
|
||||||
|
ROLES = %w[home cloud lab].freeze
|
||||||
|
STATUSES = %w[provisioning ready draining offline error].freeze
|
||||||
|
PROVIDERS = %w[local proxmox_lab hetzner].freeze
|
||||||
|
|
||||||
|
has_many :stream_sessions, dependent: :nullify
|
||||||
|
|
||||||
|
validates :slug, presence: true, uniqueness: true
|
||||||
|
validates :hostname, presence: true
|
||||||
|
validates :role, inclusion: { in: ROLES }
|
||||||
|
validates :status, inclusion: { in: STATUSES }
|
||||||
|
validates :provider, inclusion: { in: PROVIDERS }
|
||||||
|
validates :rtmp_base_url, :hls_base_url, :api_base_url, presence: true
|
||||||
|
validates :max_publishers, :max_relays, numericality: { greater_than: 0 }
|
||||||
|
|
||||||
|
scope :ready, -> { where(status: "ready") }
|
||||||
|
scope :allocatable, -> { ready }
|
||||||
|
|
||||||
|
# Sessioni che occupano uno slot path MediaMTX su questo nodo.
|
||||||
|
def occupying_sessions
|
||||||
|
stream_sessions.where(status: %w[idle connecting live reconnecting paused])
|
||||||
|
end
|
||||||
|
|
||||||
|
def active_publishers
|
||||||
|
occupying_sessions.count
|
||||||
|
end
|
||||||
|
|
||||||
|
def free_slots
|
||||||
|
[max_publishers - active_publishers, 0].max
|
||||||
|
end
|
||||||
|
|
||||||
|
def allocatable?
|
||||||
|
status == "ready" && free_slots.positive?
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -7,6 +7,7 @@ class StreamSession < ApplicationRecord
|
|||||||
|
|
||||||
belongs_to :match
|
belongs_to :match
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
belongs_to :stream_node, optional: true
|
||||||
has_many :stream_events, dependent: :destroy
|
has_many :stream_events, dependent: :destroy
|
||||||
has_one :score_state, dependent: :destroy
|
has_one :score_state, dependent: :destroy
|
||||||
has_many :device_states, dependent: :destroy
|
has_many :device_states, dependent: :destroy
|
||||||
@@ -74,7 +75,7 @@ class StreamSession < ApplicationRecord
|
|||||||
def rtmp_ingest_url
|
def rtmp_ingest_url
|
||||||
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
|
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
|
||||||
# MediaMTX path = live/match_{uuid} (no ?token= nel path).
|
# MediaMTX path = live/match_{uuid} (no ?token= nel path).
|
||||||
"#{MatchLiveTv.mediamtx_rtmp_url}/#{mediamtx_path_name}"
|
"#{rtmp_base_url.chomp('/')}/#{mediamtx_path_name}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def mediamtx_path_name
|
def mediamtx_path_name
|
||||||
@@ -90,8 +91,29 @@ class StreamSession < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def hls_playback_url
|
def hls_playback_url
|
||||||
base = MatchLiveTv.hls_public_url.chomp("/")
|
"#{hls_base_url.chomp('/')}/#{effective_hls_path_name}/index.m3u8"
|
||||||
"#{base}/#{effective_hls_path_name}/index.m3u8"
|
end
|
||||||
|
|
||||||
|
def rtmp_base_url
|
||||||
|
stream_node&.rtmp_base_url.presence || MatchLiveTv.mediamtx_rtmp_url
|
||||||
|
end
|
||||||
|
|
||||||
|
def hls_base_url
|
||||||
|
stream_node&.hls_base_url.presence || MatchLiveTv.hls_public_url
|
||||||
|
end
|
||||||
|
|
||||||
|
def mediamtx_api_base_url
|
||||||
|
stream_node&.api_base_url.presence || MatchLiveTv.mediamtx_api_url
|
||||||
|
end
|
||||||
|
|
||||||
|
def mediamtx_internal_rtmp_url
|
||||||
|
stream_node&.internal_rtmp_url.presence ||
|
||||||
|
ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935")
|
||||||
|
end
|
||||||
|
|
||||||
|
def mediamtx_internal_hls_url
|
||||||
|
stream_node&.internal_hls_url.presence ||
|
||||||
|
ENV.fetch("MEDIAMTX_HLS_URL", "http://mediamtx:8888")
|
||||||
end
|
end
|
||||||
|
|
||||||
def effective_hls_path_name
|
def effective_hls_path_name
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class User < ApplicationRecord
|
class User < ApplicationRecord
|
||||||
|
include PasswordComplexity
|
||||||
|
|
||||||
ROLES = %w[admin coach parent volunteer].freeze
|
ROLES = %w[admin coach parent volunteer].freeze
|
||||||
|
|
||||||
has_secure_password
|
has_secure_password
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ module Mediamtx
|
|||||||
class Client
|
class Client
|
||||||
class Error < StandardError; end
|
class Error < StandardError; end
|
||||||
|
|
||||||
|
def self.for_session(session)
|
||||||
|
new(base_url: session.mediamtx_api_base_url)
|
||||||
|
end
|
||||||
|
|
||||||
def initialize(base_url: MatchLiveTv.mediamtx_api_url)
|
def initialize(base_url: MatchLiveTv.mediamtx_api_url)
|
||||||
|
@base_url = base_url
|
||||||
@conn = Faraday.new(url: base_url) do |f|
|
@conn = Faraday.new(url: base_url) do |f|
|
||||||
f.request :json
|
f.request :json
|
||||||
f.response :json
|
f.response :json
|
||||||
@@ -12,6 +17,8 @@ module Mediamtx
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
attr_reader :base_url
|
||||||
|
|
||||||
def create_path(session)
|
def create_path(session)
|
||||||
path = session.mediamtx_path_name
|
path = session.mediamtx_path_name
|
||||||
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe
|
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe
|
||||||
@@ -98,6 +105,16 @@ module Mediamtx
|
|||||||
[]
|
[]
|
||||||
end
|
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
|
def online_path_names
|
||||||
Set.new(list_paths.filter_map { |item| item["name"] if item["online"] })
|
Set.new(list_paths.filter_map { |item| item["name"] if item["online"] })
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,17 +4,37 @@ module Mediamtx
|
|||||||
module_function
|
module_function
|
||||||
|
|
||||||
def active?(session)
|
def active?(session)
|
||||||
|
return true if rtmp_publisher?(session)
|
||||||
|
|
||||||
active_path?(path_info(session))
|
active_path?(path_info(session))
|
||||||
end
|
end
|
||||||
|
|
||||||
def path_info(session)
|
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
|
end
|
||||||
|
|
||||||
|
# MediaMTX <=1.19: online + source.type=rtmpConn.
|
||||||
|
# MediaMTX 1.20+: online/source spesso null anche con publisher; usare rtmpconns.
|
||||||
def active_path?(info)
|
def active_path?(info)
|
||||||
return false unless 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
|
end
|
||||||
|
|
||||||
def h264_video?(info)
|
def h264_video?(info)
|
||||||
@@ -26,12 +46,14 @@ module Mediamtx
|
|||||||
end
|
end
|
||||||
|
|
||||||
def video_publishing?(session)
|
def video_publishing?(session)
|
||||||
info = path_info(session)
|
return false unless active?(session)
|
||||||
return false unless active_path?(info)
|
|
||||||
# Slate alwaysAvailable ha H264 ma non è il telefono.
|
|
||||||
return false unless info.dig("source", "type") == "rtmpConn"
|
|
||||||
|
|
||||||
|
info = path_info(session)
|
||||||
h264_video?(info)
|
h264_video?(info)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def rtmp_source?(type)
|
||||||
|
type.to_s.match?(/\Artmps?Conn\z/)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ module Mediamtx
|
|||||||
return @session if @session.terminal?
|
return @session if @session.terminal?
|
||||||
|
|
||||||
path_info = Mediamtx::PublisherOnline.path_info(@session)
|
path_info = Mediamtx::PublisherOnline.path_info(@session)
|
||||||
publisher_online = Mediamtx::PublisherOnline.active_path?(path_info)
|
publisher_online = Mediamtx::PublisherOnline.active?(@session)
|
||||||
|
|
||||||
if publisher_online
|
if publisher_online
|
||||||
clear_publisher_misses!(@session.id)
|
clear_publisher_misses!(@session.id)
|
||||||
@@ -86,7 +86,7 @@ module Mediamtx
|
|||||||
key = format("youtube:slate_disabled:%s", session.id)
|
key = format("youtube:slate_disabled:%s", session.id)
|
||||||
return unless redis.set(key, "1", nx: true, ex: 48.hours.to_i)
|
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
|
rescue Client::Error => e
|
||||||
redis.del(format("youtube:slate_disabled:%s", session.id))
|
redis.del(format("youtube:slate_disabled:%s", session.id))
|
||||||
Rails.logger.warn("[PublisherSync] disable slate session=#{session.id}: #{e.message}")
|
Rails.logger.warn("[PublisherSync] disable slate session=#{session.id}: #{e.message}")
|
||||||
@@ -95,7 +95,7 @@ module Mediamtx
|
|||||||
def restore_slate_path!(session)
|
def restore_slate_path!(session)
|
||||||
return if session.platform == "matchlivetv"
|
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
|
rescue Client::Error => e
|
||||||
Rails.logger.warn("[PublisherSync] enable slate session=#{session.id}: #{e.message}")
|
Rails.logger.warn("[PublisherSync] enable slate session=#{session.id}: #{e.message}")
|
||||||
end
|
end
|
||||||
@@ -128,7 +128,7 @@ module Mediamtx
|
|||||||
return
|
return
|
||||||
end
|
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)
|
redis.set(key, desired, ex: 48.hours.to_i)
|
||||||
mark_recording_patch!(session.id)
|
mark_recording_patch!(session.id)
|
||||||
rescue Client::Error => e
|
rescue Client::Error => e
|
||||||
@@ -183,7 +183,7 @@ module Mediamtx
|
|||||||
key = format("youtube_relay:sched:%s", session.id)
|
key = format("youtube_relay:sched:%s", session.id)
|
||||||
return unless redis.set(key, "1", nx: true, ex: 10)
|
return unless redis.set(key, "1", nx: true, ex: 10)
|
||||||
|
|
||||||
YoutubeRelayEnsureJob.perform_later(session.id)
|
YoutubeRelayEnsureJob.set(queue: Streams::YoutubeRelay::QUEUE).perform_later(session.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def redis
|
def redis
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ module Ops
|
|||||||
check_sidekiq_heartbeat,
|
check_sidekiq_heartbeat,
|
||||||
check_sidekiq_dead,
|
check_sidekiq_dead,
|
||||||
check_http_rails,
|
check_http_rails,
|
||||||
check_rails_latency
|
check_rails_latency,
|
||||||
|
check_stream_overflow
|
||||||
]
|
]
|
||||||
findings << check_http_public if public_check_due?
|
findings << check_http_public if public_check_due?
|
||||||
findings
|
findings
|
||||||
@@ -156,11 +157,63 @@ module Ops
|
|||||||
force_path_style: MatchLiveTv.replay_storage_force_path_style?
|
force_path_style: MatchLiveTv.replay_storage_force_path_style?
|
||||||
)
|
)
|
||||||
client.head_bucket(bucket: MatchLiveTv.replay_storage_bucket)
|
client.head_bucket(bucket: MatchLiveTv.replay_storage_bucket)
|
||||||
ok_finding("garage_storage:ok", "Garage storage OK")
|
ok_finding("garage_storage:head", "Garage storage OK")
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
fail_finding("garage_storage", "warning", "garage_storage:head", "Garage storage non raggiungibile", e.message)
|
fail_finding("garage_storage", "warning", "garage_storage:head", "Garage storage non raggiungibile", e.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def check_stream_overflow
|
||||||
|
return ok_finding("stream_overflow:skip", "Nodi stream non migrati") unless ActiveRecord::Base.connection.data_source_exists?("stream_nodes")
|
||||||
|
|
||||||
|
orphan_hours = ENV.fetch("STREAM_OVERFLOW_ORPHAN_HOURS", "3").to_i
|
||||||
|
orphans = StreamNode.where.not(slug: "home").where(status: %w[ready draining]).select do |n|
|
||||||
|
n.active_publishers.zero? && n.created_at < orphan_hours.hours.ago
|
||||||
|
end
|
||||||
|
metrics = Streams::Autoscaler.metrics
|
||||||
|
over_budget = !metrics[:within_budget]
|
||||||
|
at_max = metrics[:overflow_nodes] >= metrics[:max_overflow_nodes] && metrics[:free_slots] <= metrics[:soft_free_slots]
|
||||||
|
|
||||||
|
if orphans.any?
|
||||||
|
return Finding.new(
|
||||||
|
kind: "stream_overflow",
|
||||||
|
severity: "warning",
|
||||||
|
healthy: false,
|
||||||
|
title: "Nodi stream overflow idle",
|
||||||
|
message: "#{orphans.size} nodo/i idle da >#{orphan_hours}h: #{orphans.map(&:slug).join(', ')}",
|
||||||
|
metadata: { "slugs" => orphans.map(&:slug) },
|
||||||
|
fingerprint: "stream_overflow:orphan_idle"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if over_budget
|
||||||
|
return Finding.new(
|
||||||
|
kind: "stream_overflow",
|
||||||
|
severity: "warning",
|
||||||
|
healthy: false,
|
||||||
|
title: "Budget overflow streaming",
|
||||||
|
message: "Stima €#{metrics[:estimated_monthly_eur]}/mese > budget €#{metrics[:monthly_budget_eur]}",
|
||||||
|
metadata: metrics.transform_keys(&:to_s),
|
||||||
|
fingerprint: "stream_overflow:budget"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if at_max
|
||||||
|
return Finding.new(
|
||||||
|
kind: "stream_overflow",
|
||||||
|
severity: "warning",
|
||||||
|
healthy: false,
|
||||||
|
title: "Capacità stream al massimo",
|
||||||
|
message: "overflow=#{metrics[:overflow_nodes]}/#{metrics[:max_overflow_nodes]} free_slots=#{metrics[:free_slots]}",
|
||||||
|
metadata: metrics.transform_keys(&:to_s),
|
||||||
|
fingerprint: "stream_overflow:at_max"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
ok_finding("stream_overflow:ok", "Overflow streaming OK")
|
||||||
|
rescue StandardError => e
|
||||||
|
fail_finding("stream_overflow", "warning", "stream_overflow:error", "Check overflow fallito", e.message)
|
||||||
|
end
|
||||||
|
|
||||||
def check_sidekiq_heartbeat
|
def check_sidekiq_heartbeat
|
||||||
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||||
last = redis.get(Ops::HealthMonitorJob::HEARTBEAT_KEY).to_i
|
last = redis.get(Ops::HealthMonitorJob::HEARTBEAT_KEY).to_i
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ module Recordings
|
|||||||
def cleanup_mediamtx_path(session)
|
def cleanup_mediamtx_path(session)
|
||||||
return unless session.status.in?(%w[ended error])
|
return unless session.status.in?(%w[ended error])
|
||||||
|
|
||||||
Mediamtx::Client.new.delete_path(session)
|
Mediamtx::Client.for_session(session).delete_path(session)
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
@logger.warn("[Recordings::CleanupLocal] delete_path #{session.id}: #{e.message}")
|
@logger.warn("[Recordings::CleanupLocal] delete_path #{session.id}: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ module Recordings
|
|||||||
|
|
||||||
def cleanup_mediamtx_path
|
def cleanup_mediamtx_path
|
||||||
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
||||||
Mediamtx::Client.new.delete_path(@session)
|
Mediamtx::Client.for_session(@session).delete_path(@session)
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}")
|
Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -34,11 +34,19 @@ module Sessions
|
|||||||
end
|
end
|
||||||
|
|
||||||
StreamSession.transaction do
|
StreamSession.transaction do
|
||||||
|
session.stream_node = Streams::NodeRegistry.allocate!
|
||||||
session.save!
|
session.save!
|
||||||
Scoring::Engine.ensure_score_for(session)
|
Scoring::Engine.ensure_score_for(session)
|
||||||
mtx = Mediamtx::Client.new
|
Mediamtx::Client.for_session(session).create_path(session)
|
||||||
mtx.create_path(session)
|
log_event(
|
||||||
log_event(session, "pairing", { created: true, platform: session.platform })
|
session,
|
||||||
|
"pairing",
|
||||||
|
{
|
||||||
|
created: true,
|
||||||
|
platform: session.platform,
|
||||||
|
stream_node: session.stream_node&.slug
|
||||||
|
}
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
if session.platform == "youtube"
|
if session.platform == "youtube"
|
||||||
@@ -46,6 +54,8 @@ module Sessions
|
|||||||
end
|
end
|
||||||
|
|
||||||
session
|
session
|
||||||
|
rescue Streams::NodeRegistry::NoCapacityError => e
|
||||||
|
raise Teams::EntitlementError.new(e.message, code: "stream_capacity_exhausted")
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ module Sessions
|
|||||||
@session.pause! if @session.may_pause?
|
@session.pause! if @session.may_pause?
|
||||||
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
||||||
begin
|
begin
|
||||||
Mediamtx::Client.new.set_path_recording(@session, enabled: false)
|
Mediamtx::Client.for_session(@session).set_path_recording(@session, enabled: false)
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
Rails.logger.warn("[Sessions::Pause] disable recording: #{e.message}")
|
Rails.logger.warn("[Sessions::Pause] disable recording: #{e.message}")
|
||||||
end
|
end
|
||||||
Mediamtx::Client.new.set_always_available(@session, enabled: true)
|
Mediamtx::Client.for_session(@session).set_always_available(@session, enabled: true)
|
||||||
# Slate su path per HLS in pausa; RTMP telefono si ferma via comando app.
|
# Slate su path per HLS in pausa; RTMP telefono si ferma via comando app.
|
||||||
log_event("paused")
|
log_event("paused")
|
||||||
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
|
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ module Sessions
|
|||||||
end
|
end
|
||||||
|
|
||||||
def remove_mediamtx_paths!
|
def remove_mediamtx_paths!
|
||||||
Mediamtx::Client.new.delete_path(@session)
|
Mediamtx::Client.for_session(@session).delete_path(@session)
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
Rails.logger.warn("[Sessions::Stop] delete_path #{@session.id}: #{e.message}")
|
Rails.logger.warn("[Sessions::Stop] delete_path #{@session.id}: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,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
|
module Streams
|
||||||
# Relay verso YouTube: legge RTMP/HLS da MediaMTX e inoltra su RTMPS (-c copy). Nessun overlay.
|
# Relay verso YouTube: legge RTMP/HLS da MediaMTX e inoltra su RTMPS (-c copy). Nessun overlay.
|
||||||
# ffmpeg gira solo nel container sidekiq (YOUTUBE_RELAY_WORKER=1).
|
# ffmpeg gira solo sui worker Sidekiq con YOUTUBE_RELAY_WORKER=1 (coda youtube_relay).
|
||||||
class YoutubeRelay
|
class YoutubeRelay
|
||||||
class Error < StandardError; end
|
class Error < StandardError; end
|
||||||
|
|
||||||
REDIS_KEY = "youtube_relay:pid:%s"
|
REDIS_KEY = "youtube_relay:pid:%s"
|
||||||
OWNER_KEY = "youtube_relay:owner:%s"
|
OWNER_KEY = "youtube_relay:owner:%s"
|
||||||
|
OWNED_SET = "youtube_relay:owned:%s"
|
||||||
|
QUEUE = :youtube_relay
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def worker?
|
def worker?
|
||||||
ENV["YOUTUBE_RELAY_WORKER"] == "1"
|
ENV["YOUTUBE_RELAY_WORKER"] == "1"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def max_concurrent
|
||||||
|
ENV.fetch("RELAY_MAX_CONCURRENT", "4").to_i
|
||||||
|
end
|
||||||
|
|
||||||
def start(session)
|
def start(session)
|
||||||
return unless worker?
|
return unless worker?
|
||||||
|
|
||||||
start_on_worker!(session)
|
start_on_worker!(session)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Non cancella owner/pid qui: solo il worker owner deve killare ffmpeg.
|
||||||
def stop(session)
|
def stop(session)
|
||||||
clear_pid(session.id)
|
if worker? && owner_is_local?(session.id)
|
||||||
redis.del(format(OWNER_KEY, session.id))
|
|
||||||
if worker?
|
|
||||||
stop_on_worker!(session)
|
stop_on_worker!(session)
|
||||||
else
|
else
|
||||||
YoutubeRelayStopJob.perform_later(session.id)
|
YoutubeRelayStopJob.set(queue: QUEUE).perform_later(session.id)
|
||||||
end
|
end
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
@@ -31,11 +37,13 @@ module Streams
|
|||||||
def running?(session_id)
|
def running?(session_id)
|
||||||
owner = redis.get(format(OWNER_KEY, session_id))
|
owner = redis.get(format(OWNER_KEY, session_id))
|
||||||
pid = pid_for(session_id)
|
pid = pid_for(session_id)
|
||||||
|
return false if pid.blank? && owner.blank?
|
||||||
|
return false if pid.blank? && owner.present? && owner != worker_id && redis.ttl(format(OWNER_KEY, session_id)) <= 30
|
||||||
return false if pid.blank?
|
return false if pid.blank?
|
||||||
|
|
||||||
return process_alive?(pid) if owner.blank? || owner == worker_id
|
return process_alive?(pid) if owner.blank? || owner == worker_id
|
||||||
|
|
||||||
# Relay avviato in un altro container: consideralo attivo se il lock è recente.
|
# Relay su altro host: attivo se lock owner ancora fresco.
|
||||||
redis.ttl(format(OWNER_KEY, session_id)) > 30
|
redis.ttl(format(OWNER_KEY, session_id)) > 30
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -47,12 +55,12 @@ module Streams
|
|||||||
if worker?
|
if worker?
|
||||||
ensure_on_worker!(session)
|
ensure_on_worker!(session)
|
||||||
else
|
else
|
||||||
YoutubeRelayEnsureJob.perform_later(session.id)
|
YoutubeRelayEnsureJob.set(queue: QUEUE).perform_later(session.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def ensure_on_worker!(session)
|
def ensure_on_worker!(session)
|
||||||
return unless worker?
|
return :not_worker unless worker?
|
||||||
return unless session.platform == "youtube"
|
return unless session.platform == "youtube"
|
||||||
return if session.terminal?
|
return if session.terminal?
|
||||||
return if session.stream_key.blank?
|
return if session.stream_key.blank?
|
||||||
@@ -60,28 +68,64 @@ module Streams
|
|||||||
return unless intake_available?(session)
|
return unless intake_available?(session)
|
||||||
|
|
||||||
pid = pid_for(session.id)
|
pid = pid_for(session.id)
|
||||||
clear_pid(session.id) if pid.present? && !process_alive?(pid.to_i)
|
if pid.present? && !process_alive?(pid.to_i)
|
||||||
|
clear_local_ownership(session.id)
|
||||||
|
end
|
||||||
|
|
||||||
return if running?(session.id)
|
if running?(session.id)
|
||||||
|
touch_owner!(session.id) if owner_is_local?(session.id)
|
||||||
|
return :already_running
|
||||||
|
end
|
||||||
|
|
||||||
|
if at_capacity?
|
||||||
|
YoutubeRelayEnsureJob.set(wait: 5.seconds, queue: QUEUE).perform_later(session.id)
|
||||||
|
Rails.logger.info("[YoutubeRelay] at capacity worker=#{worker_id} session=#{session.id} requeue")
|
||||||
|
return :at_capacity
|
||||||
|
end
|
||||||
|
|
||||||
last_restart = redis.get(restart_debounce_key(session.id)).to_i
|
last_restart = redis.get(restart_debounce_key(session.id)).to_i
|
||||||
return if last_restart.positive? && (Time.now.to_i - last_restart) < 5
|
return :debounced if last_restart.positive? && (Time.now.to_i - last_restart) < 5
|
||||||
|
|
||||||
start_on_worker!(session)
|
start_on_worker!(session)
|
||||||
redis.set(restart_debounce_key(session.id), Time.now.to_i, ex: 300)
|
redis.set(restart_debounce_key(session.id), Time.now.to_i, ex: 300)
|
||||||
|
:started
|
||||||
rescue Error => e
|
rescue Error => e
|
||||||
Rails.logger.warn("[YoutubeRelay] ensure_on_worker session=#{session.id}: #{e.message}")
|
Rails.logger.warn("[YoutubeRelay] ensure_on_worker session=#{session.id}: #{e.message}")
|
||||||
|
:error
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Symbol] :stopped, :wrong_host, :noop
|
||||||
def stop_on_worker!(session)
|
def stop_on_worker!(session)
|
||||||
|
return :not_worker unless worker?
|
||||||
|
|
||||||
|
owner = redis.get(format(OWNER_KEY, session.id))
|
||||||
|
if owner.present? && owner != worker_id
|
||||||
|
return :wrong_host
|
||||||
|
end
|
||||||
|
|
||||||
pid = pid_for(session.id)
|
pid = pid_for(session.id)
|
||||||
return false if pid.blank?
|
if pid.blank?
|
||||||
|
clear_local_ownership(session.id)
|
||||||
|
return :noop
|
||||||
|
end
|
||||||
|
|
||||||
terminate_pid(pid)
|
terminate_pid(pid)
|
||||||
clear_pid(session.id)
|
clear_local_ownership(session.id)
|
||||||
redis.del(format(OWNER_KEY, session.id))
|
Rails.logger.info("[YoutubeRelay] stopped pid=#{pid} session=#{session.id} worker=#{worker_id}")
|
||||||
Rails.logger.info("[YoutubeRelay] stopped pid=#{pid} session=#{session.id}")
|
:stopped
|
||||||
true
|
end
|
||||||
|
|
||||||
|
def local_owned_count
|
||||||
|
redis.scard(format(OWNED_SET, worker_id)).to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def at_capacity?
|
||||||
|
local_owned_count >= max_concurrent
|
||||||
|
end
|
||||||
|
|
||||||
|
def owner_is_local?(session_id)
|
||||||
|
owner = redis.get(format(OWNER_KEY, session_id))
|
||||||
|
owner.blank? || owner == worker_id
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -92,9 +136,9 @@ module Streams
|
|||||||
return if session.terminal?
|
return if session.terminal?
|
||||||
return unless intake_available?(session)
|
return unless intake_available?(session)
|
||||||
|
|
||||||
return pid_for(session.id).to_i if running?(session.id)
|
return pid_for(session.id).to_i if running?(session.id) && owner_is_local?(session.id)
|
||||||
|
|
||||||
stop_on_worker!(session) if pid_for(session.id).present?
|
stop_on_worker!(session) if pid_for(session.id).present? && owner_is_local?(session.id)
|
||||||
|
|
||||||
log_path = log_file(session)
|
log_path = log_file(session)
|
||||||
FileUtils.mkdir_p(File.dirname(log_path))
|
FileUtils.mkdir_p(File.dirname(log_path))
|
||||||
@@ -108,59 +152,58 @@ module Streams
|
|||||||
)
|
)
|
||||||
Process.detach(pid)
|
Process.detach(pid)
|
||||||
store_pid(session.id, pid)
|
store_pid(session.id, pid)
|
||||||
redis.set(format(OWNER_KEY, session.id), worker_id, ex: 48.hours.to_i)
|
claim_ownership!(session.id)
|
||||||
Rails.logger.info("[YoutubeRelay] started pid=#{pid} session=#{session.id} intake=#{intake_source.join(":")}")
|
Rails.logger.info("[YoutubeRelay] started pid=#{pid} session=#{session.id} intake=#{intake_source.join(":")} worker=#{worker_id}")
|
||||||
schedule_youtube_activate(session)
|
schedule_youtube_activate(session)
|
||||||
pid
|
pid
|
||||||
rescue Errno::ENOENT => e
|
rescue Errno::ENOENT => e
|
||||||
raise Error, "ffmpeg non disponibile: #{e.message}"
|
raise Error, "ffmpeg non disponibile: #{e.message}"
|
||||||
end
|
end
|
||||||
|
|
||||||
# RTMP grezzo da telefono (overlay bruciato lato app); fallback HLS via proxy Rails.
|
# Remux verso YouTube: video+audio copy (AAC già da app/slate a 48k mono).
|
||||||
|
# HLS (ADTS) → FLV richiede -bsf:a aac_adtstoasc; RTMP ha già ASC in FLV tags.
|
||||||
def youtube_ffmpeg_args(intake_source, output)
|
def youtube_ffmpeg_args(intake_source, output)
|
||||||
mode, url = intake_source
|
mode, url = intake_source
|
||||||
if mode == :rtmp
|
common_head = [
|
||||||
return [
|
|
||||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
||||||
"-fflags", "+genpts+discardcorrupt",
|
"-fflags", "+genpts+discardcorrupt"
|
||||||
|
]
|
||||||
|
copy_tail = ["-c:v", "copy", "-c:a", "copy", "-f", "flv", output]
|
||||||
|
|
||||||
|
if mode == :rtmp
|
||||||
|
return common_head + [
|
||||||
"-analyzeduration", "10000000", "-probesize", "10000000",
|
"-analyzeduration", "10000000", "-probesize", "10000000",
|
||||||
"-rw_timeout", "15000000",
|
"-rw_timeout", "15000000",
|
||||||
"-i", url,
|
"-i", url
|
||||||
"-c:v", "copy",
|
] + copy_tail
|
||||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
|
|
||||||
"-bsf:a", "aac_adtstoasc",
|
|
||||||
"-f", "flv", output
|
|
||||||
]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
[
|
common_head + [
|
||||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
|
||||||
"-fflags", "+genpts+discardcorrupt",
|
|
||||||
"-rw_timeout", "15000000",
|
"-rw_timeout", "15000000",
|
||||||
"-live_start_index", "-1",
|
"-live_start_index", "-1",
|
||||||
"-i", url,
|
"-i", url,
|
||||||
"-c:v", "copy",
|
"-c:v", "copy",
|
||||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
|
"-c:a", "copy",
|
||||||
"-bsf:a", "aac_adtstoasc",
|
"-bsf:a", "aac_adtstoasc",
|
||||||
"-f", "flv", output
|
"-f", "flv", output
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
def mediamtx_intake_source(session)
|
def mediamtx_intake_source(session)
|
||||||
base = ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935")
|
base = session.mediamtx_internal_rtmp_url
|
||||||
if Mediamtx::PublisherOnline.active?(session)
|
if Mediamtx::PublisherOnline.active?(session)
|
||||||
return [:rtmp, "#{base.chomp('/')}/#{session.mediamtx_path_name}"]
|
return [:rtmp, "#{base.chomp('/')}/#{session.mediamtx_path_name}"]
|
||||||
end
|
end
|
||||||
|
|
||||||
path = session.mediamtx_path_name
|
path = session.mediamtx_path_name
|
||||||
hls = ENV.fetch("MEDIAMTX_HLS_URL", "http://mediamtx:8888").chomp("/")
|
hls = session.mediamtx_internal_hls_url.chomp("/")
|
||||||
[:hls, "#{hls}/#{path}/index.m3u8"]
|
[:hls, "#{hls}/#{path}/index.m3u8"]
|
||||||
end
|
end
|
||||||
|
|
||||||
def intake_available?(session)
|
def intake_available?(session)
|
||||||
return true if Mediamtx::PublisherOnline.active?(session)
|
return true if Mediamtx::PublisherOnline.active?(session)
|
||||||
|
|
||||||
info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == session.mediamtx_path_name }
|
info = Mediamtx::Client.for_session(session).list_paths.find { |i| i["name"] == session.mediamtx_path_name }
|
||||||
info && (info["ready"] || info["online"] || info["available"])
|
info && (info["ready"] || info["online"] || info["available"])
|
||||||
rescue StandardError
|
rescue StandardError
|
||||||
false
|
false
|
||||||
@@ -186,6 +229,22 @@ module Streams
|
|||||||
format("youtube_relay:debounce:%s", session_id)
|
format("youtube_relay:debounce:%s", session_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def claim_ownership!(session_id)
|
||||||
|
redis.set(format(OWNER_KEY, session_id), worker_id, ex: 48.hours.to_i)
|
||||||
|
redis.sadd(format(OWNED_SET, worker_id), session_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def touch_owner!(session_id)
|
||||||
|
redis.expire(format(OWNER_KEY, session_id), 48.hours.to_i)
|
||||||
|
redis.expire(format(REDIS_KEY, session_id), 48.hours.to_i)
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_local_ownership(session_id)
|
||||||
|
redis.del(format(REDIS_KEY, session_id))
|
||||||
|
redis.del(format(OWNER_KEY, session_id))
|
||||||
|
redis.srem(format(OWNED_SET, worker_id), session_id)
|
||||||
|
end
|
||||||
|
|
||||||
def store_pid(session_id, pid)
|
def store_pid(session_id, pid)
|
||||||
redis.set(format(REDIS_KEY, session_id), pid, ex: 48.hours.to_i)
|
redis.set(format(REDIS_KEY, session_id), pid, ex: 48.hours.to_i)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
module Users
|
||||||
|
class ChangePassword
|
||||||
|
Result = Struct.new(:ok?, :error, keyword_init: true)
|
||||||
|
|
||||||
|
def self.call(user:, current_password:, password:, password_confirmation:)
|
||||||
|
new(
|
||||||
|
user: user,
|
||||||
|
current_password: current_password,
|
||||||
|
password: password,
|
||||||
|
password_confirmation: password_confirmation
|
||||||
|
).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(user:, current_password:, password:, password_confirmation:)
|
||||||
|
@user = user
|
||||||
|
@current_password = current_password.to_s
|
||||||
|
@password = password.to_s
|
||||||
|
@password_confirmation = password_confirmation.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
unless @user.authenticate(@current_password)
|
||||||
|
return Result.new(ok?: false, error: :current_incorrect)
|
||||||
|
end
|
||||||
|
|
||||||
|
if @password != @password_confirmation
|
||||||
|
return Result.new(ok?: false, error: :mismatch)
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
module Users
|
||||||
|
class RequestPasswordReset
|
||||||
|
def self.call(email:)
|
||||||
|
new(email: email).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(email:)
|
||||||
|
@email = email.to_s.downcase.strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
user = User.find_by(email: @email)
|
||||||
|
return if user.nil?
|
||||||
|
|
||||||
|
token = user.generate_password_reset!
|
||||||
|
UserMailer.password_reset(user, token).deliver_now
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -68,13 +68,13 @@ module Webhooks
|
|||||||
def enable_recording(session)
|
def enable_recording(session)
|
||||||
return unless session.match.team.entitlements.recording_enabled_for_mediamtx?
|
return unless session.match.team.entitlements.recording_enabled_for_mediamtx?
|
||||||
|
|
||||||
Mediamtx::Client.new.set_path_recording(session, enabled: true)
|
Mediamtx::Client.for_session(session).set_path_recording(session, enabled: true)
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
Rails.logger.warn("[MediamtxHandler] enable recording: #{e.message}")
|
Rails.logger.warn("[MediamtxHandler] enable recording: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|
||||||
def disable_recording(session)
|
def disable_recording(session)
|
||||||
Mediamtx::Client.new.set_path_recording(session, enabled: false)
|
Mediamtx::Client.for_session(session).set_path_recording(session, enabled: false)
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
Rails.logger.warn("[MediamtxHandler] disable recording: #{e.message}")
|
Rails.logger.warn("[MediamtxHandler] disable recording: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ module Youtube
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
Mediamtx::Client.new.set_always_available(session, enabled: false)
|
Mediamtx::Client.for_session(session).set_always_available(session, enabled: false)
|
||||||
session.go_live! if session.may_go_live?
|
session.go_live! if session.may_go_live?
|
||||||
session.reconnect! if session.reconnecting? && session.may_reconnect?
|
session.reconnect! if session.reconnecting? && session.may_reconnect?
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,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>
|
<title><%= t("admin.layout.title") %></title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="robots" content="noindex, nofollow">
|
<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) %>
|
<% if content_for?(:replay_archive_styles) %>
|
||||||
<link rel="stylesheet" href="/marketing.css?v=42">
|
<link rel="stylesheet" href="/marketing.css?v=42">
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
<%= link_to t("admin.layout.nav.billing"), admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %>
|
<%= link_to t("admin.layout.nav.billing"), admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %>
|
||||||
<%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
|
<%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
|
||||||
<%= link_to t("admin.layout.nav.sessions"), admin_sessions_path, class: ("active" if controller_name == "sessions") %>
|
<%= link_to t("admin.layout.nav.sessions"), admin_sessions_path, class: ("active" if controller_name == "sessions") %>
|
||||||
|
<%= link_to t("admin.layout.nav.stream_nodes"), admin_stream_nodes_path, class: ("active" if controller_name == "stream_nodes") %>
|
||||||
<%= link_to t("admin.layout.nav.password"), edit_admin_password_path %>
|
<%= link_to t("admin.layout.nav.password"), edit_admin_password_path %>
|
||||||
<%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %>
|
<%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -8,17 +8,17 @@
|
|||||||
<%= render "shared/meta_tags" %>
|
<%= render "shared/meta_tags" %>
|
||||||
<%= yield :head %>
|
<%= 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="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=50">
|
||||||
</head>
|
</head>
|
||||||
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
||||||
<%= render "shared/cookie_banner" %>
|
<%= render "shared/cookie_banner" %>
|
||||||
<%= render "shared/marketing_nav" %>
|
<%= render(@app_store_review_chrome ? "shared/marketing_nav_app_store" : "shared/marketing_nav") %>
|
||||||
<main>
|
<main>
|
||||||
<% if flash[:notice] %><div class="wrap"><div class="flash notice"><%= flash[:notice] %></div></div><% end %>
|
<% if flash[:notice] %><div class="wrap"><div class="flash notice"><%= flash[:notice] %></div></div><% end %>
|
||||||
<% if flash[:alert] %><div class="wrap"><div class="flash alert"><%= flash[:alert] %></div></div><% end %>
|
<% if flash[:alert] %><div class="wrap"><div class="flash alert"><%= flash[:alert] %></div></div><% end %>
|
||||||
<%= yield %>
|
<%= yield %>
|
||||||
</main>
|
</main>
|
||||||
<%= render "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="/branding-form.js?v=1" defer></script>
|
||||||
<script src="/roster-form.js?v=1" defer></script>
|
<script src="/roster-form.js?v=1" defer></script>
|
||||||
<script src="/password-toggle.js?v=2" defer></script>
|
<script src="/password-toggle.js?v=2" defer></script>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||||
<%= render "shared/meta_tags" %>
|
<%= 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="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=50">
|
||||||
<link rel="stylesheet" href="/live.css?v=26">
|
<link rel="stylesheet" href="/live.css?v=26">
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
<% elsif current_user.manageable_teams.any? %>
|
<% elsif current_user.manageable_teams.any? %>
|
||||||
· <%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first) %>
|
· <%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
· <%= link_to t("nav.account"), public_account_path %>
|
||||||
· <%= button_to t("nav.logout"), public_logout_path, method: :delete, form: { style: "display:inline" }, class: "btn btn-secondary", style: "padding:6px 12px;font-size:0.85rem" %>
|
· <%= button_to t("nav.logout"), public_logout_path, method: :delete, form: { style: "display:inline" }, class: "btn btn-secondary", style: "padding:6px 12px;font-size:0.85rem" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
· <%= link_to t("nav.login"), public_login_path %>
|
· <%= link_to t("nav.login"), public_login_path %>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<% content_for :title, t("auth.account.meta_title") %>
|
||||||
|
<% content_for :meta_description, t("auth.account.meta_description") %>
|
||||||
|
<% content_for :robots, "noindex, nofollow" %>
|
||||||
|
|
||||||
|
<section class="auth-page">
|
||||||
|
<h1><%= t("auth.account.title") %></h1>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="font-size:1.1rem;margin-top:0"><%= t("auth.account.profile_heading") %></h2>
|
||||||
|
<%= form_with url: public_account_path, method: :patch, local: true do %>
|
||||||
|
<p>
|
||||||
|
<label for="account_email"><%= t("auth.email") %></label><br>
|
||||||
|
<input type="email" id="account_email" value="<%= current_user.email %>" readonly
|
||||||
|
class="input" autocomplete="username"
|
||||||
|
style="width:100%;opacity:0.75;cursor:not-allowed">
|
||||||
|
</p>
|
||||||
|
<p class="muted" style="margin-top:-0.5rem;font-size:0.9rem">
|
||||||
|
<%= t("auth.account.role_label", role: current_user.role) %>
|
||||||
|
</p>
|
||||||
|
<%= render "shared/input_toggle",
|
||||||
|
name: :name,
|
||||||
|
label: t("auth.account.name_label"),
|
||||||
|
value: current_user.name,
|
||||||
|
required: true,
|
||||||
|
autocomplete: "name",
|
||||||
|
masked: false %>
|
||||||
|
<%= submit_tag t("auth.account.save_profile"), class: "btn btn-primary" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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,
|
||||||
|
label: t("auth.account.current_password_label"),
|
||||||
|
input_type: "password",
|
||||||
|
required: true,
|
||||||
|
autocomplete: "current-password" %>
|
||||||
|
<%= render "shared/input_toggle",
|
||||||
|
name: :password,
|
||||||
|
label: t("auth.account.new_password_label"),
|
||||||
|
input_type: "password",
|
||||||
|
required: true,
|
||||||
|
autocomplete: "new-password" %>
|
||||||
|
<%= render "shared/input_toggle",
|
||||||
|
name: :password_confirmation,
|
||||||
|
label: t("auth.password_confirmation"),
|
||||||
|
input_type: "password",
|
||||||
|
required: true,
|
||||||
|
autocomplete: "new-password" %>
|
||||||
|
<%= submit_tag t("auth.account.save_password"), class: "btn btn-primary" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<% if @subscription.admin_comped_reason.present? %>
|
<% if @subscription.admin_comped_reason.present? %>
|
||||||
(<%= @subscription.admin_comped_reason %>)
|
(<%= @subscription.admin_comped_reason %>)
|
||||||
<% end %>.
|
<% end %>.
|
||||||
<%= raw t("club.billing.comped.no_payment_html", email_link: mail_to("info@matchlive.it", "info@matchlive.it")) %>
|
<%= raw t("club.billing.comped.no_payment_html", email_link: mail_to("info@matchlivetv.it", "info@matchlivetv.it")) %>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
<% content_for :meta_description, t("auth.invitation.meta_description") %>
|
<% content_for :meta_description, t("auth.invitation.meta_description") %>
|
||||||
<% content_for :robots, "noindex, nofollow" %>
|
<% content_for :robots, "noindex, nofollow" %>
|
||||||
|
|
||||||
<div class="card" style="max-width:480px">
|
<section class="auth-page">
|
||||||
|
<div class="card">
|
||||||
<h1><%= raw t("auth.invitation.title_html", team_name: @invitation.team.name) %></h1>
|
<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><%= raw t("auth.invitation.role_notice_html", email: @invitation.email) %></p>
|
||||||
<p class="muted" style="font-size:0.9rem;margin-bottom:16px">
|
<p class="muted" style="font-size:0.9rem;margin-bottom:16px">
|
||||||
@@ -24,3 +25,4 @@
|
|||||||
<a href="matchlivetv://join/<%= @token %>"><%= t("auth.invitation.open_in_app") %></a>
|
<a href="matchlivetv://join/<%= @token %>"><%= t("auth.invitation.open_in_app") %></a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
|
|
||||||
<h3><%= t("legal.cookies.s4_1_title") %></h3>
|
<h3><%= t("legal.cookies.s4_1_title") %></h3>
|
||||||
<p><%= t("legal.cookies.s4_1_intro") %></p>
|
<p><%= t("legal.cookies.s4_1_intro") %></p>
|
||||||
|
<div class="table-scroll">
|
||||||
<table class="legal-table">
|
<table class="legal-table">
|
||||||
<thead>
|
<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>
|
<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>
|
||||||
@@ -65,6 +66,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3><%= t("legal.cookies.s4_2_title") %></h3>
|
<h3><%= t("legal.cookies.s4_2_title") %></h3>
|
||||||
<p>
|
<p>
|
||||||
@@ -75,6 +77,7 @@
|
|||||||
<% else %>
|
<% else %>
|
||||||
<p class="muted"><%= t("legal.cookies.s4_2_inactive") %></p>
|
<p class="muted"><%= t("legal.cookies.s4_2_inactive") %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<div class="table-scroll">
|
||||||
<table class="legal-table">
|
<table class="legal-table">
|
||||||
<thead>
|
<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>
|
<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>
|
||||||
@@ -100,6 +103,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
<p>
|
<p>
|
||||||
<%= raw t(
|
<%= raw t(
|
||||||
"legal.cookies.s4_2_p2_html",
|
"legal.cookies.s4_2_p2_html",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
|
|
||||||
<%= render "shared/plan_cards" %>
|
<%= render "shared/plan_cards" %>
|
||||||
|
|
||||||
|
<div class="table-scroll">
|
||||||
<table class="compare-table">
|
<table class="compare-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th></th><th>Free</th><th>Premium Light</th><th>Premium Full</th></tr>
|
<tr><th></th><th>Free</th><th>Premium Light</th><th>Premium Full</th></tr>
|
||||||
@@ -36,10 +37,11 @@
|
|||||||
<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>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-top:32px;text-align:center">
|
<div class="card" style="margin-top:32px;text-align:center">
|
||||||
<h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3>
|
<h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3>
|
||||||
<p style="color:#aaa;margin-bottom:16px"><%= t("pages.pricing.different_body") %></p>
|
<p style="color:#aaa;margin-bottom:16px"><%= t("pages.pricing.different_body") %></p>
|
||||||
<%= mail_to "info@matchlive.it", t("pages.pricing.contact_button"), class: "btn btn-primary" %>
|
<%= mail_to "info@matchlivetv.it", t("pages.pricing.contact_button"), class: "btn btn-primary" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2><%= t("legal.privacy.s5_title") %></h2>
|
<h2><%= t("legal.privacy.s5_title") %></h2>
|
||||||
|
<div class="table-scroll">
|
||||||
<table class="legal-table">
|
<table class="legal-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th><%= t("legal.privacy.s5_col_purpose") %></th><th><%= t("legal.privacy.s5_col_basis") %></th></tr>
|
<tr><th><%= t("legal.privacy.s5_col_purpose") %></th><th><%= t("legal.privacy.s5_col_basis") %></th></tr>
|
||||||
@@ -108,6 +109,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<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">
|
<section class="auth-page">
|
||||||
<h1><%= t("auth.password_reset.title") %></h1>
|
<h1><%= t("auth.password_reset.title") %></h1>
|
||||||
<div class="card">
|
<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 %>
|
<%= form_with url: public_password_reset_path, method: :patch, local: true do %>
|
||||||
<%= hidden_field_tag :token, @token %>
|
<%= hidden_field_tag :token, @token %>
|
||||||
<%= render "shared/input_toggle",
|
<%= render "shared/input_toggle",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
required: true,
|
required: true,
|
||||||
minlength: 8,
|
minlength: 8,
|
||||||
autocomplete: "new-password" %>
|
autocomplete: "new-password" %>
|
||||||
|
<p class="muted" style="font-size:0.9rem;margin-top:-0.5rem"><%= t("password_policy.hint") %></p>
|
||||||
<%= render "shared/input_toggle",
|
<%= render "shared/input_toggle",
|
||||||
name: "user[password_confirmation]",
|
name: "user[password_confirmation]",
|
||||||
id: "user_password_confirmation",
|
id: "user_password_confirmation",
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-top:24px;text-align:center">
|
<div class="card" style="margin-top:24px;text-align:center">
|
||||||
<p style="color:#aaa"><%= t("team.billing.contact_lead") %> <%= mail_to "info@matchlive.it", t("team.billing.contact_cta") %> <%= t("team.billing.contact_trailer") %></p>
|
<p style="color:#aaa"><%= t("team.billing.contact_lead") %> <%= mail_to "info@matchlivetv.it", t("team.billing.contact_cta") %> <%= t("team.billing.contact_trailer") %></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style="margin-top:20px"><%= link_to t("team.billing.club_payments_link"), public_club_billing_path(@team.club) %></p>
|
<p style="margin-top:20px"><%= link_to t("team.billing.club_payments_link"), public_club_billing_path(@team.club) %></p>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
|
|
||||||
<h3 style="margin-top:28px;font-size:1.1rem"><%= t("billing.documents.table_heading") %></h3>
|
<h3 style="margin-top:28px;font-size:1.1rem"><%= t("billing.documents.table_heading") %></h3>
|
||||||
<% if payments.any? %>
|
<% if payments.any? %>
|
||||||
|
<div class="table-scroll">
|
||||||
<table class="data billing-table">
|
<table class="data billing-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p style="color:#888"><%= t("billing.documents.no_payments") %></p>
|
<p style="color:#888"><%= t("billing.documents.no_payments") %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -58,10 +58,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggle.addEventListener("click", function (e) {
|
toggle.addEventListener("click", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setOpen(menu.hidden);
|
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) {
|
document.addEventListener("click", function (e) {
|
||||||
if (!root.contains(e.target)) setOpen(false);
|
if (!root.contains(e.target)) setOpen(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<%= link_to t("common.support"), public_support_path %> ·
|
||||||
<%= link_to t("common.pricing"), public_prezzi_path %> ·
|
<%= link_to t("common.pricing"), public_prezzi_path %> ·
|
||||||
<%= link_to t("footer.live"), public_live_index_path %> ·
|
<%= link_to t("footer.live"), public_live_index_path %> ·
|
||||||
<%= link_to t("common.faq"), public_faq_path %> ·
|
<%= link_to t("common.faq"), public_faq_path %> ·
|
||||||
|
|||||||
@@ -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">
|
<nav id="site-nav" class="nav" aria-label="<%= t('nav.main_menu') %>" aria-hidden="true">
|
||||||
<div class="nav-panel">
|
<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.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.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) %>
|
<%= link_to t("nav.pricing"), public_prezzi_path, class: (request.path == "/prezzi" ? "nav-active" : nil) %>
|
||||||
@@ -34,14 +43,12 @@
|
|||||||
<%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first), class: "nav-link-item" %>
|
<%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first), class: "nav-link-item" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<%= link_to t("nav.account"), public_account_path, class: (request.path == "/account" ? "nav-link-item nav-active" : "nav-link-item") %>
|
||||||
<%= button_to t("nav.logout"), public_logout_path, method: :delete, class: "btn btn-secondary nav-btn" %>
|
<%= button_to t("nav.logout"), public_logout_path, method: :delete, class: "btn btn-secondary nav-btn" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= link_to t("nav.login"), public_login_path, class: "nav-link-item" %>
|
<%= 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" %>
|
<%= link_to t("nav.signup"), public_signup_path, class: "btn btn-primary nav-btn" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<div class="nav-lang">
|
|
||||||
<%= render "shared/language_switcher" %>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -77,8 +84,16 @@
|
|||||||
|
|
||||||
if (backdrop) backdrop.addEventListener("click", closeMenu);
|
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) {
|
nav.querySelectorAll("a, button").forEach(function (el) {
|
||||||
el.addEventListener("click", closeMenu);
|
el.addEventListener("click", function () {
|
||||||
|
if (shouldCloseNavOnControl(el)) closeMenu();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("resize", function () {
|
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>
|
||||||
@@ -78,7 +78,13 @@ module MatchLiveTv
|
|||||||
end
|
end
|
||||||
|
|
||||||
def privacy_controller_email
|
def privacy_controller_email
|
||||||
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlive.it")
|
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
|
end
|
||||||
|
|
||||||
def privacy_controller_address
|
def privacy_controller_address
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Sidekiq.configure_server do |config|
|
|||||||
config.on(:startup) do
|
config.on(:startup) do
|
||||||
StreamPublisherSyncJob.ensure_chain
|
StreamPublisherSyncJob.ensure_chain
|
||||||
Ops::HealthMonitorJob.ensure_chain
|
Ops::HealthMonitorJob.ensure_chain
|
||||||
|
Streams::AutoscalerJob.ensure_chain
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ de:
|
|||||||
billing: Abrechnung
|
billing: Abrechnung
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sitzungen
|
sessions: Sitzungen
|
||||||
|
stream_nodes: Stream-Knoten
|
||||||
password: Passwort
|
password: Passwort
|
||||||
logout: Abmelden
|
logout: Abmelden
|
||||||
flash:
|
flash:
|
||||||
@@ -18,11 +19,19 @@ de:
|
|||||||
logout_success: Abgemeldet
|
logout_success: Abgemeldet
|
||||||
password_current_incorrect: Das aktuelle Passwort ist falsch
|
password_current_incorrect: Das aktuelle Passwort ist falsch
|
||||||
password_too_short: Das neue Passwort muss mindestens 8 Zeichen lang sein
|
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_mismatch: Die Passwörter stimmen nicht überein
|
||||||
password_updated: Passwort aktualisiert
|
password_updated: Passwort aktualisiert
|
||||||
ops_acknowledged: Vorfall übernommen
|
ops_acknowledged: Vorfall übernommen
|
||||||
ops_resolved: Vorfall gelöst
|
ops_resolved: Vorfall gelöst
|
||||||
ops_muted: Benachrichtigungen für 24 Stunden stummgeschaltet
|
ops_muted: Benachrichtigungen für 24 Stunden stummgeschaltet
|
||||||
|
stream_node_created: "Lab-Knoten %{slug} bereitgestellt."
|
||||||
|
stream_node_destroyed: "Knoten %{slug} entfernt."
|
||||||
|
stream_node_draining: "Knoten %{slug} im Drain-Modus."
|
||||||
|
autoscale_kill_on: "Autoscaler-Kill-Switch aktiv. Kein automatisches Scale-out."
|
||||||
|
autoscale_kill_off: "Autoscaler-Kill-Switch aus (STREAM_AUTOSCALE_ENABLED=1 weiterhin nötig)."
|
||||||
comped_granted: "Kostenloses Abonnement %{plan} für %{club} aktiviert."
|
comped_granted: "Kostenloses Abonnement %{plan} für %{club} aktiviert."
|
||||||
comped_revoked: "Kostenloses Abonnement für %{club} widerrufen."
|
comped_revoked: "Kostenloses Abonnement für %{club} widerrufen."
|
||||||
session_already_terminated: "Sitzung bereits beendet (%{status})."
|
session_already_terminated: "Sitzung bereits beendet (%{status})."
|
||||||
@@ -53,7 +62,7 @@ de:
|
|||||||
edit:
|
edit:
|
||||||
title: Passwort ändern
|
title: Passwort ändern
|
||||||
current_password_label: Aktuelles Passwort
|
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
|
confirm_password_label: Neues Passwort bestätigen
|
||||||
submit: Passwort speichern
|
submit: Passwort speichern
|
||||||
cancel: Abbrechen
|
cancel: Abbrechen
|
||||||
@@ -108,6 +117,48 @@ de:
|
|||||||
title: Teams
|
title: Teams
|
||||||
matches_count: "%{count} Spiele"
|
matches_count: "%{count} Spiele"
|
||||||
view_all: "Vereine ansehen (%{count} Teams)"
|
view_all: "Vereine ansehen (%{count} Teams)"
|
||||||
|
stream_nodes:
|
||||||
|
title: Stream-Knoten
|
||||||
|
providers: "Cloud: %{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:
|
ops:
|
||||||
kpi:
|
kpi:
|
||||||
critical: Kritisch offen
|
critical: Kritisch offen
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ en:
|
|||||||
billing: Billing
|
billing: Billing
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sessions
|
sessions: Sessions
|
||||||
|
stream_nodes: Stream nodes
|
||||||
password: Password
|
password: Password
|
||||||
logout: Log out
|
logout: Log out
|
||||||
flash:
|
flash:
|
||||||
@@ -18,11 +19,19 @@ en:
|
|||||||
logout_success: Signed out
|
logout_success: Signed out
|
||||||
password_current_incorrect: Current password is incorrect
|
password_current_incorrect: Current password is incorrect
|
||||||
password_too_short: The new password must be at least 8 characters long
|
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_mismatch: Passwords do not match
|
||||||
password_updated: Password updated
|
password_updated: Password updated
|
||||||
ops_acknowledged: Incident acknowledged
|
ops_acknowledged: Incident acknowledged
|
||||||
ops_resolved: Incident resolved
|
ops_resolved: Incident resolved
|
||||||
ops_muted: Notifications muted for 24 hours
|
ops_muted: Notifications muted for 24 hours
|
||||||
|
stream_node_created: "Lab node %{slug} provisioned."
|
||||||
|
stream_node_destroyed: "Node %{slug} removed."
|
||||||
|
stream_node_draining: "Node %{slug} is draining (no new sessions)."
|
||||||
|
autoscale_kill_on: "Autoscaler kill-switch engaged. No automatic scale-out."
|
||||||
|
autoscale_kill_off: "Autoscaler kill-switch cleared (still needs STREAM_AUTOSCALE_ENABLED=1)."
|
||||||
comped_granted: "%{plan} complimentary subscription activated for %{club}."
|
comped_granted: "%{plan} complimentary subscription activated for %{club}."
|
||||||
comped_revoked: "Complimentary subscription revoked for %{club}."
|
comped_revoked: "Complimentary subscription revoked for %{club}."
|
||||||
session_already_terminated: "Session already ended (%{status})."
|
session_already_terminated: "Session already ended (%{status})."
|
||||||
@@ -53,7 +62,7 @@ en:
|
|||||||
edit:
|
edit:
|
||||||
title: Change password
|
title: Change password
|
||||||
current_password_label: Current 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
|
confirm_password_label: Confirm new password
|
||||||
submit: Save password
|
submit: Save password
|
||||||
cancel: Cancel
|
cancel: Cancel
|
||||||
@@ -108,6 +117,48 @@ en:
|
|||||||
title: Teams
|
title: Teams
|
||||||
matches_count: "%{count} matches"
|
matches_count: "%{count} matches"
|
||||||
view_all: "View clubs (%{count} teams)"
|
view_all: "View clubs (%{count} teams)"
|
||||||
|
stream_nodes:
|
||||||
|
title: Streaming nodes
|
||||||
|
providers: "Cloud: %{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:
|
ops:
|
||||||
kpi:
|
kpi:
|
||||||
critical: Critical open
|
critical: Critical open
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ es:
|
|||||||
billing: Facturación
|
billing: Facturación
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sesiones
|
sessions: Sesiones
|
||||||
|
stream_nodes: Nodos stream
|
||||||
password: Contraseña
|
password: Contraseña
|
||||||
logout: Salir
|
logout: Salir
|
||||||
flash:
|
flash:
|
||||||
@@ -18,11 +19,19 @@ es:
|
|||||||
logout_success: Sesión cerrada
|
logout_success: Sesión cerrada
|
||||||
password_current_incorrect: La contraseña actual no es correcta
|
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_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_mismatch: Las contraseñas no coinciden
|
||||||
password_updated: Contraseña actualizada
|
password_updated: Contraseña actualizada
|
||||||
ops_acknowledged: Incidencia asumida
|
ops_acknowledged: Incidencia asumida
|
||||||
ops_resolved: Incidencia resuelta
|
ops_resolved: Incidencia resuelta
|
||||||
ops_muted: Notificaciones silenciadas durante 24 horas
|
ops_muted: Notificaciones silenciadas durante 24 horas
|
||||||
|
stream_node_created: "Nodo lab %{slug} provisionado."
|
||||||
|
stream_node_destroyed: "Nodo %{slug} eliminado."
|
||||||
|
stream_node_draining: "Nodo %{slug} en drain."
|
||||||
|
autoscale_kill_on: "Kill-switch del autoscaler activado. Sin scale-out automático."
|
||||||
|
autoscale_kill_off: "Kill-switch del autoscaler desactivado (hace falta STREAM_AUTOSCALE_ENABLED=1)."
|
||||||
comped_granted: "Suscripción de cortesía %{plan} activada para %{club}."
|
comped_granted: "Suscripción de cortesía %{plan} activada para %{club}."
|
||||||
comped_revoked: "Suscripción de cortesía revocada para %{club}."
|
comped_revoked: "Suscripción de cortesía revocada para %{club}."
|
||||||
session_already_terminated: "La sesión ya ha finalizado (%{status})."
|
session_already_terminated: "La sesión ya ha finalizado (%{status})."
|
||||||
@@ -53,7 +62,7 @@ es:
|
|||||||
edit:
|
edit:
|
||||||
title: Cambiar contraseña
|
title: Cambiar contraseña
|
||||||
current_password_label: Contraseña actual
|
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
|
confirm_password_label: Confirma la nueva contraseña
|
||||||
submit: Guardar contraseña
|
submit: Guardar contraseña
|
||||||
cancel: Cancelar
|
cancel: Cancelar
|
||||||
@@ -108,6 +117,48 @@ es:
|
|||||||
title: Equipos
|
title: Equipos
|
||||||
matches_count: "%{count} partidos"
|
matches_count: "%{count} partidos"
|
||||||
view_all: "Ver clubes (%{count} equipos)"
|
view_all: "Ver clubes (%{count} equipos)"
|
||||||
|
stream_nodes:
|
||||||
|
title: Nodos streaming
|
||||||
|
providers: "Cloud: %{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:
|
ops:
|
||||||
kpi:
|
kpi:
|
||||||
critical: Críticas abiertas
|
critical: Críticas abiertas
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ fr:
|
|||||||
billing: Facturation
|
billing: Facturation
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sessions
|
sessions: Sessions
|
||||||
|
stream_nodes: Nœuds stream
|
||||||
password: Mot de passe
|
password: Mot de passe
|
||||||
logout: Déconnexion
|
logout: Déconnexion
|
||||||
flash:
|
flash:
|
||||||
@@ -18,11 +19,19 @@ fr:
|
|||||||
logout_success: Déconnecté
|
logout_success: Déconnecté
|
||||||
password_current_incorrect: Le mot de passe actuel est incorrect
|
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_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_mismatch: Les mots de passe ne correspondent pas
|
||||||
password_updated: Mot de passe mis à jour
|
password_updated: Mot de passe mis à jour
|
||||||
ops_acknowledged: Incident pris en charge
|
ops_acknowledged: Incident pris en charge
|
||||||
ops_resolved: Incident résolu
|
ops_resolved: Incident résolu
|
||||||
ops_muted: Notifications suspendues pendant 24 heures
|
ops_muted: Notifications suspendues pendant 24 heures
|
||||||
|
stream_node_created: "Nœud lab %{slug} provisionné."
|
||||||
|
stream_node_destroyed: "Nœud %{slug} supprimé."
|
||||||
|
stream_node_draining: "Nœud %{slug} en drain."
|
||||||
|
autoscale_kill_on: "Kill-switch autoscaler activé. Pas de scale-out automatique."
|
||||||
|
autoscale_kill_off: "Kill-switch autoscaler désactivé (nécessite aussi STREAM_AUTOSCALE_ENABLED=1)."
|
||||||
comped_granted: "Abonnement offert %{plan} activé pour %{club}."
|
comped_granted: "Abonnement offert %{plan} activé pour %{club}."
|
||||||
comped_revoked: "Abonnement offert révoqué pour %{club}."
|
comped_revoked: "Abonnement offert révoqué pour %{club}."
|
||||||
session_already_terminated: "Session déjà terminée (%{status})."
|
session_already_terminated: "Session déjà terminée (%{status})."
|
||||||
@@ -53,7 +62,7 @@ fr:
|
|||||||
edit:
|
edit:
|
||||||
title: Changer le mot de passe
|
title: Changer le mot de passe
|
||||||
current_password_label: Mot de passe actuel
|
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
|
confirm_password_label: Confirmer le nouveau mot de passe
|
||||||
submit: Enregistrer le mot de passe
|
submit: Enregistrer le mot de passe
|
||||||
cancel: Annuler
|
cancel: Annuler
|
||||||
@@ -108,6 +117,48 @@ fr:
|
|||||||
title: Équipes
|
title: Équipes
|
||||||
matches_count: "%{count} matchs"
|
matches_count: "%{count} matchs"
|
||||||
view_all: "Voir les clubs (%{count} équipes)"
|
view_all: "Voir les clubs (%{count} équipes)"
|
||||||
|
stream_nodes:
|
||||||
|
title: Nœuds streaming
|
||||||
|
providers: "Cloud: %{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:
|
ops:
|
||||||
kpi:
|
kpi:
|
||||||
critical: Critiques ouverts
|
critical: Critiques ouverts
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ it:
|
|||||||
billing: Fatturazione
|
billing: Fatturazione
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sessioni
|
sessions: Sessioni
|
||||||
|
stream_nodes: Nodi stream
|
||||||
password: Password
|
password: Password
|
||||||
logout: Esci
|
logout: Esci
|
||||||
flash:
|
flash:
|
||||||
@@ -18,11 +19,19 @@ it:
|
|||||||
logout_success: Disconnesso
|
logout_success: Disconnesso
|
||||||
password_current_incorrect: Password attuale non corretta
|
password_current_incorrect: Password attuale non corretta
|
||||||
password_too_short: La nuova password deve avere almeno 8 caratteri
|
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_mismatch: Le password non coincidono
|
||||||
password_updated: Password aggiornata
|
password_updated: Password aggiornata
|
||||||
ops_acknowledged: Incidente preso in carico
|
ops_acknowledged: Incidente preso in carico
|
||||||
ops_resolved: Incidente risolto
|
ops_resolved: Incidente risolto
|
||||||
ops_muted: Notifiche sospese per 24 ore
|
ops_muted: Notifiche sospese per 24 ore
|
||||||
|
stream_node_created: "Nodo lab %{slug} provisionato."
|
||||||
|
stream_node_destroyed: "Nodo %{slug} rimosso."
|
||||||
|
stream_node_draining: "Nodo %{slug} in drain (niente nuove sessioni)."
|
||||||
|
autoscale_kill_on: "Kill-switch autoscaler attivato. Nessun scale-out automatico."
|
||||||
|
autoscale_kill_off: "Kill-switch autoscaler disattivato (serve comunque STREAM_AUTOSCALE_ENABLED=1)."
|
||||||
comped_granted: "Abbonamento omaggio %{plan} attivato per %{club}."
|
comped_granted: "Abbonamento omaggio %{plan} attivato per %{club}."
|
||||||
comped_revoked: "Abbonamento omaggio revocato per %{club}."
|
comped_revoked: "Abbonamento omaggio revocato per %{club}."
|
||||||
session_already_terminated: "Sessione già terminata (%{status})."
|
session_already_terminated: "Sessione già terminata (%{status})."
|
||||||
@@ -53,7 +62,7 @@ it:
|
|||||||
edit:
|
edit:
|
||||||
title: Cambia password
|
title: Cambia password
|
||||||
current_password_label: Password attuale
|
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
|
confirm_password_label: Conferma nuova password
|
||||||
submit: Salva password
|
submit: Salva password
|
||||||
cancel: Annulla
|
cancel: Annulla
|
||||||
@@ -108,6 +117,48 @@ it:
|
|||||||
title: Squadre
|
title: Squadre
|
||||||
matches_count: "%{count} partite"
|
matches_count: "%{count} partite"
|
||||||
view_all: "Vedi società (%{count} squadre)"
|
view_all: "Vedi società (%{count} squadre)"
|
||||||
|
stream_nodes:
|
||||||
|
title: Nodi streaming
|
||||||
|
providers: "Cloud: %{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:
|
ops:
|
||||||
kpi:
|
kpi:
|
||||||
critical: Critici aperti
|
critical: Critici aperti
|
||||||
|
|||||||
@@ -1,4 +1,26 @@
|
|||||||
de:
|
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:
|
club:
|
||||||
back_to_club: "← Verein"
|
back_to_club: "← Verein"
|
||||||
sport_label: Hauptsportart
|
sport_label: Hauptsportart
|
||||||
@@ -600,12 +622,29 @@ de:
|
|||||||
welcome_back: Willkommen zurück!
|
welcome_back: Willkommen zurück!
|
||||||
invalid_credentials: E-Mail oder Passwort ungültig
|
invalid_credentials: E-Mail oder Passwort ungültig
|
||||||
logged_out: Abgemeldet
|
logged_out: Abgemeldet
|
||||||
|
unauthorized: Nicht autorisiert
|
||||||
|
invalid_token: Ungültiges Token
|
||||||
password_resets:
|
password_resets:
|
||||||
email_sent: Wenn die E-Mail registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen des Passworts.
|
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.
|
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_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_mismatch: Die Passwörter stimmen nicht überein
|
||||||
password_updated: Passwort aktualisiert. Du kannst dich jetzt anmelden.
|
password_updated: Passwort aktualisiert. Du kannst dich jetzt anmelden.
|
||||||
|
accounts:
|
||||||
|
name_required: Der Name ist erforderlich
|
||||||
|
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:
|
replay:
|
||||||
download_unavailable: Download nicht verfügbar
|
download_unavailable: Download nicht verfügbar
|
||||||
not_available: Replay nicht verfügbar
|
not_available: Replay nicht verfügbar
|
||||||
|
|||||||
@@ -1,4 +1,21 @@
|
|||||||
en:
|
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:
|
club:
|
||||||
back_to_club: "← Club"
|
back_to_club: "← Club"
|
||||||
sport_label: Main sport
|
sport_label: Main sport
|
||||||
@@ -600,12 +617,29 @@ en:
|
|||||||
welcome_back: Welcome back!
|
welcome_back: Welcome back!
|
||||||
invalid_credentials: Invalid email or password
|
invalid_credentials: Invalid email or password
|
||||||
logged_out: Logged out
|
logged_out: Logged out
|
||||||
|
unauthorized: Unauthorized
|
||||||
|
invalid_token: Invalid token
|
||||||
password_resets:
|
password_resets:
|
||||||
email_sent: If the email is registered, you'll receive a password reset link shortly.
|
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.
|
invalid_or_expired_link: Invalid or expired link. Request a new password reset.
|
||||||
password_min_length: Password must be at least 8 characters
|
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_mismatch: Passwords don't match
|
||||||
password_updated: Password updated. You can now log in.
|
password_updated: Password updated. You can now log in.
|
||||||
|
accounts:
|
||||||
|
name_required: Name is required
|
||||||
|
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:
|
replay:
|
||||||
download_unavailable: Download not available
|
download_unavailable: Download not available
|
||||||
not_available: Replay not available
|
not_available: Replay not available
|
||||||
|
|||||||
@@ -1,4 +1,26 @@
|
|||||||
es:
|
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:
|
club:
|
||||||
back_to_club: "← Club"
|
back_to_club: "← Club"
|
||||||
sport_label: Deporte principal
|
sport_label: Deporte principal
|
||||||
@@ -600,12 +622,29 @@ es:
|
|||||||
welcome_back: "¡Bienvenido de nuevo!"
|
welcome_back: "¡Bienvenido de nuevo!"
|
||||||
invalid_credentials: Correo o contraseña no válidos
|
invalid_credentials: Correo o contraseña no válidos
|
||||||
logged_out: Sesión cerrada
|
logged_out: Sesión cerrada
|
||||||
|
unauthorized: No autorizado
|
||||||
|
invalid_token: Token no válido
|
||||||
password_resets:
|
password_resets:
|
||||||
email_sent: Si el correo está registrado, recibirás en breve un enlace para restablecer la contraseña.
|
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.
|
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_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_mismatch: Las contraseñas no coinciden
|
||||||
password_updated: Contraseña actualizada. Ya puedes iniciar sesión.
|
password_updated: Contraseña actualizada. Ya puedes iniciar sesión.
|
||||||
|
accounts:
|
||||||
|
name_required: El nombre es obligatorio
|
||||||
|
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:
|
replay:
|
||||||
download_unavailable: Descarga no disponible
|
download_unavailable: Descarga no disponible
|
||||||
not_available: Repetición no disponible
|
not_available: Repetición no disponible
|
||||||
|
|||||||
@@ -1,4 +1,26 @@
|
|||||||
fr:
|
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:
|
club:
|
||||||
back_to_club: "← Club"
|
back_to_club: "← Club"
|
||||||
sport_label: Sport principal
|
sport_label: Sport principal
|
||||||
@@ -600,12 +622,29 @@ fr:
|
|||||||
welcome_back: Bon retour !
|
welcome_back: Bon retour !
|
||||||
invalid_credentials: E-mail ou mot de passe invalide
|
invalid_credentials: E-mail ou mot de passe invalide
|
||||||
logged_out: Déconnecté
|
logged_out: Déconnecté
|
||||||
|
unauthorized: Non autorisé
|
||||||
|
invalid_token: Jeton invalide
|
||||||
password_resets:
|
password_resets:
|
||||||
email_sent: Si l'e-mail est enregistré, tu recevras bientôt un lien pour réinitialiser le mot de passe.
|
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.
|
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_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_mismatch: Les mots de passe ne correspondent pas
|
||||||
password_updated: Mot de passe mis à jour. Tu peux maintenant te connecter.
|
password_updated: Mot de passe mis à jour. Tu peux maintenant te connecter.
|
||||||
|
accounts:
|
||||||
|
name_required: Le nom est obligatoire
|
||||||
|
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:
|
replay:
|
||||||
download_unavailable: Téléchargement non disponible
|
download_unavailable: Téléchargement non disponible
|
||||||
not_available: Replay non disponible
|
not_available: Replay non disponible
|
||||||
|
|||||||
@@ -1,4 +1,21 @@
|
|||||||
it:
|
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:
|
club:
|
||||||
back_to_club: "← Società"
|
back_to_club: "← Società"
|
||||||
sport_label: Sport principale
|
sport_label: Sport principale
|
||||||
@@ -600,12 +617,34 @@ it:
|
|||||||
welcome_back: Bentornato!
|
welcome_back: Bentornato!
|
||||||
invalid_credentials: Email o password non validi
|
invalid_credentials: Email o password non validi
|
||||||
logged_out: Disconnesso
|
logged_out: Disconnesso
|
||||||
|
unauthorized: Non autorizzato
|
||||||
|
invalid_token: Token non valido
|
||||||
password_resets:
|
password_resets:
|
||||||
email_sent: Se l'email è registrata, riceverai a breve un link per reimpostare la password.
|
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.
|
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_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_mismatch: Le password non coincidono
|
||||||
password_updated: Password aggiornata. Ora puoi accedere.
|
password_updated: Password aggiornata. Ora puoi accedere.
|
||||||
|
accounts:
|
||||||
|
name_required: Il nome è obbligatorio
|
||||||
|
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:
|
replay:
|
||||||
download_unavailable: Download non disponibile
|
download_unavailable: Download non disponibile
|
||||||
not_available: Replay 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_body_html: "Um die von der DSGVO vorgesehenen Rechte auszuüben (Zugang, Löschung, Widerspruch, Widerruf der Einwilligung), schreiben Sie an %{email_link}. Details finden Sie im %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: Datenschutzdokument
|
s6_privacy_doc_link_text: Datenschutzdokument
|
||||||
manage_button: Cookie-Einstellungen verwalten
|
manage_button: Cookie-Einstellungen verwalten
|
||||||
|
support:
|
||||||
|
title: Match Live TV Support
|
||||||
|
meta_description: "Match Live TV Hilfe: Zugangsprobleme, Livestreams, Teamverwaltung und Support-Kontaktdaten."
|
||||||
|
h1: Match Live TV Support
|
||||||
|
intro: "Brauchen Sie Hilfe mit Match Live TV? Bei Problemen mit dem Zugang, der Einrichtung von Livestreams, der Spielverwaltung oder der Nutzung der App können Sie unseren Support kontaktieren."
|
||||||
|
email_label: "Support-E-Mail:"
|
||||||
|
access_title: Zugangsprobleme
|
||||||
|
access_body: "Wenn Sie sich nicht anmelden können, prüfen Sie die Zugangsdaten, die Sie von Ihrem Sportverein erhalten haben. Wenn das Problem weiterhin besteht, kontaktieren Sie den Support."
|
||||||
|
live_title: Probleme während eines Livestreams
|
||||||
|
live_body: "Prüfen Sie Ihre Internetverbindung und versuchen Sie erneut, die Übertragung in der App zu starten. Wenn das Problem weiterhin besteht, kontaktieren Sie den Support und geben Sie Gerät, App-Version und eine kurze Beschreibung des Problems an."
|
||||||
|
team_title: Teamverwaltung
|
||||||
|
team_body: "Konten und Übertragungsberechtigungen werden vom Sportverein verwaltet."
|
||||||
|
privacy_title: Datenschutz
|
||||||
|
privacy_body_html: "Informationen zur Verarbeitung personenbezogener Daten finden Sie auf der Seite %{privacy_link}."
|
||||||
|
privacy_link_text: Datenschutz
|
||||||
|
|||||||
@@ -195,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_body_html: "To exercise the rights provided by the GDPR (access, erasure, objection, withdrawal of consent) write to %{email_link}. Details are in the %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: privacy document
|
s6_privacy_doc_link_text: privacy document
|
||||||
manage_button: Manage cookie preferences
|
manage_button: Manage cookie preferences
|
||||||
|
support:
|
||||||
|
title: Match Live TV Support
|
||||||
|
meta_description: "Match Live TV help: access issues, live streaming, team management and support contact details."
|
||||||
|
h1: Match Live TV Support
|
||||||
|
intro: "Need help with Match Live TV? For access issues, live stream setup, match management or app usage, you can contact our support team."
|
||||||
|
email_label: "Support email:"
|
||||||
|
access_title: Access issues
|
||||||
|
access_body: "If you cannot sign in, check the credentials provided by your sports club. If the problem persists, contact support."
|
||||||
|
live_title: Issues during a live stream
|
||||||
|
live_body: "Check your Internet connection and try starting the broadcast from the app again. If the problem persists, contact support and include your device, app version and a short description of the issue."
|
||||||
|
team_title: Team management
|
||||||
|
team_body: "Accounts and broadcasting permissions are managed by the sports club."
|
||||||
|
privacy_title: Privacy
|
||||||
|
privacy_body_html: "For information on personal data processing, see the %{privacy_link} page."
|
||||||
|
privacy_link_text: Privacy
|
||||||
|
|||||||
@@ -195,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_body_html: "Para ejercer los derechos previstos por el RGPD (acceso, supresión, oposición, revocación del consentimiento) escribe a %{email_link}. Más detalles en el %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: documento de privacidad
|
s6_privacy_doc_link_text: documento de privacidad
|
||||||
manage_button: Gestionar preferencias de cookies
|
manage_button: Gestionar preferencias de cookies
|
||||||
|
support:
|
||||||
|
title: Soporte Match Live TV
|
||||||
|
meta_description: "Ayuda de Match Live TV: problemas de acceso, directos, gestión del equipo y datos de contacto del soporte."
|
||||||
|
h1: Soporte Match Live TV
|
||||||
|
intro: "¿Necesitas ayuda con Match Live TV? Para problemas de acceso, configuración de directos, gestión de partidos o uso de la app puedes contactar con nuestro soporte."
|
||||||
|
email_label: "Correo de soporte:"
|
||||||
|
access_title: Problemas de acceso
|
||||||
|
access_body: "Si no puedes acceder, verifica las credenciales recibidas de tu club deportivo. Si el problema continúa, contacta con el soporte."
|
||||||
|
live_title: Problemas durante un directo
|
||||||
|
live_body: "Verifica la conexión a Internet e intenta de nuevo iniciar la transmisión desde la app. Si el problema continúa, contacta con el soporte indicando el dispositivo, la versión de la app y una breve descripción del problema."
|
||||||
|
team_title: Gestión del equipo
|
||||||
|
team_body: "Las cuentas y los permisos de transmisión los gestiona el club deportivo."
|
||||||
|
privacy_title: Privacidad
|
||||||
|
privacy_body_html: "Para información sobre el tratamiento de datos personales, consulta la página %{privacy_link}."
|
||||||
|
privacy_link_text: Privacidad
|
||||||
|
|||||||
@@ -195,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_body_html: "Pour exercer les droits prévus par le RGPD (accès, effacement, opposition, retrait du consentement), écrivez à %{email_link}. Détails dans le %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: document de confidentialité
|
s6_privacy_doc_link_text: document de confidentialité
|
||||||
manage_button: Gérer les préférences de cookies
|
manage_button: Gérer les préférences de cookies
|
||||||
|
support:
|
||||||
|
title: Support Match Live TV
|
||||||
|
meta_description: "Assistance Match Live TV : problèmes d'accès, directs, gestion d'équipe et coordonnées du support."
|
||||||
|
h1: Support Match Live TV
|
||||||
|
intro: "Besoin d'aide avec Match Live TV ? Pour les problèmes d'accès, la configuration des directs, la gestion des matchs ou l'utilisation de l'application, vous pouvez contacter notre support."
|
||||||
|
email_label: "E-mail du support :"
|
||||||
|
access_title: Problèmes d'accès
|
||||||
|
access_body: "Si vous ne parvenez pas à vous connecter, vérifiez les identifiants fournis par votre club sportif. Si le problème persiste, contactez le support."
|
||||||
|
live_title: Problèmes pendant un direct
|
||||||
|
live_body: "Vérifiez votre connexion Internet et réessayez de démarrer la diffusion depuis l'application. Si le problème persiste, contactez le support en indiquant l'appareil, la version de l'application et une brève description du problème."
|
||||||
|
team_title: Gestion de l'équipe
|
||||||
|
team_body: "Les comptes et les autorisations de diffusion sont gérés par le club sportif."
|
||||||
|
privacy_title: Confidentialité
|
||||||
|
privacy_body_html: "Pour les informations sur le traitement des données personnelles, consultez la page %{privacy_link}."
|
||||||
|
privacy_link_text: Confidentialité
|
||||||
|
|||||||
@@ -195,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_body_html: "Per esercitare i diritti previsti dal GDPR (accesso, cancellazione, opposizione, revoca consenso) scrivi a %{email_link}. Dettagli nel %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: documento privacy
|
s6_privacy_doc_link_text: documento privacy
|
||||||
manage_button: Gestisci preferenze cookie
|
manage_button: Gestisci preferenze cookie
|
||||||
|
support:
|
||||||
|
title: Supporto Match Live TV
|
||||||
|
meta_description: "Assistenza Match Live TV: problemi di accesso, dirette live, gestione squadra e contatti del supporto."
|
||||||
|
h1: Supporto Match Live TV
|
||||||
|
intro: "Hai bisogno di assistenza con Match Live TV? Per problemi di accesso, configurazione delle dirette, gestione delle partite o utilizzo dell’app puoi contattare il nostro supporto."
|
||||||
|
email_label: "Email di supporto:"
|
||||||
|
access_title: Problemi di accesso
|
||||||
|
access_body: "Se non riesci ad accedere, verifica le credenziali ricevute dalla tua società sportiva. Se il problema persiste, contatta il supporto."
|
||||||
|
live_title: Problemi durante una diretta
|
||||||
|
live_body: "Verifica la connessione Internet e riprova ad avviare la trasmissione dall’app. Se il problema persiste, contatta il supporto indicando dispositivo, versione dell’app e una breve descrizione del problema."
|
||||||
|
team_title: Gestione della squadra
|
||||||
|
team_body: "Gli account e le autorizzazioni per la trasmissione sono gestiti dalla società sportiva."
|
||||||
|
privacy_title: Privacy
|
||||||
|
privacy_body_html: "Per informazioni sul trattamento dei dati personali consulta la pagina %{privacy_link}."
|
||||||
|
privacy_link_text: Privacy
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ de:
|
|||||||
my_team: Mein Team
|
my_team: Mein Team
|
||||||
login: Anmelden
|
login: Anmelden
|
||||||
logout: Abmelden
|
logout: Abmelden
|
||||||
|
account: Konto
|
||||||
signup: Team registrieren
|
signup: Team registrieren
|
||||||
footer:
|
footer:
|
||||||
tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können
|
tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können
|
||||||
@@ -102,7 +103,7 @@ de:
|
|||||||
password_reset:
|
password_reset:
|
||||||
meta_title: "Neues Passwort — Match Live TV"
|
meta_title: "Neues Passwort — Match Live TV"
|
||||||
title: Neues Passwort wählen
|
title: Neues Passwort wählen
|
||||||
new_password_label: "Neues Passwort (min. 8 Zeichen)"
|
new_password_label: "Neues Passwort (mind. 8, mindestens 3 Zeichenarten)"
|
||||||
submit: Passwort speichern
|
submit: Passwort speichern
|
||||||
back_to_login: Zurück zur Anmeldung
|
back_to_login: Zurück zur Anmeldung
|
||||||
invitation:
|
invitation:
|
||||||
@@ -118,8 +119,21 @@ de:
|
|||||||
signup_link: registrieren Sie sich
|
signup_link: registrieren Sie sich
|
||||||
mobile_app_label: "Mobile App:"
|
mobile_app_label: "Mobile App:"
|
||||||
open_in_app: Einladung in der App öffnen
|
open_in_app: Einladung in der App öffnen
|
||||||
|
account:
|
||||||
|
meta_title: "Dein Konto — Match Live TV"
|
||||||
|
meta_description: Name und Passwort deines Match Live TV-Kontos verwalten.
|
||||||
|
title: Dein Konto
|
||||||
|
profile_heading: Profil
|
||||||
|
password_heading: Passwort ändern
|
||||||
|
name_label: Name
|
||||||
|
role_label: "Rolle: %{role}"
|
||||||
|
current_password_label: Aktuelles Passwort
|
||||||
|
new_password_label: "Neues Passwort (mind. 8, mindestens 3 Zeichenarten)"
|
||||||
|
save_profile: Profil speichern
|
||||||
|
save_password: Passwort aktualisieren
|
||||||
common:
|
common:
|
||||||
privacy: Datenschutz
|
privacy: Datenschutz
|
||||||
|
support: Support
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: AGB
|
terms: AGB
|
||||||
pricing: Preise
|
pricing: Preise
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ en:
|
|||||||
my_team: My team
|
my_team: My team
|
||||||
login: Log in
|
login: Log in
|
||||||
logout: Log out
|
logout: Log out
|
||||||
|
account: Account
|
||||||
signup: Register a team
|
signup: Register a team
|
||||||
footer:
|
footer:
|
||||||
tagline: Every match, every event, for those who can't be there
|
tagline: Every match, every event, for those who can't be there
|
||||||
@@ -102,7 +103,7 @@ en:
|
|||||||
password_reset:
|
password_reset:
|
||||||
meta_title: "New password — Match Live TV"
|
meta_title: "New password — Match Live TV"
|
||||||
title: Choose a new password
|
title: Choose a new password
|
||||||
new_password_label: "New password (min. 8 characters)"
|
new_password_label: "New password (min. 8, at least 3 character types)"
|
||||||
submit: Save password
|
submit: Save password
|
||||||
back_to_login: Back to login
|
back_to_login: Back to login
|
||||||
invitation:
|
invitation:
|
||||||
@@ -118,8 +119,21 @@ en:
|
|||||||
signup_link: sign up
|
signup_link: sign up
|
||||||
mobile_app_label: "Mobile app:"
|
mobile_app_label: "Mobile app:"
|
||||||
open_in_app: Open invitation in the app
|
open_in_app: Open invitation in the app
|
||||||
|
account:
|
||||||
|
meta_title: "Your account — Match Live TV"
|
||||||
|
meta_description: Manage your Match Live TV account name and password.
|
||||||
|
title: Your account
|
||||||
|
profile_heading: Profile
|
||||||
|
password_heading: Change password
|
||||||
|
name_label: Name
|
||||||
|
role_label: "Role: %{role}"
|
||||||
|
current_password_label: Current password
|
||||||
|
new_password_label: "New password (min. 8, at least 3 character types)"
|
||||||
|
save_profile: Save profile
|
||||||
|
save_password: Update password
|
||||||
common:
|
common:
|
||||||
privacy: Privacy
|
privacy: Privacy
|
||||||
|
support: Support
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: Terms
|
terms: Terms
|
||||||
pricing: Pricing
|
pricing: Pricing
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ es:
|
|||||||
my_team: Mi equipo
|
my_team: Mi equipo
|
||||||
login: Acceder
|
login: Acceder
|
||||||
logout: Salir
|
logout: Salir
|
||||||
|
account: Cuenta
|
||||||
signup: Registrar equipo
|
signup: Registrar equipo
|
||||||
footer:
|
footer:
|
||||||
tagline: Cada partido, cada evento, para quien no puede estar
|
tagline: Cada partido, cada evento, para quien no puede estar
|
||||||
@@ -102,7 +103,7 @@ es:
|
|||||||
password_reset:
|
password_reset:
|
||||||
meta_title: "Nueva contraseña — Match Live TV"
|
meta_title: "Nueva contraseña — Match Live TV"
|
||||||
title: Elige una nueva contraseña
|
title: Elige una nueva contraseña
|
||||||
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)"
|
||||||
submit: Guardar contraseña
|
submit: Guardar contraseña
|
||||||
back_to_login: Volver al inicio de sesión
|
back_to_login: Volver al inicio de sesión
|
||||||
invitation:
|
invitation:
|
||||||
@@ -118,8 +119,21 @@ es:
|
|||||||
signup_link: regístrate
|
signup_link: regístrate
|
||||||
mobile_app_label: "App móvil:"
|
mobile_app_label: "App móvil:"
|
||||||
open_in_app: Abrir invitación en la app
|
open_in_app: Abrir invitación en la app
|
||||||
|
account:
|
||||||
|
meta_title: "Tu cuenta — Match Live TV"
|
||||||
|
meta_description: Gestiona el nombre y la contraseña de tu cuenta Match Live TV.
|
||||||
|
title: Tu cuenta
|
||||||
|
profile_heading: Perfil
|
||||||
|
password_heading: Cambiar contraseña
|
||||||
|
name_label: Nombre
|
||||||
|
role_label: "Rol: %{role}"
|
||||||
|
current_password_label: Contraseña actual
|
||||||
|
new_password_label: "Nueva contraseña (mín. 8, al menos 3 tipos de caracteres)"
|
||||||
|
save_profile: Guardar perfil
|
||||||
|
save_password: Actualizar contraseña
|
||||||
common:
|
common:
|
||||||
privacy: Privacidad
|
privacy: Privacidad
|
||||||
|
support: Soporte
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: Términos
|
terms: Términos
|
||||||
pricing: Precios
|
pricing: Precios
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ fr:
|
|||||||
my_team: Mon équipe
|
my_team: Mon équipe
|
||||||
login: Connexion
|
login: Connexion
|
||||||
logout: Déconnexion
|
logout: Déconnexion
|
||||||
|
account: Compte
|
||||||
signup: Inscrire une équipe
|
signup: Inscrire une équipe
|
||||||
footer:
|
footer:
|
||||||
tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là
|
tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là
|
||||||
@@ -102,7 +103,7 @@ fr:
|
|||||||
password_reset:
|
password_reset:
|
||||||
meta_title: "Nouveau mot de passe — Match Live TV"
|
meta_title: "Nouveau mot de passe — Match Live TV"
|
||||||
title: Choisissez un nouveau mot de passe
|
title: Choisissez un nouveau mot de passe
|
||||||
new_password_label: "Nouveau mot de passe (8 caractères min.)"
|
new_password_label: "Nouveau mot de passe (min. 8, au moins 3 types de caractères)"
|
||||||
submit: Enregistrer le mot de passe
|
submit: Enregistrer le mot de passe
|
||||||
back_to_login: Retour à la connexion
|
back_to_login: Retour à la connexion
|
||||||
invitation:
|
invitation:
|
||||||
@@ -118,8 +119,21 @@ fr:
|
|||||||
signup_link: inscrivez-vous
|
signup_link: inscrivez-vous
|
||||||
mobile_app_label: "Application mobile :"
|
mobile_app_label: "Application mobile :"
|
||||||
open_in_app: Ouvrir l'invitation dans l'application
|
open_in_app: Ouvrir l'invitation dans l'application
|
||||||
|
account:
|
||||||
|
meta_title: "Votre compte — Match Live TV"
|
||||||
|
meta_description: Gérez le nom et le mot de passe de votre compte Match Live TV.
|
||||||
|
title: Votre compte
|
||||||
|
profile_heading: Profil
|
||||||
|
password_heading: Changer le mot de passe
|
||||||
|
name_label: Nom
|
||||||
|
role_label: "Rôle : %{role}"
|
||||||
|
current_password_label: Mot de passe actuel
|
||||||
|
new_password_label: "Nouveau mot de passe (min. 8, au moins 3 types de caractères)"
|
||||||
|
save_profile: Enregistrer le profil
|
||||||
|
save_password: Mettre à jour le mot de passe
|
||||||
common:
|
common:
|
||||||
privacy: Confidentialité
|
privacy: Confidentialité
|
||||||
|
support: Support
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: Conditions
|
terms: Conditions
|
||||||
pricing: Tarifs
|
pricing: Tarifs
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ it:
|
|||||||
my_team: La mia squadra
|
my_team: La mia squadra
|
||||||
login: Accedi
|
login: Accedi
|
||||||
logout: Esci
|
logout: Esci
|
||||||
|
account: Account
|
||||||
signup: Registra squadra
|
signup: Registra squadra
|
||||||
footer:
|
footer:
|
||||||
tagline: Ogni partita, ogni evento, per chi non può esserci
|
tagline: Ogni partita, ogni evento, per chi non può esserci
|
||||||
@@ -102,7 +103,7 @@ it:
|
|||||||
password_reset:
|
password_reset:
|
||||||
meta_title: "Nuova password — Match Live TV"
|
meta_title: "Nuova password — Match Live TV"
|
||||||
title: Scegli una nuova password
|
title: Scegli una nuova password
|
||||||
new_password_label: "Nuova password (min. 8 caratteri)"
|
new_password_label: "Nuova password (min. 8, almeno 3 tipi di caratteri)"
|
||||||
submit: Salva password
|
submit: Salva password
|
||||||
back_to_login: Torna al login
|
back_to_login: Torna al login
|
||||||
invitation:
|
invitation:
|
||||||
@@ -118,8 +119,21 @@ it:
|
|||||||
signup_link: registrati
|
signup_link: registrati
|
||||||
mobile_app_label: "App mobile:"
|
mobile_app_label: "App mobile:"
|
||||||
open_in_app: Apri invito nell'app
|
open_in_app: Apri invito nell'app
|
||||||
|
account:
|
||||||
|
meta_title: "Il tuo account — Match Live TV"
|
||||||
|
meta_description: Gestisci nome e password del tuo account Match Live TV.
|
||||||
|
title: Il tuo account
|
||||||
|
profile_heading: Profilo
|
||||||
|
password_heading: Cambia password
|
||||||
|
name_label: Nome
|
||||||
|
role_label: "Ruolo: %{role}"
|
||||||
|
current_password_label: Password attuale
|
||||||
|
new_password_label: "Nuova password (min. 8, almeno 3 tipi di caratteri)"
|
||||||
|
save_profile: Salva profilo
|
||||||
|
save_password: Aggiorna password
|
||||||
common:
|
common:
|
||||||
privacy: Privacy
|
privacy: Privacy
|
||||||
|
support: Supporto
|
||||||
cookies: Cookie
|
cookies: Cookie
|
||||||
terms: Termini
|
terms: Termini
|
||||||
pricing: Prezzi
|
pricing: Prezzi
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ Rails.application.routes.draw do
|
|||||||
post "auth/logout", to: "auth#logout"
|
post "auth/logout", to: "auth#logout"
|
||||||
post "auth/refresh", to: "auth#refresh"
|
post "auth/refresh", to: "auth#refresh"
|
||||||
get "auth/me", to: "auth#me"
|
get "auth/me", to: "auth#me"
|
||||||
|
post "auth/password/forgot", to: "auth#forgot_password"
|
||||||
|
get "account", to: "accounts#show"
|
||||||
|
patch "account", to: "accounts#update"
|
||||||
|
patch "account/password", to: "accounts#password"
|
||||||
get "sports", to: "sports#index"
|
get "sports", to: "sports#index"
|
||||||
|
|
||||||
get "invitations/:token", to: "invitations#show"
|
get "invitations/:token", to: "invitations#show"
|
||||||
@@ -106,6 +110,15 @@ Rails.application.routes.draw do
|
|||||||
post :regia_link
|
post :regia_link
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
resources :stream_nodes, only: %i[index create destroy] do
|
||||||
|
member do
|
||||||
|
post :drain
|
||||||
|
end
|
||||||
|
collection do
|
||||||
|
post :kill_switch
|
||||||
|
delete :clear_kill_switch
|
||||||
|
end
|
||||||
|
end
|
||||||
get "youtube/platform", to: "youtube#platform", as: :youtube_platform
|
get "youtube/platform", to: "youtube#platform", as: :youtube_platform
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -150,6 +163,7 @@ Rails.application.routes.draw do
|
|||||||
get "prezzi", to: "pages#pricing", as: :prezzi
|
get "prezzi", to: "pages#pricing", as: :prezzi
|
||||||
get "pricing", to: redirect("/prezzi")
|
get "pricing", to: redirect("/prezzi")
|
||||||
get "privacy", to: "pages#privacy", as: :privacy
|
get "privacy", to: "pages#privacy", as: :privacy
|
||||||
|
get "support", to: "pages#support", as: :support
|
||||||
get "cookie", to: "pages#cookies", as: :cookies
|
get "cookie", to: "pages#cookies", as: :cookies
|
||||||
get "cookies", to: redirect("/cookie")
|
get "cookies", to: redirect("/cookie")
|
||||||
get "termini", to: "pages#terms", as: :termini
|
get "termini", to: "pages#terms", as: :termini
|
||||||
@@ -163,6 +177,10 @@ Rails.application.routes.draw do
|
|||||||
post "password/forgot", to: "password_resets#create"
|
post "password/forgot", to: "password_resets#create"
|
||||||
get "password/reset", to: "password_resets#edit", as: :password_reset
|
get "password/reset", to: "password_resets#edit", as: :password_reset
|
||||||
patch "password/reset", to: "password_resets#update"
|
patch "password/reset", to: "password_resets#update"
|
||||||
|
get "account", to: "accounts#show", as: :account
|
||||||
|
patch "account", to: "accounts#update"
|
||||||
|
get "account/password", to: redirect("/account"), as: nil
|
||||||
|
patch "account/password", to: "accounts#update_password", as: :account_password
|
||||||
get "clubs/new", to: "clubs#new", as: :new_club
|
get "clubs/new", to: "clubs#new", as: :new_club
|
||||||
post "clubs", to: "clubs#create"
|
post "clubs", to: "clubs#create"
|
||||||
get "clubs/:id", to: "clubs#show", as: :club
|
get "clubs/:id", to: "clubs#show", as: :club
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
:concurrency: 5
|
:concurrency: 5
|
||||||
:queues:
|
:queues:
|
||||||
- default
|
|
||||||
- critical
|
- critical
|
||||||
|
- youtube_relay
|
||||||
|
- default
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreateStreamNodes < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
create_table :stream_nodes, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
|
||||||
|
t.string :slug, null: false
|
||||||
|
t.string :hostname, null: false
|
||||||
|
t.string :role, null: false, default: "home"
|
||||||
|
t.string :status, null: false, default: "ready"
|
||||||
|
t.string :rtmp_base_url, null: false
|
||||||
|
t.string :hls_base_url, null: false
|
||||||
|
t.string :api_base_url, null: false
|
||||||
|
t.string :internal_rtmp_url
|
||||||
|
t.string :internal_hls_url
|
||||||
|
t.integer :max_publishers, null: false, default: 6
|
||||||
|
t.integer :max_relays, null: false, default: 6
|
||||||
|
t.string :provider, null: false, default: "local"
|
||||||
|
t.string :provider_instance_id
|
||||||
|
t.datetime :last_health_at
|
||||||
|
t.jsonb :metadata, null: false, default: {}
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :stream_nodes, :slug, unique: true
|
||||||
|
add_index :stream_nodes, :status
|
||||||
|
add_index :stream_nodes, :role
|
||||||
|
|
||||||
|
add_reference :stream_sessions, :stream_node, type: :uuid, foreign_key: true, null: true, index: true
|
||||||
|
end
|
||||||
|
end
|
||||||
Generated
+27
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
ActiveRecord::Schema[7.2].define(version: 2026_08_09_120000) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
@@ -255,6 +255,29 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
|||||||
t.index ["stream_session_id"], name: "index_stream_events_on_stream_session_id"
|
t.index ["stream_session_id"], name: "index_stream_events_on_stream_session_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "stream_nodes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.string "slug", null: false
|
||||||
|
t.string "hostname", null: false
|
||||||
|
t.string "role", default: "home", null: false
|
||||||
|
t.string "status", default: "ready", null: false
|
||||||
|
t.string "rtmp_base_url", null: false
|
||||||
|
t.string "hls_base_url", null: false
|
||||||
|
t.string "api_base_url", null: false
|
||||||
|
t.string "internal_rtmp_url"
|
||||||
|
t.string "internal_hls_url"
|
||||||
|
t.integer "max_publishers", default: 6, null: false
|
||||||
|
t.integer "max_relays", default: 6, null: false
|
||||||
|
t.string "provider", default: "local", null: false
|
||||||
|
t.string "provider_instance_id"
|
||||||
|
t.datetime "last_health_at"
|
||||||
|
t.jsonb "metadata", default: {}, null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["role"], name: "index_stream_nodes_on_role"
|
||||||
|
t.index ["slug"], name: "index_stream_nodes_on_slug", unique: true
|
||||||
|
t.index ["status"], name: "index_stream_nodes_on_status"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "stream_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "stream_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.uuid "match_id", null: false
|
t.uuid "match_id", null: false
|
||||||
t.uuid "user_id", null: false
|
t.uuid "user_id", null: false
|
||||||
@@ -280,10 +303,12 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
|||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.string "regia_token_digest"
|
t.string "regia_token_digest"
|
||||||
t.datetime "regia_token_expires_at"
|
t.datetime "regia_token_expires_at"
|
||||||
|
t.uuid "stream_node_id"
|
||||||
t.index ["match_id"], name: "index_stream_sessions_on_match_id"
|
t.index ["match_id"], name: "index_stream_sessions_on_match_id"
|
||||||
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
|
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
|
||||||
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
|
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
|
||||||
t.index ["status"], name: "index_stream_sessions_on_status"
|
t.index ["status"], name: "index_stream_sessions_on_status"
|
||||||
|
t.index ["stream_node_id"], name: "index_stream_sessions_on_stream_node_id"
|
||||||
t.index ["user_id"], name: "index_stream_sessions_on_user_id"
|
t.index ["user_id"], name: "index_stream_sessions_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -411,6 +436,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
|||||||
add_foreign_key "score_states", "stream_sessions"
|
add_foreign_key "score_states", "stream_sessions"
|
||||||
add_foreign_key "stream_events", "stream_sessions"
|
add_foreign_key "stream_events", "stream_sessions"
|
||||||
add_foreign_key "stream_sessions", "matches"
|
add_foreign_key "stream_sessions", "matches"
|
||||||
|
add_foreign_key "stream_sessions", "stream_nodes"
|
||||||
add_foreign_key "stream_sessions", "users"
|
add_foreign_key "stream_sessions", "users"
|
||||||
add_foreign_key "subscriptions", "admin_accounts", column: "admin_comped_by_id"
|
add_foreign_key "subscriptions", "admin_accounts", column: "admin_comped_by_id"
|
||||||
add_foreign_key "subscriptions", "clubs"
|
add_foreign_key "subscriptions", "clubs"
|
||||||
|
|||||||
+7
-4
@@ -1,18 +1,18 @@
|
|||||||
load Rails.root.join("db/seeds/plans.rb")
|
load Rails.root.join("db/seeds/plans.rb")
|
||||||
|
|
||||||
AdminAccount.find_or_create_by!(username: "admin") do |a|
|
AdminAccount.find_or_create_by!(username: "admin") do |a|
|
||||||
a.password = "admin"
|
a.password = "AdminPass123"
|
||||||
end
|
end
|
||||||
|
|
||||||
coach = User.find_or_create_by!(email: "coach@matchlivetv.test") do |u|
|
coach = User.find_or_create_by!(email: "coach@matchlivetv.test") do |u|
|
||||||
u.name = "Coach Demo"
|
u.name = "Coach Demo"
|
||||||
u.password = "password123"
|
u.password = "Password123"
|
||||||
u.role = "coach"
|
u.role = "coach"
|
||||||
end
|
end
|
||||||
|
|
||||||
admin = User.find_or_create_by!(email: "admin@matchlivetv.test") do |u|
|
admin = User.find_or_create_by!(email: "admin@matchlivetv.test") do |u|
|
||||||
u.name = "Admin"
|
u.name = "Admin"
|
||||||
u.password = "password123"
|
u.password = "Password123"
|
||||||
u.role = "admin"
|
u.role = "admin"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -41,5 +41,8 @@ match = team.matches.find_or_create_by!(opponent_name: "ASD Eagles Pavia") do |m
|
|||||||
m.phase = "Semifinale"
|
m.phase = "Semifinale"
|
||||||
end
|
end
|
||||||
|
|
||||||
puts "Seed OK: coach@matchlivetv.test / password123"
|
puts "Seed OK: coach@matchlivetv.test / Password123"
|
||||||
puts "Club: #{club.name}, Team: #{team.name}, Match: #{match.opponent_name}"
|
puts "Club: #{club.name}, Team: #{team.name}, Match: #{match.opponent_name}"
|
||||||
|
|
||||||
|
home = Streams::NodeRegistry.ensure_home_from_env!
|
||||||
|
puts "Stream node home: #{home.slug} rtmp=#{home.rtmp_base_url}"
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
namespace :streams do
|
||||||
|
namespace :nodes do
|
||||||
|
desc "Assicura il nodo home dagli ENV MediaMTX"
|
||||||
|
task ensure_home: :environment do
|
||||||
|
node = Streams::NodeRegistry.ensure_home_from_env!
|
||||||
|
puts "home ready slug=#{node.slug} rtmp=#{node.rtmp_base_url} max=#{node.max_publishers}"
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Provisiona un nodo lab (STREAM_CLOUD_PROVIDER=local_lab|proxmox_lab)"
|
||||||
|
task provision_lab: :environment do
|
||||||
|
node = Streams::NodeProvisioner.new.provision_lab!
|
||||||
|
puts "lab node ready slug=#{node.slug} host=#{node.hostname} id=#{node.provider_instance_id}"
|
||||||
|
if ENV.fetch("STREAM_DNS_PROVIDER", "lab") == "lab"
|
||||||
|
puts "DNS lab snippet:"
|
||||||
|
puts Streams::DnsProviders::Lab.new.hosts_file_snippet
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Provisiona un nodo Hetzner Cloud + DNS mltv-stream.net (richiede HCLOUD_TOKEN)"
|
||||||
|
task provision_cloud: :environment do
|
||||||
|
node = Streams::NodeProvisioner.new.provision_cloud!
|
||||||
|
puts "cloud node ready slug=#{node.slug} host=#{node.hostname} id=#{node.provider_instance_id} ip=#{node.metadata['public_ip']}"
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Dump record DNS lab (Redis)"
|
||||||
|
task dns_lab_dump: :environment do
|
||||||
|
puts Streams::DnsProviders::Lab.new.hosts_file_snippet
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"relation": [
|
||||||
|
"delegate_permission/common.handle_all_urls",
|
||||||
|
"delegate_permission/common.get_login_creds"
|
||||||
|
],
|
||||||
|
"target": {
|
||||||
|
"namespace": "android_app",
|
||||||
|
"package_name": "com.matchlivetv.match_live_tv",
|
||||||
|
"sha256_cert_fingerprints": [
|
||||||
|
"C3:F0:89:51:0B:00:3A:FE:10:BD:ED:68:45:1B:4F:1D:B8:5A:63:EE:8A:DA:F7:2F:5A:D6:1D:97:C9:A3:95:47",
|
||||||
|
"09:69:25:CD:8B:EC:BA:75:9A:5E:49:32:E1:35:BD:F1:AF:1E:5A:29:93:E2:65:85:8A:41:E2:11:DA:64:97:F0"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
+103
-10
@@ -228,6 +228,8 @@ body.admin-body {
|
|||||||
.badge--live { background: var(--red); color: #fff; }
|
.badge--live { background: var(--red); color: #fff; }
|
||||||
.badge--connecting { background: #ff9800; color: #111; }
|
.badge--connecting { background: #ff9800; color: #111; }
|
||||||
.badge--paused { background: #555; color: #fff; }
|
.badge--paused { background: #555; color: #fff; }
|
||||||
|
.badge--ready { background: #1b5e20; color: #c8e6c9; }
|
||||||
|
.badge--ok { background: #1b5e20; color: #c8e6c9; }
|
||||||
|
|
||||||
.team-list {
|
.team-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -345,28 +347,40 @@ body.admin-body {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-btn--secondary {
|
|
||||||
background: #333;
|
|
||||||
color: #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-btn--secondary:hover {
|
|
||||||
background: #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-btn {
|
.admin-btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.45rem 0.9rem;
|
padding: 0.45rem 0.9rem;
|
||||||
border: none;
|
border: 1px solid transparent;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
background: #2a2a36;
|
||||||
|
color: #eee;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-btn:hover { filter: brightness(1.08); }
|
||||||
|
|
||||||
.admin-btn--sm { padding: 0.25rem 0.55rem; font-size: 0.75rem; }
|
.admin-btn--sm { padding: 0.25rem 0.55rem; font-size: 0.75rem; }
|
||||||
|
|
||||||
|
.admin-btn--primary {
|
||||||
|
background: var(--red) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border-color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn--secondary {
|
||||||
|
background: #333;
|
||||||
|
color: #eee;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn--secondary:hover {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-btn--danger {
|
.admin-btn--danger {
|
||||||
background: var(--red);
|
background: var(--red);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -374,6 +388,85 @@ body.admin-body {
|
|||||||
|
|
||||||
.admin-btn--danger:hover { filter: brightness(1.1); }
|
.admin-btn--danger:hover { filter: brightness(1.1); }
|
||||||
|
|
||||||
|
.admin-btn--outline {
|
||||||
|
background: transparent;
|
||||||
|
border-color: #555;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page-head {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page-title {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page-sub {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.55rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-toolbar form {
|
||||||
|
display: inline;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: #0a0a0e;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #cfcfd8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-value-unit {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-left: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-value--sm {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.muted { color: var(--muted); }
|
.muted { color: var(--muted); }
|
||||||
.empty { color: var(--muted); font-size: 0.9rem; margin: 0; }
|
.empty { color: var(--muted); font-size: 0.9rem; margin: 0; }
|
||||||
|
|
||||||
|
|||||||
+107
-16
@@ -199,6 +199,17 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
body.nav-menu-open .site-chrome {
|
body.nav-menu-open .site-chrome {
|
||||||
z-index: 1300;
|
z-index: 1300;
|
||||||
}
|
}
|
||||||
|
/* Keep the close control above the full-screen sheet; hide duplicate mast brand. */
|
||||||
|
body.nav-menu-open .site-masthead {
|
||||||
|
z-index: 1320;
|
||||||
|
background: transparent;
|
||||||
|
border-bottom-color: transparent;
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
body.nav-menu-open .mast-brand {
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.nav-backdrop {
|
.nav-backdrop {
|
||||||
display: block;
|
display: block;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -236,16 +247,50 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
.nav-panel {
|
.nav-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex-wrap: nowrap;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
|
min-height: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 72px 24px 32px;
|
padding: 16px 24px 32px;
|
||||||
padding-top: calc(72px + env(safe-area-inset-top, 0px));
|
padding-top: calc(16px + env(safe-area-inset-top, 0px));
|
||||||
padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px));
|
padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px));
|
||||||
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
.nav-mobile-head {
|
||||||
|
order: -1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
padding: 0 52px 16px 0; /* room for the close (X) control on the right */
|
||||||
|
border-bottom: 1px solid #252530;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.nav-mobile-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.nav-mobile-brand:hover { text-decoration: none; opacity: 0.92; }
|
||||||
|
.nav-mobile-brand .brand { font-size: 1.05rem; }
|
||||||
|
.nav-mobile-brand-logo {
|
||||||
|
display: block;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.nav-panel > a,
|
.nav-panel > a,
|
||||||
.nav-panel .nav-link-item {
|
.nav-panel .nav-link-item {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -263,11 +308,13 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
}
|
}
|
||||||
.nav-actions {
|
.nav-actions {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex-wrap: nowrap;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 14px;
|
gap: 0;
|
||||||
margin-top: 24px;
|
margin-top: 8px;
|
||||||
padding: 24px 0 0;
|
padding: 0;
|
||||||
border-top: 1px solid #252530;
|
border-top: none;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
.nav-actions .nav-link-item {
|
.nav-actions .nav-link-item {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -289,10 +336,14 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
.nav-lang {
|
.nav-lang {
|
||||||
margin-left: 0;
|
margin: 0;
|
||||||
margin-top: 4px;
|
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
width: 100%;
|
width: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.nav-lang .lang-switcher__menu {
|
||||||
|
/* Keep the list inside the open mobile sheet */
|
||||||
|
z-index: 1320;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,14 +393,22 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
gap: 8px 20px;
|
gap: 8px 20px;
|
||||||
}
|
}
|
||||||
|
.nav-mobile-head {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
.nav-mobile-brand {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.nav-lang {
|
||||||
|
order: 2;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
.nav-actions {
|
.nav-actions {
|
||||||
|
order: 1;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
gap: 8px 12px;
|
gap: 8px 12px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.nav-lang {
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
.nav-backdrop { display: none !important; }
|
.nav-backdrop { display: none !important; }
|
||||||
}
|
}
|
||||||
.btn { display: inline-block; padding: 10px 18px; border-radius: 8px; font-weight: 700; font-size: 0.9rem; border: none; cursor: pointer; text-decoration: none; }
|
.btn { display: inline-block; padding: 10px 18px; border-radius: 8px; font-weight: 700; font-size: 0.9rem; border: none; cursor: pointer; text-decoration: none; }
|
||||||
@@ -1230,7 +1289,22 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
.hero-split .hero-cta { flex-direction: column; }
|
.hero-split .hero-cta { flex-direction: column; }
|
||||||
.hero-split .hero-cta .btn { width: 100%; text-align: center; }
|
.hero-split .hero-cta .btn { width: 100%; text-align: center; }
|
||||||
}
|
}
|
||||||
.section { padding: 40px 0; }
|
.section {
|
||||||
|
padding-top: 40px;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
/* Keep horizontal inset when .section shares a node with .wrap (padding shorthand must not win). */
|
||||||
|
.section.wrap {
|
||||||
|
padding-left: 20px;
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
.table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
.section h2 { font-size: 1.6rem; margin: 0 0 20px; text-align: center; }
|
.section h2 { font-size: 1.6rem; margin: 0 0 20px; text-align: center; }
|
||||||
.steps { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; }
|
.steps { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; }
|
||||||
.step { background: #14141c; border: 1px solid #2a2a36; border-radius: 12px; padding: 22px; }
|
.step { background: #14141c; border: 1px solid #2a2a36; border-radius: 12px; padding: 22px; }
|
||||||
@@ -1363,7 +1437,7 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
.site-footer__legal { flex: 1 1 100%; margin-top: 4px; }
|
.site-footer__legal { flex: 1 1 100%; margin-top: 4px; }
|
||||||
.site-footer__legal p { margin: 0 0 6px; line-height: 1.45; }
|
.site-footer__legal p { margin: 0 0 6px; line-height: 1.45; }
|
||||||
.site-footer__legal p:last-child { margin-bottom: 0; }
|
.site-footer__legal p:last-child { margin-bottom: 0; }
|
||||||
.compare-table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 0.9rem; }
|
.compare-table { width: 100%; min-width: 520px; border-collapse: collapse; margin-top: 0; font-size: 0.9rem; }
|
||||||
.compare-table th, .compare-table td { padding: 10px 12px; border-bottom: 1px solid #2a2a36; text-align: left; }
|
.compare-table th, .compare-table td { padding: 10px 12px; border-bottom: 1px solid #2a2a36; text-align: left; }
|
||||||
.compare-table th { color: #aaa; font-weight: 600; }
|
.compare-table th { color: #aaa; font-weight: 600; }
|
||||||
.billing-documents h2 { margin-top: 0; }
|
.billing-documents h2 { margin-top: 0; }
|
||||||
@@ -1372,11 +1446,14 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
.card { background: #14141c; border: 1px solid #2a2a36; border-radius: 12px; padding: 20px; margin-bottom: 16px; }
|
.card { background: #14141c; border: 1px solid #2a2a36; border-radius: 12px; padding: 20px; margin-bottom: 16px; }
|
||||||
|
|
||||||
.seo-prose { max-width: 720px; margin: 0 auto; color: #bbb; line-height: 1.65; }
|
.seo-prose { max-width: 720px; margin: 0 auto; color: #bbb; line-height: 1.65; }
|
||||||
.seo-prose h2 { color: #fff; font-size: 1.25rem; margin: 28px 0 12px; }
|
.seo-prose h2 { color: #fff; font-size: 1.25rem; margin: 28px 0 12px; text-align: left; }
|
||||||
.seo-prose h2:first-child { margin-top: 0; }
|
.seo-prose h2:first-child { margin-top: 0; }
|
||||||
.seo-prose p { margin: 0 0 14px; }
|
.seo-prose p { margin: 0 0 14px; }
|
||||||
.seo-prose ul { margin: 0 0 16px; padding-left: 1.25rem; }
|
.seo-prose ul { margin: 0 0 16px; padding-left: 1.25rem; }
|
||||||
.seo-prose a { color: #e53935; }
|
.seo-prose a { color: #e53935; }
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.seo-prose h2 { font-size: 1.15rem; line-height: 1.35; }
|
||||||
|
}
|
||||||
.seo-page .seo-lead { color: #aaa; max-width: 640px; line-height: 1.55; margin-bottom: 28px; }
|
.seo-page .seo-lead { color: #aaa; max-width: 640px; line-height: 1.55; margin-bottom: 28px; }
|
||||||
.faq-list { max-width: 720px; margin: 0 auto; }
|
.faq-list { max-width: 720px; margin: 0 auto; }
|
||||||
.faq-item {
|
.faq-item {
|
||||||
@@ -1412,7 +1489,8 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
.legal-doc a { color: #e53935; }
|
.legal-doc a { color: #e53935; }
|
||||||
.legal-meta { color: #888; font-size: 0.88rem; margin-bottom: 24px; }
|
.legal-meta { color: #888; font-size: 0.88rem; margin-bottom: 24px; }
|
||||||
.legal-back { margin-top: 32px; }
|
.legal-back { margin-top: 32px; }
|
||||||
.legal-table { width: 100%; border-collapse: collapse; margin: 12px 0 16px; font-size: 0.88rem; }
|
.legal-doc .table-scroll { margin: 12px 0 16px; }
|
||||||
|
.legal-table { width: 100%; min-width: 560px; border-collapse: collapse; margin: 0; font-size: 0.88rem; }
|
||||||
.legal-table th, .legal-table td { border: 1px solid #2a2a36; padding: 10px 12px; text-align: left; vertical-align: top; }
|
.legal-table th, .legal-table td { border: 1px solid #2a2a36; padding: 10px 12px; text-align: left; vertical-align: top; }
|
||||||
.legal-table th { background: #14141c; color: #ccc; }
|
.legal-table th { background: #14141c; color: #ccc; }
|
||||||
.legal-accept {
|
.legal-accept {
|
||||||
@@ -1447,6 +1525,19 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
.auth-forgot a { color: #e53935; }
|
.auth-forgot a { color: #e53935; }
|
||||||
table.data { width: 100%; border-collapse: collapse; }
|
table.data { width: 100%; border-collapse: collapse; }
|
||||||
table.data th, table.data td { padding: 8px; border-bottom: 1px solid #2a2a36; text-align: left; }
|
table.data th, table.data td { padding: 8px; border-bottom: 1px solid #2a2a36; text-align: left; }
|
||||||
|
/* Wide roster/match tables: scroll inside the card instead of expanding the page. */
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.card:has(table.data),
|
||||||
|
.team-streaming-staff:has(table.data),
|
||||||
|
.billing-documents:has(table.data) {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
table.data {
|
||||||
|
min-width: 520px;
|
||||||
|
}
|
||||||
|
}
|
||||||
input, select {
|
input, select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe YoutubeRelayStopJob, type: :job do
|
||||||
|
it "requeues when stop runs on the wrong host" do
|
||||||
|
session = instance_double(StreamSession, id: SecureRandom.uuid)
|
||||||
|
allow(StreamSession).to receive(:find_by).and_return(session)
|
||||||
|
allow(Streams::YoutubeRelay).to receive(:worker?).and_return(true)
|
||||||
|
allow(Streams::YoutubeRelay).to receive(:stop_on_worker!).and_return(:wrong_host)
|
||||||
|
|
||||||
|
job_proxy = double("ConfiguredJob")
|
||||||
|
expect(described_class).to receive(:set).with(wait: 2.seconds).and_return(job_proxy)
|
||||||
|
expect(job_proxy).to receive(:perform_later).with(session.id, 1)
|
||||||
|
|
||||||
|
described_class.new.perform(session.id, 0)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe PasswordComplexity do
|
||||||
|
describe ".violation" do
|
||||||
|
it "accepts a password with 3 character classes" do
|
||||||
|
expect(described_class.violation("NewPass123")).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "accepts symbols instead of one letter class" do
|
||||||
|
expect(described_class.violation("newpass1!")).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects passwords that are too short" do
|
||||||
|
expect(described_class.violation("Ab1!")).to eq(:too_short)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects passwords with fewer than 3 classes" do
|
||||||
|
expect(described_class.violation("password123")).to eq(:too_weak)
|
||||||
|
expect(described_class.violation("PASSWORD123")).to eq(:too_weak)
|
||||||
|
expect(described_class.violation("Password")).to eq(:too_weak)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe ".same_as_current?" do
|
||||||
|
let!(:user) { User.create!(email: "same@example.com", name: "Same", password: "Password123", role: "coach") }
|
||||||
|
|
||||||
|
it "detects when the new password matches the current one" do
|
||||||
|
expect(described_class.same_as_current?(user, "Password123")).to eq(true)
|
||||||
|
expect(described_class.same_as_current?(user, "OtherPass123")).to eq(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "User validation" do
|
||||||
|
it "blocks weak passwords on create" do
|
||||||
|
user = User.new(email: "weak@example.com", name: "Weak", password: "password123", role: "coach")
|
||||||
|
expect(user).not_to be_valid
|
||||||
|
expect(user.errors[:password]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it "allows strong passwords on create" do
|
||||||
|
user = User.new(email: "strong@example.com", name: "Strong", password: "NewPass123", role: "coach")
|
||||||
|
expect(user).to be_valid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user